mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Port over Reader & Tracking
This commit is contained in:
@@ -39,4 +39,8 @@ export function recordRead(entry: HistoryEntry) {
|
||||
const dateStr = new Date(entry.readAt).toISOString().slice(0, 10);
|
||||
homeState.dailyReadCounts[dateStr] = (homeState.dailyReadCounts[dateStr] ?? 0) + 1;
|
||||
homeState.stats.totalChaptersRead++;
|
||||
}
|
||||
|
||||
export function clearHistory() {
|
||||
homeState.history = [];
|
||||
}
|
||||
+215
-34
@@ -1,44 +1,225 @@
|
||||
import type { Manga, Chapter } from "$lib/types";
|
||||
import type { Page } from "$lib/server-adapters/types";
|
||||
import type { Manga, Chapter } from "$lib/types";
|
||||
import type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
|
||||
import type { MangaPrefs, ReaderSettings, ReaderPreset } from "$lib/types/settings";
|
||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
export type ReadMode = "single" | "strip";
|
||||
export type FitMode = "width" | "height" | "original";
|
||||
export type ReadDirection = "ltr" | "rtl";
|
||||
export const PAGE_STYLES = ["single", "fade", "double", "longstrip"] as const;
|
||||
export type PageStyle = typeof PAGE_STYLES[number];
|
||||
|
||||
export const readerState = $state({
|
||||
manga: null as Manga | null,
|
||||
chapter: null as Chapter | null,
|
||||
chapters: [] as Chapter[],
|
||||
export const MARKER_COLORS: MarkerColor[] = ["yellow", "red", "blue", "green", "purple"];
|
||||
export const MARKER_COLOR_HEX: Record<MarkerColor, string> = {
|
||||
yellow: "#c4a94a",
|
||||
red: "#c47a7a",
|
||||
blue: "#7a9ec4",
|
||||
green: "#7aab7a",
|
||||
purple: "#a07ac4",
|
||||
};
|
||||
|
||||
pages: [] as Page[],
|
||||
pagesLoading: false,
|
||||
pagesError: null as string | null,
|
||||
export const ZOOM_STEP = 0.05;
|
||||
export const ZOOM_MIN = 0.1;
|
||||
export const ZOOM_MAX = 1.0;
|
||||
|
||||
currentPage: 0,
|
||||
mode: "single" as ReadMode,
|
||||
fit: "width" as FitMode,
|
||||
direction: "ltr" as ReadDirection,
|
||||
zoom: 1,
|
||||
export type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
|
||||
export type { MangaPrefs, ReaderSettings, ReaderPreset } from "$lib/types/settings";
|
||||
|
||||
showControls: false,
|
||||
showSettings: false,
|
||||
fullscreen: false,
|
||||
});
|
||||
|
||||
export function currentPageData() {
|
||||
return readerState.pages[readerState.currentPage] ?? null;
|
||||
export interface StripChapter {
|
||||
chapterId: number;
|
||||
chapterName: string;
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
export function progress() {
|
||||
return readerState.pages.length > 0
|
||||
? (readerState.currentPage + 1) / readerState.pages.length
|
||||
: 0;
|
||||
class ReaderState {
|
||||
activeManga = $state<Manga | null>(null);
|
||||
activeChapter = $state<Chapter | null>(null);
|
||||
activeChapterList = $state<Chapter[]>([]);
|
||||
pageUrls = $state<string[]>([]);
|
||||
pageNumber = $state(1);
|
||||
bookmarks = $state<BookmarkEntry[]>([]);
|
||||
markers = $state<MarkerEntry[]>([]);
|
||||
|
||||
loading = $state(true);
|
||||
error = $state<string | null>(null);
|
||||
pageReady = $state(false);
|
||||
pageGroups = $state<number[][]>([]);
|
||||
stripChapters = $state<StripChapter[]>([]);
|
||||
visibleChapterId = $state<number | null>(null);
|
||||
|
||||
uiVisible = $state(true);
|
||||
isFullscreen = $state(false);
|
||||
|
||||
dlOpen = $state(false);
|
||||
zoomOpen = $state(false);
|
||||
winOpen = $state(false);
|
||||
presetOpen = $state(false);
|
||||
nextN = $state(5);
|
||||
dlBusy = $state(false);
|
||||
|
||||
fadingOut = $state(false);
|
||||
sliderDragging = $state(false);
|
||||
sliderHover = $state(false);
|
||||
|
||||
resumePage = $state(0);
|
||||
resumeDismissed = $state(false);
|
||||
resumeFading = $state(false);
|
||||
resumeVisible = $state(false);
|
||||
stripResumeReady = $state(false);
|
||||
|
||||
markerOpen = $state(false);
|
||||
markerNote = $state("");
|
||||
markerColor = $state<MarkerColor>("yellow");
|
||||
markerEditId = $state("");
|
||||
|
||||
inspectScale = $state(1);
|
||||
inspectPanX = $state(0);
|
||||
inspectPanY = $state(0);
|
||||
|
||||
containerWidth = $state(0);
|
||||
|
||||
get settings() { return settingsState.settings; }
|
||||
|
||||
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
|
||||
this.activeChapter = chapter;
|
||||
this.activeChapterList = chapterList;
|
||||
if (manga !== undefined) this.activeManga = manga;
|
||||
goto(`/reader/${this.activeManga!.id}/${chapter.id}`);
|
||||
}
|
||||
|
||||
closeReader() {
|
||||
this.activeChapter = null;
|
||||
this.activeChapterList = [];
|
||||
history.back();
|
||||
}
|
||||
|
||||
resetForChapter() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
this.pageReady = false;
|
||||
this.pageGroups = [];
|
||||
this.stripChapters = [];
|
||||
this.visibleChapterId = null;
|
||||
this.fadingOut = false;
|
||||
this.markerOpen = false;
|
||||
}
|
||||
|
||||
resetResume() {
|
||||
this.resumePage = 0;
|
||||
this.resumeDismissed = false;
|
||||
this.resumeVisible = false;
|
||||
this.stripResumeReady = false;
|
||||
}
|
||||
|
||||
resetInspect() {
|
||||
this.inspectScale = 1;
|
||||
this.inspectPanX = 0;
|
||||
this.inspectPanY = 0;
|
||||
}
|
||||
|
||||
closeAllPopovers(): boolean {
|
||||
if (this.markerOpen) { this.markerOpen = false; return true; }
|
||||
if (this.zoomOpen) { this.zoomOpen = false; return true; }
|
||||
if (this.dlOpen) { this.dlOpen = false; return true; }
|
||||
if (this.winOpen) { this.winOpen = false; return true; }
|
||||
if (this.presetOpen) { this.presetOpen = false; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
openMarker(editId: string, note: string, color: MarkerColor) {
|
||||
this.markerEditId = editId;
|
||||
this.markerNote = note;
|
||||
this.markerColor = color;
|
||||
this.markerOpen = true;
|
||||
this.zoomOpen = false;
|
||||
this.dlOpen = false;
|
||||
this.winOpen = false;
|
||||
}
|
||||
|
||||
clearMarkerPopover() {
|
||||
this.markerOpen = false;
|
||||
this.markerNote = "";
|
||||
this.markerEditId = "";
|
||||
}
|
||||
|
||||
addBookmark(entry: Omit<BookmarkEntry, "savedAt">) {
|
||||
this.bookmarks = [
|
||||
{ ...entry, savedAt: Date.now() },
|
||||
...this.bookmarks.filter(b => b.chapterId !== entry.chapterId),
|
||||
].slice(0, 200);
|
||||
}
|
||||
|
||||
removeBookmark(chapterId: number) {
|
||||
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
|
||||
}
|
||||
|
||||
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }];
|
||||
return id;
|
||||
}
|
||||
|
||||
updateMarker(id: string, patch: Partial<Pick<MarkerEntry, "note" | "color">>) {
|
||||
this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch } : m);
|
||||
}
|
||||
|
||||
removeMarker(id: string) {
|
||||
this.markers = this.markers.filter(m => m.id !== id);
|
||||
}
|
||||
|
||||
getMarkersForPage(chapterId: number, page: number) {
|
||||
return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page);
|
||||
}
|
||||
|
||||
getMarkersForChapter(chapterId: number) {
|
||||
return this.markers.filter(m => m.chapterId === chapterId);
|
||||
}
|
||||
|
||||
getMangaPrefs(mangaId: number): MangaPrefs {
|
||||
const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {};
|
||||
return { ...DEFAULT_MANGA_PREFS, ...prefs };
|
||||
}
|
||||
|
||||
setMangaReaderSettings(mangaId: number, patch: Partial<ReaderSettings>) {
|
||||
updateSettings({
|
||||
mangaReaderSettings: {
|
||||
...settingsState.settings.mangaReaderSettings,
|
||||
[mangaId]: { ...(settingsState.settings.mangaReaderSettings?.[mangaId] ?? {}), ...patch } as ReaderSettings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clearMangaReaderSettings(mangaId: number) {
|
||||
const next = { ...settingsState.settings.mangaReaderSettings };
|
||||
delete next[mangaId];
|
||||
updateSettings({ mangaReaderSettings: next });
|
||||
}
|
||||
|
||||
saveReaderPreset(name: string, settings: ReaderSettings) {
|
||||
const preset: ReaderPreset = { id: Math.random().toString(36).slice(2), name, settings };
|
||||
updateSettings({ readerPresets: [...(settingsState.settings.readerPresets ?? []), preset] });
|
||||
}
|
||||
|
||||
updateReaderPreset(id: string, patch: Partial<Pick<ReaderPreset, "name" | "settings">>) {
|
||||
updateSettings({
|
||||
readerPresets: (settingsState.settings.readerPresets ?? []).map(p =>
|
||||
p.id === id ? { ...p, ...patch } : p
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
deleteReaderPreset(id: string) {
|
||||
updateSettings({ readerPresets: (settingsState.settings.readerPresets ?? []).filter(p => p.id !== id) });
|
||||
}
|
||||
}
|
||||
|
||||
export function hasPrev() {
|
||||
return readerState.currentPage > 0;
|
||||
}
|
||||
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||
autoDownload: false, downloadAhead: 0, deleteOnRead: false,
|
||||
deleteDelayHours: 0, maxKeepChapters: 0, pauseUpdates: false,
|
||||
refreshInterval: "global", preferredScanlator: "", scanlatorFilter: [],
|
||||
scanlatorBlacklist: [], scanlatorForce: false, autoDownloadScanlators: [],
|
||||
sortMode: "source", sortDir: "asc", coverUrl: "",
|
||||
};
|
||||
|
||||
export function hasNext() {
|
||||
return readerState.currentPage < readerState.pages.length - 1;
|
||||
}
|
||||
export const readerState = new ReaderState();
|
||||
|
||||
export function openReader(ch: Chapter, list: Chapter[], manga?: Manga | null) { readerState.openReader(ch, list, manga); }
|
||||
export function closeReader() { readerState.closeReader(); }
|
||||
+242
-100
@@ -1,28 +1,34 @@
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { settingsState } from '$lib/state/settings.svelte'
|
||||
import { buildChapterList } from '$lib/components/series/lib/chapterList'
|
||||
import type { Tracker, TrackRecord } from '$lib/types'
|
||||
import type { Chapter } from '$lib/types'
|
||||
import type { ChapterDisplayPrefs } from '$lib/components/series/lib/chapterList'
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { settingsState } from '$lib/state/settings.svelte'
|
||||
import { buildChapterList, type ChapterDisplayPrefs } from '$lib/components/series/lib/chapterList'
|
||||
import { syncBackFromTracker } from '$lib/components/tracking/lib/trackingSync'
|
||||
import type { Tracker, TrackRecord } from '$lib/types'
|
||||
import type { Chapter } from '$lib/types'
|
||||
import type { TrackerWithRecords } from '$lib/components/tracking/lib/trackingSync'
|
||||
|
||||
type RecordMap = Map<number, TrackRecord[]>
|
||||
const BOOT_SYNC_RATE_MS = 400
|
||||
|
||||
class TrackingStore {
|
||||
type RecordMap = Map<number, TrackRecord[]>
|
||||
type MangaBucket = { mangaId: number; records: TrackRecord[] }
|
||||
|
||||
class TrackingState {
|
||||
private byManga: RecordMap = $state(new Map())
|
||||
|
||||
allTrackers: TrackerWithRecords[] = $state([])
|
||||
loadingAll: boolean = $state(false)
|
||||
loadingFor: Set<number> = $state(new Set())
|
||||
error: string | null = $state(null)
|
||||
|
||||
// Legacy flat fields kept for request-manager/tracking.ts compatibility
|
||||
trackers: Tracker[] = $state([])
|
||||
loading: boolean = $state(false)
|
||||
error: string | null = $state(null)
|
||||
syncing: boolean = $state(false)
|
||||
|
||||
recordsLoading: boolean = $state(false)
|
||||
recordsError: string | null = $state(null)
|
||||
searchResults: unknown[] = $state([])
|
||||
searchLoading: boolean = $state(false)
|
||||
searchError: string | null = $state(null)
|
||||
|
||||
private loadingFor = new Set<number>()
|
||||
|
||||
recordsFor(mangaId: number): TrackRecord[] {
|
||||
return this.byManga.get(mangaId) ?? []
|
||||
}
|
||||
@@ -33,84 +39,167 @@ class TrackingStore {
|
||||
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
|
||||
),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Per-manga load ──────────────────────────────────────────────────────────
|
||||
|
||||
async loadForManga(mangaId: number) {
|
||||
if (this.loadingFor.has(mangaId)) return
|
||||
const existing = this.byManga.get(mangaId)
|
||||
if (existing && existing.length > 0) return
|
||||
|
||||
this.loadingFor.add(mangaId)
|
||||
const next = new Set(this.loadingFor)
|
||||
next.add(mangaId)
|
||||
this.loadingFor = next
|
||||
|
||||
try {
|
||||
const records = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[]
|
||||
this.setFor(mangaId, records)
|
||||
} catch (e) {
|
||||
// silently ignore — tracking is non-critical
|
||||
} catch (e: unknown) {
|
||||
this.error = e instanceof Error ? e.message : 'Failed to load tracking'
|
||||
} finally {
|
||||
this.loadingFor.delete(mangaId)
|
||||
const s = new Set(this.loadingFor)
|
||||
s.delete(mangaId)
|
||||
this.loadingFor = s
|
||||
}
|
||||
}
|
||||
|
||||
// ── Global load (tracking page) ─────────────────────────────────────────────
|
||||
|
||||
async loadAll() {
|
||||
this.loadingAll = true
|
||||
this.error = null
|
||||
try {
|
||||
const trackers = await getAdapter().getAllTrackerRecords() as TrackerWithRecords[]
|
||||
this.allTrackers = trackers
|
||||
this.trackers = trackers // keep flat field in sync
|
||||
|
||||
for (const tracker of trackers.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: unknown) {
|
||||
this.error = e instanceof Error ? e.message : 'Failed to load tracking'
|
||||
} finally {
|
||||
this.loadingAll = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Field updates ───────────────────────────────────────────────────────────
|
||||
|
||||
async updateStatus(mangaId: number, record: TrackRecord, status: number): Promise<TrackRecord> {
|
||||
const fresh = await getAdapter().updateTrackRecord(String(record.id), { status })
|
||||
this.patchFor(mangaId, fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
async updateScore(mangaId: number, record: TrackRecord, scoreString: string): Promise<TrackRecord> {
|
||||
const score = parseFloat(scoreString)
|
||||
const fresh = await getAdapter().updateTrackRecord(String(record.id), { score: isNaN(score) ? undefined : score })
|
||||
this.patchFor(mangaId, fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
async updateChapterProgress(mangaId: number, record: TrackRecord, lastChapterRead: number): Promise<TrackRecord> {
|
||||
const fresh = await getAdapter().updateTrackRecord(String(record.id), { lastChapterRead })
|
||||
this.patchFor(mangaId, fresh)
|
||||
return fresh
|
||||
}
|
||||
|
||||
async unbind(mangaId: number, record: TrackRecord) {
|
||||
await getAdapter().unlinkTracker(String(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) },
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Remote sync ─────────────────────────────────────────────────────────────
|
||||
|
||||
async syncFromRemote(
|
||||
mangaId: number,
|
||||
record: TrackRecord,
|
||||
chapters: Chapter[],
|
||||
prefs: ChapterDisplayPrefs,
|
||||
): Promise<{ markedIds: number[] }> {
|
||||
if (!settingsState.settings.trackerSyncBack) return { markedIds: [] }
|
||||
): Promise<{ fresh: TrackRecord; markedIds: number[] }> {
|
||||
const fresh = await getAdapter().fetchTrackRecord(String(record.id)) as TrackRecord
|
||||
this.patchFor(mangaId, fresh)
|
||||
|
||||
try {
|
||||
await getAdapter().syncTracking(String(mangaId))
|
||||
const fresh = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[]
|
||||
this.setFor(mangaId, fresh)
|
||||
|
||||
const freshRecord = fresh.find(r => r.id === record.id)
|
||||
if (!freshRecord) return { markedIds: [] }
|
||||
|
||||
const markedIds = this._applyRemoteProgress(freshRecord, chapters, prefs)
|
||||
return { markedIds }
|
||||
} catch {
|
||||
return { markedIds: [] }
|
||||
}
|
||||
const markedIds = await this._applyRemoteProgress(fresh, chapters, prefs)
|
||||
return { fresh, markedIds }
|
||||
}
|
||||
|
||||
private _applyRemoteProgress(
|
||||
private async _applyRemoteProgress(
|
||||
record: TrackRecord,
|
||||
chapters: Chapter[],
|
||||
prefs: ChapterDisplayPrefs,
|
||||
): number[] {
|
||||
const lastRead = record.lastChapterRead ?? 0
|
||||
if (lastRead <= 0) return []
|
||||
): Promise<number[]> {
|
||||
if (!settingsState.settings.trackerSyncBack) return []
|
||||
|
||||
const threshold = settingsState.settings.trackerSyncBackThreshold ?? null
|
||||
const respectScanlator = settingsState.settings.trackerRespectScanlatorFilter ?? true
|
||||
const activeScanlators: string[] | null =
|
||||
respectScanlator && (prefs as any).scanlatorFilter?.length
|
||||
? (prefs as any).scanlatorFilter
|
||||
: null
|
||||
|
||||
return chapters
|
||||
.filter(ch => {
|
||||
if (ch.read) return false
|
||||
if (activeScanlators && ch.scanlator && !activeScanlators.includes(ch.scanlator)) return false
|
||||
return threshold !== null
|
||||
? ch.chapterNumber <= lastRead && ch.chapterNumber >= lastRead - threshold
|
||||
: ch.chapterNumber <= lastRead
|
||||
})
|
||||
.map(ch => ch.id)
|
||||
return syncBackFromTracker(
|
||||
[record],
|
||||
chapters,
|
||||
{
|
||||
threshold: settingsState.settings.trackerSyncBackThreshold ?? null,
|
||||
respectScanlatorFilter: settingsState.settings.trackerRespectScanlatorFilter ?? true,
|
||||
chapterPrefs: prefs,
|
||||
},
|
||||
(ids, read) => getAdapter().markChaptersRead(ids, read),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Read/unread sync ────────────────────────────────────────────────────────
|
||||
|
||||
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 records = this.recordsFor(mangaId)
|
||||
if (!records.length) return
|
||||
try {
|
||||
await getAdapter().syncTracking(String(mangaId))
|
||||
const fresh = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[]
|
||||
this.setFor(mangaId, fresh)
|
||||
} catch {}
|
||||
for (const record of records) {
|
||||
try {
|
||||
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)) {
|
||||
const fresh = await getAdapter().updateTrackRecord(String(record.id), {
|
||||
lastChapterRead: position,
|
||||
status: readingValue,
|
||||
})
|
||||
this.patchFor(mangaId, fresh)
|
||||
} else if (!isCompleted && position > (record.lastChapterRead ?? 0)) {
|
||||
await this.updateChapterProgress(mangaId, record, position)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async updateFromUnread(
|
||||
@@ -118,13 +207,88 @@ class TrackingStore {
|
||||
chapterList: Chapter[],
|
||||
prefs: ChapterDisplayPrefs,
|
||||
) {
|
||||
const filtered = buildChapterList(chapterList, { ...prefs, sortDir: 'asc' })
|
||||
const lastRead = [...filtered].reverse().find((c) => c.read)
|
||||
const position = lastRead ? filtered.findIndex((c) => c.id === lastRead.id) + 1 : 0
|
||||
|
||||
const records = this.recordsFor(mangaId)
|
||||
if (!records.length) return
|
||||
try {
|
||||
await getAdapter().syncTracking(String(mangaId))
|
||||
const fresh = await getAdapter().getMangaTrackRecords(String(mangaId)) as TrackRecord[]
|
||||
this.setFor(mangaId, fresh)
|
||||
} catch {}
|
||||
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) {
|
||||
const fresh = await getAdapter().updateTrackRecord(String(record.id), {
|
||||
lastChapterRead: position,
|
||||
status: readingValue,
|
||||
})
|
||||
this.patchFor(mangaId, fresh)
|
||||
} else {
|
||||
await this.updateChapterProgress(mangaId, record, position)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Boot sync ───────────────────────────────────────────────────────────────
|
||||
|
||||
async bootSync() {
|
||||
if (!settingsState.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 = { ...(settingsState.settings.mangaPrefs?.[mangaId] ?? {}) } as ChapterDisplayPrefs
|
||||
|
||||
let chapters: Chapter[]
|
||||
try {
|
||||
chapters = await getAdapter().getChapters(String(mangaId))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const freshRecords: TrackRecord[] = []
|
||||
for (const record of records) {
|
||||
await delay(BOOT_SYNC_RATE_MS)
|
||||
try {
|
||||
const fresh = await getAdapter().fetchTrackRecord(String(record.id)) as TrackRecord
|
||||
this.patchFor(mangaId, fresh)
|
||||
freshRecords.push(fresh)
|
||||
} catch {
|
||||
freshRecords.push(record)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await syncBackFromTracker(
|
||||
freshRecords,
|
||||
chapters,
|
||||
{
|
||||
threshold: settingsState.settings.trackerSyncBackThreshold ?? null,
|
||||
respectScanlatorFilter: settingsState.settings.trackerRespectScanlatorFilter ?? true,
|
||||
chapterPrefs: prefs,
|
||||
},
|
||||
(ids, read) => getAdapter().markChaptersRead(ids, read),
|
||||
)
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
clear(mangaId: number) {
|
||||
@@ -132,44 +296,22 @@ class TrackingStore {
|
||||
next.delete(mangaId)
|
||||
this.byManga = next
|
||||
}
|
||||
}
|
||||
|
||||
export const trackingState = new TrackingStore()
|
||||
// ── Status helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
// Standalone export for components that run their own sync loop (e.g. TrackingSettings)
|
||||
export async function syncBackFromTracker(
|
||||
records: TrackRecord[],
|
||||
chapters: Chapter[],
|
||||
opts: {
|
||||
threshold: number | null
|
||||
respectScanlatorFilter: boolean
|
||||
chapterPrefs: Partial<any>
|
||||
},
|
||||
markChaptersRead: (ids: string[], read: boolean) => Promise<void>,
|
||||
): Promise<Chapter[]> {
|
||||
const marked: Chapter[] = []
|
||||
|
||||
const activeScanlators: string[] | null =
|
||||
opts.respectScanlatorFilter && opts.chapterPrefs.scanlatorFilter?.length
|
||||
? opts.chapterPrefs.scanlatorFilter
|
||||
: null
|
||||
|
||||
for (const record of records) {
|
||||
const lastRead = record.lastChapterRead ?? 0
|
||||
if (lastRead <= 0) continue
|
||||
|
||||
const toMark = chapters.filter(ch => {
|
||||
if (ch.read) return false
|
||||
if (activeScanlators && ch.scanlator && !activeScanlators.includes(ch.scanlator)) return false
|
||||
return opts.threshold !== null
|
||||
? ch.chapterNumber <= lastRead && ch.chapterNumber >= lastRead - opts.threshold
|
||||
: ch.chapterNumber <= lastRead
|
||||
})
|
||||
|
||||
if (!toMark.length) continue
|
||||
await markChaptersRead(toMark.map(ch => String(ch.id)), true)
|
||||
marked.push(...toMark)
|
||||
private _statusesFor(trackerId: number): { value: number; name: string }[] {
|
||||
return this.allTrackers.find((t) => t.id === trackerId)?.statuses ?? []
|
||||
}
|
||||
|
||||
return marked
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export const trackingState = new TrackingState()
|
||||
Reference in New Issue
Block a user