import type { Manga, Source } from "@types"; import type { Settings } from "@types"; // ── Class utility ───────────────────────────────────────────────────────────── export { clsx as cn } from "clsx"; // ── Time / formatting ───────────────────────────────────────────────────────── export function timeAgo(ts: number): string { const diff = Date.now() - ts, m = Math.floor(diff / 60000); if (m < 1) return "Just now"; if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); if (d < 7) return `${d}d ago`; return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" }); } export function dayLabel(ts: number): string { const d = new Date(ts), now = new Date(); if (d.toDateString() === now.toDateString()) return "Today"; const yest = new Date(now); yest.setDate(now.getDate() - 1); if (d.toDateString() === yest.toDateString()) return "Yesterday"; return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }); } export function formatReadTime(m: number): string { if (m < 1) return "< 1 min"; if (m < 60) return `${m} min`; const h = Math.floor(m / 60), r = m % 60; return r === 0 ? `${h}h` : `${h}h ${r}m`; } // ── NSFW filtering ──────────────────────────────────────────────────────────── /** * Default genre substrings used when no user-configured list is available. * Stored as settings.nsfwFilteredTags; editable in Settings > Content. */ 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's genre list contains any of the given substrings. * Falls back to DEFAULT_NSFW_TAGS if no tag list is provided. */ export function isNsfwManga( manga: { genre?: string[] | null }, tags: string[] = DEFAULT_NSFW_TAGS, ): boolean { return (manga.genre ?? []).some(g => tags.some(sub => g.toLowerCase().trim().includes(sub)) ); } /** * Single authoritative NSFW gate used by all views. * Returns true when the manga should be HIDDEN. Priority order: * 1. Source in blockedSourceIds → always hidden, even when showNsfw is on. * 2. showNsfw globally enabled → only blocked sources are hidden. * 3. Source in allowedSourceIds → skip isNsfw flag, but genre tags still apply. * 4. source.isNsfw flag → hidden. * 5. Genre tag match → hidden. * * Usage: items.filter(m => !shouldHideNsfw(m, settings)) */ export function shouldHideNsfw( manga: Pick, settings: Pick, ): boolean { const srcId = manga.source?.id; if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true; if (settings.showNsfw) return false; 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. * Usage: sources.filter(s => !shouldHideSource(s, settings)) */ export function shouldHideSource( source: Pick, settings: Pick, ): boolean { if (settings.nsfwBlockedSourceIds.includes(source.id)) return true; if (settings.nsfwAllowedSourceIds.includes(source.id)) return false; return !settings.showNsfw && source.isNsfw; } // ── Source deduplication ────────────────────────────────────────────────────── /** * Deduplicates sources by name. When multiple sources share a name, * the preferred language wins; otherwise falls back to alphabetical by lang. * The local source (id "0") is always excluded. */ export function dedupeSources(sources: Source[], preferredLang: string): Source[] { const byName = new Map(); 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 ─────────────────────────────────────────────────────── /** Strips punctuation, articles, and source suffixes for fuzzy title matching. */ 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(); } /** Strips all non-alphanumeric chars and collapses whitespace. */ function norm(s: string): string { return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim(); } /** * First 200 normalized chars of a description — reliable cross-source fingerprint. * Returns null if too short (< 60 chars) to be a trustworthy signal. */ function descFingerprint(desc: string | null | undefined): string | null { if (!desc) return null; const n = norm(desc); return n.length >= 60 ? n.slice(0, 200) : null; } /** * Normalized author + artist concatenation for tie-breaking. * Returns null if no author info available. */ function authorFingerprint(author?: string | null, artist?: string | null): string | null { const parts = [author, artist].filter(Boolean).map(s => norm(s!)); return parts.length ? parts.sort().join("|") : null; } /** * Deduplicates manga across sources using title, description, and author signals, * plus explicit user-defined links (settings.mangaLinks). * * When two entries match, the better one is kept: * - Library membership wins over non-library. * - Otherwise higher downloadCount wins. * - Otherwise first occurrence wins. */ export function dedupeMangaByTitle(items: T[], links: Record = {}): T[] { const byTitle = new Map(); const byDesc = new Map(); const byAuthorDesc = new Map(); const byId = new Map(); const out: T[] = []; for (const m of items) { const tk = normalizeTitle(m.title); const dk = descFingerprint(m.description); const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null; const linkedIds = links[m.id] ?? []; const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined); const existingIdx = linkedIdx ?? byTitle.get(tk) ?? (dk ? byDesc.get(dk) : undefined) ?? (ak ? byAuthorDesc.get(ak) : undefined); if (existingIdx !== undefined) { const existing = out[existingIdx]; const mBetter = (m.inLibrary && !existing.inLibrary) || (!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0)); if (mBetter) { out[existingIdx] = m; byTitle.set(tk, existingIdx); byId.set(m.id, existingIdx); if (dk) byDesc.set(dk, existingIdx); if (ak) byAuthorDesc.set(ak, existingIdx); } continue; } const idx = out.length; out.push(m); byTitle.set(tk, idx); byId.set(m.id, idx); if (dk) byDesc.set(dk, idx); if (ak) byAuthorDesc.set(ak, idx); } return out; } /** * Lossless deduplication by ID only. Preserves first occurrence. */ export function dedupeMangaById(items: T[]): T[] { const seen = new Set(); const out: T[] = []; for (const m of items) { if (!seen.has(m.id)) { seen.add(m.id); out.push(m); } } return out; }