mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Attempted De-Dupe Patch #1 & Alternative Thumbnails
This commit is contained in:
@@ -4,12 +4,13 @@
|
|||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
|
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, groupDuplicates, normalizeTitle } from "../../lib/util";
|
||||||
import { settings, previewManga, activeSource, addFolder, assignMangaToFolder } from "../../store";
|
import { settings, previewManga, activeSource, addFolder, assignMangaToFolder } from "../../store";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
import ContextMenu from "../shared/ContextMenu.svelte";
|
||||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
import SourceBrowse from "../sources/SourceBrowse.svelte";
|
import SourceBrowse from "../sources/SourceBrowse.svelte";
|
||||||
|
import MangaPreview from "../shared/MangaPreview.svelte";
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────────
|
||||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
||||||
@@ -56,6 +57,32 @@
|
|||||||
// Context menu
|
// Context menu
|
||||||
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
||||||
|
|
||||||
|
// Raw pool of ALL items before dedup — used to find alternates for the preview switcher.
|
||||||
|
// Keyed by normalised title → array of Manga with that title from different sources.
|
||||||
|
let rawPool = new Map<string, Manga[]>();
|
||||||
|
|
||||||
|
function addToPool(items: Manga[]) {
|
||||||
|
for (const m of items) {
|
||||||
|
const k = normalizeTitle(m.title);
|
||||||
|
if (!rawPool.has(k)) rawPool.set(k, []);
|
||||||
|
const group = rawPool.get(k)!;
|
||||||
|
if (!group.some(x => x.id === m.id)) group.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get alternates (other source variants) for a given manga, excluding itself
|
||||||
|
function getAlternates(m: Manga): Manga[] {
|
||||||
|
const k = normalizeTitle(m.title);
|
||||||
|
return (rawPool.get(k) ?? []).filter(x => x.id !== m.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open preview with alternates pre-computed
|
||||||
|
let previewAlternates: Manga[] = [];
|
||||||
|
function openPreview(m: Manga) {
|
||||||
|
previewAlternates = getAlternates(m);
|
||||||
|
previewManga.set(m);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Derived ───────────────────────────────────────────────────────────────────
|
// ── Derived ───────────────────────────────────────────────────────────────────
|
||||||
$: visibleGrid = genreResults.get(currentGenre) ?? [];
|
$: visibleGrid = genreResults.get(currentGenre) ?? [];
|
||||||
$: isLoading = genreLoading || (currentGenre === "All" && loadingLib);
|
$: isLoading = genreLoading || (currentGenre === "All" && loadingLib);
|
||||||
@@ -86,11 +113,12 @@
|
|||||||
batchTimer = setInterval(() => {
|
batchTimer = setInterval(() => {
|
||||||
if (batchAccum.size === 0) return;
|
if (batchAccum.size === 0) return;
|
||||||
for (const [genre, incoming] of batchAccum) {
|
for (const [genre, incoming] of batchAccum) {
|
||||||
|
addToPool(incoming);
|
||||||
const current = genreResults.get(genre) ?? [];
|
const current = genreResults.get(genre) ?? [];
|
||||||
genreResults.set(genre, dedup([...current, ...incoming]).slice(0, GRID_LIMIT));
|
genreResults.set(genre, dedup([...current, ...incoming]).slice(0, GRID_LIMIT));
|
||||||
}
|
}
|
||||||
batchAccum.clear();
|
batchAccum.clear();
|
||||||
genreResults = new Map(genreResults); // single Svelte reactivity trigger
|
genreResults = new Map(genreResults);
|
||||||
}, BATCH_INTERVAL);
|
}, BATCH_INTERVAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +270,7 @@
|
|||||||
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
|
||||||
).then(m => {
|
).then(m => {
|
||||||
allManga = dedupeMangaById(m);
|
allManga = dedupeMangaById(m);
|
||||||
|
addToPool(allManga);
|
||||||
genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT));
|
genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT));
|
||||||
genreResults = new Map(genreResults);
|
genreResults = new Map(genreResults);
|
||||||
}).catch(e => { console.error(e); loadError = true; })
|
}).catch(e => { console.error(e); loadError = true; })
|
||||||
@@ -313,7 +342,7 @@
|
|||||||
{#each visibleGrid as m (m.id)}
|
{#each visibleGrid as m (m.id)}
|
||||||
<button
|
<button
|
||||||
class="manga-card"
|
class="manga-card"
|
||||||
on:click={() => previewManga.set(m)}
|
on:click={() => openPreview(m)}
|
||||||
on:contextmenu={(e) => openCtx(e, m)}
|
on:contextmenu={(e) => openCtx(e, m)}
|
||||||
>
|
>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
@@ -343,6 +372,8 @@
|
|||||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<MangaPreview alternates={previewAlternates} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,9 @@
|
|||||||
|
|
||||||
$: heroEntry = activeSlot?.kind === "continue" ? activeSlot.entry : null;
|
$: heroEntry = activeSlot?.kind === "continue" ? activeSlot.entry : null;
|
||||||
|
|
||||||
|
// Svelte action — focuses element on mount, avoiding the a11y autofocus warning
|
||||||
|
function focusEl(node: HTMLElement) { node.focus(); }
|
||||||
|
|
||||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; }
|
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; }
|
||||||
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; }
|
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; }
|
||||||
function goToSlot(i: number) { activeIdx = i; }
|
function goToSlot(i: number) { activeIdx = i; }
|
||||||
@@ -437,7 +440,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="picker-search-wrap">
|
<div class="picker-search-wrap">
|
||||||
<MagnifyingGlass size={13} weight="light" class="picker-search-icon" />
|
<MagnifyingGlass size={13} weight="light" class="picker-search-icon" />
|
||||||
<input class="picker-search" placeholder="Search library…" bind:value={pickerSearch} autofocus />
|
<input class="picker-search" placeholder="Search library…" bind:value={pickerSearch} use:focusEl />
|
||||||
</div>
|
</div>
|
||||||
<div class="picker-list">
|
<div class="picker-list">
|
||||||
{#if loadingLibrary}
|
{#if loadingLibrary}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
|
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
||||||
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
||||||
import { settings, activeManga, libraryFilter, genreFilter, activeChapter } from "../../store";
|
import { settings, activeManga, libraryFilter, genreFilter, activeChapter } from "../../store";
|
||||||
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store";
|
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store";
|
||||||
@@ -39,7 +39,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
// Saved tab — library only (inLibrary: true)
|
|
||||||
fetchLibrary()
|
fetchLibrary()
|
||||||
.then((nodes) => {
|
.then((nodes) => {
|
||||||
allManga = dedupeMangaByTitle(dedupeMangaById(nodes));
|
allManga = dedupeMangaByTitle(dedupeMangaById(nodes));
|
||||||
@@ -48,10 +47,10 @@
|
|||||||
.catch((e) => error = e.message)
|
.catch((e) => error = e.message)
|
||||||
.finally(() => loading = false);
|
.finally(() => loading = false);
|
||||||
|
|
||||||
// Folder tabs — all manga regardless of inLibrary.
|
cache.get(CACHE_KEYS.ALL_MANGA, () =>
|
||||||
// Cached separately so it doesn't bust the library cache.
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA).then((d) => d.mangas.nodes),
|
||||||
cache.get("all_manga_unfiltered", () =>
|
DEFAULT_TTL_MS,
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA).then((d) => d.mangas.nodes)
|
CACHE_GROUPS.LIBRARY,
|
||||||
).then((nodes) => {
|
).then((nodes) => {
|
||||||
allMangaUnfiltered = dedupeMangaByTitle(dedupeMangaById(nodes));
|
allMangaUnfiltered = dedupeMangaByTitle(dedupeMangaById(nodes));
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
@@ -138,7 +137,7 @@
|
|||||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
||||||
allManga = allManga.filter((m) => m.id !== manga.id);
|
allManga = allManga.filter((m) => m.id !== manga.id);
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
cache.clear("all_manga_unfiltered");
|
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAllDownloads(manga: Manga) {
|
async function deleteAllDownloads(manga: Manga) {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||||
import { dedupeSources, dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
import { dedupeSources, dedupeMangaById, dedupeMangaByTitle, normalizeTitle } from "../../lib/util";
|
||||||
import { settings, searchPrefill, previewManga } from "../../store";
|
import { settings, searchPrefill, previewManga } from "../../store";
|
||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
|
import MangaPreview from "../shared/MangaPreview.svelte";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -167,6 +168,7 @@
|
|||||||
ctrl.signal,
|
ctrl.signal,
|
||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
|
addToPool(d.fetchSourceManga.mangas);
|
||||||
kw_results = kw_results.map((r) =>
|
kw_results = kw_results.map((r) =>
|
||||||
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r,
|
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r,
|
||||||
);
|
);
|
||||||
@@ -263,6 +265,7 @@
|
|||||||
ctrl.signal,
|
ctrl.signal,
|
||||||
).then((d) => {
|
).then((d) => {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
|
addToPool(d.mangas.nodes);
|
||||||
tag_localResults = d.mangas.nodes;
|
tag_localResults = d.mangas.nodes;
|
||||||
tag_totalCount = d.mangas.totalCount;
|
tag_totalCount = d.mangas.totalCount;
|
||||||
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
|
||||||
@@ -320,6 +323,7 @@
|
|||||||
: result.mangas;
|
: result.mangas;
|
||||||
|
|
||||||
if (matching.length > 0) {
|
if (matching.length > 0) {
|
||||||
|
addToPool(matching);
|
||||||
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]));
|
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]));
|
||||||
tag_loadingSourceSearch = false;
|
tag_loadingSourceSearch = false;
|
||||||
}
|
}
|
||||||
@@ -392,8 +396,10 @@
|
|||||||
? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags))
|
? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags))
|
||||||
: result.mangas;
|
: result.mangas;
|
||||||
|
|
||||||
if (matching.length > 0)
|
if (matching.length > 0) {
|
||||||
|
addToPool(matching);
|
||||||
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]));
|
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]));
|
||||||
|
}
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
} finally {
|
} finally {
|
||||||
if (!ctrl.signal.aborted) tag_loadingMoreSource = false;
|
if (!ctrl.signal.aborted) tag_loadingMoreSource = false;
|
||||||
@@ -482,6 +488,26 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ── Raw pool + alternates for MangaPreview thumbnail switcher ────────────────
|
||||||
|
// All manga seen across all sources — keyed by normalised title.
|
||||||
|
const rawPool = new Map<string, Manga[]>();
|
||||||
|
|
||||||
|
function addToPool(items: Manga[]) {
|
||||||
|
for (const m of items) {
|
||||||
|
const k = normalizeTitle(m.title);
|
||||||
|
if (!rawPool.has(k)) rawPool.set(k, []);
|
||||||
|
const g = rawPool.get(k)!;
|
||||||
|
if (!g.some(x => x.id === m.id)) g.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let previewAlternates: Manga[] = [];
|
||||||
|
function openPreview(m: Manga) {
|
||||||
|
const k = normalizeTitle(m.title);
|
||||||
|
previewAlternates = (rawPool.get(k) ?? []).filter(x => x.id !== m.id);
|
||||||
|
previewManga.set(m);
|
||||||
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
kw_abortCtrl?.abort();
|
kw_abortCtrl?.abort();
|
||||||
tag_abortLocal?.abort();
|
tag_abortLocal?.abort();
|
||||||
@@ -684,7 +710,7 @@
|
|||||||
{:else if mangas.length > 0}
|
{:else if mangas.length > 0}
|
||||||
<div class="sourceRow">
|
<div class="sourceRow">
|
||||||
{#each mangas.slice(0, ($settings.renderLimit ?? 48)) as m (m.id)}
|
{#each mangas.slice(0, ($settings.renderLimit ?? 48)) as m (m.id)}
|
||||||
<button class="card" on:click={() => previewManga.set(m)}>
|
<button class="card" on:click={() => openPreview(m)}>
|
||||||
<div class="coverWrap">
|
<div class="coverWrap">
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(m.thumbnailUrl)}
|
src={thumbUrl(m.thumbnailUrl)}
|
||||||
@@ -834,7 +860,7 @@
|
|||||||
{:else if tag_mergedResults.length > 0}
|
{:else if tag_mergedResults.length > 0}
|
||||||
<div class="tagGrid">
|
<div class="tagGrid">
|
||||||
{#each tag_mergedResults as m (m.id)}
|
{#each tag_mergedResults as m (m.id)}
|
||||||
<button class="card" on:click={() => previewManga.set(m)}>
|
<button class="card" on:click={() => openPreview(m)}>
|
||||||
<div class="coverWrap">
|
<div class="coverWrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
@@ -1013,7 +1039,7 @@
|
|||||||
{:else if src_browseResults.length > 0}
|
{:else if src_browseResults.length > 0}
|
||||||
<div class="tagGrid">
|
<div class="tagGrid">
|
||||||
{#each src_browseResults as m (m.id)}
|
{#each src_browseResults as m (m.id)}
|
||||||
<button class="card" on:click={() => previewManga.set(m)}>
|
<button class="card" on:click={() => openPreview(m)}>
|
||||||
<div class="coverWrap">
|
<div class="coverWrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
@@ -1057,6 +1083,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MangaPreview alternates={previewAlternates} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── Root ──────────────────────────────────────────────────────────────── */
|
/* ── Root ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
@@ -863,7 +863,6 @@
|
|||||||
.perf-stat { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
.perf-stat { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
|
|
||||||
/* Storage limit */
|
/* Storage limit */
|
||||||
.storage-limit-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
|
||||||
.storage-limit-input {
|
.storage-limit-input {
|
||||||
width: 64px; text-align: center;
|
width: 64px; text-align: center;
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
@@ -876,5 +875,4 @@
|
|||||||
.storage-limit-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
.storage-limit-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
||||||
.storage-limit-input:focus { border-color: var(--border-strong); }
|
.storage-limit-input:focus { border-color: var(--border-strong); }
|
||||||
.storage-limit-unit { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
.storage-limit-unit { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
.storage-limit-note { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-3) var(--sp-2); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
||||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||||
import { settings, previewManga, activeManga, navPage, genreFilter, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted } from "../../store";
|
import { settings, previewManga, activeManga, navPage, genreFilter, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted } from "../../store";
|
||||||
|
import { groupDuplicates } from "../../lib/util";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
|
|
||||||
let manga: Manga | null = null;
|
let manga: Manga | null = null;
|
||||||
@@ -20,6 +21,13 @@
|
|||||||
let fetchError: string|null = null;
|
let fetchError: string|null = null;
|
||||||
let folderRef: HTMLDivElement;
|
let folderRef: HTMLDivElement;
|
||||||
|
|
||||||
|
// Alternate versions of this manga from other sources (same title/desc, diff source)
|
||||||
|
// Populated by the parent via the `alternates` prop if available.
|
||||||
|
export let alternates: Manga[] = [];
|
||||||
|
let selectedThumb: string | null = null; // null = use manga's own thumbnail
|
||||||
|
|
||||||
|
$: effectiveThumb = selectedThumb ?? ($previewManga?.thumbnailUrl ?? "");
|
||||||
|
|
||||||
let detailAbort: AbortController | null = null;
|
let detailAbort: AbortController | null = null;
|
||||||
let chapterAbort: AbortController | null = null;
|
let chapterAbort: AbortController | null = null;
|
||||||
|
|
||||||
@@ -28,6 +36,7 @@
|
|||||||
previewManga.set(null);
|
previewManga.set(null);
|
||||||
manga = null; chapters = []; descExpanded = false;
|
manga = null; chapters = []; descExpanded = false;
|
||||||
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
|
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
|
||||||
|
selectedThumb = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
|
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
|
||||||
@@ -157,17 +166,52 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $previewManga}
|
{#if $previewManga}
|
||||||
<div class="backdrop" on:click={(e) => { if (e.target === e.currentTarget) close(); }}>
|
<div class="backdrop" role="presentation" on:click={(e) => { if (e.target === e.currentTarget) close(); }} on:keydown={(e) => { if (e.key === "Escape") close(); }}>
|
||||||
<div class="modal" role="dialog" aria-label="Manga preview">
|
<div class="modal" role="dialog" aria-label="Manga preview">
|
||||||
|
|
||||||
<!-- Cover column -->
|
<!-- Cover column -->
|
||||||
<div class="cover-col">
|
<div class="cover-col">
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl($previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
|
<img src={thumbUrl(effectiveThumb)} alt={displayManga?.title} class="cover" />
|
||||||
{#if loadingDetail}
|
{#if loadingDetail}
|
||||||
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Alternate source thumbnails — shown when this manga exists in multiple sources -->
|
||||||
|
{#if alternates.length > 0}
|
||||||
|
<div class="thumb-switcher">
|
||||||
|
<span class="thumb-switcher-label">Sources</span>
|
||||||
|
<div class="thumb-switcher-row">
|
||||||
|
<!-- Primary -->
|
||||||
|
<button
|
||||||
|
class="thumb-option"
|
||||||
|
class:thumb-option-active={selectedThumb === null}
|
||||||
|
on:click={() => selectedThumb = null}
|
||||||
|
title={displayManga?.source?.displayName ?? "Primary"}
|
||||||
|
>
|
||||||
|
<img src={thumbUrl($previewManga?.thumbnailUrl ?? "")} alt="Primary" class="thumb-option-img" loading="lazy" />
|
||||||
|
{#if displayManga?.source?.displayName}
|
||||||
|
<span class="thumb-option-label">{displayManga.source.displayName}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<!-- Alternates -->
|
||||||
|
{#each alternates as alt (alt.id)}
|
||||||
|
<button
|
||||||
|
class="thumb-option"
|
||||||
|
class:thumb-option-active={selectedThumb === alt.thumbnailUrl}
|
||||||
|
on:click={() => selectedThumb = alt.thumbnailUrl}
|
||||||
|
title={alt.source?.displayName ?? alt.title}
|
||||||
|
>
|
||||||
|
<img src={thumbUrl(alt.thumbnailUrl)} alt={alt.title} class="thumb-option-img" loading="lazy" />
|
||||||
|
{#if alt.source?.displayName}
|
||||||
|
<span class="thumb-option-label">{alt.source.displayName}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="cover-actions">
|
<div class="cover-actions">
|
||||||
<button class="action-btn" class:active={inLibrary} on:click={toggleLibrary} disabled={togglingLib || loadingDetail}>
|
<button class="action-btn" class:active={inLibrary} on:click={toggleLibrary} disabled={togglingLib || loadingDetail}>
|
||||||
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
|
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
|
||||||
@@ -401,5 +445,40 @@
|
|||||||
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); }
|
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); }
|
||||||
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
|
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
|
||||||
.meta-link:hover { opacity: 0.75; }
|
.meta-link:hover { opacity: 0.75; }
|
||||||
|
/* ── Thumbnail switcher ──────────────────────────────────────────────────── */
|
||||||
|
.thumb-switcher {
|
||||||
|
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||||
|
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
||||||
|
}
|
||||||
|
.thumb-switcher-label {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.thumb-switcher-row {
|
||||||
|
display: flex; flex-wrap: wrap; gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.thumb-option {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
background: none; border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-sm); padding: 4px;
|
||||||
|
cursor: pointer; transition: border-color var(--t-base), background var(--t-base);
|
||||||
|
flex: 1; min-width: 52px; max-width: 64px;
|
||||||
|
}
|
||||||
|
.thumb-option:hover { border-color: var(--border-strong); background: var(--bg-overlay); }
|
||||||
|
.thumb-option-active { border-color: var(--accent) !important; background: var(--accent-muted) !important; }
|
||||||
|
.thumb-option-img {
|
||||||
|
width: 100%; aspect-ratio: 2/3; object-fit: cover;
|
||||||
|
border-radius: 2px; display: block;
|
||||||
|
}
|
||||||
|
.thumb-option-label {
|
||||||
|
font-family: var(--font-ui); font-size: 9px;
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wide);
|
||||||
|
text-align: center; line-height: 1.2;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.thumb-option-active .thumb-option-label { color: var(--accent-fg); }
|
||||||
|
|
||||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+104
-41
@@ -1,58 +1,107 @@
|
|||||||
/**
|
/**
|
||||||
* Session-level request cache.
|
* Session-level request cache — v3.
|
||||||
*
|
*
|
||||||
* Key design decisions (v1, preserved):
|
* Key design decisions (preserved from v1/v2):
|
||||||
* - Stores the Promise itself — concurrent callers await the same fetch (no thundering herd).
|
* - Stores the Promise itself — concurrent callers await the same fetch (no thundering herd).
|
||||||
* - On real errors the entry is evicted so the next call retries.
|
* - On real errors the entry is evicted so the next call retries.
|
||||||
* - AbortErrors do NOT evict — the request was cancelled by the user, not failed.
|
* - AbortErrors do NOT evict — cancellation ≠ failure.
|
||||||
* This is critical: if we evicted on abort, rapid open/close would drain the browser's
|
* - Subscribers are notified when a key is explicitly cleared or updated.
|
||||||
* connection pool (Chromium allows only 6 concurrent connections to the same origin).
|
|
||||||
* - Subscribers are notified when a key is explicitly cleared (for reactive invalidation).
|
|
||||||
*
|
*
|
||||||
* v2 additions:
|
* v3 additions:
|
||||||
* - TTL-aware get(): stale entries are re-fetched automatically (default 5 min).
|
* - cache.set(): direct write without a fetcher — for optimistic updates and
|
||||||
* Pass Infinity to pin an entry for the session (source list, extension list).
|
* post-mutation cache patching. Notifies subscribers immediately.
|
||||||
* - getPageSet(): lightweight page-number tracker for multi-page browse sessions.
|
* - Invalidation groups: tag a cache key with one or more group strings.
|
||||||
* Mirrors Suwayomi's CACHE_PAGES_KEY pattern so GenreDrillPage / Search TagTab
|
* cache.clearGroup("library") clears ALL keys tagged with "library" in one call.
|
||||||
* can resume a session without re-fetching pages already in memory.
|
* This replaces the pattern of manually calling cache.clear() on every related key.
|
||||||
* - Stable multi-tag cache keys: tag arrays are sorted before joining so
|
* - Subscriber notifications on set() — reactive components re-render when the
|
||||||
* ["Action","Romance"] and ["Romance","Action"] share the same bucket.
|
* cache is updated, not just when it's cleared.
|
||||||
|
* - cache.update(): atomically patch a cached value (read → transform → write).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Entry<T> {
|
interface Entry<T> {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
fetchedAt: number; // ms since epoch
|
fetchedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = new Map<string, Entry<unknown>>();
|
const store = new Map<string, Entry<unknown>>();
|
||||||
const subs = new Map<string, Set<() => void>>();
|
const subs = new Map<string, Set<() => void>>();
|
||||||
|
const groups = new Map<string, Set<string>>(); // groupTag → Set<cacheKey>
|
||||||
|
|
||||||
/** Default revalidation window: 5 min (matches Suwayomi's browse-page TTL). */
|
|
||||||
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
||||||
|
|
||||||
|
function notify(key: string) {
|
||||||
|
subs.get(key)?.forEach((cb) => cb());
|
||||||
|
}
|
||||||
|
|
||||||
export const cache = {
|
export const cache = {
|
||||||
/**
|
/**
|
||||||
* Return a cached promise.
|
* Return a cached promise. Re-fetches once older than `ttl` ms.
|
||||||
* Re-fetches automatically once the entry is older than `ttl` ms.
|
* Pass `Infinity` to pin for the session.
|
||||||
* Pass `Infinity` to cache for the entire session (e.g. source/extension lists).
|
|
||||||
*/
|
*/
|
||||||
get<T>(key: string, fetcher: () => Promise<T>, ttl: number = DEFAULT_TTL_MS): Promise<T> {
|
get<T>(
|
||||||
|
key: string,
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
ttl: number = DEFAULT_TTL_MS,
|
||||||
|
group?: string | string[],
|
||||||
|
): Promise<T> {
|
||||||
const existing = store.get(key) as Entry<T> | undefined;
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
|
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
|
||||||
|
|
||||||
const promise = fetcher().catch((err) => {
|
const promise = fetcher().catch((err) => {
|
||||||
// Only evict on real failures, not user cancellations
|
|
||||||
if (err?.name !== "AbortError") store.delete(key);
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
}) as Promise<T>;
|
}) as Promise<T>;
|
||||||
|
|
||||||
store.set(key, { promise, fetchedAt: Date.now() });
|
store.set(key, { promise, fetchedAt: Date.now() });
|
||||||
|
|
||||||
|
// Register in invalidation groups
|
||||||
|
if (group) {
|
||||||
|
const tags = Array.isArray(group) ? group : [group];
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (!groups.has(tag)) groups.set(tag, new Set());
|
||||||
|
groups.get(tag)!.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify subscribers once the fetch resolves (reactive update on new data)
|
||||||
|
promise.then(() => notify(key)).catch(() => {});
|
||||||
|
|
||||||
return promise;
|
return promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directly write a value into the cache — for optimistic updates and
|
||||||
|
* post-mutation patching. Notifies subscribers immediately.
|
||||||
|
*/
|
||||||
|
set<T>(key: string, value: T, group?: string | string[]) {
|
||||||
|
const promise = Promise.resolve(value);
|
||||||
|
store.set(key, { promise, fetchedAt: Date.now() });
|
||||||
|
|
||||||
|
if (group) {
|
||||||
|
const tags = Array.isArray(group) ? group : [group];
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (!groups.has(tag)) groups.set(tag, new Set());
|
||||||
|
groups.get(tag)!.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(key);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Atomically patch a cached value.
|
||||||
|
* If the key doesn't exist, does nothing.
|
||||||
|
*/
|
||||||
|
update<T>(key: string, fn: (prev: T) => T) {
|
||||||
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
|
if (!existing) return;
|
||||||
|
const next = existing.promise.then(fn);
|
||||||
|
store.set(key, { promise: next, fetchedAt: Date.now() });
|
||||||
|
next.then(() => notify(key)).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
has(key: string): boolean { return store.has(key); },
|
has(key: string): boolean { return store.has(key); },
|
||||||
|
|
||||||
/** How old (ms) a cached entry is, or undefined if absent. */
|
|
||||||
ageOf(key: string): number | undefined {
|
ageOf(key: string): number | undefined {
|
||||||
const e = store.get(key);
|
const e = store.get(key);
|
||||||
return e ? Date.now() - e.fetchedAt : undefined;
|
return e ? Date.now() - e.fetchedAt : undefined;
|
||||||
@@ -60,15 +109,30 @@ export const cache = {
|
|||||||
|
|
||||||
clear(key: string) {
|
clear(key: string) {
|
||||||
store.delete(key);
|
store.delete(key);
|
||||||
subs.get(key)?.forEach((cb) => cb());
|
notify(key);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all keys belonging to an invalidation group.
|
||||||
|
* e.g. cache.clearGroup("library") clears "library", "all_manga_unfiltered", etc.
|
||||||
|
*/
|
||||||
|
clearGroup(tag: string) {
|
||||||
|
const keys = groups.get(tag);
|
||||||
|
if (!keys) return;
|
||||||
|
for (const key of keys) {
|
||||||
|
store.delete(key);
|
||||||
|
notify(key);
|
||||||
|
}
|
||||||
|
groups.delete(tag);
|
||||||
},
|
},
|
||||||
|
|
||||||
clearAll() {
|
clearAll() {
|
||||||
|
const allKeys = [...store.keys()];
|
||||||
store.clear();
|
store.clear();
|
||||||
subs.forEach((set) => set.forEach((cb) => cb()));
|
groups.clear();
|
||||||
|
allKeys.forEach(notify);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Subscribe to cache invalidation for a key. Returns unsubscribe fn. */
|
|
||||||
subscribe(key: string, cb: () => void): () => void {
|
subscribe(key: string, cb: () => void): () => void {
|
||||||
if (!subs.has(key)) subs.set(key, new Set());
|
if (!subs.has(key)) subs.set(key, new Set());
|
||||||
subs.get(key)!.add(cb);
|
subs.get(key)!.add(cb);
|
||||||
@@ -78,24 +142,24 @@ export const cache = {
|
|||||||
|
|
||||||
// ── Cache key constants ───────────────────────────────────────────────────────
|
// ── Cache key constants ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidation group tags.
|
||||||
|
* cache.clearGroup(CACHE_GROUPS.LIBRARY) clears all library-related keys at once.
|
||||||
|
*/
|
||||||
|
export const CACHE_GROUPS = {
|
||||||
|
LIBRARY: "g:library", // library + all_manga_unfiltered
|
||||||
|
SOURCES: "g:sources", // sources list + per-source page caches
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const CACHE_KEYS = {
|
export const CACHE_KEYS = {
|
||||||
LIBRARY: "library",
|
LIBRARY: "library",
|
||||||
SOURCES: "sources",
|
ALL_MANGA: "all_manga_unfiltered",
|
||||||
POPULAR: "popular",
|
SOURCES: "sources",
|
||||||
|
POPULAR: "popular",
|
||||||
GENRE: (genre: string) => `genre:${genre}`,
|
GENRE: (genre: string) => `genre:${genre}`,
|
||||||
MANGA: (id: number) => `manga:${id}`,
|
MANGA: (id: number) => `manga:${id}`,
|
||||||
CHAPTERS: (id: number) => `chapters:${id}`,
|
CHAPTERS: (id: number) => `chapters:${id}`,
|
||||||
|
|
||||||
/**
|
|
||||||
* Stable key for a browse session's page-number set.
|
|
||||||
* Tag arrays are sorted so order never creates duplicate buckets —
|
|
||||||
* ["Action","Romance"] and ["Romance","Action"] share one key.
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* CACHE_KEYS.sourceMangaPages("src123", "POPULAR")
|
|
||||||
* CACHE_KEYS.sourceMangaPages("src123", "SEARCH", "naruto")
|
|
||||||
* CACHE_KEYS.sourceMangaPages("src123", "SEARCH", ["Action","Romance"])
|
|
||||||
*/
|
|
||||||
sourceMangaPages(
|
sourceMangaPages(
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
type: "POPULAR" | "LATEST" | "SEARCH",
|
type: "POPULAR" | "LATEST" | "SEARCH",
|
||||||
@@ -105,7 +169,6 @@ export const CACHE_KEYS = {
|
|||||||
return `pages:${sourceId}:${type}:${q}`;
|
return `pages:${sourceId}:${type}:${q}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Per-page result key. Always pair with sourceMangaPages(). */
|
|
||||||
sourceMangaPage(
|
sourceMangaPage(
|
||||||
sourceId: string,
|
sourceId: string,
|
||||||
type: "POPULAR" | "LATEST" | "SEARCH",
|
type: "POPULAR" | "LATEST" | "SEARCH",
|
||||||
|
|||||||
+87
-7
@@ -30,16 +30,60 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[
|
|||||||
// ── Manga deduplication ───────────────────────────────────────────────────────
|
// ── Manga deduplication ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deduplicates manga by title (case-insensitive), keeping the first occurrence.
|
* Normalizes a title for fuzzy matching:
|
||||||
* Use this when merging results across sources — eliminates the same series
|
* - Lowercases and trims
|
||||||
* appearing multiple times in grids from different source variants.
|
* - Strips common subtitle suffixes: "(Official)", "(Web Comic)", etc.
|
||||||
|
* - Removes all non-alphanumeric characters (punctuation, dashes, colons)
|
||||||
|
* - Strips leading articles: "the ", "a ", "an "
|
||||||
|
* - Collapses whitespace
|
||||||
|
*
|
||||||
|
* "The Solo Leveling: Official Comic" → "solo leveling official comic"
|
||||||
|
* "Solo Leveling (Web Comic)" → "solo leveling web comic"
|
||||||
*/
|
*/
|
||||||
export function dedupeMangaByTitle<T extends { id: number; title: string }>(items: T[]): T[] {
|
export function normalizeTitle(title: string): string {
|
||||||
const seen = new Set<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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a short fingerprint from a description — first 120 chars, normalized.
|
||||||
|
* Used as a secondary dedup signal when titles differ but the series is the same.
|
||||||
|
* Returns null if the description is too short to be a reliable signal (< 40 chars).
|
||||||
|
*/
|
||||||
|
function descFingerprint(desc: string | null | undefined): string | null {
|
||||||
|
if (!desc) return null;
|
||||||
|
const norm = desc.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
if (norm.length < 40) return null;
|
||||||
|
return norm.slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates manga by normalized title OR description fingerprint, keeping the
|
||||||
|
* first occurrence. Runs in a single O(n) pass — no nested loops.
|
||||||
|
*
|
||||||
|
* Use this when merging results across sources. Same series from different source
|
||||||
|
* variants (e.g. MangaDex EN + Asura Scans) will be collapsed.
|
||||||
|
*
|
||||||
|
* The kept entry is the first one seen, so prefer passing library manga first so
|
||||||
|
* the richer/preferred entry survives.
|
||||||
|
*/
|
||||||
|
export function dedupeMangaByTitle<T extends { id: number; title: string; description?: string | null }>(items: T[]): T[] {
|
||||||
|
const seenTitles = new Set<string>();
|
||||||
|
const seenDescs = new Set<string>();
|
||||||
const out: T[] = [];
|
const out: T[] = [];
|
||||||
for (const m of items) {
|
for (const m of items) {
|
||||||
const key = m.title.toLowerCase().trim();
|
const tk = normalizeTitle(m.title);
|
||||||
if (!seen.has(key)) { seen.add(key); out.push(m); }
|
const dk = descFingerprint(m.description);
|
||||||
|
if (seenTitles.has(tk)) continue;
|
||||||
|
if (dk && seenDescs.has(dk)) continue;
|
||||||
|
seenTitles.add(tk);
|
||||||
|
if (dk) seenDescs.add(dk);
|
||||||
|
out.push(m);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -57,3 +101,39 @@ export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups items that share a normalized title or description fingerprint.
|
||||||
|
* Returns an array of groups — single-member groups are non-duplicates,
|
||||||
|
* multi-member groups are the same series from different sources.
|
||||||
|
*
|
||||||
|
* Used by MangaPreview to show alternate thumbnails for merged entries.
|
||||||
|
*/
|
||||||
|
export function groupDuplicates<T extends { id: number; title: string; description?: string | null }>(items: T[]): T[][] {
|
||||||
|
const titleMap = new Map<string, T[]>();
|
||||||
|
const descMap = new Map<string, T[]>();
|
||||||
|
|
||||||
|
for (const m of items) {
|
||||||
|
const tk = normalizeTitle(m.title);
|
||||||
|
const dk = descFingerprint(m.description);
|
||||||
|
|
||||||
|
const existingGroup = titleMap.get(tk) ?? (dk ? descMap.get(dk) : undefined);
|
||||||
|
if (existingGroup) {
|
||||||
|
existingGroup.push(m);
|
||||||
|
if (!titleMap.has(tk)) titleMap.set(tk, existingGroup);
|
||||||
|
if (dk && !descMap.has(dk)) descMap.set(dk, existingGroup);
|
||||||
|
} else {
|
||||||
|
const group = [m];
|
||||||
|
titleMap.set(tk, group);
|
||||||
|
if (dk) descMap.set(dk, group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return unique groups only
|
||||||
|
const seen = new Set<T[]>();
|
||||||
|
const out: T[][] = [];
|
||||||
|
for (const g of titleMap.values()) {
|
||||||
|
if (!seen.has(g)) { seen.add(g); out.push(g); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user