mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Dual-Sync Tracking (#52)
This commit is contained in:
@@ -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>
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user