mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Reworked ENTIRE Project for Readability
This commit is contained in:
-101
@@ -1,101 +0,0 @@
|
||||
import { store, updateSettings } from "../store/state.svelte";
|
||||
|
||||
export type AuthMode = "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
|
||||
export const authSession = {
|
||||
clearTokens() {},
|
||||
hasSession(): boolean { return true; },
|
||||
};
|
||||
|
||||
function getServerBase(): string {
|
||||
const url = store.settings.serverUrl;
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||
}
|
||||
|
||||
function basicHeader(user: string, pass: string): Record<string, string> {
|
||||
return { Authorization: `Basic ${btoa(`${user}:${pass}`)}` };
|
||||
}
|
||||
|
||||
export function fetchAuthenticated(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
return fetch(url, {
|
||||
...init,
|
||||
signal,
|
||||
credentials: "omit",
|
||||
headers: {
|
||||
...(init.headers as Record<string, string> ?? {}),
|
||||
...(user && pass ? basicHeader(user, pass) : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return fetch(url, { ...init, signal, credentials: "omit" });
|
||||
}
|
||||
|
||||
export async function loginBasic(user: string, pass: string): Promise<void> {
|
||||
const res = await fetch(`${getServerBase()}/api/graphql`, {
|
||||
method: "POST",
|
||||
credentials: "omit",
|
||||
headers: { "Content-Type": "application/json", ...basicHeader(user, pass) },
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Authentication failed (${res.status})`);
|
||||
updateSettings({ serverAuthMode: "BASIC_AUTH", serverAuthUser: user, serverAuthPass: pass });
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
updateSettings({ serverAuthPass: "" });
|
||||
}
|
||||
|
||||
export async function probeServer(): Promise<"ok" | "auth_required" | "unsupported_mode" | "unreachable"> {
|
||||
const base = getServerBase();
|
||||
const mode = store.settings.serverAuthMode ?? "NONE";
|
||||
const s = store.settings;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
|
||||
if (mode === "BASIC_AUTH") {
|
||||
const user = s.serverAuthUser?.trim() ?? "";
|
||||
const pass = s.serverAuthPass?.trim() ?? "";
|
||||
if (user && pass) Object.assign(headers, basicHeader(user, pass));
|
||||
}
|
||||
|
||||
const res = await fetch(`${base}/api/graphql`, {
|
||||
method: "POST",
|
||||
credentials: "omit",
|
||||
headers,
|
||||
body: JSON.stringify({ query: "{ __typename }" }),
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
|
||||
if (res.ok) return "ok";
|
||||
|
||||
if (res.status === 401) {
|
||||
const wwwAuth = res.headers.get("WWW-Authenticate") ?? "";
|
||||
|
||||
if (/basic/i.test(wwwAuth)) {
|
||||
if (mode !== "BASIC_AUTH") updateSettings({ serverAuthMode: "BASIC_AUTH" });
|
||||
return "auth_required";
|
||||
}
|
||||
|
||||
if (/bearer/i.test(wwwAuth)) {
|
||||
if (mode !== "UI_LOGIN") updateSettings({ serverAuthMode: "UI_LOGIN" });
|
||||
} else if (mode === "NONE") {
|
||||
updateSettings({ serverAuthMode: "SIMPLE_LOGIN" });
|
||||
}
|
||||
return "unsupported_mode";
|
||||
}
|
||||
|
||||
return "unreachable";
|
||||
} catch { return "unreachable"; }
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
/**
|
||||
* Session-level request cache — v3.
|
||||
*
|
||||
* Key design decisions (preserved from v1/v2):
|
||||
* - Stores the Promise itself — concurrent callers await the same fetch (no thundering herd).
|
||||
* - On real errors the entry is evicted so the next call retries.
|
||||
* - AbortErrors do NOT evict — cancellation ≠ failure.
|
||||
* - Subscribers are notified when a key is explicitly cleared or updated.
|
||||
*
|
||||
* v3 additions:
|
||||
* - cache.set(): direct write without a fetcher — for optimistic updates and
|
||||
* post-mutation cache patching. Notifies subscribers immediately.
|
||||
* - Invalidation groups: tag a cache key with one or more group strings.
|
||||
* cache.clearGroup("library") clears ALL keys tagged with "library" in one call.
|
||||
* This replaces the pattern of manually calling cache.clear() on every related key.
|
||||
* - Subscriber notifications on set() — reactive components re-render when the
|
||||
* cache is updated, not just when it's cleared.
|
||||
* - cache.update(): atomically patch a cached value (read → transform → write).
|
||||
*/
|
||||
|
||||
interface Entry<T> {
|
||||
promise: Promise<T>;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, Entry<unknown>>();
|
||||
const subs = new Map<string, Set<() => void>>();
|
||||
const groups = new Map<string, Set<string>>(); // groupTag → Set<cacheKey>
|
||||
|
||||
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
||||
|
||||
function notify(key: string) {
|
||||
subs.get(key)?.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
export const cache = {
|
||||
/**
|
||||
* Return a cached promise. Re-fetches once older than `ttl` ms.
|
||||
* Pass `Infinity` to pin for the session.
|
||||
*/
|
||||
get<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
ttl: number = 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() });
|
||||
|
||||
// Register in invalidation groups
|
||||
if (group) {
|
||||
const tags = Array.isArray(group) ? group : [group];
|
||||
for (const tag of tags) {
|
||||
if (!groups.has(tag)) groups.set(tag, new Set());
|
||||
groups.get(tag)!.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify subscribers once the fetch resolves (reactive update on new data)
|
||||
promise.then(() => notify(key)).catch(() => {});
|
||||
|
||||
return promise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Directly write a value into the cache — for optimistic updates and
|
||||
* post-mutation patching. Notifies subscribers immediately.
|
||||
*/
|
||||
set<T>(key: string, value: T, group?: string | string[]) {
|
||||
const promise = Promise.resolve(value);
|
||||
store.set(key, { promise, fetchedAt: Date.now() });
|
||||
|
||||
if (group) {
|
||||
const tags = Array.isArray(group) ? group : [group];
|
||||
for (const tag of tags) {
|
||||
if (!groups.has(tag)) groups.set(tag, new Set());
|
||||
groups.get(tag)!.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
notify(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Atomically patch a cached value.
|
||||
* If the key doesn't exist, does nothing.
|
||||
*/
|
||||
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, { promise: next, fetchedAt: Date.now() });
|
||||
next.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;
|
||||
},
|
||||
|
||||
clear(key: string) {
|
||||
store.delete(key);
|
||||
notify(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all keys belonging to an invalidation group.
|
||||
* e.g. cache.clearGroup("library") clears "library", "all_manga_unfiltered", etc.
|
||||
*/
|
||||
clearGroup(tag: string) {
|
||||
const keys = groups.get(tag);
|
||||
if (!keys) return;
|
||||
for (const key of keys) {
|
||||
store.delete(key);
|
||||
notify(key);
|
||||
}
|
||||
groups.delete(tag);
|
||||
},
|
||||
|
||||
clearAll() {
|
||||
const allKeys = [...store.keys()];
|
||||
store.clear();
|
||||
groups.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);
|
||||
},
|
||||
};
|
||||
|
||||
// ── Cache key constants ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Invalidation group tags.
|
||||
* cache.clearGroup(CACHE_GROUPS.LIBRARY) clears all library-related keys at once.
|
||||
*/
|
||||
export const CACHE_GROUPS = {
|
||||
LIBRARY: "g:library", // library + all_manga_unfiltered
|
||||
SOURCES: "g:sources", // sources list + per-source page caches
|
||||
} as const;
|
||||
|
||||
export const CACHE_KEYS = {
|
||||
LIBRARY: "library",
|
||||
ALL_MANGA: "all_manga_unfiltered",
|
||||
CATEGORIES: "categories",
|
||||
SEARCH: "search_all_manga", // Search's unfiltered fetch — separate from library
|
||||
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;
|
||||
|
||||
// ── In-flight request deduplication (for non-cached calls) ───────────────────
|
||||
//
|
||||
// Some requests (chapter lists, manga detail) are NOT stored in the long-lived
|
||||
// cache but still get fired multiple times when a user rapidly opens/closes a
|
||||
// manga. This map deduplicates them so only one network round-trip is active at
|
||||
// a time per key.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── PageSet: per-session page-number tracker ──────────────────────────────────
|
||||
//
|
||||
// Tracks which page numbers have been fetched for a (source, type, query) bucket.
|
||||
// Lives in a separate map from the TTL store so it never gets TTL-evicted while
|
||||
// a browse session is actively paginating.
|
||||
//
|
||||
// Usage:
|
||||
// const ps = getPageSet(sourceId, "SEARCH", ["Action", "Romance"]);
|
||||
// ps.add(1); // after fetching page 1
|
||||
// ps.next(); // → 2
|
||||
// ps.pages(); // → Set {1}
|
||||
// ps.clear(); // call when query/tags change
|
||||
|
||||
const _pageSets = new Map<string, Set<number>>();
|
||||
|
||||
export interface PageSet {
|
||||
add(page: number): void;
|
||||
pages(): Set<number>;
|
||||
/** Next page to fetch: max fetched + 1, or 1 if nothing fetched yet. */
|
||||
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); },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Source frecency helpers ───────────────────────────────────────────────────
|
||||
|
||||
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 }));
|
||||
const hasFrecency = withScore.some((x) => x.score > 0);
|
||||
if (hasFrecency) {
|
||||
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);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { Chapter } from "./types";
|
||||
|
||||
export function buildReaderChapterList(
|
||||
chapters: Chapter[],
|
||||
mangaPrefs: { preferredScanlator?: string; scanlatorFilter?: string[] } | undefined,
|
||||
): Chapter[] {
|
||||
const preferred = mangaPrefs?.preferredScanlator ?? "";
|
||||
const filter = mangaPrefs?.scanlatorFilter ?? [];
|
||||
|
||||
let base = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
|
||||
if (preferred) {
|
||||
const pref: Chapter[] = [], rest: Chapter[] = [];
|
||||
for (const c of base) (c.scanlator === preferred ? pref : rest).push(c);
|
||||
base = [...pref, ...rest];
|
||||
}
|
||||
|
||||
if (filter.length > 0) {
|
||||
const seen = new Map<number, Chapter>();
|
||||
for (const ch of base) {
|
||||
const existing = seen.get(ch.chapterNumber);
|
||||
if (!existing) {
|
||||
seen.set(ch.chapterNumber, ch);
|
||||
} else {
|
||||
const np = filter.indexOf(ch.scanlator ?? "");
|
||||
const op = filter.indexOf(existing.scanlator ?? "");
|
||||
if (np !== -1 && (op === -1 || np < op)) seen.set(ch.chapterNumber, ch);
|
||||
}
|
||||
}
|
||||
base = [...seen.values()].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { store } from "../store/state.svelte";
|
||||
import { fetchAuthenticated } from "./auth";
|
||||
|
||||
const DEFAULT_URL = "http://127.0.0.1:4567";
|
||||
|
||||
function getServerUrl(): string {
|
||||
const url = store.settings.serverUrl;
|
||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
|
||||
}
|
||||
|
||||
function gqlUrl(): string { return `${getServerUrl()}/api/graphql`; }
|
||||
|
||||
export function plainThumbUrl(path: string): string {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${getServerUrl()}${path}`;
|
||||
}
|
||||
|
||||
export function thumbUrl(path: string): string {
|
||||
return plainThumbUrl(path);
|
||||
}
|
||||
|
||||
interface GQLResponse<T> {
|
||||
data: T;
|
||||
errors?: { message: string }[];
|
||||
}
|
||||
|
||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; }
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchWithRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal?: AbortSignal,
|
||||
retries = 3,
|
||||
delayMs = 300,
|
||||
): Promise<Response> {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
try {
|
||||
const res = await fetchAuthenticated(url, init, signal);
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
return res;
|
||||
} catch (e: any) {
|
||||
if (e?.authRequired) throw e;
|
||||
const isAbort = e?.name === "AbortError" || signal?.aborted;
|
||||
if (isAbort) throw new DOMException("Aborted", "AbortError");
|
||||
if (i === retries - 1) throw e;
|
||||
await abortableSleep(delayMs * Math.pow(1.5, i), signal);
|
||||
}
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
}
|
||||
|
||||
export async function gql<T>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const res = await fetchWithRetry(gqlUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
}, signal);
|
||||
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
|
||||
|
||||
const json: GQLResponse<T> = await res.json();
|
||||
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
||||
|
||||
return json.data;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { connect, disconnect, setActivity, clearActivity } from "tauri-plugin-discord-rpc-api";
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import type { Manga, Chapter } from './types'
|
||||
|
||||
const APP_ID = '1487894643613106298'
|
||||
const FALLBACK_IMAGE = 'moku_logo'
|
||||
|
||||
let sessionStart: number | null = null
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
function isPublicUrl(url: string | null | undefined): boolean {
|
||||
return typeof url === 'string' && url.startsWith('https://')
|
||||
}
|
||||
|
||||
function resolveCoverImage(manga: Manga): string {
|
||||
return isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE
|
||||
}
|
||||
|
||||
function trunc(s: string, max = 128): string {
|
||||
return s.length <= max ? s : `${s.slice(0, max - 1)}…`
|
||||
}
|
||||
|
||||
function formatChapter(chapter: Chapter): string {
|
||||
const n = chapter.chapterNumber
|
||||
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`
|
||||
}
|
||||
|
||||
const BUTTONS = [
|
||||
{ label: 'GitHub', url: 'https://github.com/Youwes09/Moku' },
|
||||
{ label: 'Discord', url: 'https://discord.gg/Jq3pwuNqPp' },
|
||||
]
|
||||
|
||||
export async function initRpc(): Promise<void> {
|
||||
sessionStart = Date.now()
|
||||
|
||||
unlisten = await listen('discord-rpc://running', ({ payload }) => {
|
||||
if (payload) setIdle().catch(() => {})
|
||||
})
|
||||
|
||||
await connect(APP_ID).catch(() => {})
|
||||
}
|
||||
|
||||
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
|
||||
await setActivity({
|
||||
details: trunc(manga.title),
|
||||
state: `${formatChapter(chapter)} · Reading`,
|
||||
timestamps: { start: sessionStart ?? Date.now() },
|
||||
assets: {
|
||||
largeImage: resolveCoverImage(manga),
|
||||
largeText: trunc(manga.title),
|
||||
smallImage: FALLBACK_IMAGE,
|
||||
smallText: 'Moku',
|
||||
},
|
||||
buttons: BUTTONS,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
export async function setIdle(): Promise<void> {
|
||||
await setActivity({
|
||||
details: 'Browsing',
|
||||
timestamps: { start: sessionStart ?? Date.now() },
|
||||
assets: {
|
||||
largeImage: FALLBACK_IMAGE,
|
||||
largeText: 'Moku',
|
||||
},
|
||||
buttons: BUTTONS,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
export async function clearReading(): Promise<void> {
|
||||
await clearActivity().catch(() => {})
|
||||
}
|
||||
|
||||
export async function destroyRpc(): Promise<void> {
|
||||
unlisten?.()
|
||||
unlisten = null
|
||||
sessionStart = null
|
||||
await disconnect().catch(() => {})
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
|
||||
import { store } from "../store/state.svelte";
|
||||
|
||||
const cache = new Map<string, string>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
|
||||
const MAX_CONCURRENT = 6;
|
||||
let active = 0;
|
||||
|
||||
interface QueueEntry {
|
||||
url: string;
|
||||
priority: number;
|
||||
resolve: (v: string) => void;
|
||||
reject: (e: unknown) => void;
|
||||
}
|
||||
|
||||
const queue: QueueEntry[] = [];
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const user = store.settings.serverAuthUser?.trim() ?? "";
|
||||
const pass = store.settings.serverAuthPass?.trim() ?? "";
|
||||
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
|
||||
}
|
||||
|
||||
async function doFetch(url: string): Promise<string> {
|
||||
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const blobUrl = URL.createObjectURL(await res.blob());
|
||||
cache.set(url, blobUrl);
|
||||
return blobUrl;
|
||||
}
|
||||
|
||||
function insertSorted(entry: QueueEntry) {
|
||||
let lo = 0, hi = queue.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (queue[mid].priority > entry.priority) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
queue.splice(lo, 0, entry);
|
||||
}
|
||||
|
||||
function drain() {
|
||||
while (active < MAX_CONCURRENT && queue.length > 0) {
|
||||
const entry = queue.shift()!;
|
||||
active++;
|
||||
doFetch(entry.url)
|
||||
.then(entry.resolve, entry.reject)
|
||||
.finally(() => {
|
||||
inflight.delete(entry.url);
|
||||
active--;
|
||||
drain();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(url: string, priority: number): Promise<string> {
|
||||
const promise = new Promise<string>((resolve, reject) => {
|
||||
insertSorted({ url, priority, resolve, reject });
|
||||
});
|
||||
inflight.set(url, promise);
|
||||
drain();
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function getBlobUrl(url: string, priority = 0): Promise<string> {
|
||||
if (!url) return Promise.resolve("");
|
||||
|
||||
const cached = cache.get(url);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
|
||||
const existing = inflight.get(url);
|
||||
if (existing) {
|
||||
const idx = queue.findIndex(e => e.url === url);
|
||||
if (idx !== -1 && priority > queue[idx].priority) {
|
||||
const [entry] = queue.splice(idx, 1);
|
||||
entry.priority = priority;
|
||||
insertSorted(entry);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
return enqueue(url, priority);
|
||||
}
|
||||
|
||||
export function preloadBlobUrls(urls: string[], basePriority = 0): void {
|
||||
urls.forEach((url, i) => {
|
||||
if (!url || cache.has(url) || inflight.has(url)) return;
|
||||
enqueue(url, basePriority - i);
|
||||
});
|
||||
}
|
||||
|
||||
export function revokeBlobUrl(url: string): void {
|
||||
const blob = cache.get(url);
|
||||
if (blob) {
|
||||
URL.revokeObjectURL(blob);
|
||||
cache.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearBlobCache(): void {
|
||||
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||
cache.clear();
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function toggleFullscreen(): Promise<void> {
|
||||
try {
|
||||
const win = getCurrentWindow();
|
||||
const isFs = await win.isFullscreen();
|
||||
await win.setFullscreen(!isFs);
|
||||
} catch (e) {
|
||||
console.warn("toggleFullscreen unavailable:", e);
|
||||
}
|
||||
}
|
||||
-1001
File diff suppressed because it is too large
Load Diff
@@ -1,148 +0,0 @@
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
order: number;
|
||||
default: boolean;
|
||||
includeInUpdate: string;
|
||||
includeInDownload: string;
|
||||
mangas?: {
|
||||
nodes: Manga[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface Manga {
|
||||
id: number;
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
inLibrary: boolean;
|
||||
downloadCount?: number;
|
||||
unreadCount?: number;
|
||||
chapterCount?: number;
|
||||
description?: string | null;
|
||||
status?: string | null;
|
||||
author?: string | null;
|
||||
artist?: string | null;
|
||||
genre?: string[];
|
||||
realUrl?: string | null;
|
||||
source?: {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
id: number;
|
||||
name: string;
|
||||
chapterNumber: number;
|
||||
sourceOrder: number;
|
||||
isRead: boolean;
|
||||
isDownloaded: boolean;
|
||||
isBookmarked: boolean;
|
||||
pageCount: number;
|
||||
mangaId: number;
|
||||
uploadDate?: string | null;
|
||||
realUrl?: string | null;
|
||||
lastPageRead?: number;
|
||||
scanlator?: string | null;
|
||||
}
|
||||
|
||||
export interface MangaDetail extends Manga {
|
||||
description: string | null;
|
||||
author: string | null;
|
||||
artist: string | null;
|
||||
status: string | null;
|
||||
genre: string[];
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
id: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
displayName: string;
|
||||
iconUrl: string;
|
||||
isNsfw: boolean;
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
apkName: string;
|
||||
pkgName: string;
|
||||
name: string;
|
||||
lang: string;
|
||||
versionName: string;
|
||||
isInstalled: boolean;
|
||||
isObsolete: boolean;
|
||||
hasUpdate: boolean;
|
||||
iconUrl: string;
|
||||
}
|
||||
|
||||
export interface DownloadQueueItem {
|
||||
progress: number;
|
||||
state: "QUEUED" | "DOWNLOADING" | "FINISHED" | "ERROR";
|
||||
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 TrackerStatus {
|
||||
value: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Tracker {
|
||||
id: number;
|
||||
name: string;
|
||||
icon: string;
|
||||
isLoggedIn: boolean;
|
||||
authUrl: string | null;
|
||||
supportsPrivateTracking: boolean;
|
||||
scores: string[];
|
||||
statuses: TrackerStatus[];
|
||||
}
|
||||
|
||||
export interface TrackRecord {
|
||||
id: number;
|
||||
trackerId: number;
|
||||
remoteId: string;
|
||||
title: string;
|
||||
status: number;
|
||||
score: number;
|
||||
displayScore: string;
|
||||
lastChapterRead: number;
|
||||
totalChapters: number;
|
||||
remoteUrl: string | null;
|
||||
startDate: string | null;
|
||||
finishDate: string | null;
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
export interface TrackSearch {
|
||||
id: number;
|
||||
trackerId: number;
|
||||
remoteId: string;
|
||||
title: string;
|
||||
coverUrl: string | null;
|
||||
summary: string | null;
|
||||
publishingStatus: string | null;
|
||||
publishingType: string | null;
|
||||
startDate: string | null;
|
||||
totalChapters: number;
|
||||
trackingUrl: string | null;
|
||||
}
|
||||
-255
@@ -1,255 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import type { Source } from "./types";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
// ── NSFW genre filtering ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Default substrings used when no user-configured list is available.
|
||||
* The Settings > Content tab lets users add/remove entries from this list,
|
||||
* which is stored as settings.nsfwFilteredTags.
|
||||
*/
|
||||
export const DEFAULT_NSFW_TAGS = [
|
||||
"adult",
|
||||
"mature",
|
||||
"hentai",
|
||||
"ecchi",
|
||||
"erotic", // catches "erotica", "erotic content", "erotic manga"
|
||||
"pornograph", // catches "pornographic", "pornography"
|
||||
"18+",
|
||||
"smut",
|
||||
"lemon",
|
||||
"explicit",
|
||||
"sexual violence",
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns true if the manga carries at least one genre tag matching any of
|
||||
* the provided substrings (case-insensitive). Pass settings.nsfwFilteredTags
|
||||
* as the tag list; falls back to DEFAULT_NSFW_TAGS if omitted.
|
||||
*/
|
||||
export function isNsfwManga(
|
||||
manga: { genre?: string[] | null },
|
||||
tags: string[] = DEFAULT_NSFW_TAGS,
|
||||
): boolean {
|
||||
return (manga.genre ?? []).some((g) => {
|
||||
const normalized = g.toLowerCase().trim();
|
||||
return tags.some((sub) => normalized.includes(sub));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Single authoritative NSFW gate used by all views.
|
||||
*
|
||||
* Returns true when the manga should be HIDDEN. Checks in order:
|
||||
* 1. showNsfw disabled globally → skip everything, hide by source flag or genre match.
|
||||
* 2. Source is in blockedSourceIds → always hide regardless of showNsfw.
|
||||
* 3. Source is in allowedSourceIds → always show (bypasses isNsfw flag only, genre tags still apply).
|
||||
* 4. Source isNsfw flag → hide unless source is allowed.
|
||||
* 5. Genre tag match → hide.
|
||||
*
|
||||
* Usage: items.filter(m => !shouldHideNsfw(m, settings))
|
||||
*/
|
||||
export function shouldHideNsfw(
|
||||
manga: {
|
||||
genre?: string[] | null;
|
||||
source?: { id?: string; isNsfw?: boolean } | null;
|
||||
},
|
||||
settings: {
|
||||
showNsfw: boolean;
|
||||
nsfwFilteredTags: string[];
|
||||
nsfwAllowedSourceIds: string[];
|
||||
nsfwBlockedSourceIds: string[];
|
||||
},
|
||||
): boolean {
|
||||
const srcId = manga.source?.id;
|
||||
|
||||
// Explicit block always wins, even when showNsfw is on
|
||||
if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true;
|
||||
|
||||
// If NSFW is globally allowed, only explicit blocks apply
|
||||
if (settings.showNsfw) return false;
|
||||
|
||||
// Source is explicitly allowed — skip the isNsfw flag check, but still filter genres
|
||||
const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId));
|
||||
|
||||
if (!sourceAllowed && manga.source?.isNsfw) return true;
|
||||
|
||||
return isNsfwManga(manga, settings.nsfwFilteredTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for Source objects — parallel to shouldHideNsfw for manga.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Blocked list → always hidden, even when showNsfw is on.
|
||||
* 2. Allowed list → always shown, even if isNsfw is true.
|
||||
* 3. Fallback → hide when showNsfw is off and source.isNsfw is true.
|
||||
*
|
||||
* Usage: sources.filter(s => !shouldHideSource(s, settings))
|
||||
*/
|
||||
export function shouldHideSource(
|
||||
source: { id: string; isNsfw: boolean },
|
||||
settings: {
|
||||
showNsfw: boolean;
|
||||
nsfwAllowedSourceIds: string[];
|
||||
nsfwBlockedSourceIds: string[];
|
||||
},
|
||||
): boolean {
|
||||
if (settings.nsfwBlockedSourceIds.includes(source.id)) return true;
|
||||
if (settings.nsfwAllowedSourceIds.includes(source.id)) return false;
|
||||
return !settings.showNsfw && source.isNsfw;
|
||||
}
|
||||
|
||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ── Manga deduplication ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalizes a title for fuzzy matching.
|
||||
* Strips punctuation, articles, and common source-specific suffixes so that
|
||||
* "The Greatest Estate Developer" and "Yeokdaegeum Yeongji Seolgyesa" won't
|
||||
* match on title alone — but their identical descriptions will catch them.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a string for fingerprinting — strip all non-alpha, collapse spaces.
|
||||
*/
|
||||
function norm(s: string): string {
|
||||
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Description fingerprint — first 200 normalized chars.
|
||||
* Long enough to reliably identify the same series across sources even when
|
||||
* translations differ in punctuation or minor wording.
|
||||
* Returns null if too short (< 60 chars) to be a reliable signal.
|
||||
*/
|
||||
function descFingerprint(desc: string | null | undefined): string | null {
|
||||
if (!desc) return null;
|
||||
const n = norm(desc);
|
||||
if (n.length < 60) return null;
|
||||
return n.slice(0, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Author fingerprint — normalized concatenation of author + artist.
|
||||
* Used as a tie-breaker / additional signal alongside description.
|
||||
* Two manga with the same authors AND same description are almost certainly
|
||||
* the same series. Returns null if no author info.
|
||||
*/
|
||||
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
|
||||
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
|
||||
if (!parts.length) return null;
|
||||
return parts.sort().join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates manga by:
|
||||
* 1. Normalized title
|
||||
* 2. Description fingerprint (first 200 chars)
|
||||
* 3. Author + description together
|
||||
* 4. User-defined links (mangaLinks from store) — explicit "same series" overrides
|
||||
*
|
||||
* Pass `links` as `settings.mangaLinks` to honour user-registered pairs.
|
||||
* When two entries match, the PREFERRED one is kept:
|
||||
* - Library membership wins
|
||||
* - Otherwise higher downloadCount wins
|
||||
* - Otherwise first occurrence wins
|
||||
*/
|
||||
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>();
|
||||
// id → index in out[]
|
||||
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;
|
||||
|
||||
// Check user-defined links first (explicit override)
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates manga by id only (lossless).
|
||||
*/
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user