diff --git a/app/README.md b/app/README.md index ca2c5fe0..50902245 100644 --- a/app/README.md +++ b/app/README.md @@ -35,3 +35,48 @@ npm run tauri ios dev # iOS simulator (needs Xcode) npm run tauri android init && npm run tauri android dev npm run tauri build # release desktop bundle ``` + +## iOS Shortcuts (iOS 16+) + +The iOS build provides three read-only actions in Shortcuts under **CrossPoint Sync**: + +- **Find Books**: optional reading-status and title/author filters. Returns books newest sync first. +- **Get Book**: select a book, or pass one from Find Books, to get its current saved details. +- **Find Clippings**: optional book, text (passage/note/chapter), and creation-date filters. Returns newest first. + +Book results expose Title, Author, Reading Status, Progress (%) (0–100), Last Sync, +and Cached At. Clippings expose Text, Note, Chapter, Created At, Book, and Cached At. +For example: Find Books with Reading Status = Reading → Repeat with Each → get +Title, Progress (%) and Last Sync → append to a note. Use status Finished for a +finished-book list. Find Clippings → get Text can feed a quote collection. + +Actions read the app's existing offline data through a credential-free snapshot in +the main app’s private Application Support storage. They do not contact the server +or require the app's UI to be open. Open the Library to save books; open Clippings to save the all-book list, +or an individual book to save its clippings. Only data the app has loaded is +available. The all-book clippings endpoint currently returns at most 5,000 items; +opening a book also saves its complete paginated clipping list. Unsynced reader +data cannot appear until the reader uploads it and the app refreshes. + +**Last Sync** is the server's latest recorded progress update. **Cached At** is when +the app fetched the data; offline reads and restarts preserve it. Older caches may +have an unknown Cached At until refreshed online. Clippings can have different +cache times because books can refresh separately. Signing out clears the shared +snapshot; saved Shortcuts book selections are scoped to their server and account. +An uncached collection gives instructions to open the relevant app screen, while a +cached empty collection returns an empty list. + +Native store checks (no simulator needed): + +```sh +swiftc -module-cache-path /tmp/crosspoint-swift-cache \ + src-tauri/gen/apple/Sources/crosspoint-sync-app/ShortcutsStore.swift \ + scripts/shortcuts-tests/main.swift -o /tmp/crosspoint-shortcuts-tests +/tmp/crosspoint-shortcuts-tests +``` + +Before release, build/install using Xcode and verify all three actions appear in +Shortcuts. Load the Library and Clippings online, turn on airplane mode, close the +app and run each action; check the returned fields and unchanged Cached At. Sign +out and confirm actions no longer return the previous account's data. Sign into a +different account and verify a previously selected book cannot resolve to it. diff --git a/app/scripts/shortcuts-tests/main.swift b/app/scripts/shortcuts-tests/main.swift new file mode 100644 index 00000000..7a2bc5c7 --- /dev/null +++ b/app/scripts/shortcuts-tests/main.swift @@ -0,0 +1,31 @@ +import Foundation + +func check(_ condition: @autoclosure () throws -> Bool, _ message: String) rethrows { + if try !condition() { fatalError(message) } +} +let json = #"{"account":"account-a","books":{"savedAt":200000,"items":[{"document":"a","title":"One","author":"Writer","status":"finished","percentage":1,"timestamp":100},{"document":"b","title":"Two","author":"Other","status":"reading","percentage":0.4,"timestamp":110}]},"clippings":{"items":[{"document":"a","id":"1","text":"A passage","note":"Remember","chapter":"Chapter 1","created_at":50,"cachedAt":200000},{"document":"b","id":"1","text":"Another passage","created_at":75,"cachedAt":210000}]}}"# +let store = try ShortcutsSnapshot.decode(Data(json.utf8)) +try check(try store.findBooks(status: "finished", text: nil).map(\.document) == ["a"], "status filter") +try check(try store.findBooks(status: nil, text: "writer").map(\.document) == ["a"], "author search") +try check(try store.findBooks(status: nil, text: nil).map(\.document) == ["b", "a"], "last-sync ordering") +try check(try store.findClippings(document: "a", text: "remember", since: Date(timeIntervalSince1970: 40)).map(\.id) == ["1"], "clipping filters") +try check(try store.findClippings(document: nil, text: nil, since: Date(timeIntervalSince1970: 60)).map(\.document) == ["b"], "date filter") +check(store.bookID("a") != store.clippingID(document: "a", id: "1"), "entity IDs") +var other = store +other.account = "account-b" +check(store.bookID("a") != other.bookID("a"), "account isolation") +check(store.books?.cachedAt == Date(timeIntervalSince1970: 200), "millisecond cache timestamp") +let empty = try ShortcutsSnapshot.decode(Data(#"{"account":"a","books":{"savedAt":null,"items":[]},"clippings":null}"#.utf8)) +try check(try empty.findBooks(status: nil, text: nil).isEmpty, "cached empty library") +do { + _ = try empty.findClippings(document: nil, text: nil, since: nil) + fatalError("missing clippings should give an actionable error") +} catch { check(error.localizedDescription.contains("Clippings"), "missing data guidance") } +let privateRoot = URL(fileURLWithPath: "/sandbox/Library/Application Support") +check(ShortcutsSnapshot.cacheDirectory(in: privateRoot, bundleIdentifier: "com.crosspointreader.sync").path == "/sandbox/Library/Application Support/com.crosspointreader.sync/Shortcuts", "private cache path") +let testDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) +try FileManager.default.createDirectory(at: testDir, withIntermediateDirectories: true) +defer { try? FileManager.default.removeItem(at: testDir) } +try Data(json.utf8).write(to: testDir.appendingPathComponent("shortcuts.json")) +try check(try ShortcutsSnapshot.load(from: testDir).account == "account-a", "load private cache without App Group") +print("Shortcuts store: 12 checks passed") diff --git a/app/src-tauri/gen/apple/Sources/crosspoint-sync-app/ReadingIntents.swift b/app/src-tauri/gen/apple/Sources/crosspoint-sync-app/ReadingIntents.swift new file mode 100644 index 00000000..e99d5157 --- /dev/null +++ b/app/src-tauri/gen/apple/Sources/crosspoint-sync-app/ReadingIntents.swift @@ -0,0 +1,153 @@ +import AppIntents +import Foundation + +// Keep the iOS 16 floor. These actions run locally without opening the WebView. +@available(iOS 16.0, macOS 13.0, *) +enum ReadingStatus: String, AppEnum { + case reading, paused, finished, dnf + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Reading Status" + static var caseDisplayRepresentations: [ReadingStatus: DisplayRepresentation] = [ + .reading: "Reading", .paused: "Paused", .finished: "Finished", .dnf: "Did Not Finish", + ] +} + +@available(iOS 16.0, macOS 13.0, *) +struct BookEntity: AppEntity { + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Book" + static var defaultQuery = BookQuery() + var id: String + @Property(title: "Title") var title: String + @Property(title: "Author") var author: String + @Property(title: "Reading Status") var status: ReadingStatus? + @Property(title: "Progress (%)") var progress: Double + @Property(title: "Last Sync") var lastSync: Date? + @Property(title: "Cached At") var cachedAt: Date? + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(title)", subtitle: "\(author)") + } + + init(_ book: CachedBook, in snapshot: ShortcutsSnapshot) { + id = snapshot.bookID(book.document) + title = book.title + author = book.author ?? "" + status = ReadingStatus(rawValue: book.status) + progress = min(100, max(0, book.percentage * 100)) + lastSync = book.timestamp.map { Date(timeIntervalSince1970: $0) } + cachedAt = snapshot.books?.cachedAt + } +} + +@available(iOS 16.0, macOS 13.0, *) +struct BookQuery: EntityStringQuery { + func entities(for identifiers: [String]) async throws -> [BookEntity] { + let snapshot = try ShortcutsSnapshot.load() + let books = try snapshot.findBooks(status: nil, text: nil).map { BookEntity($0, in: snapshot) } + // Never reconstruct an entity from the identifier alone: it may belong to an old account. + return identifiers.compactMap { id in books.first { $0.id == id } } + } + func entities(matching string: String) async throws -> [BookEntity] { + let snapshot = try ShortcutsSnapshot.load() + return try snapshot.findBooks(status: nil, text: string).map { BookEntity($0, in: snapshot) } + } + func suggestedEntities() async throws -> [BookEntity] { + let snapshot = try ShortcutsSnapshot.load() + return try snapshot.findBooks(status: nil, text: nil).map { BookEntity($0, in: snapshot) } + } +} + +@available(iOS 16.0, macOS 13.0, *) +struct ClippingEntity: AppEntity { + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Clipping" + static var defaultQuery = ClippingQuery() + var id: String + @Property(title: "Text") var text: String + @Property(title: "Note") var note: String + @Property(title: "Chapter") var chapter: String + @Property(title: "Created At") var createdAt: Date + @Property(title: "Book") var book: BookEntity? + @Property(title: "Cached At") var cachedAt: Date? + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(String(text.prefix(100)))", subtitle: "\(book?.title ?? "")") + } + + init(_ clipping: CachedClipping, in snapshot: ShortcutsSnapshot) { + id = snapshot.clippingID(document: clipping.document, id: clipping.id) + text = clipping.text + note = clipping.note ?? "" + chapter = clipping.chapter ?? "" + createdAt = Date(timeIntervalSince1970: clipping.created_at) + book = snapshot.books?.items.first { $0.document == clipping.document }.map { BookEntity($0, in: snapshot) } + cachedAt = clipping.cachedAt.map { Date(timeIntervalSince1970: $0 / 1000) } + } +} + +@available(iOS 16.0, macOS 13.0, *) +struct ClippingQuery: EntityStringQuery { + func entities(for identifiers: [String]) async throws -> [ClippingEntity] { + let snapshot = try ShortcutsSnapshot.load() + let clips = try snapshot.findClippings(document: nil, text: nil, since: nil).map { ClippingEntity($0, in: snapshot) } + return identifiers.compactMap { id in clips.first { $0.id == id } } + } + func entities(matching string: String) async throws -> [ClippingEntity] { + let snapshot = try ShortcutsSnapshot.load() + return try snapshot.findClippings(document: nil, text: string, since: nil).map { ClippingEntity($0, in: snapshot) } + } + func suggestedEntities() async throws -> [ClippingEntity] { + let snapshot = try ShortcutsSnapshot.load() + return try snapshot.findClippings(document: nil, text: nil, since: nil).prefix(20).map { ClippingEntity($0, in: snapshot) } + } +} + +@available(iOS 16.0, macOS 13.0, *) +struct FindBooksIntent: AppIntent { + static var title: LocalizedStringResource = "Find Books" + static var description = IntentDescription("Find books in your saved CrossPoint Sync library, even offline. Results are ordered by last sync, newest first. Open the app to refresh the cache.") + static var openAppWhenRun = false + @Parameter(title: "Reading Status") var status: ReadingStatus? + @Parameter(title: "Title or Author Contains") var text: String? + static var parameterSummary: some ParameterSummary { + Summary("Find books") { \.$status; \.$text } + } + func perform() async throws -> some IntentResult & ReturnsValue<[BookEntity]> { + let snapshot = try ShortcutsSnapshot.load() + return .result(value: try snapshot.findBooks(status: status?.rawValue, text: text).map { BookEntity($0, in: snapshot) }) + } +} + +@available(iOS 16.0, macOS 13.0, *) +struct GetBookIntent: AppIntent { + static var title: LocalizedStringResource = "Get Book" + static var description = IntentDescription("Get a book's saved progress, last sync and cache time without a network request.") + static var openAppWhenRun = false + @Parameter(title: "Book") var book: BookEntity + static var parameterSummary: some ParameterSummary { Summary("Get \(\.$book)") } + func perform() async throws -> some IntentResult & ReturnsValue { + let snapshot = try ShortcutsSnapshot.load() + guard let current = try snapshot.findBooks(status: nil, text: nil).first(where: { snapshot.bookID($0.document) == book.id }) + else { throw ShortcutsDataError.bookMissing } + return .result(value: BookEntity(current, in: snapshot)) + } +} + +@available(iOS 16.0, macOS 13.0, *) +struct FindClippingsIntent: AppIntent { + static var title: LocalizedStringResource = "Find Clippings" + static var description = IntentDescription("Find saved clippings by book, passage, note, chapter or creation date. Open Clippings or individual books in the app to save passages. The all-book list is limited to the newest 5,000. Results are newest first.") + static var openAppWhenRun = false + @Parameter(title: "Book") var book: BookEntity? + @Parameter(title: "Text Contains") var text: String? + @Parameter(title: "Created On or After") var since: Date? + static var parameterSummary: some ParameterSummary { + Summary("Find clippings") { \.$book; \.$text; \.$since } + } + func perform() async throws -> some IntentResult & ReturnsValue<[ClippingEntity]> { + let snapshot = try ShortcutsSnapshot.load() + var document: String? + if let book { + guard let current = try snapshot.findBooks(status: nil, text: nil).first(where: { snapshot.bookID($0.document) == book.id }) + else { throw ShortcutsDataError.bookMissing } + document = current.document + } + return .result(value: try snapshot.findClippings(document: document, text: text, since: since).map { ClippingEntity($0, in: snapshot) }) + } +} diff --git a/app/src-tauri/gen/apple/Sources/crosspoint-sync-app/ShortcutsStore.swift b/app/src-tauri/gen/apple/Sources/crosspoint-sync-app/ShortcutsStore.swift new file mode 100644 index 00000000..2f5ddb88 --- /dev/null +++ b/app/src-tauri/gen/apple/Sources/crosspoint-sync-app/ShortcutsStore.swift @@ -0,0 +1,110 @@ +import Foundation + +// Schema written by api.js from the existing offline cache. Dates from the +// server are seconds; savedAt/cachedAt are JavaScript milliseconds. +struct CachedBook: Decodable { + let document: String + let title: String + let author: String? + let status: String + let percentage: Double + let timestamp: Double? +} + +struct CachedClipping: Decodable { + let document: String + let id: String + let text: String + let note: String? + let chapter: String? + let created_at: Double + let cachedAt: Double? +} + +struct SavedBooks: Decodable { + let savedAt: Double? + let items: [CachedBook] + var cachedAt: Date? { savedAt.map { Date(timeIntervalSince1970: $0 / 1000) } } +} + +struct SavedClippings: Decodable { + let items: [CachedClipping] +} + +enum ShortcutsDataError: LocalizedError { + case unavailable, booksMissing, clippingsMissing, bookMissing + var errorDescription: String? { + switch self { + case .unavailable: return "Open CrossPoint Sync and sign in to make your offline data available to Shortcuts." + case .booksMissing: return "Open the Library in CrossPoint Sync to save books for offline Shortcuts." + case .clippingsMissing: return "Open Clippings in CrossPoint Sync to save clippings for offline Shortcuts." + case .bookMissing: return "This book is no longer in the saved library. Select it again in Shortcuts." + } + } +} + +struct ShortcutsSnapshot: Decodable { + var account: String + let books: SavedBooks? + let clippings: SavedClippings? + + static func decode(_ data: Data) throws -> Self { + try JSONDecoder().decode(Self.self, from: data) + } + + // Matches Tauri's app_data_dir(): Application Support/. + // Intents live in the main app target, so no shared App Group is needed. + static func cacheDirectory(in support: URL, bundleIdentifier: String) -> URL { + support.appendingPathComponent(bundleIdentifier, isDirectory: true) + .appendingPathComponent("Shortcuts", isDirectory: true) + } + + static func load(from directory: URL? = nil) throws -> Self { + let dir: URL + if let directory { + dir = directory + } else { + guard let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first, + let identifier = Bundle.main.bundleIdentifier + else { throw ShortcutsDataError.unavailable } + dir = cacheDirectory(in: support, bundleIdentifier: identifier) + } + do { + return try decode(Data(contentsOf: dir.appendingPathComponent("shortcuts.json"))) + } catch { throw ShortcutsDataError.unavailable } + } + + func bookID(_ document: String) -> String { "\(account):book:\(document)" } + func clippingID(document: String, id: String) -> String { "\(account):clipping:\(document):\(id)" } + + func findBooks(status: String?, text: String?) throws -> [CachedBook] { + guard let books else { throw ShortcutsDataError.booksMissing } + return books.items.filter { book in + (status == nil || book.status == status) && matches(text, [book.title, book.author ?? ""]) + }.sorted { + if $0.timestamp == $1.timestamp { return $0.document < $1.document } + return ($0.timestamp ?? 0) > ($1.timestamp ?? 0) + } + } + + func findClippings(document: String?, text: String?, since: Date?) throws -> [CachedClipping] { + guard let clippings else { throw ShortcutsDataError.clippingsMissing } + let visible = Set(try findBooks(status: nil, text: nil).map(\.document)) + return clippings.items.filter { clipping in + visible.contains(clipping.document) && + (document == nil || clipping.document == document) && + (since == nil || clipping.created_at >= since!.timeIntervalSince1970) && + matches(text, [clipping.text, clipping.note ?? "", clipping.chapter ?? ""]) + }.sorted { + if $0.created_at == $1.created_at { + return clippingID(document: $0.document, id: $0.id) < clippingID(document: $1.document, id: $1.id) + } + return $0.created_at > $1.created_at + } + } + + private func matches(_ text: String?, _ fields: [String]) -> Bool { + guard let text, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return true } + return fields.contains { $0.localizedStandardContains(text) } + } +} diff --git a/app/src-tauri/gen/apple/crosspoint-sync-app.xcodeproj/project.pbxproj b/app/src-tauri/gen/apple/crosspoint-sync-app.xcodeproj/project.pbxproj index 61ff2c62..3d77975b 100644 --- a/app/src-tauri/gen/apple/crosspoint-sync-app.xcodeproj/project.pbxproj +++ b/app/src-tauri/gen/apple/crosspoint-sync-app.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + A65E756C12B6408AA9100005 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A65E756C12B6408AA9100006 /* AppIntents.framework */; }; + A65E756C12B6408AA9100003 /* ReadingIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65E756C12B6408AA9100004 /* ReadingIntents.swift */; }; + A65E756C12B6408AA9100001 /* ShortcutsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65E756C12B6408AA9100002 /* ShortcutsStore.swift */; }; 05558E78087CE893FD133EED /* WidgetReload.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FDA312FF333FEE325F3784 /* WidgetReload.swift */; }; 07E120DC37D4E3BA35660834 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1C1D61425D6C89B023A68699 /* WebKit.framework */; }; 0D22C69FE6169D6661235690 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BECC0ABE484B2047607C4F0A /* SwiftUI.framework */; }; @@ -53,6 +56,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + A65E756C12B6408AA9100006 /* AppIntents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; }; + A65E756C12B6408AA9100004 /* ReadingIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingIntents.swift; sourceTree = ""; }; + A65E756C12B6408AA9100002 /* ShortcutsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsStore.swift; sourceTree = ""; }; 06400398C81B2BCE0778BAED /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; }; 0ADABAFF8038D69A13E988F1 /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = ""; }; 1602AFD916563BCBF295CAFA /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; }; @@ -113,6 +119,7 @@ CEA14FCBC23E088D806332E2 /* UIKit.framework in Frameworks */, 07E120DC37D4E3BA35660834 /* WebKit.framework in Frameworks */, E952F1320553223057A59239 /* WidgetKit.framework in Frameworks */, + A65E756C12B6408AA9100005 /* AppIntents.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -156,6 +163,8 @@ children = ( 917F15BCA5CFDD43F04EE308 /* main.mm */, C0FDA312FF333FEE325F3784 /* WidgetReload.swift */, + A65E756C12B6408AA9100004 /* ReadingIntents.swift */, + A65E756C12B6408AA9100002 /* ShortcutsStore.swift */, 967C288423B4F5D0AD41A6A9 /* bindings */, ); path = "crosspoint-sync-app"; @@ -184,6 +193,7 @@ BEF7345547BE54CBD0CAA946 /* UIKit.framework */, 1C1D61425D6C89B023A68699 /* WebKit.framework */, FE54F751F09F6D338C764211 /* WidgetKit.framework */, + A65E756C12B6408AA9100006 /* AppIntents.framework */, ); name = Frameworks; sourceTree = ""; @@ -385,6 +395,8 @@ buildActionMask = 2147483647; files = ( 05558E78087CE893FD133EED /* WidgetReload.swift in Sources */, + A65E756C12B6408AA9100003 /* ReadingIntents.swift in Sources */, + A65E756C12B6408AA9100001 /* ShortcutsStore.swift in Sources */, 9CC5FA4D3B3EEAB970E0A4DE /* main.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/app/src-tauri/gen/apple/project.yml b/app/src-tauri/gen/apple/project.yml index 617745ce..79b362a1 100644 --- a/app/src-tauri/gen/apple/project.yml +++ b/app/src-tauri/gen/apple/project.yml @@ -107,6 +107,7 @@ targets: - sdk: QuartzCore.framework - sdk: Security.framework - sdk: UIKit.framework + - sdk: AppIntents.framework - sdk: WebKit.framework - sdk: WidgetKit.framework preBuildScripts: diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 54618e95..af18f5e0 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,6 +3,8 @@ mod epub_name; mod mdns; mod opds; mod refresh; +#[cfg(any(target_os = "ios", test))] +mod shortcuts_cache; mod widget; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -39,6 +41,7 @@ pub fn run() { books::send_bytes, books::save_file, widget::update_widget, + widget::update_shortcuts, set_appearance, supports_native_chrome, set_page_scroll, diff --git a/app/src-tauri/src/shortcuts_cache.rs b/app/src-tauri/src/shortcuts_cache.rs new file mode 100644 index 00000000..3e500a95 --- /dev/null +++ b/app/src-tauri/src/shortcuts_cache.rs @@ -0,0 +1,52 @@ +//! Atomic publication for the credential-free App Intents snapshot. +use std::{path::Path, sync::Mutex}; + +static WRITES: Mutex<()> = Mutex::new(()); + +pub fn snapshot_dir(app_data_dir: &Path) -> std::path::PathBuf { + app_data_dir.join("Shortcuts") +} + +pub fn publish(dir: &Path, json: Option<&str>) -> Result<(), std::io::Error> { + let _lock = WRITES.lock().unwrap_or_else(|e| e.into_inner()); + let path = dir.join("shortcuts.json"); + if let Some(json) = json { + let temp = dir.join("shortcuts.json.tmp"); + std::fs::write(&temp, json)?; + std::fs::rename(temp, path) + } else { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn private_snapshot_directory_matches_native_reader() { + assert_eq!(snapshot_dir(Path::new("/sandbox/Library/Application Support/com.crosspointreader.sync")), + Path::new("/sandbox/Library/Application Support/com.crosspointreader.sync/Shortcuts")); + } + #[test] + fn replaces_and_clears_snapshots_without_leaving_partial_files() { + let dir = std::env::temp_dir().join(format!("crosspoint-shortcuts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + publish(&dir, Some(r#"{"books":["one"]}"#)).unwrap(); + publish(&dir, Some(r#"{"books":[]}"#)).unwrap(); + assert_eq!(std::fs::read_to_string(dir.join("shortcuts.json")).unwrap(), r#"{"books":[]}"#); + assert!(!dir.join("shortcuts.json.tmp").exists()); + // A failed write must preserve the previous complete snapshot. + std::fs::create_dir(dir.join("shortcuts.json.tmp")).unwrap(); + assert!(publish(&dir, Some("partial")).is_err()); + assert_eq!(std::fs::read_to_string(dir.join("shortcuts.json")).unwrap(), r#"{"books":[]}"#); + std::fs::remove_dir(dir.join("shortcuts.json.tmp")).unwrap(); + publish(&dir, None).unwrap(); + publish(&dir, None).unwrap(); + assert!(!dir.join("shortcuts.json").exists()); + std::fs::remove_dir(dir).unwrap(); + } +} diff --git a/app/src-tauri/src/widget.rs b/app/src-tauri/src/widget.rs index 05d480fb..ac1f621c 100644 --- a/app/src-tauri/src/widget.rs +++ b/app/src-tauri/src/widget.rs @@ -39,3 +39,21 @@ fn group_dir() -> Option { let path = unsafe { url.path() }?; Some(path.to_string().into()) } + +/// Credential-free offline data in private Application Support storage, read by +/// App Intents in the main app process (no App Group entitlement required). Writes are atomic so +/// Shortcuts never sees a partially written library. `None` clears it on logout. +#[tauri::command] +pub fn update_shortcuts(app: tauri::AppHandle, json: Option) -> Result<(), String> { + #[cfg(target_os = "ios")] + { + use tauri::Manager; + let app_data = app.path().app_data_dir().map_err(|e| e.to_string())?; + let dir = crate::shortcuts_cache::snapshot_dir(&app_data); + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + crate::shortcuts_cache::publish(&dir, json.as_deref()).map_err(|e| e.to_string())?; + } + #[cfg(not(target_os = "ios"))] + let _ = (app, json); + Ok(()) +} diff --git a/app/src/App.jsx b/app/src/App.jsx index debb0e36..d2ce9a25 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { ChartColumn, CloudOff, Compass, Quote, Eye, EyeOff, LibraryBig, Loader2, Lock, Send as SendIcon, Server, Settings as SettingsIcon, User } from 'lucide-react' import { useTheme } from './theme.js' -import { DEFAULT_SERVER, api, hostedServer, isApp, lastServer, loadSession, login, logout, offline, cached, register, saveSession } from './api.js' +import { DEFAULT_SERVER, syncShortcutsCache, api, hostedServer, isApp, lastServer, loadSession, login, logout, offline, cached, register, saveSession } from './api.js' import { ErrorNote, LoadCacheProvider, PageSkeleton, RefreshPill, Toaster, useLoad } from './ui.jsx' import Library from './Library.jsx' import Book from './Book.jsx' @@ -321,6 +321,7 @@ function Sidebar({ route, session }) { } function Home({ session, onSession, onLogout, theme }) { + useEffect(() => { syncShortcutsCache(session) }, [session]) const [parts, from] = useHash() // Offline: api.js serves the last saved copy and says so; show a slim notice until the network is back. const [isOffline, setOffline] = useState(false) @@ -436,23 +437,58 @@ function Home({ session, onSession, onLogout, theme }) { export default function App() { const theme = useTheme() const [session, setSession] = useState(loadSession) + const [signOut, setSignOut] = useState(() => session ? null : { error: null }) + const sessionGeneration = useRef(0) + const generation = sessionGeneration.current + async function handleLogout() { + if (generation !== sessionGeneration.current) return + sessionGeneration.current++ + setSignOut({ error: null }) + try { + await logout() + setSession(null) + setSignOut(null) + } catch (error) { + setSignOut({ error: error.message || 'Please try again.' }) + } + } + useEffect(() => { + if (!session) handleLogout() + // Later sign-outs already await removal in handleLogout. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) useEffect(() => { if (!session) clearWidget() }, [session]) - if (!session) return (window.scrollTo(0, 0), setSession(s))} /> + if (signOut) return ( +
+
+ {signOut.error ? <> +

Couldn’t finish signing out. {signOut.error}

+

Retry to clear saved Shortcuts data and finish signing out.

+ + :

Signing out…

} +
+
+ ) + if (!session) return { + if (generation !== sessionGeneration.current) return + sessionGeneration.current++ + window.scrollTo(0, 0) + setSession(s) + }} /> return ( { + if (generation !== sessionGeneration.current) return saveSession(s) + sessionGeneration.current++ setSession(s) }} - onLogout={() => { - logout() - setSession(null) - }} + onLogout={handleLogout} /> {/* After sign-in, so the card never covers the sign-in button. */} diff --git a/app/src/App.logout.test.jsx b/app/src/App.logout.test.jsx new file mode 100644 index 00000000..589a8d04 --- /dev/null +++ b/app/src/App.logout.test.jsx @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { StrictMode } from 'react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' + +const settingsCallbacks = vi.hoisted(() => ({ onSession: null, onLogout: null })) + +vi.mock('./api.js', () => ({ + DEFAULT_SERVER: 'https://sync.test', isApp: false, hostedServer: null, + loadSession: vi.fn(), lastServer: vi.fn(() => 'https://sync.test'), + login: vi.fn(), register: vi.fn(), logout: vi.fn(), saveSession: vi.fn(), syncShortcutsCache: vi.fn(), + offline: new EventTarget(), + api: { books: vi.fn(), summary: vi.fn(), activity: vi.fn() }, + cached: { books: () => [], summary: () => null, activity: () => null, activityUpdatedAt: () => null }, +})) +vi.mock('./theme.js', () => ({ useTheme: () => ['system', vi.fn()] })) +vi.mock('./widget.js', () => ({ clearWidget: vi.fn(), updateWidget: vi.fn() })) +vi.mock('./tabbar.js', () => ({ nativeTabBar: false, mountTabBar: vi.fn(), selectNativeTab: vi.fn(), useNativeButton: vi.fn(), useSheet: vi.fn() })) +vi.mock('./Settings.jsx', () => ({ default: ({ onLogout, onSession }) => { + settingsCallbacks.onSession = onSession + settingsCallbacks.onLogout = onLogout + return +}, Matches: () => null })) +vi.mock('./Library.jsx', () => ({ default: () => null })) +vi.mock('./Book.jsx', () => ({ default: () => null })) +vi.mock('./Stats.jsx', () => ({ default: () => null })) +vi.mock('./Send.jsx', () => ({ default: () => null })) +vi.mock('./Wallpaper.jsx', () => ({ default: () => null })) +vi.mock('./Browse.jsx', () => ({ default: () => null })) +vi.mock('./Clippings.jsx', () => ({ default: () => null })) +vi.mock('./Install.jsx', () => ({ default: () => null })) +const { api, loadSession, logout, saveSession } = await import('./api.js') +const { default: App } = await import('./App.jsx') + +beforeEach(() => { + location.hash = '#/settings' + loadSession.mockReturnValue({ server: 'https://sync.test', username: 'reader', key: 'secret' }) + api.books.mockReset().mockResolvedValue([]) + api.summary.mockReset().mockResolvedValue({}) + api.activity.mockReset().mockResolvedValue([]) + logout.mockReset().mockResolvedValue() + saveSession.mockClear() +}) +afterEach(cleanup) + +it('shows the sign-in form only after logout confirms native removal', async () => { + let finish + logout.mockReturnValueOnce(new Promise((r) => { finish = r })) + render() + fireEvent.click(screen.getByRole('button', { name: 'Sign out' })) + expect(screen.queryByRole('heading', { name: 'Sign in' })).toBeNull() + expect(screen.getByText('Signing out…')).toBeTruthy() + await act(async () => { finish() }) + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeTruthy() +}) + +it('surfaces native removal errors and lets the user retry before signing out', async () => { + logout.mockRejectedValueOnce(new Error('disk unavailable')).mockResolvedValueOnce() + render() + fireEvent.click(screen.getByRole('button', { name: 'Sign out' })) + const retry = await screen.findByRole('button', { name: 'Retry sign out' }) + expect(screen.queryByRole('heading', { name: 'Sign in' })).toBeNull() + expect(screen.getByRole('alert').textContent).toContain('disk unavailable') + fireEvent.click(retry) + await waitFor(() => expect(screen.getByRole('heading', { name: 'Sign in' })).toBeTruthy()) + expect(logout).toHaveBeenCalledTimes(2) +}) + +it('also waits for native removal when an expired session triggers logout', async () => { + let finish + logout.mockReturnValueOnce(new Promise((r) => { finish = r })) + api.activity.mockRejectedValueOnce(Object.assign(new Error('Signed out'), { status: 401 })) + render() + await screen.findByText('Signing out…') + expect(screen.queryByRole('heading', { name: 'Sign in' })).toBeNull() + await act(async () => { finish() }) + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeTruthy() +}) + +it.each(['pending', 'complete'])('ignores a stale password-change callback when logout is %s', async (phase) => { + let finish + logout.mockReturnValueOnce(new Promise((r) => { finish = r })) + render() + const oldSessionCallback = settingsCallbacks.onSession + const oldLogoutCallback = settingsCallbacks.onLogout + fireEvent.click(screen.getByRole('button', { name: 'Sign out' })) + if (phase === 'complete') await act(async () => { finish() }) + await act(async () => { oldSessionCallback({ server: 'https://sync.test', username: 'reader', key: 'new-password-key' }) }) + expect(saveSession).not.toHaveBeenCalled() + if (phase === 'pending') await act(async () => { finish() }) + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeTruthy() + await act(async () => { await oldLogoutCallback() }) + expect(logout).toHaveBeenCalledOnce() +}) + +it('continues accepting password updates for the active session', async () => { + render() + const updated = { server: 'https://sync.test', username: 'reader', key: 'new-password-key' } + await act(async () => { settingsCallbacks.onSession(updated) }) + expect(saveSession).toHaveBeenCalledWith(updated) + expect(screen.getByRole('button', { name: 'Sign out' })).toBeTruthy() +}) + +it('waits for startup cleanup before showing sign-in when no session is saved', async () => { + loadSession.mockReturnValue(null) + let finish + logout.mockReturnValueOnce(new Promise((r) => { finish = r })) + render() + expect(screen.queryByRole('heading', { name: 'Sign in' })).toBeNull() + expect(screen.getByText('Signing out…')).toBeTruthy() + await act(async () => { finish() }) + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeTruthy() + expect(logout).toHaveBeenCalledOnce() +}) + +it('offers startup cleanup retry rather than sign-in when native removal fails', async () => { + loadSession.mockReturnValue(null) + logout.mockRejectedValueOnce(new Error('disk unavailable')).mockResolvedValueOnce() + render() + const retry = await screen.findByRole('button', { name: 'Retry sign out' }) + expect(screen.queryByRole('heading', { name: 'Sign in' })).toBeNull() + expect(screen.getByRole('alert').textContent).toContain('disk unavailable') + fireEvent.click(retry) + await waitFor(() => expect(screen.getByRole('heading', { name: 'Sign in' })).toBeTruthy()) + expect(logout).toHaveBeenCalledTimes(2) +}) + +it('shares startup cleanup across StrictMode effect replays', async () => { + loadSession.mockReturnValue(null) + let finish + logout.mockReturnValueOnce(new Promise((r) => { finish = r })) + render() + expect(logout).toHaveBeenCalledOnce() + expect(screen.queryByRole('heading', { name: 'Sign in' })).toBeNull() + await act(async () => { finish() }) + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeTruthy() +}) diff --git a/app/src/Clippings.jsx b/app/src/Clippings.jsx index c04cfaee..b5e63fd0 100644 --- a/app/src/Clippings.jsx +++ b/app/src/Clippings.jsx @@ -58,7 +58,7 @@ function Clip({ session, clip, book, onShare, onDelete, showBook, onMicroblog, s } export default function Clippings({ session, books }) { - const [items, error, reload] = useLoad(() => api.allClippings(session, books), [session], `clippings:${session.username}`, () => cached.clippings(session)) + const [items, error, reload] = useLoad(() => api.allClippings(session, books), [session, books], `clippings:${session.username}`, () => cached.clippings(session)) const [connectors] = useLoad(() => api.connectors(session).then((r) => r.connectors), [session], `clipping-connectors:${session.username}`) const microblog = connectors?.some((c) => c.id === 'microblog' && c.linked) const [postData, postError, reloadPosts] = useLoad(() => microblog ? api.microblogClippingPosts(session) : Promise.resolve({ items: [] }), [session, microblog]) diff --git a/app/src/Clippings.test.jsx b/app/src/Clippings.test.jsx index f5882db4..38922ac2 100644 --- a/app/src/Clippings.test.jsx +++ b/app/src/Clippings.test.jsx @@ -1,15 +1,17 @@ // @vitest-environment jsdom import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' vi.hoisted(() => { window.scrollTo = vi.fn(); window.matchMedia = vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {} })) }) vi.mock('./api.js', () => ({ isApp: false, cached: { clippings: () => null }, api: { allClippings: vi.fn(), connectors: vi.fn(), addMicroblogClipping: vi.fn(), microblogClippingPosts: vi.fn(), recreateMicroblogPost: vi.fn(), microblogDestinations: vi.fn() } })) vi.mock('./tabbar.js', () => ({ useSheet() {}, useNativeBack() {}, useNativeButton() {} })) +vi.mock('./ui.jsx', async (original) => ({ ...await original(), Cover: () => null })) import Clippings from './Clippings.jsx' import { api } from './api.js' -const session = { username: 'reader' } +const session = { server: 'https://sync.test', username: 'reader', key: 'secret' } const books = [{ document: 'book', title: 'Test book', author: 'An author', cover_url: '/cover.jpg' }] beforeEach(() => { + vi.resetAllMocks() api.allClippings.mockResolvedValue([{ id: 'one', document: 'book', text: 'A passage', created_at: 1 }]) api.connectors.mockResolvedValue({ connectors: [{ id: 'microblog', linked: true }] }) api.addMicroblogClipping.mockResolvedValue({ queued: true, waiting_for_match: false }) @@ -121,3 +123,31 @@ it('keeps the previous blog usable when the current clippings blog is unavailabl fireEvent.click(screen.getByRole('button', { name: 'Recreate post' })) await waitFor(() => expect(api.recreateMicroblogPost).toHaveBeenCalledWith(session, 'book', 'original')) }) + +const first = { document: 'one', title: 'First' } +const discovered = { document: 'two', title: 'Newly discovered' } +const clip = { document: 'two', id: 'new', text: 'New book passage', created_at: 100 } + +it('reloads all-book clippings when the refreshed library discovers another book', async () => { + api.connectors.mockResolvedValue({ connectors: [] }) + api.allClippings.mockResolvedValueOnce([]).mockResolvedValueOnce([clip]) + const view = render() + await screen.findByText('No clippings yet') + view.rerender() + await screen.findAllByText('New book passage') + expect(api.allClippings).toHaveBeenCalledTimes(2) + expect(api.allClippings).toHaveBeenLastCalledWith(session, [first, discovered]) +}) + +it('keeps the refreshed book-list results if the earlier clipping request finishes last', async () => { + let finish + api.connectors.mockResolvedValue({ connectors: [] }) + api.allClippings.mockReturnValueOnce(new Promise((r) => { finish = r })).mockResolvedValueOnce([clip]) + const view = render() + await waitFor(() => expect(api.allClippings).toHaveBeenCalledOnce()) + view.rerender() + await screen.findAllByText('New book passage') + await act(async () => { finish([{ document: 'one', id: 'old', text: 'Old snapshot', created_at: 50 }]) }) + expect(screen.queryByText('Old snapshot')).toBeNull() + expect(screen.getAllByText('New book passage').length).toBeGreaterThan(0) +}) diff --git a/app/src/Settings.jsx b/app/src/Settings.jsx index 48efe71f..aa91bd4a 100644 --- a/app/src/Settings.jsx +++ b/app/src/Settings.jsx @@ -924,7 +924,7 @@ function Account({ session, onSession, onLogout }) { location.reload() } else { await api.deleteAccount(session) - onLogout() + await onLogout() } } finally { setBusy(null) diff --git a/app/src/api.js b/app/src/api.js index 3d098221..d87c70f4 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -1,4 +1,5 @@ import SparkMD5 from 'spark-md5' +import { publishShortcuts, supportsShortcuts } from './shortcutsCache.js' import { fetch as tauriFetch } from '@tauri-apps/plugin-http' // In the app, requests go through Rust (plain-http LAN servers, any CORS_ORIGINS); @@ -49,9 +50,16 @@ export function saveSession(session) { } export function logout() { + if (logoutPending) return logoutPending sessionVersion++ - localStorage.removeItem(KEY) clearOffline() + logoutPending = Promise.resolve(publishShortcuts(null)).then(() => { + // Discard reads started while native removal was waiting in the write queue. + sessionVersion++ + clearOffline() + localStorage.removeItem(KEY) + }).finally(() => { logoutPending = null }) + return logoutPending } // Offline: every successful GET is kept on the device; when the network is down @@ -62,16 +70,48 @@ const OFFLINE_PREFIX = 'crosspoint-offline:' const OFFLINE = `${OFFLINE_PREFIX}v2:` export const offline = new EventTarget() let sessionVersion = 0 +let readingVersion = 0 +let logoutPending = null +let libraryReadSequence = 0 +let latestLibraryRead = 0 +let clippingReadSequence = 0 +let latestClippingRead = 0 +let latestHubRead = 0 +const latestBookReads = new Map() + +function finishClippingRead(version, sequence, document, fresh) { + if (version !== sessionVersion) return + const latest = document === undefined ? latestClippingRead : Math.max(latestHubRead, latestBookReads.get(document) ?? 0) + if (sequence < latest) throw Object.assign(new Error('Reading data changed while loading'), { stale: true }) + // Cached fallbacks do not supersede a still-pending online refresh. + if (!fresh) return + latestClippingRead = Math.max(latestClippingRead, sequence) + if (document === undefined) latestHubRead = sequence + else latestBookReads.set(document, sequence) +} + +function assertReadingVersion(session, reading) { + if (session === sessionVersion && reading !== readingVersion) { + throw Object.assign(new Error('Reading data changed while loading'), { stale: true }) + } +} +const offlineCopies = new WeakSet() -function clearOffline() { +function clearOffline(session) { + latestLibraryRead = 0 + latestClippingRead = latestHubRead = 0 + latestBookReads.clear() + const prefix = session ? `${OFFLINE}${session.username}@${session.server}/` : OFFLINE_PREFIX for (const key of Object.keys(localStorage)) { - if (key.startsWith(OFFLINE_PREFIX)) localStorage.removeItem(key) + if (key.startsWith(prefix)) localStorage.removeItem(key) } } // store: false keeps the offline fallback but leaves saving to the caller. async function call(session, path, init = {}, keepOffline = true, { offlineFallback = true, timestampSaved = false, store = true } = {}) { const version = sessionVersion + const reading = readingVersion + const readingGet = (!init.method || init.method === 'GET') && /^\/api\/v1\/(clippings|progress)([/?]|$)/.test(path) const key = `${OFFLINE}${session.username}@${session.server}${path}` const isGet = keepOffline && (!init.method || init.method === 'GET') let res @@ -87,10 +127,13 @@ async function call(session, path, init = {}, keepOffline = true, { offlineFallb }, }) } catch (e) { + if (readingGet) assertReadingVersion(version, reading) const saved = isGet && version === sessionVersion && localStorage.getItem(key) if (!saved || !offlineFallback) throw e offline.dispatchEvent(new Event('offline')) - return JSON.parse(saved) + const data = JSON.parse(saved) + offlineCopies.add(data) + return data } if (res.status === 401) throw Object.assign(new Error('Signed out'), { status: 401 }) if (!res.ok) { @@ -99,6 +142,7 @@ async function call(session, path, init = {}, keepOffline = true, { offlineFallb throw Object.assign(new Error(body?.message || `Server error ${res.status}`), { status: res.status }) } const data = await res.json() + if (readingGet) assertReadingVersion(version, reading) if (isGet && version === sessionVersion) { offline.dispatchEvent(new Event('online')) if (store) keep(key, data, timestampSaved) @@ -106,21 +150,203 @@ async function call(session, path, init = {}, keepOffline = true, { offlineFallb return data } +async function editBook(session, document, field, patch) { + const version = sessionVersion + const result = await call(session, `/api/v1/documents/${document}/${field}`, { method: 'PUT', body: JSON.stringify(patch) }) + // A later library refresh must not be overwritten by a pre-edit response. + if (version === sessionVersion) { + readingVersion++ + patchCachedBook(session, result.document ?? document, field, result) + await syncShortcutsCache(session)?.catch(() => {}) + } + return result +} + function keep(key, data, timestampSaved = false) { try { localStorage.setItem(key, JSON.stringify(data)) if (timestampSaved) localStorage.setItem(`${key}:saved-at`, JSON.stringify(Date.now())) + return true } catch { // storage full: offline copy just stays older + return false + } +} + +// Remove a successful deletion from every saved representation of that book. +// An omitted id removes all of the book's clippings. +function removeCachedClipping(session, document, id) { + const hubKey = `${OFFLINE}${session.username}@${session.server}/api/v1/clippings` + const bookKey = `${hubKey}/${document}` + for (const key of Object.keys(localStorage)) { + const hub = key === hubKey + if (!hub && key !== bookKey && !key.startsWith(`${bookKey}?`)) continue + const data = read(key) + if (!Array.isArray(data?.items)) continue + const items = data.items.filter((c) => (id !== undefined && c.id !== id) || (hub && c.document !== document)) + if (items.length === data.items.length) continue + // Removing one item doesn't make a capped response a complete collection. + const truncated = hub && data.items.length >= 5000 ? { truncated: true } : {} + keep(key, { ...data, ...truncated, items }) + } +} + +const clippingOrder = (a, b) => a.spine - b.spine || (a.start_offset ?? a.para ?? a.start_page) - (b.start_offset ?? b.para ?? b.start_page) + +// Stage complete clipping reads so a failed legacy batch cannot alter later snapshots. +async function readClippings(s, doc) { + const complete = saved(s, `/api/v1/clippings/${doc}`) + const items = [] + let fromCache = false + try { + for (let cursor = 0; ; ) { + const path = `/api/v1/clippings/${doc}?cursor=${cursor}&limit=100` + const page = await call(s, path, {}, true, { store: false, offlineFallback: !complete }) + fromCache ||= offlineCopies.has(page) + items.push(...page.items) + if (!page.more) break + cursor = page.cursor + } + } catch (error) { + if (!complete || error.status || error.stale) throw error + offline.dispatchEvent(new Event('offline')) + return { items: complete.items, fromCache: true } + } + const live = items.filter((c) => !c.deleted).sort(clippingOrder) + return { items: live, fromCache } +} + +function cacheClippings(s, doc, result, savedAt = Date.now()) { + const key = `${OFFLINE}${s.username}@${s.server}/api/v1/clippings/${doc}` + const previous = result.fromCache && read(key) + const record = previous + ? { items: previous.items, savedAt: Object.hasOwn(previous, 'savedAt') ? previous.savedAt : read(`${key}:saved-at`) } + : { items: result.items, savedAt: result.fromCache ? null : savedAt } + // Store one complete representation, including freshness. Free legacy page + // copies first so migration doesn't require twice the payload's storage quota. + const legacy = Object.keys(localStorage).filter((k) => k.startsWith(`${key}?`) || k === `${key}:saved-at`) + .map((k) => [k, localStorage.getItem(k)]) + for (const [k] of legacy) localStorage.removeItem(k) + try { + localStorage.setItem(key, JSON.stringify(record)) + return true + } catch { + // setItem is atomic; the prior aggregate remains. Restore legacy fallbacks. + for (const [k, value] of legacy) { + try { localStorage.setItem(k, value) } catch { /* storage unavailable */ } + } + return false + } +} + +function cacheClippingsHub(s, data) { + const key = `${OFFLINE}${s.username}@${s.server}/api/v1/clippings` + const savedAt = offlineCopies.has(data) + ? Object.hasOwn(data, 'savedAt') ? data.savedAt : read(`${key}:saved-at`) + : Date.now() + // One atomic write keeps payload and freshness consistent even at quota. + if (!keep(key, { ...data, savedAt }) || !Number.isFinite(savedAt) || data.truncated || data.items.length >= 5000) return + // A complete hub proves both presence and absence for every older book copy. + // Persist that knowledge before a later capped response can use those copies. + const documents = new Set(Object.keys(localStorage).filter((k) => k.startsWith(`${key}/`)) + .map((k) => k.slice(key.length + 1).split('?')[0].replace(/:saved-at$/, ''))) + const byDocument = new Map() + for (const c of data.items) { + if (!byDocument.has(c.document)) byDocument.set(c.document, []) + byDocument.get(c.document).push(c) + } + for (const doc of documents) { + const bookKey = `${key}/${doc}` + const per = read(bookKey) + const at = per && Object.hasOwn(per, 'savedAt') ? per.savedAt : read(`${bookKey}:saved-at`) + if ((at ?? 0) > savedAt || (offlineCopies.has(data) && at === savedAt)) continue + const previous = new Map(per?.items.map((c) => [c.id, c]) ?? []) + const items = (byDocument.get(doc) ?? []).map(({ document, ...c }) => ({ ...previous.get(c.id), ...c })).sort(clippingOrder) + if (!cacheClippings(s, doc, { items, fromCache: false }, savedAt)) { + // Failed replacement must not leave a disproven copy available to revive. + for (const k of Object.keys(localStorage)) { + if (k === bookKey || k === `${bookKey}:saved-at` || k.startsWith(`${bookKey}?`)) localStorage.removeItem(k) + } + } + } +} + +function invalidateClippingCaches(s, documents) { + const hubKey = `${OFFLINE}${s.username}@${s.server}/api/v1/clippings` + const hub = read(hubKey) + if (Array.isArray(hub?.items)) { + const items = hub.items.filter((c) => !documents.includes(c.document)) + // Retain unrelated passages and their original freshness. The affected + // books still need a fetch, so this filtered hub is an incomplete copy. + if (items.length !== hub.items.length && !keep(hubKey, { ...hub, items, truncated: true })) { + localStorage.removeItem(hubKey) + localStorage.removeItem(`${hubKey}:saved-at`) + } + } + for (const key of Object.keys(localStorage)) { + if (documents.some((doc) => { + const bookKey = `${hubKey}/${doc}` + return key === bookKey || key === `${bookKey}:saved-at` || key.startsWith(`${bookKey}?`) + })) localStorage.removeItem(key) } } const PROGRESS = '/api/v1/progress?limit=500' +function patchCachedBook(session, document, field, result) { + const key = `${OFFLINE}${session.username}@${session.server}${PROGRESS}` + const progress = read(key) + if (!progress?.items.some((b) => b.document === document)) return + // Clearing a manual status needs server-side reading stats to derive it. + if (field === 'status' && result.status === null) { + localStorage.removeItem(key) + localStorage.removeItem(`${key}:saved-at`) + return + } + const fields = field === 'info' + ? ['title', 'author', 'language', 'cover_url', 'page_count', 'genres', 'moods', 'content_warnings', 'series', 'series_position', 'release_year'] + : field === 'status' ? ['status', 'status_at'] : ['status'] + const patch = Object.fromEntries(fields.filter((f) => Object.hasOwn(result, f) && (f !== 'status' || result[f] !== null)).map((f) => [f, result[f]])) + const items = progress.items.map((b) => { + if (b.document !== document) return b + const edited = { ...b, ...patch } + if (field === 'status' && patch.status) edited.pause_reason = patch.status === 'paused' ? 'manual' : null + if (field === 'dates' && patch.status && patch.status !== b.status) { + delete edited.status_at // The dates response does not include this timestamp. + edited.pause_reason = null + } + return edited + }) + // Keep the library's fetch time and the reader's Last Sync unchanged. + if (!keep(key, { ...progress, items })) { + localStorage.removeItem(key) + localStorage.removeItem(`${key}:saved-at`) + } +} + +function removeCachedBook(session, document) { + const prefix = `${OFFLINE}${session.username}@${session.server}` + const key = `${prefix}${PROGRESS}` + const progress = read(key) + if (Array.isArray(progress?.items)) { + keep(key, { ...progress, items: progress.items.filter((b) => b.document !== document) }) + } + removeCachedClipping(session, document) + // Discard complete and legacy per-book copies, including freshness metadata. + for (const key of Object.keys(localStorage)) { + for (const kind of ['progress', 'clippings']) { + const bookKey = `${prefix}/api/v1/${kind}/${document}` + if (key === bookKey || key === `${bookKey}:saved-at` || key.startsWith(`${bookKey}?`)) localStorage.removeItem(key) + } + } +} + // Every synced book: follows the server's pages, then saves the whole list as the first page's offline copy. // A page that fails leaves the previous complete copy in place. async function allProgress(session) { const version = sessionVersion + const reading = readingVersion + const sequence = ++libraryReadSequence const first = await call(session, PROGRESS, {}, true, { store: false }) const items = [...first.items] for (let next = first.next, seen = new Set(); next; ) { @@ -131,8 +357,19 @@ async function allProgress(session) { items.push(...page.items) next = page.next } + assertReadingVersion(version, reading) const all = { items, next: null } - if (version === sessionVersion) keep(`${OFFLINE}${session.username}@${session.server}${PROGRESS}`, all) + if (version === sessionVersion) { + if (sequence < latestLibraryRead) throw Object.assign(new Error('Reading data changed while loading'), { stale: true }) + if (!offlineCopies.has(first)) latestLibraryRead = sequence + const key = `${OFFLINE}${session.username}@${session.server}${PROGRESS}` + const savedAt = offlineCopies.has(first) + ? Object.hasOwn(first, 'savedAt') ? first.savedAt : read(`${key}:saved-at`) + : Date.now() + // Commit the complete library and its freshness in one atomic write. + keep(key, { ...all, savedAt }) + syncShortcutsCache(session) + } return all } @@ -182,6 +419,7 @@ export async function login(server, username, password) { await call(session, '/users/auth', {}, false) sessionVersion++ clearOffline() + publishShortcuts(null) localStorage.setItem(KEY, JSON.stringify(session)) localStorage.setItem(LAST_SERVER, JSON.stringify(session.server)) return session @@ -202,6 +440,53 @@ const bookList = (r) => .filter((b) => b.title || b.filename) .map((b) => (b.status === 'finished' ? { ...b, device_percentage: b.percentage, percentage: 1 } : b)) +// A credential-free mirror of the data already saved by this app. No requests. +export function syncShortcutsCache(session) { + if (!supportsShortcuts()) return + if (session && logoutPending) return + if (!session) return publishShortcuts(null) + const prefix = `${OFFLINE}${session.username}@${session.server}` + const books = saved(session, PROGRESS) + const all = saved(session, '/api/v1/clippings') + const allAt = all && Object.hasOwn(all, 'savedAt') ? all.savedAt : read(`${prefix}/api/v1/clippings:saved-at`) + let clippings = all ? { items: all.items.map((c) => ({ ...c, cachedAt: allAt })) } : null + // Complete per-book fetches supersede older hub data, including deletions. + const perPrefix = `${prefix}/api/v1/clippings/` + for (const key of Object.keys(localStorage)) { + if (!key.startsWith(perPrefix) || key.includes('?') || key.endsWith(':saved-at')) continue + const doc = key.slice(perPrefix.length) + const per = read(key) + if (!per) continue + const at = Object.hasOwn(per, 'savedAt') ? per.savedAt : read(`${key}:saved-at`) + if (all && (at ?? 0) < (allAt ?? 0)) { + if (all.truncated || all.items.length >= 5000) { + // The capped hub cannot establish that omitted passages were deleted. + const known = new Set(clippings.items.filter((c) => c.document === doc).map((c) => c.id)) + clippings.items.push(...per.items.filter((c) => !known.has(c.id)).map((c) => ({ ...c, document: doc, cachedAt: at }))) + } + continue + } + clippings ??= { items: [] } + clippings.items = clippings.items.filter((c) => c.document !== doc) + clippings.items.push(...per.items.map((c) => ({ ...c, document: doc, cachedAt: at }))) + } + // Allowlist fields rather than exposing server responses or session credentials. + return publishShortcuts({ + account: JSON.stringify([session.server, session.username]), + books: books ? { + savedAt: Object.hasOwn(books, 'savedAt') ? books.savedAt : read(`${prefix}${PROGRESS}:saved-at`), + items: bookList(books).map((b) => ({ + document: b.document, title: b.title || b.filename, author: b.author ?? '', + status: b.status ?? 'reading', percentage: b.percentage, timestamp: b.timestamp, + })), + } : null, + clippings: clippings ? { items: clippings.items.map((c) => ({ + document: c.document, id: c.id, text: c.text ?? '', note: c.note ?? '', + chapter: c.chapter ?? '', created_at: c.created_at ?? 0, cachedAt: c.cachedAt, + })) } : null, + }) +} + export const cached = { books: (s) => { const r = saved(s, PROGRESS) @@ -299,39 +584,105 @@ export const api = { }, // The sync account itself. changePassword: (s, password) => call(s, '/api/v1/account/password', { method: 'POST', body: JSON.stringify({ key: SparkMD5.hash(password) }) }), - clearData: (s) => call(s, '/api/v1/account/data', { method: 'DELETE' }), + async clearData(s) { + const version = sessionVersion + const result = await call(s, '/api/v1/account/data', { method: 'DELETE' }) + if (version === sessionVersion) { + sessionVersion++ // Older in-flight reads must not restore pre-clear data. + clearOffline(s) + await publishShortcuts(null) // Settings reloads as soon as this resolves. + } + return result + }, deleteAccount: (s) => call(s, '/api/v1/account', { method: 'DELETE' }), - removeBook: (s, doc) => call(s, `/api/v1/progress/${doc}`, { method: 'DELETE' }), + async removeBook(s, doc) { + const version = sessionVersion + const result = await call(s, `/api/v1/progress/${doc}`, { method: 'DELETE' }) + if (version === sessionVersion) { + readingVersion++ + removeCachedBook(s, doc) + await syncShortcutsCache(s)?.catch(() => {}) + } + return result + }, // Manual reading dates: { start_date, finished_date } as 'YYYY-MM-DD', null clears, omitted = unchanged. - setDates: (s, doc, dates) => call(s, `/api/v1/documents/${doc}/dates`, { method: 'PUT', body: JSON.stringify(dates) }), - setInfo: (s, doc, patch) => call(s, `/api/v1/documents/${doc}/info`, { method: 'PUT', body: JSON.stringify(patch) }), + setDates: (s, doc, dates) => editBook(s, doc, 'dates', dates), + setInfo: (s, doc, patch) => editBook(s, doc, 'info', patch), coverCandidates: (s, doc, q) => call(s, `/api/v1/documents/${doc}/cover/candidates${q ? `?q=${encodeURIComponent(q)}` : ''}`).then((r) => r.items), // `document` becomes an alias of `into`: its progress, clippings and stats move there. - merge: (s, document, into, stats = true) => call(s, '/api/v1/documents/merge', { method: 'POST', body: JSON.stringify({ document, into, stats }) }), + async merge(s, document, into, stats = true) { + const version = sessionVersion + const result = await call(s, '/api/v1/documents/merge', { method: 'POST', body: JSON.stringify({ document, into, stats }) }) + if (version === sessionVersion) { + readingVersion++ + invalidateClippingCaches(s, [document, into]) + removeCachedBook(s, document) + // The server selects furthest progress and assigns a new Last Sync. + // Its merge response lacks those values; fetch before exposing the target. + removeCachedBook(s, into) + await syncShortcutsCache(s)?.catch(() => {}) + } + return result + }, unmerge: (s, alias) => call(s, `/api/v1/documents/merge/${alias}`, { method: 'DELETE' }), // Every clipping across books; older servers without /clippings get fetched book by book. async allClippings(s, books) { + const version = sessionVersion + const reading = readingVersion + const sequence = ++clippingReadSequence try { - return (await call(s, '/api/v1/clippings')).items + const data = await call(s, '/api/v1/clippings', {}, true, { store: false }) + assertReadingVersion(version, reading) + finishClippingRead(version, sequence, undefined, !offlineCopies.has(data)) + if (version === sessionVersion) { + cacheClippingsHub(s, data) + syncShortcutsCache(s) + } + return data.items } catch (e) { - if (e.status !== 404) throw e - const per = await Promise.all(books.map((b) => api.clippings(s, b.document).then((items) => items.map((c) => ({ ...c, document: b.document }))))) - return per.flat().sort((a, b) => b.created_at - a.created_at) + if (version !== sessionVersion || e.status !== 404) throw e + assertReadingVersion(version, reading) + const per = await Promise.all(books.map(async (b) => ({ document: b.document, ...await readClippings(s, b.document) }))) + assertReadingVersion(version, reading) + const fresh = per.every((result) => !result.fromCache) + finishClippingRead(version, sequence, undefined, fresh) + if (!fresh) { + for (const result of per) { + if (!result.fromCache) finishClippingRead(version, sequence, result.document, true) + } + } + if (version === sessionVersion) { + if (!per.length) cacheClippingsHub(s, { items: [] }) + for (const result of per) cacheClippings(s, result.document, result) + syncShortcutsCache(s) + } + return per.flatMap((result) => result.items.map((c) => ({ ...c, document: result.document }))) + .sort((a, b) => b.created_at - a.created_at) } }, - deleteClipping: (s, doc, id) => - call(s, `/api/v1/clippings/${doc}`, { method: 'PUT', body: JSON.stringify({ items: [{ id, deleted: 1 }] }) }), - setStatus: (s, doc, status) => - call(s, `/api/v1/documents/${doc}/status`, { method: 'PUT', body: JSON.stringify({ status }) }), - async clippings(s, doc) { - const items = [] - for (let cursor = 0; ; ) { - const page = await call(s, `/api/v1/clippings/${doc}?cursor=${cursor}&limit=100`) - items.push(...page.items) - if (!page.more) break - cursor = page.cursor + async deleteClipping(s, doc, id) { + const version = sessionVersion + const result = await call(s, `/api/v1/clippings/${doc}`, { method: 'PUT', body: JSON.stringify({ items: [{ id, deleted: 1 }] }) }) + if (version === sessionVersion) { + readingVersion++ // Reads begun before this deletion cannot restore it. + removeCachedClipping(s, doc, id) + // Native publication already logs failures; the server deletion succeeded. + await syncShortcutsCache(s)?.catch(() => {}) + } + return result + }, + setStatus: (s, doc, status) => editBook(s, doc, 'status', { status }), + async clippings(s, doc, { syncShortcuts = true } = {}) { + const version = sessionVersion + const reading = readingVersion + const sequence = ++clippingReadSequence + const result = await readClippings(s, doc) + assertReadingVersion(version, reading) + finishClippingRead(version, sequence, doc, !result.fromCache) + if (version === sessionVersion) { + cacheClippings(s, doc, result) + if (syncShortcuts) syncShortcutsCache(s) } - return items.filter((c) => !c.deleted) - .sort((a, b) => a.spine - b.spine || (a.start_offset ?? a.para ?? a.start_page) - (b.start_offset ?? b.para ?? b.start_page)) + return result.items }, } diff --git a/app/src/api.test.js b/app/src/api.test.js index 30d2f179..1183b569 100644 --- a/app/src/api.test.js +++ b/app/src/api.test.js @@ -13,8 +13,8 @@ const prefix = (s) => `crosspoint-offline:v2:${s.username}@${s.server}` const progress = '/api/v1/progress?limit=500' const response = (data, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => data }) -beforeEach(() => { - logout() +beforeEach(async () => { + await logout() localStorage.clear() fetchMock.mockReset() }) @@ -69,10 +69,10 @@ it('rejects a failed activity refresh while retaining its saved copy for display }) describe('account data isolation', () => { - it('ignores legacy copies that may contain a different account after upgrading', () => { + it('ignores legacy copies that may contain a different account after upgrading', async () => { localStorage.setItem(`crosspoint-offline:uxj3@https://sync.test${progress}`, JSON.stringify({ items: [{ title: 'uxj4 book' }] })) expect(cached.books(session('uxj3'))).toBeNull() - logout() + await logout() expect(Object.keys(localStorage)).toHaveLength(0) }) @@ -99,7 +99,7 @@ describe('account data isolation', () => { saveSession(session('uxj4')) localStorage.setItem('crosspoint-theme', 'dark') localStorage.setItem(`${prefix(session('uxj4'))}${progress}`, JSON.stringify({ items: [] })) - logout() + await logout() expect(loadSession()).toBeNull() expect(cached.books(session('uxj4'))).toBeNull() expect(localStorage.getItem('crosspoint-theme')).toBe('dark') @@ -130,7 +130,7 @@ describe('account data isolation', () => { let resolve fetchMock.mockReturnValueOnce(new Promise((r) => { resolve = r })) const pending = api.books(session('uxj4')) - logout() + await logout() fetchMock.mockResolvedValueOnce(response({ status: 'ok' })).mockResolvedValueOnce(response({ authorized: true })) await login('https://sync.test', 'uxj3', 'password') resolve(response({ items: [{ title: 'old account' }] })) @@ -213,7 +213,7 @@ describe('book list paging', () => { it('does not save the list for an account that signed out meanwhile', async () => { const s = session('pager') fetchMock.mockResolvedValueOnce(page([1], '1:d1')).mockImplementationOnce(async () => { - logout() + await logout() return page([2], null) }) await api.books(s) diff --git a/app/src/shortcutsCache.js b/app/src/shortcutsCache.js new file mode 100644 index 00000000..26336669 --- /dev/null +++ b/app/src/shortcutsCache.js @@ -0,0 +1,21 @@ +import { invoke } from '@tauri-apps/api/core' + +// iPadOS can use a desktop-style user agent; touch support distinguishes it +// from macOS. Browser sessions must also have Tauri's native bridge. +export function supportsShortcuts() { + return '__TAURI_INTERNALS__' in window && ( + /iPhone|iPad|iPod/i.test(navigator.userAgent) || + (/Macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1) + ) +} + +// Keep native writes in order, especially a clear queued after an in-flight write. +let writes = Promise.resolve() +export function publishShortcuts(snapshot) { + if (!supportsShortcuts()) return + const json = snapshot ? JSON.stringify(snapshot) : null + writes = writes.catch(() => {}).then(() => invoke('update_shortcuts', { json })) + // A native bridge failure must not break normal app use. The next publication retries. + writes.catch((error) => console.warn('Could not update Shortcuts offline data', error)) + return writes +} diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js new file mode 100644 index 00000000..1a9997c8 --- /dev/null +++ b/app/src/shortcutsCache.test.js @@ -0,0 +1,1214 @@ +// @vitest-environment jsdom +import { beforeEach, expect, it, vi } from 'vitest' +import { JSDOM } from 'jsdom' +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn().mockResolvedValue() })) +vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() })) +window.__TAURI_INTERNALS__ = {} +vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone OS 26_0') +vi.stubGlobal('localStorage', new JSDOM('', { url: 'https://app.test' }).window.localStorage) +const { invoke } = await import('@tauri-apps/api/core') +const { fetch: http } = await import('@tauri-apps/plugin-http') +const apiModule = await import('./api.js') +const { api, logout } = apiModule +const session = { server: 'https://sync.test', username: 'reader', key: 'secret' } +const response = (data) => ({ ok: true, status: 200, json: async () => data }) +const book = { document: 'one', title: 'One', status: 'finished', percentage: 0.9, timestamp: 100 } +const flush = async () => { await new Promise((r) => setTimeout(r, 0)) } +const snapshot = () => { + const calls = invoke.mock.calls.filter(([name]) => name === 'update_shortcuts') + const json = calls.at(-1)?.[1].json + return json ? JSON.parse(json) : null +} +beforeEach(async () => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone OS 26_0') + Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 0 }) + window.__TAURI_INTERNALS__ = {} + await logout() + await flush() + localStorage.clear() + invoke.mockClear() + http.mockReset() +}) +it('publishes a complete library with normalized progress, fetch time and no credentials', async () => { + http.mockResolvedValueOnce(response({ items: [book], next: 'next' })) + .mockResolvedValueOnce(response({ items: [{ document: 'two', filename: 'Two', status: 'reading', percentage: 0.2, timestamp: 110 }], next: null })) + await api.books(session) + await flush() + expect(snapshot()).toMatchObject({ account: '["https://sync.test","reader"]', books: { items: [ + { document: 'one', title: 'One', percentage: 1, timestamp: 100 }, + { document: 'two', title: 'Two', percentage: 0.2 }, + ] } }) + expect(snapshot().books.savedAt).toBeGreaterThan(0) + expect(JSON.stringify(snapshot())).not.toContain('secret') +}) +it('preserves fetch time on offline reads and when restoring the app cache', async () => { + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(session) + await flush() + const saved = snapshot().books.savedAt + http.mockRejectedValue(new Error('offline')) + await api.books(session) + apiModule.syncShortcutsCache(session) + await flush() + expect(snapshot().books.savedAt).toBe(saved) +}) +it('keeps the previous complete library if a later page fails', async () => { + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(session) + await flush() + const saved = snapshot() + http.mockResolvedValueOnce(response({ items: [], next: 'next' })).mockRejectedValueOnce(new Error('offline')) + await expect(api.books(session)).rejects.toThrow('offline') + await flush() + expect(snapshot()).toEqual(saved) +}) +it('replaces one book’s clippings without retaining deletions or losing another book', async () => { + http.mockResolvedValueOnce(response({ items: [book, { ...book, document: 'two', title: 'Two' }] })) + await api.books(session) + http.mockResolvedValueOnce(response({ items: [{ document: 'one', id: 'old', text: 'old' }, { document: 'two', id: 'other', text: 'other' }] })) + await api.allClippings(session, []) + http.mockResolvedValueOnce(response({ items: [{ id: 'new', text: 'new', created_at: 200 }], more: false })) + await api.clippings(session, 'one') + await flush() + expect(snapshot().clippings.items.map((c) => c.id).sort()).toEqual(['new', 'other']) + expect(snapshot().clippings.items.find((c) => c.id === 'new').document).toBe('one') +}) +it('clears shared data on logout and ignores delayed responses', async () => { + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = api.books(session) + await logout() + resolve(response({ items: [book] })) + await pending + await flush() + expect(snapshot()).toBeNull() +}) +it('makes complete legacy per-book caches available offline without inventing freshness', async () => { + const prefix = 'crosspoint-offline:v2:reader@https://sync.test' + localStorage.setItem(`${prefix}/api/v1/progress?limit=500`, JSON.stringify({ items: [book] })) + localStorage.setItem(`${prefix}/api/v1/clippings/one?cursor=0&limit=100`, JSON.stringify({ items: [{ id: 'legacy', text: 'Saved before upgrade' }], more: false })) + http.mockRejectedValue(new Error('offline')) + await api.clippings(session, 'one') + await flush() + expect(snapshot().clippings.items).toMatchObject([{ id: 'legacy', cachedAt: null }]) +}) +it('does not republish old clippings after logout', async () => { + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = api.allClippings(session, []) + await logout() + resolve(response({ items: [{ document: 'one', id: 'old' }] })) + await pending + await flush() + expect(snapshot()).toBeNull() +}) +it('keeps distinct accounts and servers from resolving each other’s data', async () => { + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(session) + apiModule.syncShortcutsCache({ ...session, server: 'https://another.test' }) + await flush() + expect(snapshot()).toMatchObject({ account: '["https://another.test","reader"]', books: null, clippings: null }) +}) +it('retains older per-book clippings when the newer all-book response reaches its 5000-item cap', async () => { + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(session) + http.mockResolvedValueOnce(response({ items: [{ id: 'old', text: 'Old passage', created_at: 1 }], more: false })) + await api.clippings(session, 'one') + await new Promise((r) => setTimeout(r, 5)) + http.mockResolvedValueOnce(response({ items: Array.from({ length: 5000 }, (_, i) => ({ document: 'two', id: String(i), text: 'Newer', created_at: 300 })) })) + await api.allClippings(session, []) + await flush() + expect(snapshot().clippings.items.some((c) => c.id === 'old')).toBe(true) +}) +it('clears a leftover native snapshot when launched without a session', async () => { + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(session) + apiModule.syncShortcutsCache(null) + await flush() + expect(snapshot()).toBeNull() +}) +it('does not start legacy fallback requests for a signed-out account after a delayed 404', async () => { + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = api.allClippings(session, [book]) + await logout() + resolve({ ok: false, status: 404, json: async () => ({}) }) + http.mockResolvedValue(response({ items: [{ id: 'old', text: 'Old account' }], more: false })) + await pending.catch(() => {}) + await flush() + expect(snapshot()).toBeNull() + expect(http).toHaveBeenCalledTimes(1) +}) +it('publishes the legacy clipping batch once, only after every book has completed', async () => { + let finishSecond + http.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }) + .mockResolvedValueOnce(response({ items: [{ id: 'a', text: 'First', created_at: 1 }], more: false })) + .mockReturnValueOnce(new Promise((r) => { finishSecond = r })) + const pending = api.allClippings(session, [book, { ...book, document: 'two' }]) + await flush() + expect(invoke.mock.calls.filter(([name]) => name === 'update_shortcuts')).toHaveLength(0) + finishSecond(response({ items: [{ id: 'b', text: 'Second', created_at: 2 }], more: false })) + expect((await pending).map((c) => c.id)).toEqual(['b', 'a']) + await flush() + expect(invoke.mock.calls.filter(([name]) => name === 'update_shortcuts')).toHaveLength(1) + expect(snapshot().clippings.items.map((c) => c.id).sort()).toEqual(['a', 'b']) +}) +it('does not publish a legacy clipping batch that finishes after logout', async () => { + let finishSecond + http.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }) + .mockResolvedValueOnce(response({ items: [{ id: 'a', text: 'First' }], more: false })) + .mockReturnValueOnce(new Promise((r) => { finishSecond = r })) + const pending = api.allClippings(session, [book, { ...book, document: 'two' }]) + await flush() + await logout() + finishSecond(response({ items: [{ id: 'b', text: 'Second' }], more: false })) + await pending + await flush() + const publications = invoke.mock.calls.filter(([name]) => name === 'update_shortcuts') + expect(publications.map(([, args]) => args.json)).toEqual([null]) +}) +it('does not publish partial legacy clipping data if a book fetch fails', async () => { + http.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }) + .mockResolvedValueOnce(response({ items: [{ id: 'a', text: 'First' }], more: false })) + .mockRejectedValueOnce(new Error('connection lost')) + await expect(api.allClippings(session, [book, { ...book, document: 'two' }])).rejects.toThrow('connection lost') + await flush() + expect(invoke.mock.calls.filter(([name]) => name === 'update_shortcuts')).toHaveLength(0) +}) +it('clears reading caches before resolving clearData, waits for native clearing and keeps sign-in', async () => { + apiModule.saveSession(session) + localStorage.setItem('crosspoint-theme', 'dark') + localStorage.setItem('crosspoint-offline:v2:other@https://sync.test/api/v1/clippings', JSON.stringify({ items: [] })) + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(session) + http.mockResolvedValueOnce(response({ items: [{ document: 'one', id: 'old', text: 'Old' }] })) + await api.allClippings(session, []) + await flush() + let finishNative + invoke.mockImplementationOnce(() => new Promise((r) => { finishNative = r })) + http.mockResolvedValueOnce(response({ ok: true })) + let finished = false + const pending = api.clearData(session).then(() => { finished = true }) + try { + await flush() + expect(apiModule.cached.books(session)).toBeNull() + expect(apiModule.cached.clippings(session)).toBeNull() + expect(snapshot()).toBeNull() + expect(finished).toBe(false) + expect(apiModule.loadSession()).toEqual(session) + expect(localStorage.getItem('crosspoint-theme')).toBe('dark') + expect(localStorage.getItem('crosspoint-offline:v2:other@https://sync.test/api/v1/clippings')).not.toBeNull() + finishNative() + await pending + } finally { + finishNative?.() + invoke.mockReset().mockResolvedValue() + } +}) +it('does not restore reading data from a fetch that completes after clearData', async () => { + let finishFetch + http.mockReturnValueOnce(new Promise((r) => { finishFetch = r })) + const pending = api.books(session) + http.mockResolvedValueOnce(response({ ok: true })) + await api.clearData(session) + finishFetch(response({ items: [book] })) + await pending + await flush() + expect(apiModule.cached.books(session)).toBeNull() + expect(snapshot()).toBeNull() +}) +it('retains cached data if clearing reading data fails', async () => { + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(session) + await flush() + const before = snapshot() + http.mockRejectedValueOnce(new Error('offline')) + await expect(api.clearData(session)).rejects.toThrow('offline') + await flush() + expect(apiModule.cached.books(session)).toHaveLength(1) + expect(snapshot()).toEqual(before) +}) +it('does not clear a newly selected account after a delayed old-account clearData response', async () => { + let finishClear + http.mockReturnValueOnce(new Promise((r) => { finishClear = r })) + const pending = api.clearData(session) + await logout() + const other = { ...session, username: 'other' } + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(other) + finishClear(response({ ok: true })) + await pending + await flush() + expect(snapshot().account).toBe('["https://sync.test","other"]') + expect(apiModule.cached.books(other)).toHaveLength(1) +}) +it('removes known deleted clips from hub, per-book and page caches without losing older book history', async () => { + const prefix = 'crosspoint-offline:v2:reader@https://sync.test' + const clips = [{ id: 'remove', text: 'Deleted', created_at: 2 }, { id: 'keep', text: 'Older passage', created_at: 1 }] + const hub = [{ ...clips[0], document: 'one' }, ...Array.from({ length: 4999 }, (_, i) => ({ + document: 'two', id: i === 0 ? 'remove' : `other-${i}`, text: 'Other book', created_at: 3, + }))] + localStorage.setItem(`${prefix}/api/v1/progress?limit=500`, JSON.stringify({ items: [book] })) + localStorage.setItem(`${prefix}/api/v1/clippings`, JSON.stringify({ items: hub })) + localStorage.setItem(`${prefix}/api/v1/clippings:saved-at`, '200') + localStorage.setItem(`${prefix}/api/v1/clippings/one`, JSON.stringify({ items: clips })) + localStorage.setItem(`${prefix}/api/v1/clippings/one:saved-at`, '100') + localStorage.setItem(`${prefix}/api/v1/clippings/one?cursor=0&limit=100`, JSON.stringify({ items: clips, more: false })) + apiModule.syncShortcutsCache(session) + await flush() + http.mockResolvedValueOnce(response({ ok: true })) + await api.deleteClipping(session, 'one', 'remove') + await flush() + expect(snapshot().clippings.items.some((c) => c.document === 'one' && c.id === 'remove')).toBe(false) + expect(snapshot().clippings.items.some((c) => c.document === 'one' && c.id === 'keep')).toBe(true) + expect(snapshot().clippings.items.some((c) => c.document === 'two' && c.id === 'remove')).toBe(true) + expect(localStorage.getItem(`${prefix}/api/v1/clippings/one:saved-at`)).toBe('100') + // A subsequent capped hub refresh must not resurrect the deleted per-book item. + http.mockResolvedValueOnce(response({ items: [...hub.slice(1), { document: 'two', id: 'extra', text: 'Newest', created_at: 4 }] })) + await api.allClippings(session, []) + await flush() + expect(snapshot().clippings.items.filter((c) => c.document === 'one').map((c) => c.id)).toEqual(['keep']) + // Cached pages must also be pruned, or an offline book open could restore it. + http.mockRejectedValue(new Error('offline')) + expect((await api.clippings(session, 'one')).map((c) => c.id)).toEqual(['keep']) +}) +it('retains clipping caches when a server deletion fails', async () => { + http.mockResolvedValueOnce(response({ items: [{ id: 'keep', text: 'Saved' }], more: false })) + await api.clippings(session, 'one') + await flush() + const before = snapshot() + http.mockRejectedValueOnce(new Error('offline')) + await expect(api.deleteClipping(session, 'one', 'keep')).rejects.toThrow('offline') + await flush() + expect(snapshot()).toEqual(before) +}) +it('does not republish the old account when its clipping deletion completes after switching accounts', async () => { + let finishDelete + http.mockReturnValueOnce(new Promise((r) => { finishDelete = r })) + const pending = api.deleteClipping(session, 'one', 'old') + await logout() + const other = { ...session, username: 'other' } + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(other) + finishDelete(response({ ok: true })) + await pending + await flush() + expect(snapshot().account).toBe('["https://sync.test","other"]') +}) +it('keeps a successful clipping deletion successful when native publication fails', async () => { + http.mockResolvedValueOnce(response({ items: [{ id: 'remove', text: 'Saved' }], more: false })) + await api.clippings(session, 'one') + await flush() + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + invoke.mockRejectedValueOnce(new Error('native write failed')) + http.mockResolvedValueOnce(response({ ok: true })) + await expect(api.deleteClipping(session, 'one', 'remove')).resolves.toEqual({ ok: true }) + // The successful deletion still prunes offline data; later publications retry. + http.mockRejectedValueOnce(new Error('offline')) + expect(await api.clippings(session, 'one')).toEqual([]) + await flush() + expect(snapshot().clippings.items).toEqual([]) + } finally { + warning.mockRestore() + } +}) +it('keeps failed legacy batches out of later snapshots and offline book loads', async () => { + http.mockResolvedValueOnce(response({ items: [{ id: 'saved', text: 'Previously loaded' }], more: false })) + await api.clippings(session, 'one') + await flush() + http.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }) + .mockResolvedValueOnce(response({ items: [{ id: 'partial', text: 'Partial batch' }], more: false })) + .mockRejectedValueOnce(new Error('connection lost')) + await expect(api.allClippings(session, [book, { ...book, document: 'two' }])).rejects.toThrow('connection lost') + apiModule.syncShortcutsCache(session) + await flush() + expect(snapshot().clippings.items.map((c) => c.id)).toEqual(['saved']) + http.mockRejectedValueOnce(new Error('offline')) + expect((await api.clippings(session, 'one')).map((c) => c.id)).toEqual(['saved']) +}) +it('does not persist a late successful book after another legacy batch request has failed', async () => { + let finishLate + http.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }) + .mockRejectedValueOnce(new Error('connection lost')) + .mockReturnValueOnce(new Promise((r) => { finishLate = r })) + await expect(api.allClippings(session, [book, { ...book, document: 'two' }])).rejects.toThrow('connection lost') + finishLate(response({ items: [{ id: 'late', text: 'Late partial batch' }], more: false })) + await flush() + apiModule.syncShortcutsCache(session) + await flush() + expect(snapshot().clippings).toBeNull() +}) +it('records an empty legacy clipping collection and restores it offline', async () => { + http.mockResolvedValueOnce(response({ items: [] })) + await api.books(session) + http.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }) + expect(await api.allClippings(session, [])).toEqual([]) + await flush() + expect(snapshot().clippings).toEqual({ items: [] }) + expect(apiModule.cached.clippings(session)).toEqual([]) + apiModule.syncShortcutsCache(session) + http.mockRejectedValueOnce(new Error('offline')) + expect(await api.allClippings(session, [])).toEqual([]) + await flush() + expect(snapshot().clippings).toEqual({ items: [] }) +}) +it.each([ + ['Android', 'Mozilla/5.0 (Linux; Android 16)', 5], + ['macOS', 'Mozilla/5.0 (Macintosh; Intel Mac OS X)', 0], + ['Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', 5], + ['Linux', 'Mozilla/5.0 (X11; Linux x86_64)', 0], +])('skips cache reads and native IPC for Shortcuts on %s', (_, agent, touches) => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(agent) + Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: touches }) + const readCache = vi.spyOn(Object.getPrototypeOf(localStorage), 'getItem') + try { + apiModule.syncShortcutsCache(session) + expect(readCache).not.toHaveBeenCalled() + expect(invoke).not.toHaveBeenCalled() + } finally { + readCache.mockRestore() + } +}) +it.each([ + ['iPhone', 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0)', 5], + ['iPad', 'Mozilla/5.0 (iPad; CPU OS 26_0)', 5], + ['iPad desktop user agent', 'Mozilla/5.0 (Macintosh; Intel Mac OS X)', 5], +])('publishes cached data on %s', async (_, agent, touches) => { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(agent) + Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: touches }) + localStorage.setItem('crosspoint-offline:v2:reader@https://sync.test/api/v1/progress?limit=500', JSON.stringify({ items: [book] })) + apiModule.syncShortcutsCache(session) + await flush() + expect(snapshot().books.items[0].title).toBe('One') +}) +it('skips Shortcuts cache work in an iPhone browser without the native bridge', () => { + delete window.__TAURI_INTERNALS__ + const readCache = vi.spyOn(Object.getPrototypeOf(localStorage), 'getItem') + try { + apiModule.syncShortcutsCache(session) + expect(readCache).not.toHaveBeenCalled() + expect(invoke).not.toHaveBeenCalled() + } finally { + readCache.mockRestore() + } +}) + +function limitStorage(limit) { + const prototype = Object.getPrototypeOf(localStorage) + const original = prototype.setItem + return vi.spyOn(prototype, 'setItem').mockImplementation(function (key, value) { + const size = Object.keys(this).filter((k) => k !== key) + .reduce((total, k) => total + k.length + this.getItem(k).length, 0) + key.length + String(value).length + if (size > limit) throw new DOMException('Storage quota exceeded', 'QuotaExceededError') + return original.call(this, key, value) + }) +} +it('stores a single complete clipping copy when quota only permits one representation', async () => { + const clip = { id: 'large', text: 'x'.repeat(2400), created_at: 1 } + const quota = limitStorage(3500) + try { + http.mockResolvedValueOnce(response({ items: [clip], more: false })) + await api.clippings(session, 'one') + await flush() + expect(snapshot().clippings.items[0].text).toBe(clip.text) + expect(Object.keys(localStorage).filter((k) => k.includes('/clippings/one?'))).toEqual([]) + http.mockRejectedValueOnce(new Error('offline')) + expect(await api.clippings(session, 'one')).toEqual([clip]) + } finally { + quota.mockRestore() + } +}) +it('migrates complete legacy pages within single-copy storage quota', async () => { + const pageKey = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings/one?cursor=0&limit=100' + const clip = { id: 'legacy-large', text: 'x'.repeat(2400), created_at: 1 } + localStorage.setItem(pageKey, JSON.stringify({ items: [clip], more: false })) + const quota = limitStorage(3500) + try { + http.mockRejectedValue(new Error('offline')) + expect(await api.clippings(session, 'one')).toEqual([clip]) + await flush() + expect(snapshot().clippings.items[0]).toMatchObject({ id: 'legacy-large', cachedAt: null }) + expect(localStorage.getItem(pageKey)).toBeNull() + expect(await api.clippings(session, 'one')).toEqual([clip]) + } finally { + quota.mockRestore() + } +}) +it('restores legacy pages if a new complete clipping payload cannot fit', async () => { + const pageKey = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings/one?cursor=0&limit=100' + const saved = { items: [{ id: 'saved', text: 'Existing' }], more: false } + localStorage.setItem(pageKey, JSON.stringify(saved)) + const quota = limitStorage(1000) + try { + http.mockResolvedValueOnce(response({ items: [{ id: 'large', text: 'x'.repeat(2400) }], more: false })) + await api.clippings(session, 'one') + expect(JSON.parse(localStorage.getItem(pageKey))).toEqual(saved) + http.mockRejectedValueOnce(new Error('offline')) + expect(await api.clippings(session, 'one')).toEqual(saved.items) + } finally { + quota.mockRestore() + } +}) + +it.each(['hub', 'book', 'legacy'])('rejects a %s clipping read started before a successful deletion', async (source) => { + const clip = { document: 'one', id: 'remove', text: 'Removed' } + http.mockResolvedValueOnce(response({ items: [clip] })) + await api.allClippings(session, []) + let resolve + if (source === 'legacy') http.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }) + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = source === 'book' ? api.clippings(session, 'one') : api.allClippings(session, [book]) + const rejected = expect(pending).rejects.toThrow('Reading data changed') + await flush() + http.mockResolvedValueOnce(response({ ok: true })) + await api.deleteClipping(session, 'one', 'remove') + resolve(response({ items: [clip], more: false })) + await rejected + await flush() + expect(snapshot().clippings.items).toEqual([]) + http.mockRejectedValue(new Error('offline')) + expect(await api.allClippings(session, [])).toEqual([]) +}) + +it('applies both concurrent clipping deletions', async () => { + http.mockResolvedValueOnce(response({ items: ['a', 'b'].map((id) => ({ document: 'one', id })) })) + await api.allClippings(session, []) + let resolveA, resolveB + http.mockReturnValueOnce(new Promise((r) => { resolveA = r })) + .mockReturnValueOnce(new Promise((r) => { resolveB = r })) + const a = api.deleteClipping(session, 'one', 'a') + const b = api.deleteClipping(session, 'one', 'b') + resolveA(response({ ok: true })) + await a + resolveB(response({ ok: true })) + await b + expect(snapshot().clippings.items).toEqual([]) +}) + +it('does not fall back to a complete clipping copy captured before deletion', async () => { + const clip = { id: 'remove', text: 'Removed' } + http.mockResolvedValueOnce(response({ items: [clip], more: false })) + await api.clippings(session, 'one') + let reject + http.mockReturnValueOnce(new Promise((_, r) => { reject = r })) + const pending = api.clippings(session, 'one') + const rejected = expect(pending).rejects.toThrow('Reading data changed') + http.mockResolvedValueOnce(response({ ok: true })) + await api.deleteClipping(session, 'one', 'remove') + reject(new Error('offline')) + await rejected + expect(snapshot().clippings.items).toEqual([]) +}) + +it('prunes a removed book and its clippings before any library reload', async () => { + const prefix = 'crosspoint-offline:v2:reader@https://sync.test' + http.mockResolvedValueOnce(response({ items: [book, { ...book, document: 'two', title: 'Two' }] })) + await api.books(session) + http.mockResolvedValueOnce(response({ items: [{ document: 'one', id: 'remove' }, { document: 'two', id: 'keep' }] })) + await api.allClippings(session, []) + http.mockResolvedValueOnce(response({ items: [{ id: 'remove' }], more: false })) + await api.clippings(session, 'one') + localStorage.setItem(`${prefix}/api/v1/clippings/one?cursor=0&limit=100`, JSON.stringify({ items: [{ id: 'remove' }] })) + http.mockResolvedValueOnce(response({ ok: true })) + await api.removeBook(session, 'one') + expect(snapshot().books.items.map((b) => b.document)).toEqual(['two']) + expect(snapshot().clippings.items.map((c) => c.id)).toEqual(['keep']) + expect(Object.keys(localStorage).some((key) => key.startsWith(`${prefix}/api/v1/clippings/one`))).toBe(false) + http.mockRejectedValue(new Error('offline')) + expect((await api.books(session)).map((b) => b.document)).toEqual(['two']) + expect((await api.allClippings(session, [])).map((c) => c.id)).toEqual(['keep']) +}) + +it.each(['books', 'clippings'])('rejects a delayed %s read after removing its book', async (source) => { + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = source === 'books' ? api.books(session) : api.clippings(session, 'one') + const rejected = expect(pending).rejects.toThrow('Reading data changed') + http.mockResolvedValueOnce(response({ ok: true })) + await api.removeBook(session, 'one') + resolve(response({ items: source === 'books' ? [book] : [{ id: 'removed' }], more: false })) + await rejected + expect(snapshot().books).toBeNull() + expect(snapshot().clippings).toBeNull() +}) + +it('keeps cached books when removal fails', async () => { + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(session) + await flush() + const before = snapshot() + http.mockRejectedValue(new Error('offline')) + await expect(api.removeBook(session, 'one')).rejects.toThrow('offline') + expect(snapshot()).toEqual(before) + expect(apiModule.cached.books(session)).toHaveLength(1) +}) + +it('does not prune a new session when an old book removal completes', async () => { + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = api.removeBook(session, 'one') + await logout() + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(session) + resolve(response({ ok: true })) + await pending + await flush() + expect(snapshot().books.items).toHaveLength(1) +}) + +it('announces offline state when falling back to complete book clippings', async () => { + const clip = { id: 'saved', text: 'Cached passage' } + http.mockResolvedValueOnce(response({ items: [clip], more: false })) + await api.clippings(session, 'one') + const onOffline = vi.fn() + apiModule.offline.addEventListener('offline', onOffline) + try { + http.mockRejectedValueOnce(new Error('offline')) + expect(await api.clippings(session, 'one')).toEqual([clip]) + expect(onOffline).toHaveBeenCalledOnce() + } finally { + apiModule.offline.removeEventListener('offline', onOffline) + } +}) + +it('saves hub freshness with its payload when quota only permits one record', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings' + localStorage.setItem(`${key}/one`, JSON.stringify({ items: [{ id: 'old' }], savedAt: 100 })) + const fresh = [{ document: 'one', id: 'fresh' }] + const used = Object.keys(localStorage).reduce((total, k) => total + k.length + localStorage.getItem(k).length, 0) + const storage = limitStorage(used + key.length + JSON.stringify({ items: fresh, savedAt: 200 }).length) + const now = vi.spyOn(Date, 'now').mockReturnValue(200) + try { + http.mockResolvedValueOnce(response({ items: fresh })) + await api.allClippings(session, []) + await flush() + expect(snapshot().clippings.items).toMatchObject([{ id: 'fresh', cachedAt: 200 }]) + expect(JSON.parse(localStorage.getItem(key))).toEqual({ items: fresh, savedAt: 200 }) + now.mockReturnValue(300) + http.mockRejectedValueOnce(new Error('offline')) + await api.allClippings(session, []) + await flush() + expect(snapshot().clippings.items).toMatchObject([{ id: 'fresh', cachedAt: 200 }]) + } finally { + storage.mockRestore() + now.mockRestore() + } +}) + +it.each(['legacy', 'embedded'])('preserves %s hub freshness on offline reads', async (format) => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings' + const items = [{ document: 'one', id: 'old-hub' }] + localStorage.setItem(key, JSON.stringify(format === 'embedded' ? { items, savedAt: 100 } : { items })) + if (format === 'legacy') localStorage.setItem(`${key}:saved-at`, '100') + localStorage.setItem(`${key}/one`, JSON.stringify({ items: [{ id: 'newer-book' }], savedAt: 150 })) + http.mockRejectedValueOnce(new Error('offline')) + await api.allClippings(session, []) + await flush() + expect(snapshot().clippings.items).toMatchObject([{ id: 'newer-book', cachedAt: 150 }]) + expect(JSON.parse(localStorage.getItem(key))).toEqual({ items, savedAt: 100 }) +}) + +it('retains both old hub data and freshness when a replacement exceeds quota', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings' + const previous = { items: [{ document: 'one', id: 'saved' }], savedAt: 100 } + localStorage.setItem(key, JSON.stringify(previous)) + const storage = limitStorage(key.length + JSON.stringify(previous).length) + try { + http.mockResolvedValueOnce(response({ items: [{ document: 'one', id: 'new', text: 'x'.repeat(2000) }] })) + await api.allClippings(session, []) + await flush() + expect(JSON.parse(localStorage.getItem(key))).toEqual(previous) + expect(snapshot().clippings.items).toMatchObject([{ id: 'saved', cachedAt: 100 }]) + } finally { + storage.mockRestore() + } +}) + +it('saves book freshness with its payload when quota only permits one record', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/progress?limit=500' + // An upgraded cache can already contain the payload without freshness metadata. + localStorage.setItem(key, JSON.stringify({ items: [book], next: null })) + const fresh = { ...book, title: 'Updated title' } + const record = { items: [fresh], next: null, savedAt: 200 } + const storage = limitStorage(key.length + JSON.stringify(record).length) + const now = vi.spyOn(Date, 'now').mockReturnValue(200) + try { + http.mockResolvedValueOnce(response({ items: [fresh] })) + await api.books(session) + await flush() + expect(snapshot().books).toMatchObject({ savedAt: 200, items: [{ title: 'Updated title' }] }) + expect(JSON.parse(localStorage.getItem(key))).toEqual(record) + now.mockReturnValue(300) + http.mockRejectedValueOnce(new Error('offline')) + await api.books(session) + await flush() + expect(snapshot().books.savedAt).toBe(200) + } finally { + storage.mockRestore() + now.mockRestore() + } +}) + +it.each(['legacy', 'embedded'])('preserves %s book freshness on offline reads', async (format) => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/progress?limit=500' + const previous = { items: [book], next: null } + localStorage.setItem(key, JSON.stringify(format === 'embedded' ? { ...previous, savedAt: 100 } : previous)) + if (format === 'legacy') localStorage.setItem(`${key}:saved-at`, '100') + http.mockRejectedValueOnce(new Error('offline')) + await api.books(session) + await flush() + expect(snapshot().books.savedAt).toBe(100) + expect(JSON.parse(localStorage.getItem(key))).toEqual({ ...previous, savedAt: 100 }) +}) + +it('retains both old book data and freshness when a replacement exceeds quota', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/progress?limit=500' + const previous = { items: [book], next: null, savedAt: 100 } + localStorage.setItem(key, JSON.stringify(previous)) + const storage = limitStorage(key.length + JSON.stringify(previous).length) + try { + http.mockResolvedValueOnce(response({ items: [{ ...book, title: 'x'.repeat(2000) }] })) + await api.books(session) + await flush() + expect(JSON.parse(localStorage.getItem(key))).toEqual(previous) + expect(snapshot().books).toMatchObject({ savedAt: 100, items: [{ title: book.title }] }) + } finally { + storage.mockRestore() + } +}) + +it('invalidates merged clipping caches before reloads and preserves unrelated per-book copies', async () => { + const prefix = 'crosspoint-offline:v2:reader@https://sync.test' + http.mockResolvedValueOnce(response({ items: [book, { ...book, document: 'two' }] })) + await api.books(session) + http.mockResolvedValueOnce(response({ items: [{ document: 'one', id: 'source' }, { document: 'two', id: 'target' }] })) + await api.allClippings(session, []) + for (const doc of ['one', 'two', 'other']) { + http.mockResolvedValueOnce(response({ items: [{ id: doc }], more: false })) + await api.clippings(session, doc) + } + localStorage.setItem(`${prefix}/api/v1/clippings/one?cursor=0&limit=100`, JSON.stringify({ items: [{ id: 'legacy' }] })) + http.mockResolvedValueOnce(response({ ok: true })) + await api.merge(session, 'one', 'two') + expect(JSON.parse(localStorage.getItem(`${prefix}/api/v1/clippings`))).toMatchObject({ items: [], truncated: true }) + expect(Object.keys(localStorage).some((key) => /^.*\/clippings\/(one|two)([?:]|$)/.test(key))).toBe(false) + expect(localStorage.getItem(`${prefix}/api/v1/clippings/other`)).not.toBeNull() + expect(snapshot().clippings.items.map((c) => c.document)).toEqual(['other']) + // Successful library reload cannot expose passages under an obsolete identity. + http.mockResolvedValueOnce(response({ items: [{ ...book, document: 'two' }] })) + await api.books(session) + http.mockRejectedValueOnce(new Error('offline')) + await expect(api.clippings(session, 'two')).rejects.toThrow('offline') + await flush() + expect(snapshot().clippings.items.map((c) => c.document)).toEqual(['other']) +}) + +it('rejects clipping reads started before a merge', async () => { + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = api.allClippings(session, []) + const rejected = expect(pending).rejects.toThrow('Reading data changed') + http.mockResolvedValueOnce(response({ ok: true })) + await api.merge(session, 'one', 'two') + resolve(response({ items: [{ document: 'one', id: 'old' }] })) + await rejected + expect(snapshot().clippings).toBeNull() +}) + +it('keeps clipping caches when a merge fails', async () => { + http.mockResolvedValueOnce(response({ items: [{ document: 'one', id: 'keep' }] })) + await api.allClippings(session, []) + await flush() + const previous = snapshot() + http.mockRejectedValueOnce(new Error('offline')) + await expect(api.merge(session, 'one', 'two')).rejects.toThrow('offline') + expect(snapshot()).toEqual(previous) +}) + +it('ignores an old session merge completion', async () => { + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = api.merge(session, 'one', 'two') + await logout() + http.mockResolvedValueOnce(response({ items: [{ document: 'one', id: 'new-session' }] })) + await api.allClippings(session, []) + resolve(response({ ok: true })) + await pending + await flush() + expect(snapshot().clippings.items.map((c) => c.id)).toEqual(['new-session']) +}) + +it('retains complete hub deletion evidence when a later hub reaches its cap', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings' + const old = [{ id: 'deleted', text: 'Gone' }, { id: 'kept', text: 'Old text' }] + localStorage.setItem(`${key}/one`, JSON.stringify({ items: old, savedAt: 100 })) + localStorage.setItem(`${key}/one?cursor=0&limit=100`, JSON.stringify({ items: old, more: false })) + localStorage.setItem(`${key}/empty?cursor=0&limit=100`, JSON.stringify({ items: [{ id: 'also-deleted' }], more: false })) + const now = vi.spyOn(Date, 'now').mockReturnValue(200) + try { + http.mockResolvedValueOnce(response({ items: [{ document: 'one', id: 'kept', text: 'Edited text' }] })) + await api.allClippings(session, []) + now.mockReturnValue(300) + http.mockResolvedValueOnce(response({ items: Array.from({ length: 5000 }, (_, i) => ({ document: 'other', id: `c${i}` })) })) + await api.allClippings(session, []) + await flush() + expect(snapshot().clippings.items.filter((c) => c.document === 'one')).toMatchObject([{ id: 'kept', text: 'Edited text', cachedAt: 200 }]) + expect(snapshot().clippings.items.some((c) => c.id === 'deleted' || c.id === 'also-deleted')).toBe(false) + http.mockRejectedValue(new Error('offline')) + expect(await api.clippings(session, 'one')).toEqual([{ id: 'kept', text: 'Edited text' }]) + expect(await api.clippings(session, 'empty')).toEqual([]) + } finally { + now.mockRestore() + } +}) + +it('preserves per-book data newer than a complete cached hub', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings' + localStorage.setItem(key, JSON.stringify({ items: [], savedAt: 100 })) + const recent = { items: [{ id: 'newer' }], savedAt: 200 } + localStorage.setItem(`${key}/one`, JSON.stringify(recent)) + http.mockRejectedValueOnce(new Error('offline')) + await api.allClippings(session, []) + await flush() + expect(JSON.parse(localStorage.getItem(`${key}/one`))).toEqual(recent) + expect(snapshot().clippings.items).toMatchObject([{ id: 'newer', cachedAt: 200 }]) +}) + +it('preserves hub freshness when reconciling older book copies offline', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings' + localStorage.setItem(key, JSON.stringify({ items: [], savedAt: 200 })) + localStorage.setItem(`${key}/one`, JSON.stringify({ items: [{ id: 'deleted' }], savedAt: 100 })) + http.mockRejectedValueOnce(new Error('offline')) + await api.allClippings(session, []) + expect(JSON.parse(localStorage.getItem(`${key}/one`))).toEqual({ items: [], savedAt: 200 }) +}) + +it('keeps book clipping order and extra location fields when reconciling a hub', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings' + localStorage.setItem(`${key}/one`, JSON.stringify({ items: [{ id: 'first', spine: 1, start_page: 12, color: 'yellow' }], savedAt: 100 })) + http.mockResolvedValueOnce(response({ items: [ + { document: 'one', id: 'last', spine: 2, text: 'Later passage' }, + { document: 'one', id: 'first', spine: 1, text: 'Edited passage' }, + ] })) + await api.allClippings(session, []) + http.mockRejectedValueOnce(new Error('offline')) + expect(await api.clippings(session, 'one')).toEqual([ + { id: 'first', spine: 1, start_page: 12, color: 'yellow', text: 'Edited passage' }, + { id: 'last', spine: 2, text: 'Later passage' }, + ]) +}) + +it('discards disproven copies if replacing them from the hub fails', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings' + localStorage.setItem(`${key}/one`, JSON.stringify({ items: [{ id: 'deleted' }], savedAt: 100 })) + const proto = Object.getPrototypeOf(localStorage) + const setItem = proto.setItem + const storage = vi.spyOn(proto, 'setItem').mockImplementation(function (k, value) { + if (k === `${key}/one`) throw new DOMException('Quota exceeded', 'QuotaExceededError') + return setItem.call(this, k, value) + }) + try { + http.mockResolvedValueOnce(response({ items: [] })) + await api.allClippings(session, []) + await flush() + expect(localStorage.getItem(`${key}/one`)).toBeNull() + expect(snapshot().clippings.items).toEqual([]) + } finally { + storage.mockRestore() + } +}) + +it('preserves a book copy with an equal timestamp when replaying an offline hub', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings' + localStorage.setItem(key, JSON.stringify({ items: [], savedAt: 200 })) + const per = { items: [{ id: 'possibly-newer' }], savedAt: 200 } + localStorage.setItem(`${key}/one`, JSON.stringify(per)) + http.mockRejectedValueOnce(new Error('offline')) + await api.allClippings(session, []) + expect(JSON.parse(localStorage.getItem(`${key}/one`))).toEqual(per) +}) + +it.each([false, true])('preserves unrelated hub-only clippings during a merge (capped: %s)', async (capped) => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings' + const unrelated = Array.from({ length: capped ? 4998 : 1 }, (_, i) => ({ document: 'other', id: `other-${i}`, text: 'Unrelated' })) + localStorage.setItem(key, JSON.stringify({ items: [ + { document: 'one', id: 'source' }, { document: 'two', id: 'target' }, ...unrelated, + ], savedAt: 100 })) + if (capped) localStorage.setItem(`${key}/other`, JSON.stringify({ items: [{ id: 'history' }], savedAt: 90 })) + http.mockResolvedValueOnce(response({ ok: true })) + await api.merge(session, 'one', 'two') + const saved = JSON.parse(localStorage.getItem(key)) + expect(saved).toEqual({ items: unrelated, savedAt: 100, truncated: true }) + expect(snapshot().clippings.items.filter((c) => c.id !== 'history')).toHaveLength(unrelated.length) + if (capped) expect(snapshot().clippings.items.some((c) => c.id === 'history')).toBe(true) + // The normal target refresh must retain unrelated books in the native copy. + http.mockResolvedValueOnce(response({ items: [{ id: 'merged' }], more: false })) + await api.clippings(session, 'two') + await flush() + expect(snapshot().clippings.items.filter((c) => c.document === 'other')).toHaveLength(unrelated.length + Number(capped)) + http.mockRejectedValueOnce(new Error('offline')) + expect(await api.allClippings(session, [])).toEqual(unrelated) + await flush() + expect(snapshot().clippings.items.some((c) => c.document === 'two' && c.id === 'merged')).toBe(true) +}) + +it('invalidates both merged books when the library reload is offline', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/progress?limit=500' + const books = [book, { ...book, document: 'two', title: 'Target' }, { ...book, document: 'other', title: 'Unrelated' }] + localStorage.setItem(key, JSON.stringify({ items: books, next: null, savedAt: 100 })) + http.mockResolvedValueOnce(response({ ok: true })) + await api.merge(session, 'one', 'two') + expect(apiModule.cached.books(session).map((b) => b.document)).toEqual(['other']) + expect(snapshot().books.items.map((b) => b.document)).toEqual(['other']) + expect(snapshot().books.savedAt).toBe(100) + http.mockRejectedValueOnce(new Error('offline')) + expect((await api.books(session)).map((b) => b.document)).toEqual(['other']) + await flush() + expect(snapshot().books.items.map((b) => b.document)).toEqual(['other']) + expect(snapshot().books.savedAt).toBe(100) +}) + +it('rejects library reads begun before a merge', async () => { + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = api.books(session) + const rejected = expect(pending).rejects.toThrow('Reading data changed') + http.mockResolvedValueOnce(response({ ok: true })) + await api.merge(session, 'one', 'two') + resolve(response({ items: [book, { ...book, document: 'two' }] })) + await rejected + expect(apiModule.cached.books(session)).toBeNull() +}) + +it('retains cached books when the merge request fails', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/progress?limit=500' + const previous = { items: [book, { ...book, document: 'two' }], next: null, savedAt: 100 } + localStorage.setItem(key, JSON.stringify(previous)) + http.mockRejectedValueOnce(new Error('offline')) + await expect(api.merge(session, 'one', 'two')).rejects.toThrow('offline') + expect(JSON.parse(localStorage.getItem(key))).toEqual(previous) +}) + +const bookEdits = [ + ['status', () => api.setStatus(session, 'one', 'finished'), { ...book, status: 'finished' }], + ['metadata', () => api.setInfo(session, 'one', { title: 'Edited title', author: 'Edited author' }), { ...book, title: 'Edited title', author: 'Edited author' }], + ['dates', () => api.setDates(session, 'one', { finished_date: '2026-10-28' }), { ...book, status: 'finished' }], +] + +it.each(bookEdits)('rejects pre-%s library reads that finish after the edited library loads', async (_, edit, updated) => { + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = api.books(session) + const rejected = expect(pending).rejects.toThrow('Reading data changed') + http.mockResolvedValueOnce(response({ ok: true })) + await edit() + http.mockResolvedValueOnce(response({ items: [updated] })) + await api.books(session) + await flush() + const fresh = snapshot() + resolve(response({ items: [{ ...book, status: 'reading', title: 'Old title', author: 'Old author' }] })) + await rejected + await flush() + expect(snapshot()).toEqual(fresh) + expect(apiModule.cached.books(session)[0]).toMatchObject({ ...updated, percentage: updated.status === 'finished' ? 1 : updated.percentage }) +}) + +it.each(bookEdits)('does not invalidate library reads when a %s edit fails', async (_, edit) => { + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = api.books(session) + http.mockRejectedValueOnce(new Error('offline')) + await expect(edit()).rejects.toThrow('offline') + resolve(response({ items: [book] })) + await expect(pending).resolves.toHaveLength(1) + expect(apiModule.cached.books(session)).toHaveLength(1) +}) + +it.each(bookEdits)('ignores an old session %s edit completing during a new library read', async (_, edit) => { + let resolveEdit, resolveBooks + http.mockReturnValueOnce(new Promise((r) => { resolveEdit = r })) + const editing = edit() + await logout() + http.mockReturnValueOnce(new Promise((r) => { resolveBooks = r })) + const reading = api.books(session) + resolveEdit(response({ ok: true })) + await editing + resolveBooks(response({ items: [book] })) + await expect(reading).resolves.toHaveLength(1) + await flush() + expect(snapshot().books.items).toHaveLength(1) +}) + +it.each([ + ['status', () => api.setStatus(session, 'one', 'finished'), { document: 'one', status: 'finished', status_at: 200 }, { status: 'finished', percentage: 1 }], + ['metadata', () => api.setInfo(session, 'one', { title: ' Edited ', author: '' }), { document: 'one', title: 'Edited', author: null }, { title: 'Edited', author: null }], + ['dates', () => api.setDates(session, 'one', { finished_date: '2026-10-28' }), { document: 'one', status: 'finished', finished_date: 1793145600 }, { status: 'finished', percentage: 1 }], +])('patches confirmed %s edits before an offline library reload', async (_, edit, result, expected) => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/progress?limit=500' + const initial = { ...book, status: 'reading', percentage: 0.3, title: 'Old title', author: 'Old author' } + localStorage.setItem(key, JSON.stringify({ items: [initial, { ...book, document: 'other' }], next: null, savedAt: 100 })) + http.mockResolvedValueOnce(response(result)) + await expect(edit()).resolves.toEqual(result) + expect(apiModule.cached.books(session)[0]).toMatchObject(expected) + expect(snapshot().books.items[0]).toMatchObject({ ...expected, author: expected.author ?? (expected.author === null ? '' : 'Old author') }) + expect(snapshot().books.savedAt).toBe(100) + http.mockRejectedValueOnce(new Error('offline')) + const reloaded = await api.books(session) + await flush() + expect(reloaded[0]).toMatchObject(expected) + expect(reloaded[1].document).toBe('other') + expect(snapshot().books.savedAt).toBe(100) + expect(snapshot().books.items[0].timestamp).toBe(initial.timestamp) +}) + +it('invalidates library data if an edited title cannot fit in its cached record', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/progress?limit=500' + const record = { items: [book], next: null, savedAt: 100 } + localStorage.setItem(key, JSON.stringify(record)) + const storage = limitStorage(key.length + JSON.stringify(record).length) + try { + http.mockResolvedValueOnce(response({ document: 'one', title: 'x'.repeat(500) })) + await api.setInfo(session, 'one', { title: 'x'.repeat(500) }) + expect(apiModule.cached.books(session)).toBeNull() + expect(snapshot().books).toBeNull() + } finally { + storage.mockRestore() + } +}) + +it('requires a new library copy when clearing a manual status needs server derivation', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/progress?limit=500' + localStorage.setItem(key, JSON.stringify({ items: [{ ...book, status: 'paused' }], next: null, savedAt: 100 })) + http.mockResolvedValueOnce(response({ document: 'one', status: null, status_at: 200 })) + await api.setStatus(session, 'one', null) + expect(apiModule.cached.books(session)).toBeNull() + expect(snapshot().books).toBeNull() +}) + +it.each(['book-after-hub', 'hub-after-book', 'same-book'])('rejects older overlapping clipping results (%s)', async (order) => { + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = order === 'hub-after-book' ? api.allClippings(session, []) : api.clippings(session, 'one') + const rejected = expect(pending).rejects.toThrow('Reading data changed') + http.mockResolvedValueOnce(response({ items: [], more: false })) + if (order === 'book-after-hub') await api.allClippings(session, []) + else await api.clippings(session, 'one') + await flush() + const fresh = snapshot() + resolve(response({ items: [{ document: 'one', id: 'deleted', text: 'Deleted elsewhere' }], more: false })) + await rejected + await flush() + expect(snapshot()).toEqual(fresh) + apiModule.syncShortcutsCache(session) + await flush() + expect(snapshot().clippings.items).toEqual([]) +}) + +it('allows independent book clipping requests to finish in either order', async () => { + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const first = api.clippings(session, 'one') + http.mockResolvedValueOnce(response({ items: [{ id: 'second' }], more: false })) + await api.clippings(session, 'two') + resolve(response({ items: [{ id: 'first' }], more: false })) + await first + await flush() + expect(snapshot().clippings.items.map((c) => c.id).sort()).toEqual(['first', 'second']) +}) + +it('does not invalidate an online clipping request when a newer request fails', async () => { + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = api.clippings(session, 'one') + http.mockRejectedValueOnce(new Error('offline')) + await expect(api.allClippings(session, [])).rejects.toThrow('offline') + resolve(response({ items: [{ id: 'valid' }], more: false })) + await expect(pending).resolves.toEqual([{ id: 'valid' }]) +}) + +it('does not let an older legacy clipping batch overwrite a newer hub', async () => { + let resolve + http.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }) + .mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = api.allClippings(session, [book]) + const rejected = expect(pending).rejects.toThrow('Reading data changed') + await flush() + http.mockResolvedValueOnce(response({ items: [] })) + await api.allClippings(session, []) + resolve(response({ items: [{ id: 'deleted' }], more: false })) + await rejected + await flush() + expect(snapshot().clippings.items).toEqual([]) +}) + +it('does not let a newer offline fallback cancel a pending online clipping refresh', async () => { + localStorage.setItem('crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings', JSON.stringify({ items: [], savedAt: 100 })) + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = api.clippings(session, 'one') + http.mockRejectedValueOnce(new Error('offline')) + await api.allClippings(session, []) + resolve(response({ items: [{ id: 'fresh' }], more: false })) + await expect(pending).resolves.toEqual([{ id: 'fresh' }]) + await flush() + expect(snapshot().clippings.items).toMatchObject([{ id: 'fresh' }]) +}) + +it.each(['one', 'two'])('tracks only online books in a mixed legacy clipping batch (pending: %s)', async (pendingDoc) => { + const prefix = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/clippings' + localStorage.setItem(`${prefix}/two`, JSON.stringify({ items: [{ id: 'cached-two' }], savedAt: 100 })) + let resolve + http.mockReturnValueOnce(new Promise((r) => { resolve = r })) + const pending = api.clippings(session, pendingDoc) + const settled = pendingDoc === 'one' + ? expect(pending).rejects.toThrow('Reading data changed') + : expect(pending).resolves.toEqual([{ id: 'fresh-two' }]) + http.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }) + .mockResolvedValueOnce(response({ items: [], more: false })) + .mockRejectedValueOnce(new Error('offline')) + await api.allClippings(session, [book, { ...book, document: 'two' }]) + resolve(response({ items: [{ id: pendingDoc === 'one' ? 'deleted' : 'fresh-two' }], more: false })) + await settled + await flush() + expect(snapshot().clippings.items.some((c) => c.id === 'deleted')).toBe(false) + if (pendingDoc === 'two') expect(snapshot().clippings.items.some((c) => c.id === 'fresh-two')).toBe(true) +}) + +it('requires fresh merge destination progress while retaining unrelated cached books', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/progress?limit=500' + const source = { ...book, status: 'reading', percentage: 0.8, timestamp: 20 } + const target = { ...book, document: 'two', status: 'reading', percentage: 0.2, timestamp: 10 } + const other = { ...book, document: 'other', status: 'reading', percentage: 0.1, timestamp: 5 } + localStorage.setItem(key, JSON.stringify({ items: [source, target, other], next: null, savedAt: 100 })) + http.mockResolvedValueOnce(response({ document: 'two', merged: 'one', stats: true })) + await api.merge(session, 'one', 'two') + expect(apiModule.cached.books(session)).toEqual([other]) + expect(snapshot().books.items.map((b) => b.document)).toEqual(['other']) + http.mockRejectedValueOnce(new Error('offline')) + expect(await api.books(session)).toEqual([other]) + const merged = { ...target, percentage: 0.8, timestamp: 201 } + http.mockResolvedValueOnce(response({ items: [merged, other] })) + await api.books(session) + await flush() + expect(snapshot().books.items[0]).toMatchObject({ document: 'two', percentage: 0.8, timestamp: 201 }) +}) + +it('waits for queued snapshot writes and native removal before completing logout', async () => { + apiModule.saveSession(session) + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/progress?limit=500' + localStorage.setItem(key, JSON.stringify({ items: [book], savedAt: 100 })) + let finishWrite, finishClear + invoke.mockImplementationOnce(() => new Promise((r) => { finishWrite = r })) + .mockImplementationOnce(() => new Promise((r) => { finishClear = r })) + const writing = apiModule.syncShortcutsCache(session) + await flush() + let completed = false + const signingOut = logout() + Promise.resolve(signingOut).then(() => { completed = true }) + await flush() + expect(completed).toBe(false) + expect(apiModule.loadSession()).toEqual(session) + finishWrite() + await writing + await flush() + expect(invoke.mock.calls.at(-1)[1].json).toBeNull() + expect(completed).toBe(false) + finishClear() + await signingOut + expect(apiModule.loadSession()).toBeNull() +}) + +it('surfaces failed snapshot removal and allows logout to be retried', async () => { + apiModule.saveSession(session) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + invoke.mockRejectedValueOnce(new Error('disk unavailable')) + await expect(logout()).rejects.toThrow('disk unavailable') + expect(apiModule.loadSession()).toEqual(session) + await logout() + expect(apiModule.loadSession()).toBeNull() + } finally { + warn.mockRestore() + } +}) + +it('blocks snapshot publication and discards reads started while logout is pending', async () => { + apiModule.saveSession(session) + let finishClear, finishRead + invoke.mockImplementationOnce(() => new Promise((r) => { finishClear = r })) + const signingOut = logout() + await flush() + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(session) + await flush() + expect(invoke.mock.calls.every(([, args]) => args.json === null)).toBe(true) + http.mockReturnValueOnce(new Promise((r) => { finishRead = r })) + const reading = api.books(session) + finishClear() + await signingOut + finishRead(response({ items: [book] })) + await reading + await flush() + expect(apiModule.cached.books(session)).toBeNull() + expect(invoke.mock.calls.every(([, args]) => args.json === null)).toBe(true) +}) + +it('shares one native removal for overlapping logout attempts', async () => { + let finish + invoke.mockImplementationOnce(() => new Promise((r) => { finish = r })) + const first = logout() + const second = logout() + expect(first).toBe(second) + await flush() + expect(invoke).toHaveBeenCalledOnce() + finish() + await first +}) + +it.each(['single-page', 'later-page'])('rejects an older library refresh after a newer one completes (%s)', async (paging) => { + let finish + if (paging === 'later-page') http.mockResolvedValueOnce(response({ items: [{ ...book, status: 'reading', percentage: 0.2 }], next: 'next' })) + http.mockReturnValueOnce(new Promise((r) => { finish = r })) + const pending = api.books(session) + const rejected = expect(pending).rejects.toThrow('Reading data changed') + await flush() + const updated = { ...book, status: 'reading', percentage: 0.7, timestamp: 200 } + http.mockResolvedValueOnce(response({ items: [updated] })) + await api.books(session) + await flush() + const fresh = snapshot() + finish(response({ items: [{ ...book, status: 'reading', percentage: 0.2, timestamp: 100 }] })) + await rejected + await flush() + expect(apiModule.cached.books(session)).toEqual([updated]) + expect(snapshot()).toEqual(fresh) +}) + +it('does not let a later offline library fallback supersede a pending online refresh', async () => { + const key = 'crosspoint-offline:v2:reader@https://sync.test/api/v1/progress?limit=500' + localStorage.setItem(key, JSON.stringify({ items: [book], next: null, savedAt: 100 })) + let finish + http.mockReturnValueOnce(new Promise((r) => { finish = r })) + const pending = api.books(session) + http.mockRejectedValueOnce(new Error('offline')) + await api.books(session) + const fresh = { ...book, status: 'reading', percentage: 0.5, timestamp: 200 } + finish(response({ items: [fresh] })) + await expect(pending).resolves.toEqual([fresh]) + await flush() + expect(snapshot().books.items[0]).toMatchObject({ percentage: 0.5, timestamp: 200 }) +}) + +it('publishes newly discovered book clippings after an initially empty legacy batch', async () => { + http.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }) + await api.allClippings(session, []) + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(session) + http.mockResolvedValueOnce({ ok: false, status: 404, json: async () => ({}) }) + .mockResolvedValueOnce(response({ items: [{ id: 'new', text: 'Newly discovered passage' }], more: false })) + await api.allClippings(session, [book]) + await flush() + expect(snapshot().books.items.map((b) => b.document)).toEqual(['one']) + expect(snapshot().clippings.items).toMatchObject([{ document: 'one', id: 'new', text: 'Newly discovered passage' }]) +}) diff --git a/docs/superpowers/plans/2026-10-28-ios-shortcuts.md b/docs/superpowers/plans/2026-10-28-ios-shortcuts.md new file mode 100644 index 00000000..b4e40b16 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-ios-shortcuts.md @@ -0,0 +1,23 @@ +# Offline iOS Shortcuts Implementation Plan + +**Goal:** Read-only iOS Shortcuts actions backed by the app's existing offline data. +**Architecture:** API cache writes publish a credential-free native snapshot in private Application Support storage. Swift App Intents in the main app read structured books and clippings without network requests. +**Spec:** docs/superpowers/specs/2026-10-28-ios-shortcuts-design.md + +## Completed work + +- [x] Publish complete cached books and clippings through a Tauri command; restore on launch and clear on logout. +- [x] Preserve fetch timestamps on offline reads, reject late account responses, and retain per-book passages omitted by the capped global clipping response. +- [x] Atomically replace the native snapshot and expose Find Books, Get Book and Find Clippings as structured App Intents. +- [x] Register Swift sources and AppIntents framework in the existing iOS build. +- [x] Document fields, cache population, clipping limits and device verification. + +## Validation + +- App suite: 239 tests across 21 files, including 11 Shortcuts cache tests. +- Swift store: 12 checks covering filters, dates, account identity and private cache loading. +- Rust: 2 standalone persistence/path tests. +- iOS 16 device-target Swift typecheck and isolated iOS Simulator compilation/metadata extraction. +- App production build and Xcode project plist validation. + +Scope excludes changes to signing, widgets, build tooling and developer-team setup. diff --git a/docs/superpowers/specs/2026-10-28-ios-shortcuts-design.md b/docs/superpowers/specs/2026-10-28-ios-shortcuts-design.md new file mode 100644 index 00000000..56e62c66 --- /dev/null +++ b/docs/superpowers/specs/2026-10-28-ios-shortcuts-design.md @@ -0,0 +1,19 @@ +# Offline iOS Shortcuts + +Approved scope: read-only actions for finding books and clippings, backed by the app's existing offline data. Books expose title, author, status, progress, last sync and cache freshness; clippings expose text, note, chapter, creation date, book and cache freshness. Signing out clears shared data. + +## Approach + +Mirror the existing WebView cache into the main app’s private Application Support directory through a Tauri command. Native Swift App Intents read that snapshot without network calls or WebView execution. Preserve server fetch timestamps on offline reads and app restarts. Publish complete book lists and update clippings when fetched by the app, including individual book fetches. Never export credentials. Partition entity identifiers by server/account and reject stale account results. + +Alternatives considered: native queries fetching the server each time (poor offline behavior); a separate native sync process (duplicates the app's data flow). The user selected reusing the app's offline data. + +## Boundaries + +Keep iOS 16 support. Find Books filters status and text; Find Clippings filters book, text and creation date. Get Book resolves a selected book. Return structured entities, not formatted summaries. Uncached data produces an actionable error; a cached empty collection returns no results. No write actions or automatic background fetching. Clippings availability follows what the app has loaded; document the server's all-clippings limit. + +## Validation + +Exercise publication, offline freshness, complete pagination, per-book replacement, sign-out and in-flight account isolation. Test native decoding, filtering and entity account isolation. Compile Swift App Intents for iOS if an SDK is available; otherwise report that limitation explicitly and provide device verification steps. + +App Intents run in the main app target and read private Application Support storage. Widget sharing and signing settings are unaffected.