Repos / pytaku / fc7236d595
commit fc7236d59518fc11b75d12b307cdb978ea7fe879
Author: Bùi Thành Nhân <hi@imnhan.com>
Date:   Sun Aug 23 22:52:51 2020 +0700

    implement "read all", tweak follows page
    
    - "read all" button on Title page allows to mark all chapters as read
    - Follows page now shows all unread chapters instead of just chapters
    that are newer than latest read chapter.

diff --git a/src/pytaku/database/migrations/latest_schema.sql b/src/pytaku/database/migrations/latest_schema.sql
index c9bf35d..fc65604 100644
--- a/src/pytaku/database/migrations/latest_schema.sql
+++ b/src/pytaku/database/migrations/latest_schema.sql
@@ -50,6 +50,15 @@ CREATE TABLE IF NOT EXISTS "chapter" (
     unique(site, title_id, id),
     unique(site, title_id, num_major, num_minor)
 );
+CREATE TABLE token (
+    user_id integer not null,
+    token text unique not null,
+    created_at text not null default (datetime('now')),
+    last_accessed_at text not null default (datetime('now')),
+    lifespan text not null, -- '+1 day', '+365 days', etc.
+
+    foreign key (user_id) references user (id)
+);
 CREATE TABLE IF NOT EXISTS "read" (
     user_id integer not null,
     site text not null,
@@ -58,15 +67,5 @@ CREATE TABLE IF NOT EXISTS "read" (
     updated_at text default (datetime('now')),
 
     foreign key (user_id) references user (id),
-    foreign key (site, title_id, chapter_id) references chapter (site, title_id, id),
     unique(user_id, site, title_id, chapter_id)
 );
-CREATE TABLE token (
-    user_id integer not null,
-    token text unique not null,
-    created_at text not null default (datetime('now')),
-    last_accessed_at text not null default (datetime('now')),
-    lifespan text not null, -- '+1 day', '+365 days', etc.
-
-    foreign key (user_id) references user (id)
-);
diff --git a/src/pytaku/database/migrations/m0005.sql b/src/pytaku/database/migrations/m0005.sql
new file mode 100644
index 0000000..b4adb73
--- /dev/null
+++ b/src/pytaku/database/migrations/m0005.sql
@@ -0,0 +1,24 @@
+-- Remove foreign key from "read" table pointing to "chapter".
+-- So we can, say, mark all chapters of a title as read even if some of those
+-- chapters haven't been created.
+
+pragma foreign_keys = off; -- to let us do anything at all
+begin transaction;
+
+create table new_read (
+    user_id integer not null,
+    site text not null,
+    title_id text, -- nullable to accomodate existing mangadex rows, urgh.
+    chapter_id text not null,
+    updated_at text default (datetime('now')),
+
+    foreign key (user_id) references user (id),
+    unique(user_id, site, title_id, chapter_id)
+);
+insert into new_read select * from read;
+drop table read;
+alter table new_read rename to read;
+
+pragma foreign_key_check;
+commit;
+pragma foreign_keys = on;
diff --git a/src/pytaku/main.py b/src/pytaku/main.py
index d934a5e..95b7721 100644
--- a/src/pytaku/main.py
+++ b/src/pytaku/main.py
@@ -542,12 +542,13 @@ def api_read():
 
     if reads:
         for r in reads:
+            print(">> reading", request.user_id, r)
             read(
                 request.user_id, r["site"], r["title_id"], r["chapter_id"],
             )
     if unreads:
         for u in unreads:
-            read(
+            unread(
                 request.user_id, u["site"], u["title_id"], u["chapter_id"],
             )
     # TODO: rewrite read/unread to do bulk updates instead of n+1 queries like these.
diff --git a/src/pytaku/persistence.py b/src/pytaku/persistence.py
index 7cb0a81..2a246e0 100644
--- a/src/pytaku/persistence.py
+++ b/src/pytaku/persistence.py
@@ -80,10 +80,9 @@ def load_title(site, title_id, user_id=None):
                 """
                 SELECT r.chapter_id
                 FROM read r
-                  INNER JOIN chapter c ON c.id = r.chapter_id AND c.site = r.site
                 WHERE r.user_id = ?
-                  AND c.title_id = ?
-                  AND c.site = ?
+                  AND r.title_id = ?
+                  AND r.site = ?
                 ORDER BY r.updated_at;
                 """,
                 (user_id, title["id"], title["site"]),
@@ -248,27 +247,18 @@ def get_followed_titles(user_id):
 
         # n+1 queries cuz I don't give a f- actually I do, but sqlite's cool with it:
         # https://www.sqlite.org/np1queryprob.html
-        chapters_i_finished = run_sql_on_demand(
+        chapters_i_finished = run_sql(
             """
-            SELECT r.chapter_id
-            FROM read r
-                INNER JOIN chapter c ON c.id = r.chapter_id AND c.site = r.site
-            WHERE r.user_id = ?
-                AND c.title_id = ?
-                AND c.site = ?
-            ORDER BY c.num_major desc, c.num_minor desc;
+            SELECT chapter_id
+            FROM read
+            WHERE user_id = ?
+              AND title_id = ?
+              AND site = ?;
             """,
             (user_id, t["id"], t["site"]),
         )
-        # Cut off chapter list:
-        # only show chapters newer than the latest chapter that user has finished.
-        # Running a loop here instead of just picking the one latest finished chapter
-        # because source site may have deleted said chapter.
-        for finished_chapter_id in chapters_i_finished:
-            for i, ch in enumerate(chapters):
-                if finished_chapter_id == ch["id"]:
-                    chapters = chapters[:i]
-                    break
+        # Only show chapters that user hasn't read
+        chapters = [ch for ch in chapters if ch["id"] not in chapters_i_finished]
 
         t["chapters"] = chapters
 
diff --git a/src/pytaku/static/js/routes/title.js b/src/pytaku/static/js/routes/title.js
index 529bceb..c5a368e 100644
--- a/src/pytaku/static/js/routes/title.js
+++ b/src/pytaku/static/js/routes/title.js
@@ -4,7 +4,9 @@ import { LoadingMessage, Button, fullChapterName, Chapter } from "../utils.js";
 function Title(initialVNode) {
   let isLoading = false;
   let isTogglingFollow = false;
+  let isMarkingAllAsRead = false;
   let title = {};
+  let allAreRead;
 
   return {
     oninit: (vnode) => {
@@ -29,6 +31,15 @@ function Title(initialVNode) {
         });
     },
     view: (vnode) => {
+      if (!isLoading && Auth.isLoggedIn()) {
+        allAreRead = true;
+        for (let chap of title.chapters) {
+          if (!chap.is_read) {
+            allAreRead = false;
+            break;
+          }
+        }
+      }
       return m(
         "div.content",
         isLoading
@@ -37,40 +48,82 @@ function Title(initialVNode) {
               m("h1", title.name),
               m("div.title--details", [
                 Auth.isLoggedIn()
-                  ? m(Button, {
-                      icon: "bookmark",
-                      disabled: isTogglingFollow ? "disabled" : null,
-                      text: isTogglingFollow
-                        ? "submitting..."
-                        : title.is_following
-                        ? "following"
-                        : "follow",
-                      color: title.is_following ? "red" : "green",
-                      title: title.is_following
-                        ? "Click to unfollow"
-                        : "Click to follow",
-                      onclick: (ev) => {
-                        isTogglingFollow = true;
-                        m.redraw();
-                        Auth.request({
-                          method: "POST",
-                          url: "/api/follow",
-                          body: {
-                            site: title.site,
-                            title_id: title.id,
-                            follow: !title.is_following,
-                          },
-                        })
-                          .then((resp) => {
-                            title.is_following = resp.follow;
+                  ? [
+                      m(Button, {
+                        icon: "bookmark",
+                        disabled: isTogglingFollow ? "disabled" : null,
+                        text: isTogglingFollow
+                          ? "submitting..."
+                          : title.is_following
+                          ? "following"
+                          : "follow",
+                        color: title.is_following ? "red" : "green",
+                        title: title.is_following
+                          ? "Click to unfollow"
+                          : "Click to follow",
+                        onclick: (ev) => {
+                          isTogglingFollow = true;
+                          m.redraw();
+                          Auth.request({
+                            method: "POST",
+                            url: "/api/follow",
+                            body: {
+                              site: title.site,
+                              title_id: title.id,
+                              follow: !title.is_following,
+                            },
                           })
-                          .finally(() => {
-                            isTogglingFollow = false;
-                          });
-                      },
-                    })
+                            .then((resp) => {
+                              title.is_following = resp.follow;
+                            })
+                            .finally(() => {
+                              isTogglingFollow = false;
+                            });
+                        },
+                      }),
+                      m(Button, {
+                        icon: "eye",
+                        disabled:
+                          isMarkingAllAsRead || allAreRead ? "disabled" : null,
+                        text: isMarkingAllAsRead
+                          ? "submitting..."
+                          : allAreRead
+                          ? "no new chapters"
+                          : "read all",
+                        color: "green",
+                        title: allAreRead
+                          ? null
+                          : "Click to mark all chapters as read",
+                        onclick: (ev) => {
+                          isMarkingAllAsRead = true;
+                          m.redraw();
+                          Auth.request({
+                            method: "POST",
+                            url: "/api/read",
+                            body: {
+                              read: title.chapters
+                                .filter((ch) => !ch.is_read)
+                                .map((ch) => {
+                                  return {
+                                    site: title.site,
+                                    title_id: title.id,
+                                    chapter_id: ch.id,
+                                  };
+                                }),
+                            },
+                          })
+                            .then((resp) => {
+                              title.chapters.forEach((chap) => {
+                                chap.is_read = true;
+                              });
+                            })
+                            .finally(() => {
+                              isMarkingAllAsRead = false;
+                            });
+                        },
+                      }),
+                    ]
                   : null,
-                " ",
                 m(
                   "a.touch-friendly[title=Go to source site][target=_blank]",
                   { href: title.source_url },
diff --git a/src/pytaku/static/spa.css b/src/pytaku/static/spa.css
index 9d7bc59..f2bebf5 100644
--- a/src/pytaku/static/spa.css
+++ b/src/pytaku/static/spa.css
@@ -287,6 +287,9 @@ .title--cover {
 .title--details {
   margin: 1rem 0;
 }
+.title--details > * {
+  margin-right: 0.3rem;
+}
 
 /* Chapter route */
 .chapter.content {
@@ -327,7 +330,7 @@ .utils--chapter {
 .utils--chapter.read > a:before {
   content: "🗹";
   color: green;
-  margin-right: 0.2rem;
+  margin-right: 0.4rem;
   font-weight: bold;
 }
 .utils--chapter > a {