Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .agents/skills/repo-conventions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions KMReader/Core/Storage/DatabaseOperator+Series.swift
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ extension DatabaseOperator {
browseOpts.completeFilter.isActive
|| !browseOpts.includeSeriesStatuses.isEmpty
|| !browseOpts.excludeSeriesStatuses.isEmpty
|| browseOpts.metadataFilter.notableEntities != nil

if !requiresResidualFilter {
return try fetchBrowseSeriesSQLCandidates(
Expand Down
14 changes: 10 additions & 4 deletions KMReader/Core/Storage/DatabaseOperator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,13 @@ extension DatabaseOperator {
return false
}

if !(book.metadata?.notableEntities ?? []).matches(
filter.notableEntities,
logic: filter.notableEntitiesLogic
) {
return false
}

return true
}

Expand Down Expand Up @@ -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
}
Expand Down
15 changes: 15 additions & 0 deletions KMReader/Features/Book/Models/BookSearch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions KMReader/Features/Book/Models/Metadata/BookMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 ?? [],
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 3 additions & 1 deletion KMReader/Features/Book/ViewModels/BookBrowseOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
3 changes: 2 additions & 1 deletion KMReader/Features/Book/Views/BookBrowseOptionsSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ struct BookBrowseOptionsSheet: View {
libraryIds: libraryIds,
seriesId: seriesId,
showAuthors: true,
showTags: true
showTags: true,
showNotableEntities: true
)

}
Expand Down
5 changes: 5 additions & 0 deletions KMReader/Features/Book/Views/BookDetailContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions KMReader/Features/Book/Views/BookEditSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions KMReader/Features/OneShot/OneShotDetailContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion KMReader/Features/OneShot/OneshotEditSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
24 changes: 24 additions & 0 deletions KMReader/Features/Referential/ReferentialService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<NotableEntity> = 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]
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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?
Expand Down
15 changes: 15 additions & 0 deletions KMReader/Features/Series/Models/SeriesSearch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]]
Expand Down
2 changes: 2 additions & 0 deletions KMReader/Features/Series/ViewModels/SeriesBrowseOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ struct SeriesBrowseOptionsSheet: View {
showAuthors: true,
showGenres: true,
showTags: true,
showNotableEntities: true,
showLanguages: true,
showAgeRatings: true,
showReleaseYears: true
Expand Down
1 change: 1 addition & 0 deletions KMReader/Features/Series/Views/SeriesDetailChipsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 14 additions & 2 deletions KMReader/Features/SmartList/Models/SmartListFilterMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"] {
Expand Down
3 changes: 2 additions & 1 deletion KMReader/Features/SmartList/Views/SmartListEditSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading