Chore: Port over Settings (Barely Works)

This commit is contained in:
Youwes09
2026-05-24 20:31:46 -05:00
parent ae5d9748c7
commit d9a9427e3b
87 changed files with 8821 additions and 615 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util";
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from '$lib/core/util'
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
return (item) => predicates.every((p) => p(item));
return item => predicates.every(p => p(item))
}
+9 -15
View File
@@ -1,11 +1,11 @@
export interface PaginationState {
visible: number;
visible: number
}
export interface PaginationResult<T> {
items: T[];
hasMore: boolean;
remaining: number;
items: T[]
hasMore: boolean
remaining: number
}
export function createPaginator<T>(pageSize: number) {
@@ -15,15 +15,9 @@ export function createPaginator<T>(pageSize: number) {
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;
},
};
}
nextVisible(current: number): number { return current + pageSize },
reset(): number { return pageSize },
}
}
+16 -19
View File
@@ -1,29 +1,26 @@
export interface AsyncQueue<T> {
enqueue(item: T): void;
drain(): void;
clear(): void;
size(): number;
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;
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(); });
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; },
};
}
enqueue(item) { queue.push(item); next() },
drain() { next() },
clear() { queue.length = 0 },
size() { return queue.length },
}
}
+15 -24
View File
@@ -1,33 +1,24 @@
export interface SearchResult<T> {
item: T;
score: number;
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 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 }));
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 };
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);
}
.sort((a, b) => b.score - a.score)
}
+16 -17
View File
@@ -1,32 +1,31 @@
export type SortDir = "asc" | "desc";
export type SortDir = 'asc' | 'desc'
export interface SortField<T> {
key: string;
comparator: (a: T, b: T, context?: Record<string, unknown>) => number;
key: string
comparator: (a: T, b: T, context?: Record<string, unknown>) => number
}
export interface SortConfig<T> {
fields: SortField<T>[];
defaultField: string;
defaultDir: SortDir;
fields: SortField<T>[]
defaultField: string
defaultDir: SortDir
}
export interface Sorter<T> {
sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): 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]));
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;
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;
});
const cmp = f.comparator(a, b, context)
return d === 'asc' ? cmp : -cmp
})
},
};
}
}
}
+35
View File
@@ -0,0 +1,35 @@
const _inflight = new Map<string, Promise<unknown>>()
export async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
concurrency = 6,
): Promise<void> {
let i = 0
async function worker() {
while (i < items.length) {
if (signal.aborted) return
const item = items[i++]
await fn(item).catch(() => {})
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
}
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>
export function dedupeRequest<T>(
keyOrFn: string | ((key: string) => Promise<T>),
factory?: () => Promise<T>,
): Promise<T> | ((key: string) => Promise<T>) {
if (typeof keyOrFn === 'function') {
const fn = keyOrFn
return (key: string) => dedupeRequest(key, () => fn(key))
}
const key = keyOrFn
if (_inflight.has(key)) return _inflight.get(key) as Promise<T>
const p = factory!().finally(() => _inflight.delete(key))
_inflight.set(key, p)
return p
}
@@ -0,0 +1,22 @@
export interface PaginatedQuery<T> {
fetchPage(page: number): Promise<T[]>
reset(): void
hasMore(): boolean
}
export interface PaginatedQueryConfig<T> {
fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }>
}
export function createPaginatedQuery<T>(config: PaginatedQueryConfig<T>): PaginatedQuery<T> {
let _hasMore = true
return {
async fetchPage(page) {
const { items, hasNextPage } = await config.fetcher(page)
_hasMore = hasNextPage
return items
},
reset() { _hasMore = true },
hasMore() { return _hasMore },
}
}
+31
View File
@@ -0,0 +1,31 @@
export interface RetryOptions {
maxAttempts?: number
baseDelayMs?: number
maxDelayMs?: number
shouldRetry?: (err: unknown, attempt: number) => boolean
}
export async function fetchWithRetry<T>(
fetcher: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxAttempts = 3,
baseDelayMs = 500,
maxDelayMs = 10_000,
shouldRetry = () => true,
} = options
let lastErr: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fetcher()
} catch (err) {
lastErr = err
if (attempt === maxAttempts || !shouldRetry(err, attempt)) throw err
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs)
await new Promise(r => setTimeout(r, delay))
}
}
throw lastErr
}
+89 -14
View File
@@ -1,16 +1,74 @@
const DEFAULT_URL = 'http://127.0.0.1:4567'
interface AuthConfig {
baseUrl: string
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
user?: string
pass?: string
baseUrl: string
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
user?: string
pass?: string
}
export interface UiAuthDebugStatus {
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
hasSession: boolean
hasRefreshToken: boolean
accessExpiresAt: number | null
refreshExpiresAt: number | null
accessExpiresInMs: number | null
refreshExpiresInMs: number | null
shouldRefreshSoon: boolean
refreshInFlight: boolean
skewMs: number
}
const SKEW_MS = 60_000 * 2
let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' }
let accessToken: string | null = null
let refreshToken: string | null = null
let accessToken: string | null = null
let refreshToken: string | null = null
let accessExpiresAt: number | null = null
let refreshExpiresAt: number | null = null
let refreshInFlight = false
function parseExpiry(token: string): number | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
} catch {
return null
}
}
export const authSession = {
clearTokens() {
accessToken = null
refreshToken = null
accessExpiresAt = null
refreshExpiresAt = null
},
}
export function getUIAccessToken(): string | null {
return accessToken
}
export function getUiAuthDebugStatus(): UiAuthDebugStatus {
const now = Date.now()
const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null
const refreshExpiresInMs = refreshExpiresAt !== null ? refreshExpiresAt - now : null
return {
mode: config.mode,
hasSession: accessToken !== null,
hasRefreshToken: refreshToken !== null,
accessExpiresAt,
refreshExpiresAt,
accessExpiresInMs,
refreshExpiresInMs,
shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS,
refreshInFlight,
skewMs: SKEW_MS,
}
}
export function configureAuth(
baseUrl: string,
@@ -19,8 +77,7 @@ export function configureAuth(
pass?: string,
): void {
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass }
accessToken = null
refreshToken = null
authSession.clearTokens()
}
export function authHeaders(): Record<string, string> {
@@ -92,10 +149,12 @@ export async function loginUI(user: string, pass: string): Promise<void> {
const data = await gqlRaw(LOGIN_MUTATION, { username: user, password: pass }) as {
login: { accessToken: string; refreshToken: string }
}
accessToken = data.login.accessToken
refreshToken = data.login.refreshToken
config.mode = 'UI_LOGIN'
config.user = user
accessToken = data.login.accessToken
refreshToken = data.login.refreshToken
accessExpiresAt = parseExpiry(accessToken)
refreshExpiresAt = parseExpiry(refreshToken)
config.mode = 'UI_LOGIN'
config.user = user
}
export async function refreshAccessToken(): Promise<boolean> {
@@ -104,9 +163,25 @@ export async function refreshAccessToken(): Promise<boolean> {
const data = await gqlRaw(REFRESH_MUTATION, { refreshToken }) as {
refreshToken: { accessToken: string }
}
accessToken = data.refreshToken.accessToken
accessToken = data.refreshToken.accessToken
accessExpiresAt = parseExpiry(accessToken)
return true
} catch {
return false
}
}
}
export async function refreshUiAccessToken(force = false): Promise<string | null> {
if (config.mode !== 'UI_LOGIN') return null
if (!refreshToken) return null
const now = Date.now()
if (!force && accessExpiresAt !== null && accessExpiresAt - now > SKEW_MS) return accessToken
if (refreshInFlight) return accessToken
refreshInFlight = true
try {
const ok = await refreshAccessToken()
return ok ? accessToken : null
} finally {
refreshInFlight = false
}
}
+256
View File
@@ -0,0 +1,256 @@
import { invoke } from "@tauri-apps/api/core";
import {
persistSettings,
persistLibrary,
persistUpdates,
} from "$lib/core/persistence/persist";
const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const;
export async function exportAppData(): Promise<void> {
const entries: [string, string][] = await invoke("read_store_files", {
names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("export_app_data", { bytes: Array.from(zip) });
}
export async function importAppData(): Promise<void> {
const raw: number[] = await invoke("import_app_data");
const files = parseZip(new Uint8Array(raw));
const decode = (name: string) => {
const bytes = files.get(name);
if (!bytes) throw new Error(`Backup is missing ${name}`);
return JSON.parse(new TextDecoder().decode(bytes));
};
const s = decode("settings.json");
const l = decode("library.json");
const u = decode("updates.json");
await Promise.all([
persistSettings({
settings: s.settings ?? null,
storeVersion: s.storeVersion ?? 1,
}),
persistLibrary({
history: l.history ?? [],
bookmarks: l.bookmarks ?? [],
markers: l.markers ?? [],
readLog: l.readLog ?? [],
readingStats: l.readingStats ?? null,
dailyReadCounts: l.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: u.libraryUpdates ?? [],
lastLibraryRefresh: u.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [],
}),
]);
await showExitModal();
invoke("exit_app");
}
function showExitModal(): Promise<void> {
return new Promise(resolve => {
const backdrop = document.createElement("div");
backdrop.className = "s-backdrop";
backdrop.style.cssText = "z-index:99999";
const modal = document.createElement("div");
modal.style.cssText = [
"background:var(--bg-surface)",
"border:1px solid var(--border-base)",
"border-radius:var(--radius-2xl)",
"box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7),0 8px 24px rgba(0,0,0,0.4)",
"width:min(400px,calc(100vw - 40px))",
"display:flex",
"flex-direction:column",
"overflow:hidden",
"animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both",
].join(";");
const header = document.createElement("div");
header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)";
const title = document.createElement("p");
title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em";
title.textContent = "Import complete";
header.appendChild(title);
const body = document.createElement("div");
body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)";
const sub = document.createElement("p");
sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)";
sub.textContent = "Your settings have been restored. Moku will close so you can relaunch with the imported data.";
const counter = document.createElement("p");
counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)";
counter.textContent = "Closing in 3…";
body.append(sub, counter);
const footer = document.createElement("div");
footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end";
const btn = document.createElement("button");
btn.className = "s-btn s-btn-danger";
btn.textContent = "Close now";
footer.appendChild(btn);
modal.append(header, body, footer);
backdrop.appendChild(modal);
document.body.appendChild(backdrop);
let secs = 3;
const tick = setInterval(() => {
secs--;
counter.textContent = secs > 0 ? `Closing in ${secs}` : "Closing…";
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); }
}, 1000);
btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); });
});
}
export async function autoBackupAppData(): Promise<void> {
try {
const entries: [string, string][] = await invoke("read_store_files", {
names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
} catch (e) {
console.warn("[moku] auto-backup failed:", e);
}
}
function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of data) {
crc ^= byte;
for (let j = 0; j < 8; j++) {
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array {
const buf = new ArrayBuffer(30 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x04034b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 0, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint32(14, crc32(data), true);
v.setUint32(18, data.byteLength, true);
v.setUint32(22, data.byteLength, true);
v.setUint16(26, name.byteLength, true);
v.setUint16(28, 0, true);
new Uint8Array(buf).set(name, 30);
return new Uint8Array(buf);
}
function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array {
const buf = new ArrayBuffer(46 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x02014b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 20, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint16(14, 0, true);
v.setUint32(16, crc32(data), true);
v.setUint32(20, data.byteLength, true);
v.setUint32(24, data.byteLength, true);
v.setUint16(28, name.byteLength, true);
v.setUint16(30, 0, true);
v.setUint16(32, 0, true);
v.setUint16(34, 0, true);
v.setUint16(36, 0, true);
v.setUint32(38, 0, true);
v.setUint32(42, offset, true);
new Uint8Array(buf).set(name, 46);
return new Uint8Array(buf);
}
function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array {
const buf = new ArrayBuffer(22);
const v = new DataView(buf);
v.setUint32(0, 0x06054b50, true);
v.setUint16(4, 0, true);
v.setUint16(6, 0, true);
v.setUint16(8, count, true);
v.setUint16(10, count, true);
v.setUint32(12, cdSize, true);
v.setUint32(16, cdOffset, true);
v.setUint16(20, 0, true);
return new Uint8Array(buf);
}
function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array {
const enc = new TextEncoder();
const parts: Uint8Array[] = [];
const offsets: number[] = [];
let pos = 0;
for (const { name, bytes } of files) {
const nameBytes = enc.encode(name);
const lh = localHeader(nameBytes, bytes);
offsets.push(pos);
parts.push(lh, bytes);
pos += lh.byteLength + bytes.byteLength;
}
const cdParts = files.map(({ name, bytes }, i) =>
centralHeader(enc.encode(name), bytes, offsets[i])
);
const cd = concat(cdParts);
return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]);
}
function parseZip(data: Uint8Array): Map<string, Uint8Array> {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const files = new Map<string, Uint8Array>();
let pos = 0;
while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) {
const fnLen = view.getUint16(pos + 26, true);
const exLen = view.getUint16(pos + 28, true);
const cSize = view.getUint32(pos + 18, true);
const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen));
const start = pos + 30 + fnLen + exLen;
files.set(name, data.subarray(start, start + cSize));
pos = start + cSize;
}
return files;
}
function concat(arrays: Uint8Array[]): Uint8Array {
const total = arrays.reduce((n, a) => n + a.byteLength, 0);
const out = new Uint8Array(total);
let pos = 0;
for (const a of arrays) { out.set(a, pos); pos += a.byteLength; }
return out;
}
+134
View File
@@ -0,0 +1,134 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { settingsState } from "$lib/state/settings.svelte";
import { getUIAccessToken } from "$lib/core/auth";
const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6;
let active = 0;
let drainScheduled = false;
let clearing = false;
interface QueueEntry {
url: string;
priority: number;
resolve: (v: string) => void;
reject: (e: unknown) => void;
}
const queue: QueueEntry[] = [];
async function getAuthHeaders(): Promise<Record<string, string>> {
const mode = settingsState.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") {
const token = await getUIAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
if (mode === "BASIC_AUTH") {
const user = settingsState.serverAuthUser?.trim() ?? "";
const pass = settingsState.serverAuthPass?.trim() ?? "";
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
}
return {};
}
async function doFetch(url: string): Promise<string> {
const headers = await getAuthHeaders();
const res = await tauriFetch(url, { method: "GET", headers });
if (!res.ok) throw new Error(`${res.status}`);
const blob = await res.blob();
if (clearing) throw new DOMException("Cancelled", "AbortError");
const blobUrl = URL.createObjectURL(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() {
drainScheduled = false;
while (active < MAX_CONCURRENT && queue.length > 0) {
const entry = queue.shift()!;
active++;
doFetch(entry.url)
.then(entry.resolve, entry.reject)
.finally(() => { active--; drain(); });
}
}
function scheduleDrain() {
if (drainScheduled) return;
drainScheduled = true;
requestAnimationFrame(drain);
}
function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => {
insertSorted({ url, priority, resolve, reject });
}).catch(err => {
inflight.delete(url);
return Promise.reject(err);
});
inflight.set(url, promise);
scheduleDrain();
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 deprioritizeQueue(): void {
for (const entry of queue) entry.priority = 0;
queue.sort((a, b) => b.priority - a.priority);
}
export function cancelQueuedFetches(): void {
const dropped = queue.splice(0);
for (const entry of dropped) {
inflight.delete(entry.url);
entry.reject(new DOMException("Cancelled", "AbortError"));
}
}
export function clearBlobCache(): void {
clearing = true;
cancelQueuedFetches();
cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear();
inflight.clear();
clearing = false;
}
+7 -7
View File
@@ -146,13 +146,13 @@ export const CACHE_GROUPS = {
} as const;
export const CACHE_KEYS = {
LIBRARY: "library",
LIBRARY: "library",
RECENT_UPDATES: "recent_updates",
ALL_MANGA: "all_manga_unfiltered",
CATEGORIES: "categories",
SEARCH: "search_all_manga",
SOURCES: "sources",
POPULAR: "popular",
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}`,
@@ -234,7 +234,7 @@ export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string):
cache.clear(CACHE_KEYS.ALL_MANGA);
if (thumbnailUrl) {
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache");
const { revokeBlobUrl, getBlobUrl } = await import("$lib/core/cache/imageCache");
revokeBlobUrl(thumbnailUrl);
getBlobUrl(thumbnailUrl, 999).catch(() => {});
}
+24
View File
@@ -0,0 +1,24 @@
import { appState } from '$lib/state/app.svelte'
import type { Manga } from '$lib/types'
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
return new Promise(resolve => {
const worker = new Worker(new URL('./autoLinkWorker.ts', import.meta.url), { type: 'module' })
worker.onmessage = (e: MessageEvent<number[]>) => {
const matches = e.data
for (const id of matches) appState.linkManga(focal.id, id)
worker.terminate()
resolve(matches.length)
}
worker.onerror = () => { worker.terminate(); resolve(0) }
worker.postMessage({
focalTitle: focal.title,
focalId: focal.id,
allManga: allManga.map(m => ({ id: m.id, title: m.title })),
linkedIds: appState.settings.mangaLinks?.[focal.id] ?? [],
})
})
}
+17 -20
View File
@@ -1,29 +1,26 @@
interface WorkerMsg {
focalTitle: string;
focalId: number;
allManga: { id: number; title: string }[];
linkedIds: number[];
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;
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[] = [];
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);
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);
};
self.postMessage(matches)
}
+28 -29
View File
@@ -1,54 +1,53 @@
const THUMB_SIZE = 16;
const DUPE_THRESH = 0.12;
const hashCache = new Map<string, Uint8ClampedArray>();
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);
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;
const o = i * 4
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000
}
return gray;
return gray
}
function loadThumb(url: string): Promise<Uint8ClampedArray> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
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;
});
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);
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)!;
if (hashCache.has(url)) return hashCache.get(url)!
try {
const thumb = await loadThumb(url);
hashCache.set(url, thumb);
return thumb;
const thumb = await loadThumb(url)
hashCache.set(url, thumb)
return thumb
} catch {
return null;
return null
}
}
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
return similarity(a, b) <= DUPE_THRESH;
return similarity(a, b) <= DUPE_THRESH
}
export function clearHashCache(): void {
hashCache.clear();
hashCache.clear()
}
+92
View File
@@ -0,0 +1,92 @@
import { appState } from '$lib/state/app.svelte'
import { searchWithScore } from '$lib/core/algorithms/search'
import { getHash, areDuplicates } from '$lib/core/cover/coverHash'
type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null }
export type CoverCandidate = {
mangaId: number
url: string
label: string
isActive: boolean
}
const FUZZY_SCORE_THRESHOLD = 0.65
function normalizeUrl(url: string): string {
try {
const u = new URL(url)
u.search = ''
return u.href.toLowerCase()
} catch {
return url.toLowerCase()
}
}
export function resolvedCover(mangaId: number, ownUrl: string): string {
return appState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl
}
function fuzzyMatchIds(
mangaId: number,
title: string,
mangaById: Map<number, CoverManga & { title: string }>,
): number[] {
return searchWithScore(
[...mangaById.values()].filter(m => m.id !== mangaId),
title,
m => m.title,
)
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
.map(r => r.item.id)
}
export function coverCandidatesSync(
mangaId: number,
title: string,
ownUrl: string,
mangaById: Map<number, CoverManga & { title: string }>,
): CoverCandidate[] {
const linkedIds = appState.getLinkedMangaIds(mangaId)
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById)
const current = appState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl
const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds]))
const raw: { mangaId: number; url: string; label: string }[] = [
{ mangaId, url: ownUrl, label: 'This source' },
...allIds.flatMap(id => {
const m = mangaById.get(id)
return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : []
}),
]
const seen = new Set<string>()
return raw
.filter(c => {
const key = normalizeUrl(c.url)
if (seen.has(key)) return false
seen.add(key)
return true
})
.map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) }))
}
export async function dedupeByImage(candidates: CoverCandidate[]): Promise<CoverCandidate[]> {
const hashes = await Promise.all(candidates.map(c => getHash(c.url)))
const groups: number[][] = []
for (let i = 0; i < candidates.length; i++) {
const hi = hashes[i]
const existing = hi
? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false })
: undefined
if (existing) existing.push(i)
else groups.push([i])
}
return groups.map(group => {
const active = group.find(i => candidates[i].isActive) ?? group[0]
const labels = [...new Set(group.map(i => candidates[i].label))]
return { ...candidates[active], label: labels.join(' · ') }
})
}
+3
View File
@@ -0,0 +1,3 @@
export { eventToKeybind, matchesKeybind, toggleFullscreen } from './keybindEngine'
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from './defaultBinds'
export type { Keybinds } from './defaultBinds'
+5
View File
@@ -0,0 +1,5 @@
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
export type { PersistedData } from "./persist";
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
export type { VaultPayload } from "./credentialVault";
+166
View File
@@ -0,0 +1,166 @@
import { LazyStore } from "@tauri-apps/plugin-store";
const settingsStore = new LazyStore("settings.json", { autoSave: false });
const libraryStore = new LazyStore("library.json", { autoSave: false });
const updatesStore = new LazyStore("updates.json", { autoSave: false });
const backupsStore = new LazyStore("backups.json", { autoSave: false });
export interface PersistedData {
settings: any;
storeVersion: number | null;
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any | null;
dailyReadCounts: Record<string, number>;
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
}
export async function loadAllStores(): Promise<PersistedData> {
const migrated = await migrateFromLocalStorage();
if (migrated) return migrated;
const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([
settingsStore.get<number>("storeVersion"),
settingsStore.get<any>("settings"),
libraryStore.get<any[]>("history"),
libraryStore.get<any[]>("bookmarks"),
libraryStore.get<any[]>("markers"),
libraryStore.get<any[]>("readLog"),
libraryStore.get<any>("readingStats"),
libraryStore.get<Record<string, number>>("dailyReadCounts"),
updatesStore.get<any[]>("libraryUpdates"),
updatesStore.get<number>("lastLibraryRefresh"),
updatesStore.get<number[]>("acknowledgedUpdateIds"),
]);
return {
storeVersion: sv ?? null,
settings: s ?? null,
history: hist ?? [],
bookmarks: bk ?? [],
markers: mk ?? [],
readLog: rl ?? [],
readingStats: rs ?? null,
dailyReadCounts: dc ?? {},
libraryUpdates: lu ?? [],
lastLibraryRefresh: llr ?? 0,
acknowledgedUpdateIds: au ?? [],
};
}
async function migrateFromLocalStorage(): Promise<PersistedData | null> {
try {
const raw = localStorage.getItem("moku-store");
if (!raw) return null;
const data = JSON.parse(raw);
await Promise.all([
persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }),
persistLibrary({
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
}),
]);
localStorage.removeItem("moku-store");
return {
storeVersion: data.storeVersion ?? null,
settings: data.settings ?? null,
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
};
} catch {
return null;
}
}
export async function persistSettings(data: { settings: any; storeVersion: number }) {
await Promise.all([
settingsStore.set("settings", data.settings),
settingsStore.set("storeVersion", data.storeVersion),
]);
await settingsStore.save();
}
export async function persistLibrary(data: {
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any;
dailyReadCounts: Record<string, number>;
}) {
await Promise.all([
libraryStore.set("history", data.history),
libraryStore.set("bookmarks", data.bookmarks),
libraryStore.set("markers", data.markers),
libraryStore.set("readLog", data.readLog),
libraryStore.set("readingStats", data.readingStats),
libraryStore.set("dailyReadCounts", data.dailyReadCounts),
]);
await libraryStore.save();
}
export async function persistUpdates(data: {
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
}) {
await Promise.all([
updatesStore.set("libraryUpdates", data.libraryUpdates),
updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh),
updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds),
]);
await updatesStore.save();
}
export interface BackupEntry { url: string; name: string; }
export async function loadBackups(): Promise<BackupEntry[]> {
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
if (fromStore) return fromStore;
try {
const raw = localStorage.getItem("moku_backups");
if (!raw) return [];
const migrated: BackupEntry[] = JSON.parse(raw);
await persistBackups(migrated);
localStorage.removeItem("moku_backups");
return migrated;
} catch { return []; }
}
export async function persistBackups(list: BackupEntry[]): Promise<void> {
await backupsStore.set("backupList", list);
await backupsStore.save();
}
export async function resetAuthSettings(): Promise<void> {
const current = await settingsStore.get<any>("settings") ?? {};
current.serverAuthMode = "NONE";
current.serverAuthUser = "";
current.serverAuthPass = "";
await settingsStore.set("settings", current);
await settingsStore.save();
localStorage.removeItem("moku-credential-vault");
}
+67
View File
@@ -0,0 +1,67 @@
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
let themeStyleEl: HTMLStyleElement | null = null;
let mediaQuery: MediaQueryList | null = null;
let mediaHandler: (() => void) | null = null;
export function applyTheme() {
const themeId = settingsState.theme ?? "dark";
const isCustom = themeId.startsWith("custom:");
if (!isCustom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", themeId);
return;
}
const custom = settingsState.customThemes?.find(t => t.id === themeId);
if (!custom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", "dark");
return;
}
const vars = Object.entries(custom.tokens)
.map(([k, v]) => ` --${k}: ${v};`)
.join("\n");
const css = `[data-theme="custom"] {\n${vars}\n}`;
if (!themeStyleEl) {
themeStyleEl = document.createElement("style");
themeStyleEl.id = "moku-custom-theme";
document.head.appendChild(themeStyleEl);
}
themeStyleEl.textContent = css;
document.documentElement.setAttribute("data-theme", "custom");
}
function applySystemTheme(dark: boolean) {
const themeId = dark
? (settingsState.systemThemeDark ?? "dark")
: (settingsState.systemThemeLight ?? "light");
updateSettings({ theme: themeId });
}
export function mountSystemThemeSync() {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
}
if (!settingsState.systemThemeSync) return;
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaHandler = () => applySystemTheme(mediaQuery!.matches);
mediaQuery.addEventListener("change", mediaHandler);
applySystemTheme(mediaQuery.matches);
}
export function unmountSystemThemeSync() {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
mediaQuery = null;
}
}