From 7a765a7ea93f4457725b61839a710b4a3f311e03 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Thu, 29 Oct 2026 00:27:52 +0800 Subject: [PATCH] feat(metadata): support notable entities --- .agents/skills/repo-conventions/SKILL.md | 7 +- .../Storage/DatabaseOperator+Series.swift | 1 + KMReader/Core/Storage/DatabaseOperator.swift | 14 +- .../Features/Book/Models/BookSearch.swift | 15 + .../Book/Models/Metadata/BookMetadata.swift | 4 + .../Models/Metadata/BookMetadataUpdate.swift | 9 + .../Book/ViewModels/BookBrowseOptions.swift | 4 +- .../Book/Views/BookBrowseOptionsSheet.swift | 3 +- .../Book/Views/BookDetailContentView.swift | 5 + .../Features/Book/Views/BookEditSheet.swift | 10 + .../OneShot/OneShotDetailContentView.swift | 2 + .../Features/OneShot/OneshotEditSheet.swift | 8 +- .../Referential/ReferentialService.swift | 24 + .../Models/Metadata/SeriesBooksMetadata.swift | 2 + .../Features/Series/Models/SeriesSearch.swift | 15 + .../ViewModels/SeriesBrowseOptions.swift | 2 + .../Views/SeriesBrowseOptionsSheet.swift | 1 + .../Series/Views/SeriesDetailChipsView.swift | 1 + .../Models/SmartListFilterMapper.swift | 16 +- .../SmartList/Views/SmartListEditSheet.swift | 3 +- KMReader/Localizable.xcstrings | 512 ++++++++++++++++++ .../Models/MetadataFilterConfig.swift | 26 +- .../Foundation/Models/NavDestination.swift | 14 + .../Foundation/Models/NotableEntity.swift | 59 ++ .../Foundation/Models/PageResponse.swift | 15 + .../UI/MetadataFilterDisplayStyle.swift | 4 + .../Shared/UI/MetadataFilterItemSource.swift | 10 + .../Shared/UI/MetadataFilterSection.swift | 55 +- .../Shared/UI/NotableEntitiesEditor.swift | 84 +++ .../Shared/UI/NotableEntityChipFlows.swift | 61 +++ 30 files changed, 973 insertions(+), 13 deletions(-) create mode 100644 KMReader/Shared/Foundation/Models/NotableEntity.swift create mode 100644 KMReader/Shared/Foundation/Models/PageResponse.swift create mode 100644 KMReader/Shared/UI/NotableEntitiesEditor.swift create mode 100644 KMReader/Shared/UI/NotableEntityChipFlows.swift diff --git a/.agents/skills/repo-conventions/SKILL.md b/.agents/skills/repo-conventions/SKILL.md index 175dbd088..3dd6f6756 100644 --- a/.agents/skills/repo-conventions/SKILL.md +++ b/.agents/skills/repo-conventions/SKILL.md @@ -160,7 +160,7 @@ Subsystem conventions and invariants for KMReader. `AGENTS.md` holds repo-wide r - Smart lists (`/api/v1/smart-lists/*`) are kmrs-private saved searches evaluated live server-side, absent on Komga (the whole API 404s). Support is probed per instance: the first `SmartListsViewModel` load doubles as the probe, a 404 records `ServerSmartListCapability` in `AppConfig.serverSmartListCapability` for 24h (the same verdict pattern as komf/reading stats), and a marked-unsupported instance skips the doomed request. - Smart lists are DTO-driven and online-only: the lists themselves are NOT mirrored into GRDB (no tables, no offline branch), so the view model holds plain DTOs and re-fetches on `.smartListsDidChange`. Fetched members DO upsert into the books/series tables like every other fetched page (`SyncService.syncSmartListBooks`/`syncSmartListSeries`). Pin state has no mirror table to hang on, so it is client-local per-instance in UserDefaults (`PinnedSmartListIds` via `AppConfig.pinnedSmartListIds`, views read it through `@AppStorage("pinnedSmartListIds")`) — device-local and server-invisible like the GRDB-backed collection/read-list pins. Browse surfaces prepend pinned lists via `PinnedSmartListIds.pinnedFirst` (stable partition preserving the server's name order), and cards/rows carry the same pin badge as the other list types. -- Member loads POST a `BookSearch`/`SeriesSearch` overlay that narrows the stored filter server-side, built from the browse options via `BookBrowseOptions.toSearchFilters()`/`SeriesBrowseOptions.toSearchFilters()` + `buildCondition` (the same mapping the browse endpoints use; scoping ids stay with the caller). +- Member loads POST a `BookSearch`/`SeriesSearch` overlay that narrows the stored filter server-side, built from the browse options via `BookBrowseOptions.toSearchFilters()`/`SeriesBrowseOptions.toSearchFilters()` + `buildCondition` (the same mapping the browse endpoints use; scoping ids stay with the caller). `notableEntity` exact-pair filters preserve kind and name separately in `MetadataFilterConfig` and round-trip through `SmartListFilterMapper`, including all/any grouping; unknown or richer server-side predicates remain lossy and are not silently discarded on unrelated edits. - Remote smart-list SSE events debounce (5s) through `ListProjectionSyncService.scheduleSmartListSync`, which posts `.smartListsDidChange` per accumulated id — there is no projection to sync, but observers filter the post by smartListId, so ids accumulate like the other families. `SmartListThumbnailChanged` force-refreshes the cover through `ThumbnailCache.refreshThumbnail(id:type: .smartList)`. Remote read-progress SSE events debounce separately into `.smartListMembershipDidChange` (no id — any read-status-based list may move), which member lists revalidate on. - UI surfaces: a third strip on the Lists page (`ListsBrowseView`, hidden when unsupported/empty/offline — the library scope does not filter it), the full `browseSmartLists` page (`SmartListsBrowseView`, client-side name search, no sort/filter/library scope — the endpoint is name-sorted with no query params), and `SmartListDetailView` (browse + read + pin toggle, edit/delete for owner/admin; no download or selection mode). Create/edit goes through `SmartListEditSheet` (entry points: the browse page's trailing + and its empty state, the detail-page menu, card/row context menus): the target is fixed after creation, no sort is stored (chosen at browse time — the options sheets take `showsSort: false`, which also hides preset saving since presets write the shared browse-options keys), and visibility/share-target picking is admin-only (non-admins always create private lists). On update the sheet sends `search` only when the filter subsection diverges from its parsed baseline, and the reverse mapping's `lossy` flag shows a warning that any filter change replaces the whole stored document — web-UI-built filters the editor cannot represent survive a rename. Member lists revalidate on every book/series projection change and on `.smartListMembershipDidChange`, never gated on `isSensitiveToReadingProgress`: the stored server-side filter may be read-status-based even when the overlay options are not. @@ -184,6 +184,11 @@ Subsystem conventions and invariants for KMReader. `AGENTS.md` holds repo-wide r - Browse pages split shell from content: `BrowseContentView` is the scrollable content (optional library header, content-type menu, search placeholder, per-type lists) and owns no chrome; the content-type switcher is `BrowseContentTypeMenu`, a leading-aligned chip-style dropdown (current type icon + name + chevron, same chrome as `LayoutModeMenu`/`FilterChip`), never a segmented picker. `BrowseView` is the standalone-page shell around it (search field + `onSubmit` query state, toolbar, navigation title, refresh triggers), the Dashboard search overlay is `DashboardSearchResultsView`, which feeds the Dashboard's submitted query straight to `BrowseContentView`, filtered by the dashboard's session scope, and the Lists page's single-type detail pages use `ListsBrowseDetailView` (Collections/Read Lists/Smart Lists fixed content, scope menu except smart lists, layout/filter actions, inline navigation title) — split from `BrowseView` so that shell keeps only the tab-root and search modes plus metadata-filtered series/books pages. The overlay appears only once a query is submitted — there is no placeholder state on iPad/macOS; while typing or after clearing the field, the dashboard stays visible. Embedded content never gets its own search field or toolbar — they would duplicate the enclosing page's chrome. Every shell wires real filter-sheet state into `BrowseContentView` (the in-content filter bars drive those bindings; a `.constant` leaves the sort/preset chips dead), and the effective content type resolves through `BrowseContentType.effective(fixed:offered:persisted:)`. +### Metadata & Notable Entities + +- Book metadata owns `notableEntities` (`kind`, `name`) and its lock; series `booksMetadata` exposes the read-only aggregate, so only Book and Oneshot editors submit entities. The existing raw JSON blobs in `KomgaBook`/`KomgaSeries` persist both shapes, with no separate schema migration. `NotableEntitiesEditor` preserves arbitrary future kinds and trims/lowercases kinds while keeping names verbatim. +- Detail pages render grouped `NotableEntityChipFlows` (known kinds first, localized names) that navigate to exact-pair book/series browse filters. `MetadataFilterConfig` persists a stable kind+name key and logic, maps to `notableEntity` conditions in both search models, and round-trips through `SmartListFilterMapper`. The v3 referential endpoint has a flat `PageResponse`, not the ordinary Spring `Page`. Offline matching decodes raw metadata; series residual filtering must run whenever a notable filter is active. + ### Pagination & Ordering - Browse pages paginate with `PaginationState(pageSize: 50)`. Notification-driven refreshes revalidate the loaded window in place via `PaginationState.replaceItems`; full `pagination.reset()` is reserved for initial loads and explicit user actions. diff --git a/KMReader/Core/Storage/DatabaseOperator+Series.swift b/KMReader/Core/Storage/DatabaseOperator+Series.swift index 5c80affb0..caa3c9d8f 100644 --- a/KMReader/Core/Storage/DatabaseOperator+Series.swift +++ b/KMReader/Core/Storage/DatabaseOperator+Series.swift @@ -506,6 +506,7 @@ extension DatabaseOperator { browseOpts.completeFilter.isActive || !browseOpts.includeSeriesStatuses.isEmpty || !browseOpts.excludeSeriesStatuses.isEmpty + || browseOpts.metadataFilter.notableEntities != nil if !requiresResidualFilter { return try fetchBrowseSeriesSQLCandidates( diff --git a/KMReader/Core/Storage/DatabaseOperator.swift b/KMReader/Core/Storage/DatabaseOperator.swift index 3be9344eb..616005022 100644 --- a/KMReader/Core/Storage/DatabaseOperator.swift +++ b/KMReader/Core/Storage/DatabaseOperator.swift @@ -308,6 +308,13 @@ extension DatabaseOperator { return false } + if !(book.metadata?.notableEntities ?? []).matches( + filter.notableEntities, + logic: filter.notableEntitiesLogic + ) { + return false + } + return true } @@ -347,10 +354,9 @@ extension DatabaseOperator { return false } - if !MetadataIndex.matches( - index: series.metaLanguageIndex, - values: filter.languages, - logic: filter.languagesLogic + if !(series.booksMetadata?.notableEntities ?? []).matches( + filter.notableEntities, + logic: filter.notableEntitiesLogic ) { return false } diff --git a/KMReader/Features/Book/Models/BookSearch.swift b/KMReader/Features/Book/Models/BookSearch.swift index e4074284f..4fdaee936 100644 --- a/KMReader/Features/Book/Models/BookSearch.swift +++ b/KMReader/Features/Book/Models/BookSearch.swift @@ -53,6 +53,8 @@ nonisolated struct BookSearchFilters { var authorsLogic: FilterLogic = .all var tags: [String]? = nil var tagsLogic: FilterLogic = .all + var notableEntities: [NotableEntity]? = nil + var notableEntitiesLogic: FilterLogic = .all } // Helper functions to build conditions @@ -144,6 +146,19 @@ extension BookSearch { conditions.append([wrapperKey: tagConditions]) } + if let entities = filters.notableEntities, !entities.isEmpty { + let entityConditions = entities.map { entity in + [ + "notableEntity": [ + "operator": "is", + "value": ["kind": entity.kind, "name": entity.name], + ] + ] + } + let wrapperKey = filters.notableEntitiesLogic == .all ? "allOf" : "anyOf" + conditions.append([wrapperKey: entityConditions]) + } + if conditions.isEmpty { return nil } else if conditions.count == 1 { diff --git a/KMReader/Features/Book/Models/Metadata/BookMetadata.swift b/KMReader/Features/Book/Models/Metadata/BookMetadata.swift index d53e1d85f..447315767 100644 --- a/KMReader/Features/Book/Models/Metadata/BookMetadata.swift +++ b/KMReader/Features/Book/Models/Metadata/BookMetadata.swift @@ -21,6 +21,8 @@ nonisolated struct BookMetadata: Codable, Equatable, Hashable, Sendable { releaseDateLock: nil, authors: nil, authorsLock: nil, + notableEntities: nil, + notableEntitiesLock: nil, tags: nil, tagsLock: nil, isbn: nil, @@ -43,6 +45,8 @@ nonisolated struct BookMetadata: Codable, Equatable, Hashable, Sendable { let releaseDateLock: Bool? let authors: [Author]? let authorsLock: Bool? + let notableEntities: [NotableEntity]? + let notableEntitiesLock: Bool? let tags: [String]? let tagsLock: Bool? let isbn: String? diff --git a/KMReader/Features/Book/Models/Metadata/BookMetadataUpdate.swift b/KMReader/Features/Book/Models/Metadata/BookMetadataUpdate.swift index 459f4869e..5ba26b49f 100644 --- a/KMReader/Features/Book/Models/Metadata/BookMetadataUpdate.swift +++ b/KMReader/Features/Book/Models/Metadata/BookMetadataUpdate.swift @@ -21,6 +21,8 @@ struct BookMetadataUpdate: Codable { var isbnLock: Bool var authors: [Author] var authorsLock: Bool + var notableEntities: [NotableEntity] + var notableEntitiesLock: Bool var tags: [String] var tagsLock: Bool var links: [WebLink] @@ -52,6 +54,8 @@ struct BookMetadataUpdate: Codable { isbnLock: book.metadata.isbnLock ?? false, authors: book.metadata.authors ?? [], authorsLock: book.metadata.authorsLock ?? false, + notableEntities: book.metadata.notableEntities ?? [], + notableEntitiesLock: book.metadata.notableEntitiesLock ?? false, tags: book.metadata.tags ?? [], tagsLock: book.metadata.tagsLock ?? false, links: book.metadata.links ?? [], @@ -102,6 +106,11 @@ struct BookMetadataUpdate: Codable { } dict["authorsLock"] = authorsLock + if notableEntities != (original.metadata.notableEntities ?? []) { + dict["notableEntities"] = notableEntities.map { ["kind": $0.kind, "name": $0.name] } + } + dict["notableEntitiesLock"] = notableEntitiesLock + if tags != (original.metadata.tags ?? []) { dict["tags"] = tags } diff --git a/KMReader/Features/Book/ViewModels/BookBrowseOptions.swift b/KMReader/Features/Book/ViewModels/BookBrowseOptions.swift index e5524eedd..539931cc2 100644 --- a/KMReader/Features/Book/ViewModels/BookBrowseOptions.swift +++ b/KMReader/Features/Book/ViewModels/BookBrowseOptions.swift @@ -120,7 +120,9 @@ extension BookBrowseOptions { authors: metadataFilter.authors, authorsLogic: metadataFilter.authorsLogic, tags: metadataFilter.tags, - tagsLogic: metadataFilter.tagsLogic + tagsLogic: metadataFilter.tagsLogic, + notableEntities: metadataFilter.notableEntities, + notableEntitiesLogic: metadataFilter.notableEntitiesLogic ) } } diff --git a/KMReader/Features/Book/Views/BookBrowseOptionsSheet.swift b/KMReader/Features/Book/Views/BookBrowseOptionsSheet.swift index 65882f6d7..51c7dc3e5 100644 --- a/KMReader/Features/Book/Views/BookBrowseOptionsSheet.swift +++ b/KMReader/Features/Book/Views/BookBrowseOptionsSheet.swift @@ -126,7 +126,8 @@ struct BookBrowseOptionsSheet: View { libraryIds: libraryIds, seriesId: seriesId, showAuthors: true, - showTags: true + showTags: true, + showNotableEntities: true ) } diff --git a/KMReader/Features/Book/Views/BookDetailContentView.swift b/KMReader/Features/Book/Views/BookDetailContentView.swift index 7fb7190e5..dd94a239c 100644 --- a/KMReader/Features/Book/Views/BookDetailContentView.swift +++ b/KMReader/Features/Book/Views/BookDetailContentView.swift @@ -183,6 +183,11 @@ struct BookDetailContentView: View { ) } + NotableEntityChipFlows( + entities: book.metadata.notableEntities ?? [], + target: .books + ) + DetailChipFlow(items: tagItems, collapsedLimit: collapsedLinkLimit, glass: false) DetailChipFlow(items: linkItems, collapsedLimit: collapsedLinkLimit) diff --git a/KMReader/Features/Book/Views/BookEditSheet.swift b/KMReader/Features/Book/Views/BookEditSheet.swift index f70073f07..cefba1b98 100644 --- a/KMReader/Features/Book/Views/BookEditSheet.swift +++ b/KMReader/Features/Book/Views/BookEditSheet.swift @@ -152,6 +152,16 @@ struct BookEditSheet: View { } private var authorsTab: some View { + Group { + authorsSection + NotableEntitiesEditor( + entities: $bookMetadataUpdate.notableEntities, + isLocked: $bookMetadataUpdate.notableEntitiesLock + ) + } + } + + private var authorsSection: some View { Section { ForEach(bookMetadataUpdate.authors.indices, id: \.self) { index in HStack { diff --git a/KMReader/Features/OneShot/OneShotDetailContentView.swift b/KMReader/Features/OneShot/OneShotDetailContentView.swift index eaa73122f..677933c44 100644 --- a/KMReader/Features/OneShot/OneShotDetailContentView.swift +++ b/KMReader/Features/OneShot/OneShotDetailContentView.swift @@ -184,6 +184,8 @@ struct OneShotDetailContentView: View { ) } + NotableEntityChipFlows(entities: book.metadata.notableEntities ?? [], target: .books) + DetailChipFlow(items: genreItems, collapsedLimit: collapsedLinkLimit, glass: false) DetailChipFlow(items: tagItems, collapsedLimit: collapsedLinkLimit, glass: false) diff --git a/KMReader/Features/OneShot/OneshotEditSheet.swift b/KMReader/Features/OneShot/OneshotEditSheet.swift index 438087a67..81604d787 100644 --- a/KMReader/Features/OneShot/OneshotEditSheet.swift +++ b/KMReader/Features/OneShot/OneshotEditSheet.swift @@ -85,7 +85,13 @@ struct OneshotEditSheet: View { } private var authorsTab: some View { - authorsSection + Group { + authorsSection + NotableEntitiesEditor( + entities: $bookMetadataUpdate.notableEntities, + isLocked: $bookMetadataUpdate.notableEntitiesLock + ) + } } private var tagsTab: some View { diff --git a/KMReader/Features/Referential/ReferentialService.swift b/KMReader/Features/Referential/ReferentialService.swift index a4df916d9..b2cdb47d3 100644 --- a/KMReader/Features/Referential/ReferentialService.swift +++ b/KMReader/Features/Referential/ReferentialService.swift @@ -56,6 +56,30 @@ nonisolated enum ReferentialService { } } + static func getNotableEntities( + libraryIds: [String]? = nil, + collectionId: String? = nil, + seriesId: String? = nil, + readListId: String? = nil + ) async throws -> [NotableEntity] { + var queryItems = [URLQueryItem(name: "unpaged", value: "true")] + for id in libraryIds ?? [] where !id.isEmpty { + queryItems.append(URLQueryItem(name: "library_id", value: id)) + } + if let collectionId { + queryItems.append(URLQueryItem(name: "collection_id", value: collectionId)) + } + if let seriesId { + queryItems.append(URLQueryItem(name: "series_id", value: seriesId)) + } + if let readListId { + queryItems.append(URLQueryItem(name: "readlist_id", value: readListId)) + } + let page: PageResponse = try await apiClient.request( + path: "/api/v3/notable-entities", queryItems: queryItems) + return page.content + } + static func getPublishers(libraryIds: [String]? = nil, collectionId: String? = nil) async throws -> [String] { diff --git a/KMReader/Features/Series/Models/Metadata/SeriesBooksMetadata.swift b/KMReader/Features/Series/Models/Metadata/SeriesBooksMetadata.swift index 9c34550f7..595735573 100644 --- a/KMReader/Features/Series/Models/Metadata/SeriesBooksMetadata.swift +++ b/KMReader/Features/Series/Models/Metadata/SeriesBooksMetadata.swift @@ -10,6 +10,7 @@ nonisolated struct SeriesBooksMetadata: Codable, Equatable, Hashable, Sendable { created: nil, lastModified: nil, authors: nil, + notableEntities: nil, tags: nil, releaseDate: nil, summary: nil, @@ -19,6 +20,7 @@ nonisolated struct SeriesBooksMetadata: Codable, Equatable, Hashable, Sendable { let created: String? let lastModified: String? let authors: [Author]? + let notableEntities: [NotableEntity]? let tags: [String]? let releaseDate: String? let summary: String? diff --git a/KMReader/Features/Series/Models/SeriesSearch.swift b/KMReader/Features/Series/Models/SeriesSearch.swift index dc3390c83..c8a70234e 100644 --- a/KMReader/Features/Series/Models/SeriesSearch.swift +++ b/KMReader/Features/Series/Models/SeriesSearch.swift @@ -60,6 +60,8 @@ nonisolated struct SeriesSearchFilters { var genresLogic: FilterLogic = .all var tags: [String]? = nil var tagsLogic: FilterLogic = .all + var notableEntities: [NotableEntity]? = nil + var notableEntitiesLogic: FilterLogic = .all var languages: [String]? = nil var languagesLogic: FilterLogic = .all var ageRatings: [Int]? = nil @@ -182,6 +184,19 @@ extension SeriesSearch { conditions.append([wrapperKey: tagConditions]) } + if let entities = filters.notableEntities, !entities.isEmpty { + let entityConditions = entities.map { entity in + [ + "notableEntity": [ + "operator": "is", + "value": ["kind": entity.kind, "name": entity.name], + ] + ] + } + let wrapperKey = filters.notableEntitiesLogic == .all ? "allOf" : "anyOf" + conditions.append([wrapperKey: entityConditions]) + } + if let languages = filters.languages, !languages.isEmpty { let languageConditions = languages.map { language in ["language": ["operator": "is", "value": language]] diff --git a/KMReader/Features/Series/ViewModels/SeriesBrowseOptions.swift b/KMReader/Features/Series/ViewModels/SeriesBrowseOptions.swift index 131f3b4e2..741859b1e 100644 --- a/KMReader/Features/Series/ViewModels/SeriesBrowseOptions.swift +++ b/KMReader/Features/Series/ViewModels/SeriesBrowseOptions.swift @@ -159,6 +159,8 @@ extension SeriesBrowseOptions { genresLogic: metadataFilter.genresLogic, tags: metadataFilter.tags, tagsLogic: metadataFilter.tagsLogic, + notableEntities: metadataFilter.notableEntities, + notableEntitiesLogic: metadataFilter.notableEntitiesLogic, languages: metadataFilter.languages, languagesLogic: metadataFilter.languagesLogic, ageRatings: metadataFilter.ageRatings?.compactMap { Int($0) }, diff --git a/KMReader/Features/Series/Views/SeriesBrowseOptionsSheet.swift b/KMReader/Features/Series/Views/SeriesBrowseOptionsSheet.swift index c45a5f427..0087065d3 100644 --- a/KMReader/Features/Series/Views/SeriesBrowseOptionsSheet.swift +++ b/KMReader/Features/Series/Views/SeriesBrowseOptionsSheet.swift @@ -158,6 +158,7 @@ struct SeriesBrowseOptionsSheet: View { showAuthors: true, showGenres: true, showTags: true, + showNotableEntities: true, showLanguages: true, showAgeRatings: true, showReleaseYears: true diff --git a/KMReader/Features/Series/Views/SeriesDetailChipsView.swift b/KMReader/Features/Series/Views/SeriesDetailChipsView.swift index d92972449..ff16ef3de 100644 --- a/KMReader/Features/Series/Views/SeriesDetailChipsView.swift +++ b/KMReader/Features/Series/Views/SeriesDetailChipsView.swift @@ -12,6 +12,7 @@ struct SeriesDetailChipsView: View { private let collapsedLinkLimit = 6 var body: some View { + NotableEntityChipFlows(entities: series.booksMetadata.notableEntities ?? [], target: .series) DetailChipFlow(items: genreItems, collapsedLimit: collapsedLinkLimit, glass: false) DetailChipFlow(items: tagItems, collapsedLimit: collapsedLinkLimit, glass: false) DetailChipFlow(items: linkItems, collapsedLimit: collapsedLinkLimit) diff --git a/KMReader/Features/SmartList/Models/SmartListFilterMapper.swift b/KMReader/Features/SmartList/Models/SmartListFilterMapper.swift index 41284be47..e1426c8db 100644 --- a/KMReader/Features/SmartList/Models/SmartListFilterMapper.swift +++ b/KMReader/Features/SmartList/Models/SmartListFilterMapper.swift @@ -261,7 +261,7 @@ private nonisolated struct SearchDocumentParser { excludeSeriesStatuses.formUnion(statuses) } seriesStatusLogic = logic - case "author", "tag", "genre", "publisher", "language", "ageRating": + case "author", "tag", "genre", "publisher", "language", "ageRating", "notableEntity": parseMetadataLeafGroup(key: key, bodies: bodies, logic: logic) default: lossy = true @@ -307,7 +307,7 @@ private nonisolated struct SearchDocumentParser { seriesStatusLogic = .all case "oneShot", "deleted", "complete": parseFlagLeaf(key: key, body: body) - case "author", "tag", "genre", "publisher", "language", "ageRating": + case "author", "tag", "genre", "publisher", "language", "ageRating", "notableEntity": parseMetadataLeafGroup(key: key, bodies: [body], logic: .all) default: // Scoping ids (seriesId, readListId, collectionId), releaseDate outside a @@ -369,6 +369,12 @@ private nonisolated struct SearchDocumentParser { case "language": metadata.languages = (metadata.languages ?? []) + values metadata.languagesLogic = logic + case "notableEntity": + metadata.notableEntities = + (metadata.notableEntities ?? []) + + values.compactMap( + NotableEntity.fromFilterKey) + metadata.notableEntitiesLogic = logic default: metadata.ageRatings = (metadata.ageRatings ?? []) + values metadata.ageRatingsLogic = logic @@ -388,6 +394,12 @@ private nonisolated struct SearchDocumentParser { case "genre", "publisher", "language": guard target == .series else { return nil } return stringValue(body["value"]) + case "notableEntity": + guard case .dictionary(let value) = body["value"], + case .string(let kind) = value["kind"], + case .string(let name) = value["name"] + else { return nil } + return NotableEntity(kind: kind, name: name).filterKey case "ageRating": guard target == .series else { return nil } switch body["value"] { diff --git a/KMReader/Features/SmartList/Views/SmartListEditSheet.swift b/KMReader/Features/SmartList/Views/SmartListEditSheet.swift index e6e0a7aef..ca692f3bb 100644 --- a/KMReader/Features/SmartList/Views/SmartListEditSheet.swift +++ b/KMReader/Features/SmartList/Views/SmartListEditSheet.swift @@ -318,13 +318,14 @@ struct SmartListEditSheet: View { private func metadataValueCount(_ config: MetadataFilterConfig, seriesKinds: Bool) -> Int { var values: [[String]?] = [config.authors, config.tags] + let notableCount = config.notableEntities?.count ?? 0 if seriesKinds { values += [ config.publishers, config.genres, config.languages, config.ageRatings, config.releaseYears, ] } - return values.reduce(0) { $0 + ($1?.count ?? 0) } + return values.reduce(0) { $0 + ($1?.count ?? 0) } + notableCount } private func toggleShareTarget(_ id: String) { diff --git a/KMReader/Localizable.xcstrings b/KMReader/Localizable.xcstrings index 2b135d114..2ca4ea08e 100644 --- a/KMReader/Localizable.xcstrings +++ b/KMReader/Localizable.xcstrings @@ -3341,6 +3341,70 @@ } } }, + "Add Notable Entity" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bezeichnung hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Notable Entity" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir entidad destacada" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter une entité notable" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi entità notevole" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "注目項目を追加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주요 항목 추가" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Добавить значимый элемент" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加重要实体" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "新增重要實體" + } + } + } + }, "Add or connect to a Komga server to get started." : { "localizations" : { "de" : { @@ -12173,6 +12237,70 @@ } } }, + "Characters" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Charaktere" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Characters" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personajes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personnages" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personaggi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "キャラクター" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "캐릭터" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Персонажи" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "角色" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "角色" + } + } + } + }, "Check Server Connection" : { "extractionState" : "stale", "localizations" : { @@ -17166,6 +17294,70 @@ } } }, + "Custom Kind" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eigene Art" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom Kind" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo personalizado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type personnalisé" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo personalizzato" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "カスタム種類" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 종류" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пользовательский тип" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义类型" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "自訂類型" + } + } + } + }, "Custom Role" : { "localizations" : { "de" : { @@ -38364,6 +38556,70 @@ } } }, + "Kind" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Art" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kind" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "種類" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "종류" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тип" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "类型" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "類型" + } + } + } + }, "KMReader" : { "localizations" : { "de" : { @@ -45148,6 +45404,70 @@ } } }, + "Locations" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Orte" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Locations" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lugares" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lieux" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Luoghi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "場所" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "장소" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Локации" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "地点" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "地點" + } + } + } + }, "Logged in successfully" : { "localizations" : { "de" : { @@ -51356,6 +51676,70 @@ } } }, + "No notable entities available" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Bezeichnungen verfügbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No notable entities available" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay entidades destacadas disponibles" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune entité notable disponible" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessuna entità notevole disponibile" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "注目項目がありません" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용 가능한 주요 항목 없음" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет доступных значимых элементов" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有可用的重要实体" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "沒有可用的重要實體" + } + } + } + }, "No Pages Available" : { "localizations" : { "de" : { @@ -52828,6 +53212,70 @@ } } }, + "Notable Entities" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bezeichnungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notable Entities" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entidades destacadas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entités notables" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entità notevoli" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "注目項目" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "주요 항목" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Значимые элементы" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重要实体" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重要實體" + } + } + } + }, "Notice" : { "localizations" : { "de" : { @@ -89697,6 +90145,70 @@ } } }, + "Teams" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teams" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teams" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Equipos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Équipes" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Squadre" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "チーム" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "팀" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Команды" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "团队" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "團隊" + } + } + } + }, "Terms of Use" : { "localizations" : { "de" : { diff --git a/KMReader/Shared/Foundation/Models/MetadataFilterConfig.swift b/KMReader/Shared/Foundation/Models/MetadataFilterConfig.swift index ce85db62f..c59361a84 100644 --- a/KMReader/Shared/Foundation/Models/MetadataFilterConfig.swift +++ b/KMReader/Shared/Foundation/Models/MetadataFilterConfig.swift @@ -17,6 +17,8 @@ nonisolated struct MetadataFilterConfig: Equatable, RawRepresentable, Sendable { var genresLogic: FilterLogic = .all var tags: [String]? var tagsLogic: FilterLogic = .all + var notableEntities: [NotableEntity]? + var notableEntitiesLogic: FilterLogic = .all var languages: [String]? var languagesLogic: FilterLogic = .all var ageRatings: [String]? @@ -33,6 +35,8 @@ nonisolated struct MetadataFilterConfig: Equatable, RawRepresentable, Sendable { genresLogic: FilterLogic = .all, tags: [String]? = nil, tagsLogic: FilterLogic = .all, + notableEntities: [NotableEntity]? = nil, + notableEntitiesLogic: FilterLogic = .all, languages: [String]? = nil, languagesLogic: FilterLogic = .all, ageRatings: [String]? = nil, @@ -48,6 +52,8 @@ nonisolated struct MetadataFilterConfig: Equatable, RawRepresentable, Sendable { self.genresLogic = genresLogic self.tags = tags self.tagsLogic = tagsLogic + self.notableEntities = notableEntities + self.notableEntitiesLogic = notableEntitiesLogic self.languages = languages self.languagesLogic = languagesLogic self.ageRatings = ageRatings @@ -58,7 +64,8 @@ nonisolated struct MetadataFilterConfig: Equatable, RawRepresentable, Sendable { /// Check if any filter is active var hasAnyFilter: Bool { - return publishers != nil || authors != nil || genres != nil || tags != nil || languages != nil + return publishers != nil || authors != nil || genres != nil || tags != nil + || notableEntities != nil || languages != nil || ageRatings != nil || releaseYears != nil } @@ -80,6 +87,10 @@ nonisolated struct MetadataFilterConfig: Equatable, RawRepresentable, Sendable { dict["tags"] = tags } dict["tagsLogic"] = tagsLogic.rawValue + if let notableEntities { + dict["notableEntities"] = notableEntities.map(\.filterKey) + } + dict["notableEntitiesLogic"] = notableEntitiesLogic.rawValue if let languages = languages { dict["languages"] = languages } @@ -141,6 +152,15 @@ nonisolated struct MetadataFilterConfig: Equatable, RawRepresentable, Sendable { } else { self.tagsLogic = .all } + self.notableEntities = (dict["notableEntities"] as? [String])?.compactMap( + NotableEntity.fromFilterKey) + if let raw = dict["notableEntitiesLogic"] as? String, + let logic = FilterLogic(rawValue: raw) + { + self.notableEntitiesLogic = logic + } else { + self.notableEntitiesLogic = .all + } self.languages = dict["languages"] as? [String] if let languagesLogicRaw = dict["languagesLogic"] as? String, let logic = FilterLogic(rawValue: languagesLogicRaw) @@ -207,6 +227,10 @@ nonisolated struct MetadataFilterConfig: Equatable, RawRepresentable, Sendable { return MetadataFilterConfig(tags: tags) } + static func forNotableEntity(_ entity: NotableEntity) -> MetadataFilterConfig { + MetadataFilterConfig(notableEntities: [entity]) + } + /// Create config for age rating filter static func forAgeRating(_ ageRating: String) -> MetadataFilterConfig { return MetadataFilterConfig(ageRatings: [ageRating]) diff --git a/KMReader/Shared/Foundation/Models/NavDestination.swift b/KMReader/Shared/Foundation/Models/NavDestination.swift index 7f9488026..88b281e02 100644 --- a/KMReader/Shared/Foundation/Models/NavDestination.swift +++ b/KMReader/Shared/Foundation/Models/NavDestination.swift @@ -32,6 +32,8 @@ enum NavDestination: Hashable { case browseSeriesWithTag(tag: String) case browseBooksWithAuthor(author: String) case browseBooksWithTag(tag: String) + case browseSeriesWithNotableEntity(entity: NotableEntity) + case browseBooksWithNotableEntity(entity: NotableEntity) case seriesDetail(seriesId: String) case bookDetail(bookId: String) @@ -201,6 +203,18 @@ enum NavDestination: Hashable { fixedContent: .books, metadataFilter: MetadataFilterConfig.forTags([tag]) ) + case .browseSeriesWithNotableEntity(let entity): + BrowseView( + authViewModel: context.authViewModel, + fixedContent: .series, + metadataFilter: MetadataFilterConfig.forNotableEntity(entity) + ) + case .browseBooksWithNotableEntity(let entity): + BrowseView( + authViewModel: context.authViewModel, + fixedContent: .books, + metadataFilter: MetadataFilterConfig.forNotableEntity(entity) + ) case .seriesDetail(let seriesId): SeriesDetailView(seriesId: seriesId) diff --git a/KMReader/Shared/Foundation/Models/NotableEntity.swift b/KMReader/Shared/Foundation/Models/NotableEntity.swift new file mode 100644 index 000000000..b1321f646 --- /dev/null +++ b/KMReader/Shared/Foundation/Models/NotableEntity.swift @@ -0,0 +1,59 @@ +// +// NotableEntity.swift +// + +import Foundation + +nonisolated struct NotableEntity: Codable, Hashable, Sendable { + let kind: String + let name: String + + init(kind: String, name: String) { + self.kind = kind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + self.name = name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var kindDisplayName: String { + switch kind { + case "character": + return String(localized: "Characters") + case "team": + return String(localized: "Teams") + case "location": + return String(localized: "Locations") + default: + return kind.capitalized + } + } + + var filterKey: String { + "\(kind)\u{1F}\(name)" + } + + static func fromFilterKey(_ key: String) -> NotableEntity? { + let components = key.split(separator: "\u{1F}", maxSplits: 1, omittingEmptySubsequences: false) + guard components.count == 2 else { return nil } + return NotableEntity(kind: String(components[0]), name: String(components[1])) + } + + static var knownKinds: [String] { + ["character", "team", "location"] + } + + static func kindDisplayName(_ kind: String) -> String { + NotableEntity(kind: kind, name: "").kindDisplayName + } +} + +nonisolated extension Collection where Element == NotableEntity { + func matches(_ entities: [NotableEntity]?, logic: FilterLogic) -> Bool { + guard let entities, !entities.isEmpty else { return true } + let candidates = Set((self).map(\.filterKey)) + switch logic { + case .all: + return entities.allSatisfy { candidates.contains($0.filterKey) } + case .any: + return entities.contains { candidates.contains($0.filterKey) } + } + } +} diff --git a/KMReader/Shared/Foundation/Models/PageResponse.swift b/KMReader/Shared/Foundation/Models/PageResponse.swift new file mode 100644 index 000000000..82f04d9bf --- /dev/null +++ b/KMReader/Shared/Foundation/Models/PageResponse.swift @@ -0,0 +1,15 @@ +// +// PageResponse.swift +// + +import Foundation + +nonisolated struct PageResponse: Decodable, Sendable { + let content: [T] + let page: Int + let size: Int + let totalElements: Int + let totalPages: Int + let first: Bool + let last: Bool +} diff --git a/KMReader/Shared/UI/MetadataFilterDisplayStyle.swift b/KMReader/Shared/UI/MetadataFilterDisplayStyle.swift index 4afd954df..78f34b694 100644 --- a/KMReader/Shared/UI/MetadataFilterDisplayStyle.swift +++ b/KMReader/Shared/UI/MetadataFilterDisplayStyle.swift @@ -9,6 +9,7 @@ enum MetadataFilterDisplayStyle: Sendable { case plain case language case ageRating + case notableEntity func displayName(for value: String) -> String { switch self { @@ -18,6 +19,9 @@ enum MetadataFilterDisplayStyle: Sendable { return LanguageCodeHelper.displayName(for: value) case .ageRating: return "\(value)+" + case .notableEntity: + guard let entity = NotableEntity.fromFilterKey(value) else { return value } + return "\(entity.kindDisplayName) · \(entity.name)" } } } diff --git a/KMReader/Shared/UI/MetadataFilterItemSource.swift b/KMReader/Shared/UI/MetadataFilterItemSource.swift index 6cd772fcb..61926e79a 100644 --- a/KMReader/Shared/UI/MetadataFilterItemSource.swift +++ b/KMReader/Shared/UI/MetadataFilterItemSource.swift @@ -13,6 +13,8 @@ enum MetadataFilterItemSource: Sendable { case languages(libraryIds: [String]?, collectionId: String?) case ageRatings(libraryIds: [String]?, collectionId: String?) case releaseYears(libraryIds: [String]?, collectionId: String?) + case notableEntities( + libraryIds: [String]?, collectionId: String?, seriesId: String?, readListId: String?) func load() async throws -> [String] { switch self { @@ -52,6 +54,14 @@ enum MetadataFilterItemSource: Sendable { libraryIds: libraryIds, collectionId: collectionId ) + case .notableEntities(let libraryIds, let collectionId, let seriesId, let readListId): + let entities = try await ReferentialService.getNotableEntities( + libraryIds: libraryIds, + collectionId: collectionId, + seriesId: seriesId, + readListId: readListId + ) + return entities.map(\.filterKey) case .releaseYears(let libraryIds, let collectionId): return try await ReferentialService.getReleaseYears( libraryIds: libraryIds, diff --git a/KMReader/Shared/UI/MetadataFilterSection.swift b/KMReader/Shared/UI/MetadataFilterSection.swift index 3ded9714b..b72bdef85 100644 --- a/KMReader/Shared/UI/MetadataFilterSection.swift +++ b/KMReader/Shared/UI/MetadataFilterSection.swift @@ -15,6 +15,7 @@ struct MetadataFilterSection: View { let showAuthors: Bool let showGenres: Bool let showTags: Bool + let showNotableEntities: Bool let showLanguages: Bool let showAgeRatings: Bool let showReleaseYears: Bool @@ -23,6 +24,7 @@ struct MetadataFilterSection: View { @State private var authors: [String]? @State private var genres: [String]? @State private var tags: [String]? + @State private var notableEntities: [String]? @State private var languages: [String]? @State private var ageRatings: [String]? @State private var releaseYears: [String]? @@ -37,6 +39,7 @@ struct MetadataFilterSection: View { showAuthors: Bool = false, showGenres: Bool = false, showTags: Bool = false, + showNotableEntities: Bool = false, showLanguages: Bool = false, showAgeRatings: Bool = false, showReleaseYears: Bool = false @@ -50,13 +53,16 @@ struct MetadataFilterSection: View { self.showAuthors = showAuthors self.showGenres = showGenres self.showTags = showTags + self.showNotableEntities = showNotableEntities self.showLanguages = showLanguages self.showAgeRatings = showAgeRatings self.showReleaseYears = showReleaseYears } var body: some View { - if showPublisher || showAuthors || showGenres || showTags || showLanguages || showAgeRatings || showReleaseYears { + if showPublisher || showAuthors || showGenres || showTags || showNotableEntities || showLanguages || showAgeRatings + || showReleaseYears + { Section(String(localized: "Metadata")) { if showPublisher { publisherPicker @@ -74,6 +80,10 @@ struct MetadataFilterSection: View { tagsSection } + if showNotableEntities { + notableEntitiesSection + } + if showLanguages { languagesSection } @@ -211,6 +221,49 @@ struct MetadataFilterSection: View { } } + @ViewBuilder + private var notableEntitiesSection: some View { + NavigationLink { + MetadataMultiSelectLoader( + title: String(localized: "Notable Entities"), + cachedItems: $notableEntities, + source: .notableEntities( + libraryIds: libraryIds, + collectionId: collectionId, + seriesId: seriesId, + readListId: readListId + ), + selectedItems: Binding( + get: { Set((metadataFilter.notableEntities ?? []).map(\.filterKey)) }, + set: { selected in + metadataFilter.notableEntities = + selected.isEmpty + ? nil + : selected.sorted().compactMap(NotableEntity.fromFilterKey) + } + ), + logic: $metadataFilter.notableEntitiesLogic, + emptyDescription: String(localized: "No notable entities available"), + displayStyle: .notableEntity + ) + } label: { + HStack { + Text(String(localized: "Notable Entities")) + Spacer() + if let entities = metadataFilter.notableEntities, !entities.isEmpty { + let logicSymbol = metadataFilter.notableEntitiesLogic == .all ? "∧" : "∨" + Text(entities.map(notableDisplayName).joined(separator: " \(logicSymbol) ")) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + } + + private func notableDisplayName(_ entity: NotableEntity) -> String { + MetadataFilterDisplayStyle.notableEntity.displayName(for: entity.filterKey) + } + @ViewBuilder private var languagesSection: some View { NavigationLink { diff --git a/KMReader/Shared/UI/NotableEntitiesEditor.swift b/KMReader/Shared/UI/NotableEntitiesEditor.swift new file mode 100644 index 000000000..a8b619119 --- /dev/null +++ b/KMReader/Shared/UI/NotableEntitiesEditor.swift @@ -0,0 +1,84 @@ +// +// NotableEntitiesEditor.swift +// + +import SwiftUI + +struct NotableEntitiesEditor: View { + @Binding var entities: [NotableEntity] + @Binding var isLocked: Bool + + @State private var newKind = "character" + @State private var customKind = "" + @State private var newName = "" + + var body: some View { + Section { + ForEach(entities.indices, id: \.self) { index in + HStack { + VStack(alignment: .leading) { + Text(entities[index].name) + Text(entities[index].kindDisplayName) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Button(role: .destructive) { + let indexToRemove = index + withAnimation { + entities.remove(at: indexToRemove) + isLocked = true + } + } label: { + Image(systemName: AppIcon.delete) + .padding(8) + .contentShape(Rectangle()) + } + .accessibilityLabel(Text("Delete")) + } + } + Picker(String(localized: "Kind"), selection: $newKind) { + ForEach(NotableEntity.knownKinds, id: \.self) { kind in + Text(NotableEntity.kindDisplayName(kind)).tag(kind) + } + Text(String(localized: "Custom")).tag("custom") + } + if newKind == "custom" { + TextField(String(localized: "Custom Kind"), text: $customKind) + #if os(iOS) || os(tvOS) + .autocapitalization(.none) + #endif + } + TextField(String(localized: "Name"), text: $newName) + .onSubmit(addEntity) + Button(action: addEntity) { + Label(String(localized: "Add Notable Entity"), systemImage: AppIcon.addRow) + .frame(maxWidth: .infinity) + } + .adaptiveButtonStyle(hasPendingEntity ? .borderedProminent : .borderless) + .disabled(!hasPendingEntity) + .opacity(hasPendingEntity ? 1 : 0.5) + } header: { + Text(String(localized: "Notable Entities")) + .lockToggle(isLocked: $isLocked) + } + } + + private var hasPendingEntity: Bool { + !newName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && (newKind != "custom" + || !customKind.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + private func addEntity() { + guard hasPendingEntity else { return } + let kind = newKind == "custom" ? customKind : newKind + let entity = NotableEntity(kind: kind, name: newName) + withAnimation { + entities.append(entity) + isLocked = true + newName = "" + customKind = "" + } + } +} diff --git a/KMReader/Shared/UI/NotableEntityChipFlows.swift b/KMReader/Shared/UI/NotableEntityChipFlows.swift new file mode 100644 index 000000000..187b4931b --- /dev/null +++ b/KMReader/Shared/UI/NotableEntityChipFlows.swift @@ -0,0 +1,61 @@ +// +// NotableEntityChipFlows.swift +// + +import SwiftUI + +struct NotableEntityChipFlows: View { + enum Target { + case books + case series + } + + let entities: [NotableEntity] + let target: Target + + private var kinds: [String] { + let present = Set(entities.map(\.kind)) + return NotableEntity.knownKinds.filter(present.contains) + + present.filter { !NotableEntity.knownKinds.contains($0) }.sorted() + } + + var body: some View { + ForEach(kinds, id: \.self) { kind in + DetailChipFlow(items: items(kind: kind), collapsedLimit: 6, glass: false) + } + } + + private func items(kind: String) -> [DetailChipFlow.Item] { + entities.filter { $0.kind == kind } + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + .map { entity in + .init( + title: entity.name, + systemImage: icon(kind: kind), + destination: .navigate(destination(for: entity)) + ) + } + } + + private func destination(for entity: NotableEntity) -> NavDestination { + switch target { + case .books: + return .browseBooksWithNotableEntity(entity: entity) + case .series: + return .browseSeriesWithNotableEntity(entity: entity) + } + } + + private func icon(kind: String) -> String { + switch kind { + case "character": + return "person" + case "team": + return "person.2" + case "location": + return "mappin" + default: + return "tag" + } + } +}