mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Restructure Repository for SvelteKit
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" ry="112" fill="#091209"/>
|
||||
<g transform="translate(256,265) rotate(7) scale(0.098,-0.098) translate(-5000,-4800)" fill="#2d7a5f" stroke="none">
|
||||
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,5 @@
|
||||
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util";
|
||||
|
||||
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
|
||||
return (item) => predicates.every((p) => p(item));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface PaginationState {
|
||||
visible: number;
|
||||
}
|
||||
|
||||
export interface PaginationResult<T> {
|
||||
items: T[];
|
||||
hasMore: boolean;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export function createPaginator<T>(pageSize: number) {
|
||||
return {
|
||||
slice(all: T[], visible: number): PaginationResult<T> {
|
||||
return {
|
||||
items: all.slice(0, visible),
|
||||
hasMore: all.length > visible,
|
||||
remaining: Math.max(0, all.length - visible),
|
||||
};
|
||||
},
|
||||
|
||||
nextVisible(current: number): number {
|
||||
return current + pageSize;
|
||||
},
|
||||
|
||||
reset(): number {
|
||||
return pageSize;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface AsyncQueue<T> {
|
||||
enqueue(item: T): void;
|
||||
drain(): void;
|
||||
clear(): void;
|
||||
size(): number;
|
||||
}
|
||||
|
||||
export function createAsyncQueue<T>(
|
||||
worker: (item: T) => Promise<void>,
|
||||
concurrency = 1,
|
||||
): AsyncQueue<T> {
|
||||
const queue: T[] = [];
|
||||
let active = 0;
|
||||
|
||||
function next() {
|
||||
while (active < concurrency && queue.length > 0) {
|
||||
const item = queue.shift()!;
|
||||
active++;
|
||||
worker(item).finally(() => { active--; next(); });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enqueue(item) { queue.push(item); next(); },
|
||||
drain() { next(); },
|
||||
clear() { queue.length = 0; },
|
||||
size() { return queue.length; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface SearchResult<T> {
|
||||
item: T;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export function searchItems<T>(
|
||||
items: T[],
|
||||
query: string,
|
||||
getField: (item: T) => string,
|
||||
): T[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter(item => getField(item).toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
export function searchWithScore<T>(
|
||||
items: T[],
|
||||
query: string,
|
||||
getField: (item: T) => string,
|
||||
): SearchResult<T>[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items.map(item => ({ item, score: 0 }));
|
||||
|
||||
return items
|
||||
.map(item => {
|
||||
const field = getField(item).toLowerCase();
|
||||
if (!field.includes(q)) return null;
|
||||
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
|
||||
return { item, score };
|
||||
})
|
||||
.filter((r): r is SearchResult<T> => r !== null)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export type SortDir = "asc" | "desc";
|
||||
|
||||
export interface SortField<T> {
|
||||
key: string;
|
||||
comparator: (a: T, b: T, context?: Record<string, unknown>) => number;
|
||||
}
|
||||
|
||||
export interface SortConfig<T> {
|
||||
fields: SortField<T>[];
|
||||
defaultField: string;
|
||||
defaultDir: SortDir;
|
||||
}
|
||||
|
||||
export interface Sorter<T> {
|
||||
sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): T[];
|
||||
}
|
||||
|
||||
export function createSorter<T>(config: SortConfig<T>): Sorter<T> {
|
||||
const fieldMap = new Map(config.fields.map(f => [f.key, f]));
|
||||
|
||||
return {
|
||||
sort(items, field, dir, context) {
|
||||
const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField);
|
||||
if (!f) return [...items];
|
||||
const d = dir ?? config.defaultDir;
|
||||
return [...items].sort((a, b) => {
|
||||
const cmp = f.comparator(a, b, context);
|
||||
return d === "asc" ? cmp : -cmp;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
interface MemEntry<T> {
|
||||
value: T;
|
||||
expiresAt: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export class MemoryCache<T> {
|
||||
readonly #cap: number;
|
||||
readonly #ttl: number;
|
||||
readonly #map = new Map<string, MemEntry<T>>();
|
||||
|
||||
constructor(capacity: number, ttlMs: number) {
|
||||
this.#cap = capacity;
|
||||
this.#ttl = ttlMs;
|
||||
}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
const entry = this.#map.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return undefined; }
|
||||
this.#map.delete(key);
|
||||
this.#map.set(key, entry);
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: T): void {
|
||||
if (this.#map.has(key)) this.#map.delete(key);
|
||||
else if (this.#map.size >= this.#cap) this.#map.delete(this.#map.keys().next().value!);
|
||||
this.#map.set(key, { value, expiresAt: Date.now() + this.#ttl, key });
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
const entry = this.#map.get(key);
|
||||
if (!entry) return false;
|
||||
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
delete(key: string): void { this.#map.delete(key); }
|
||||
|
||||
clear(): void { this.#map.clear(); }
|
||||
|
||||
get size(): number { return this.#map.size; }
|
||||
}
|
||||
Vendored
+241
@@ -0,0 +1,241 @@
|
||||
interface Entry<T> {
|
||||
promise: Promise<T>;
|
||||
fetchedAt: number;
|
||||
fetcher?: () => Promise<T>;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, Entry<unknown>>();
|
||||
const subs = new Map<string, Set<() => void>>();
|
||||
const keyToGroups = new Map<string, Set<string>>();
|
||||
const groups = new Map<string, Set<string>>();
|
||||
|
||||
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
||||
|
||||
function notify(key: string) { subs.get(key)?.forEach(cb => cb()); }
|
||||
|
||||
function registerGroups(key: string, group?: string | string[]) {
|
||||
if (!group) return;
|
||||
for (const tag of Array.isArray(group) ? group : [group]) {
|
||||
if (!groups.has(tag)) groups.set(tag, new Set());
|
||||
groups.get(tag)!.add(key);
|
||||
if (!keyToGroups.has(key)) keyToGroups.set(key, new Set());
|
||||
keyToGroups.get(key)!.add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterKey(key: string) {
|
||||
const tags = keyToGroups.get(key);
|
||||
if (tags) {
|
||||
for (const tag of tags) groups.get(tag)?.delete(key);
|
||||
keyToGroups.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export const cache = {
|
||||
get<T>(key: string, fetcher: () => Promise<T>, ttl = DEFAULT_TTL_MS, group?: string | string[]): Promise<T> {
|
||||
const existing = store.get(key) as Entry<T> | undefined;
|
||||
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
|
||||
const promise = fetcher().catch(err => {
|
||||
if (err?.name !== "AbortError") store.delete(key);
|
||||
return Promise.reject(err);
|
||||
}) as Promise<T>;
|
||||
store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl });
|
||||
registerGroups(key, group);
|
||||
promise.then(() => notify(key)).catch(() => {});
|
||||
return promise;
|
||||
},
|
||||
|
||||
set<T>(key: string, value: T, group?: string | string[]) {
|
||||
const existing = store.get(key) as Entry<T> | undefined;
|
||||
store.set(key, {
|
||||
promise: Promise.resolve(value),
|
||||
fetchedAt: Date.now(),
|
||||
fetcher: existing?.fetcher,
|
||||
ttl: existing?.ttl,
|
||||
});
|
||||
registerGroups(key, group);
|
||||
notify(key);
|
||||
},
|
||||
|
||||
update<T>(key: string, fn: (prev: T) => T) {
|
||||
const existing = store.get(key) as Entry<T> | undefined;
|
||||
if (!existing) return;
|
||||
const next = existing.promise.then(fn);
|
||||
store.set(key, { ...existing, promise: next, fetchedAt: Date.now() });
|
||||
next.then(() => notify(key)).catch(() => {});
|
||||
},
|
||||
|
||||
refresh<T>(key: string): Promise<T> | undefined {
|
||||
const existing = store.get(key) as Entry<T> | undefined;
|
||||
if (!existing?.fetcher) return undefined;
|
||||
const promise = (existing.fetcher as () => Promise<T>)().catch(err => {
|
||||
if (err?.name !== "AbortError") store.delete(key);
|
||||
return Promise.reject(err);
|
||||
});
|
||||
store.set(key, { ...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now() });
|
||||
promise.then(() => notify(key)).catch(() => {});
|
||||
return promise;
|
||||
},
|
||||
|
||||
refreshGroup(tag: string): void {
|
||||
const keys = groups.get(tag);
|
||||
if (!keys) return;
|
||||
for (const key of [...keys]) {
|
||||
const existing = store.get(key);
|
||||
if (existing?.fetcher) {
|
||||
const promise = existing.fetcher().catch(err => {
|
||||
if (err?.name !== "AbortError") store.delete(key);
|
||||
return Promise.reject(err);
|
||||
});
|
||||
store.set(key, { ...existing, promise, fetchedAt: Date.now() });
|
||||
promise.then(() => notify(key)).catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
has(key: string): boolean { return store.has(key); },
|
||||
|
||||
ageOf(key: string): number | undefined {
|
||||
const e = store.get(key);
|
||||
return e ? Date.now() - e.fetchedAt : undefined;
|
||||
},
|
||||
|
||||
isStale(key: string): boolean {
|
||||
const e = store.get(key);
|
||||
if (!e) return true;
|
||||
return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS);
|
||||
},
|
||||
|
||||
clear(key: string) {
|
||||
unregisterKey(key);
|
||||
store.delete(key);
|
||||
notify(key);
|
||||
},
|
||||
|
||||
clearGroup(tag: string) {
|
||||
const keys = groups.get(tag);
|
||||
if (!keys) return;
|
||||
for (const key of [...keys]) {
|
||||
keyToGroups.get(key)?.delete(tag);
|
||||
if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key);
|
||||
store.delete(key);
|
||||
notify(key);
|
||||
}
|
||||
groups.delete(tag);
|
||||
},
|
||||
|
||||
clearAll() {
|
||||
const allKeys = [...store.keys()];
|
||||
store.clear();
|
||||
groups.clear();
|
||||
keyToGroups.clear();
|
||||
allKeys.forEach(notify);
|
||||
},
|
||||
|
||||
subscribe(key: string, cb: () => void): () => void {
|
||||
if (!subs.has(key)) subs.set(key, new Set());
|
||||
subs.get(key)!.add(cb);
|
||||
return () => subs.get(key)?.delete(cb);
|
||||
},
|
||||
};
|
||||
|
||||
export const CACHE_GROUPS = {
|
||||
LIBRARY: "g:library",
|
||||
SOURCES: "g:sources",
|
||||
} as const;
|
||||
|
||||
export const CACHE_KEYS = {
|
||||
LIBRARY: "library",
|
||||
RECENT_UPDATES: "recent_updates",
|
||||
ALL_MANGA: "all_manga_unfiltered",
|
||||
CATEGORIES: "categories",
|
||||
SEARCH: "search_all_manga",
|
||||
SOURCES: "sources",
|
||||
POPULAR: "popular",
|
||||
GENRE: (genre: string) => `genre:${genre}`,
|
||||
MANGA: (id: number) => `manga:${id}`,
|
||||
CHAPTERS: (id: number) => `chapters:${id}`,
|
||||
|
||||
sourceMangaPages(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): string {
|
||||
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
|
||||
return `pages:${sourceId}:${type}:${q}`;
|
||||
},
|
||||
|
||||
sourceMangaPage(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", page: number, query?: string | string[]): string {
|
||||
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
|
||||
return `page:${sourceId}:${type}:${page}:${q}`;
|
||||
},
|
||||
} as const;
|
||||
|
||||
const inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
|
||||
const p = fetcher().finally(() => inflight.delete(key));
|
||||
inflight.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
const _pageSets = new Map<string, Set<number>>();
|
||||
|
||||
export interface PageSet {
|
||||
add(page: number): void;
|
||||
pages(): Set<number>;
|
||||
next(): number;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet {
|
||||
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
|
||||
return {
|
||||
add(page) { if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page); },
|
||||
pages() { return new Set(_pageSets.get(key) ?? []); },
|
||||
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; },
|
||||
clear() { _pageSets.delete(key); },
|
||||
};
|
||||
}
|
||||
|
||||
const FRECENCY_KEY = "moku-source-frecency";
|
||||
const MAX_FRECENCY_SOURCES = 4;
|
||||
type FrecencyMap = Record<string, number>;
|
||||
|
||||
function loadFrecency(): FrecencyMap {
|
||||
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
function saveFrecency(map: FrecencyMap) {
|
||||
try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {}
|
||||
}
|
||||
|
||||
export function recordSourceAccess(sourceId: string) {
|
||||
if (!sourceId || sourceId === "0") return;
|
||||
const map = loadFrecency();
|
||||
map[sourceId] = (map[sourceId] ?? 0) + 1;
|
||||
saveFrecency(map);
|
||||
}
|
||||
|
||||
export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
||||
const map = loadFrecency();
|
||||
const withScore = sources.map(s => ({ s, score: map[s.id] ?? 0 }));
|
||||
if (withScore.some(x => x.score > 0)) {
|
||||
return withScore.sort((a, b) => b.score - a.score).slice(0, MAX_FRECENCY_SOURCES).map(x => x.s);
|
||||
}
|
||||
return sources.slice(0, MAX_FRECENCY_SOURCES);
|
||||
}
|
||||
|
||||
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
|
||||
const didRefresh = cache.refresh(CACHE_KEYS.MANGA(mangaId));
|
||||
if (!didRefresh) cache.clear(CACHE_KEYS.MANGA(mangaId));
|
||||
|
||||
cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
cache.clear(CACHE_KEYS.ALL_MANGA);
|
||||
|
||||
if (thumbnailUrl) {
|
||||
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache");
|
||||
revokeBlobUrl(thumbnailUrl);
|
||||
getBlobUrl(thumbnailUrl, 999).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
interface WorkerMsg {
|
||||
focalTitle: string;
|
||||
focalId: number;
|
||||
allManga: { id: number; title: string }[];
|
||||
linkedIds: number[];
|
||||
}
|
||||
|
||||
function titleSimilarity(a: string, b: string): number {
|
||||
const norm = (s: string) =>
|
||||
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||
const wa = new Set(norm(a));
|
||||
const wb = new Set(norm(b));
|
||||
if (!wa.size || !wb.size) return 0;
|
||||
const intersection = [...wa].filter(w => wb.has(w)).length;
|
||||
return intersection / new Set([...wa, ...wb]).size;
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent<WorkerMsg>) => {
|
||||
const { focalTitle, focalId, allManga, linkedIds } = e.data;
|
||||
const matches: number[] = [];
|
||||
|
||||
for (const m of allManga) {
|
||||
if (m.id === focalId) continue;
|
||||
if (linkedIds.includes(m.id)) continue;
|
||||
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id);
|
||||
}
|
||||
|
||||
self.postMessage(matches);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
const THUMB_SIZE = 16;
|
||||
const DUPE_THRESH = 0.12;
|
||||
|
||||
const hashCache = new Map<string, Uint8ClampedArray>();
|
||||
|
||||
function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray {
|
||||
const gray = new Uint8ClampedArray(pixels);
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
const o = i * 4;
|
||||
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
|
||||
}
|
||||
return gray;
|
||||
}
|
||||
|
||||
function loadThumb(url: string): Promise<Uint8ClampedArray> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = THUMB_SIZE;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE);
|
||||
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE));
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
|
||||
return diff / (a.length * 255);
|
||||
}
|
||||
|
||||
export async function getHash(url: string): Promise<Uint8ClampedArray | null> {
|
||||
if (hashCache.has(url)) return hashCache.get(url)!;
|
||||
try {
|
||||
const thumb = await loadThumb(url);
|
||||
hashCache.set(url, thumb);
|
||||
return thumb;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
|
||||
return similarity(a, b) <= DUPE_THRESH;
|
||||
}
|
||||
|
||||
export function clearHashCache(): void {
|
||||
hashCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface Keybinds {
|
||||
turnPageRight: string;
|
||||
turnPageLeft: string;
|
||||
firstPage: string;
|
||||
lastPage: string;
|
||||
turnChapterRight: string;
|
||||
turnChapterLeft: string;
|
||||
exitReader: string;
|
||||
toggleReadingDirection: string;
|
||||
togglePageStyle: string;
|
||||
toggleFullscreen: string;
|
||||
openSettings: string;
|
||||
toggleBookmark: string;
|
||||
toggleMarker: string;
|
||||
toggleAutoScroll: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_KEYBINDS: Keybinds = {
|
||||
turnPageRight: "ArrowRight",
|
||||
turnPageLeft: "ArrowLeft",
|
||||
firstPage: "ctrl+ArrowLeft",
|
||||
lastPage: "ctrl+ArrowRight",
|
||||
turnChapterRight: "]",
|
||||
turnChapterLeft: "[",
|
||||
exitReader: "Backspace",
|
||||
toggleReadingDirection: "d",
|
||||
togglePageStyle: "q",
|
||||
toggleFullscreen: "f",
|
||||
openSettings: "o",
|
||||
toggleBookmark: "m",
|
||||
toggleMarker: "n",
|
||||
toggleAutoScroll: "s",
|
||||
};
|
||||
|
||||
export const KEYBIND_LABELS: Record<keyof Keybinds, string> = {
|
||||
turnPageRight: "Turn page right (→)",
|
||||
turnPageLeft: "Turn page left (←)",
|
||||
firstPage: "Jump to first page",
|
||||
lastPage: "Jump to last page",
|
||||
turnChapterRight: "Turn chapter right (→)",
|
||||
turnChapterLeft: "Turn chapter left (←)",
|
||||
exitReader: "Exit reader",
|
||||
toggleReadingDirection: "Toggle reading direction",
|
||||
togglePageStyle: "Toggle page style",
|
||||
toggleFullscreen: "Toggle fullscreen",
|
||||
openSettings: "Open settings",
|
||||
toggleBookmark: "Toggle bookmark",
|
||||
toggleMarker: "Toggle marker",
|
||||
toggleAutoScroll: "Toggle auto scroll",
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export function eventToKeybind(e: KeyboardEvent): string {
|
||||
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) return "";
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey) parts.push("ctrl");
|
||||
if (e.altKey) parts.push("alt");
|
||||
if (e.shiftKey) parts.push("shift");
|
||||
if (e.metaKey) parts.push("meta");
|
||||
parts.push(e.key);
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
|
||||
return eventToKeybind(e) === bind;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
const VAULT_KEY = "moku-credential-vault";
|
||||
const SALT_ITERATIONS = 200_000;
|
||||
const KEY_USAGE: KeyUsage[] = ["encrypt", "decrypt"];
|
||||
|
||||
export interface VaultPayload {
|
||||
refreshToken?: string;
|
||||
basicUser?: string;
|
||||
basicPass?: string;
|
||||
authMode: "UI_LOGIN" | "BASIC_AUTH" | "NONE";
|
||||
}
|
||||
|
||||
interface StoredVault {
|
||||
salt: string;
|
||||
iv: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
function toB64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
function fromB64(s: string): Uint8Array {
|
||||
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder();
|
||||
const keyMat = await crypto.subtle.importKey("raw", enc.encode(pin), "PBKDF2", false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "PBKDF2", salt, iterations: SALT_ITERATIONS, hash: "SHA-256" },
|
||||
keyMat,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
KEY_USAGE,
|
||||
);
|
||||
}
|
||||
|
||||
export function vaultExists(): boolean {
|
||||
return !!localStorage.getItem(VAULT_KEY);
|
||||
}
|
||||
|
||||
export async function lockVault(pin: string, payload: VaultPayload): Promise<void> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const key = await deriveKey(pin, salt);
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const cipher = await crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv },
|
||||
key,
|
||||
enc.encode(JSON.stringify(payload)),
|
||||
);
|
||||
|
||||
localStorage.setItem(VAULT_KEY, JSON.stringify({
|
||||
salt: toB64(salt),
|
||||
iv: toB64(iv),
|
||||
data: toB64(cipher),
|
||||
} satisfies StoredVault));
|
||||
}
|
||||
|
||||
export async function unlockVault(pin: string): Promise<VaultPayload | null> {
|
||||
const raw = localStorage.getItem(VAULT_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const stored = JSON.parse(raw) as StoredVault;
|
||||
const key = await deriveKey(pin, fromB64(stored.salt));
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: fromB64(stored.iv) },
|
||||
key,
|
||||
fromB64(stored.data),
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearVault(): void {
|
||||
localStorage.removeItem(VAULT_KEY);
|
||||
}
|
||||
|
||||
export async function rekeyVault(oldPin: string, newPin: string): Promise<boolean> {
|
||||
const payload = await unlockVault(oldPin);
|
||||
if (!payload) return false;
|
||||
await lockVault(newPin, payload);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
export interface LongPressOptions {
|
||||
onLongPress: (e: PointerEvent) => void;
|
||||
duration?: number;
|
||||
moveThreshold?: number;
|
||||
}
|
||||
|
||||
export function longPress(node: HTMLElement, opts: LongPressOptions) {
|
||||
const { onLongPress, duration = 500, moveThreshold = 8 } = opts;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let startX = 0, startY = 0;
|
||||
let fired = false;
|
||||
|
||||
function start(e: PointerEvent) {
|
||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||
startX = e.clientX; startY = e.clientY; fired = false;
|
||||
timer = setTimeout(() => { timer = null; fired = true; onLongPress(e); }, duration);
|
||||
}
|
||||
function move(e: PointerEvent) {
|
||||
if (!timer) return;
|
||||
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||
if (Math.sqrt(dx * dx + dy * dy) > moveThreshold) cancel();
|
||||
}
|
||||
function cancel() { if (timer) { clearTimeout(timer); timer = null; } }
|
||||
|
||||
node.addEventListener("pointerdown", start);
|
||||
node.addEventListener("pointermove", move);
|
||||
node.addEventListener("pointerup", cancel);
|
||||
node.addEventListener("pointerleave", cancel);
|
||||
node.addEventListener("pointercancel",cancel);
|
||||
|
||||
return {
|
||||
get fired() { return fired; },
|
||||
destroy() {
|
||||
cancel();
|
||||
node.removeEventListener("pointerdown", start);
|
||||
node.removeEventListener("pointermove", move);
|
||||
node.removeEventListener("pointerup", cancel);
|
||||
node.removeEventListener("pointerleave", cancel);
|
||||
node.removeEventListener("pointercancel",cancel);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface TapOptions {
|
||||
onTap: (e: PointerEvent) => void;
|
||||
onDoubleTap?: (e: PointerEvent) => void;
|
||||
doubleTapGap?: number;
|
||||
}
|
||||
|
||||
export function tap(node: HTMLElement, opts: TapOptions) {
|
||||
const { onTap, onDoubleTap, doubleTapGap = 300 } = opts;
|
||||
let lastTap = 0;
|
||||
let pending: ReturnType<typeof setTimeout> | null = null;
|
||||
let startX = 0, startY = 0;
|
||||
const SLOP = 8;
|
||||
|
||||
function down(e: PointerEvent) { startX = e.clientX; startY = e.clientY; }
|
||||
function up(e: PointerEvent) {
|
||||
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||
if (Math.sqrt(dx * dx + dy * dy) > SLOP) return;
|
||||
const now = Date.now();
|
||||
if (onDoubleTap && now - lastTap < doubleTapGap) {
|
||||
if (pending) { clearTimeout(pending); pending = null; }
|
||||
onDoubleTap(e);
|
||||
lastTap = 0;
|
||||
} else {
|
||||
lastTap = now;
|
||||
if (onDoubleTap) {
|
||||
pending = setTimeout(() => { pending = null; onTap(e); }, doubleTapGap);
|
||||
} else {
|
||||
onTap(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.addEventListener("pointerdown", down);
|
||||
node.addEventListener("pointerup", up);
|
||||
return { destroy() {
|
||||
node.removeEventListener("pointerdown", down);
|
||||
node.removeEventListener("pointerup", up);
|
||||
}};
|
||||
}
|
||||
|
||||
export interface SwipeOptions {
|
||||
onSwipeLeft?: (e: PointerEvent) => void;
|
||||
onSwipeRight?: (e: PointerEvent) => void;
|
||||
onSwipeUp?: (e: PointerEvent) => void;
|
||||
onSwipeDown?: (e: PointerEvent) => void;
|
||||
threshold?: number;
|
||||
lockAxis?: boolean;
|
||||
}
|
||||
|
||||
export function swipe(node: HTMLElement, opts: SwipeOptions) {
|
||||
const { onSwipeLeft, onSwipeRight, onSwipeUp, onSwipeDown, threshold = 40, lockAxis = true } = opts;
|
||||
let startX = 0, startY = 0, active = false;
|
||||
|
||||
function down(e: PointerEvent) {
|
||||
if (e.pointerType === "mouse") return;
|
||||
startX = e.clientX; startY = e.clientY; active = true;
|
||||
node.setPointerCapture(e.pointerId);
|
||||
}
|
||||
function up(e: PointerEvent) {
|
||||
if (!active) return; active = false;
|
||||
const dx = e.clientX - startX, dy = e.clientY - startY;
|
||||
const ax = Math.abs(dx), ay = Math.abs(dy);
|
||||
if (Math.max(ax, ay) < threshold) return;
|
||||
if (lockAxis && ax > ay) {
|
||||
if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e);
|
||||
} else if (lockAxis && ay >= ax) {
|
||||
if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e);
|
||||
} else {
|
||||
if (ax >= ay) { if (dx < 0) onSwipeLeft?.(e); else onSwipeRight?.(e); }
|
||||
else { if (dy < 0) onSwipeUp?.(e); else onSwipeDown?.(e); }
|
||||
}
|
||||
}
|
||||
function cancel() { active = false; }
|
||||
|
||||
node.addEventListener("pointerdown", down);
|
||||
node.addEventListener("pointerup", up);
|
||||
node.addEventListener("pointercancel", cancel);
|
||||
return { destroy() {
|
||||
node.removeEventListener("pointerdown", down);
|
||||
node.removeEventListener("pointerup", up);
|
||||
node.removeEventListener("pointercancel", cancel);
|
||||
}};
|
||||
}
|
||||
|
||||
export interface PinchOptions {
|
||||
onPinch: (scale: number, origin: { x: number; y: number }) => void;
|
||||
onPinchEnd?: (scale: number) => void;
|
||||
}
|
||||
|
||||
export interface PinchGestureOptions {
|
||||
onPinch: (scale: number, origin: { x: number; y: number }) => void;
|
||||
onPinchEnd?: (scale: number) => void;
|
||||
}
|
||||
|
||||
export interface PinchGesture {
|
||||
onPointerDown: (e: PointerEvent) => void;
|
||||
onPointerMove: (e: PointerEvent) => void;
|
||||
onPointerUp: (e: PointerEvent) => void;
|
||||
isPinching: () => boolean;
|
||||
}
|
||||
|
||||
export function createPinchGesture(opts: PinchGestureOptions): PinchGesture {
|
||||
const { onPinch, onPinchEnd } = opts;
|
||||
const pointers = new Map<number, PointerEvent>();
|
||||
let initDist = 0;
|
||||
|
||||
function pdist(a: PointerEvent, b: PointerEvent) {
|
||||
const dx = a.clientX - b.clientX, dy = a.clientY - b.clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
function pmid(a: PointerEvent, b: PointerEvent) {
|
||||
return { x: (a.clientX + b.clientX) / 2, y: (a.clientY + b.clientY) / 2 };
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
pointers.set(e.pointerId, e);
|
||||
if (pointers.size === 2) {
|
||||
const [a, b] = [...pointers.values()];
|
||||
initDist = pdist(a, b);
|
||||
}
|
||||
}
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!pointers.has(e.pointerId)) return;
|
||||
pointers.set(e.pointerId, e);
|
||||
if (pointers.size !== 2 || initDist === 0) return;
|
||||
const [a, b] = [...pointers.values()];
|
||||
onPinch(pdist(a, b) / initDist, pmid(a, b));
|
||||
}
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
if (pointers.size === 2 && onPinchEnd) {
|
||||
const [a, b] = [...pointers.values()];
|
||||
onPinchEnd(pdist(a, b) / initDist);
|
||||
}
|
||||
pointers.delete(e.pointerId);
|
||||
initDist = 0;
|
||||
}
|
||||
|
||||
return { onPointerDown, onPointerMove, onPointerUp, isPinching: () => pointers.size >= 2 };
|
||||
}
|
||||
|
||||
export function pinch(node: HTMLElement, opts: PinchOptions) {
|
||||
const gesture = createPinchGesture(opts);
|
||||
function down(e: PointerEvent) { node.setPointerCapture(e.pointerId); gesture.onPointerDown(e); }
|
||||
node.addEventListener("pointerdown", down);
|
||||
node.addEventListener("pointermove", gesture.onPointerMove);
|
||||
node.addEventListener("pointerup", gesture.onPointerUp);
|
||||
node.addEventListener("pointercancel", gesture.onPointerUp);
|
||||
return { destroy() {
|
||||
node.removeEventListener("pointerdown", down);
|
||||
node.removeEventListener("pointermove", gesture.onPointerMove);
|
||||
node.removeEventListener("pointerup", gesture.onPointerUp);
|
||||
node.removeEventListener("pointercancel", gesture.onPointerUp);
|
||||
}};
|
||||
}
|
||||
|
||||
export interface DragScrollOptions {
|
||||
direction?: "x" | "y" | "both";
|
||||
onDragStart?: () => void;
|
||||
onDragEnd?: () => void;
|
||||
}
|
||||
|
||||
export function dragScroll(node: HTMLElement, opts: DragScrollOptions = {}) {
|
||||
const { direction = "both", onDragStart, onDragEnd } = opts;
|
||||
let active = false, startX = 0, startY = 0, scrollX = 0, scrollY = 0;
|
||||
|
||||
function down(e: PointerEvent) {
|
||||
if (e.pointerType === "mouse") return;
|
||||
active = true;
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
scrollX = node.scrollLeft; scrollY = node.scrollTop;
|
||||
node.setPointerCapture(e.pointerId);
|
||||
onDragStart?.();
|
||||
}
|
||||
function move(e: PointerEvent) {
|
||||
if (!active) return;
|
||||
if (direction !== "x") node.scrollTop = scrollY - (e.clientY - startY);
|
||||
if (direction !== "y") node.scrollLeft = scrollX - (e.clientX - startX);
|
||||
}
|
||||
function up() { if (active) { active = false; onDragEnd?.(); } }
|
||||
|
||||
node.addEventListener("pointerdown", down);
|
||||
node.addEventListener("pointermove", move);
|
||||
node.addEventListener("pointerup", up);
|
||||
node.addEventListener("pointercancel", up);
|
||||
return { destroy() {
|
||||
node.removeEventListener("pointerdown", down);
|
||||
node.removeEventListener("pointermove", move);
|
||||
node.removeEventListener("pointerup", up);
|
||||
node.removeEventListener("pointercancel", up);
|
||||
}};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
let _appliedZoom: number = -1;
|
||||
let _vhRafId: number | null = null;
|
||||
|
||||
export function applyZoom(uiZoom: number) {
|
||||
if (uiZoom === _appliedZoom) return;
|
||||
_appliedZoom = uiZoom;
|
||||
document.documentElement.style.setProperty("--ui-zoom", String(uiZoom));
|
||||
document.documentElement.style.setProperty("--ui-scale", String(uiZoom));
|
||||
document.documentElement.style.zoom = `${uiZoom * 100}%`;
|
||||
if (_vhRafId !== null) cancelAnimationFrame(_vhRafId);
|
||||
_vhRafId = requestAnimationFrame(() => {
|
||||
_vhRafId = null;
|
||||
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / uiZoom}px`);
|
||||
});
|
||||
}
|
||||
|
||||
export function zoomDelta(e: KeyboardEvent, current: number): number | null {
|
||||
if (!e.ctrlKey) return null;
|
||||
if (e.key === "=" || e.key === "+") { e.preventDefault(); return Math.min(2.0, Math.round((current + 0.1) * 10) / 10); }
|
||||
if (e.key === "-") { e.preventDefault(); return Math.max(0.5, Math.round((current - 0.1) * 10) / 10); }
|
||||
if (e.key === "0") { e.preventDefault(); return 1.0; }
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clampZoom(z: number, min: number, max: number): number {
|
||||
return Math.round(Math.min(max, Math.max(min, z)) * 1000) / 1000;
|
||||
}
|
||||
|
||||
export function captureZoomAnchor(
|
||||
containerEl: HTMLElement | null,
|
||||
style: string,
|
||||
out: { el: HTMLElement | null; offset: number },
|
||||
) {
|
||||
if (!containerEl || style !== "longstrip") return;
|
||||
const containerTop = containerEl.getBoundingClientRect().top;
|
||||
for (const img of containerEl.querySelectorAll<HTMLElement>("img[data-local-page]")) {
|
||||
const rect = img.getBoundingClientRect();
|
||||
if (rect.bottom > containerTop) { out.el = img; out.offset = rect.top - containerTop; return; }
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreZoomAnchor(
|
||||
containerEl: HTMLElement | null,
|
||||
out: { el: HTMLElement | null; offset: number },
|
||||
) {
|
||||
if (!out.el || !containerEl) return;
|
||||
const el = out.el;
|
||||
out.el = null;
|
||||
requestAnimationFrame(() => {
|
||||
const containerTop = containerEl!.getBoundingClientRect().top;
|
||||
containerEl!.scrollTop += (el.getBoundingClientRect().top - containerTop) - out.offset;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import type { Manga, Source } from "$lib/types";
|
||||
import type { Settings } from "$lib/types";
|
||||
|
||||
export { clsx as cn } from "clsx";
|
||||
|
||||
export function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "Just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function dayLabel(ts: number): string {
|
||||
const d = new Date(ts), now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) return "Today";
|
||||
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yest.toDateString()) return "Yesterday";
|
||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
export function formatReadTime(m: number): string {
|
||||
if (m < 1) return "< 1 min";
|
||||
if (m < 60) return `${m} min`;
|
||||
const h = Math.floor(m / 60), r = m % 60;
|
||||
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||
}
|
||||
|
||||
const STRICT_TAGS: string[] = [
|
||||
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||
"18+", "smut", "explicit", "sexual violence",
|
||||
"gore", "guro", "graphic violence", "torture", "body horror",
|
||||
];
|
||||
|
||||
const MODERATE_TAGS: string[] = [
|
||||
"adult", "mature", "hentai", "ecchi", "erotic", "pornograph",
|
||||
"18+", "smut", "explicit", "sexual violence",
|
||||
];
|
||||
|
||||
type ContentFilterSettings = Pick<
|
||||
Settings,
|
||||
"contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds"
|
||||
>;
|
||||
|
||||
function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
|
||||
if (settings.contentLevel === "strict") return STRICT_TAGS;
|
||||
if (settings.contentLevel === "moderate") return MODERATE_TAGS;
|
||||
return [];
|
||||
}
|
||||
|
||||
function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean {
|
||||
if (!blockedTags.length) return false;
|
||||
return genre.some(g => {
|
||||
const norm = g.toLowerCase().trim();
|
||||
return blockedTags.some(tag => {
|
||||
const idx = norm.indexOf(tag);
|
||||
if (idx === -1) return false;
|
||||
const before = idx === 0 || /\W/.test(norm[idx - 1]);
|
||||
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
|
||||
return before && after;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldHideNsfw(
|
||||
manga: Pick<Manga, "genre" | "source">,
|
||||
settings: ContentFilterSettings,
|
||||
): boolean {
|
||||
if (settings.contentLevel === "unrestricted") return false;
|
||||
|
||||
const srcId = manga.source?.id;
|
||||
const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : [];
|
||||
const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : [];
|
||||
|
||||
if (srcId && blocked.includes(srcId)) return true;
|
||||
|
||||
const sourceAllowed = !!(srcId && allowed.includes(srcId));
|
||||
|
||||
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
||||
|
||||
return genreMatchesBlocklist(manga.genre ?? [], blockedTagsForSettings(settings));
|
||||
}
|
||||
|
||||
export function shouldHideSource(
|
||||
source: Pick<Source, "id" | "isNsfw">,
|
||||
settings: ContentFilterSettings,
|
||||
): boolean {
|
||||
if (settings.contentLevel === "unrestricted") return false;
|
||||
|
||||
if (settings.sourceOverridesEnabled) {
|
||||
if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true;
|
||||
if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return false;
|
||||
}
|
||||
|
||||
return source.isNsfw;
|
||||
}
|
||||
|
||||
export function dedupeSourcesByLang(
|
||||
sources: Source[],
|
||||
preferredLang: string,
|
||||
settings: ContentFilterSettings,
|
||||
applyHide = false,
|
||||
): Source[] {
|
||||
const map = new Map<string, Source>();
|
||||
for (const s of sources) {
|
||||
if (s.id === "0") continue;
|
||||
if (applyHide && shouldHideSource(s, settings)) continue;
|
||||
const existing = map.get(s.name);
|
||||
if (!existing) { map.set(s.name, s); continue; }
|
||||
const existingPref = existing.lang === preferredLang;
|
||||
const newPref = s.lang === preferredLang;
|
||||
if (newPref && !existingPref) map.set(s.name, s);
|
||||
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||
const byName = new Map<string, Source[]>();
|
||||
for (const src of sources) {
|
||||
if (src.id === "0") continue;
|
||||
if (!byName.has(src.name)) byName.set(src.name, []);
|
||||
byName.get(src.name)!.push(src);
|
||||
}
|
||||
const picked: Source[] = [];
|
||||
for (const group of byName.values()) {
|
||||
const preferred = group.find(s => s.lang === preferredLang);
|
||||
picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
export function normalizeTitle(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/\(official\)|\(web comic\)|\(webtoon\)|\(manhwa\)|\(manhua\)/gi, "")
|
||||
.replace(/[^a-z0-9\s]/g, " ")
|
||||
.replace(/^(the|a|an)\s+/, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function norm(s: string): string {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function descFingerprint(desc: string | null | undefined): string | null {
|
||||
if (!desc) return null;
|
||||
const n = norm(desc);
|
||||
return n.length >= 60 ? n.slice(0, 200) : null;
|
||||
}
|
||||
|
||||
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
|
||||
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
|
||||
return parts.length ? parts.sort().join("|") : null;
|
||||
}
|
||||
|
||||
export function dedupeMangaByTitle<T extends {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
author?: string | null;
|
||||
artist?: string | null;
|
||||
inLibrary?: boolean;
|
||||
downloadCount?: number;
|
||||
}>(items: T[], links: Record<number, number[]> = {}): T[] {
|
||||
const byTitle = new Map<string, number>();
|
||||
const byDesc = new Map<string, number>();
|
||||
const byAuthorDesc = new Map<string, number>();
|
||||
const byId = new Map<number, number>();
|
||||
const out: T[] = [];
|
||||
|
||||
for (const m of items) {
|
||||
const tk = normalizeTitle(m.title);
|
||||
const dk = descFingerprint(m.description);
|
||||
const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null;
|
||||
|
||||
const linkedIds = links[m.id] ?? [];
|
||||
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
|
||||
const existingIdx =
|
||||
linkedIdx ??
|
||||
byTitle.get(tk) ??
|
||||
(dk ? byDesc.get(dk) : undefined) ??
|
||||
(ak ? byAuthorDesc.get(ak) : undefined);
|
||||
|
||||
if (existingIdx !== undefined) {
|
||||
const existing = out[existingIdx];
|
||||
const mBetter =
|
||||
(m.inLibrary && !existing.inLibrary) ||
|
||||
(!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0));
|
||||
|
||||
if (mBetter) {
|
||||
out[existingIdx] = m;
|
||||
byTitle.set(tk, existingIdx);
|
||||
byId.set(m.id, existingIdx);
|
||||
if (dk) byDesc.set(dk, existingIdx);
|
||||
if (ak) byAuthorDesc.set(ak, existingIdx);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const idx = out.length;
|
||||
out.push(m);
|
||||
byTitle.set(tk, idx);
|
||||
byId.set(m.id, idx);
|
||||
if (dk) byDesc.set(dk, idx);
|
||||
if (ak) byAuthorDesc.set(ak, idx);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
||||
const seen = new Set<number>();
|
||||
const out: T[] = [];
|
||||
for (const m of items) {
|
||||
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { seriesState } from '$lib/state/series.svelte'
|
||||
import { readerState } from '$lib/state/reader.svelte'
|
||||
|
||||
export async function loadChapters(mangaId: string) {
|
||||
seriesState.chaptersLoading = true
|
||||
seriesState.chaptersError = null
|
||||
try {
|
||||
seriesState.chapters = await getAdapter().getChapters(mangaId)
|
||||
} catch (e) {
|
||||
seriesState.chaptersError = String(e)
|
||||
} finally {
|
||||
seriesState.chaptersLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadChapterPages(chapterId: string) {
|
||||
readerState.pagesLoading = true
|
||||
readerState.pagesError = null
|
||||
try {
|
||||
readerState.pages = await getAdapter().getChapterPages(chapterId)
|
||||
} catch (e) {
|
||||
readerState.pagesError = String(e)
|
||||
} finally {
|
||||
readerState.pagesLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function markRead(id: string, read: boolean) {
|
||||
await getAdapter().markChapterRead(id, read)
|
||||
const chapter = seriesState.chapters.find(c => c.id === id)
|
||||
if (chapter) chapter.read = read
|
||||
}
|
||||
|
||||
export async function markManyRead(ids: string[], read: boolean) {
|
||||
await getAdapter().markChaptersRead(ids, read)
|
||||
for (const c of seriesState.chapters) {
|
||||
if (ids.includes(c.id)) c.read = read
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { downloadsState } from '$lib/state/downloads.svelte'
|
||||
|
||||
export async function loadDownloads() {
|
||||
try {
|
||||
downloadsState.items = await getAdapter().getDownloads()
|
||||
} catch (e) {
|
||||
downloadsState.error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function enqueueDownload(chapterId: string) {
|
||||
await getAdapter().enqueueDownload(chapterId)
|
||||
await loadDownloads()
|
||||
}
|
||||
|
||||
export async function dequeueDownload(chapterId: string) {
|
||||
await getAdapter().dequeueDownload(chapterId)
|
||||
downloadsState.items = downloadsState.items.filter(d => d.chapterId !== chapterId)
|
||||
}
|
||||
|
||||
export async function clearDownloads() {
|
||||
await getAdapter().clearDownloads()
|
||||
downloadsState.items = []
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { extensionsState } from '$lib/state/extensions.svelte'
|
||||
|
||||
export async function loadExtensions() {
|
||||
extensionsState.loading = true
|
||||
extensionsState.error = null
|
||||
try {
|
||||
extensionsState.items = await getAdapter().getExtensions()
|
||||
} catch (e) {
|
||||
extensionsState.error = String(e)
|
||||
} finally {
|
||||
extensionsState.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSources() {
|
||||
try {
|
||||
extensionsState.sources = await getAdapter().getSources()
|
||||
} catch (e) {
|
||||
extensionsState.error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function installExtension(id: string) {
|
||||
await getAdapter().installExtension(id)
|
||||
await loadExtensions()
|
||||
}
|
||||
|
||||
export async function uninstallExtension(id: string) {
|
||||
await getAdapter().uninstallExtension(id)
|
||||
extensionsState.items = extensionsState.items.filter(e => e.id !== id)
|
||||
}
|
||||
|
||||
export async function updateExtension(id: string) {
|
||||
await getAdapter().updateExtension(id)
|
||||
await loadExtensions()
|
||||
}
|
||||
|
||||
export async function browseSource(sourceId: string, page: number) {
|
||||
extensionsState.browseLoading = true
|
||||
extensionsState.browseError = null
|
||||
try {
|
||||
const result = await getAdapter().browseSource(sourceId, page)
|
||||
extensionsState.browseResults = result.items
|
||||
extensionsState.browseHasMore = result.hasNextPage
|
||||
} catch (e) {
|
||||
extensionsState.browseError = String(e)
|
||||
} finally {
|
||||
extensionsState.browseLoading = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { libraryState } from '$lib/state/library.svelte'
|
||||
import { seriesState } from '$lib/state/series.svelte'
|
||||
import type { MangaFilters, MangaMeta } from '$lib/server-adapters/types'
|
||||
|
||||
export async function loadLibrary(filters: MangaFilters = { inLibrary: true }) {
|
||||
libraryState.loading = true
|
||||
libraryState.error = null
|
||||
try {
|
||||
const result = await getAdapter().getMangaList(filters)
|
||||
libraryState.items = result.items
|
||||
} catch (e) {
|
||||
libraryState.error = String(e)
|
||||
} finally {
|
||||
libraryState.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadManga(id: string) {
|
||||
seriesState.loading = true
|
||||
seriesState.error = null
|
||||
try {
|
||||
seriesState.current = await getAdapter().getManga(id)
|
||||
} catch (e) {
|
||||
seriesState.error = String(e)
|
||||
} finally {
|
||||
seriesState.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchManga(query: string, sourceId?: string) {
|
||||
libraryState.loading = true
|
||||
libraryState.error = null
|
||||
try {
|
||||
libraryState.searchResults = await getAdapter().searchManga(query, sourceId)
|
||||
} catch (e) {
|
||||
libraryState.error = String(e)
|
||||
} finally {
|
||||
libraryState.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function addToLibrary(mangaId: string) {
|
||||
await getAdapter().addToLibrary(mangaId)
|
||||
await loadLibrary()
|
||||
}
|
||||
|
||||
export async function removeFromLibrary(mangaId: string) {
|
||||
await getAdapter().removeFromLibrary(mangaId)
|
||||
libraryState.items = libraryState.items.filter(m => m.id !== mangaId)
|
||||
}
|
||||
|
||||
export async function updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
|
||||
await getAdapter().updateMangaMeta(id, meta)
|
||||
if (seriesState.current?.id === id) {
|
||||
await loadManga(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { trackingState } from '$lib/state/tracking.svelte'
|
||||
|
||||
export async function loadTrackers() {
|
||||
trackingState.loading = true
|
||||
trackingState.error = null
|
||||
try {
|
||||
trackingState.trackers = await getAdapter().getTrackers()
|
||||
} catch (e) {
|
||||
trackingState.error = String(e)
|
||||
} finally {
|
||||
trackingState.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function linkTracker(mangaId: string, trackerId: string, remoteId: string) {
|
||||
await getAdapter().linkTracker(mangaId, trackerId, remoteId)
|
||||
await loadTrackers()
|
||||
}
|
||||
|
||||
export async function syncTracking(mangaId: string) {
|
||||
trackingState.syncing = true
|
||||
try {
|
||||
await getAdapter().syncTracking(mangaId)
|
||||
} finally {
|
||||
trackingState.syncing = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
export const GET_CHAPTERS = `
|
||||
query GetChapters($mangaId: Int!) {
|
||||
chapters(condition: { mangaId: $mangaId }) {
|
||||
nodes {
|
||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_RECENTLY_UPDATED = `
|
||||
query GetRecentlyUpdated {
|
||||
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
||||
nodes {
|
||||
id name chapterNumber sourceOrder isRead lastPageRead mangaId fetchedAt
|
||||
manga { id title thumbnailUrl inLibrary }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const FETCH_CHAPTERS = `
|
||||
mutation FetchChapters($mangaId: Int!) {
|
||||
fetchChapters(input: { mangaId: $mangaId }) {
|
||||
chapters {
|
||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const FETCH_CHAPTER_PAGES = `
|
||||
mutation FetchChapterPages($chapterId: Int!) {
|
||||
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
|
||||
}
|
||||
`
|
||||
|
||||
export const MARK_CHAPTER_READ = `
|
||||
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
|
||||
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
|
||||
chapter { id isRead }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const MARK_CHAPTERS_READ = `
|
||||
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
|
||||
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
|
||||
chapters { id isRead }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_CHAPTERS_PROGRESS = `
|
||||
mutation UpdateChaptersProgress($ids: [Int!]!, $isRead: Boolean, $isBookmarked: Boolean, $lastPageRead: Int) {
|
||||
updateChapters(input: { ids: $ids, patch: { isRead: $isRead, isBookmarked: $isBookmarked, lastPageRead: $lastPageRead } }) {
|
||||
chapters { id isRead isBookmarked lastPageRead }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_DOWNLOADED_CHAPTERS = `
|
||||
mutation DeleteDownloadedChapters($ids: [Int!]!) {
|
||||
deleteDownloadedChapters(input: { ids: $ids }) {
|
||||
chapters { id isDownloaded }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_CHAPTER_META = `
|
||||
mutation SetChapterMeta($chapterId: Int!, $key: String!, $value: String!) {
|
||||
setChapterMeta(input: { meta: { chapterId: $chapterId, key: $key, value: $value } }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_CHAPTER_META = `
|
||||
mutation DeleteChapterMeta($chapterId: Int!, $key: String!) {
|
||||
deleteChapterMeta(input: { chapterId: $chapterId, key: $key }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,105 @@
|
||||
const QUEUE_FRAGMENT = `
|
||||
state
|
||||
queue {
|
||||
progress state tries
|
||||
chapter {
|
||||
id name pageCount mangaId
|
||||
manga { id title thumbnailUrl }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_DOWNLOAD_STATUS = `
|
||||
query GetDownloadStatus {
|
||||
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||
}
|
||||
`
|
||||
|
||||
export const ENQUEUE_DOWNLOAD = `
|
||||
mutation EnqueueDownload($chapterId: Int!) {
|
||||
enqueueChapterDownload(input: { id: $chapterId }) {
|
||||
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const ENQUEUE_CHAPTERS_DOWNLOAD = `
|
||||
mutation EnqueueChaptersDownload($chapterIds: [Int!]!) {
|
||||
enqueueChapterDownloads(input: { ids: $chapterIds }) {
|
||||
downloadStatus { state }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DEQUEUE_DOWNLOAD = `
|
||||
mutation DequeueDownload($chapterId: Int!) {
|
||||
dequeueChapterDownload(input: { id: $chapterId }) {
|
||||
downloadStatus { state }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DEQUEUE_CHAPTERS_DOWNLOAD = `
|
||||
mutation DequeueChaptersDownload($chapterIds: [Int!]!) {
|
||||
dequeueChapterDownloads(input: { ids: $chapterIds }) {
|
||||
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const REORDER_DOWNLOAD = `
|
||||
mutation ReorderDownload($chapterId: Int!, $to: Int!) {
|
||||
reorderChapterDownload(input: { chapterId: $chapterId, to: $to }) {
|
||||
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const START_DOWNLOADER = `
|
||||
mutation StartDownloader {
|
||||
startDownloader(input: {}) {
|
||||
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const STOP_DOWNLOADER = `
|
||||
mutation StopDownloader {
|
||||
stopDownloader(input: {}) {
|
||||
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CLEAR_DOWNLOADER = `
|
||||
mutation ClearDownloader {
|
||||
clearDownloader(input: {}) {
|
||||
downloadStatus { ${QUEUE_FRAGMENT} }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const FETCH_SOURCE_MANGA = `
|
||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String, $filters: [FilterChangeInput!]) {
|
||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query, filters: $filters }) {
|
||||
mangas { id title thumbnailUrl inLibrary }
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_DOWNLOADS_PATH = `
|
||||
mutation SetDownloadsPath($path: String!) {
|
||||
setSettings(input: { settings: { downloadsPath: $path } }) {
|
||||
settings { downloadsPath }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_LOCAL_SOURCE_PATH = `
|
||||
mutation SetLocalSourcePath($path: String!) {
|
||||
setSettings(input: { settings: { localSourcePath: $path } }) {
|
||||
settings { localSourcePath }
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,191 @@
|
||||
export const GET_EXTENSIONS = `
|
||||
query GetExtensions {
|
||||
extensions {
|
||||
nodes {
|
||||
apkName pkgName name lang versionName
|
||||
isInstalled isObsolete hasUpdate iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_SOURCES = `
|
||||
query GetSources {
|
||||
sources {
|
||||
nodes {
|
||||
id name lang displayName iconUrl isNsfw
|
||||
isConfigurable supportsLatest
|
||||
extension { pkgName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_SOURCE_SETTINGS = `
|
||||
query GetSourceSettings($id: LongString!) {
|
||||
source(id: $id) {
|
||||
id
|
||||
displayName
|
||||
preferences {
|
||||
... on CheckBoxPreference {
|
||||
type: __typename
|
||||
CheckBoxTitle: title
|
||||
CheckBoxSummary: summary
|
||||
CheckBoxDefault: default
|
||||
CheckBoxCurrentValue: currentValue
|
||||
key
|
||||
}
|
||||
... on SwitchPreference {
|
||||
type: __typename
|
||||
SwitchPreferenceTitle: title
|
||||
SwitchPreferenceSummary: summary
|
||||
SwitchPreferenceDefault: default
|
||||
SwitchPreferenceCurrentValue: currentValue
|
||||
key
|
||||
}
|
||||
... on ListPreference {
|
||||
type: __typename
|
||||
ListPreferenceTitle: title
|
||||
ListPreferenceSummary: summary
|
||||
ListPreferenceDefault: default
|
||||
ListPreferenceCurrentValue: currentValue
|
||||
entries
|
||||
entryValues
|
||||
key
|
||||
}
|
||||
... on EditTextPreference {
|
||||
type: __typename
|
||||
EditTextPreferenceTitle: title
|
||||
EditTextPreferenceSummary: summary
|
||||
EditTextPreferenceDefault: default
|
||||
EditTextPreferenceCurrentValue: currentValue
|
||||
dialogTitle
|
||||
dialogMessage
|
||||
key
|
||||
}
|
||||
... on MultiSelectListPreference {
|
||||
type: __typename
|
||||
MultiSelectListPreferenceTitle: title
|
||||
MultiSelectListPreferenceSummary: summary
|
||||
MultiSelectListPreferenceDefault: default
|
||||
MultiSelectListPreferenceCurrentValue: currentValue
|
||||
entries
|
||||
entryValues
|
||||
key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_SETTINGS = `
|
||||
query GetSettings {
|
||||
settings { extensionRepos }
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_SERVER_SECURITY = `
|
||||
query GetServerSecurity {
|
||||
settings {
|
||||
authMode authUsername
|
||||
socksProxyEnabled socksProxyHost socksProxyPort socksProxyVersion socksProxyUsername
|
||||
flareSolverrEnabled flareSolverrUrl flareSolverrTimeout
|
||||
flareSolverrSessionName flareSolverrSessionTtl flareSolverrAsResponseFallback
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const FETCH_EXTENSIONS = `
|
||||
mutation FetchExtensions {
|
||||
fetchExtensions(input: {}) {
|
||||
extensions {
|
||||
apkName pkgName name lang versionName
|
||||
isInstalled isObsolete hasUpdate iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_EXTENSION = `
|
||||
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||
extension { apkName pkgName name isInstalled hasUpdate }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_EXTENSIONS = `
|
||||
mutation UpdateExtensions($ids: [String!]!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||
updateExtensions(input: { ids: $ids, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||
extensions { apkName pkgName name isInstalled hasUpdate }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const INSTALL_EXTERNAL_EXTENSION = `
|
||||
mutation InstallExternalExtension($url: String!) {
|
||||
installExternalExtension(input: { extensionUrl: $url }) {
|
||||
extension { apkName pkgName name isInstalled }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_SOURCE_PREFERENCE = `
|
||||
mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) {
|
||||
updateSourcePreference(input: { source: $source, change: $change }) {
|
||||
source { id displayName }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_SOURCE_META = `
|
||||
mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) {
|
||||
setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_SOURCE_META = `
|
||||
mutation DeleteSourceMeta($sourceId: LongString!, $key: String!) {
|
||||
deleteSourceMeta(input: { sourceId: $sourceId, key: $key }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_EXTENSION_REPOS = `
|
||||
mutation SetExtensionRepos($repos: [String!]!) {
|
||||
setSettings(input: { settings: { extensionRepos: $repos } }) {
|
||||
settings { extensionRepos }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_SERVER_AUTH = `
|
||||
mutation SetServerAuth($authMode: AuthMode!, $authUsername: String!, $authPassword: String!) {
|
||||
setSettings(input: { settings: { authMode: $authMode, authUsername: $authUsername, authPassword: $authPassword } }) {
|
||||
settings { authMode authUsername }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CLEAR_CACHED_IMAGES = `
|
||||
mutation ClearCachedImages($cachedPages: Boolean, $cachedThumbnails: Boolean, $downloadedThumbnails: Boolean) {
|
||||
clearCachedImages(input: {
|
||||
cachedPages: $cachedPages
|
||||
cachedThumbnails: $cachedThumbnails
|
||||
downloadedThumbnails: $downloadedThumbnails
|
||||
}) {
|
||||
cachedPages cachedThumbnails downloadedThumbnails
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const RESET_SETTINGS = `
|
||||
mutation ResetSettings {
|
||||
resetSettings(input: {}) {
|
||||
settings { extensionRepos }
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,517 @@
|
||||
import type {
|
||||
ServerAdapter,
|
||||
ServerConfig,
|
||||
ServerStatus,
|
||||
MangaFilters,
|
||||
MangaMeta,
|
||||
PaginatedResult,
|
||||
Page,
|
||||
DownloadItem,
|
||||
UpdateResult,
|
||||
} from '$lib/server-adapters/types'
|
||||
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
|
||||
|
||||
interface GQLResponse<T> {
|
||||
data: T
|
||||
errors?: { message: string }[]
|
||||
}
|
||||
|
||||
const GET_LIBRARY = `
|
||||
query GetLibrary {
|
||||
mangas(condition: { inLibrary: true }) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
|
||||
description status author artist genre inLibraryAt lastFetchedAt
|
||||
source { id name displayName }
|
||||
chapters { totalCount }
|
||||
lastReadChapter { id chapterNumber }
|
||||
firstUnreadChapter { id chapterNumber }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_MANGA = `
|
||||
query GetManga($id: Int!) {
|
||||
manga(id: $id) {
|
||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||
inLibraryAt lastFetchedAt updateStrategy
|
||||
source { id name displayName }
|
||||
lastReadChapter { id chapterNumber lastPageRead }
|
||||
firstUnreadChapter { id chapterNumber }
|
||||
highestNumberedChapter { id chapterNumber }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_CHAPTERS = `
|
||||
query GetChapters($mangaId: Int!) {
|
||||
chapters(condition: { mangaId: $mangaId }) {
|
||||
nodes {
|
||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_DOWNLOAD_STATUS = `
|
||||
query GetDownloadStatus {
|
||||
downloadStatus {
|
||||
state
|
||||
queue {
|
||||
progress state tries
|
||||
chapter {
|
||||
id name pageCount mangaId
|
||||
manga { id title thumbnailUrl }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_EXTENSIONS = `
|
||||
query GetExtensions {
|
||||
extensions {
|
||||
nodes {
|
||||
apkName pkgName name lang versionName
|
||||
isInstalled isObsolete hasUpdate iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_SOURCES = `
|
||||
query GetSources {
|
||||
sources {
|
||||
nodes {
|
||||
id name lang displayName iconUrl isNsfw
|
||||
isConfigurable supportsLatest
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_TRACKERS = `
|
||||
query GetTrackers {
|
||||
trackers {
|
||||
nodes {
|
||||
id name icon isLoggedIn isTokenExpired authUrl
|
||||
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
||||
scores
|
||||
statuses { value name }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_MANGA = `
|
||||
mutation FetchManga($id: Int!) {
|
||||
fetchManga(input: { id: $id }) {
|
||||
manga {
|
||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||
source { id name displayName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_SOURCE_MANGA = `
|
||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) {
|
||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) {
|
||||
mangas { id title thumbnailUrl inLibrary }
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_MANGA = `
|
||||
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
|
||||
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
|
||||
manga { id inLibrary }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SET_MANGA_META = `
|
||||
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
|
||||
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_CHAPTERS = `
|
||||
mutation FetchChapters($mangaId: Int!) {
|
||||
fetchChapters(input: { mangaId: $mangaId }) {
|
||||
chapters {
|
||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_CHAPTER_PAGES = `
|
||||
mutation FetchChapterPages($chapterId: Int!) {
|
||||
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
|
||||
}
|
||||
`
|
||||
|
||||
const MARK_CHAPTER_READ = `
|
||||
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
|
||||
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
|
||||
chapter { id isRead }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const MARK_CHAPTERS_READ = `
|
||||
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
|
||||
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
|
||||
chapters { id isRead }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const ENQUEUE_DOWNLOAD = `
|
||||
mutation EnqueueDownload($chapterId: Int!) {
|
||||
enqueueChapterDownload(input: { id: $chapterId }) {
|
||||
downloadStatus { state }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const DEQUEUE_DOWNLOAD = `
|
||||
mutation DequeueDownload($chapterId: Int!) {
|
||||
dequeueChapterDownload(input: { id: $chapterId }) {
|
||||
downloadStatus { state }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CLEAR_DOWNLOADER = `
|
||||
mutation ClearDownloader {
|
||||
clearDownloader(input: {}) {
|
||||
downloadStatus { state }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_EXTENSIONS = `
|
||||
mutation FetchExtensions {
|
||||
fetchExtensions(input: {}) {
|
||||
extensions {
|
||||
apkName pkgName name lang versionName
|
||||
isInstalled isObsolete hasUpdate iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_EXTENSION = `
|
||||
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||
extension { apkName pkgName name isInstalled hasUpdate }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const BIND_TRACK = `
|
||||
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
||||
trackRecord { id trackerId remoteId }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TRACK_PROGRESS = `
|
||||
mutation TrackProgress($mangaId: Int!) {
|
||||
trackProgress(input: { mangaId: $mangaId }) {
|
||||
trackRecords { id trackerId lastChapterRead status }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_LIBRARY = `
|
||||
mutation UpdateLibrary {
|
||||
updateLibrary(input: {}) {
|
||||
updateStatus { jobsInfo { isRunning finishedJobs totalJobs } }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
function mapChapter(raw: Record<string, unknown>): Chapter {
|
||||
return {
|
||||
id: raw.id as number,
|
||||
name: raw.name as string,
|
||||
chapterNumber: raw.chapterNumber as number,
|
||||
sourceOrder: raw.sourceOrder as number,
|
||||
read: (raw.isRead as boolean) ?? false,
|
||||
downloaded: (raw.isDownloaded as boolean) ?? false,
|
||||
bookmarked: (raw.isBookmarked as boolean) ?? false,
|
||||
pageCount: (raw.pageCount as number) ?? 0,
|
||||
mangaId: raw.mangaId as number,
|
||||
fetchedAt: raw.fetchedAt as string | undefined,
|
||||
uploadDate: raw.uploadDate as string | null | undefined,
|
||||
realUrl: raw.realUrl as string | null | undefined,
|
||||
lastPageRead: raw.lastPageRead as number | undefined,
|
||||
lastReadAt: raw.lastReadAt as string | undefined,
|
||||
scanlator: raw.scanlator as string | null | undefined,
|
||||
manga: raw.manga as Chapter['manga'],
|
||||
}
|
||||
}
|
||||
|
||||
function mapManga(raw: Record<string, unknown>): Manga {
|
||||
const inLibraryAt = raw.inLibraryAt as string | null | undefined
|
||||
return {
|
||||
...(raw as unknown as Manga),
|
||||
tags: raw.genre as string[] | undefined,
|
||||
addedAt: inLibraryAt ? new Date(inLibraryAt).getTime() : undefined,
|
||||
lastReadAt: raw.lastReadChapter
|
||||
? Date.now()
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapExtension(raw: Record<string, unknown>): Extension {
|
||||
return {
|
||||
...(raw as unknown as Extension),
|
||||
id: raw.pkgName as string,
|
||||
}
|
||||
}
|
||||
|
||||
function mapDownloadItem(raw: Record<string, unknown>): DownloadItem {
|
||||
const chapter = raw.chapter as Record<string, unknown>
|
||||
const manga = chapter?.manga as Record<string, unknown>
|
||||
return {
|
||||
chapterId: String(chapter?.id),
|
||||
mangaId: String(chapter?.mangaId ?? manga?.id),
|
||||
chapterName: chapter?.name as string,
|
||||
mangaTitle: manga?.title as string,
|
||||
progress: (raw.progress as number) ?? 0,
|
||||
state: mapDownloadState(raw.state as string),
|
||||
}
|
||||
}
|
||||
|
||||
function mapDownloadState(state: string): DownloadItem['state'] {
|
||||
switch (state) {
|
||||
case 'DOWNLOADING': return 'downloading'
|
||||
case 'FINISHED': return 'finished'
|
||||
case 'ERROR': return 'error'
|
||||
default: return 'queued'
|
||||
}
|
||||
}
|
||||
|
||||
export class SuwayomiAdapter implements ServerAdapter {
|
||||
private baseUrl = 'http://127.0.0.1:4567'
|
||||
private authHeader: string | null = null
|
||||
|
||||
async connect(config: ServerConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '')
|
||||
if (config.credentials) {
|
||||
const { username, password } = config.credentials
|
||||
this.authHeader = 'Basic ' + btoa(`${username}:${password}`)
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus(): Promise<ServerStatus> {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
|
||||
})
|
||||
return res.ok ? 'connected' : 'error'
|
||||
} catch {
|
||||
return 'disconnected'
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (this.authHeader) h['Authorization'] = this.authHeader
|
||||
return h
|
||||
}
|
||||
|
||||
private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
|
||||
const json: GQLResponse<T> = await res.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
return json.data
|
||||
}
|
||||
|
||||
async getManga(id: string): Promise<Manga> {
|
||||
const data = await this.gql<{ manga: Record<string, unknown> }>(
|
||||
GET_MANGA, { id: Number(id) }
|
||||
)
|
||||
return mapManga(data.manga)
|
||||
}
|
||||
|
||||
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
|
||||
if (filters.inLibrary) {
|
||||
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
|
||||
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false }
|
||||
}
|
||||
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
|
||||
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false }
|
||||
}
|
||||
|
||||
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
|
||||
if (!sourceId) return []
|
||||
const data = await this.gql<{
|
||||
fetchSourceManga: { mangas: Record<string, unknown>[] }
|
||||
}>(FETCH_SOURCE_MANGA, {
|
||||
source: sourceId,
|
||||
type: 'SEARCH',
|
||||
page: 1,
|
||||
query,
|
||||
})
|
||||
return data.fetchSourceManga.mangas.map(mapManga)
|
||||
}
|
||||
|
||||
async addToLibrary(mangaId: string) {
|
||||
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: true })
|
||||
}
|
||||
|
||||
async removeFromLibrary(mangaId: string) {
|
||||
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false })
|
||||
}
|
||||
|
||||
async updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value === undefined) continue
|
||||
await this.gql(SET_MANGA_META, {
|
||||
mangaId: Number(id),
|
||||
key,
|
||||
value: String(value),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async getChapters(mangaId: string): Promise<Chapter[]> {
|
||||
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
|
||||
GET_CHAPTERS, { mangaId: Number(mangaId) }
|
||||
)
|
||||
return data.chapters.nodes.map(mapChapter)
|
||||
}
|
||||
|
||||
async getChapter(id: string): Promise<Chapter> {
|
||||
const chapters = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
|
||||
GET_CHAPTERS, { mangaId: 0 }
|
||||
)
|
||||
const found = chapters.chapters.nodes.find(c => String(c.id) === id)
|
||||
if (!found) throw new Error(`Chapter ${id} not found`)
|
||||
return mapChapter(found)
|
||||
}
|
||||
|
||||
async getChapterPages(id: string): Promise<Page[]> {
|
||||
const data = await this.gql<{ fetchChapterPages: { pages: string[] } }>(
|
||||
FETCH_CHAPTER_PAGES, { chapterId: Number(id) }
|
||||
)
|
||||
return data.fetchChapterPages.pages.map((url, index) => ({ index, url }))
|
||||
}
|
||||
|
||||
async markChapterRead(id: string, read: boolean) {
|
||||
await this.gql(MARK_CHAPTER_READ, { id: Number(id), isRead: read })
|
||||
}
|
||||
|
||||
async markChaptersRead(ids: string[], read: boolean) {
|
||||
await this.gql(MARK_CHAPTERS_READ, { ids: ids.map(Number), isRead: read })
|
||||
}
|
||||
|
||||
async getDownloads(): Promise<DownloadItem[]> {
|
||||
const data = await this.gql<{
|
||||
downloadStatus: { queue: Record<string, unknown>[] }
|
||||
}>(GET_DOWNLOAD_STATUS)
|
||||
return data.downloadStatus.queue.map(mapDownloadItem)
|
||||
}
|
||||
|
||||
async enqueueDownload(chapterId: string) {
|
||||
await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
|
||||
}
|
||||
|
||||
async dequeueDownload(chapterId: string) {
|
||||
await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
|
||||
}
|
||||
|
||||
async clearDownloads() {
|
||||
await this.gql(CLEAR_DOWNLOADER)
|
||||
}
|
||||
|
||||
async getExtensions(): Promise<Extension[]> {
|
||||
await this.gql(FETCH_EXTENSIONS)
|
||||
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(
|
||||
GET_EXTENSIONS
|
||||
)
|
||||
return data.extensions.nodes.map(mapExtension)
|
||||
}
|
||||
|
||||
async installExtension(id: string) {
|
||||
await this.gql(UPDATE_EXTENSION, { id, install: true })
|
||||
}
|
||||
|
||||
async uninstallExtension(id: string) {
|
||||
await this.gql(UPDATE_EXTENSION, { id, uninstall: true })
|
||||
}
|
||||
|
||||
async updateExtension(id: string) {
|
||||
await this.gql(UPDATE_EXTENSION, { id, update: true })
|
||||
}
|
||||
|
||||
async getSources(): Promise<Source[]> {
|
||||
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
return data.sources.nodes
|
||||
}
|
||||
|
||||
async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> {
|
||||
const data = await this.gql<{
|
||||
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean }
|
||||
}>(FETCH_SOURCE_MANGA, {
|
||||
source: sourceId,
|
||||
type: 'LATEST',
|
||||
page,
|
||||
})
|
||||
return {
|
||||
items: data.fetchSourceManga.mangas.map(mapManga),
|
||||
hasNextPage: data.fetchSourceManga.hasNextPage,
|
||||
}
|
||||
}
|
||||
|
||||
async getTrackers(): Promise<Tracker[]> {
|
||||
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
|
||||
return data.trackers.nodes
|
||||
}
|
||||
|
||||
async linkTracker(mangaId: string, trackerId: string, remoteId: string) {
|
||||
await this.gql(BIND_TRACK, {
|
||||
mangaId: Number(mangaId),
|
||||
trackerId: Number(trackerId),
|
||||
remoteId,
|
||||
})
|
||||
}
|
||||
|
||||
async syncTracking(mangaId: string) {
|
||||
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
|
||||
}
|
||||
|
||||
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
|
||||
if (mangaIds?.length) {
|
||||
const results: UpdateResult[] = []
|
||||
for (const id of mangaIds) {
|
||||
const before = await this.getChapters(id)
|
||||
await this.gql(FETCH_CHAPTERS, { mangaId: Number(id) })
|
||||
const after = await this.getChapters(id)
|
||||
results.push({ mangaId: id, newChapters: after.length - before.length })
|
||||
}
|
||||
return results
|
||||
}
|
||||
await this.gql(UPDATE_LIBRARY)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
export const GET_LIBRARY = `
|
||||
query GetLibrary {
|
||||
mangas(condition: { inLibrary: true }) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
|
||||
description status author artist genre inLibraryAt lastFetchedAt
|
||||
source { id name displayName }
|
||||
chapters { totalCount }
|
||||
lastReadChapter { id chapterNumber }
|
||||
firstUnreadChapter { id chapterNumber }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_MANGA = `
|
||||
query GetManga($id: Int!) {
|
||||
manga(id: $id) {
|
||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||
inLibraryAt lastFetchedAt updateStrategy
|
||||
source { id name displayName }
|
||||
lastReadChapter { id chapterNumber lastPageRead }
|
||||
firstUnreadChapter { id chapterNumber }
|
||||
highestNumberedChapter { id chapterNumber }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_CATEGORIES = `
|
||||
query GetCategories {
|
||||
categories {
|
||||
nodes {
|
||||
id name order default includeInUpdate includeInDownload
|
||||
mangas {
|
||||
nodes { id title thumbnailUrl inLibrary downloadCount unreadCount }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const MANGAS_BY_GENRE = `
|
||||
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
||||
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary genre status
|
||||
source { id displayName }
|
||||
}
|
||||
pageInfo { hasNextPage }
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const LIBRARY_UPDATE_STATUS = `
|
||||
query LibraryUpdateStatus {
|
||||
libraryUpdateStatus {
|
||||
jobsInfo {
|
||||
isRunning finishedJobs totalJobs skippedMangasCount skippedCategoriesCount
|
||||
}
|
||||
mangaUpdates {
|
||||
status
|
||||
manga { id title thumbnailUrl unreadCount }
|
||||
}
|
||||
}
|
||||
lastUpdateTimestamp { timestamp }
|
||||
}
|
||||
`
|
||||
|
||||
export const FETCH_MANGA = `
|
||||
mutation FetchManga($id: Int!) {
|
||||
fetchManga(input: { id: $id }) {
|
||||
manga {
|
||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||
source { id name displayName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_MANGA = `
|
||||
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
|
||||
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
|
||||
manga { id inLibrary }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_MANGAS = `
|
||||
mutation UpdateMangas($ids: [Int!]!, $inLibrary: Boolean) {
|
||||
updateMangas(input: { ids: $ids, patch: { inLibrary: $inLibrary } }) {
|
||||
mangas { id inLibrary }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_MANGA_CATEGORIES = `
|
||||
mutation UpdateMangaCategories($mangaId: Int!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
|
||||
updateMangaCategories(input: { id: $mangaId, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
|
||||
manga { id }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_MANGAS_CATEGORIES = `
|
||||
mutation UpdateMangasCategories($ids: [Int!]!, $addTo: [Int!]!, $removeFrom: [Int!]!) {
|
||||
updateMangasCategories(input: { ids: $ids, patch: { addToCategories: $addTo, removeFromCategories: $removeFrom } }) {
|
||||
mangas { id }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CREATE_CATEGORY = `
|
||||
mutation CreateCategory($name: String!) {
|
||||
createCategory(input: { name: $name }) {
|
||||
category { id name order default includeInUpdate includeInDownload }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_CATEGORY = `
|
||||
mutation UpdateCategory($id: Int!, $name: String) {
|
||||
updateCategory(input: { id: $id, patch: { name: $name } }) {
|
||||
category { id name order }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_CATEGORY = `
|
||||
mutation DeleteCategory($id: Int!) {
|
||||
deleteCategory(input: { categoryId: $id }) {
|
||||
category { id }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_CATEGORY_ORDER = `
|
||||
mutation UpdateCategoryOrder($id: Int!, $position: Int!) {
|
||||
updateCategoryOrder(input: { id: $id, position: $position }) {
|
||||
categories { id name order default includeInUpdate includeInDownload }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_LIBRARY = `
|
||||
mutation UpdateLibrary {
|
||||
updateLibrary(input: {}) {
|
||||
updateStatus { jobsInfo { isRunning finishedJobs totalJobs } }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const UPDATE_LIBRARY_MANGA = `
|
||||
mutation UpdateLibraryManga($mangaId: Int!) {
|
||||
updateLibraryManga(input: { mangaId: $mangaId }) {
|
||||
updateStatus { jobsInfo { isRunning finishedJobs totalJobs } }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const SET_MANGA_META = `
|
||||
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
|
||||
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const DELETE_MANGA_META = `
|
||||
mutation DeleteMangaMeta($mangaId: Int!, $key: String!) {
|
||||
deleteMangaMeta(input: { mangaId: $mangaId, key: $key }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const CREATE_BACKUP = `
|
||||
mutation CreateBackup {
|
||||
createBackup(input: {}) { url }
|
||||
}
|
||||
`
|
||||
|
||||
export const RESTORE_BACKUP = `
|
||||
mutation RestoreBackup($backup: Upload!) {
|
||||
restoreBackup(input: { backup: $backup }) {
|
||||
id
|
||||
status { mangaProgress state totalManga }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_RESTORE_STATUS = `
|
||||
query GetRestoreStatus($id: String!) {
|
||||
restoreStatus(id: $id) { mangaProgress state totalManga }
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Manga, Chapter, Extension } from '$lib/types'
|
||||
import type { DownloadItem } from '$lib/server-adapters/types'
|
||||
|
||||
export interface GQLResponse<T> {
|
||||
data: T
|
||||
errors?: { message: string }[]
|
||||
}
|
||||
|
||||
export function mapManga(raw: Record<string, unknown>): Manga {
|
||||
const inLibraryAt = raw.inLibraryAt as string | null | undefined
|
||||
return {
|
||||
...(raw as unknown as Manga),
|
||||
tags: raw.genre as string[] | undefined,
|
||||
addedAt: inLibraryAt ? new Date(inLibraryAt).getTime() : undefined,
|
||||
lastReadAt: raw.lastReadChapter ? Date.now() : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapChapter(raw: Record<string, unknown>): Chapter {
|
||||
return {
|
||||
id: raw.id as number,
|
||||
name: raw.name as string,
|
||||
chapterNumber: raw.chapterNumber as number,
|
||||
sourceOrder: raw.sourceOrder as number,
|
||||
read: (raw.isRead as boolean) ?? false,
|
||||
downloaded: (raw.isDownloaded as boolean) ?? false,
|
||||
bookmarked: (raw.isBookmarked as boolean) ?? false,
|
||||
pageCount: (raw.pageCount as number) ?? 0,
|
||||
mangaId: raw.mangaId as number,
|
||||
fetchedAt: raw.fetchedAt as string | undefined,
|
||||
uploadDate: raw.uploadDate as string | null | undefined,
|
||||
realUrl: raw.realUrl as string | null | undefined,
|
||||
lastPageRead: raw.lastPageRead as number | undefined,
|
||||
lastReadAt: raw.lastReadAt as string | undefined,
|
||||
scanlator: raw.scanlator as string | null | undefined,
|
||||
manga: raw.manga as Chapter['manga'],
|
||||
}
|
||||
}
|
||||
|
||||
export function mapExtension(raw: Record<string, unknown>): Extension {
|
||||
return {
|
||||
...(raw as unknown as Extension),
|
||||
id: raw.pkgName as string,
|
||||
}
|
||||
}
|
||||
|
||||
export function mapDownloadItem(raw: Record<string, unknown>): DownloadItem {
|
||||
const chapter = raw.chapter as Record<string, unknown>
|
||||
const manga = chapter?.manga as Record<string, unknown>
|
||||
return {
|
||||
chapterId: String(chapter?.id),
|
||||
mangaId: String(chapter?.mangaId ?? manga?.id),
|
||||
chapterName: chapter?.name as string,
|
||||
mangaTitle: manga?.title as string,
|
||||
progress: (raw.progress as number) ?? 0,
|
||||
state: mapDownloadState(raw.state as string),
|
||||
}
|
||||
}
|
||||
|
||||
function mapDownloadState(state: string): DownloadItem['state'] {
|
||||
switch (state) {
|
||||
case 'DOWNLOADING': return 'downloading'
|
||||
case 'FINISHED': return 'finished'
|
||||
case 'ERROR': return 'error'
|
||||
default: return 'queued'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type {
|
||||
Manga,
|
||||
Chapter,
|
||||
Extension,
|
||||
Source,
|
||||
Tracker,
|
||||
} from '$lib/types'
|
||||
|
||||
export interface ServerConfig {
|
||||
baseUrl: string
|
||||
credentials?: { username: string; password: string }
|
||||
}
|
||||
|
||||
export type ServerStatus = 'connected' | 'disconnected' | 'error'
|
||||
|
||||
export interface MangaFilters {
|
||||
inLibrary?: boolean
|
||||
status?: MangaStatus
|
||||
tags?: string[]
|
||||
unread?: boolean
|
||||
sourceId?: string
|
||||
}
|
||||
|
||||
export type MangaStatus = 'ONGOING' | 'COMPLETED' | 'LICENSED' | 'PUBLISHING_FINISHED' | 'CANCELLED' | 'ON_HIATUS'
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
hasNextPage: boolean
|
||||
total?: number
|
||||
}
|
||||
|
||||
export interface MangaMeta {
|
||||
customTitle?: string
|
||||
customCover?: string
|
||||
notes?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Page {
|
||||
index: number
|
||||
url: string
|
||||
imageData?: string
|
||||
}
|
||||
|
||||
export interface DownloadItem {
|
||||
chapterId: string
|
||||
mangaId: string
|
||||
chapterName: string
|
||||
mangaTitle: string
|
||||
progress: number
|
||||
state: 'queued' | 'downloading' | 'finished' | 'error'
|
||||
}
|
||||
|
||||
export interface UpdateResult {
|
||||
mangaId: string
|
||||
newChapters: number
|
||||
}
|
||||
|
||||
export interface ServerAdapter {
|
||||
connect(config: ServerConfig): Promise<void>
|
||||
getStatus(): Promise<ServerStatus>
|
||||
|
||||
getManga(id: string): Promise<Manga>
|
||||
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
|
||||
searchManga(query: string, sourceId?: string): Promise<Manga[]>
|
||||
addToLibrary(mangaId: string): Promise<void>
|
||||
removeFromLibrary(mangaId: string): Promise<void>
|
||||
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>
|
||||
|
||||
getChapters(mangaId: string): Promise<Chapter[]>
|
||||
getChapter(id: string): Promise<Chapter>
|
||||
getChapterPages(id: string): Promise<Page[]>
|
||||
markChapterRead(id: string, read: boolean): Promise<void>
|
||||
markChaptersRead(ids: string[], read: boolean): Promise<void>
|
||||
|
||||
getDownloads(): Promise<DownloadItem[]>
|
||||
enqueueDownload(chapterId: string): Promise<void>
|
||||
dequeueDownload(chapterId: string): Promise<void>
|
||||
clearDownloads(): Promise<void>
|
||||
|
||||
getExtensions(): Promise<Extension[]>
|
||||
installExtension(id: string): Promise<void>
|
||||
uninstallExtension(id: string): Promise<void>
|
||||
updateExtension(id: string): Promise<void>
|
||||
|
||||
getSources(): Promise<Source[]>
|
||||
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
|
||||
|
||||
getTrackers(): Promise<Tracker[]>
|
||||
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
|
||||
syncTracking(mangaId: string): Promise<void>
|
||||
|
||||
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type AppStatus = 'booting' | 'auth' | 'ready' | 'error'
|
||||
|
||||
export const appState = $state({
|
||||
status: 'booting' as AppStatus,
|
||||
error: null as string | null,
|
||||
serverUrl: '',
|
||||
authenticated: false,
|
||||
platform: 'web' as 'web' | 'tauri' | 'capacitor',
|
||||
version: '',
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DownloadItem } from '$lib/server-adapters/types'
|
||||
|
||||
export const downloadsState = $state({
|
||||
items: [] as DownloadItem[],
|
||||
error: null as string | null,
|
||||
})
|
||||
|
||||
export const activeDownloads = $derived(
|
||||
downloadsState.items.filter(d => d.state === 'downloading')
|
||||
)
|
||||
|
||||
export const queuedDownloads = $derived(
|
||||
downloadsState.items.filter(d => d.state === 'queued')
|
||||
)
|
||||
|
||||
export const downloadCount = $derived(downloadsState.items.length)
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Extension, Source, Manga } from '$lib/types'
|
||||
|
||||
export const extensionsState = $state({
|
||||
items: [] as Extension[],
|
||||
sources: [] as Source[],
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
filter: {
|
||||
query: '',
|
||||
installed: false,
|
||||
language: 'all',
|
||||
},
|
||||
|
||||
browseResults: [] as Manga[],
|
||||
browseLoading: false,
|
||||
browseError: null as string | null,
|
||||
browseHasMore: false,
|
||||
})
|
||||
|
||||
export const filteredExtensions = $derived.by(() => {
|
||||
let result = extensionsState.items
|
||||
|
||||
if (extensionsState.filter.installed) {
|
||||
result = result.filter(e => e.installed)
|
||||
}
|
||||
if (extensionsState.filter.language !== 'all') {
|
||||
result = result.filter(e => e.lang === extensionsState.filter.language)
|
||||
}
|
||||
if (extensionsState.filter.query) {
|
||||
const q = extensionsState.filter.query.toLowerCase()
|
||||
result = result.filter(e => e.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Manga } from '$lib/types'
|
||||
import type { MangaStatus } from '$lib/server-adapters/types'
|
||||
|
||||
export type LibrarySortOption = 'alphabetical' | 'unread' | 'lastRead' | 'dateAdded'
|
||||
|
||||
export const libraryState = $state({
|
||||
items: [] as Manga[],
|
||||
searchResults: [] as Manga[],
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
filter: {
|
||||
status: 'all' as MangaStatus | 'all',
|
||||
tags: [] as string[],
|
||||
unread: false,
|
||||
query: '',
|
||||
},
|
||||
sort: 'alphabetical' as LibrarySortOption,
|
||||
sortDesc: false,
|
||||
view: 'grid' as 'grid' | 'list',
|
||||
selected: new Set<string>(),
|
||||
})
|
||||
|
||||
export const filteredItems = $derived.by(() => {
|
||||
let result = libraryState.items
|
||||
|
||||
if (libraryState.filter.unread) {
|
||||
result = result.filter(m => m.unreadCount > 0)
|
||||
}
|
||||
if (libraryState.filter.status !== 'all') {
|
||||
result = result.filter(m => m.status === libraryState.filter.status)
|
||||
}
|
||||
if (libraryState.filter.tags.length > 0) {
|
||||
result = result.filter(m =>
|
||||
libraryState.filter.tags.every(tag => m.tags?.includes(tag))
|
||||
)
|
||||
}
|
||||
if (libraryState.filter.query) {
|
||||
const q = libraryState.filter.query.toLowerCase()
|
||||
result = result.filter(m => m.title.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
const sorted = [...result].sort((a, b) => {
|
||||
switch (libraryState.sort) {
|
||||
case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0)
|
||||
case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0)
|
||||
case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0)
|
||||
case 'alphabetical':
|
||||
default: return a.title.localeCompare(b.title)
|
||||
}
|
||||
})
|
||||
|
||||
return libraryState.sortDesc ? sorted.reverse() : sorted
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
export type ToastKind = 'info' | 'success' | 'error' | 'download'
|
||||
|
||||
export interface Toast {
|
||||
id: string
|
||||
kind: ToastKind
|
||||
message: string
|
||||
detail?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export const notificationsState = $state({
|
||||
toasts: [] as Toast[],
|
||||
})
|
||||
|
||||
export function toast(kind: ToastKind, message: string, detail?: string, duration = 4000) {
|
||||
const id = crypto.randomUUID()
|
||||
notificationsState.toasts.push({ id, kind, message, detail, duration })
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismissToast(id), duration)
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissToast(id: string) {
|
||||
notificationsState.toasts = notificationsState.toasts.filter(t => t.id !== id)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Manga, Chapter } from '$lib/types'
|
||||
import type { Page } from '$lib/server-adapters/types'
|
||||
|
||||
export type ReadMode = 'single' | 'strip'
|
||||
export type FitMode = 'width' | 'height' | 'original'
|
||||
export type ReadDirection = 'ltr' | 'rtl'
|
||||
|
||||
export const readerState = $state({
|
||||
manga: null as Manga | null,
|
||||
chapter: null as Chapter | null,
|
||||
chapters: [] as Chapter[],
|
||||
|
||||
pages: [] as Page[],
|
||||
pagesLoading: false,
|
||||
pagesError: null as string | null,
|
||||
|
||||
currentPage: 0,
|
||||
mode: 'single' as ReadMode,
|
||||
fit: 'width' as FitMode,
|
||||
direction: 'ltr' as ReadDirection,
|
||||
zoom: 1,
|
||||
|
||||
showControls: false,
|
||||
showSettings: false,
|
||||
fullscreen: false,
|
||||
})
|
||||
|
||||
export const currentPageData = $derived(
|
||||
readerState.pages[readerState.currentPage] ?? null
|
||||
)
|
||||
|
||||
export const progress = $derived(
|
||||
readerState.pages.length > 0
|
||||
? (readerState.currentPage + 1) / readerState.pages.length
|
||||
: 0
|
||||
)
|
||||
|
||||
export const hasPrev = $derived(readerState.currentPage > 0)
|
||||
export const hasNext = $derived(
|
||||
readerState.currentPage < readerState.pages.length - 1
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Manga, Chapter } from '$lib/types'
|
||||
|
||||
export const seriesState = $state({
|
||||
current: null as Manga | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
chapters: [] as Chapter[],
|
||||
chaptersLoading: false,
|
||||
chaptersError: null as string | null,
|
||||
|
||||
chapterFilter: {
|
||||
unread: false,
|
||||
downloaded: false,
|
||||
query: '',
|
||||
},
|
||||
chapterSortDesc: true,
|
||||
})
|
||||
|
||||
export const filteredChapters = $derived.by(() => {
|
||||
let result = seriesState.chapters
|
||||
|
||||
if (seriesState.chapterFilter.unread) {
|
||||
result = result.filter(c => !c.read)
|
||||
}
|
||||
if (seriesState.chapterFilter.downloaded) {
|
||||
result = result.filter(c => c.downloaded)
|
||||
}
|
||||
if (seriesState.chapterFilter.query) {
|
||||
const q = seriesState.chapterFilter.query.toLowerCase()
|
||||
result = result.filter(c => c.name.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
const sorted = [...result].sort((a, b) => a.chapterNumber - b.chapterNumber)
|
||||
return seriesState.chapterSortDesc ? sorted.reverse() : sorted
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Tracker } from '$lib/types'
|
||||
|
||||
export const trackingState = $state({
|
||||
trackers: [] as Tracker[],
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
syncing: false,
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface DownloadQueueItem {
|
||||
progress: number
|
||||
state: "QUEUED" | "DOWNLOADING" | "FINISHED" | "ERROR"
|
||||
tries: number
|
||||
chapter: {
|
||||
id: number
|
||||
name: string
|
||||
mangaId: number
|
||||
pageCount: number
|
||||
manga: { id: number; title: string; thumbnailUrl: string } | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface DownloadStatus {
|
||||
state: "STARTED" | "STOPPED"
|
||||
queue: DownloadQueueItem[]
|
||||
}
|
||||
|
||||
export interface Connection<T> {
|
||||
nodes: T[]
|
||||
}
|
||||
|
||||
export interface PageInfo {
|
||||
hasNextPage: boolean
|
||||
}
|
||||
|
||||
export interface PaginatedConnection<T> extends Connection<T> {
|
||||
pageInfo: PageInfo
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
export interface MetaEntry {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface UpdaterJobsInfo {
|
||||
isRunning: boolean
|
||||
finishedJobs: number
|
||||
totalJobs: number
|
||||
skippedMangasCount: number
|
||||
skippedCategoriesCount: number
|
||||
}
|
||||
|
||||
export interface UpdateStatus {
|
||||
jobsInfo: UpdaterJobsInfo
|
||||
}
|
||||
|
||||
export interface AboutServer {
|
||||
name: string
|
||||
version: string
|
||||
buildType: string
|
||||
buildTime: string
|
||||
github: string
|
||||
discord: string
|
||||
}
|
||||
|
||||
export interface ServerUpdateEntry {
|
||||
channel: string
|
||||
tag: string
|
||||
url: string
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface Chapter {
|
||||
id: number
|
||||
name: string
|
||||
chapterNumber: number
|
||||
sourceOrder: number
|
||||
read: boolean
|
||||
downloaded: boolean
|
||||
bookmarked: boolean
|
||||
pageCount: number
|
||||
mangaId: number
|
||||
fetchedAt?: string
|
||||
uploadDate?: string | null
|
||||
realUrl?: string | null
|
||||
url?: string
|
||||
lastPageRead?: number
|
||||
lastReadAt?: string
|
||||
scanlator?: string | null
|
||||
manga?: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } | null
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface Source {
|
||||
id: string
|
||||
name: string
|
||||
lang: string
|
||||
displayName: string
|
||||
iconUrl: string
|
||||
isNsfw: boolean
|
||||
isConfigurable: boolean
|
||||
supportsLatest: boolean
|
||||
baseUrl?: string | null
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
apkName: string
|
||||
pkgName: string
|
||||
name: string
|
||||
lang: string
|
||||
versionName: string
|
||||
isInstalled: boolean
|
||||
isObsolete: boolean
|
||||
hasUpdate: boolean
|
||||
iconUrl: string
|
||||
id: string
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export interface HistoryEntry {
|
||||
mangaId: number
|
||||
mangaTitle: string
|
||||
thumbnailUrl: string
|
||||
chapterId: number
|
||||
chapterName: string
|
||||
readAt: number
|
||||
}
|
||||
|
||||
export interface BookmarkEntry {
|
||||
mangaId: number
|
||||
mangaTitle: string
|
||||
thumbnailUrl: string
|
||||
chapterId: number
|
||||
chapterName: string
|
||||
pageNumber: number
|
||||
savedAt: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
export type MarkerColor = "yellow" | "red" | "blue" | "green" | "purple"
|
||||
|
||||
export interface MarkerEntry {
|
||||
id: string
|
||||
mangaId: number
|
||||
mangaTitle: string
|
||||
thumbnailUrl: string
|
||||
chapterId: number
|
||||
chapterName: string
|
||||
pageNumber: number
|
||||
note: string
|
||||
color: MarkerColor
|
||||
createdAt: number
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
export interface ReadLogEntry {
|
||||
mangaId: number
|
||||
chapterId: number
|
||||
readAt: number
|
||||
minutes: number
|
||||
}
|
||||
|
||||
export interface ReadingStats {
|
||||
totalChaptersRead: number
|
||||
totalMangaRead: number
|
||||
totalMinutesRead: number
|
||||
firstReadAt: number
|
||||
lastReadAt: number
|
||||
currentStreakDays: number
|
||||
longestStreakDays: number
|
||||
lastStreakDate: string
|
||||
}
|
||||
|
||||
export const DEFAULT_READING_STATS: ReadingStats = {
|
||||
totalChaptersRead: 0,
|
||||
totalMangaRead: 0,
|
||||
totalMinutesRead: 0,
|
||||
firstReadAt: 0,
|
||||
lastReadAt: 0,
|
||||
currentStreakDays: 0,
|
||||
longestStreakDays: 0,
|
||||
lastStreakDate: "",
|
||||
}
|
||||
|
||||
export interface LibraryUpdateEntry {
|
||||
mangaId: number
|
||||
mangaTitle: string
|
||||
thumbnailUrl: string
|
||||
newChapters: number
|
||||
checkedAt: number
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
import type {
|
||||
ServerAdapter,
|
||||
ServerConfig,
|
||||
ServerStatus,
|
||||
MangaFilters,
|
||||
MangaMeta,
|
||||
PaginatedResult,
|
||||
Page,
|
||||
DownloadItem,
|
||||
UpdateResult,
|
||||
} from '$lib/server-adapters/types'
|
||||
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types'
|
||||
|
||||
// ─── GQL client ────────────────────────────────────────────────────────────
|
||||
|
||||
interface GQLResponse<T> {
|
||||
data: T
|
||||
errors?: { message: string }[]
|
||||
}
|
||||
|
||||
// ─── Queries ────────────────────────────────────────────────────────────────
|
||||
|
||||
const GET_LIBRARY = `
|
||||
query GetLibrary {
|
||||
mangas(condition: { inLibrary: true }) {
|
||||
nodes {
|
||||
id title thumbnailUrl inLibrary downloadCount unreadCount bookmarkCount
|
||||
description status author artist genre inLibraryAt lastFetchedAt
|
||||
source { id name displayName }
|
||||
chapters { totalCount }
|
||||
lastReadChapter { id chapterNumber }
|
||||
firstUnreadChapter { id chapterNumber }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_MANGA = `
|
||||
query GetManga($id: Int!) {
|
||||
manga(id: $id) {
|
||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||
inLibraryAt lastFetchedAt updateStrategy
|
||||
source { id name displayName }
|
||||
lastReadChapter { id chapterNumber lastPageRead }
|
||||
firstUnreadChapter { id chapterNumber }
|
||||
highestNumberedChapter { id chapterNumber }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_CHAPTERS = `
|
||||
query GetChapters($mangaId: Int!) {
|
||||
chapters(condition: { mangaId: $mangaId }) {
|
||||
nodes {
|
||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_DOWNLOAD_STATUS = `
|
||||
query GetDownloadStatus {
|
||||
downloadStatus {
|
||||
state
|
||||
queue {
|
||||
progress state tries
|
||||
chapter {
|
||||
id name pageCount mangaId
|
||||
manga { id title thumbnailUrl }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_EXTENSIONS = `
|
||||
query GetExtensions {
|
||||
extensions {
|
||||
nodes {
|
||||
apkName pkgName name lang versionName
|
||||
isInstalled isObsolete hasUpdate iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_SOURCES = `
|
||||
query GetSources {
|
||||
sources {
|
||||
nodes {
|
||||
id name lang displayName iconUrl isNsfw
|
||||
isConfigurable supportsLatest
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_TRACKERS = `
|
||||
query GetTrackers {
|
||||
trackers {
|
||||
nodes {
|
||||
id name icon isLoggedIn isTokenExpired authUrl
|
||||
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
||||
scores
|
||||
statuses { value name }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// ─── Mutations ──────────────────────────────────────────────────────────────
|
||||
|
||||
const FETCH_MANGA = `
|
||||
mutation FetchManga($id: Int!) {
|
||||
fetchManga(input: { id: $id }) {
|
||||
manga {
|
||||
id title description thumbnailUrl status author artist genre inLibrary realUrl
|
||||
source { id name displayName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_SOURCE_MANGA = `
|
||||
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) {
|
||||
fetchSourceManga(input: { source: $source, type: $type, page: $page, query: $query }) {
|
||||
mangas { id title thumbnailUrl inLibrary }
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_MANGA = `
|
||||
mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
|
||||
updateManga(input: { id: $id, patch: { inLibrary: $inLibrary } }) {
|
||||
manga { id inLibrary }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SET_MANGA_META = `
|
||||
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
|
||||
setMangaMeta(input: { meta: { mangaId: $mangaId, key: $key, value: $value } }) {
|
||||
meta { key value }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_CHAPTERS = `
|
||||
mutation FetchChapters($mangaId: Int!) {
|
||||
fetchChapters(input: { mangaId: $mangaId }) {
|
||||
chapters {
|
||||
id name chapterNumber sourceOrder isRead isDownloaded isBookmarked
|
||||
pageCount mangaId uploadDate realUrl lastPageRead lastReadAt scanlator
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_CHAPTER_PAGES = `
|
||||
mutation FetchChapterPages($chapterId: Int!) {
|
||||
fetchChapterPages(input: { chapterId: $chapterId }) { pages }
|
||||
}
|
||||
`
|
||||
|
||||
const MARK_CHAPTER_READ = `
|
||||
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
|
||||
updateChapter(input: { id: $id, patch: { isRead: $isRead } }) {
|
||||
chapter { id isRead }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const MARK_CHAPTERS_READ = `
|
||||
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
|
||||
updateChapters(input: { ids: $ids, patch: { isRead: $isRead } }) {
|
||||
chapters { id isRead }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const ENQUEUE_DOWNLOAD = `
|
||||
mutation EnqueueDownload($chapterId: Int!) {
|
||||
enqueueChapterDownload(input: { id: $chapterId }) {
|
||||
downloadStatus { state }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const DEQUEUE_DOWNLOAD = `
|
||||
mutation DequeueDownload($chapterId: Int!) {
|
||||
dequeueChapterDownload(input: { id: $chapterId }) {
|
||||
downloadStatus { state }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CLEAR_DOWNLOADER = `
|
||||
mutation ClearDownloader {
|
||||
clearDownloader(input: {}) {
|
||||
downloadStatus { state }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const FETCH_EXTENSIONS = `
|
||||
mutation FetchExtensions {
|
||||
fetchExtensions(input: {}) {
|
||||
extensions {
|
||||
apkName pkgName name lang versionName
|
||||
isInstalled isObsolete hasUpdate iconUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_EXTENSION = `
|
||||
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
|
||||
updateExtension(input: { id: $id, patch: { install: $install, uninstall: $uninstall, update: $update } }) {
|
||||
extension { apkName pkgName name isInstalled hasUpdate }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const BIND_TRACK = `
|
||||
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||
bindTrack(input: { mangaId: $mangaId, trackerId: $trackerId, remoteId: $remoteId }) {
|
||||
trackRecord { id trackerId remoteId }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TRACK_PROGRESS = `
|
||||
mutation TrackProgress($mangaId: Int!) {
|
||||
trackProgress(input: { mangaId: $mangaId }) {
|
||||
trackRecords { id trackerId lastChapterRead status }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const UPDATE_LIBRARY = `
|
||||
mutation UpdateLibrary {
|
||||
updateLibrary(input: {}) {
|
||||
updateStatus { jobsInfo { isRunning finishedJobs totalJobs } }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// ─── Mappers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function mapChapter(raw: Record<string, unknown>): Chapter {
|
||||
return {
|
||||
id: raw.id as number,
|
||||
name: raw.name as string,
|
||||
chapterNumber: raw.chapterNumber as number,
|
||||
sourceOrder: raw.sourceOrder as number,
|
||||
read: (raw.isRead as boolean) ?? false,
|
||||
downloaded: (raw.isDownloaded as boolean) ?? false,
|
||||
bookmarked: (raw.isBookmarked as boolean) ?? false,
|
||||
pageCount: (raw.pageCount as number) ?? 0,
|
||||
mangaId: raw.mangaId as number,
|
||||
fetchedAt: raw.fetchedAt as string | undefined,
|
||||
uploadDate: raw.uploadDate as string | null | undefined,
|
||||
realUrl: raw.realUrl as string | null | undefined,
|
||||
lastPageRead: raw.lastPageRead as number | undefined,
|
||||
lastReadAt: raw.lastReadAt as string | undefined,
|
||||
scanlator: raw.scanlator as string | null | undefined,
|
||||
manga: raw.manga as Chapter['manga'],
|
||||
}
|
||||
}
|
||||
|
||||
function mapManga(raw: Record<string, unknown>): Manga {
|
||||
const inLibraryAt = raw.inLibraryAt as string | null | undefined
|
||||
return {
|
||||
...(raw as unknown as Manga),
|
||||
tags: raw.genre as string[] | undefined,
|
||||
addedAt: inLibraryAt ? new Date(inLibraryAt).getTime() : undefined,
|
||||
lastReadAt: raw.lastReadChapter
|
||||
? Date.now()
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapExtension(raw: Record<string, unknown>): Extension {
|
||||
return {
|
||||
...(raw as unknown as Extension),
|
||||
id: raw.pkgName as string,
|
||||
}
|
||||
}
|
||||
|
||||
function mapDownloadItem(raw: Record<string, unknown>): DownloadItem {
|
||||
const chapter = raw.chapter as Record<string, unknown>
|
||||
const manga = chapter?.manga as Record<string, unknown>
|
||||
return {
|
||||
chapterId: String(chapter?.id),
|
||||
mangaId: String(chapter?.mangaId ?? manga?.id),
|
||||
chapterName: chapter?.name as string,
|
||||
mangaTitle: manga?.title as string,
|
||||
progress: (raw.progress as number) ?? 0,
|
||||
state: mapDownloadState(raw.state as string),
|
||||
}
|
||||
}
|
||||
|
||||
function mapDownloadState(state: string): DownloadItem['state'] {
|
||||
switch (state) {
|
||||
case 'DOWNLOADING': return 'downloading'
|
||||
case 'FINISHED': return 'finished'
|
||||
case 'ERROR': return 'error'
|
||||
default: return 'queued'
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Adapter ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class SuwayomiAdapter implements ServerAdapter {
|
||||
private baseUrl = 'http://127.0.0.1:4567'
|
||||
private authHeader: string | null = null
|
||||
|
||||
async connect(config: ServerConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, '')
|
||||
if (config.credentials) {
|
||||
const { username, password } = config.credentials
|
||||
this.authHeader = 'Basic ' + btoa(`${username}:${password}`)
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus(): Promise<ServerStatus> {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ query: '{ aboutServer { name } }' }),
|
||||
})
|
||||
return res.ok ? 'connected' : 'error'
|
||||
} catch {
|
||||
return 'disconnected'
|
||||
}
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
if (this.authHeader) h['Authorization'] = this.authHeader
|
||||
return h
|
||||
}
|
||||
|
||||
private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`)
|
||||
const json: GQLResponse<T> = await res.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
return json.data
|
||||
}
|
||||
|
||||
// ── Manga ──────────────────────────────────────────────────────────────
|
||||
|
||||
async getManga(id: string): Promise<Manga> {
|
||||
const data = await this.gql<{ manga: Record<string, unknown> }>(
|
||||
GET_MANGA, { id: Number(id) }
|
||||
)
|
||||
return mapManga(data.manga)
|
||||
}
|
||||
|
||||
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
|
||||
if (filters.inLibrary) {
|
||||
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
|
||||
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false }
|
||||
}
|
||||
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
|
||||
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false }
|
||||
}
|
||||
|
||||
async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
|
||||
if (!sourceId) return []
|
||||
const data = await this.gql<{
|
||||
fetchSourceManga: { mangas: Record<string, unknown>[] }
|
||||
}>(FETCH_SOURCE_MANGA, {
|
||||
source: sourceId,
|
||||
type: 'SEARCH',
|
||||
page: 1,
|
||||
query,
|
||||
})
|
||||
return data.fetchSourceManga.mangas.map(mapManga)
|
||||
}
|
||||
|
||||
async addToLibrary(mangaId: string) {
|
||||
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: true })
|
||||
}
|
||||
|
||||
async removeFromLibrary(mangaId: string) {
|
||||
await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false })
|
||||
}
|
||||
|
||||
async updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value === undefined) continue
|
||||
await this.gql(SET_MANGA_META, {
|
||||
mangaId: Number(id),
|
||||
key,
|
||||
value: String(value),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Chapters ───────────────────────────────────────────────────────────
|
||||
|
||||
async getChapters(mangaId: string): Promise<Chapter[]> {
|
||||
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
|
||||
GET_CHAPTERS, { mangaId: Number(mangaId) }
|
||||
)
|
||||
return data.chapters.nodes.map(mapChapter)
|
||||
}
|
||||
|
||||
async getChapter(id: string): Promise<Chapter> {
|
||||
const chapters = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
|
||||
GET_CHAPTERS, { mangaId: 0 }
|
||||
)
|
||||
const found = chapters.chapters.nodes.find(c => String(c.id) === id)
|
||||
if (!found) throw new Error(`Chapter ${id} not found`)
|
||||
return mapChapter(found)
|
||||
}
|
||||
|
||||
async getChapterPages(id: string): Promise<Page[]> {
|
||||
const data = await this.gql<{ fetchChapterPages: { pages: string[] } }>(
|
||||
FETCH_CHAPTER_PAGES, { chapterId: Number(id) }
|
||||
)
|
||||
return data.fetchChapterPages.pages.map((url, index) => ({ index, url }))
|
||||
}
|
||||
|
||||
async markChapterRead(id: string, read: boolean) {
|
||||
await this.gql(MARK_CHAPTER_READ, { id: Number(id), isRead: read })
|
||||
}
|
||||
|
||||
async markChaptersRead(ids: string[], read: boolean) {
|
||||
await this.gql(MARK_CHAPTERS_READ, { ids: ids.map(Number), isRead: read })
|
||||
}
|
||||
|
||||
// ── Downloads ──────────────────────────────────────────────────────────
|
||||
|
||||
async getDownloads(): Promise<DownloadItem[]> {
|
||||
const data = await this.gql<{
|
||||
downloadStatus: { queue: Record<string, unknown>[] }
|
||||
}>(GET_DOWNLOAD_STATUS)
|
||||
return data.downloadStatus.queue.map(mapDownloadItem)
|
||||
}
|
||||
|
||||
async enqueueDownload(chapterId: string) {
|
||||
await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
|
||||
}
|
||||
|
||||
async dequeueDownload(chapterId: string) {
|
||||
await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) })
|
||||
}
|
||||
|
||||
async clearDownloads() {
|
||||
await this.gql(CLEAR_DOWNLOADER)
|
||||
}
|
||||
|
||||
// ── Extensions ─────────────────────────────────────────────────────────
|
||||
|
||||
async getExtensions(): Promise<Extension[]> {
|
||||
await this.gql(FETCH_EXTENSIONS)
|
||||
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(
|
||||
GET_EXTENSIONS
|
||||
)
|
||||
return data.extensions.nodes.map(mapExtension)
|
||||
}
|
||||
|
||||
async installExtension(id: string) {
|
||||
await this.gql(UPDATE_EXTENSION, { id, install: true })
|
||||
}
|
||||
|
||||
async uninstallExtension(id: string) {
|
||||
await this.gql(UPDATE_EXTENSION, { id, uninstall: true })
|
||||
}
|
||||
|
||||
async updateExtension(id: string) {
|
||||
await this.gql(UPDATE_EXTENSION, { id, update: true })
|
||||
}
|
||||
|
||||
async getSources(): Promise<Source[]> {
|
||||
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
return data.sources.nodes
|
||||
}
|
||||
|
||||
async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> {
|
||||
const data = await this.gql<{
|
||||
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean }
|
||||
}>(FETCH_SOURCE_MANGA, {
|
||||
source: sourceId,
|
||||
type: 'LATEST',
|
||||
page,
|
||||
})
|
||||
return {
|
||||
items: data.fetchSourceManga.mangas.map(mapManga),
|
||||
hasNextPage: data.fetchSourceManga.hasNextPage,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tracking ───────────────────────────────────────────────────────────
|
||||
|
||||
async getTrackers(): Promise<Tracker[]> {
|
||||
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
|
||||
return data.trackers.nodes
|
||||
}
|
||||
|
||||
async linkTracker(mangaId: string, trackerId: string, remoteId: string) {
|
||||
await this.gql(BIND_TRACK, {
|
||||
mangaId: Number(mangaId),
|
||||
trackerId: Number(trackerId),
|
||||
remoteId,
|
||||
})
|
||||
}
|
||||
|
||||
async syncTracking(mangaId: string) {
|
||||
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
|
||||
}
|
||||
|
||||
// ── Updates ────────────────────────────────────────────────────────────
|
||||
|
||||
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
|
||||
if (mangaIds?.length) {
|
||||
const results: UpdateResult[] = []
|
||||
for (const id of mangaIds) {
|
||||
const before = await this.getChapters(id)
|
||||
await this.gql(FETCH_CHAPTERS, { mangaId: Number(id) })
|
||||
const after = await this.getChapters(id)
|
||||
results.push({ mangaId: id, newChapters: after.length - before.length })
|
||||
}
|
||||
return results
|
||||
}
|
||||
await this.gql(UPDATE_LIBRARY)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
order: number
|
||||
default: boolean
|
||||
includeInUpdate: string
|
||||
includeInDownload: string
|
||||
mangas?: { nodes: Manga[] }
|
||||
}
|
||||
|
||||
export interface ChapterRef {
|
||||
id: number
|
||||
chapterNumber: number
|
||||
uploadDate?: string
|
||||
lastPageRead?: number
|
||||
}
|
||||
|
||||
export interface Manga {
|
||||
id: number
|
||||
title: string
|
||||
thumbnailUrl: string
|
||||
inLibrary: boolean
|
||||
initialized?: boolean
|
||||
downloadCount?: number
|
||||
unreadCount?: number
|
||||
bookmarkCount?: number
|
||||
hasDuplicateChapters?: boolean
|
||||
chapters?: { totalCount: number }
|
||||
description?: string | null
|
||||
status?: string | null
|
||||
author?: string | null
|
||||
artist?: string | null
|
||||
genre?: string[]
|
||||
tags?: string[]
|
||||
realUrl?: string | null
|
||||
url?: string
|
||||
sourceId?: string
|
||||
inLibraryAt?: string | null
|
||||
lastFetchedAt?: string | null
|
||||
chaptersLastFetchedAt?: string | null
|
||||
thumbnailUrlLastFetched?: string | null
|
||||
addedAt?: number
|
||||
lastReadAt?: number
|
||||
age?: string | null
|
||||
chaptersAge?: string | null
|
||||
updateStrategy?: 'ALWAYS_UPDATE' | 'ONLY_FETCH_ONCE'
|
||||
latestFetchedChapter?: ChapterRef | null
|
||||
latestUploadedChapter?: ChapterRef | null
|
||||
latestReadChapter?: ChapterRef | null
|
||||
lastReadChapter?: ChapterRef | null
|
||||
firstUnreadChapter?: ChapterRef | null
|
||||
highestNumberedChapter?: ChapterRef | null
|
||||
source?: { id: string; name: string; displayName: string } | null
|
||||
}
|
||||
|
||||
export interface MangaDetail extends Manga {
|
||||
description: string | null
|
||||
author: string | null
|
||||
artist: string | null
|
||||
status: string | null
|
||||
genre: string[]
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { Keybinds } from "$lib/core/keybinds/defaultBinds";
|
||||
|
||||
export type PageStyle = "single" | "double" | "longstrip";
|
||||
export type FitMode = "width" | "height" | "screen" | "original";
|
||||
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
||||
export type ReadingDirection = "ltr" | "rtl";
|
||||
export type ChapterSortDir = "desc" | "asc";
|
||||
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
|
||||
export type ContentLevel = "strict" | "moderate" | "unrestricted";
|
||||
|
||||
export type LibrarySortMode =
|
||||
| "az" | "unreadCount" | "totalChapters"
|
||||
| "recentlyAdded" | "recentlyRead" | "latestFetched" | "latestUploaded";
|
||||
|
||||
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" | "light-contrast" | "midnight" | "warm";
|
||||
export type Theme = BuiltinTheme | string;
|
||||
|
||||
export interface ThemeTokens {
|
||||
"bg-void": string;
|
||||
"bg-base": string;
|
||||
"bg-surface": string;
|
||||
"bg-raised": string;
|
||||
"bg-overlay": string;
|
||||
"bg-subtle": string;
|
||||
"border-dim": string;
|
||||
"border-base": string;
|
||||
"border-strong": string;
|
||||
"border-focus": string;
|
||||
"text-primary": string;
|
||||
"text-secondary": string;
|
||||
"text-muted": string;
|
||||
"text-faint": string;
|
||||
"text-disabled": string;
|
||||
"accent": string;
|
||||
"accent-dim": string;
|
||||
"accent-muted": string;
|
||||
"accent-fg": string;
|
||||
"accent-bright": string;
|
||||
"color-error": string;
|
||||
"color-error-bg": string;
|
||||
"color-success": string;
|
||||
"color-info": string;
|
||||
"color-info-bg": string;
|
||||
}
|
||||
|
||||
export interface CustomTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
tokens: ThemeTokens;
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME_TOKENS: ThemeTokens = {
|
||||
"bg-void": "#080808",
|
||||
"bg-base": "#0c0c0c",
|
||||
"bg-surface": "#101010",
|
||||
"bg-raised": "#151515",
|
||||
"bg-overlay": "#1a1a1a",
|
||||
"bg-subtle": "#202020",
|
||||
"border-dim": "#1c1c1c",
|
||||
"border-base": "#242424",
|
||||
"border-strong": "#2e2e2e",
|
||||
"border-focus": "#4a5c4a",
|
||||
"text-primary": "#f0efec",
|
||||
"text-secondary": "#c8c6c0",
|
||||
"text-muted": "#8a8880",
|
||||
"text-faint": "#4e4d4a",
|
||||
"text-disabled": "#2a2a28",
|
||||
"accent": "#6b8f6b",
|
||||
"accent-dim": "#2a3d2a",
|
||||
"accent-muted": "#1a251a",
|
||||
"accent-fg": "#a8c4a8",
|
||||
"accent-bright": "#8fb88f",
|
||||
"color-error": "#c47a7a",
|
||||
"color-error-bg": "#1f1212",
|
||||
"color-success": "#7aab7a",
|
||||
"color-info": "#7a9ec4",
|
||||
"color-info-bg": "#121a1f",
|
||||
};
|
||||
|
||||
export interface MangaPrefs {
|
||||
autoDownload: boolean;
|
||||
downloadAhead: number;
|
||||
deleteOnRead: boolean;
|
||||
deleteDelayHours: number;
|
||||
maxKeepChapters: number;
|
||||
pauseUpdates: boolean;
|
||||
refreshInterval: "global" | "daily" | "weekly" | "manual";
|
||||
preferredScanlator: string;
|
||||
scanlatorFilter: string[];
|
||||
scanlatorBlacklist: string[];
|
||||
scanlatorForce: boolean;
|
||||
autoDownloadScanlators: string[];
|
||||
coverUrl?: string;
|
||||
}
|
||||
|
||||
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: [],
|
||||
};
|
||||
|
||||
export interface ReaderSettings {
|
||||
pageStyle: PageStyle;
|
||||
fitMode: FitMode;
|
||||
readingDirection: ReadingDirection;
|
||||
readerZoom: number;
|
||||
pageGap: boolean;
|
||||
optimizeContrast: boolean;
|
||||
offsetDoubleSpreads: boolean;
|
||||
barPosition?: "top" | "left" | "right";
|
||||
}
|
||||
|
||||
export interface ReaderPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
settings: ReaderSettings;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
pageStyle: PageStyle;
|
||||
readingDirection: ReadingDirection;
|
||||
fitMode: FitMode;
|
||||
readerZoom: number;
|
||||
pageGap: boolean;
|
||||
optimizeContrast: boolean;
|
||||
offsetDoubleSpreads: boolean;
|
||||
preloadPages: number;
|
||||
autoMarkRead: boolean;
|
||||
autoNextChapter: boolean;
|
||||
libraryCropCovers: boolean;
|
||||
libraryPageSize: number;
|
||||
contentLevel: ContentLevel;
|
||||
sourceOverridesEnabled: boolean;
|
||||
nsfwAllowedSourceIds: string[];
|
||||
nsfwBlockedSourceIds: string[];
|
||||
discordRpc: boolean;
|
||||
chapterSortDir: ChapterSortDir;
|
||||
chapterSortMode: ChapterSortMode;
|
||||
chapterPageSize: number;
|
||||
uiZoom: number;
|
||||
compactSidebar: boolean;
|
||||
gpuAcceleration: boolean;
|
||||
serverUrl: string;
|
||||
serverBinary: string;
|
||||
serverBinaryArgs: string;
|
||||
autoStartServer: boolean;
|
||||
suwayomiWebUI: boolean;
|
||||
preferredExtensionLang: string;
|
||||
keybinds: Keybinds;
|
||||
idleTimeoutMin?: number;
|
||||
splashCards?: boolean;
|
||||
storageLimitGb: number | null;
|
||||
markReadOnNext: boolean;
|
||||
readerDebounceMs: number;
|
||||
autoBookmark: boolean;
|
||||
theme: Theme;
|
||||
libraryBranches: boolean;
|
||||
renderLimit: number;
|
||||
heroSlots: (number | null)[];
|
||||
mangaLinks: Record<number, number[]>;
|
||||
mangaPrefs: Record<number, Partial<MangaPrefs>>;
|
||||
serverAuthUser: string;
|
||||
serverAuthPass: string;
|
||||
serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
socksProxyEnabled: boolean;
|
||||
socksProxyHost: string;
|
||||
socksProxyPort: string;
|
||||
socksProxyVersion: number;
|
||||
socksProxyUsername: string;
|
||||
socksProxyPassword: string;
|
||||
flareSolverrEnabled: boolean;
|
||||
flareSolverrUrl: string;
|
||||
flareSolverrTimeout: number;
|
||||
flareSolverrSessionName: string;
|
||||
flareSolverrSessionTtl: number;
|
||||
flareSolverrAsResponseFallback: boolean;
|
||||
appLockEnabled: boolean;
|
||||
appLockPin: string;
|
||||
customThemes: CustomTheme[];
|
||||
hiddenCategoryIds: number[];
|
||||
defaultLibraryCategoryId: number | null;
|
||||
savedIsDefaultCategory: boolean;
|
||||
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
||||
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
||||
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
|
||||
maxPageWidth?: number;
|
||||
uiScale?: number;
|
||||
extraScanDirs: string[];
|
||||
serverDownloadsPath: string;
|
||||
serverLocalSourcePath: string;
|
||||
qolAnimations: boolean;
|
||||
libraryStatsAlways: boolean;
|
||||
pinnedSourceIds: string[];
|
||||
readerPresets: ReaderPreset[];
|
||||
mangaReaderSettings: Record<number, ReaderSettings>;
|
||||
barPosition?: "top" | "left" | "right";
|
||||
trackerSyncBack: boolean;
|
||||
trackerSyncBackThreshold: number | null;
|
||||
trackerRespectScanlatorFilter: boolean;
|
||||
pinchZoom?: boolean;
|
||||
autoLinkOnOpen: boolean;
|
||||
downloadToastsEnabled: boolean;
|
||||
downloadAutoRetry: boolean;
|
||||
hiddenLibraryTabs: string[];
|
||||
libraryPinnedTabOrder: string[];
|
||||
autoScroll?: boolean;
|
||||
autoScrollSpeed?: number;
|
||||
disableAutoComplete: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
pageStyle: "longstrip",
|
||||
readingDirection: "ltr",
|
||||
fitMode: "width",
|
||||
readerZoom: 1.0,
|
||||
pageGap: true,
|
||||
optimizeContrast: false,
|
||||
offsetDoubleSpreads: false,
|
||||
preloadPages: 3,
|
||||
autoMarkRead: true,
|
||||
autoNextChapter: true,
|
||||
libraryCropCovers: true,
|
||||
libraryPageSize: 48,
|
||||
contentLevel: "strict",
|
||||
sourceOverridesEnabled: false,
|
||||
nsfwAllowedSourceIds: [],
|
||||
nsfwBlockedSourceIds: [],
|
||||
discordRpc: false,
|
||||
chapterSortDir: "desc",
|
||||
chapterSortMode: "source",
|
||||
chapterPageSize: 25,
|
||||
uiZoom: 1.0,
|
||||
compactSidebar: false,
|
||||
gpuAcceleration: true,
|
||||
serverUrl: "http://localhost:4567",
|
||||
serverBinary: "",
|
||||
serverBinaryArgs: "",
|
||||
autoStartServer: true,
|
||||
suwayomiWebUI: false,
|
||||
preferredExtensionLang: "en",
|
||||
keybinds: {} as Keybinds,
|
||||
idleTimeoutMin: 5,
|
||||
splashCards: true,
|
||||
storageLimitGb: null,
|
||||
markReadOnNext: true,
|
||||
readerDebounceMs: 120,
|
||||
autoBookmark: true,
|
||||
theme: "dark",
|
||||
libraryBranches: true,
|
||||
renderLimit: 48,
|
||||
heroSlots: [null, null, null, null],
|
||||
mangaLinks: {},
|
||||
mangaPrefs: {},
|
||||
serverAuthUser: "",
|
||||
serverAuthPass: "",
|
||||
serverAuthMode: "NONE",
|
||||
socksProxyEnabled: false,
|
||||
socksProxyHost: "",
|
||||
socksProxyPort: "1080",
|
||||
socksProxyVersion: 5,
|
||||
socksProxyUsername: "",
|
||||
socksProxyPassword: "",
|
||||
flareSolverrEnabled: false,
|
||||
flareSolverrUrl: "http://localhost:8191",
|
||||
flareSolverrTimeout: 60,
|
||||
flareSolverrSessionName: "moku",
|
||||
flareSolverrSessionTtl: 15,
|
||||
flareSolverrAsResponseFallback: false,
|
||||
appLockEnabled: false,
|
||||
appLockPin: "",
|
||||
customThemes: [],
|
||||
hiddenCategoryIds: [],
|
||||
defaultLibraryCategoryId: null,
|
||||
savedIsDefaultCategory: false,
|
||||
libraryTabSort: {},
|
||||
libraryTabStatus: {},
|
||||
libraryTabFilters: {},
|
||||
extraScanDirs: [],
|
||||
serverDownloadsPath: "",
|
||||
serverLocalSourcePath: "",
|
||||
qolAnimations: true,
|
||||
libraryStatsAlways: false,
|
||||
pinnedSourceIds: [],
|
||||
readerPresets: [],
|
||||
mangaReaderSettings: {},
|
||||
trackerSyncBack: false,
|
||||
trackerSyncBackThreshold: 20,
|
||||
trackerRespectScanlatorFilter: true,
|
||||
pinchZoom: false,
|
||||
autoLinkOnOpen: false,
|
||||
downloadToastsEnabled: true,
|
||||
downloadAutoRetry: false,
|
||||
hiddenLibraryTabs: [],
|
||||
libraryPinnedTabOrder: [],
|
||||
autoScroll: false,
|
||||
autoScrollSpeed: 5,
|
||||
disableAutoComplete: false,
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface TrackerStatus {
|
||||
value: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface TrackRecord {
|
||||
id: number
|
||||
trackerId: number
|
||||
remoteId: string
|
||||
title: string
|
||||
status: number
|
||||
score: number
|
||||
displayScore: string
|
||||
lastChapterRead: number
|
||||
totalChapters: number
|
||||
remoteUrl: string
|
||||
startDate?: string
|
||||
finishDate?: string
|
||||
private: boolean
|
||||
libraryId?: string
|
||||
manga?: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean }
|
||||
}
|
||||
|
||||
export interface Tracker {
|
||||
id: number
|
||||
name: string
|
||||
icon: string
|
||||
isLoggedIn: boolean
|
||||
isTokenExpired: boolean
|
||||
authUrl: string
|
||||
supportsPrivateTracking: boolean
|
||||
supportsReadingDates: boolean
|
||||
supportsTrackDeletion: boolean
|
||||
scores: string[]
|
||||
statuses: TrackerStatus[]
|
||||
trackRecords?: { nodes: TrackRecord[] }
|
||||
}
|
||||
Reference in New Issue
Block a user