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;
+}