From c0efbba4df6c03427e941b21958f854feddc97fe Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Sun, 26 Apr 2026 12:11:45 -0500 Subject: [PATCH] Feat: Automated Tracking + Proper Sync --- src/features/reader/lib/chapterActions.ts | 5 +- src/features/reader/lib/chapterLoader.ts | 4 + .../series/components/SeriesDetail.svelte | 53 +++++---- .../series/panels/TrackingPanel.svelte | 99 ++++++++++------- .../settings/sections/TrackingSettings.svelte | 103 +++++++++++++++++- src/features/tracking/lib/trackingSync.ts | 58 ++++++++++ .../tracking/store/trackingState.svelte.ts | 92 ++++++++++++++++ src/store/state.svelte.ts | 8 +- 8 files changed, 358 insertions(+), 64 deletions(-) create mode 100644 src/features/tracking/store/trackingState.svelte.ts diff --git a/src/features/reader/lib/chapterActions.ts b/src/features/reader/lib/chapterActions.ts index 5dcb547..73c36d9 100644 --- a/src/features/reader/lib/chapterActions.ts +++ b/src/features/reader/lib/chapterActions.ts @@ -3,6 +3,7 @@ import { store, addHistory, addBookmark, removeBookmark, checkAndMarkCompleted, DEFAULT_MANGA_PREFS } from "@store/state.svelte"; import { MARK_CHAPTER_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters"; import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads"; +import { trackingState } from "@features/tracking/store/trackingState.svelte"; const AVG_MIN_PER_PAGE = 0.33; @@ -30,6 +31,8 @@ export function markChapterRead(id: number, markedRead: Set) { if (!mangaId) return; const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c); 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(); if (prefs.deleteOnRead) { const ch = store.activeChapterList.find(c => c.id === id); @@ -73,4 +76,4 @@ export function toggleBookmark( if (existing) removeBookmark(existing.chapterId); addBookmark({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber }); } -} +} \ No newline at end of file diff --git a/src/features/reader/lib/chapterLoader.ts b/src/features/reader/lib/chapterLoader.ts index 3fbdd26..885487e 100644 --- a/src/features/reader/lib/chapterLoader.ts +++ b/src/features/reader/lib/chapterLoader.ts @@ -1,6 +1,7 @@ import { store, openReader } from "@store/state.svelte"; import { readerState } from "../store/readerState.svelte"; import { fetchPages } from "./pageLoader"; +import { trackingState } from "@features/tracking/store/trackingState.svelte"; export function scheduleResumeDismiss() { setTimeout(() => { readerState.resumeFading = true; }, 1500); @@ -23,6 +24,9 @@ export async function loadChapter( readerState.resetForChapter(); store.pageUrls = []; + const mangaId = store.activeManga?.id; + if (mangaId) trackingState.loadForManga(mangaId); + const bookmark = store.bookmarks.find(b => b.chapterId === id); const resumeTo = bookmark ? bookmark.pageNumber : 0; readerState.resumePage = resumeTo > 1 ? resumeTo : 0; diff --git a/src/features/series/components/SeriesDetail.svelte b/src/features/series/components/SeriesDetail.svelte index 555376e..de557ca 100644 --- a/src/features/series/components/SeriesDetail.svelte +++ b/src/features/series/components/SeriesDetail.svelte @@ -18,6 +18,7 @@ checkAndMarkCompleted as storeCheckAndMarkCompleted, clearMarkersForManga, } from "@store/state.svelte"; + import { trackingState } from "@features/tracking/store/trackingState.svelte"; import type { MangaPrefs } from "@store/state.svelte"; import { DEFAULT_MANGA_PREFS } from "@store/state.svelte"; import type { Manga, Chapter, Category } from "@types"; @@ -102,24 +103,21 @@ const continueChapter = $derived((() => { if (!sortedChapters.length) return null; - const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder); - const anyRead = asc.some(c => c.isRead); + const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder); + const anyRead = asc.some(c => c.isRead); const bookmark = store.activeManga ? store.bookmarks.find(b => b.mangaId === store.activeManga!.id) : null; - if (bookmark) { - const ch = asc.find(c => c.id === bookmark.chapterId); - if (ch) { - 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 bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null; + if (bookmarkedCh && !bookmarkedCh.isRead) { + return { chapter: bookmarkedCh, type: (anyRead ? "continue" : "start") as const, resumePage: bookmark!.pageNumber }; } 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); - 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 }; })()); @@ -258,7 +256,7 @@ $effect(() => { 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; @@ -309,6 +307,8 @@ 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 (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")) { const ch = chapters.find(c => c.id === chapterId); if (ch?.isDownloaded) { @@ -334,17 +334,22 @@ const idSet = new Set(ids); 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 (isRead && get("deleteOnRead")) { - const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded); - if (toDelete.length) { - const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000; - const doDelete = async () => { - await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error); - chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c); - if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); - }; - if (delayMs === 0) doDelete(); - else setTimeout(doDelete, delayMs); + 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); + if (toDelete.length) { + const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000; + const doDelete = async () => { + await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error); + chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c); + if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); + }; + if (delayMs === 0) doDelete(); + else setTimeout(doDelete, delayMs); + } } } } diff --git a/src/features/series/panels/TrackingPanel.svelte b/src/features/series/panels/TrackingPanel.svelte index c1f8148..882af89 100644 --- a/src/features/series/panels/TrackingPanel.svelte +++ b/src/features/series/panels/TrackingPanel.svelte @@ -1,11 +1,15 @@
@@ -148,4 +189,64 @@
+
+

Sync back from tracker

+
+
+ Enable sync back + Mark chapters read locally based on tracker progress +
+ +
+ + {#if store.settings.trackerSyncBack} + + {#if store.settings.trackerSyncBackThreshold !== null} +
+
ToleranceMax chapter number difference allowed (1–20)
+
+ + {store.settings.trackerSyncBackThreshold} + +
+
+ {/if} + +
+
+ Respect scanlator filter + Only mark chapters matching the series' active scanlator filter +
+ +
+ +
+
+ Sync now + Apply tracker progress to all linked manga in your library +
+ +
+ {/if} +
+ \ No newline at end of file diff --git a/src/features/tracking/lib/trackingSync.ts b/src/features/tracking/lib/trackingSync.ts index 698523a..8cb8a5a 100644 --- a/src/features/tracking/lib/trackingSync.ts +++ b/src/features/tracking/lib/trackingSync.ts @@ -1,4 +1,8 @@ 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 { 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) => Promise, +): Promise { + 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; +} \ No newline at end of file diff --git a/src/features/tracking/store/trackingState.svelte.ts b/src/features/tracking/store/trackingState.svelte.ts new file mode 100644 index 0000000..2c41d94 --- /dev/null +++ b/src/features/tracking/store/trackingState.svelte.ts @@ -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 & { 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(); \ No newline at end of file diff --git a/src/store/state.svelte.ts b/src/store/state.svelte.ts index 71c2a2b..7e7f6a2 100644 --- a/src/store/state.svelte.ts +++ b/src/store/state.svelte.ts @@ -22,7 +22,7 @@ export type LibrarySortDir = "asc" | "desc"; export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN"; 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 interface ThemeTokens { @@ -148,6 +148,9 @@ export interface Settings { readerPresets: ReaderPreset[]; mangaReaderSettings: Record; barPosition?: "top" | "left" | "right"; + trackerSyncBack: boolean; + trackerSyncBackThreshold: number | null; + trackerRespectScanlatorFilter: boolean; } export const DEFAULT_READING_STATS: ReadingStats = { @@ -185,6 +188,9 @@ export const DEFAULT_SETTINGS: Settings = { pinnedSourceIds: [], readerPresets: [], mangaReaderSettings: {}, + trackerSyncBack: false, + trackerSyncBackThreshold: 20, + trackerRespectScanlatorFilter: true, }; const STORE_VERSION = 3;