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
6 changes: 6 additions & 0 deletions .agents/skills/repo-conventions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ Subsystem conventions and invariants for KMReader. `AGENTS.md` holds repo-wide r
- 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.

### Reading Trackers

- 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.

## Browse & Dashboard

### Library Selection
Expand Down
8 changes: 4 additions & 4 deletions KMReader.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 666;
CURRENT_PROJECT_VERSION = 667;
DEVELOPMENT_TEAM = M777UHWZA4;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
Expand Down Expand Up @@ -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 = 666;
CURRENT_PROJECT_VERSION = 667;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=appletvos*]" = M777UHWZA4;
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4;
Expand Down Expand Up @@ -563,7 +563,7 @@
CODE_SIGN_ENTITLEMENTS = KMReaderWidgets/KMReaderWidgets.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 666;
CURRENT_PROJECT_VERSION = 667;
DEVELOPMENT_TEAM = M777UHWZA4;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = KMReaderWidgets/Info.plist;
Expand Down Expand Up @@ -597,7 +597,7 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = 666;
CURRENT_PROJECT_VERSION = 667;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4;
GENERATE_INFOPLIST_FILE = YES;
Expand Down
1 change: 1 addition & 0 deletions KMReader/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ struct ContentView: View {
#if os(iOS) || os(macOS)
Task {
await KomfIntegrationStore.shared.refresh(isAdmin: current.isAdmin)
await TrackerCapabilityStore.shared.refresh()
}
#endif
await ExternalContentSurfaceService.refreshWidgetsForCurrentInstance()
Expand Down
14 changes: 14 additions & 0 deletions KMReader/Core/Storage/AppConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1169,6 +1169,20 @@ enum AppConfig {
}
}

static nonisolated var serverTrackerCapability: ServerCapability {
get {
if let stored = UserDefaults.standard.string(forKey: "serverTrackerCapability"),
let capability = ServerCapability(rawValue: stored)
{
return capability
}
return ServerCapability()
}
set {
UserDefaults.standard.set(newValue.rawValue, forKey: "serverTrackerCapability")
}
}

static nonisolated var serverSmartListCapability: ServerSmartListCapability {
get {
if let stored = UserDefaults.standard.string(forKey: "serverSmartListCapability"),
Expand Down
11 changes: 11 additions & 0 deletions KMReader/Features/Series/Views/SeriesCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct SeriesCardView: View {
@State private var showCollectionPicker = false
@State private var showEditSheet = false
@State private var showKomfIdentify = false
@State private var showTrackers = false

var progress: Double {
guard item.booksCount > 0 else { return 0 }
Expand Down Expand Up @@ -90,6 +91,9 @@ struct SeriesCardView: View {
onKomfIdentifyRequested: {
showKomfIdentify = true
},
onTrackersRequested: {
showTrackers = true
},
onMutationCompleted: onMutationCompleted
)
} detail: {
Expand All @@ -109,6 +113,13 @@ struct SeriesCardView: View {
.sheet(isPresented: $showEditSheet) {
SeriesEditSheet(series: item.series)
}
.sheet(isPresented: $showTrackers) {
TrackerSeriesSheet(
seriesId: item.seriesId,
libraryId: item.series.libraryId,
seriesTitle: item.metaTitle
)
}
.sheet(isPresented: $showKomfIdentify) {
KomfIdentifySheet(series: item.series)
}
Expand Down
14 changes: 14 additions & 0 deletions KMReader/Features/Series/Views/SeriesContextMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ struct SeriesContextMenu: View {
var onDeleteRequested: (() -> Void)? = nil
var onEditRequested: (() -> Void)? = nil
var onKomfIdentifyRequested: (() -> Void)? = nil
var onTrackersRequested: (() -> Void)? = nil
var onMutationCompleted: (() -> Void)? = nil

@AppStorage("currentAccount") private var current: Current = .init()
Expand Down Expand Up @@ -89,6 +90,19 @@ struct SeriesContextMenu: View {
}
}

#if os(iOS) || os(macOS)
if TrackerCapabilityStore.shared.isSupported,
TrackerCapabilityStore.shared.allows(libraryId: libraryId),
onTrackersRequested != nil
{
Button {
deferMenuActionPresentation { onTrackersRequested?() }
} label: {
Label("Trackers", systemImage: "arrow.triangle.2.circlepath")
}
}
#endif

Divider()

if current.isAdmin {
Expand Down
6 changes: 6 additions & 0 deletions KMReader/Features/Series/Views/SeriesDetailContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ struct SeriesDetailContentView<Actions: View>: View {

SeriesDetailChipsView(series: series)

TrackerSeriesSection(
seriesId: series.id,
libraryId: series.libraryId,
seriesTitle: series.metadata.title
)

SeriesAlternateTitlesView(series: series)
}
.onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { contentWidth = $0 }
Expand Down
23 changes: 23 additions & 0 deletions KMReader/Features/Series/Views/SeriesDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ 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?
Expand Down Expand Up @@ -268,6 +269,15 @@ 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)
Expand Down Expand Up @@ -593,6 +603,19 @@ 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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ struct SeriesDetailWideLayoutView<Actions: View>: View {
VStack(alignment: .leading, spacing: 16) {
SeriesSummaryView(series: series)
SeriesDetailChipsView(series: series)
TrackerSeriesSection(
seriesId: series.id,
libraryId: series.libraryId,
seriesTitle: series.metadata.title
)
SeriesAlternateTitlesView(series: series)
}
}
Expand Down
11 changes: 11 additions & 0 deletions KMReader/Features/Series/Views/SeriesRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ struct SeriesRowView: View {
@State private var showCollectionPicker = false
@State private var showEditSheet = false
@State private var showKomfIdentify = false
@State private var showTrackers = false

var series: Series {
item.series
Expand Down Expand Up @@ -158,6 +159,9 @@ struct SeriesRowView: View {
onKomfIdentifyRequested: {
showKomfIdentify = true
},
onTrackersRequested: {
showTrackers = true
},
onMutationCompleted: onMutationCompleted
)
.id(item.seriesId)
Expand All @@ -177,6 +181,13 @@ struct SeriesRowView: View {
.sheet(isPresented: $showEditSheet) {
SeriesEditSheet(series: series)
}
.sheet(isPresented: $showTrackers) {
TrackerSeriesSheet(
seriesId: item.seriesId,
libraryId: item.series.libraryId,
seriesTitle: item.metaTitle
)
}
.sheet(isPresented: $showKomfIdentify) {
KomfIdentifySheet(series: series)
}
Expand Down
7 changes: 7 additions & 0 deletions KMReader/Features/Server/ServerSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum ServerSection: String, CaseIterable {
case account
case apiKeys
case accountSessions
case trackers
case authenticationActivity

var icon: String {
Expand All @@ -40,6 +41,8 @@ enum ServerSection: String, CaseIterable {
return "key"
case .accountSessions:
return "person.2"
case .trackers:
return "arrow.triangle.2.circlepath"
case .authenticationActivity:
return "person.badge.key"

Expand Down Expand Up @@ -67,6 +70,8 @@ enum ServerSection: String, CaseIterable {
return .yellow
case .accountSessions:
return .blue
case .trackers:
return .purple
case .authenticationActivity:
return .gray
}
Expand All @@ -93,6 +98,8 @@ enum ServerSection: String, CaseIterable {
return String(localized: "API Keys")
case .accountSessions:
return String(localized: "Sessions")
case .trackers:
return String(localized: "Trackers")
case .authenticationActivity:
return String(localized: "Authentication Activity")
}
Expand Down
11 changes: 11 additions & 0 deletions KMReader/Features/Server/ServerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ struct ServerView: View {
}
.adaptiveButtonStyle(.plain)

#if os(iOS) || os(macOS)
NavigationLink(value: NavDestination.settingsTrackers) {
ServerActionTile(
title: ServerSection.trackers.title,
systemImage: ServerSection.trackers.icon,
color: ServerSection.trackers.color
)
}
.adaptiveButtonStyle(.plain)
#endif

NavigationLink(value: NavDestination.settingsAuthenticationActivity) {
ServerActionTile(
title: ServerSection.authenticationActivity.title,
Expand Down
9 changes: 9 additions & 0 deletions KMReader/Features/Settings/SettingsAccountView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ struct SettingsAccountView: View {
color: ServerSection.accountSessions.color
)
}
#if os(iOS) || os(macOS)
NavigationLink(value: NavDestination.settingsTrackers) {
SettingsBadgeRow(
title: ServerSection.trackers.title,
icon: ServerSection.trackers.icon,
color: ServerSection.trackers.color
)
}
#endif
NavigationLink(value: NavDestination.settingsAuthenticationActivity) {
SettingsBadgeRow(
title: ServerSection.authenticationActivity.title,
Expand Down
77 changes: 77 additions & 0 deletions KMReader/Features/Tracker/Models/TrackerAccount.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// TrackerAccount.swift
//

import Foundation

nonisolated struct TrackerAccount: Codable, Sendable {
let loggedIn: Bool
let username: String?

enum CodingKeys: String, CodingKey {
case loggedIn = "logged_in"
case username
}
}

nonisolated struct TrackerPreferences: Codable, Sendable {
var libraries: [String]
var defaultTracker: TrackerProvider?
}

nonisolated struct TrackerSearchItem: Codable, Equatable, Identifiable, Sendable {
let id: String
let title: String
let coverUrl: String?
let description: String?
let tracked: Bool
let mediaType: String?
let url: String?

var remoteURL: URL? {
url.flatMap(URL.init(string:))
}
}

nonisolated struct TrackerRemoteState: Codable, Sendable {
var score: Int?
var status: TrackerStatus?
var lastReadChapter: Double?
var lastReadVolume: Int?
var totalChapters: Int?
var totalVolumes: Int?
var startReadDate: String?
var finishReadDate: String?
}

nonisolated enum TrackerStatus: String, CaseIterable, Codable, Sendable {
case reading
case planning
case completed
case paused
case dropped
case rereading

var displayName: String {
switch self {
case .reading: return String(localized: "Reading")
case .planning: return String(localized: "Planning")
case .completed: return String(localized: "Completed")
case .paused: return String(localized: "Paused")
case .dropped: return String(localized: "Dropped")
case .rereading: return String(localized: "Rereading")
}
}
}

nonisolated struct TrackerLedgerItem: Codable, Equatable, Identifiable, Sendable {
let provider: TrackerProvider
let trackId: String
let title: String?
let url: String?
let coverUrl: String?
let updatedAt: TimeInterval

var id: String { "\(provider.rawValue):\(trackId)" }
var updatedDate: Date { Date(timeIntervalSince1970: updatedAt) }
}
Loading