Feat: Dual-Sync Tracking (#52)

This commit is contained in:
Youwes09
2026-04-26 13:36:30 -05:00
parent c0efbba4df
commit 50c5131477
6 changed files with 451 additions and 175 deletions
+1 -1
View File
@@ -32,8 +32,8 @@ export function markChapterRead(id: number, markedRead: Set<number>) {
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 (ch) trackingState.updateFromRead(mangaId, ch, store.activeChapterList, prefs);
if (prefs.deleteOnRead) {
const ch = store.activeChapterList.find(c => c.id === id);
if (ch?.isDownloaded) {
@@ -81,18 +81,21 @@
const scanlatorBlacklist = $derived((get("scanlatorBlacklist") ?? []) as string[]);
const scanlatorForce = $derived((get("scanlatorForce") ?? false) as boolean);
const currentPrefs = $derived({
sortMode,
sortDir,
preferredScanlator: get("preferredScanlator") as string,
scanlatorFilter: scanlatorFilter as string[],
scanlatorBlacklist: scanlatorBlacklist as string[],
scanlatorForce: scanlatorForce as boolean,
});
const availableScanlators = $derived(
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
.sort((a, b) => a.localeCompare(b))
);
const sortedChapters = $derived(buildChapterList(chapters, {
sortMode, sortDir,
preferredScanlator: get("preferredScanlator") as string,
scanlatorFilter: scanlatorFilter as string[],
scanlatorBlacklist: scanlatorBlacklist as string[],
scanlatorForce: scanlatorForce as boolean,
}));
const sortedChapters = $derived(buildChapterList(chapters, currentPrefs));
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
@@ -158,13 +161,7 @@
function applyChapters(nodes: Chapter[]) {
if (get("autoDownload") && _prevChapterIds.size > 0) {
const filtered = buildChapterList(nodes, {
sortMode, sortDir,
preferredScanlator: get("preferredScanlator") as string,
scanlatorFilter: scanlatorFilter as string[],
scanlatorBlacklist: scanlatorBlacklist as string[],
scanlatorForce: scanlatorForce as boolean,
});
const filtered = buildChapterList(nodes, currentPrefs);
const newChapters = filtered.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
}
@@ -254,9 +251,33 @@
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false; });
}
async function syncTrackersIntoChapters(mangaId: number, chaps: Chapter[]) {
if (!store.settings.trackerSyncBack) return;
const records = trackingState.recordsFor(mangaId);
if (!records.length) return;
for (const record of records) {
try {
const { markedIds } = await trackingState.syncFromRemote(mangaId, record, chaps, currentPrefs);
if (markedIds.length > 0) {
const idSet = new Set(markedIds);
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead: true } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
} catch {}
}
}
$effect(() => {
const m = store.activeManga;
if (m) untrack(() => { acknowledgeUpdate(m.id); loadManga(m.id); loadChapters(m.id); loadCategories(m.id); trackingState.loadForManga(m.id); });
if (m) untrack(() => {
acknowledgeUpdate(m.id);
loadManga(m.id);
loadChapters(m.id);
loadCategories(m.id);
trackingState.loadForManga(m.id).then(() => {
syncTrackersIntoChapters(m.id, chapters);
});
});
});
let prevChapterId: number | null = null;
@@ -303,12 +324,22 @@
}
async function markRead(chapterId: number, isRead: boolean) {
const mangaId = store.activeManga?.id;
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
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) {
if (mangaId) {
chapterStore.set(mangaId, { data: chapters, fetchedAt: Date.now() });
checkAndMarkCompleted(mangaId, chapters);
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 (ch) {
if (isRead) {
await trackingState.updateFromRead(mangaId, ch, chapters, currentPrefs);
} else {
await trackingState.updateFromUnread(mangaId, chapters, currentPrefs);
}
}
}
if (isRead) {
if (get("deleteOnRead")) {
const ch = chapters.find(c => c.id === chapterId);
if (ch?.isDownloaded) {
@@ -330,14 +361,22 @@
async function markBulk(ids: number[], isRead: boolean) {
if (!ids.length) return;
const mangaId = store.activeManga?.id;
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
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 (mangaId) {
chapterStore.set(mangaId, { data: chapters, fetchedAt: Date.now() });
checkAndMarkCompleted(mangaId, chapters);
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) await trackingState.updateFromRead(mangaId, lastInBatch, chapters, currentPrefs);
} else {
await trackingState.updateFromUnread(mangaId, chapters, currentPrefs);
}
}
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) {
@@ -345,7 +384,7 @@
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 (mangaId) chapterStore.set(mangaId, { data: chapters, fetchedAt: Date.now() });
};
if (delayMs === 0) doDelete();
else setTimeout(doDelete, delayMs);
@@ -1,113 +1,146 @@
<script lang="ts">
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass } from "phosphor-svelte";
import { gql } from "@api/client";
import { gql } from "@api/client";
import { addToast, setActiveManga, setNavPage } from "@store/state.svelte";
import { GET_ALL_TRACKER_RECORDS } from "@api/queries";
import { UPDATE_TRACK, UNBIND_TRACK, FETCH_TRACK } from "@api/mutations";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { GET_CHAPTERS } from "@api/queries/chapters";
import { store } from "@store/state.svelte";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { TrackRecord } from "@types/index";
import type { Chapter } from "@types/index";
import {
flattenRecords, filterRecords, sortRecords, dedupeStatuses,
scoreToStars, calcProgress, patchTracker, removeRecord,
type TrackerWithRecords, type FlatRecord, type SortKey,
scoreToStars, calcProgress,
type FlatRecord, type SortKey,
} from "../lib/trackingSync";
let trackers = $state<TrackerWithRecords[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let activeTrackerId = $state<number | "all">("all");
let statusFilter = $state<number | "all">("all");
let searchQuery = $state("");
let sortBy = $state<SortKey>("title");
let updatingId = $state<number | null>(null);
let syncingId = $state<number | null>(null);
let editingChapter = $state<number | null>(null);
let chapterDraft = $state(0);
let confirmUnbind = $state<FlatRecord | null>(null);
let updatingId = $state<number | null>(null);
let syncingId = $state<number | null>(null);
let editingChapter = $state<number | null>(null);
let chapterDraft = $state(0);
let confirmUnbind = $state<FlatRecord | null>(null);
async function load() {
loading = true; error = null;
try {
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
trackers = res.trackers.nodes;
} catch (e: any) {
error = e?.message ?? "Failed to load tracking data";
} finally { loading = false; }
}
$effect(() => {
if (trackingState.allTrackers.length === 0 && !trackingState.loadingAll) {
trackingState.loadAll();
}
});
$effect(() => { load(); });
const loggedIn = $derived(trackers.filter((t) => t.isLoggedIn));
const allRecords = $derived(flattenRecords(trackers));
const loggedIn = $derived(trackingState.allTrackers.filter(t => t.isLoggedIn));
const allRecords = $derived(flattenRecords(trackingState.allTrackers));
const totalCount = $derived(allRecords.length);
const statusOptions = $derived(
activeTrackerId === "all"
? dedupeStatuses(trackers)
: loggedIn.find((t) => t.id === activeTrackerId)?.statuses ?? []
? dedupeStatuses(trackingState.allTrackers)
: loggedIn.find(t => t.id === activeTrackerId)?.statuses ?? []
);
const filtered = $derived(
sortRecords(filterRecords(allRecords, activeTrackerId, statusFilter, searchQuery), sortBy)
);
function mangaIdForRecord(record: FlatRecord): number | null {
return record.manga?.id ?? null;
}
function prefsForManga(mangaId: number) {
return store.settings.mangaPrefs?.[mangaId] ?? {};
}
async function updateStatus(record: FlatRecord, status: number) {
const mangaId = mangaIdForRecord(record);
if (mangaId === null) return;
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
await trackingState.updateStatus(mangaId, record, status);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
async function updateScore(record: FlatRecord, scoreString: string) {
const mangaId = mangaIdForRecord(record);
if (mangaId === null) return;
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
await trackingState.updateScore(mangaId, record, scoreString);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
async function submitChapter(record: FlatRecord) {
const val = Math.max(0, chapterDraft);
editingChapter = null;
if (val === record.lastChapterRead) return;
const mangaId = mangaIdForRecord(record);
if (mangaId === null) return;
updatingId = record.id;
try {
await trackingState.updateChapterProgress(mangaId, record, val);
if (store.settings.trackerSyncBack && record.manga?.id) {
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(
GET_CHAPTERS, { mangaId: record.manga.id }
);
await trackingState.syncFromRemote(
mangaId,
{ ...record, lastChapterRead: val },
chapRes.chapters.nodes,
prefsForManga(mangaId),
);
}
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
async function syncRecord(record: FlatRecord) {
const mangaId = mangaIdForRecord(record);
if (mangaId === null) return;
syncingId = record.id;
try {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
trackers = patchTracker(trackers, record.trackerId, res.fetchTrack.trackRecord);
addToast({ kind: "success", title: "Synced from tracker" });
let chapters: Chapter[] = [];
if (store.settings.trackerSyncBack && record.manga?.id) {
const res = await gql<{ chapters: { nodes: Chapter[] } }>(
GET_CHAPTERS, { mangaId: record.manga.id }
);
chapters = res.chapters.nodes;
}
const { markedIds } = await trackingState.syncFromRemote(
mangaId, record, chapters, prefsForManga(mangaId)
);
const body = markedIds.length > 0
? `${markedIds.length} chapter${markedIds.length !== 1 ? "s" : ""} marked read`
: undefined;
addToast({ kind: "success", title: "Synced from tracker", body });
} catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally { syncingId = null; }
}
async function unbind(record: FlatRecord) {
const mangaId = mangaIdForRecord(record);
if (mangaId === null) return;
updatingId = record.id;
try {
await gql(UNBIND_TRACK, { recordId: record.id });
trackers = removeRecord(trackers, record.trackerId, record.id);
await trackingState.unbind(mangaId, record);
addToast({ kind: "info", title: `Unlinked from ${record.tracker.name}` });
} catch (e: any) {
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
} finally { updatingId = null; }
}
async function submitChapter(record: FlatRecord) {
const val = Math.max(0, chapterDraft);
editingChapter = null;
if (val === record.lastChapterRead) return;
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val });
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
function openManga(record: FlatRecord) {
if (!record.manga) return;
setActiveManga(record.manga as any);
@@ -127,12 +160,12 @@
<div class="header">
<div class="header-top">
<h1 class="heading">Tracking</h1>
<button class="icon-btn" onclick={load} disabled={loading} title="Refresh">
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} />
<button class="icon-btn" onclick={() => trackingState.loadAll()} disabled={trackingState.loadingAll} title="Refresh">
<ArrowsClockwise size={14} weight="light" class={trackingState.loadingAll ? "anim-spin" : ""} />
</button>
</div>
{#if !loading && loggedIn.length > 0}
{#if !trackingState.loadingAll && loggedIn.length > 0}
<div class="tracker-tabs">
<button
class="tracker-tab" class:active={activeTrackerId === "all"}
@@ -179,15 +212,15 @@
</div>
<div class="body">
{#if loading}
{#if trackingState.loadingAll}
<div class="state">
<CircleNotch size={18} weight="light" class="anim-spin" style="color:var(--text-faint)" />
</div>
{:else if error}
{:else if trackingState.error}
<div class="state">
<span class="state-error">{error}</span>
<button class="ghost-btn" onclick={load}>Retry</button>
<span class="state-error">{trackingState.error}</span>
<button class="ghost-btn" onclick={() => trackingState.loadAll()}>Retry</button>
</div>
{:else if loggedIn.length === 0}
@@ -234,7 +267,7 @@
{#if isSyncing}
<span class="cover-btn"><CircleNotch size={10} weight="light" class="anim-spin" /></span>
{:else}
<button class="cover-btn" title="Sync" onclick={() => syncRecord(record)}>
<button class="cover-btn" title="Sync from tracker" onclick={() => syncRecord(record)}>
<ArrowsClockwise size={10} weight="light" />
</button>
{/if}
@@ -664,4 +697,4 @@
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
</style>
</style>
+22 -33
View File
@@ -1,6 +1,5 @@
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";
@@ -115,7 +114,6 @@ export function removeRecord(
}
export interface SyncBackOptions {
threshold: number | null;
respectScanlatorFilter: boolean;
chapterPrefs: ChapterDisplayPrefs;
}
@@ -125,45 +123,36 @@ export async function syncBackFromTracker(
chapters: Chapter[],
opts: SyncBackOptions,
gqlFn: (query: string, vars: Record<string, unknown>) => Promise<unknown>,
): Promise<number[]> {
const eligible = opts.respectScanlatorFilter
? buildChapterList(chapters, opts.chapterPrefs)
: chapters;
): Promise<{ markedRead: number[]; markedUnread: number[] }> {
const eligible = buildChapterList(
opts.respectScanlatorFilter ? buildChapterList(chapters, opts.chapterPrefs) : chapters,
{ ...opts.chapterPrefs, sortDir: "asc" },
);
const toMark: number[] = [];
const toMarkRead: number[] = [];
const toMarkUnread: 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;
const remote = record.lastChapterRead;
if (!remote || remote <= 0) continue;
let ceiling: number;
const position = Math.round(remote);
const below = eligible.slice(0, position);
const above = eligible.slice(position);
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));
toMarkRead.push(...below.filter(c => !c.isRead).map(c => c.id));
toMarkUnread.push(...above.filter(c => c.isRead).map(c => c.id));
}
const ids = [...new Set(toMark)];
if (ids.length > 0) {
await gqlFn(MARK_CHAPTERS_READ, { ids, isRead: true });
const readIds = [...new Set(toMarkRead)];
const unreadIds = [...new Set(toMarkUnread)];
if (readIds.length > 0) {
await gqlFn(MARK_CHAPTERS_READ, { ids: readIds, isRead: true });
}
if (unreadIds.length > 0) {
await gqlFn(MARK_CHAPTERS_READ, { ids: unreadIds, isRead: false });
}
return ids;
return { markedRead: readIds, markedUnread: unreadIds };
}
@@ -1,89 +1,300 @@
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";
import { gql } from "@api/client";
import { GET_MANGA_TRACK_RECORDS, GET_ALL_TRACKER_RECORDS } from "@api/queries/tracking";
import { GET_CHAPTERS } from "@api/queries/chapters";
import { UPDATE_TRACK, FETCH_TRACK, UNBIND_TRACK } from "@api/mutations/tracking";
import { MARK_CHAPTERS_READ } from "@api/mutations/chapters";
import { buildChapterList, type ChapterDisplayPrefs } from "@features/series/lib/chapterList";
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
import { store } from "@store/state.svelte";
import type { TrackRecord, Tracker } from "@types/index";
import type { Chapter } from "@types/index";
import type { TrackerWithRecords } from "@features/tracking/lib/trackingSync";
const BOOT_SYNC_RATE_MS = 400;
type RecordMap = Map<number, TrackRecord[]>;
type MangaBucket = { mangaId: number; records: TrackRecord[] };
class TrackingState {
records: TrackRecord[] = $state([]);
mangaId: number | null = $state(null);
loading: boolean = $state(false);
error: string | null = $state(null);
private byManga: RecordMap = $state(new Map());
allTrackers: TrackerWithRecords[] = $state([]);
loadingAll: boolean = $state(false);
loadingFor: Set<number> = $state(new Set());
error: string | null = $state(null);
recordsFor(mangaId: number): TrackRecord[] {
return this.byManga.get(mangaId) ?? [];
}
private setFor(mangaId: number, records: TrackRecord[]) {
const next = new Map(this.byManga);
next.set(mangaId, records);
this.byManga = next;
}
private patchFor(mangaId: number, updated: Partial<TrackRecord> & { id: number }) {
const records = this.recordsFor(mangaId).map(r =>
r.id === updated.id ? { ...r, ...updated } : r
);
this.setFor(mangaId, records);
this.allTrackers = this.allTrackers.map(t => ({
...t,
trackRecords: {
nodes: t.trackRecords.nodes.map(r =>
r.id === updated.id ? { ...r, ...updated } : r
),
},
}));
}
async loadForManga(mangaId: number) {
if (this.loadingFor.has(mangaId)) return;
const existing = this.byManga.get(mangaId);
if (existing && existing.length > 0) return;
const next = new Set(this.loadingFor);
next.add(mangaId);
this.loadingFor = next;
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 }
GET_MANGA_TRACK_RECORDS, { mangaId }
);
if (this.mangaId !== id) return;
this.records = res.manga.trackRecords.nodes;
this.setFor(mangaId, res.manga.trackRecords.nodes);
} catch (e: any) {
this.error = e?.message ?? "Failed to load tracking";
} finally {
this.loading = false;
const s = new Set(this.loadingFor);
s.delete(mangaId);
this.loadingFor = s;
}
}
async refresh() {
if (this.mangaId === null) return;
const id = this.mangaId;
this.loading = true;
async loadAll() {
this.loadingAll = 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;
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
this.allTrackers = res.trackers.nodes;
for (const tracker of res.trackers.nodes.filter(t => t.isLoggedIn)) {
for (const record of tracker.trackRecords.nodes) {
if (!record.manga?.id) continue;
const mangaId = record.manga.id;
const existing = this.byManga.get(mangaId) ?? [];
const merged = [...existing.filter(r => r.id !== record.id), record];
this.setFor(mangaId, merged);
}
}
} catch (e: any) {
this.error = e?.message ?? "Failed to refresh tracking";
this.error = e?.message ?? "Failed to load tracking";
} finally {
this.loading = false;
this.loadingAll = false;
}
}
patchRecord(updated: Partial<TrackRecord> & { id: number }) {
this.records = this.records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
async updateStatus(mangaId: number, record: TrackRecord, status: number): Promise<TrackRecord> {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, status }
);
this.patchFor(mangaId, res.updateTrack.trackRecord);
return res.updateTrack.trackRecord;
}
removeRecord(id: number) {
this.records = this.records.filter(r => r.id !== id);
async updateScore(mangaId: number, record: TrackRecord, scoreString: string): Promise<TrackRecord> {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, scoreString }
);
this.patchFor(mangaId, res.updateTrack.trackRecord);
return res.updateTrack.trackRecord;
}
clear() {
this.records = [];
this.mangaId = null;
this.error = null;
async updateChapterProgress(mangaId: number, record: TrackRecord, lastChapterRead: number): Promise<TrackRecord> {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, lastChapterRead }
);
this.patchFor(mangaId, res.updateTrack.trackRecord);
return res.updateTrack.trackRecord;
}
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 unbind(mangaId: number, record: TrackRecord) {
await gql(UNBIND_TRACK, { recordId: record.id });
this.setFor(mangaId, this.recordsFor(mangaId).filter(r => r.id !== record.id));
this.allTrackers = this.allTrackers.map(t => ({
...t,
trackRecords: { nodes: t.trackRecords.nodes.filter(r => r.id !== record.id) },
}));
}
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);
async syncFromRemote(
mangaId: number,
record: TrackRecord,
chapters: Chapter[],
prefs: ChapterDisplayPrefs,
): Promise<{ fresh: TrackRecord; markedIds: number[] }> {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
FETCH_TRACK, { recordId: record.id }
);
const fresh = res.fetchTrack.trackRecord;
this.patchFor(mangaId, fresh);
const { markedRead } = await this._applyRemoteProgress(fresh, chapters, prefs);
return { fresh, markedIds: markedRead };
}
private async _applyRemoteProgress(
record: TrackRecord,
chapters: Chapter[],
prefs: ChapterDisplayPrefs,
): Promise<{ markedRead: number[]; markedUnread: number[] }> {
if (!store.settings.trackerSyncBack) return { markedRead: [], markedUnread: [] };
return syncBackFromTracker(
[record],
chapters,
{
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
chapterPrefs: prefs,
},
(query, vars) => gql(query, vars),
);
}
async updateFromRead(
mangaId: number,
chapter: Chapter,
chapterList: Chapter[],
prefs: ChapterDisplayPrefs,
) {
const filtered = buildChapterList(chapterList, { ...prefs, sortDir: "asc" });
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) {
const position = idx + 1;
const records = this.recordsFor(mangaId);
for (const record of records) {
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, lastChapterRead: position }
const completedValue = this._completedStatusFor(record.trackerId);
const isCompleted = completedValue !== null && record.status === completedValue;
const readingValue = this._readingStatusFor(record.trackerId);
const belowMax = record.totalChapters > 0 && (record.lastChapterRead ?? 0) < record.totalChapters;
if ((isCompleted || belowMax) && readingValue !== null && position > (record.lastChapterRead ?? 0)) {
await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, lastChapterRead: position, status: readingValue }
).then(res => this.patchFor(mangaId, res.updateTrack.trackRecord));
} else if (!isCompleted && position > (record.lastChapterRead ?? 0)) {
await this.updateChapterProgress(mangaId, record, position);
}
} catch {}
}
}
async updateFromUnread(
mangaId: number,
chapterList: Chapter[],
prefs: ChapterDisplayPrefs,
) {
const filtered = buildChapterList(chapterList, { ...prefs, sortDir: "asc" });
const lastRead = [...filtered].reverse().find(c => c.isRead);
const position = lastRead ? filtered.findIndex(c => c.id === lastRead.id) + 1 : 0;
const records = this.recordsFor(mangaId);
for (const record of records.filter(r => (r.lastChapterRead ?? 0) > position)) {
try {
const completedValue = this._completedStatusFor(record.trackerId);
const isCompleted = completedValue !== null && record.status === completedValue;
const belowMax = record.totalChapters > 0 && position < record.totalChapters;
const readingValue = this._readingStatusFor(record.trackerId);
if ((isCompleted || belowMax) && readingValue !== null) {
await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
UPDATE_TRACK, { recordId: record.id, lastChapterRead: position, status: readingValue }
).then(res => this.patchFor(mangaId, res.updateTrack.trackRecord));
} else {
await this.updateChapterProgress(mangaId, record, position);
}
} catch {}
}
}
clear(mangaId: number) {
const next = new Map(this.byManga);
next.delete(mangaId);
this.byManga = next;
}
private _statusesFor(trackerId: number): { value: number; name: string }[] {
return this.allTrackers.find(t => t.id === trackerId)?.statuses ?? [];
}
private _completedStatusFor(trackerId: number): number | null {
const s = this._statusesFor(trackerId).find(s => s.name.toLowerCase() === "completed");
return s?.value ?? null;
}
private _readingStatusFor(trackerId: number): number | null {
const s = this._statusesFor(trackerId).find(s => s.name.toLowerCase() === "reading");
return s?.value ?? null;
}
async bootSync() {
if (!store.settings.trackerSyncBack) return;
if (this.allTrackers.length === 0) await this.loadAll();
const buckets = new Map<number, MangaBucket>();
for (const tracker of this.allTrackers.filter(t => t.isLoggedIn)) {
const completedValue = this._completedStatusFor(tracker.id);
for (const record of tracker.trackRecords.nodes) {
const mangaId = record.manga?.id;
if (!mangaId) continue;
if (completedValue !== null && record.status === completedValue) continue;
const bucket = buckets.get(mangaId) ?? { mangaId, records: [] };
bucket.records.push(record);
buckets.set(mangaId, bucket);
}
}
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
for (const { mangaId, records } of buckets.values()) {
const prefs = { ...(store.settings.mangaPrefs?.[mangaId] ?? {}) } as ChapterDisplayPrefs;
let chapters: Chapter[];
try {
const res = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
chapters = res.chapters.nodes;
} catch {
continue;
}
const freshRecords: TrackRecord[] = [];
for (const record of records) {
await delay(BOOT_SYNC_RATE_MS);
try {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
const fresh = res.fetchTrack.trackRecord;
this.patchFor(mangaId, fresh);
freshRecords.push(fresh);
} catch {
freshRecords.push(record);
}
}
try {
await syncBackFromTracker(
freshRecords,
chapters,
{
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
chapterPrefs: prefs,
},
(query, vars) => gql(query, vars),
);
this.patchRecord(res.updateTrack.trackRecord);
} catch {}
}
}