From 4df7f416a7dbe7a2a2847b934edc9bbfdce6aa8b Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Tue, 28 Apr 2026 23:22:29 -0500 Subject: [PATCH] Feat: Revamped Content-Filtering + Levels & Source-Based Toggle --- src/core/algorithms/filter.ts | 49 +--- src/core/util.ts | 147 +++++------ src/features/discover/lib/searchFilter.ts | 6 +- .../settings/sections/ContentSettings.svelte | 232 +++++++++++------- src/types/settings.ts | 13 +- 5 files changed, 216 insertions(+), 231 deletions(-) diff --git a/src/core/algorithms/filter.ts b/src/core/algorithms/filter.ts index 67fee53..f5ecd05 100644 --- a/src/core/algorithms/filter.ts +++ b/src/core/algorithms/filter.ts @@ -1,50 +1,5 @@ -import type { Manga, Source } from "@types"; -import type { Settings } from "@types"; -import { shouldHideSource } from "@core/util"; +export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util"; -// ── Source deduplication ────────────────────────────────────────────────────── - -/** - * Deduplicates sources by name, preferring `preferredLang` when multiple - * sources share a name. The local source (id "0") is always excluded. - * - * When `applyHide` is true, sources that fail the NSFW/block check are - * also removed — used in fan-out and cache-build paths where only - * user-visible sources should be queried. - */ -export function dedupeSourcesByLang( - sources: Source[], - preferredLang: string, - settings: Pick, - applyHide = false, -): Source[] { - const map = new Map(); - for (const s of sources) { - if (s.id === "0") continue; - if (applyHide && shouldHideSource(s, settings)) continue; - const existing = map.get(s.name); - if (!existing) { map.set(s.name, s); continue; } - const existingPref = existing.lang === preferredLang; - const newPref = s.lang === preferredLang; - if (newPref && !existingPref) map.set(s.name, s); - else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s); - } - return Array.from(map.values()); -} - -// ── Manga predicate filters ─────────────────────────────────────────────────── - -/** - * Generic predicate pipeline — composes multiple boolean predicates into one. - * All predicates must return true for an item to pass. - * - * Usage: - * const keep = buildFilter( - * m => !shouldHideNsfw(m, settings), - * m => m.inLibrary, - * ); - * const filtered = items.filter(keep); - */ export function buildFilter(...predicates: ((item: T) => boolean)[]): (item: T) => boolean { return (item) => predicates.every((p) => p(item)); -} +} \ No newline at end of file diff --git a/src/core/util.ts b/src/core/util.ts index acb9212..4cbec57 100644 --- a/src/core/util.ts +++ b/src/core/util.ts @@ -1,7 +1,5 @@ import type { Manga, Source } from "@types"; -import type { Settings } from "@types"; - -// ── Class utility ───────────────────────────────────────────────────────────── +import type { Settings } from "@types"; export { clsx as cn } from "clsx"; @@ -33,85 +31,98 @@ export function formatReadTime(m: number): string { return r === 0 ? `${h}h` : `${h}h ${r}m`; } -// ── NSFW filtering ──────────────────────────────────────────────────────────── +// ── Content 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", +const STRICT_TAGS: string[] = [ + "adult", "mature", "hentai", "ecchi", "erotic", "pornograph", + "18+", "smut", "lemon", "explicit", "sexual violence", + "gore", "guro", "graphic violence", "torture", "body horror", ]; -/** - * 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)) - ); +const MODERATE_TAGS: string[] = [ + "adult", "mature", "hentai", "ecchi", "erotic", "pornograph", + "18+", "smut", "lemon", "explicit", "sexual violence", +]; + +type ContentFilterSettings = Pick< + Settings, + "contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds" +>; + +function blockedTagsForSettings(settings: ContentFilterSettings): string[] { + if (settings.contentLevel === "strict") return STRICT_TAGS; + if (settings.contentLevel === "moderate") return MODERATE_TAGS; + return []; +} + +function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean { + if (!blockedTags.length) return false; + return genre.some(g => blockedTags.some(tag => g.toLowerCase().trim().includes(tag))); } /** - * 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)) + * Returns true when the manga should be hidden. + * Called by all views — library, search cache, discover. */ export function shouldHideNsfw( manga: Pick, - settings: Pick, + settings: ContentFilterSettings, ): boolean { + if (settings.contentLevel === "unrestricted") return false; + const srcId = manga.source?.id; + const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : []; + const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : []; - if (srcId && settings.nsfwBlockedSourceIds.includes(srcId)) return true; - if (settings.showNsfw) return false; + if (srcId && blocked.includes(srcId)) return true; - const sourceAllowed = !!(srcId && settings.nsfwAllowedSourceIds.includes(srcId)); - if (!sourceAllowed && manga.source?.isNsfw) return true; + const sourceAllowed = !!(srcId && allowed.includes(srcId)); + const blockedTags = blockedTagsForSettings(settings); - return isNsfwManga(manga, settings.nsfwFilteredTags); + if (!sourceAllowed && manga.source?.isNsfw && settings.contentLevel === "strict") return true; + return genreMatchesBlocklist(manga.genre ?? [], blockedTags); } /** - * Gate for Source objects — parallel to shouldHideNsfw for manga. - * Usage: sources.filter(s => !shouldHideSource(s, settings)) + * Returns true when the source should be hidden. + * Used in extension lists and source fan-out. */ export function shouldHideSource( source: Pick, - settings: Pick, + settings: ContentFilterSettings, ): boolean { - if (settings.nsfwBlockedSourceIds.includes(source.id)) return true; - if (settings.nsfwAllowedSourceIds.includes(source.id)) return false; - return !settings.showNsfw && source.isNsfw; + if (settings.contentLevel === "unrestricted") return false; + + if (settings.sourceOverridesEnabled) { + if ((settings.nsfwBlockedSourceIds ?? []).includes(source.id)) return true; + if ((settings.nsfwAllowedSourceIds ?? []).includes(source.id)) return settings.contentLevel === "strict"; + } + + return source.isNsfw && settings.contentLevel === "strict"; } // ── 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 dedupeSourcesByLang( + sources: Source[], + preferredLang: string, + settings: ContentFilterSettings, + applyHide = false, +): Source[] { + const map = new Map(); + for (const s of sources) { + if (s.id === "0") continue; + if (applyHide && shouldHideSource(s, settings)) continue; + const existing = map.get(s.name); + if (!existing) { map.set(s.name, s); continue; } + const existingPref = existing.lang === preferredLang; + const newPref = s.lang === preferredLang; + if (newPref && !existingPref) map.set(s.name, s); + else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s); + } + return Array.from(map.values()); +} + export function dedupeSources(sources: Source[], preferredLang: string): Source[] { const byName = new Map(); for (const src of sources) { @@ -129,7 +140,6 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[ // ── Manga deduplication ─────────────────────────────────────────────────────── -/** Strips punctuation, articles, and source suffixes for fuzzy title matching. */ export function normalizeTitle(title: string): string { return title .toLowerCase() @@ -140,39 +150,21 @@ export function normalizeTitle(title: string): string { .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[]): T[] { const seen = new Set(); const out: T[] = []; diff --git a/src/features/discover/lib/searchFilter.ts b/src/features/discover/lib/searchFilter.ts index 002e3c6..50164c2 100644 --- a/src/features/discover/lib/searchFilter.ts +++ b/src/features/discover/lib/searchFilter.ts @@ -49,7 +49,6 @@ export interface CachedManga { genreEnriched: boolean; } - export const COMMON_GENRES = [ "Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance", "Sci-Fi", "Slice of Life", "Horror", "Mystery", "Thriller", "Sports", @@ -66,7 +65,6 @@ export const MANGA_STATUSES: { value: string; label: string }[] = [ { value: "UNKNOWN", label: "Unknown" }, ]; - export function buildTagFilter( tags: string[], mode: TagMode, @@ -90,13 +88,12 @@ export function buildTagFilter( return { and: [genrePart, statusPart] }; } - export function filterSourceCache( sourceCache: Map, tags: string[], mode: TagMode, statuses: string[], - settings: Pick, + settings: Pick, ): CachedManga[] { return [...sourceCache.values()].filter((m) => { if (shouldHideNsfw(m as any, settings)) return false; @@ -118,7 +115,6 @@ export function filterSourceCache( }); } - export function toCachedManga( m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string }, srcId: string, diff --git a/src/features/settings/sections/ContentSettings.svelte b/src/features/settings/sections/ContentSettings.svelte index d8c931f..7ec7950 100644 --- a/src/features/settings/sections/ContentSettings.svelte +++ b/src/features/settings/sections/ContentSettings.svelte @@ -1,18 +1,17 @@
-

Content Filter

+

Content Level

- -
-
- -
-

- Blocked Genre Tags - -

-
-
- Manga matching any of these substrings are filtered. Case-insensitive, partial match. +
+ Controls what content is visible across library, search, and discover.
- {#if tagsRevealed} -
- {#each (store.settings.nsfwFilteredTags ?? []) as tag} - - - {tag} - - - {/each} -
- {/if} -
- { if (e.key === "Enter") addTag(); }} /> - - +
+ {#each LEVELS as lvl} + {@const active = store.settings.contentLevel === lvl.value} + + {/each}
@@ -132,39 +99,114 @@

Source Overrides

-
- Allow lets a source through even if flagged NSFW. Block always hides it. -
-
- -
- {#if contentSourcesLoading} -

Loading sources…

- {:else if contentSources.length === 0} -

No sources found — check your server connection.

- {:else} -
- {#each contentSourcesFiltered as group (group.name)} - {@const ids = group.sources.map(s => s.id)} - {@const allowed = store.settings.nsfwAllowedSourceIds ?? []} - {@const blocked = store.settings.nsfwBlockedSourceIds ?? []} - {@const isAllowed = ids.every(id => allowed.includes(id))} - {@const isBlocked = ids.every(id => blocked.includes(id))} -
- -
- {group.name} - {group.sources[0].isNsfw ? "NSFW · " : ""}{group.sources.length > 1 ? `${group.sources.length} languages` : group.sources[0].lang.toUpperCase()} -
-
- - -
-
- {/each} + + + {#if store.settings.sourceOverridesEnabled} +
+ +
+ {#if contentSourcesLoading} +

Loading sources…

+ {:else if contentSources.length === 0} +

No sources found — check your server connection.

+ {:else} +
+ {#each contentSourcesFiltered as group (group.name)} + {@const ids = group.sources.map(s => s.id)} + {@const allowed = store.settings.nsfwAllowedSourceIds ?? []} + {@const blocked = store.settings.nsfwBlockedSourceIds ?? []} + {@const isAllowed = ids.every(id => allowed.includes(id))} + {@const isBlocked = ids.every(id => blocked.includes(id))} +
+ +
+ {group.name} + + {group.sources[0].isNsfw ? "NSFW · " : ""}{group.sources.length > 1 ? `${group.sources.length} languages` : group.sources[0].lang.toUpperCase()} + +
+
+ + +
+
+ {/each} +
+ {/if} {/if}
-
\ No newline at end of file +
+ + \ No newline at end of file diff --git a/src/types/settings.ts b/src/types/settings.ts index 9a365be..ecb4cc1 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -6,6 +6,7 @@ export type LibraryFilter = "all" | "library" | "downloaded" | string; export type ReadingDirection = "ltr" | "rtl"; export type ChapterSortDir = "desc" | "asc"; export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate"; +export type ContentLevel = "strict" | "moderate" | "unrestricted"; export type LibrarySortMode = | "az" | "unreadCount" | "totalChapters" @@ -84,7 +85,9 @@ export interface Settings { offsetDoubleSpreads: boolean; preloadPages: number; autoMarkRead: boolean; autoNextChapter: boolean; libraryCropCovers: boolean; libraryPageSize: number; - showNsfw: boolean; discordRpc: boolean; + contentLevel: ContentLevel; sourceOverridesEnabled: boolean; + nsfwAllowedSourceIds: string[]; nsfwBlockedSourceIds: string[]; + discordRpc: boolean; chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number; uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean; serverUrl: string; serverBinary: string; autoStartServer: boolean; @@ -103,7 +106,6 @@ export interface Settings { appLockEnabled: boolean; appLockPin: string; customThemes: CustomTheme[]; hiddenCategoryIds: number[]; defaultLibraryCategoryId: number | null; savedIsDefaultCategory: boolean; - nsfwFilteredTags: string[]; nsfwAllowedSourceIds: string[]; nsfwBlockedSourceIds: string[]; libraryTabSort: Record; libraryTabStatus: Record; libraryTabFilters: Record>>; @@ -126,7 +128,10 @@ export const DEFAULT_SETTINGS: Settings = { pageStyle: "longstrip", readingDirection: "ltr", fitMode: "width", readerZoom: 1.0, pageGap: true, optimizeContrast: false, offsetDoubleSpreads: false, preloadPages: 3, autoMarkRead: true, autoNextChapter: true, - libraryCropCovers: true, libraryPageSize: 48, showNsfw: false, discordRpc: false, + libraryCropCovers: true, libraryPageSize: 48, + contentLevel: "strict", sourceOverridesEnabled: false, + nsfwAllowedSourceIds: [], nsfwBlockedSourceIds: [], + discordRpc: false, chapterSortDir: "desc", chapterSortMode: "source", chapterPageSize: 25, uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true, serverUrl: "http://localhost:4567", serverBinary: "", autoStartServer: true, @@ -144,8 +149,6 @@ export const DEFAULT_SETTINGS: Settings = { appLockEnabled: false, appLockPin: "", customThemes: [], hiddenCategoryIds: [], defaultLibraryCategoryId: null, savedIsDefaultCategory: false, - nsfwFilteredTags: ["adult", "mature", "hentai", "ecchi", "erotic", "pornograph", "18+", "smut", "lemon", "explicit", "sexual violence"], - nsfwAllowedSourceIds: [], nsfwBlockedSourceIds: [], libraryTabSort: {}, libraryTabStatus: {}, libraryTabFilters: {}, extraScanDirs: [], serverDownloadsPath: "", serverLocalSourcePath: "", qolAnimations: true,