Repos / pytaku / 881d89bfb1
commit 881d89bfb1880d1fac7e8dcf295446e4e6e67999
Author: Bùi Thành Nhân <hi@imnhan.com>
Date: Tue Aug 25 21:08:45 2020 +0700
implement tachiyomi importer
diff --git a/pyproject.toml b/pyproject.toml
index 06bc4d0..cffb1ee 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[tool.poetry]
name = "pytaku"
-version = "0.3.4"
-description = ""
+version = "0.3.5"
+description = "Self-hostable web-based manga reader"
authors = ["Bùi Thành Nhân <hi@imnhan.com>"]
license = "AGPL-3.0-only"
packages = [
diff --git a/src/pytaku/main.py b/src/pytaku/main.py
index 9f8deb2..747f23e 100644
--- a/src/pytaku/main.py
+++ b/src/pytaku/main.py
@@ -303,11 +303,20 @@ def read_tachiyomi_follows(text: str) -> List[Tuple[str, str]]:
def ensure_titles(site_title_pairs: List[Tuple[str, str]]):
- new_titles = [
- (site, title_id)
- for site, title_id in site_title_pairs
- if load_title(site, title_id) is None # again, n+1 queries are fine in sqlite
- ]
+ """
+ Fetch and save titles that are not already in db.
+ Returns a list of title dicts, both old and new.
+ """
+ title_dicts = []
+ new_titles = []
+
+ for site, title_id in site_title_pairs:
+ existing_title = load_title(site, title_id)
+ if existing_title is None: # again, n+1 queries are fine in sqlite
+ new_titles.append((site, title_id))
+ else:
+ title_dicts.append(existing_title)
+
print(f"Fetching {len(new_titles)} new titles out of {len(site_title_pairs)}.")
with ThreadPoolExecutor(max_workers=3) as executor:
futures = [
@@ -317,6 +326,9 @@ def ensure_titles(site_title_pairs: List[Tuple[str, str]]):
title = future.result()
save_title(title)
print(f"Saved {title['site']}: {title['name']}")
+ title_dicts.append(title)
+
+ return title_dicts
"""
@@ -330,6 +342,7 @@ def ensure_titles(site_title_pairs: List[Tuple[str, str]]):
@app.route("/f")
@app.route("/s")
@app.route("/s/<query>")
+@app.route("/i")
def home_view(query=None):
return render_template("spa.html")
@@ -556,3 +569,36 @@ def api_read():
# Also TODO: maybe a separate "read all from title" API would be cleaner & easier on
# FE side.
return {}
+
+
+@app.route("/api/import", methods=["POST"])
+@process_token(required=True)
+def api_import():
+ # check if the post request has the file part
+ if "tachiyomi" not in request.files:
+ return jsonify({"message": "No file provided"}), 400
+ file = request.files["tachiyomi"]
+
+ # if user does not select file, browser also
+ # submits an empty part without filename
+ if file.filename == "":
+ return jsonify({"message": "No selected file"}), 400
+
+ if file:
+ text = file.read()
+ site_title_pairs = read_tachiyomi_follows(text)
+ if site_title_pairs is None:
+ return jsonify({"message": "Malformed file."}), 400
+
+ # First fetch & save titles if they're not already in db
+ titles = ensure_titles(site_title_pairs)
+
+ # Then follow them all
+ import_follows(request.user_id, site_title_pairs)
+
+ # Mark all chapters as read too
+ for title in titles:
+ for chapter in title["chapters"]:
+ read(request.user_id, title["site"], title["id"], chapter["id"])
+
+ return jsonify({"message": f"Added {len(site_title_pairs)} follows."})
diff --git a/src/pytaku/persistence.py b/src/pytaku/persistence.py
index 2a246e0..5729d9e 100644
--- a/src/pytaku/persistence.py
+++ b/src/pytaku/persistence.py
@@ -5,7 +5,7 @@
import apsw
import argon2
-from .database.common import run_sql, run_sql_many, run_sql_on_demand
+from .database.common import run_sql, run_sql_many
def save_title(title):
diff --git a/src/pytaku/static/js/main.js b/src/pytaku/static/js/main.js
index 427f052..afff953 100644
--- a/src/pytaku/static/js/main.js
+++ b/src/pytaku/static/js/main.js
@@ -6,6 +6,7 @@ import Follows from "./routes/follows.js";
import Search from "./routes/search.js";
import Title from "./routes/title.js";
import Chapter from "./routes/chapter.js";
+import Importer from "./routes/importer.js";
Auth.init().then(() => {
const root = document.getElementById("spa-root");
@@ -76,5 +77,15 @@ Auth.init().then(() => {
})
),
},
+ "/i": {
+ onmatch: () => {
+ if (Auth.isLoggedIn()) {
+ return Importer;
+ } else {
+ m.route.set("/a", null, { replace: true });
+ }
+ },
+ render: (vnode) => m(Layout, vnode),
+ },
});
});
diff --git a/src/pytaku/static/js/routes/follows.js b/src/pytaku/static/js/routes/follows.js
index b603aed..eef5e95 100644
--- a/src/pytaku/static/js/routes/follows.js
+++ b/src/pytaku/static/js/routes/follows.js
@@ -70,10 +70,14 @@ function Follows(initialVNode) {
}
if (titles.length === 0) {
- return m(
- "div.content",
- "You're not following any title yet. Try searching for some."
- );
+ return m("div.content", [
+ m("p", "You're not following any title yet. Try searching for some."),
+ m("p", [
+ "Migrating from Tachiyomi? ",
+ m(m.route.Link, { href: "/i" }, "Use the importer"),
+ "!",
+ ]),
+ ]);
}
return m("div.content", [titles.map((title) => m(Title, { title }))]);
diff --git a/src/pytaku/static/js/routes/home.js b/src/pytaku/static/js/routes/home.js
index 5005492..326a531 100644
--- a/src/pytaku/static/js/routes/home.js
+++ b/src/pytaku/static/js/routes/home.js
@@ -1,5 +1,3 @@
-import { Auth } from "../models.js";
-
const Home = {
oncreate: (vnode) => {
document.title = "Pytaku";
diff --git a/src/pytaku/static/js/routes/importer.js b/src/pytaku/static/js/routes/importer.js
new file mode 100644
index 0000000..1d7b354
--- /dev/null
+++ b/src/pytaku/static/js/routes/importer.js
@@ -0,0 +1,77 @@
+import { Auth } from "../models.js";
+import { Button } from "../utils.js";
+
+function Importer(initialVNode) {
+ let resultMessage = null;
+ let isSuccess = null;
+ let isUploading = false;
+
+ return {
+ oninit: (vnode) => {
+ document.title = "Import from Tachiyomi";
+ },
+ view: (vnode) => {
+ return m("div.content", [
+ m("h1", "Importing from Tachiyomi"),
+ m(
+ "form[enctype=multipart/form-data]",
+ {
+ onsubmit: (ev) => {
+ ev.preventDefault();
+ // prepare multipart form body
+ const file = document.getElementById("tachiyomi").files[0];
+ const body = new FormData();
+ body.append("tachiyomi", file);
+
+ isUploading = true;
+ resultMessage = null;
+ m.redraw();
+ Auth.request({
+ method: "POST",
+ url: "/api/import",
+ body,
+ })
+ .then((resp) => {
+ resultMessage = resp.message;
+ isSuccess = true;
+ })
+ .catch((err) => {
+ resultMessage = err.response.message;
+ isSuccess = false;
+ })
+ .finally(() => {
+ isUploading = false;
+ });
+ },
+ },
+ [
+ m("p", [
+ "Go to ",
+ m("b", "Settings > Backup > Create backup"),
+ ", then upload the generated json file here:",
+ ]),
+ m("input[type=file][id=tachiyomi].importer--filepicker"),
+ m("br"),
+ m(Button, {
+ type: "submit",
+ text: isUploading ? "uploading..." : "upload",
+ color: "green",
+ icon: "upload",
+ disabled: isUploading ? "disabled" : null,
+ }),
+ ]
+ ),
+ m(
+ "p",
+ { class: `importer--${isSuccess ? "success" : "failure"}` },
+ resultMessage
+ ),
+ isSuccess
+ ? m(m.route.Link, { href: "/f" }, "See your following list here.")
+ : null,
+ ]);
+ },
+ };
+}
+
+export default Importer;
diff --git a/src/pytaku/static/spa.css b/src/pytaku/static/spa.css
index 358858b..0a0d385 100644
--- a/src/pytaku/static/spa.css
+++ b/src/pytaku/static/spa.css
@@ -349,3 +349,16 @@ .utils--chapter--group {
.utils--chapter--read-icon {
color: green;
}
+
+/* Tachiyomi importer */
+input[type="file"].importer--filepicker {
+ border: 2px solid black;
+ border-radius: var(--border-radius);
+ margin: 1rem 0;
+}
+.importer--success {
+ color: green;
+}
+.importer--failure {
+ color: red;
+}