diff --git a/.agents/skills/repo-conventions/SKILL.md b/.agents/skills/repo-conventions/SKILL.md index 192d624ab..0a743cca7 100644 --- a/.agents/skills/repo-conventions/SKILL.md +++ b/.agents/skills/repo-conventions/SKILL.md @@ -168,8 +168,8 @@ Subsystem conventions and invariants for KMReader. `AGENTS.md` holds repo-wide r - Trackers (`/api/v1/trackers/*`) are kmrs-private per-user integrations, not admin features and absent on Komga. `TrackerCapabilityStore` probes preferences with independent 24h negative capability records, clears preferences on account switches, and gates series actions by both support and per-user library preferences. Account and series management are online-only on iOS/macOS; tvOS has no tracker UI. - Native OAuth is intentionally not implemented: kmrs's current callback requires a browser session, while KMReader uses API keys. Disconnected providers link to `/account/trackers` in the server web UI, never to a relay carrying the KMReader credential. Connected account status and logout use the kmrs endpoints; provider tokens remain server-side. -- `TrackerSeriesSheet` lists existing per-series bindings, adds a provider only when connected, and opens `TrackerLinkEditSheet` for search, binding mode/offset, and sparse remote updates. Empty remote fields are never submitted, binding-only saves do not call remote update, and an Unlink never edits the provider entry. Server-side automatic sync owns progress aggregation; the client does not mirror it. Grouped bindings and the manual-update ledger use `PaginationState`; the ledger is recent manual updates, not a full bound-series catalog. -- Supported, enabled series detail pages always expose the tracker flow below the metadata chips: branded binding chips show remote reading status, open their own `TrackerLinkEditSheet` on tap, and a persistent Add Tracker button opens `TrackerSeriesSheet`; the toolbar and card context-menu entries remain. The chips use `TrackerLogo`, the same provider artwork as kmweb, also shown in tracker account rows. No extra section title or enclosing card. +- `TrackerSeriesSheet` lists existing per-series bindings, adds a provider only when connected, and opens `TrackerLinkEditSheet` for automatic initial search, switchable series alternate titles, binding mode/offset, and sparse remote updates. Empty remote fields are never submitted, binding-only saves do not call remote update, and an Unlink never edits the provider entry. Server-side automatic sync owns progress aggregation; the client does not mirror it. Grouped bindings and the manual-update ledger use `PaginationState`; the ledger is recent manual updates, not a full bound-series catalog. +- Supported, enabled series detail pages always expose the tracker flow below the metadata chips: branded binding chips show remote reading status, open their own `TrackerLinkEditSheet` on tap, and a persistent Add Tracker button opens `TrackerSeriesSheet`; the card and row context-menu entries remain. The chips use `TrackerLogo`, the same provider artwork as kmweb, also shown in tracker account rows. No extra section title or enclosing card. ## Browse & Dashboard diff --git a/KMReader.xcodeproj/project.pbxproj b/KMReader.xcodeproj/project.pbxproj index 66c591556..33ec9f87e 100644 --- a/KMReader.xcodeproj/project.pbxproj +++ b/KMReader.xcodeproj/project.pbxproj @@ -443,7 +443,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 668; + CURRENT_PROJECT_VERSION = 669; DEVELOPMENT_TEAM = M777UHWZA4; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -502,7 +502,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 668; + CURRENT_PROJECT_VERSION = 669; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=appletvos*]" = M777UHWZA4; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; @@ -563,7 +563,7 @@ CODE_SIGN_ENTITLEMENTS = KMReaderWidgets/KMReaderWidgets.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 668; + CURRENT_PROJECT_VERSION = 669; DEVELOPMENT_TEAM = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = KMReaderWidgets/Info.plist; @@ -597,7 +597,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 668; + CURRENT_PROJECT_VERSION = 669; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES; diff --git a/KMReader/Features/Series/Views/SeriesCardView.swift b/KMReader/Features/Series/Views/SeriesCardView.swift index 81ad5184b..c0e8dc398 100644 --- a/KMReader/Features/Series/Views/SeriesCardView.swift +++ b/KMReader/Features/Series/Views/SeriesCardView.swift @@ -117,7 +117,8 @@ struct SeriesCardView: View { TrackerSeriesSheet( seriesId: item.seriesId, libraryId: item.series.libraryId, - seriesTitle: item.metaTitle + seriesTitle: item.metaTitle, + alternateTitles: (item.series.metadata.alternateTitles ?? []).map(\.title) ) } .sheet(isPresented: $showKomfIdentify) { diff --git a/KMReader/Features/Series/Views/SeriesDetailContentView.swift b/KMReader/Features/Series/Views/SeriesDetailContentView.swift index 25afafe9c..ea4312b75 100644 --- a/KMReader/Features/Series/Views/SeriesDetailContentView.swift +++ b/KMReader/Features/Series/Views/SeriesDetailContentView.swift @@ -64,7 +64,8 @@ struct SeriesDetailContentView: View { TrackerSeriesSection( seriesId: series.id, libraryId: series.libraryId, - seriesTitle: series.metadata.title + seriesTitle: series.metadata.title, + alternateTitles: (series.metadata.alternateTitles ?? []).map(\.title) ) SeriesAlternateTitlesView(series: series) diff --git a/KMReader/Features/Series/Views/SeriesDetailView.swift b/KMReader/Features/Series/Views/SeriesDetailView.swift index 16e815e2c..9b1234fef 100644 --- a/KMReader/Features/Series/Views/SeriesDetailView.swift +++ b/KMReader/Features/Series/Views/SeriesDetailView.swift @@ -28,7 +28,6 @@ struct SeriesDetailView: View { @State private var showSavedFilters = false @State private var showKomfIdentify = false @State private var showKomfResetConfirmation = false - @State private var showTrackers = false @State private var readingTargetBook: Book? @State private var readingTargetInstanceId: String? @State private var readingTargetIsOffline: Bool? @@ -269,15 +268,6 @@ struct SeriesDetailView: View { } } } - .sheet(isPresented: $showTrackers) { - if let series { - TrackerSeriesSheet( - seriesId: series.id, - libraryId: series.libraryId, - seriesTitle: series.metadata.title - ) - } - } .sheet(isPresented: $showKomfIdentify) { if let series { KomfIdentifySheet(series: series) @@ -603,19 +593,6 @@ extension SeriesDetailView { } #endif - #if os(iOS) || os(macOS) - if TrackerCapabilityStore.shared.isSupported, let series, - TrackerCapabilityStore.shared.allows(libraryId: series.libraryId) - { - Button { - deferMenuActionPresentation { showTrackers = true } - } label: { - Label("Trackers", systemImage: "arrow.triangle.2.circlepath") - } - Divider() - } - #endif - if current.isAdmin { Button { deferMenuActionPresentation { showEditSheet = true } diff --git a/KMReader/Features/Series/Views/SeriesDetailWideLayoutView.swift b/KMReader/Features/Series/Views/SeriesDetailWideLayoutView.swift index 841ffb239..110b96237 100644 --- a/KMReader/Features/Series/Views/SeriesDetailWideLayoutView.swift +++ b/KMReader/Features/Series/Views/SeriesDetailWideLayoutView.swift @@ -86,7 +86,8 @@ struct SeriesDetailWideLayoutView: View { TrackerSeriesSection( seriesId: series.id, libraryId: series.libraryId, - seriesTitle: series.metadata.title + seriesTitle: series.metadata.title, + alternateTitles: (series.metadata.alternateTitles ?? []).map(\.title) ) SeriesAlternateTitlesView(series: series) } diff --git a/KMReader/Features/Series/Views/SeriesRowView.swift b/KMReader/Features/Series/Views/SeriesRowView.swift index 285f9159d..11295d751 100644 --- a/KMReader/Features/Series/Views/SeriesRowView.swift +++ b/KMReader/Features/Series/Views/SeriesRowView.swift @@ -185,7 +185,8 @@ struct SeriesRowView: View { TrackerSeriesSheet( seriesId: item.seriesId, libraryId: item.series.libraryId, - seriesTitle: item.metaTitle + seriesTitle: item.metaTitle, + alternateTitles: (item.series.metadata.alternateTitles ?? []).map(\.title) ) } .sheet(isPresented: $showKomfIdentify) { diff --git a/KMReader/Features/Tracker/Views/TrackerLinkEditSheet.swift b/KMReader/Features/Tracker/Views/TrackerLinkEditSheet.swift index c4bc15331..08adcf02f 100644 --- a/KMReader/Features/Tracker/Views/TrackerLinkEditSheet.swift +++ b/KMReader/Features/Tracker/Views/TrackerLinkEditSheet.swift @@ -2,11 +2,13 @@ // TrackerLinkEditSheet.swift // +import Flow import SwiftUI struct TrackerLinkEditSheet: View { let seriesId: String let seriesTitle: String + let alternateTitles: [String] let link: TrackerLink let onSaved: () async -> Void @@ -33,11 +35,13 @@ struct TrackerLinkEditSheet: View { init( seriesId: String, seriesTitle: String, + alternateTitles: [String], link: TrackerLink, onSaved: @escaping () async -> Void ) { self.seriesId = seriesId self.seriesTitle = seriesTitle + self.alternateTitles = alternateTitles self.link = link self.onSaved = onSaved _query = State(initialValue: link.title ?? seriesTitle) @@ -58,6 +62,17 @@ struct TrackerLinkEditSheet: View { ) } + private var searchTitles: [String] { + var titles: [String] = [] + for title in [seriesTitle] + alternateTitles { + let title = title.trimmingCharacters(in: .whitespacesAndNewlines) + if !title.isEmpty, !titles.contains(title) { + titles.append(title) + } + } + return titles + } + var body: some View { SheetView( title: link.provider.displayName, @@ -82,6 +97,34 @@ struct TrackerLinkEditSheet: View { } else { TextField(String(localized: "Search Title or URL"), text: $query) .onChange(of: query) { _, _ in scheduleSearch() } + if searchTitles.count > 1 { + HFlow(itemSpacing: 8) { + ForEach(searchTitles, id: \.self) { title in + Button { + query = title + scheduleSearch() + } label: { + Text(title) + .font(.caption) + .lineLimit(1) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + query == title ? Color.accentColor.opacity(0.15) : LayoutConfig.neutralFillColor, + in: Capsule() + ) + .overlay { + Capsule() + .strokeBorder( + query == title ? Color.accentColor.opacity(0.4) : Color.secondary.opacity(0.2) + ) + } + .contentShape(Capsule()) + } + .adaptiveButtonStyle(.plain) + } + } + } if isSearching { ProgressView() } @@ -156,6 +199,9 @@ struct TrackerLinkEditSheet: View { remoteLoadTask = Task { await loadRemoteState() } + if selectedItem == nil, link.trackId.isEmpty { + scheduleSearch() + } } .onDisappear { searchTask?.cancel() diff --git a/KMReader/Features/Tracker/Views/TrackerSeriesSection.swift b/KMReader/Features/Tracker/Views/TrackerSeriesSection.swift index 14fa2247b..400aadd4d 100644 --- a/KMReader/Features/Tracker/Views/TrackerSeriesSection.swift +++ b/KMReader/Features/Tracker/Views/TrackerSeriesSection.swift @@ -9,6 +9,7 @@ struct TrackerSeriesSection: View { let seriesId: String let libraryId: String let seriesTitle: String + let alternateTitles: [String] @State private var links: [TrackerLink] = [] @State private var states: [TrackerProvider: TrackerRemoteState] = [:] @@ -60,13 +61,15 @@ struct TrackerSeriesSection: View { TrackerSeriesSheet( seriesId: seriesId, libraryId: libraryId, - seriesTitle: seriesTitle + seriesTitle: seriesTitle, + alternateTitles: alternateTitles ) } .sheet(item: $editingLink) { link in TrackerLinkEditSheet( seriesId: seriesId, seriesTitle: seriesTitle, + alternateTitles: alternateTitles, link: link ) { await load() @@ -81,21 +84,26 @@ struct TrackerSeriesSection: View { do { let loadedLinks = try await TrackerService.getLinks(seriesId: seriesId) guard !Task.isCancelled else { return } - links = loadedLinks - states = [:] - providersRequiringLogin = [] + if links != loadedLinks { + withAnimation(.appCurve()) { + links = loadedLinks + } + } for link in loadedLinks { do { let state = try await TrackerService.getState( provider: link.provider, trackId: link.trackId) guard !Task.isCancelled else { return } - states[link.provider] = state + withAnimation(.appCurve()) { + states[link.provider] = state + providersRequiringLogin.remove(link.provider) + } } catch APIError.unauthorized { - providersRequiringLogin.insert(link.provider) + _ = withAnimation(.appCurve()) { + providersRequiringLogin.insert(link.provider) + } } catch {} } - } catch { - links = [] - } + } catch {} } } diff --git a/KMReader/Features/Tracker/Views/TrackerSeriesSheet.swift b/KMReader/Features/Tracker/Views/TrackerSeriesSheet.swift index c41036b14..cd3c24c76 100644 --- a/KMReader/Features/Tracker/Views/TrackerSeriesSheet.swift +++ b/KMReader/Features/Tracker/Views/TrackerSeriesSheet.swift @@ -8,6 +8,7 @@ struct TrackerSeriesSheet: View { let seriesId: String let libraryId: String let seriesTitle: String + let alternateTitles: [String] let onLinksChanged: (() async -> Void)? @AppStorage("currentAccount") private var current: Current = .init() @@ -23,11 +24,13 @@ struct TrackerSeriesSheet: View { seriesId: String, libraryId: String, seriesTitle: String, + alternateTitles: [String] = [], onLinksChanged: (() async -> Void)? = nil ) { self.seriesId = seriesId self.libraryId = libraryId self.seriesTitle = seriesTitle + self.alternateTitles = alternateTitles self.onLinksChanged = onLinksChanged } @@ -144,6 +147,7 @@ struct TrackerSeriesSheet: View { TrackerLinkEditSheet( seriesId: seriesId, seriesTitle: seriesTitle, + alternateTitles: alternateTitles, link: link ) { await load()