Chore: Restructure Repository for SvelteKit

This commit is contained in:
Youwes09
2026-05-22 04:04:59 -05:00
parent bf071dcfc7
commit 8cef74bb98
266 changed files with 5093 additions and 396 deletions
+22
View File
@@ -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

+5
View File
@@ -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));
}
+29
View File
@@ -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;
},
};
}
+29
View File
@@ -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; },
};
}
+33
View File
@@ -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);
}
+32
View File
@@ -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;
});
},
};
}
+44
View File
@@ -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; }
}
+241
View File
@@ -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(() => {});
}
}
+29
View File
@@ -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);
};
+54
View File
@@ -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();
}
+50
View File
@@ -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",
};
+14
View File
@@ -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;
}
+234
View File
@@ -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);
}};
}
+53
View File
@@ -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;
});
}
+223
View File
@@ -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;
}
+40
View File
@@ -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
}
}
+25
View File
@@ -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 = []
}
+51
View File
@@ -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
}
}
+58
View File
@@ -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)
}
}
+28
View File
@@ -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 }
}
}
`
+517
View File
@@ -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 []
}
}
+196
View File
@@ -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 }
}
`
+67
View File
@@ -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'
}
}
+94
View File
@@ -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[]>
}
+10
View File
@@ -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: '',
})
+16
View File
@@ -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)
+36
View File
@@ -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
})
+53
View File
@@ -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
})
+25
View File
@@ -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)
}
+41
View File
@@ -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
)
+36
View File
@@ -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
})
+8
View File
@@ -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,
})
+62
View File
@@ -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
}
+19
View File
@@ -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
}
+24
View File
@@ -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
}
+72
View File
@@ -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
}
+539
View File
@@ -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 []
}
}
+62
View File
@@ -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[]
}
+312
View File
@@ -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,
};
+37
View File
@@ -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[] }
}