mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Automated Tracking + Proper Sync
This commit is contained in:
@@ -3,6 +3,7 @@ import { store, addHistory, addBookmark, removeBookmark,
|
|||||||
checkAndMarkCompleted, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
checkAndMarkCompleted, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||||
import { MARK_CHAPTER_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
|
import { MARK_CHAPTER_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
|
||||||
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
|
||||||
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
|
|
||||||
const AVG_MIN_PER_PAGE = 0.33;
|
const AVG_MIN_PER_PAGE = 0.33;
|
||||||
|
|
||||||
@@ -30,6 +31,8 @@ export function markChapterRead(id: number, markedRead: Set<number>) {
|
|||||||
if (!mangaId) return;
|
if (!mangaId) return;
|
||||||
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
|
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
|
||||||
checkAndMarkCompleted(mangaId, updated);
|
checkAndMarkCompleted(mangaId, updated);
|
||||||
|
const ch = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter;
|
||||||
|
if (ch) trackingState.updateFromRead(ch, store.activeChapterList, getMangaPrefs());
|
||||||
const prefs = getMangaPrefs();
|
const prefs = getMangaPrefs();
|
||||||
if (prefs.deleteOnRead) {
|
if (prefs.deleteOnRead) {
|
||||||
const ch = store.activeChapterList.find(c => c.id === id);
|
const ch = store.activeChapterList.find(c => c.id === id);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { store, openReader } from "@store/state.svelte";
|
import { store, openReader } from "@store/state.svelte";
|
||||||
import { readerState } from "../store/readerState.svelte";
|
import { readerState } from "../store/readerState.svelte";
|
||||||
import { fetchPages } from "./pageLoader";
|
import { fetchPages } from "./pageLoader";
|
||||||
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
|
|
||||||
export function scheduleResumeDismiss() {
|
export function scheduleResumeDismiss() {
|
||||||
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
setTimeout(() => { readerState.resumeFading = true; }, 1500);
|
||||||
@@ -23,6 +24,9 @@ export async function loadChapter(
|
|||||||
readerState.resetForChapter();
|
readerState.resetForChapter();
|
||||||
store.pageUrls = [];
|
store.pageUrls = [];
|
||||||
|
|
||||||
|
const mangaId = store.activeManga?.id;
|
||||||
|
if (mangaId) trackingState.loadForManga(mangaId);
|
||||||
|
|
||||||
const bookmark = store.bookmarks.find(b => b.chapterId === id);
|
const bookmark = store.bookmarks.find(b => b.chapterId === id);
|
||||||
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
const resumeTo = bookmark ? bookmark.pageNumber : 0;
|
||||||
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
|
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
checkAndMarkCompleted as storeCheckAndMarkCompleted,
|
checkAndMarkCompleted as storeCheckAndMarkCompleted,
|
||||||
clearMarkersForManga,
|
clearMarkersForManga,
|
||||||
} from "@store/state.svelte";
|
} from "@store/state.svelte";
|
||||||
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
import type { MangaPrefs } from "@store/state.svelte";
|
import type { MangaPrefs } from "@store/state.svelte";
|
||||||
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
|
||||||
import type { Manga, Chapter, Category } from "@types";
|
import type { Manga, Chapter, Category } from "@types";
|
||||||
@@ -107,19 +108,16 @@
|
|||||||
const bookmark = store.activeManga
|
const bookmark = store.activeManga
|
||||||
? store.bookmarks.find(b => b.mangaId === store.activeManga!.id)
|
? store.bookmarks.find(b => b.mangaId === store.activeManga!.id)
|
||||||
: null;
|
: null;
|
||||||
if (bookmark) {
|
const bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null;
|
||||||
const ch = asc.find(c => c.id === bookmark.chapterId);
|
if (bookmarkedCh && !bookmarkedCh.isRead) {
|
||||||
if (ch) {
|
return { chapter: bookmarkedCh, type: (anyRead ? "continue" : "start") as const, resumePage: bookmark!.pageNumber };
|
||||||
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
|
|
||||||
const allRead = asc.every(c => c.isRead);
|
|
||||||
if (!(isLastChapter && allRead))
|
|
||||||
return { chapter: ch, type: "continue" as const, resumePage: bookmark.pageNumber };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||||
if (inProgress) return { chapter: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
|
|
||||||
const firstUnread = asc.find(c => !c.isRead);
|
const firstUnread = asc.find(c => !c.isRead);
|
||||||
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
|
const target = inProgress ?? firstUnread;
|
||||||
|
if (target) {
|
||||||
|
return { chapter: target, type: (anyRead ? "continue" : "start") as const, resumePage: null };
|
||||||
|
}
|
||||||
return { chapter: asc[0], type: "reread" as const, resumePage: null };
|
return { chapter: asc[0], type: "reread" as const, resumePage: null };
|
||||||
})());
|
})());
|
||||||
|
|
||||||
@@ -258,7 +256,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const m = store.activeManga;
|
const m = store.activeManga;
|
||||||
if (m) untrack(() => { acknowledgeUpdate(m.id); loadManga(m.id); loadChapters(m.id); loadCategories(m.id); });
|
if (m) untrack(() => { acknowledgeUpdate(m.id); loadManga(m.id); loadChapters(m.id); loadCategories(m.id); trackingState.loadForManga(m.id); });
|
||||||
});
|
});
|
||||||
|
|
||||||
let prevChapterId: number | null = null;
|
let prevChapterId: number | null = null;
|
||||||
@@ -309,6 +307,8 @@
|
|||||||
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
|
||||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
||||||
if (isRead) {
|
if (isRead) {
|
||||||
|
const ch = chapters.find(c => c.id === chapterId);
|
||||||
|
if (ch) trackingState.updateFromRead(ch, chapters, { sortMode, sortDir, preferredScanlator: get("preferredScanlator") as string, scanlatorFilter: scanlatorFilter as string[], scanlatorBlacklist: scanlatorBlacklist as string[], scanlatorForce: scanlatorForce as boolean });
|
||||||
if (get("deleteOnRead")) {
|
if (get("deleteOnRead")) {
|
||||||
const ch = chapters.find(c => c.id === chapterId);
|
const ch = chapters.find(c => c.id === chapterId);
|
||||||
if (ch?.isDownloaded) {
|
if (ch?.isDownloaded) {
|
||||||
@@ -334,7 +334,11 @@
|
|||||||
const idSet = new Set(ids);
|
const idSet = new Set(ids);
|
||||||
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
|
||||||
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
|
||||||
if (isRead && get("deleteOnRead")) {
|
if (isRead) {
|
||||||
|
const ascending = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
const lastInBatch = ascending.filter(c => idSet.has(c.id)).at(-1);
|
||||||
|
if (lastInBatch) trackingState.updateFromRead(lastInBatch, chapters, { sortMode, sortDir, preferredScanlator: get("preferredScanlator") as string, scanlatorFilter: scanlatorFilter as string[], scanlatorBlacklist: scanlatorBlacklist as string[], scanlatorForce: scanlatorForce as boolean });
|
||||||
|
if (get("deleteOnRead")) {
|
||||||
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
||||||
if (toDelete.length) {
|
if (toDelete.length) {
|
||||||
const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000;
|
const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000;
|
||||||
@@ -348,6 +352,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteSelected() {
|
async function deleteSelected() {
|
||||||
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.isDownloaded);
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte";
|
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise, ArrowLineDown } from "phosphor-svelte";
|
||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import { GET_TRACKERS, GET_MANGA_TRACK_RECORDS, SEARCH_TRACKER } from "@api/queries/tracking";
|
import { GET_TRACKERS, SEARCH_TRACKER } from "@api/queries/tracking";
|
||||||
import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK, FETCH_TRACK } from "@api/mutations/tracking";
|
import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK } from "@api/mutations/tracking";
|
||||||
import { addToast } from "@store/state.svelte";
|
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||||
|
import { addToast, store } from "@store/state.svelte";
|
||||||
|
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||||
|
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
|
||||||
import type { Tracker, TrackRecord, TrackSearch } from "@types";
|
import type { Tracker, TrackRecord, TrackSearch } from "@types";
|
||||||
|
import type { Chapter } from "@types/index";
|
||||||
|
|
||||||
let { mangaId, mangaTitle, onClose }: {
|
let { mangaId, mangaTitle, onClose }: {
|
||||||
mangaId: number;
|
mangaId: number;
|
||||||
@@ -16,8 +20,7 @@
|
|||||||
type TabId = "records" | number;
|
type TabId = "records" | number;
|
||||||
|
|
||||||
let trackers: Tracker[] = $state([]);
|
let trackers: Tracker[] = $state([]);
|
||||||
let records: TrackRecord[] = $state([]);
|
let loadingTrackers: boolean = $state(true);
|
||||||
let loading: boolean = $state(true);
|
|
||||||
let activeTab: TabId = $state("records");
|
let activeTab: TabId = $state("records");
|
||||||
|
|
||||||
let searchQuery: string = $state("");
|
let searchQuery: string = $state("");
|
||||||
@@ -30,26 +33,22 @@
|
|||||||
let syncing: number | null = $state(null);
|
let syncing: number | null = $state(null);
|
||||||
let editingChapter: number | null = $state(null);
|
let editingChapter: number | null = $state(null);
|
||||||
let chapterDraft: number = $state(0);
|
let chapterDraft: number = $state(0);
|
||||||
|
let applyingRecord: number | null = $state(null);
|
||||||
|
|
||||||
|
const records = $derived(trackingState.records);
|
||||||
|
const loading = $derived(trackingState.loading || loadingTrackers);
|
||||||
|
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
||||||
|
|
||||||
function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); }
|
function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); }
|
||||||
|
|
||||||
async function load() {
|
$effect(() => {
|
||||||
loading = true;
|
loadingTrackers = true;
|
||||||
try {
|
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
|
||||||
const [tRes, rRes] = await Promise.all([
|
.then(r => { trackers = r.trackers.nodes; })
|
||||||
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS),
|
.catch(e => addToast({ kind: "error", title: "Failed to load trackers", body: e?.message }))
|
||||||
gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(GET_MANGA_TRACK_RECORDS, { mangaId }),
|
.finally(() => { loadingTrackers = false; });
|
||||||
]);
|
trackingState.loadForManga(mangaId);
|
||||||
trackers = tRes.trackers.nodes;
|
});
|
||||||
records = rRes.manga.trackRecords.nodes;
|
|
||||||
} catch (e: any) {
|
|
||||||
addToast({ kind: "error", title: "Failed to load tracking", body: e?.message });
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => { load(); });
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const tab = activeTab;
|
const tab = activeTab;
|
||||||
@@ -62,7 +61,6 @@
|
|||||||
|
|
||||||
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
|
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
|
||||||
function recordFor(trackerId: number) { return records.find(r => r.trackerId === trackerId); }
|
function recordFor(trackerId: number) { return records.find(r => r.trackerId === trackerId); }
|
||||||
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
|
||||||
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout>;
|
let searchTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
@@ -96,7 +94,7 @@
|
|||||||
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
|
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
|
||||||
BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId }
|
BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId }
|
||||||
);
|
);
|
||||||
records = [...records.filter(r => r.trackerId !== activeTab), res.bindTrack.trackRecord];
|
trackingState.patchRecord(res.bindTrack.trackRecord);
|
||||||
activeTab = "records";
|
activeTab = "records";
|
||||||
addToast({ kind: "success", title: "Now tracking", body: result.title });
|
addToast({ kind: "success", title: "Now tracking", body: result.title });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -110,7 +108,7 @@
|
|||||||
updatingRecord = record.id;
|
updatingRecord = record.id;
|
||||||
try {
|
try {
|
||||||
await gql(UNBIND_TRACK, { recordId: record.id });
|
await gql(UNBIND_TRACK, { recordId: record.id });
|
||||||
records = records.filter(r => r.id !== record.id);
|
trackingState.removeRecord(record.id);
|
||||||
addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name });
|
addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Failed to unlink", body: e?.message });
|
addToast({ kind: "error", title: "Failed to unlink", body: e?.message });
|
||||||
@@ -119,15 +117,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchRecord(updated: Partial<TrackRecord> & { id: number }) {
|
|
||||||
records = records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateStatus(record: TrackRecord, status: number) {
|
async function updateStatus(record: TrackRecord, status: number) {
|
||||||
updatingRecord = record.id;
|
updatingRecord = record.id;
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
|
||||||
patchRecord(res.updateTrack.trackRecord);
|
trackingState.patchRecord(res.updateTrack.trackRecord);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -139,7 +133,7 @@
|
|||||||
updatingRecord = record.id;
|
updatingRecord = record.id;
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
|
||||||
patchRecord(res.updateTrack.trackRecord);
|
trackingState.patchRecord(res.updateTrack.trackRecord);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -151,7 +145,7 @@
|
|||||||
updatingRecord = record.id;
|
updatingRecord = record.id;
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, private: !record.private });
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, private: !record.private });
|
||||||
patchRecord(res.updateTrack.trackRecord);
|
trackingState.patchRecord(res.updateTrack.trackRecord);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -162,9 +156,8 @@
|
|||||||
async function syncRecord(record: TrackRecord) {
|
async function syncRecord(record: TrackRecord) {
|
||||||
syncing = record.id;
|
syncing = record.id;
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
|
const fresh = await trackingState.syncRecordFromRemote(record.id);
|
||||||
patchRecord(res.fetchTrack.trackRecord);
|
if (fresh) addToast({ kind: "success", title: "Synced from tracker" });
|
||||||
addToast({ kind: "success", title: "Synced from tracker" });
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -179,6 +172,33 @@
|
|||||||
|
|
||||||
function cancelChapterEditor() { editingChapter = null; }
|
function cancelChapterEditor() { editingChapter = null; }
|
||||||
|
|
||||||
|
async function applyToLibrary(record: TrackRecord) {
|
||||||
|
applyingRecord = record.id;
|
||||||
|
try {
|
||||||
|
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
||||||
|
const prefs = store.settings.mangaPrefs?.[mangaId] ?? {};
|
||||||
|
const marked = await syncBackFromTracker(
|
||||||
|
[record],
|
||||||
|
chapRes.chapters.nodes,
|
||||||
|
{
|
||||||
|
threshold: store.settings.trackerSyncBackThreshold ?? null,
|
||||||
|
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
|
||||||
|
chapterPrefs: prefs,
|
||||||
|
},
|
||||||
|
(query, vars) => gql(query, vars),
|
||||||
|
);
|
||||||
|
if (marked.length > 0) {
|
||||||
|
addToast({ kind: "success", title: `${marked.length} chapter${marked.length !== 1 ? "s" : ""} marked read` });
|
||||||
|
} else {
|
||||||
|
addToast({ kind: "info", title: "Already up to date" });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Apply failed", body: e?.message });
|
||||||
|
} finally {
|
||||||
|
applyingRecord = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitChapter(record: TrackRecord) {
|
async function submitChapter(record: TrackRecord) {
|
||||||
const val = Math.max(0, chapterDraft);
|
const val = Math.max(0, chapterDraft);
|
||||||
editingChapter = null;
|
editingChapter = null;
|
||||||
@@ -186,7 +206,7 @@
|
|||||||
updatingRecord = record.id;
|
updatingRecord = record.id;
|
||||||
try {
|
try {
|
||||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val });
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val });
|
||||||
patchRecord(res.updateTrack.trackRecord);
|
trackingState.patchRecord(res.updateTrack.trackRecord);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -269,6 +289,11 @@
|
|||||||
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
|
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
|
||||||
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
|
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
|
||||||
</button>
|
</button>
|
||||||
|
{#if store.settings.trackerSyncBack}
|
||||||
|
<button class="record-icon-btn" title="Apply tracker progress to library" disabled={applyingRecord === record.id} onclick={() => applyToLibrary(record)}>
|
||||||
|
<ArrowLineDown size={11} weight="light" class={applyingRecord === record.id ? "anim-spin" : ""} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}>
|
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}>
|
||||||
<X size={11} weight="bold" />
|
<X size={11} weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
import { GET_TRACKERS } from "@api/queries/tracking";
|
import { GET_TRACKERS } from "@api/queries/tracking";
|
||||||
import { LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER } from "@api/mutations/tracking";
|
import { LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER } from "@api/mutations/tracking";
|
||||||
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
||||||
import type { Tracker } from "../../lib/types";
|
import { store, updateSettings, addToast } from "@store/state.svelte";
|
||||||
|
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
|
||||||
|
import { GET_ALL_TRACKER_RECORDS } from "@api/queries/tracking";
|
||||||
|
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||||
|
import type { Tracker, TrackRecord } from "../../lib/types";
|
||||||
|
import type { Chapter } from "@types/index";
|
||||||
|
|
||||||
let trackers = $state<Tracker[]>([]);
|
let trackers = $state<Tracker[]>([]);
|
||||||
let trackersLoading = $state(false);
|
let trackersLoading = $state(false);
|
||||||
@@ -78,6 +83,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
||||||
|
|
||||||
|
let syncing = $state(false);
|
||||||
|
|
||||||
|
async function runSyncAll() {
|
||||||
|
syncing = true;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ trackers: { nodes: any[] } }>(GET_ALL_TRACKER_RECORDS);
|
||||||
|
const allTrackers = res.trackers.nodes.filter((t: any) => t.isLoggedIn);
|
||||||
|
let totalMarked = 0;
|
||||||
|
|
||||||
|
for (const tracker of allTrackers) {
|
||||||
|
for (const record of tracker.trackRecords.nodes as TrackRecord[]) {
|
||||||
|
if (!record.manga?.id) continue;
|
||||||
|
const mangaId = record.manga.id;
|
||||||
|
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
|
||||||
|
const prefs = store.settings.mangaPrefs?.[mangaId] ?? {};
|
||||||
|
|
||||||
|
const marked = await syncBackFromTracker(
|
||||||
|
[record],
|
||||||
|
chapRes.chapters.nodes,
|
||||||
|
{
|
||||||
|
threshold: store.settings.trackerSyncBackThreshold ?? null,
|
||||||
|
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
|
||||||
|
chapterPrefs: prefs,
|
||||||
|
},
|
||||||
|
(query, vars) => gql(query, vars),
|
||||||
|
);
|
||||||
|
totalMarked += marked.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addToast({ kind: "success", title: "Sync complete", body: `${totalMarked} chapter${totalMarked !== 1 ? "s" : ""} marked read` });
|
||||||
|
} catch (e: any) {
|
||||||
|
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||||
|
} finally { syncing = false; }
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="s-panel">
|
<div class="s-panel">
|
||||||
@@ -148,4 +189,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="s-section">
|
||||||
|
<p class="s-section-title">Sync back from tracker</p>
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Enable sync back</span>
|
||||||
|
<span class="s-desc">Mark chapters read locally based on tracker progress</span>
|
||||||
|
</div>
|
||||||
|
<button class="s-toggle" class:on={store.settings.trackerSyncBack}
|
||||||
|
onclick={() => updateSettings({ trackerSyncBack: !store.settings.trackerSyncBack })}
|
||||||
|
role="switch" aria-checked={store.settings.trackerSyncBack}>
|
||||||
|
<span class="s-toggle-thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if store.settings.trackerSyncBack}
|
||||||
|
<label class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Chapter number tolerance</span>
|
||||||
|
<span class="s-desc">Allow source and tracker chapter numbers to differ by up to the set amount. When off, the tracker number is used as-is with no range check.</span>
|
||||||
|
</div>
|
||||||
|
<button role="switch" aria-checked={store.settings.trackerSyncBackThreshold !== null} class="s-toggle" class:on={store.settings.trackerSyncBackThreshold !== null}
|
||||||
|
onclick={() => updateSettings({ trackerSyncBackThreshold: store.settings.trackerSyncBackThreshold !== null ? null : 20 })}>
|
||||||
|
<span class="s-toggle-thumb"></span>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
{#if store.settings.trackerSyncBackThreshold !== null}
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info"><span class="s-label">Tolerance</span><span class="s-desc">Max chapter number difference allowed (1–20)</span></div>
|
||||||
|
<div class="s-stepper">
|
||||||
|
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.max(1, (store.settings.trackerSyncBackThreshold ?? 20) - 1) })}>−</button>
|
||||||
|
<span class="s-step-val">{store.settings.trackerSyncBackThreshold}</span>
|
||||||
|
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.min(20, (store.settings.trackerSyncBackThreshold ?? 20) + 1) })}>+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Respect scanlator filter</span>
|
||||||
|
<span class="s-desc">Only mark chapters matching the series' active scanlator filter</span>
|
||||||
|
</div>
|
||||||
|
<button class="s-toggle" class:on={store.settings.trackerRespectScanlatorFilter}
|
||||||
|
onclick={() => updateSettings({ trackerRespectScanlatorFilter: !store.settings.trackerRespectScanlatorFilter })}
|
||||||
|
role="switch" aria-checked={store.settings.trackerRespectScanlatorFilter}>
|
||||||
|
<span class="s-toggle-thumb"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="s-row">
|
||||||
|
<div class="s-row-info">
|
||||||
|
<span class="s-label">Sync now</span>
|
||||||
|
<span class="s-desc">Apply tracker progress to all linked manga in your library</span>
|
||||||
|
</div>
|
||||||
|
<button class="s-btn" onclick={runSyncAll} disabled={syncing}>
|
||||||
|
{syncing ? "Syncing…" : "Sync all"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { Tracker, TrackRecord } from "@types/index";
|
import type { Tracker, TrackRecord } from "@types/index";
|
||||||
|
import type { Chapter } from "@types/index";
|
||||||
|
import { FETCH_TRACK } from "@api/mutations/tracking";
|
||||||
|
import { MARK_CHAPTERS_READ } from "@api/mutations/chapters";
|
||||||
|
import { buildChapterList, type ChapterDisplayPrefs } from "@features/series/lib/chapterList";
|
||||||
|
|
||||||
export interface TrackerWithRecords extends Tracker {
|
export interface TrackerWithRecords extends Tracker {
|
||||||
trackRecords: { nodes: TrackRecord[] };
|
trackRecords: { nodes: TrackRecord[] };
|
||||||
@@ -109,3 +113,57 @@ export function removeRecord(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SyncBackOptions {
|
||||||
|
threshold: number | null;
|
||||||
|
respectScanlatorFilter: boolean;
|
||||||
|
chapterPrefs: ChapterDisplayPrefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncBackFromTracker(
|
||||||
|
records: TrackRecord[],
|
||||||
|
chapters: Chapter[],
|
||||||
|
opts: SyncBackOptions,
|
||||||
|
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
|
||||||
|
): Promise<number[]> {
|
||||||
|
const eligible = opts.respectScanlatorFilter
|
||||||
|
? buildChapterList(chapters, opts.chapterPrefs)
|
||||||
|
: chapters;
|
||||||
|
|
||||||
|
const toMark: number[] = [];
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
let fresh: TrackRecord;
|
||||||
|
try {
|
||||||
|
const res = await gqlFn(FETCH_TRACK, { recordId: record.id }) as { fetchTrack: { trackRecord: TrackRecord } };
|
||||||
|
fresh = res.fetchTrack.trackRecord;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remote = fresh.lastChapterRead;
|
||||||
|
if (!remote || remote <= 0) continue;
|
||||||
|
|
||||||
|
let ceiling: number;
|
||||||
|
|
||||||
|
if (opts.threshold === null) {
|
||||||
|
ceiling = remote;
|
||||||
|
} else {
|
||||||
|
const match = eligible
|
||||||
|
.filter(c => Math.abs(c.chapterNumber - remote) <= opts.threshold!)
|
||||||
|
.sort((a, b) => Math.abs(a.chapterNumber - remote) - Math.abs(b.chapterNumber - remote))[0];
|
||||||
|
if (!match) continue;
|
||||||
|
ceiling = match.chapterNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unread = eligible.filter(c => !c.isRead && c.chapterNumber <= ceiling);
|
||||||
|
toMark.push(...unread.map(c => c.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = [...new Set(toMark)];
|
||||||
|
if (ids.length > 0) {
|
||||||
|
await gqlFn(MARK_CHAPTERS_READ, { ids, isRead: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { gql } from "@api/client";
|
||||||
|
import { GET_MANGA_TRACK_RECORDS } from "@api/queries/tracking";
|
||||||
|
import { UPDATE_TRACK, FETCH_TRACK } from "@api/mutations/tracking";
|
||||||
|
import { buildChapterList } from "@features/series/lib/chapterList";
|
||||||
|
import type { TrackRecord } from "@types/index";
|
||||||
|
import type { Chapter } from "@types/index";
|
||||||
|
|
||||||
|
class TrackingState {
|
||||||
|
records: TrackRecord[] = $state([]);
|
||||||
|
mangaId: number | null = $state(null);
|
||||||
|
loading: boolean = $state(false);
|
||||||
|
error: string | null = $state(null);
|
||||||
|
|
||||||
|
async loadForManga(id: number) {
|
||||||
|
if (this.mangaId === id && this.records.length > 0) return;
|
||||||
|
this.mangaId = id;
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(
|
||||||
|
GET_MANGA_TRACK_RECORDS, { mangaId: id }
|
||||||
|
);
|
||||||
|
if (this.mangaId !== id) return;
|
||||||
|
this.records = res.manga.trackRecords.nodes;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.message ?? "Failed to load tracking";
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
if (this.mangaId === null) return;
|
||||||
|
const id = this.mangaId;
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(
|
||||||
|
GET_MANGA_TRACK_RECORDS, { mangaId: id }
|
||||||
|
);
|
||||||
|
if (this.mangaId !== id) return;
|
||||||
|
this.records = res.manga.trackRecords.nodes;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e?.message ?? "Failed to refresh tracking";
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
patchRecord(updated: Partial<TrackRecord> & { id: number }) {
|
||||||
|
this.records = this.records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeRecord(id: number) {
|
||||||
|
this.records = this.records.filter(r => r.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.records = [];
|
||||||
|
this.mangaId = null;
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncRecordFromRemote(recordId: number) {
|
||||||
|
try {
|
||||||
|
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
FETCH_TRACK, { recordId }
|
||||||
|
);
|
||||||
|
this.patchRecord(res.fetchTrack.trackRecord);
|
||||||
|
return res.fetchTrack.trackRecord;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFromRead(chapter: Chapter, chapterList: Chapter[], prefs?: import("@features/series/lib/chapterList").ChapterDisplayPrefs) {
|
||||||
|
const filtered = prefs ? buildChapterList(chapterList, { ...prefs, sortDir: "asc" }) : [...chapterList].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
const idx = filtered.findIndex(c => c.id === chapter.id);
|
||||||
|
if (idx < 0) return;
|
||||||
|
const position = idx + 1;
|
||||||
|
const eligible = this.records.filter(r => position > (r.lastChapterRead ?? 0));
|
||||||
|
for (const record of eligible) {
|
||||||
|
try {
|
||||||
|
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||||
|
UPDATE_TRACK, { recordId: record.id, lastChapterRead: position }
|
||||||
|
);
|
||||||
|
this.patchRecord(res.updateTrack.trackRecord);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trackingState = new TrackingState();
|
||||||
@@ -22,7 +22,7 @@ export type LibrarySortDir = "asc" | "desc";
|
|||||||
export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN";
|
export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN";
|
||||||
export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked" | "marked";
|
export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked" | "marked";
|
||||||
|
|
||||||
export type BuiltinTheme = "original" | "dark" | "light" | "midnight" | "warm";
|
export type BuiltinTheme = "original" | "dark" | "light" | "light-contrast" | "midnight" | "warm" | "starry";
|
||||||
export type Theme = BuiltinTheme | string;
|
export type Theme = BuiltinTheme | string;
|
||||||
|
|
||||||
export interface ThemeTokens {
|
export interface ThemeTokens {
|
||||||
@@ -148,6 +148,9 @@ export interface Settings {
|
|||||||
readerPresets: ReaderPreset[];
|
readerPresets: ReaderPreset[];
|
||||||
mangaReaderSettings: Record<number, ReaderSettings>;
|
mangaReaderSettings: Record<number, ReaderSettings>;
|
||||||
barPosition?: "top" | "left" | "right";
|
barPosition?: "top" | "left" | "right";
|
||||||
|
trackerSyncBack: boolean;
|
||||||
|
trackerSyncBackThreshold: number | null;
|
||||||
|
trackerRespectScanlatorFilter: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_READING_STATS: ReadingStats = {
|
export const DEFAULT_READING_STATS: ReadingStats = {
|
||||||
@@ -185,6 +188,9 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
pinnedSourceIds: [],
|
pinnedSourceIds: [],
|
||||||
readerPresets: [],
|
readerPresets: [],
|
||||||
mangaReaderSettings: {},
|
mangaReaderSettings: {},
|
||||||
|
trackerSyncBack: false,
|
||||||
|
trackerSyncBackThreshold: 20,
|
||||||
|
trackerRespectScanlatorFilter: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORE_VERSION = 3;
|
const STORE_VERSION = 3;
|
||||||
|
|||||||
Reference in New Issue
Block a user