From 869360928765c560dd62eee1ca3497f480904460 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Thu, 29 Oct 2026 01:19:04 +0800 Subject: [PATCH 1/2] chore: incr build ver to 667 --- KMReader.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/KMReader.xcodeproj/project.pbxproj b/KMReader.xcodeproj/project.pbxproj index d19c7b755..c85e0fde0 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 = 666; + CURRENT_PROJECT_VERSION = 667; 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 = 666; + CURRENT_PROJECT_VERSION = 667; 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 = 666; + CURRENT_PROJECT_VERSION = 667; 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 = 666; + CURRENT_PROJECT_VERSION = 667; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES; From b3ca828be16b078bdb613d1575a006a702e6590f Mon Sep 17 00:00:00 2001 From: everpcpc Date: Thu, 29 Oct 2026 01:20:16 +0800 Subject: [PATCH 2/2] feat(trackers): manage reading tracker accounts and series links --- .agents/skills/repo-conventions/SKILL.md | 6 + KMReader/ContentView.swift | 1 + KMReader/Core/Storage/AppConfig.swift | 14 + .../Series/Views/SeriesCardView.swift | 11 + .../Series/Views/SeriesContextMenu.swift | 14 + .../Views/SeriesDetailContentView.swift | 6 + .../Series/Views/SeriesDetailView.swift | 23 + .../Views/SeriesDetailWideLayoutView.swift | 5 + .../Features/Series/Views/SeriesRowView.swift | 11 + KMReader/Features/Server/ServerSection.swift | 7 + KMReader/Features/Server/ServerView.swift | 11 + .../Settings/SettingsAccountView.swift | 9 + .../Tracker/Models/TrackerAccount.swift | 77 + .../Features/Tracker/Models/TrackerLink.swift | 74 + .../Tracker/Models/TrackerProvider.swift | 30 + .../Services/TrackerCapabilityStore.swift | 81 + .../Tracker/Services/TrackerService.swift | 124 + .../Tracker/Views/TrackerAccountRow.swift | 32 + .../Tracker/Views/TrackerAccountsView.swift | 181 + .../Tracker/Views/TrackerBindingsView.swift | 110 + .../Tracker/Views/TrackerLedgerView.swift | 76 + .../Views/TrackerLibrarySelectionView.swift | 44 + .../Tracker/Views/TrackerLinkEditSheet.swift | 287 ++ .../Views/TrackerSearchResultRow.swift | 38 + .../Tracker/Views/TrackerSeriesSection.swift | 71 + .../Tracker/Views/TrackerSeriesSheet.swift | 155 + KMReader/Localizable.xcstrings | 3328 +++++++++++++++++ .../Foundation/Models/NavDestination.swift | 9 + 28 files changed, 4835 insertions(+) create mode 100644 KMReader/Features/Tracker/Models/TrackerAccount.swift create mode 100644 KMReader/Features/Tracker/Models/TrackerLink.swift create mode 100644 KMReader/Features/Tracker/Models/TrackerProvider.swift create mode 100644 KMReader/Features/Tracker/Services/TrackerCapabilityStore.swift create mode 100644 KMReader/Features/Tracker/Services/TrackerService.swift create mode 100644 KMReader/Features/Tracker/Views/TrackerAccountRow.swift create mode 100644 KMReader/Features/Tracker/Views/TrackerAccountsView.swift create mode 100644 KMReader/Features/Tracker/Views/TrackerBindingsView.swift create mode 100644 KMReader/Features/Tracker/Views/TrackerLedgerView.swift create mode 100644 KMReader/Features/Tracker/Views/TrackerLibrarySelectionView.swift create mode 100644 KMReader/Features/Tracker/Views/TrackerLinkEditSheet.swift create mode 100644 KMReader/Features/Tracker/Views/TrackerSearchResultRow.swift create mode 100644 KMReader/Features/Tracker/Views/TrackerSeriesSection.swift create mode 100644 KMReader/Features/Tracker/Views/TrackerSeriesSheet.swift diff --git a/.agents/skills/repo-conventions/SKILL.md b/.agents/skills/repo-conventions/SKILL.md index 3dd6f6756..0e3ef8199 100644 --- a/.agents/skills/repo-conventions/SKILL.md +++ b/.agents/skills/repo-conventions/SKILL.md @@ -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 diff --git a/KMReader/ContentView.swift b/KMReader/ContentView.swift index 097600343..2d2972b2e 100644 --- a/KMReader/ContentView.swift +++ b/KMReader/ContentView.swift @@ -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() diff --git a/KMReader/Core/Storage/AppConfig.swift b/KMReader/Core/Storage/AppConfig.swift index 9689c7999..eff40cf18 100644 --- a/KMReader/Core/Storage/AppConfig.swift +++ b/KMReader/Core/Storage/AppConfig.swift @@ -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"), diff --git a/KMReader/Features/Series/Views/SeriesCardView.swift b/KMReader/Features/Series/Views/SeriesCardView.swift index 526b4f043..81ad5184b 100644 --- a/KMReader/Features/Series/Views/SeriesCardView.swift +++ b/KMReader/Features/Series/Views/SeriesCardView.swift @@ -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 } @@ -90,6 +91,9 @@ struct SeriesCardView: View { onKomfIdentifyRequested: { showKomfIdentify = true }, + onTrackersRequested: { + showTrackers = true + }, onMutationCompleted: onMutationCompleted ) } detail: { @@ -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) } diff --git a/KMReader/Features/Series/Views/SeriesContextMenu.swift b/KMReader/Features/Series/Views/SeriesContextMenu.swift index f977ac737..857770854 100644 --- a/KMReader/Features/Series/Views/SeriesContextMenu.swift +++ b/KMReader/Features/Series/Views/SeriesContextMenu.swift @@ -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() @@ -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 { diff --git a/KMReader/Features/Series/Views/SeriesDetailContentView.swift b/KMReader/Features/Series/Views/SeriesDetailContentView.swift index 14dddb8b8..25afafe9c 100644 --- a/KMReader/Features/Series/Views/SeriesDetailContentView.swift +++ b/KMReader/Features/Series/Views/SeriesDetailContentView.swift @@ -61,6 +61,12 @@ struct SeriesDetailContentView: 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 } diff --git a/KMReader/Features/Series/Views/SeriesDetailView.swift b/KMReader/Features/Series/Views/SeriesDetailView.swift index 9b1234fef..16e815e2c 100644 --- a/KMReader/Features/Series/Views/SeriesDetailView.swift +++ b/KMReader/Features/Series/Views/SeriesDetailView.swift @@ -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? @@ -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) @@ -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 } diff --git a/KMReader/Features/Series/Views/SeriesDetailWideLayoutView.swift b/KMReader/Features/Series/Views/SeriesDetailWideLayoutView.swift index 5f121b48e..841ffb239 100644 --- a/KMReader/Features/Series/Views/SeriesDetailWideLayoutView.swift +++ b/KMReader/Features/Series/Views/SeriesDetailWideLayoutView.swift @@ -83,6 +83,11 @@ struct SeriesDetailWideLayoutView: 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) } } diff --git a/KMReader/Features/Series/Views/SeriesRowView.swift b/KMReader/Features/Series/Views/SeriesRowView.swift index 93afd9f08..285f9159d 100644 --- a/KMReader/Features/Series/Views/SeriesRowView.swift +++ b/KMReader/Features/Series/Views/SeriesRowView.swift @@ -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 @@ -158,6 +159,9 @@ struct SeriesRowView: View { onKomfIdentifyRequested: { showKomfIdentify = true }, + onTrackersRequested: { + showTrackers = true + }, onMutationCompleted: onMutationCompleted ) .id(item.seriesId) @@ -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) } diff --git a/KMReader/Features/Server/ServerSection.swift b/KMReader/Features/Server/ServerSection.swift index 02576e0ab..b42fce191 100644 --- a/KMReader/Features/Server/ServerSection.swift +++ b/KMReader/Features/Server/ServerSection.swift @@ -17,6 +17,7 @@ enum ServerSection: String, CaseIterable { case account case apiKeys case accountSessions + case trackers case authenticationActivity var icon: String { @@ -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" @@ -67,6 +70,8 @@ enum ServerSection: String, CaseIterable { return .yellow case .accountSessions: return .blue + case .trackers: + return .purple case .authenticationActivity: return .gray } @@ -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") } diff --git a/KMReader/Features/Server/ServerView.swift b/KMReader/Features/Server/ServerView.swift index 970de11f9..b38277c08 100644 --- a/KMReader/Features/Server/ServerView.swift +++ b/KMReader/Features/Server/ServerView.swift @@ -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, diff --git a/KMReader/Features/Settings/SettingsAccountView.swift b/KMReader/Features/Settings/SettingsAccountView.swift index 2304e2838..813c64985 100644 --- a/KMReader/Features/Settings/SettingsAccountView.swift +++ b/KMReader/Features/Settings/SettingsAccountView.swift @@ -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, diff --git a/KMReader/Features/Tracker/Models/TrackerAccount.swift b/KMReader/Features/Tracker/Models/TrackerAccount.swift new file mode 100644 index 000000000..b40840b2c --- /dev/null +++ b/KMReader/Features/Tracker/Models/TrackerAccount.swift @@ -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) } +} diff --git a/KMReader/Features/Tracker/Models/TrackerLink.swift b/KMReader/Features/Tracker/Models/TrackerLink.swift new file mode 100644 index 000000000..54fe2a91d --- /dev/null +++ b/KMReader/Features/Tracker/Models/TrackerLink.swift @@ -0,0 +1,74 @@ +// +// TrackerLink.swift +// + +import Foundation + +nonisolated enum TrackerMode: String, CaseIterable, Codable, Sendable { + case auto + case chapter + case volume + + var displayName: String { + switch self { + case .auto: return String(localized: "Auto") + case .chapter: return String(localized: "Chapter") + case .volume: return String(localized: "Volume") + } + } +} + +nonisolated struct TrackerLink: Codable, Equatable, Identifiable, Sendable { + let seriesId: String + let provider: TrackerProvider + let trackId: String + let title: String? + let trackMode: TrackerMode + let chapterOffset: Int + let createdDate: String + let lastModifiedDate: String + + var id: String { "\(seriesId):\(provider.rawValue)" } + var displayTitle: String { title ?? trackId } +} + +nonisolated struct TrackerLinkGroup: Codable, Equatable, Identifiable, Sendable { + let seriesId: String + let links: [TrackerLink] + + var id: String { seriesId } +} + +nonisolated struct TrackerLinkCounts: Codable, Sendable { + let series: Int + let providers: [String: Int] +} + +nonisolated struct TrackerLinkPage: Codable, Sendable { + let content: [TrackerLinkGroup] + let pageable: Pageable + let totalElements: Int + let totalPages: Int + let last: Bool + let size: Int + let number: Int + let numberOfElements: Int + let first: Bool + let empty: Bool + let counts: TrackerLinkCounts + + var page: Page { + Page( + content: content, + pageable: pageable, + totalElements: totalElements, + totalPages: totalPages, + last: last, + size: size, + number: number, + numberOfElements: numberOfElements, + first: first, + empty: empty + ) + } +} diff --git a/KMReader/Features/Tracker/Models/TrackerProvider.swift b/KMReader/Features/Tracker/Models/TrackerProvider.swift new file mode 100644 index 000000000..276b2f4fb --- /dev/null +++ b/KMReader/Features/Tracker/Models/TrackerProvider.swift @@ -0,0 +1,30 @@ +// +// TrackerProvider.swift +// + +import Foundation + +nonisolated enum TrackerProvider: String, CaseIterable, Codable, Sendable { + case anilist + case mal + case bangumi + case mangabaka + + var displayName: String { + switch self { + case .anilist: return "AniList" + case .mal: return "MyAnimeList" + case .bangumi: return "Bangumi" + case .mangabaka: return "MangaBaka" + } + } + + func entryURL(trackId: String) -> URL? { + switch self { + case .anilist: return URL(string: "https://anilist.co/manga/\(trackId)") + case .mal: return URL(string: "https://myanimelist.net/manga/\(trackId)") + case .bangumi: return URL(string: "https://bgm.tv/subject/\(trackId)") + case .mangabaka: return URL(string: "https://mangabaka.org/\(trackId)") + } + } +} diff --git a/KMReader/Features/Tracker/Services/TrackerCapabilityStore.swift b/KMReader/Features/Tracker/Services/TrackerCapabilityStore.swift new file mode 100644 index 000000000..890f01bee --- /dev/null +++ b/KMReader/Features/Tracker/Services/TrackerCapabilityStore.swift @@ -0,0 +1,81 @@ +// +// TrackerCapabilityStore.swift +// + +import Foundation + +@MainActor +@Observable +final class TrackerCapabilityStore { + static let shared = TrackerCapabilityStore() + + private(set) var isSupported = false + private(set) var preferences: TrackerPreferences? + private var checkedInstanceId: String? + + private init() {} + + func refresh() async { + let instanceId = AppConfig.current.instanceId + guard !AppConfig.isOffline, !instanceId.isEmpty else { + clear() + return + } + guard checkedInstanceId != instanceId else { return } + preferences = nil + isSupported = false + guard AppConfig.serverTrackerCapability.shouldQuery(instanceId: instanceId) else { + isSupported = false + checkedInstanceId = instanceId + return + } + + do { + preferences = try await TrackerService.getPreferences() + record(supported: true) + isSupported = true + checkedInstanceId = instanceId + } catch { + isSupported = false + if case APIError.notFound = error { + record(supported: false) + checkedInstanceId = instanceId + } else { + checkedInstanceId = nil + preferences = nil + } + } + } + + func allows(libraryId: String) -> Bool { + guard let preferences else { return isSupported } + return preferences.libraries.isEmpty || preferences.libraries.contains(libraryId) + } + + func updatePreferences(_ preferences: TrackerPreferences) { + self.preferences = preferences + } + + func refreshCompleted(supported: Bool) { + record(supported: supported) + isSupported = supported + checkedInstanceId = AppConfig.current.instanceId + } + + func invalidate() { + checkedInstanceId = nil + clear() + } + + private func clear() { + checkedInstanceId = nil + isSupported = false + preferences = nil + } + + private func record(supported: Bool) { + var capability = AppConfig.serverTrackerCapability + capability.record(instanceId: AppConfig.current.instanceId, supported: supported) + AppConfig.serverTrackerCapability = capability + } +} diff --git a/KMReader/Features/Tracker/Services/TrackerService.swift b/KMReader/Features/Tracker/Services/TrackerService.swift new file mode 100644 index 000000000..bae5d55f3 --- /dev/null +++ b/KMReader/Features/Tracker/Services/TrackerService.swift @@ -0,0 +1,124 @@ +// +// TrackerService.swift +// + +import Foundation + +nonisolated enum TrackerService { + private static let apiClient = APIClient.shared + + static func getPreferences() async throws -> TrackerPreferences { + try await apiClient.request(path: "/api/v1/trackers/preferences") + } + + static func updatePreferences(_ preferences: TrackerPreferences) async throws -> TrackerPreferences { + try await apiClient.request( + path: "/api/v1/trackers/preferences", + method: "PUT", + body: JSONEncoder().encode(preferences) + ) + } + + static func getAccount(provider: TrackerProvider) async throws -> TrackerAccount { + try await apiClient.request(path: "/api/v1/trackers/oauth/\(provider.rawValue)/status") + } + + static func logout(provider: TrackerProvider) async throws { + let _: EmptyResponse = try await apiClient.request( + path: "/api/v1/trackers/oauth/\(provider.rawValue)/logout", + method: "POST" + ) + } + + static func getLinks(page: Int = 0, size: Int = 20, provider: TrackerProvider? = nil) async throws + -> TrackerLinkPage + { + var queryItems = [ + URLQueryItem(name: "page", value: "\(page)"), + URLQueryItem(name: "size", value: "\(size)"), + ] + if let provider { + queryItems.append(URLQueryItem(name: "provider", value: provider.rawValue)) + } + return try await apiClient.request(path: "/api/v1/trackers/links", queryItems: queryItems) + } + + static func getLinks(seriesId: String) async throws -> [TrackerLink] { + try await apiClient.request(path: "/api/v1/trackers/links/\(seriesId)") + } + + static func upsertLink( + seriesId: String, + provider: TrackerProvider, + trackId: String, + title: String?, + mode: TrackerMode, + chapterOffset: Int + ) async throws -> TrackerLink { + let body = try JSONSerialization.data( + withJSONObject: [ + "provider": provider.rawValue, + "trackId": trackId, + "title": title as Any, + "trackMode": mode.rawValue, + "chapterOffset": chapterOffset, + ], + options: [.sortedKeys] + ) + return try await apiClient.request( + path: "/api/v1/trackers/links/\(seriesId)", + method: "PUT", + body: body + ) + } + + static func deleteLink(seriesId: String, provider: TrackerProvider) async throws { + let _: EmptyResponse = try await apiClient.request( + path: "/api/v1/trackers/links/\(seriesId)/\(provider.rawValue)", + method: "DELETE" + ) + } + + static func search(provider: TrackerProvider, name: String, includeNsfw: Bool = true) async throws + -> [TrackerSearchItem] + { + try await apiClient.request( + path: "/api/v1/trackers/\(provider.rawValue)/search", + queryItems: [ + URLQueryItem(name: "name", value: name), + URLQueryItem(name: "nsfw", value: includeNsfw ? "true" : "false"), + ] + ) + } + + static func getState(provider: TrackerProvider, trackId: String) async throws -> TrackerRemoteState { + try await apiClient.request( + path: "/api/v1/trackers/\(provider.rawValue)/state", + queryItems: [URLQueryItem(name: "trackId", value: trackId)] + ) + } + + static func updateState( + provider: TrackerProvider, + trackId: String, + state: [String: Any] + ) async throws { + var body = state + body["trackId"] = trackId + let _: EmptyResponse = try await apiClient.request( + path: "/api/v1/trackers/\(provider.rawValue)/update", + method: "POST", + body: JSONSerialization.data(withJSONObject: body, options: [.sortedKeys]) + ) + } + + static func getLedger(page: Int = 0, size: Int = 20) async throws -> Page { + try await apiClient.request( + path: "/api/v1/trackers/ledger", + queryItems: [ + URLQueryItem(name: "page", value: "\(page)"), + URLQueryItem(name: "size", value: "\(size)"), + ] + ) + } +} diff --git a/KMReader/Features/Tracker/Views/TrackerAccountRow.swift b/KMReader/Features/Tracker/Views/TrackerAccountRow.swift new file mode 100644 index 000000000..54290710c --- /dev/null +++ b/KMReader/Features/Tracker/Views/TrackerAccountRow.swift @@ -0,0 +1,32 @@ +// +// TrackerAccountRow.swift +// + +import SwiftUI + +struct TrackerAccountRow: View { + let provider: TrackerProvider + let account: TrackerAccount? + let isPending: Bool + let webURL: URL? + let onLogout: () -> Void + + var body: some View { + HStack { + Text(provider.displayName) + Spacer() + if isPending { + ProgressView() + } else if account?.loggedIn == true { + Text(account?.username ?? String(localized: "Connected")) + .foregroundStyle(.secondary) + .lineLimit(1) + Button(String(localized: "Disconnect"), role: .destructive, action: onLogout) + .font(.caption) + } else if let webURL { + Link(String(localized: "Connect on Web"), destination: webURL) + .font(.caption) + } + } + } +} diff --git a/KMReader/Features/Tracker/Views/TrackerAccountsView.swift b/KMReader/Features/Tracker/Views/TrackerAccountsView.swift new file mode 100644 index 000000000..0bd512ed4 --- /dev/null +++ b/KMReader/Features/Tracker/Views/TrackerAccountsView.swift @@ -0,0 +1,181 @@ +// +// TrackerAccountsView.swift +// + +import SwiftUI + +struct TrackerAccountsView: View { + @AppStorage("currentAccount") private var current: Current = .init() + + @State private var accounts: [TrackerProvider: TrackerAccount] = [:] + @State private var preferences: TrackerPreferences? + @State private var libraries: [LibraryInfo] = [] + @State private var isLoading = false + @State private var isSavingPreferences = false + @State private var pendingProvider: TrackerProvider? + @State private var loadedInstanceId: String? + @State private var hasLoaded = false + + private var webURL: URL? { + URL(string: current.serverURL + "/account/trackers") + } + + var body: some View { + Form { + if !hasLoaded && isLoading { + Section { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + } else if !hasLoaded { + Section { + ContentUnavailableView { + Label(String(localized: "Trackers Unavailable"), systemImage: "arrow.triangle.2.circlepath") + } description: { + Text(String(localized: "This server does not support reading trackers, or it could not be reached.")) + } + } + } else { + Section { + ForEach(TrackerProvider.allCases, id: \.self) { provider in + TrackerAccountRow( + provider: provider, + account: accounts[provider], + isPending: pendingProvider == provider, + webURL: webURL + ) { + logout(provider) + } + } + } header: { + Text(String(localized: "Connected Accounts")) + } footer: { + Text( + String( + localized: "Connect tracker accounts in the server web interface. KMReader never stores provider tokens.") + ) + } + + if preferences != nil { + Section(header: Text(String(localized: "Preferences"))) { + Picker(String(localized: "Default Tracker"), selection: defaultTrackerBinding) { + Text(String(localized: "None")).tag(TrackerProvider?.none) + ForEach(TrackerProvider.allCases, id: \.self) { provider in + Text(provider.displayName).tag(TrackerProvider?.some(provider)) + } + } + .disabled(isSavingPreferences) + + NavigationLink(String(localized: "Libraries")) { + TrackerLibrarySelectionView( + libraries: libraries, + selectedIds: librarySelectionBinding + ) + } + } + } + + Section { + NavigationLink(value: NavDestination.settingsTrackerBindings) { + Label(String(localized: "Linked Series"), systemImage: "link") + } + NavigationLink(value: NavDestination.settingsTrackerLedger) { + Label(String(localized: "Recent Manual Updates"), systemImage: "clock.arrow.circlepath") + } + } + } + } + .formStyle(.grouped) + .platformNavigationTitle(String(localized: "Trackers")) + .task(id: current.instanceId) { + guard loadedInstanceId != current.instanceId else { return } + loadedInstanceId = current.instanceId + await load() + } + .refreshable { + await load() + } + } + + private var defaultTrackerBinding: Binding { + Binding( + get: { preferences?.defaultTracker }, + set: { value in + preferences?.defaultTracker = value + savePreferences() + } + ) + } + + private var librarySelectionBinding: Binding> { + Binding( + get: { Set(preferences?.libraries ?? []) }, + set: { value in + preferences?.libraries = value.sorted() + savePreferences() + } + ) + } + + private func load() async { + guard !isLoading else { return } + isLoading = true + defer { + isLoading = false + hasLoaded = true + } + do { + let loadedPreferences = try await TrackerService.getPreferences() + preferences = loadedPreferences + TrackerCapabilityStore.shared.updatePreferences(loadedPreferences) + libraries = try await DatabaseOperator.database().fetchLibraries( + instanceId: current.instanceId + ).filter { $0.id != KomgaLibrary.allLibrariesId } + try await loadAccounts() + TrackerCapabilityStore.shared.refreshCompleted(supported: true) + } catch { + ErrorManager.shared.alert(error: error) + } + } + + private func loadAccounts() async throws { + var loaded: [TrackerProvider: TrackerAccount] = [:] + for provider in TrackerProvider.allCases { + loaded[provider] = try await TrackerService.getAccount(provider: provider) + } + accounts = loaded + } + + private func logout(_ provider: TrackerProvider) { + guard pendingProvider == nil else { return } + pendingProvider = provider + Task { + do { + try await TrackerService.logout(provider: provider) + accounts[provider] = TrackerAccount(loggedIn: false, username: nil) + ErrorManager.shared.notify(message: String(localized: "Tracker disconnected.")) + } catch { + ErrorManager.shared.alert(error: error) + } + pendingProvider = nil + } + } + + private func savePreferences() { + guard let preferences, !isSavingPreferences else { return } + isSavingPreferences = true + Task { + do { + self.preferences = try await TrackerService.updatePreferences(preferences) + TrackerCapabilityStore.shared.updatePreferences(self.preferences ?? preferences) + ErrorManager.shared.notify(message: String(localized: "Tracker preferences saved.")) + } catch { + ErrorManager.shared.alert(error: error) + } + isSavingPreferences = false + } + } +} diff --git a/KMReader/Features/Tracker/Views/TrackerBindingsView.swift b/KMReader/Features/Tracker/Views/TrackerBindingsView.swift new file mode 100644 index 000000000..1633eb905 --- /dev/null +++ b/KMReader/Features/Tracker/Views/TrackerBindingsView.swift @@ -0,0 +1,110 @@ +// +// TrackerBindingsView.swift +// + +import SwiftUI + +struct TrackerBindingsView: View { + @State private var pagination = PaginationState(pageSize: 20) + @State private var provider: TrackerProvider? + @State private var counts: TrackerLinkCounts? + @State private var isLoading = false + @State private var loadTask: Task? + + var body: some View { + List { + Section { + Picker(String(localized: "Provider"), selection: $provider) { + Text(String(localized: "All")).tag(TrackerProvider?.none) + ForEach(TrackerProvider.allCases, id: \.self) { provider in + Text(provider.displayName).tag(TrackerProvider?.some(provider)) + } + } + .onChange(of: provider) { _, _ in + loadTask?.cancel() + loadTask = Task { await load(refresh: true) } + } + } + if pagination.isEmpty && !isLoading { + Section { + ContentUnavailableView { + Label(String(localized: "No Linked Series"), systemImage: "link") + } description: { + Text(String(localized: "Link a series from its detail page to sync reading progress.")) + } + } + } else { + Section { + ForEach(pagination.items) { group in + VStack(alignment: .leading, spacing: 8) { + SeriesQueryItemView(seriesId: group.seriesId, layout: .list) + ForEach(group.links) { link in + if let url = link.provider.entryURL(trackId: link.trackId) { + Link(destination: url) { + HStack { + Text(link.provider.displayName) + Spacer() + Text(link.displayTitle) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .font(.caption) + .contentShape(Rectangle()) + } + } + } + } + .onAppear { loadMoreIfNeeded(group) } + } + if isLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + } footer: { + if let counts { + Text(String.localizedStringWithFormat(String(localized: "%d linked series"), counts.series)) + } + } + } + } + .platformNavigationTitle(String(localized: "Linked Series")) + .task { + loadTask?.cancel() + await load(refresh: true) + } + .onDisappear { + loadTask?.cancel() + } + .refreshable { + await load(refresh: true) + } + } + + private func load(refresh: Bool) async { + if refresh { pagination.reset() } + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let response = try await TrackerService.getLinks( + page: pagination.currentPage, + size: pagination.pageSize, + provider: provider + ) + counts = response.counts + guard !Task.isCancelled else { return } + _ = pagination.applyPage(response.page.content) + pagination.advance(moreAvailable: !response.page.last) + } catch { + ErrorManager.shared.alert(error: error) + } + } + + private func loadMoreIfNeeded(_ group: TrackerLinkGroup) { + guard pagination.shouldLoadMore(after: group), !isLoading else { return } + Task { await load(refresh: false) } + } +} diff --git a/KMReader/Features/Tracker/Views/TrackerLedgerView.swift b/KMReader/Features/Tracker/Views/TrackerLedgerView.swift new file mode 100644 index 000000000..5437770cf --- /dev/null +++ b/KMReader/Features/Tracker/Views/TrackerLedgerView.swift @@ -0,0 +1,76 @@ +// +// TrackerLedgerView.swift +// + +import SwiftUI + +struct TrackerLedgerView: View { + @State private var pagination = PaginationState(pageSize: 20) + @State private var isLoading = false + + var body: some View { + List { + if pagination.isEmpty && !isLoading { + Section { + ContentUnavailableView { + Label(String(localized: "No Manual Updates"), systemImage: "clock.arrow.circlepath") + } description: { + Text(String(localized: "Tracker entries updated manually will appear here.")) + } + } + } else { + Section { + ForEach(pagination.items) { item in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(item.provider.displayName) + Spacer() + Text(item.updatedDate.formatted(.relative(presentation: .named))) + .font(.caption) + .foregroundStyle(.secondary) + } + if let url = item.url.flatMap(URL.init(string:)) { + Link(item.title ?? item.trackId, destination: url) + } else { + Text(item.title ?? item.trackId) + } + } + .onAppear { loadMoreIfNeeded(item) } + } + if isLoading { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + } + } + } + .platformNavigationTitle(String(localized: "Recent Manual Updates")) + .task { await load(refresh: true) } + .refreshable { await load(refresh: true) } + } + + private func load(refresh: Bool) async { + if refresh { pagination.reset() } + guard !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let page = try await TrackerService.getLedger( + page: pagination.currentPage, + size: pagination.pageSize + ) + _ = pagination.applyPage(page.content) + pagination.advance(moreAvailable: !page.last) + } catch { + ErrorManager.shared.alert(error: error) + } + } + + private func loadMoreIfNeeded(_ item: TrackerLedgerItem) { + guard pagination.shouldLoadMore(after: item), !isLoading else { return } + Task { await load(refresh: false) } + } +} diff --git a/KMReader/Features/Tracker/Views/TrackerLibrarySelectionView.swift b/KMReader/Features/Tracker/Views/TrackerLibrarySelectionView.swift new file mode 100644 index 000000000..bee15678a --- /dev/null +++ b/KMReader/Features/Tracker/Views/TrackerLibrarySelectionView.swift @@ -0,0 +1,44 @@ +// +// TrackerLibrarySelectionView.swift +// + +import SwiftUI + +struct TrackerLibrarySelectionView: View { + let libraries: [LibraryInfo] + @Binding var selectedIds: Set + + var body: some View { + List { + Section { + ForEach(libraries) { library in + Button { + toggle(library.id) + } label: { + HStack { + Text(library.name) + Spacer() + if selectedIds.contains(library.id) { + Image(systemName: "checkmark") + .foregroundStyle(.tint) + } + } + .contentShape(Rectangle()) + } + .adaptiveButtonStyle(.plain) + } + } footer: { + Text(String(localized: "With no libraries selected, tracker actions are available everywhere.")) + } + } + .platformNavigationTitle(String(localized: "Libraries")) + } + + private func toggle(_ id: String) { + if selectedIds.contains(id) { + selectedIds.remove(id) + } else { + selectedIds.insert(id) + } + } +} diff --git a/KMReader/Features/Tracker/Views/TrackerLinkEditSheet.swift b/KMReader/Features/Tracker/Views/TrackerLinkEditSheet.swift new file mode 100644 index 000000000..c4bc15331 --- /dev/null +++ b/KMReader/Features/Tracker/Views/TrackerLinkEditSheet.swift @@ -0,0 +1,287 @@ +// +// TrackerLinkEditSheet.swift +// + +import SwiftUI + +struct TrackerLinkEditSheet: View { + let seriesId: String + let seriesTitle: String + let link: TrackerLink + let onSaved: () async -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var selectedItem: TrackerSearchItem? + @State private var searchResults: [TrackerSearchItem] = [] + @State private var query: String + @State private var searchTask: Task? + @State private var remoteLoadTask: Task? + @State private var isSearching = false + @State private var mode: TrackerMode + @State private var chapterOffset: Int + @State private var remoteState: TrackerRemoteState? + @State private var status: TrackerStatus? + @State private var score = "" + @State private var chapter = "" + @State private var volume = "" + @State private var startDate = "" + @State private var finishDate = "" + @State private var isSaving = false + @State private var showDeleteConfirmation = false + + init( + seriesId: String, + seriesTitle: String, + link: TrackerLink, + onSaved: @escaping () async -> Void + ) { + self.seriesId = seriesId + self.seriesTitle = seriesTitle + self.link = link + self.onSaved = onSaved + _query = State(initialValue: link.title ?? seriesTitle) + _mode = State(initialValue: link.trackMode) + _chapterOffset = State(initialValue: link.chapterOffset) + _selectedItem = State( + initialValue: link.trackId.isEmpty + ? nil + : TrackerSearchItem( + id: link.trackId, + title: link.title ?? link.trackId, + coverUrl: nil, + description: nil, + tracked: true, + mediaType: nil, + url: nil + ) + ) + } + + var body: some View { + SheetView( + title: link.provider.displayName, + size: .large, + applyFormStyle: true + ) { + Form { + Section(header: Text(String(localized: "Tracker Entry"))) { + if let selectedItem { + HStack { + Text(selectedItem.title) + Spacer() + Button(String(localized: "Change")) { + self.selectedItem = nil + remoteState = nil + } + .font(.caption) + } + Text(selectedItem.id) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } else { + TextField(String(localized: "Search Title or URL"), text: $query) + .onChange(of: query) { _, _ in scheduleSearch() } + if isSearching { + ProgressView() + } + ForEach(searchResults) { result in + Button { + select(result) + } label: { + TrackerSearchResultRow(result: result) + .contentShape(Rectangle()) + } + .adaptiveButtonStyle(.plain) + } + } + } + + if selectedItem != nil { + Section(header: Text(String(localized: "Progress Mapping"))) { + Picker(String(localized: "Mode"), selection: $mode) { + ForEach(TrackerMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } + } + #if os(tvOS) + TextField( + String(localized: "Chapter Offset"), + value: $chapterOffset, + format: .number + ) + #else + Stepper(String(localized: "Chapter Offset"), value: $chapterOffset, in: -10000...10000) + #endif + } + + if remoteState != nil { + Section { + Picker(String(localized: "Status"), selection: $status) { + Text(String(localized: "Unchanged")).tag(TrackerStatus?.none) + ForEach(TrackerStatus.allCases, id: \.self) { status in + Text(status.displayName).tag(TrackerStatus?.some(status)) + } + } + TextField(String(localized: "Score"), text: $score) + TextField(String(localized: "Chapter"), text: $chapter) + TextField(String(localized: "Volume"), text: $volume) + TextField(String(localized: "Start Date (YYYY-MM-DD)"), text: $startDate) + TextField(String(localized: "Finish Date (YYYY-MM-DD)"), text: $finishDate) + } header: { + Text(String(localized: "Remote Progress")) + } footer: { + Text( + String( + localized: + "Only changed, nonempty fields are submitted. Automatic sync still follows the series' completed books." + )) + } + } + } + } + .disabled(isSaving) + } controls: { + if link.trackId.isEmpty == false { + Button(String(localized: "Unlink"), role: .destructive) { + showDeleteConfirmation = true + } + .disabled(isSaving) + } + Button(String(localized: "Save")) { save() } + .disabled(selectedItem == nil || isSaving) + } + .task(id: selectedItem?.id) { + remoteLoadTask?.cancel() + remoteLoadTask = Task { + await loadRemoteState() + } + } + .onDisappear { + searchTask?.cancel() + remoteLoadTask?.cancel() + } + .alert(String(localized: "Unlink Tracker"), isPresented: $showDeleteConfirmation) { + Button(String(localized: "Cancel"), role: .cancel) {} + Button(String(localized: "Unlink"), role: .destructive) { unlink() } + } message: { + Text(String(localized: "This removes the series binding but does not change the remote entry.")) + } + } + + private func scheduleSearch() { + searchTask?.cancel() + let text = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { + searchResults = [] + return + } + searchTask = Task { + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled else { return } + isSearching = true + defer { isSearching = false } + do { + searchResults = try await TrackerService.search(provider: link.provider, name: text) + } catch { + if !Task.isCancelled { + ErrorManager.shared.alert(error: error) + } + } + } + } + + private func select(_ result: TrackerSearchItem) { + selectedItem = result + searchResults = [] + query = result.title + } + + private func loadRemoteState() async { + guard let selectedItem else { return } + do { + let state = try await TrackerService.getState( + provider: link.provider, trackId: selectedItem.id) + guard !Task.isCancelled, self.selectedItem?.id == selectedItem.id else { return } + remoteState = state + status = state.status + score = state.score.map(String.init) ?? "" + chapter = + state.lastReadChapter.map { $0.truncatingRemainder(dividingBy: 1) == 0 ? String(Int($0)) : String($0) } ?? "" + volume = state.lastReadVolume.map(String.init) ?? "" + startDate = state.startReadDate ?? "" + finishDate = state.finishReadDate ?? "" + } catch { + ErrorManager.shared.alert(error: error) + } + } + + private func save() { + guard let selectedItem, !isSaving else { return } + isSaving = true + Task { + do { + _ = try await TrackerService.upsertLink( + seriesId: seriesId, + provider: link.provider, + trackId: selectedItem.id, + title: selectedItem.title, + mode: mode, + chapterOffset: chapterOffset + ) + let remoteChanges = changedRemoteFields() + if remoteState != nil, !remoteChanges.isEmpty { + try await TrackerService.updateState( + provider: link.provider, + trackId: selectedItem.id, + state: remoteChanges + ) + } + await onSaved() + ErrorManager.shared.notify(message: String(localized: "Tracker link saved.")) + dismiss() + } catch { + ErrorManager.shared.alert(error: error) + } + isSaving = false + } + } + + private func changedRemoteFields() -> [String: Any] { + var fields: [String: Any] = [:] + if let status, status != remoteState?.status { + fields["status"] = status.rawValue + } + if let score = Int(score), score != remoteState?.score { + fields["score"] = score + } + if let chapter = Double(chapter), chapter != remoteState?.lastReadChapter { + fields["lastReadChapter"] = chapter + } + if let volume = Int(volume), volume != remoteState?.lastReadVolume { + fields["lastReadVolume"] = volume + } + if !startDate.isEmpty, startDate != remoteState?.startReadDate { + fields["startReadDate"] = startDate + } + if !finishDate.isEmpty, finishDate != remoteState?.finishReadDate { + fields["finishReadDate"] = finishDate + } + return fields + } + + private func unlink() { + isSaving = true + Task { + do { + try await TrackerService.deleteLink(seriesId: seriesId, provider: link.provider) + await onSaved() + ErrorManager.shared.notify(message: String(localized: "Tracker unlinked.")) + dismiss() + } catch { + ErrorManager.shared.alert(error: error) + } + isSaving = false + } + } +} diff --git a/KMReader/Features/Tracker/Views/TrackerSearchResultRow.swift b/KMReader/Features/Tracker/Views/TrackerSearchResultRow.swift new file mode 100644 index 000000000..3bfffa87a --- /dev/null +++ b/KMReader/Features/Tracker/Views/TrackerSearchResultRow.swift @@ -0,0 +1,38 @@ +// +// TrackerSearchResultRow.swift +// + +import SwiftUI + +struct TrackerSearchResultRow: View { + let result: TrackerSearchItem + + var body: some View { + HStack(spacing: 12) { + AsyncImage(url: result.coverUrl.flatMap(URL.init(string:))) { phase in + switch phase { + case .success(let image): + image.resizable().aspectRatio(contentMode: .fill) + case .failure: + Image(systemName: "book.closed").foregroundStyle(.secondary) + default: + ProgressView() + } + } + .frame(width: 40, height: 60) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: 4)) + + VStack(alignment: .leading, spacing: 3) { + Text(result.title).lineLimit(2) + if let description = result.description, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + Spacer() + } + } +} diff --git a/KMReader/Features/Tracker/Views/TrackerSeriesSection.swift b/KMReader/Features/Tracker/Views/TrackerSeriesSection.swift new file mode 100644 index 000000000..5b22d6be0 --- /dev/null +++ b/KMReader/Features/Tracker/Views/TrackerSeriesSection.swift @@ -0,0 +1,71 @@ +// +// TrackerSeriesSection.swift +// + +import SwiftUI + +struct TrackerSeriesSection: View { + let seriesId: String + let libraryId: String + let seriesTitle: String + + @State private var links: [TrackerLink] = [] + @State private var showManage = false + + var body: some View { + #if os(iOS) || os(macOS) + if TrackerCapabilityStore.shared.isSupported, + TrackerCapabilityStore.shared.allows(libraryId: libraryId), + !links.isEmpty + { + VStack(alignment: .leading, spacing: 8) { + Text(String(localized: "Trackers")) + .font(.headline) + Button { + showManage = true + } label: { + HStack { + ForEach(links) { link in + Text(link.provider.displayName) + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(LayoutConfig.neutralFillColor, in: Capsule()) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .adaptiveButtonStyle(.plain) + } + .task(id: seriesId) { + await load() + } + .sheet( + isPresented: $showManage, + onDismiss: { + Task { await load() } + } + ) { + TrackerSeriesSheet( + seriesId: seriesId, + libraryId: libraryId, + seriesTitle: seriesTitle + ) + } + } + #endif + } + + private func load() async { + guard !AppConfig.isOffline else { return } + do { + links = try await TrackerService.getLinks(seriesId: seriesId) + } catch { + links = [] + } + } +} diff --git a/KMReader/Features/Tracker/Views/TrackerSeriesSheet.swift b/KMReader/Features/Tracker/Views/TrackerSeriesSheet.swift new file mode 100644 index 000000000..7b55c2a7b --- /dev/null +++ b/KMReader/Features/Tracker/Views/TrackerSeriesSheet.swift @@ -0,0 +1,155 @@ +// +// TrackerSeriesSheet.swift +// + +import SwiftUI + +struct TrackerSeriesSheet: View { + let seriesId: String + let libraryId: String + let seriesTitle: String + + @AppStorage("currentAccount") private var current: Current = .init() + @Environment(\.dismiss) private var dismiss + + @State private var links: [TrackerLink] = [] + @State private var accounts: [TrackerProvider: TrackerAccount] = [:] + @State private var preferences: TrackerPreferences? + @State private var isLoading = false + @State private var editingLink: TrackerLink? + + private var webURL: URL? { + URL(string: current.serverURL + "/account/trackers") + } + + private var allowsLibrary: Bool { + preferences?.libraries.isEmpty != false || preferences?.libraries.contains(libraryId) == true + } + + var body: some View { + SheetView(title: String(localized: "Trackers"), size: .large, applyFormStyle: true) { + Form { + if isLoading && links.isEmpty { + Section { + HStack { + Spacer() + ProgressView() + Spacer() + } + } + } else if !allowsLibrary { + Section { + ContentUnavailableView { + Label(String(localized: "Trackers Disabled for This Library"), systemImage: "link") + } description: { + Text(String(localized: "Enable this library in your tracker preferences to manage links.")) + } + } + } else { + Section { + if links.isEmpty { + Text(String(localized: "No linked trackers")) + .foregroundStyle(.secondary) + } + ForEach(links) { link in + Button { + editingLink = link + } label: { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(link.provider.displayName) + Text(link.displayTitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .adaptiveButtonStyle(.plain) + } + } header: { + Text(String(localized: "Linked Trackers")) + } + + Section { + ForEach(TrackerProvider.allCases, id: \.self) { provider in + if links.contains(where: { $0.provider == provider }) == false { + if accounts[provider]?.loggedIn == true { + Button { + editingLink = TrackerLink( + seriesId: seriesId, + provider: provider, + trackId: "", + title: nil, + trackMode: .auto, + chapterOffset: 0, + createdDate: "", + lastModifiedDate: "" + ) + } label: { + Label(provider.displayName, systemImage: "plus") + .contentShape(Rectangle()) + } + .adaptiveButtonStyle(.plain) + } else if let webURL { + Link(destination: webURL) { + Label( + String.localizedStringWithFormat( + String(localized: "Connect %@ on Web"), provider.displayName), + systemImage: "safari" + ) + .contentShape(Rectangle()) + } + } + } + } + } header: { + Text(String(localized: "Add Tracker")) + } footer: { + Text( + String( + localized: + "Completing books advances linked tracker entries on the server. Progress never moves backward when a book is marked unread." + )) + } + } + } + } controls: { + Button(String(localized: "Done")) { dismiss() } + } + .task { + await load() + } + .sheet(item: $editingLink) { link in + TrackerLinkEditSheet( + seriesId: seriesId, + seriesTitle: seriesTitle, + link: link + ) { + await load() + } + } + } + + private func load() async { + isLoading = true + defer { isLoading = false } + do { + async let linksRequest = TrackerService.getLinks(seriesId: seriesId) + async let preferencesRequest = TrackerService.getPreferences() + links = try await linksRequest + preferences = try await preferencesRequest + for provider in TrackerProvider.allCases { + accounts[provider] = try await TrackerService.getAccount(provider: provider) + } + TrackerCapabilityStore.shared.refreshCompleted(supported: true) + } catch { + ErrorManager.shared.alert(error: error) + } + } +} diff --git a/KMReader/Localizable.xcstrings b/KMReader/Localizable.xcstrings index 2ca4ea08e..48c84c0ea 100644 --- a/KMReader/Localizable.xcstrings +++ b/KMReader/Localizable.xcstrings @@ -364,6 +364,70 @@ }, "shouldTranslate" : false }, + "%d linked series" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d verknüpfte Serien" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d linked series" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d series enlazadas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d séries liées" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d serie collegate" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d 件のリンク済みシリーズ" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d개 연결된 시리즈" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d связанных серий" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d 个已关联系列" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d 個已連結系列" + } + } + } + }, "%lld" : { "localizations" : { "en" : { @@ -3725,6 +3789,70 @@ } } }, + "Add Tracker" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker hinzufügen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add Tracker" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Añadir seguidor" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter un suivi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi tracker" + } + }, + "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" : "新增追蹤器" + } + } + } + }, "Adjust the maximum size of the cover cache. Cache will be cleaned automatically when exceeded." : { "localizations" : { "de" : { @@ -12045,6 +12173,198 @@ } } }, + "Change" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ändern" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambiar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modifier" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cambia" + } + }, + "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" : "更改" + } + } + } + }, + "Chapter" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapitel" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chapter" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capítulo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chapitre" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capitolo" + } + }, + "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" : "章節" + } + } + } + }, + "Chapter Offset" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kapitelversatz" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chapter Offset" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desplazamiento de capítulo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Décalage de chapitre" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Offset capitolo" + } + }, + "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" : "章節偏移" + } + } + } + }, "Chapter Progress %@" : { "localizations" : { "de" : { @@ -14286,6 +14606,70 @@ } } }, + "Completing books advances linked tracker entries on the server. Progress never moves backward when a book is marked unread." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abgeschlossene Bücher aktualisieren verknüpfte Tracker auf dem Server. Als ungelesen markierte Bücher setzen den Fortschritt nicht zurück." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completing books advances linked tracker entries on the server. Progress never moves backward when a book is marked unread." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completar libros avanza las entradas enlazadas en el servidor. Marcar un libro como no leído nunca retrocede el progreso." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminer des livres fait avancer les suivis liés sur le serveur. Marquer un livre comme non lu ne fait jamais reculer la progression." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completare i libri fa avanzare i tracker collegati sul server. Contrassegnare un libro come non letto non fa mai retrocedere il progresso." + } + }, + "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" : "讀完書籍會推進伺服器上關聯的追蹤進度。將書籍標為未讀不會使進度後退。" + } + } + } + }, "Configure an LLM provider to enable translation. Page images never leave the device." : { "localizations" : { "de" : { @@ -14862,6 +15246,134 @@ } } }, + "Connect %@ on Web" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ im Web verbinden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect %@ on Web" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar %@ en la web" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecter %@ sur le Web" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connetti %@ sul Web" + } + }, + "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" : "在網頁連結 %@" + } + } + } + }, + "Connect on Web" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Im Web verbinden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect on Web" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectar en la web" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecter sur le Web" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connetti sul Web" + } + }, + "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" : "在網頁連結" + } + } + } + }, "Connect to a Server" : { "localizations" : { "de" : { @@ -14926,6 +15438,198 @@ } } }, + "Connect tracker accounts in the server web interface. KMReader never stores provider tokens." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbinde Tracker-Konten in der Weboberfläche des Servers. KMReader speichert keine Provider-Tokens." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect tracker accounts in the server web interface. KMReader never stores provider tokens." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conecta las cuentas de seguimiento en la interfaz web del servidor. KMReader nunca almacena tokens de proveedores." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connectez vos comptes de suivi dans l'interface Web du serveur. KMReader ne conserve jamais les jetons des fournisseurs." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collega gli account tracker nell'interfaccia web del server. KMReader non memorizza mai i token dei provider." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "サーバーのウェブ画面でトラッカーアカウントを接続します。KMReader はプロバイダーのトークンを保存しません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버 웹 인터페이스에서 트래커 계정을 연결하세요. KMReader는 제공업체 토큰을 저장하지 않습니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключайте аккаунты трекеров в веб-интерфейсе сервера. KMReader не хранит токены провайдеров." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在服务器网页界面连接追踪账户。KMReader 不会保存第三方令牌。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在伺服器網頁介面連結追蹤帳戶。KMReader 不會儲存第三方權杖。" + } + } + } + }, + "Connected" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connected" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecté" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connesso" + } + }, + "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" : "已連結" + } + } + } + }, + "Connected Accounts" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbundene Konten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connected Accounts" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cuentas conectadas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comptes connectés" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Account connessi" + } + }, + "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" : "已連結帳戶" + } + } + } + }, "Connection" : { "localizations" : { "de" : { @@ -19150,6 +19854,70 @@ } } }, + "Default Tracker" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard-Tracker" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Default Tracker" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguidor predeterminado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivi par défaut" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker predefinito" + } + }, + "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" : "預設追蹤器" + } + } + } + }, "Delete" : { "localizations" : { "de" : { @@ -21070,6 +21838,70 @@ } } }, + "Disconnect" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trennen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disconnect" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desconectar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Déconnecter" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disconnetti" + } + }, + "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" : "中斷連結" + } + } + } + }, "Discord" : { "localizations" : { "de" : { @@ -23054,6 +23886,70 @@ } } }, + "Dropped" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abgebrochen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dropped" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abandonado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abandonné" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbandonato" + } + }, + "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" : "已放棄" + } + } + } + }, "Dual Page Continuous" : { "localizations" : { "de" : { @@ -24528,6 +25424,70 @@ } } }, + "Enable this library in your tracker preferences to manage links." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktiviere diese Bibliothek in den Tracker-Einstellungen, um Verknüpfungen zu verwalten." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable this library in your tracker preferences to manage links." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activa esta biblioteca en tus preferencias de seguimiento para gestionar enlaces." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activez cette bibliothèque dans vos préférences de suivi pour gérer les liens." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abilita questa libreria nelle preferenze tracker per gestire i collegamenti." + } + }, + "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" : "在追蹤器偏好設定中啟用此書庫以管理連結。" + } + } + } + }, "Enable Translation" : { "localizations" : { "de" : { @@ -33424,6 +34384,70 @@ } } }, + "Finish Date (YYYY-MM-DD)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enddatum (JJJJ-MM-TT)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finish Date (YYYY-MM-DD)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fecha de finalización (AAAA-MM-DD)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date de fin (AAAA-MM-JJ)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data di fine (AAAA-MM-GG)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "終了日 (YYYY-MM-DD)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "완료일 (YYYY-MM-DD)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дата завершения (ГГГГ-ММ-ДД)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成日期 (YYYY-MM-DD)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成日期 (YYYY-MM-DD)" + } + } + } + }, "First" : { "localizations" : { "de" : { @@ -44508,6 +45532,198 @@ } } }, + "Link a series from its detail page to sync reading progress." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verknüpfe eine Serie auf ihrer Detailseite, um den Lesefortschritt zu synchronisieren." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Link a series from its detail page to sync reading progress." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enlaza una serie desde su página de detalles para sincronizar el progreso de lectura." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Liez une série depuis sa page de détail pour synchroniser votre progression." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collega una serie dalla sua pagina dei dettagli per sincronizzare i progressi di lettura." + } + }, + "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" : "在系列詳情頁連結以同步閱讀進度。" + } + } + } + }, + "Linked Series" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verknüpfte Serien" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linked Series" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Series enlazadas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Séries liées" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serie collegate" + } + }, + "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" : "已連結系列" + } + } + } + }, + "Linked Trackers" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verknüpfte Tracker" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Linked Trackers" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguidores enlazados" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivis liés" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker collegati" + } + }, + "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" : "已連結追蹤器" + } + } + } + }, "Links" : { "localizations" : { "de" : { @@ -49244,6 +50460,70 @@ } } }, + "Mode" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modus" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mode" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mode" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modalità" + } + }, + "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" : "模式" + } + } + } + }, "Model" : { "localizations" : { "de" : { @@ -51356,6 +52636,198 @@ } } }, + "No Linked Series" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine verknüpften Serien" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Linked Series" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay series enlazadas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune série liée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessuna serie collegata" + } + }, + "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 linked trackers" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine verknüpften Tracker" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No linked trackers" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay seguidores enlazados" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun suivi lié" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun tracker collegato" + } + }, + "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 Manual Updates" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine manuellen Updates" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Manual Updates" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No hay actualizaciones manuales" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucune mise à jour manuelle" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessun aggiornamento manuale" + } + }, + "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 matches found" : { "localizations" : { "de" : { @@ -53020,6 +54492,70 @@ } } }, + "None" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "None" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ninguno" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessuno" + } + }, + "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" : "無" + } + } + } + }, "Not checked yet" : { "localizations" : { "de" : { @@ -60828,6 +62364,70 @@ } } }, + "Only changed, nonempty fields are submitted. Automatic sync still follows the series' completed books." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur geänderte, nicht leere Felder werden übertragen. Die automatische Synchronisierung folgt weiterhin den abgeschlossenen Büchern der Serie." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Only changed, nonempty fields are submitted. Automatic sync still follows the series' completed books." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Solo se envían los campos modificados y no vacíos. La sincronización automática sigue los libros completados de la serie." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seuls les champs modifiés et non vides sont envoyés. La synchronisation automatique suit toujours les livres terminés de la série." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vengono inviati solo i campi modificati e non vuoti. La sincronizzazione automatica segue comunque i libri completati della serie." + } + }, + "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" : "僅提交已修改的非空欄位。自動同步仍按系列中已讀完的書籍進行。" + } + } + } + }, "Only OCR text is sent to the provider; page images are never uploaded." : { "localizations" : { "de" : { @@ -63390,6 +64990,70 @@ } } }, + "Planning" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geplant" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planning" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Planificado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prévu" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pianificato" + } + }, + "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" : "計劃中" + } + } + } + }, "Please copy your API key now. It will not be shown again." : { "localizations" : { "de" : { @@ -63646,6 +65310,70 @@ } } }, + "Preferences" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preferences" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preferencias" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Préférences" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preferenze" + } + }, + "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" : "偏好設定" + } + } + } + }, "Preferred Direction" : { "localizations" : { "de" : { @@ -64478,6 +66206,70 @@ } } }, + "Progress Mapping" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortschrittszuordnung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Progress Mapping" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Asignación de progreso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Correspondance de progression" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mappatura progresso" + } + }, + "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" : "進度映射" + } + } + } + }, "Protected" : { "localizations" : { "de" : { @@ -64734,6 +66526,70 @@ } } }, + "Provider" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anbieter" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provider" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proveedor" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fournisseur" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provider" + } + }, + "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" : "平台" + } + } + } + }, "Provider Links" : { "localizations" : { "de" : { @@ -68446,6 +70302,70 @@ } } }, + "Reading" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lesend" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reading" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leyendo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "En cours" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "In lettura" + } + }, + "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" : "閱讀中" + } + } + } + }, "Reading & Language" : { "localizations" : { "de" : { @@ -70046,6 +71966,70 @@ } } }, + "Recent Manual Updates" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Letzte manuelle Updates" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recent Manual Updates" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actualizaciones manuales recientes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mises à jour manuelles récentes" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiornamenti manuali recenti" + } + }, + "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" : "最近手動更新" + } + } + } + }, "Recognition" : { "localizations" : { "de" : { @@ -71006,6 +72990,70 @@ } } }, + "Remote Progress" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remote-Fortschritt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remote Progress" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Progreso remoto" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Progression distante" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Progresso remoto" + } + }, + "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" : "遠端進度" + } + } + } + }, "Remove All" : { "localizations" : { "de" : { @@ -71646,6 +73694,70 @@ } } }, + "Rereading" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erneut lesend" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rereading" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Releyendo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relecture" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rilettura" + } + }, + "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" : "重讀中" + } + } + } + }, "Reset" : { "localizations" : { "de" : { @@ -73822,6 +75934,70 @@ } } }, + "Score" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bewertung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Score" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puntuación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Note" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Punteggio" + } + }, + "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" : "評分" + } + } + } + }, "Screen" : { "localizations" : { "de" : { @@ -74334,6 +76510,70 @@ } } }, + "Search Title or URL" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel oder URL suchen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search Title or URL" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Buscar título o URL" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rechercher un titre ou une URL" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerca titolo o URL" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "タイトルまたは URL を検索" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제목 또는 URL 검색" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Поиск по названию или URL" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索标题或链接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜尋標題或連結" + } + } + } + }, "search.empty.hint" : { "localizations" : { "de" : { @@ -87585,6 +89825,70 @@ } } }, + "Start Date (YYYY-MM-DD)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Startdatum (JJJJ-MM-TT)" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Date (YYYY-MM-DD)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fecha de inicio (AAAA-MM-DD)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Date de début (AAAA-MM-JJ)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data di inizio (AAAA-MM-GG)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "開始日 (YYYY-MM-DD)" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "시작일 (YYYY-MM-DD)" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дата начала (ГГГГ-ММ-ДД)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始日期 (YYYY-MM-DD)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "開始日期 (YYYY-MM-DD)" + } + } + } + }, "Start Reading" : { "localizations" : { "de" : { @@ -91362,6 +93666,70 @@ } } }, + "This removes the series binding but does not change the remote entry." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dies entfernt die Serienverknüpfung, ändert aber den Remote-Eintrag nicht." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This removes the series binding but does not change the remote entry." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Esto elimina el enlace de la serie, pero no cambia la entrada remota." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cela supprime le lien de la série sans modifier l'entrée distante." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questo rimuove il collegamento della serie senza modificare la voce remota." + } + }, + "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" : "這會解除系列連結,但不會更改遠端項目。" + } + } + } + }, "This server does not support managing your sessions." : { "localizations" : { "de" : { @@ -91426,6 +93794,70 @@ } } }, + "This server does not support reading trackers, or it could not be reached." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Server unterstützt keine Tracker oder ist nicht erreichbar." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This server does not support reading trackers, or it could not be reached." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este servidor no admite seguidores de lectura o no está disponible." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ce serveur ne prend pas en charge les suivis de lecture ou est injoignable." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Questo server non supporta i tracker di lettura o non è raggiungibile." + } + }, + "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" : "此伺服器不支援閱讀追蹤,或目前無法連線。" + } + } + } + }, "This server has not been initialized yet. Create the first administrator account to get started." : { "localizations" : { "de" : { @@ -93346,6 +95778,582 @@ } } }, + "Tracker disconnected." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker getrennt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker disconnected." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguidor desconectado." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivi déconnecté." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker disconnesso." + } + }, + "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" : "追蹤器已中斷連結。" + } + } + } + }, + "Tracker entries updated manually will appear here." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Manuell aktualisierte Tracker-Einträge erscheinen hier." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker entries updated manually will appear here." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las entradas actualizadas manualmente aparecerán aquí." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les entrées mises à jour manuellement apparaîtront ici." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le voci tracker aggiornate manualmente appariranno qui." + } + }, + "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" : "手動更新的追蹤項目會顯示在這裡。" + } + } + } + }, + "Tracker Entry" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker-Eintrag" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker Entry" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrada del seguidor" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entrée du suivi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voce tracker" + } + }, + "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" : "追蹤項目" + } + } + } + }, + "Tracker link saved." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker-Verknüpfung gespeichert." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker link saved." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enlace del seguidor guardado." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lien du suivi enregistré." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collegamento tracker salvato." + } + }, + "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" : "追蹤器連結已儲存。" + } + } + } + }, + "Tracker preferences saved." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker-Einstellungen gespeichert." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker preferences saved." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preferencias del seguidor guardadas." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Préférences du suivi enregistrées." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preferenze tracker salvate." + } + }, + "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" : "追蹤器偏好設定已儲存。" + } + } + } + }, + "Tracker unlinked." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker-Verknüpfung entfernt." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker unlinked." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguidor desenlazado." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivi dissocié." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker scollegato." + } + }, + "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" : "追蹤器已解除連結。" + } + } + } + }, + "Trackers" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trackers" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguidores" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivis" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker" + } + }, + "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" : "追蹤器" + } + } + } + }, + "Trackers Disabled for This Library" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker für diese Bibliothek deaktiviert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trackers Disabled for This Library" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguidores desactivados para esta biblioteca" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivis désactivés pour cette bibliothèque" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker disabilitati per questa libreria" + } + }, + "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" : "此書庫已停用追蹤器" + } + } + } + }, + "Trackers Unavailable" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker nicht verfügbar" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trackers Unavailable" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seguidores no disponibles" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivis indisponibles" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker non disponibili" + } + }, + "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" : "追蹤器不可用" + } + } + } + }, "Transaction verification failed" : { "localizations" : { "de" : { @@ -94306,6 +97314,70 @@ } } }, + "Unchanged" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unverändert" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unchanged" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin cambios" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inchangé" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invariato" + } + }, + "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" : "不更改" + } + } + } + }, "Undo" : { "localizations" : { "de" : { @@ -94498,6 +97570,134 @@ } } }, + "Unlink" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verknüpfung entfernen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlink" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desenlazar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dissocier" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scollega" + } + }, + "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" : "解除連結" + } + } + } + }, + "Unlink Tracker" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tracker-Verknüpfung entfernen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlink Tracker" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Desenlazar seguidor" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dissocier le suivi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scollega tracker" + } + }, + "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" : "解除追蹤器連結" + } + } + } + }, "Unread" : { "localizations" : { "de" : { @@ -96930,6 +100130,70 @@ } } }, + "Volume" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Band" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volume" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volumen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volume" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Volume" + } + }, + "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" : "卷" + } + } + } + }, "Waifu2x Mode" : { "localizations" : { "de" : { @@ -97634,6 +100898,70 @@ } } }, + "With no libraries selected, tracker actions are available everywhere." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ohne ausgewählte Bibliotheken sind Tracker-Aktionen überall verfügbar." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "With no libraries selected, tracker actions are available everywhere." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin bibliotecas seleccionadas, las acciones de seguimiento están disponibles en todas partes." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sans bibliothèque sélectionnée, les actions de suivi sont disponibles partout." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Senza librerie selezionate, le azioni tracker sono disponibili ovunque." + } + }, + "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" : "未選擇書庫時,所有書庫都可使用追蹤操作。" + } + } + } + }, "Word Spacing" : { "localizations" : { "de" : { diff --git a/KMReader/Shared/Foundation/Models/NavDestination.swift b/KMReader/Shared/Foundation/Models/NavDestination.swift index 88b281e02..914abcaed 100644 --- a/KMReader/Shared/Foundation/Models/NavDestination.swift +++ b/KMReader/Shared/Foundation/Models/NavDestination.swift @@ -84,6 +84,9 @@ enum NavDestination: Hashable { case settingsServers case settingsApiKey case settingsAccountSessions + case settingsTrackers + case settingsTrackerBindings + case settingsTrackerLedger case settingsAuthenticationActivity case settingsAccount case settingsAbout @@ -304,6 +307,12 @@ enum NavDestination: Hashable { ApiKeysView() case .settingsAccountSessions: ServerSessionsView(scope: .own) + case .settingsTrackers: + TrackerAccountsView() + case .settingsTrackerBindings: + TrackerBindingsView() + case .settingsTrackerLedger: + TrackerLedgerView() case .settingsAuthenticationActivity: AccountActivityView() case .settingsAccount: