mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-14 01:39:56 -05:00
Chore: Port over Settings (Barely Works)
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+134
@@ -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;
|
||||
}
|
||||
Vendored
+7
-7
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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] ?? [],
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(' · ') }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { eventToKeybind, matchesKeybind, toggleFullscreen } from './keybindEngine'
|
||||
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from './defaultBinds'
|
||||
export type { Keybinds } from './defaultBinds'
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user