From cde8b3e003612bff3a96a19c3e0e7a1f682acbf2 Mon Sep 17 00:00:00 2001 From: John Payne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:47:25 -0400 Subject: [PATCH 01/26] Add offline iOS Shortcuts for books and clippings --- app/README.md | 45 ++++++ app/scripts/shortcuts-tests/main.swift | 31 ++++ .../crosspoint-sync-app/ReadingIntents.swift | 153 ++++++++++++++++++ .../crosspoint-sync-app/ShortcutsStore.swift | 110 +++++++++++++ .../project.pbxproj | 12 ++ app/src-tauri/gen/apple/project.yml | 1 + app/src-tauri/src/lib.rs | 3 + app/src-tauri/src/shortcuts_cache.rs | 52 ++++++ app/src-tauri/src/widget.rs | 18 +++ app/src/App.jsx | 8 +- app/src/api.js | 80 ++++++++- app/src/shortcutsCache.js | 11 ++ app/src/shortcutsCache.test.js | 137 ++++++++++++++++ .../plans/2026-10-28-ios-shortcuts.md | 23 +++ .../specs/2026-10-28-ios-shortcuts-design.md | 19 +++ 15 files changed, 696 insertions(+), 7 deletions(-) create mode 100644 app/scripts/shortcuts-tests/main.swift create mode 100644 app/src-tauri/gen/apple/Sources/crosspoint-sync-app/ReadingIntents.swift create mode 100644 app/src-tauri/gen/apple/Sources/crosspoint-sync-app/ShortcutsStore.swift create mode 100644 app/src-tauri/src/shortcuts_cache.rs create mode 100644 app/src/shortcutsCache.js create mode 100644 app/src/shortcutsCache.test.js create mode 100644 docs/superpowers/plans/2026-10-28-ios-shortcuts.md create mode 100644 docs/superpowers/specs/2026-10-28-ios-shortcuts-design.md 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 2851fb95..8f713f2c 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 */; }; @@ -52,6 +55,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; }; @@ -111,6 +117,7 @@ CEA14FCBC23E088D806332E2 /* UIKit.framework in Frameworks */, 07E120DC37D4E3BA35660834 /* WebKit.framework in Frameworks */, E952F1320553223057A59239 /* WidgetKit.framework in Frameworks */, + A65E756C12B6408AA9100005 /* AppIntents.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -154,6 +161,8 @@ children = ( 917F15BCA5CFDD43F04EE308 /* main.mm */, C0FDA312FF333FEE325F3784 /* WidgetReload.swift */, + A65E756C12B6408AA9100004 /* ReadingIntents.swift */, + A65E756C12B6408AA9100002 /* ShortcutsStore.swift */, 967C288423B4F5D0AD41A6A9 /* bindings */, ); path = "crosspoint-sync-app"; @@ -182,6 +191,7 @@ BEF7345547BE54CBD0CAA946 /* UIKit.framework */, 1C1D61425D6C89B023A68699 /* WebKit.framework */, FE54F751F09F6D338C764211 /* WidgetKit.framework */, + A65E756C12B6408AA9100006 /* AppIntents.framework */, ); name = Frameworks; sourceTree = ""; @@ -381,6 +391,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 440d4cdf..d11f84da 100644 --- a/app/src-tauri/gen/apple/project.yml +++ b/app/src-tauri/gen/apple/project.yml @@ -105,6 +105,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..e0063164 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) @@ -437,7 +438,10 @@ export default function App() { const theme = useTheme() const [session, setSession] = useState(loadSession) useEffect(() => { - if (!session) clearWidget() + if (!session) { + clearWidget() + syncShortcutsCache(null) + } }, [session]) if (!session) return (window.scrollTo(0, 0), setSession(s))} /> return ( diff --git a/app/src/api.js b/app/src/api.js index 0c389bd1..70afe8bf 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -1,4 +1,5 @@ import SparkMD5 from 'spark-md5' +import { publishShortcuts } 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); @@ -52,6 +53,7 @@ export function logout() { sessionVersion++ localStorage.removeItem(KEY) clearOffline() + publishShortcuts(null) } // Offline: every successful GET is kept on the device; when the network is down @@ -62,6 +64,7 @@ const OFFLINE_PREFIX = 'crosspoint-offline:' const OFFLINE = `${OFFLINE_PREFIX}v2:` export const offline = new EventTarget() let sessionVersion = 0 +const offlineCopies = new WeakSet() function clearOffline() { for (const key of Object.keys(localStorage)) { @@ -90,7 +93,9 @@ async function call(session, path, init = {}, keepOffline = true, { offlineFallb 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) { @@ -132,7 +137,10 @@ async function allProgress(session) { next = page.next } const all = { items, next: null } - if (version === sessionVersion) keep(`${OFFLINE}${session.username}@${session.server}${PROGRESS}`, all) + if (version === sessionVersion) { + keep(`${OFFLINE}${session.username}@${session.server}${PROGRESS}`, all, !offlineCopies.has(first)) + syncShortcutsCache(session) + } return all } @@ -182,6 +190,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 +211,52 @@ 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 (!isApp) 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 = 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) + const at = read(`${key}:saved-at`) + if (!per) continue + if (all && (at ?? 0) < (allAt ?? 0)) { + if (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. + publishShortcuts({ + account: JSON.stringify([session.server, session.username]), + books: 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) @@ -298,10 +353,13 @@ export const api = { 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 try { - return (await call(s, '/api/v1/clippings')).items + const data = await call(s, '/api/v1/clippings', {}, true, { timestampSaved: true }) + if (version === sessionVersion) syncShortcutsCache(s) + return data.items } catch (e) { - if (e.status !== 404) throw e + if (version !== sessionVersion || 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) } @@ -311,14 +369,26 @@ export const api = { setStatus: (s, doc, status) => call(s, `/api/v1/documents/${doc}/status`, { method: 'PUT', body: JSON.stringify({ status }) }), async clippings(s, doc) { + const version = sessionVersion const items = [] + let fromCache = false for (let cursor = 0; ; ) { const page = await call(s, `/api/v1/clippings/${doc}?cursor=${cursor}&limit=100`) + fromCache ||= offlineCopies.has(page) items.push(...page.items) if (!page.more) break cursor = page.cursor } - return items.filter((c) => !c.deleted) + const live = 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)) + if (version === sessionVersion) { + // Migrate a complete legacy offline copy, but never overwrite a newer + // complete snapshot with potentially mixed-age cached pages. + if (!fromCache || !saved(s, `/api/v1/clippings/${doc}`)) { + keep(`${OFFLINE}${s.username}@${s.server}/api/v1/clippings/${doc}`, { items: live }, !fromCache) + } + syncShortcutsCache(s) + } + return live }, } diff --git a/app/src/shortcutsCache.js b/app/src/shortcutsCache.js new file mode 100644 index 00000000..74105d16 --- /dev/null +++ b/app/src/shortcutsCache.js @@ -0,0 +1,11 @@ +import { invoke } from '@tauri-apps/api/core' + +// Keep native writes in order, especially a clear queued after an in-flight write. +let writes = Promise.resolve() +export function publishShortcuts(snapshot) { + if (!('__TAURI_INTERNALS__' in window)) 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)) +} diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js new file mode 100644 index 00000000..20a5dc2e --- /dev/null +++ b/app/src/shortcutsCache.test.js @@ -0,0 +1,137 @@ +// @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.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 () => { + 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) + 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, []) + 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]) + 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) +}) 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. From 143e6e2969b19a1f94f2e25a9b185e984b7313ea Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:06:34 -0400 Subject: [PATCH 02/26] Batch legacy clipping snapshots for Shortcuts --- app/src/api.js | 7 ++++--- app/src/shortcutsCache.test.js | 36 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 70afe8bf..8766ce17 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -360,7 +360,8 @@ export const api = { return data.items } catch (e) { if (version !== sessionVersion || 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 }))))) + const per = await Promise.all(books.map((b) => api.clippings(s, b.document, { syncShortcuts: false }).then((items) => items.map((c) => ({ ...c, document: b.document }))))) + if (version === sessionVersion) syncShortcutsCache(s) return per.flat().sort((a, b) => b.created_at - a.created_at) } }, @@ -368,7 +369,7 @@ export const api = { 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) { + async clippings(s, doc, { syncShortcuts = true } = {}) { const version = sessionVersion const items = [] let fromCache = false @@ -387,7 +388,7 @@ export const api = { if (!fromCache || !saved(s, `/api/v1/clippings/${doc}`)) { keep(`${OFFLINE}${s.username}@${s.server}/api/v1/clippings/${doc}`, { items: live }, !fromCache) } - syncShortcutsCache(s) + if (syncShortcuts) syncShortcutsCache(s) } return live }, diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index 20a5dc2e..f62e2ce1 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -135,3 +135,39 @@ it('does not start legacy fallback requests for a signed-out account after a del 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() + 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) +}) From 196fa75285e029d2a4877fbd6da8a182981c9507 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:09:15 -0400 Subject: [PATCH 03/26] Clear offline Shortcuts data when reading data is reset --- app/src/api.js | 16 ++++++-- app/src/shortcutsCache.js | 1 + app/src/shortcutsCache.test.js | 67 ++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 8766ce17..7d999d7a 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -66,9 +66,10 @@ export const offline = new EventTarget() let sessionVersion = 0 const offlineCopies = new WeakSet() -function clearOffline() { +function clearOffline(session) { + 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) } } @@ -341,7 +342,16 @@ export const api = { setProfile: (s, enabled) => call(s, '/api/v1/profile', { method: 'PUT', body: JSON.stringify({ enabled }) }), // 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' }), // Manual reading dates: { start_date, finished_date } as 'YYYY-MM-DD', null clears, omitted = unchanged. diff --git a/app/src/shortcutsCache.js b/app/src/shortcutsCache.js index 74105d16..9c414e3f 100644 --- a/app/src/shortcutsCache.js +++ b/app/src/shortcutsCache.js @@ -8,4 +8,5 @@ export function publishShortcuts(snapshot) { 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 index f62e2ce1..b86e5675 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -171,3 +171,70 @@ it('does not publish partial legacy clipping data if a book fetch fails', async 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) + 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) +}) From b503763d9159078274f818d3434d942a12b2b97b Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:10:20 -0400 Subject: [PATCH 04/26] Remove deleted clippings from offline Shortcuts caches --- app/src/api.js | 32 +++++++++++++++++--- app/src/shortcutsCache.test.js | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 7d999d7a..8c4743a4 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -121,6 +121,23 @@ function keep(key, data, timestampSaved = false) { } } +// Remove a successful deletion from every saved representation of that book. +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) => 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 PROGRESS = '/api/v1/progress?limit=500' // Every synced book: follows the server's pages, then saves the whole list as the first page's offline copy. @@ -230,7 +247,7 @@ export function syncShortcutsCache(session) { const at = read(`${key}:saved-at`) if (!per) continue if (all && (at ?? 0) < (allAt ?? 0)) { - if (all.items.length >= 5000) { + 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 }))) @@ -242,7 +259,7 @@ export function syncShortcutsCache(session) { clippings.items.push(...per.items.map((c) => ({ ...c, document: doc, cachedAt: at }))) } // Allowlist fields rather than exposing server responses or session credentials. - publishShortcuts({ + return publishShortcuts({ account: JSON.stringify([session.server, session.username]), books: books ? { savedAt: read(`${prefix}${PROGRESS}:saved-at`), @@ -375,8 +392,15 @@ export const api = { return per.flat().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 }] }) }), + 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) { + removeCachedClipping(s, doc, id) + await syncShortcutsCache(s) + } + return result + }, setStatus: (s, doc, status) => call(s, `/api/v1/documents/${doc}/status`, { method: 'PUT', body: JSON.stringify({ status }) }), async clippings(s, doc, { syncShortcuts = true } = {}) { diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index b86e5675..4ac0eab3 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -238,3 +238,56 @@ it('does not clear a newly selected account after a delayed old-account clearDat 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') + 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"]') +}) From 31564c696ae6690be328c082ae43ab8ac9238e34 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:21:54 -0400 Subject: [PATCH 05/26] Keep clipping deletion successful when native cache update fails --- app/src/api.js | 3 ++- app/src/shortcutsCache.test.js | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/src/api.js b/app/src/api.js index 8c4743a4..8ae4c1e4 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -397,7 +397,8 @@ export const api = { const result = await call(s, `/api/v1/clippings/${doc}`, { method: 'PUT', body: JSON.stringify({ items: [{ id, deleted: 1 }] }) }) if (version === sessionVersion) { removeCachedClipping(s, doc, id) - await syncShortcutsCache(s) + // Native publication already logs failures; the server deletion succeeded. + await syncShortcutsCache(s)?.catch(() => {}) } return result }, diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index 4ac0eab3..8cd3c628 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -291,3 +291,21 @@ it('does not republish the old account when its clipping deletion completes afte 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() + } +}) From 2cd6247c8950e8112c2036ca5f77af42724e8657 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:13:46 -0400 Subject: [PATCH 06/26] Stage legacy clipping caches until the full batch succeeds --- app/src/api.js | 59 ++++++++++++++++++++++------------ app/src/shortcutsCache.test.js | 26 +++++++++++++++ 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 8ae4c1e4..4b2a296b 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -138,6 +138,35 @@ function removeCachedClipping(session, document, id) { } } +// Stage complete clipping reads so a failed legacy batch cannot alter later snapshots. +async function readClippings(s, doc) { + const items = [] + const pages = [] + let fromCache = false + for (let cursor = 0; ; ) { + const path = `/api/v1/clippings/${doc}?cursor=${cursor}&limit=100` + const page = await call(s, path, {}, true, { store: false }) + const cached = offlineCopies.has(page) + fromCache ||= cached + if (!cached) pages.push({ path, data: page }) + items.push(...page.items) + if (!page.more) break + cursor = page.cursor + } + const live = 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 { items: live, pages, fromCache } +} + +function cacheClippings(s, doc, result) { + const prefix = `${OFFLINE}${s.username}@${s.server}` + for (const { path, data } of result.pages) keep(`${prefix}${path}`, data) + // Migrate legacy offline data without replacing newer complete aggregates. + if (!result.fromCache || !saved(s, `/api/v1/clippings/${doc}`)) { + keep(`${prefix}/api/v1/clippings/${doc}`, { items: result.items }, !result.fromCache) + } +} + const PROGRESS = '/api/v1/progress?limit=500' // Every synced book: follows the server's pages, then saves the whole list as the first page's offline copy. @@ -387,9 +416,13 @@ export const api = { return data.items } catch (e) { if (version !== sessionVersion || e.status !== 404) throw e - const per = await Promise.all(books.map((b) => api.clippings(s, b.document, { syncShortcuts: false }).then((items) => items.map((c) => ({ ...c, document: b.document }))))) - if (version === sessionVersion) syncShortcutsCache(s) - return per.flat().sort((a, b) => b.created_at - a.created_at) + const per = await Promise.all(books.map(async (b) => ({ document: b.document, ...await readClippings(s, b.document) }))) + if (version === sessionVersion) { + 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) } }, async deleteClipping(s, doc, id) { @@ -406,25 +439,11 @@ export const api = { call(s, `/api/v1/documents/${doc}/status`, { method: 'PUT', body: JSON.stringify({ status }) }), async clippings(s, doc, { syncShortcuts = true } = {}) { const version = sessionVersion - const items = [] - let fromCache = false - for (let cursor = 0; ; ) { - const page = await call(s, `/api/v1/clippings/${doc}?cursor=${cursor}&limit=100`) - fromCache ||= offlineCopies.has(page) - items.push(...page.items) - if (!page.more) break - cursor = page.cursor - } - const live = 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)) + const result = await readClippings(s, doc) if (version === sessionVersion) { - // Migrate a complete legacy offline copy, but never overwrite a newer - // complete snapshot with potentially mixed-age cached pages. - if (!fromCache || !saved(s, `/api/v1/clippings/${doc}`)) { - keep(`${OFFLINE}${s.username}@${s.server}/api/v1/clippings/${doc}`, { items: live }, !fromCache) - } + cacheClippings(s, doc, result) if (syncShortcuts) syncShortcutsCache(s) } - return live + return result.items }, } diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index 8cd3c628..75fa0ae9 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -309,3 +309,29 @@ it('keeps a successful clipping deletion successful when native publication fail 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() +}) From 6377f2820a5d57443f76065d8d73753fe3ba5824 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:14:58 -0400 Subject: [PATCH 07/26] Cache successful empty legacy clipping collections --- app/src/api.js | 1 + app/src/shortcutsCache.test.js | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/app/src/api.js b/app/src/api.js index 4b2a296b..357d14b2 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -418,6 +418,7 @@ export const api = { if (version !== sessionVersion || e.status !== 404) throw e const per = await Promise.all(books.map(async (b) => ({ document: b.document, ...await readClippings(s, b.document) }))) if (version === sessionVersion) { + if (!per.length) keep(`${OFFLINE}${s.username}@${s.server}/api/v1/clippings`, { items: [] }, true) for (const result of per) cacheClippings(s, result.document, result) syncShortcutsCache(s) } diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index 75fa0ae9..e3b95bea 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -335,3 +335,17 @@ it('does not persist a late successful book after another legacy batch request h 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: [] }) +}) From 1731b39e1d398f3eccf7388de2e5c843e49c5311 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:17:01 -0400 Subject: [PATCH 08/26] Skip Shortcuts snapshot work outside native iOS --- app/src/api.js | 4 ++-- app/src/shortcutsCache.js | 11 ++++++++- app/src/shortcutsCache.test.js | 44 ++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 357d14b2..6002c79d 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -1,5 +1,5 @@ import SparkMD5 from 'spark-md5' -import { publishShortcuts } from './shortcutsCache.js' +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); @@ -260,7 +260,7 @@ const bookList = (r) => // A credential-free mirror of the data already saved by this app. No requests. export function syncShortcutsCache(session) { - if (!isApp) return + if (!supportsShortcuts()) return if (!session) return publishShortcuts(null) const prefix = `${OFFLINE}${session.username}@${session.server}` const books = saved(session, PROGRESS) diff --git a/app/src/shortcutsCache.js b/app/src/shortcutsCache.js index 9c414e3f..26336669 100644 --- a/app/src/shortcutsCache.js +++ b/app/src/shortcutsCache.js @@ -1,9 +1,18 @@ 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 (!('__TAURI_INTERNALS__' in window)) return + 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. diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index e3b95bea..e9f54f5a 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -4,6 +4,7 @@ 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__ = {} +const userAgent = 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') @@ -19,6 +20,9 @@ const snapshot = () => { return json ? JSON.parse(json) : null } beforeEach(async () => { + userAgent.mockReturnValue('iPhone OS 26_0') + Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 0 }) + window.__TAURI_INTERNALS__ = {} logout() await flush() localStorage.clear() @@ -349,3 +353,43 @@ it('records an empty legacy clipping collection and restores it offline', async 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() + } +}) From 5794cece66ff455a1d45a5899359e3cd4961c718 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:34:54 -0400 Subject: [PATCH 09/26] Store one complete offline clipping representation --- app/src/api.js | 49 +++++++++++++++++---------- app/src/shortcutsCache.test.js | 61 ++++++++++++++++++++++++++++++++-- 2 files changed, 91 insertions(+), 19 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 6002c79d..0394a125 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -140,30 +140,45 @@ function removeCachedClipping(session, document, id) { // 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 = [] - const pages = [] let fromCache = false - for (let cursor = 0; ; ) { - const path = `/api/v1/clippings/${doc}?cursor=${cursor}&limit=100` - const page = await call(s, path, {}, true, { store: false }) - const cached = offlineCopies.has(page) - fromCache ||= cached - if (!cached) pages.push({ path, data: page }) - items.push(...page.items) - if (!page.more) break - cursor = page.cursor + 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) throw error + return { items: complete.items, fromCache: true } } const live = 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 { items: live, pages, fromCache } + return { items: live, fromCache } } function cacheClippings(s, doc, result) { - const prefix = `${OFFLINE}${s.username}@${s.server}` - for (const { path, data } of result.pages) keep(`${prefix}${path}`, data) - // Migrate legacy offline data without replacing newer complete aggregates. - if (!result.fromCache || !saved(s, `/api/v1/clippings/${doc}`)) { - keep(`${prefix}/api/v1/clippings/${doc}`, { items: result.items }, !result.fromCache) + 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 : Date.now() } + // 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)) + } 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 */ } + } } } @@ -273,8 +288,8 @@ export function syncShortcutsCache(session) { if (!key.startsWith(perPrefix) || key.includes('?') || key.endsWith(':saved-at')) continue const doc = key.slice(perPrefix.length) const per = read(key) - const at = read(`${key}:saved-at`) 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. diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index e9f54f5a..c15a3426 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -4,7 +4,7 @@ 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__ = {} -const userAgent = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone OS 26_0') +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') @@ -20,7 +20,7 @@ const snapshot = () => { return json ? JSON.parse(json) : null } beforeEach(async () => { - userAgent.mockReturnValue('iPhone OS 26_0') + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone OS 26_0') Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 0 }) window.__TAURI_INTERNALS__ = {} logout() @@ -393,3 +393,60 @@ it('skips Shortcuts cache work in an iPhone browser without the native bridge', 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() + } +}) From bf1754b1e8b663cd67873c559e0efb339657a9a0 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:35:43 -0400 Subject: [PATCH 10/26] Reject clipping reads that predate a deletion --- app/src/api.js | 22 ++++++++++++++- app/src/shortcutsCache.test.js | 50 ++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/app/src/api.js b/app/src/api.js index 0394a125..8feeb5bd 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -64,6 +64,13 @@ const OFFLINE_PREFIX = 'crosspoint-offline:' const OFFLINE = `${OFFLINE_PREFIX}v2:` export const offline = new EventTarget() let sessionVersion = 0 +let readingVersion = 0 + +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(session) { @@ -76,6 +83,8 @@ function clearOffline(session) { // 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 @@ -91,6 +100,7 @@ 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')) @@ -105,6 +115,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) @@ -153,7 +164,7 @@ async function readClippings(s, doc) { cursor = page.cursor } } catch (error) { - if (!complete || error.status) throw error + if (!complete || error.status || error.stale) throw error return { items: complete.items, fromCache: true } } const live = items.filter((c) => !c.deleted) @@ -188,6 +199,7 @@ const PROGRESS = '/api/v1/progress?limit=500' // A page that fails leaves the previous complete copy in place. async function allProgress(session) { const version = sessionVersion + const reading = readingVersion const first = await call(session, PROGRESS, {}, true, { store: false }) const items = [...first.items] for (let next = first.next, seen = new Set(); next; ) { @@ -198,6 +210,7 @@ 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, !offlineCopies.has(first)) @@ -425,13 +438,17 @@ export const api = { // Every clipping across books; older servers without /clippings get fetched book by book. async allClippings(s, books) { const version = sessionVersion + const reading = readingVersion try { const data = await call(s, '/api/v1/clippings', {}, true, { timestampSaved: true }) + assertReadingVersion(version, reading) if (version === sessionVersion) syncShortcutsCache(s) return data.items } catch (e) { 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) if (version === sessionVersion) { if (!per.length) keep(`${OFFLINE}${s.username}@${s.server}/api/v1/clippings`, { items: [] }, true) for (const result of per) cacheClippings(s, result.document, result) @@ -445,6 +462,7 @@ export const api = { 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(() => {}) @@ -455,7 +473,9 @@ export const api = { call(s, `/api/v1/documents/${doc}/status`, { method: 'PUT', body: JSON.stringify({ status }) }), async clippings(s, doc, { syncShortcuts = true } = {}) { const version = sessionVersion + const reading = readingVersion const result = await readClippings(s, doc) + assertReadingVersion(version, reading) if (version === sessionVersion) { cacheClippings(s, doc, result) if (syncShortcuts) syncShortcutsCache(s) diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index c15a3426..ecc9861f 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -450,3 +450,53 @@ it('restores legacy pages if a new complete clipping payload cannot fit', async 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([]) +}) From 4b5e8d659ef80df7fb097ac3b05867de80d700c2 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:36:50 -0400 Subject: [PATCH 11/26] Prune offline book data immediately after removal --- app/src/api.js | 31 +++++++++++++++++-- app/src/shortcutsCache.test.js | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 8feeb5bd..a37cb4ee 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -133,6 +133,7 @@ function keep(key, data, timestampSaved = 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}` @@ -141,7 +142,7 @@ function removeCachedClipping(session, document, id) { if (!hub && key !== bookKey && !key.startsWith(`${bookKey}?`)) continue const data = read(key) if (!Array.isArray(data?.items)) continue - const items = data.items.filter((c) => c.id !== id || (hub && c.document !== document)) + 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 } : {} @@ -195,6 +196,23 @@ function cacheClippings(s, doc, result) { const PROGRESS = '/api/v1/progress?limit=500' +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) { @@ -427,7 +445,16 @@ export const api = { 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) }), diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index ecc9861f..6a339eaf 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -500,3 +500,59 @@ it('does not fall back to a complete clipping copy captured before deletion', as 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') + logout() + http.mockResolvedValueOnce(response({ items: [book] })) + await api.books(session) + resolve(response({ ok: true })) + await pending + await flush() + expect(snapshot().books.items).toHaveLength(1) +}) From 2bdacaee01742ffaf93cc5dcbd3bf38a82739487 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:23:58 -0400 Subject: [PATCH 12/26] Announce offline state for cached clipping fallback --- app/src/api.js | 1 + app/src/shortcutsCache.test.js | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/app/src/api.js b/app/src/api.js index a37cb4ee..9f1836f4 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -166,6 +166,7 @@ async function readClippings(s, doc) { } } 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) diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index 6a339eaf..aa603ce1 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -556,3 +556,18 @@ it('does not prune a new session when an old book removal completes', async () = 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) + } +}) From 1a5ac46be313ab56b4131b8e437c1156421b5c87 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:24:49 -0400 Subject: [PATCH 13/26] Store clipping hub freshness atomically with payload --- app/src/api.js | 20 ++++++++++--- app/src/shortcutsCache.test.js | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 9f1836f4..6305050d 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -195,6 +195,15 @@ function cacheClippings(s, doc, result) { } } +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. + keep(key, { ...data, savedAt }) +} + const PROGRESS = '/api/v1/progress?limit=500' function removeCachedBook(session, document) { @@ -312,7 +321,7 @@ export function syncShortcutsCache(session) { const prefix = `${OFFLINE}${session.username}@${session.server}` const books = saved(session, PROGRESS) const all = saved(session, '/api/v1/clippings') - const allAt = read(`${prefix}/api/v1/clippings:saved-at`) + 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/` @@ -468,9 +477,12 @@ export const api = { const version = sessionVersion const reading = readingVersion try { - const data = await call(s, '/api/v1/clippings', {}, true, { timestampSaved: true }) + const data = await call(s, '/api/v1/clippings', {}, true, { store: false }) assertReadingVersion(version, reading) - if (version === sessionVersion) syncShortcutsCache(s) + if (version === sessionVersion) { + cacheClippingsHub(s, data) + syncShortcutsCache(s) + } return data.items } catch (e) { if (version !== sessionVersion || e.status !== 404) throw e @@ -478,7 +490,7 @@ export const api = { const per = await Promise.all(books.map(async (b) => ({ document: b.document, ...await readClippings(s, b.document) }))) assertReadingVersion(version, reading) if (version === sessionVersion) { - if (!per.length) keep(`${OFFLINE}${s.username}@${s.server}/api/v1/clippings`, { items: [] }, true) + if (!per.length) cacheClippingsHub(s, { items: [] }) for (const result of per) cacheClippings(s, result.document, result) syncShortcutsCache(s) } diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index aa603ce1..207fd66d 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -571,3 +571,56 @@ it('announces offline state when falling back to complete book clippings', async 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() + } +}) From c96ede6cdb9851045e217a503692f8c800bdaaa3 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:47 -0400 Subject: [PATCH 14/26] Store library freshness atomically with book data --- app/src/api.js | 9 ++++-- app/src/shortcutsCache.test.js | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 6305050d..717d4cea 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -241,7 +241,12 @@ async function allProgress(session) { assertReadingVersion(version, reading) const all = { items, next: null } if (version === sessionVersion) { - keep(`${OFFLINE}${session.username}@${session.server}${PROGRESS}`, all, !offlineCopies.has(first)) + 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 @@ -347,7 +352,7 @@ export function syncShortcutsCache(session) { return publishShortcuts({ account: JSON.stringify([session.server, session.username]), books: books ? { - savedAt: read(`${prefix}${PROGRESS}:saved-at`), + 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, diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index 207fd66d..df549896 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -624,3 +624,56 @@ it('retains both old hub data and freshness when a replacement exceeds quota', a 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() + } +}) From aa3c5dac3f598fd37790cf9c5daa25c8706442b0 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:17:50 -0400 Subject: [PATCH 15/26] Invalidate clipping snapshots after merging books --- app/src/api.js | 25 +++++++++++++- app/src/shortcutsCache.test.js | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/app/src/api.js b/app/src/api.js index 717d4cea..768e77ab 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -204,6 +204,20 @@ function cacheClippingsHub(s, data) { keep(key, { ...data, savedAt }) } +function invalidateClippingCaches(s, documents) { + const hubKey = `${OFFLINE}${s.username}@${s.server}/api/v1/clippings` + // The hub's document identities and both complete book copies are no longer + // authoritative after a merge. A subsequent fetch resolves server conflicts. + 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 removeCachedBook(session, document) { @@ -475,7 +489,16 @@ export const api = { setInfo: (s, doc, patch) => call(s, `/api/v1/documents/${doc}/info`, { method: 'PUT', body: JSON.stringify(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]) + 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) { diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index df549896..74abd5de 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -677,3 +677,64 @@ it('retains both old book data and freshness when a replacement exceeds quota', 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(localStorage.getItem(`${prefix}/api/v1/clippings`)).toBeNull() + 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') + 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']) +}) From 90a1b1b49c5caf61b5e71cd70dae1883fac2b32d Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:19:43 -0400 Subject: [PATCH 16/26] Retire clipping copies disproven by complete hub refreshes --- app/src/api.js | 38 ++++++++++++-- app/src/shortcutsCache.test.js | 90 ++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 768e77ab..a7c24b13 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -127,8 +127,10 @@ 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 } } @@ -150,6 +152,8 @@ function removeCachedClipping(session, document, id) { } } +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}`) @@ -169,17 +173,16 @@ async function readClippings(s, doc) { offline.dispatchEvent(new Event('offline')) return { items: complete.items, fromCache: true } } - const live = 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)) + const live = items.filter((c) => !c.deleted).sort(clippingOrder) return { items: live, fromCache } } -function cacheClippings(s, doc, result) { +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 : Date.now() } + : { 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`) @@ -187,11 +190,13 @@ function cacheClippings(s, doc, result) { 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 } } @@ -201,7 +206,30 @@ function cacheClippingsHub(s, data) { ? Object.hasOwn(data, 'savedAt') ? data.savedAt : read(`${key}:saved-at`) : Date.now() // One atomic write keeps payload and freshness consistent even at quota. - keep(key, { ...data, savedAt }) + 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) { diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index 74abd5de..84b1abba 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -738,3 +738,93 @@ it('ignores an old session merge completion', async () => { 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) +}) From 688c86d540084599616e71d02d94f9149ee9f006 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:31:32 -0400 Subject: [PATCH 17/26] Preserve unrelated hub clippings during book merges --- app/src/api.js | 14 ++++++++++---- app/src/shortcutsCache.test.js | 26 +++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index a7c24b13..2be4ee8f 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -234,10 +234,16 @@ function cacheClippingsHub(s, data) { function invalidateClippingCaches(s, documents) { const hubKey = `${OFFLINE}${s.username}@${s.server}/api/v1/clippings` - // The hub's document identities and both complete book copies are no longer - // authoritative after a merge. A subsequent fetch resolves server conflicts. - localStorage.removeItem(hubKey) - localStorage.removeItem(`${hubKey}:saved-at`) + 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}` diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index 84b1abba..2a2a35f3 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -691,7 +691,7 @@ it('invalidates merged clipping caches before reloads and preserves unrelated pe 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(localStorage.getItem(`${prefix}/api/v1/clippings`)).toBeNull() + 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']) @@ -828,3 +828,27 @@ it('preserves a book copy with an equal timestamp when replaying an offline hub' 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) +}) From cfa128adc7d163b5149b570667346759e0e67f40 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:32:35 -0400 Subject: [PATCH 18/26] Remove merged source books from the offline library --- app/src/api.js | 1 + app/src/shortcutsCache.test.js | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/app/src/api.js b/app/src/api.js index 2be4ee8f..1befd628 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -529,6 +529,7 @@ export const api = { if (version === sessionVersion) { readingVersion++ invalidateClippingCaches(s, [document, into]) + removeCachedBook(s, document) await syncShortcutsCache(s)?.catch(() => {}) } return result diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index 2a2a35f3..68645d39 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -852,3 +852,40 @@ it.each([false, true])('preserves unrelated hub-only clippings during a merge (c await flush() expect(snapshot().clippings.items.some((c) => c.document === 'two' && c.id === 'merged')).toBe(true) }) + +it('removes a merged source from cached 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(['two', 'other']) + expect(snapshot().books.items.map((b) => b.document)).toEqual(['two', 'other']) + expect(snapshot().books.savedAt).toBe(100) + http.mockRejectedValueOnce(new Error('offline')) + expect((await api.books(session)).map((b) => b.document)).toEqual(['two', 'other']) + await flush() + expect(snapshot().books.items.map((b) => b.document)).toEqual(['two', '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) +}) From 01c5ede44a0153833c50c2c22b9363b793304cba Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:13:55 -0400 Subject: [PATCH 19/26] Invalidate older library reads after successful book edits --- app/src/api.js | 15 +++++++--- app/src/shortcutsCache.test.js | 50 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 1befd628..6cfcd71d 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -123,6 +123,14 @@ 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++ + return result +} + function keep(key, data, timestampSaved = false) { try { localStorage.setItem(key, JSON.stringify(data)) @@ -519,8 +527,8 @@ export const api = { 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. async merge(s, document, into, stats = true) { @@ -572,8 +580,7 @@ export const api = { } return result }, - setStatus: (s, doc, status) => - call(s, `/api/v1/documents/${doc}/status`, { method: 'PUT', body: JSON.stringify({ status }) }), + setStatus: (s, doc, status) => editBook(s, doc, 'status', { status }), async clippings(s, doc, { syncShortcuts = true } = {}) { const version = sessionVersion const reading = readingVersion diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index 68645d39..8143c6e4 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -889,3 +889,53 @@ it('retains cached books when the merge request fails', async () => { 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() + 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) +}) From bf7a765a4af2b237a0deeb53384a97fcbc7155f3 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:33:37 -0400 Subject: [PATCH 20/26] Patch offline books from confirmed edit responses --- app/src/api.js | 37 ++++++++++++++++++++++++++- app/src/shortcutsCache.test.js | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/app/src/api.js b/app/src/api.js index 6cfcd71d..9c7c6316 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -127,7 +127,11 @@ 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++ + if (version === sessionVersion) { + readingVersion++ + patchCachedBook(session, result.document ?? document, field, result) + await syncShortcutsCache(session)?.catch(() => {}) + } return result } @@ -262,6 +266,37 @@ function invalidateClippingCaches(s, documents) { 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}` diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index 8143c6e4..798ee3f8 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -939,3 +939,49 @@ it.each(bookEdits)('ignores an old session %s edit completing during a new libra 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() +}) From c677a5c78743ce0c6085dd2ef8a3e402947c7974 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:34:43 -0400 Subject: [PATCH 21/26] Reject superseded overlapping clipping reads --- app/src/api.js | 28 +++++++++++ app/src/shortcutsCache.test.js | 90 ++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+) diff --git a/app/src/api.js b/app/src/api.js index 9c7c6316..44018df3 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -65,6 +65,21 @@ const OFFLINE = `${OFFLINE_PREFIX}v2:` export const offline = new EventTarget() let sessionVersion = 0 let readingVersion = 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) { @@ -74,6 +89,8 @@ function assertReadingVersion(session, reading) { const offlineCopies = new WeakSet() function clearOffline(session) { + 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(prefix)) localStorage.removeItem(key) @@ -582,9 +599,11 @@ export const api = { async allClippings(s, books) { const version = sessionVersion const reading = readingVersion + const sequence = ++clippingReadSequence try { 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) @@ -595,6 +614,13 @@ export const api = { 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) @@ -619,8 +645,10 @@ export const api = { 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) diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index 798ee3f8..ec0c89f0 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -985,3 +985,93 @@ it('requires a new library copy when clearing a manual status needs server deriv 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) +}) + From f0a6b21fcd5c57cff615984c2a096f231d318448 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:37:25 -0400 Subject: [PATCH 22/26] Invalidate stale destination progress after book merges --- app/src/api.js | 3 +++ app/src/shortcutsCache.test.js | 28 +++++++++++++++++++++++----- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/src/api.js b/app/src/api.js index 44018df3..18c214eb 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -590,6 +590,9 @@ export const api = { 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 diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index ec0c89f0..1094cc58 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -853,19 +853,19 @@ it.each([false, true])('preserves unrelated hub-only clippings during a merge (c expect(snapshot().clippings.items.some((c) => c.document === 'two' && c.id === 'merged')).toBe(true) }) -it('removes a merged source from cached books when the library reload is offline', async () => { +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(['two', 'other']) - expect(snapshot().books.items.map((b) => b.document)).toEqual(['two', 'other']) + 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(['two', 'other']) + expect((await api.books(session)).map((b) => b.document)).toEqual(['other']) await flush() - expect(snapshot().books.items.map((b) => b.document)).toEqual(['two', 'other']) + expect(snapshot().books.items.map((b) => b.document)).toEqual(['other']) expect(snapshot().books.savedAt).toBe(100) }) @@ -1075,3 +1075,21 @@ it.each(['one', 'two'])('tracks only online books in a mixed legacy clipping bat 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 }) +}) From 0d0fb0f625de803db92301fd6f2bbac8ef745584 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:03:08 -0400 Subject: [PATCH 23/26] Await confirmed Shortcuts data removal before signing out --- app/src/App.jsx | 40 +++++++++++-- app/src/App.logout.test.jsx | 101 +++++++++++++++++++++++++++++++++ app/src/Settings.jsx | 2 +- app/src/api.js | 12 +++- app/src/api.test.js | 14 ++--- app/src/shortcutsCache.test.js | 92 ++++++++++++++++++++++++++---- 6 files changed, 236 insertions(+), 25 deletions(-) create mode 100644 app/src/App.logout.test.jsx diff --git a/app/src/App.jsx b/app/src/App.jsx index e0063164..e00a5dcd 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -437,26 +437,56 @@ function Home({ session, onSession, onLogout, theme }) { export default function App() { const theme = useTheme() const [session, setSession] = useState(loadSession) + const [signOut, setSignOut] = useState(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) { clearWidget() syncShortcutsCache(null) } }, [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..80d32fe7 --- /dev/null +++ b/app/src/App.logout.test.jsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +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() +}) diff --git a/app/src/Settings.jsx b/app/src/Settings.jsx index 9c4350df..f35c4e32 100644 --- a/app/src/Settings.jsx +++ b/app/src/Settings.jsx @@ -852,7 +852,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 18c214eb..6aa1cf55 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -50,10 +50,16 @@ export function saveSession(session) { } export function logout() { + if (logoutPending) return logoutPending sessionVersion++ - localStorage.removeItem(KEY) clearOffline() - publishShortcuts(null) + 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 @@ -65,6 +71,7 @@ const OFFLINE = `${OFFLINE_PREFIX}v2:` export const offline = new EventTarget() let sessionVersion = 0 let readingVersion = 0 +let logoutPending = null let clippingReadSequence = 0 let latestClippingRead = 0 let latestHubRead = 0 @@ -430,6 +437,7 @@ const bookList = (r) => // 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) diff --git a/app/src/api.test.js b/app/src/api.test.js index b6420e61..334fe07e 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() }) @@ -61,10 +61,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) }) @@ -91,7 +91,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') @@ -122,7 +122,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' }] })) @@ -205,7 +205,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.test.js b/app/src/shortcutsCache.test.js index 1094cc58..8d6ec63f 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -23,7 +23,7 @@ beforeEach(async () => { vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('iPhone OS 26_0') Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 0 }) window.__TAURI_INTERNALS__ = {} - logout() + await logout() await flush() localStorage.clear() invoke.mockClear() @@ -77,7 +77,7 @@ 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) - logout() + await logout() resolve(response({ items: [book] })) await pending await flush() @@ -96,7 +96,7 @@ it('does not republish old clippings after logout', async () => { let resolve http.mockReturnValueOnce(new Promise((r) => { resolve = r })) const pending = api.allClippings(session, []) - logout() + await logout() resolve(response({ items: [{ document: 'one', id: 'old' }] })) await pending await flush() @@ -131,7 +131,7 @@ it('does not start legacy fallback requests for a signed-out account after a del let resolve http.mockReturnValueOnce(new Promise((r) => { resolve = r })) const pending = api.allClippings(session, [book]) - logout() + await logout() resolve({ ok: false, status: 404, json: async () => ({}) }) http.mockResolvedValue(response({ items: [{ id: 'old', text: 'Old account' }], more: false })) await pending.catch(() => {}) @@ -160,7 +160,7 @@ it('does not publish a legacy clipping batch that finishes after logout', async .mockReturnValueOnce(new Promise((r) => { finishSecond = r })) const pending = api.allClippings(session, [book, { ...book, document: 'two' }]) await flush() - logout() + await logout() finishSecond(response({ items: [{ id: 'b', text: 'Second' }], more: false })) await pending await flush() @@ -232,7 +232,7 @@ it('does not clear a newly selected account after a delayed old-account clearDat let finishClear http.mockReturnValueOnce(new Promise((r) => { finishClear = r })) const pending = api.clearData(session) - logout() + await logout() const other = { ...session, username: 'other' } http.mockResolvedValueOnce(response({ items: [book] })) await api.books(other) @@ -286,7 +286,7 @@ it('does not republish the old account when its clipping deletion completes afte let finishDelete http.mockReturnValueOnce(new Promise((r) => { finishDelete = r })) const pending = api.deleteClipping(session, 'one', 'old') - logout() + await logout() const other = { ...session, username: 'other' } http.mockResolvedValueOnce(response({ items: [book] })) await api.books(other) @@ -548,7 +548,7 @@ 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') - logout() + await logout() http.mockResolvedValueOnce(response({ items: [book] })) await api.books(session) resolve(response({ ok: true })) @@ -730,7 +730,7 @@ it('ignores an old session merge completion', async () => { let resolve http.mockReturnValueOnce(new Promise((r) => { resolve = r })) const pending = api.merge(session, 'one', 'two') - logout() + await logout() http.mockResolvedValueOnce(response({ items: [{ document: 'one', id: 'new-session' }] })) await api.allClippings(session, []) resolve(response({ ok: true })) @@ -929,7 +929,7 @@ it.each(bookEdits)('ignores an old session %s edit completing during a new libra let resolveEdit, resolveBooks http.mockReturnValueOnce(new Promise((r) => { resolveEdit = r })) const editing = edit() - logout() + await logout() http.mockReturnValueOnce(new Promise((r) => { resolveBooks = r })) const reading = api.books(session) resolveEdit(response({ ok: true })) @@ -1093,3 +1093,75 @@ it('requires fresh merge destination progress while retaining unrelated cached b 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 +}) From 7299c1b0fed994cccc7fb914a751d538dd42c2b8 Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:21:24 -0400 Subject: [PATCH 24/26] Await startup snapshot cleanup before showing sign-in --- app/src/App.jsx | 12 +++++++----- app/src/App.logout.test.jsx | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/app/src/App.jsx b/app/src/App.jsx index e00a5dcd..d2ce9a25 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -437,7 +437,7 @@ function Home({ session, onSession, onLogout, theme }) { export default function App() { const theme = useTheme() const [session, setSession] = useState(loadSession) - const [signOut, setSignOut] = useState(null) + const [signOut, setSignOut] = useState(() => session ? null : { error: null }) const sessionGeneration = useRef(0) const generation = sessionGeneration.current async function handleLogout() { @@ -453,10 +453,12 @@ export default function App() { } } useEffect(() => { - if (!session) { - clearWidget() - syncShortcutsCache(null) - } + 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 (signOut) return (
diff --git a/app/src/App.logout.test.jsx b/app/src/App.logout.test.jsx index 80d32fe7..589a8d04 100644 --- a/app/src/App.logout.test.jsx +++ b/app/src/App.logout.test.jsx @@ -1,4 +1,5 @@ // @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' @@ -99,3 +100,38 @@ it('continues accepting password updates for the active session', async () => { 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() +}) From 0ffdc47e0db8965988679073299b208125a57a9c Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:22:25 -0400 Subject: [PATCH 25/26] Reject superseded overlapping library refreshes --- app/src/api.js | 6 ++++++ app/src/shortcutsCache.test.js | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/app/src/api.js b/app/src/api.js index 6aa1cf55..7c9df410 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -72,6 +72,8 @@ 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 @@ -96,6 +98,7 @@ function assertReadingVersion(session, reading) { const offlineCopies = new WeakSet() function clearOffline(session) { + latestLibraryRead = 0 latestClippingRead = latestHubRead = 0 latestBookReads.clear() const prefix = session ? `${OFFLINE}${session.username}@${session.server}/` : OFFLINE_PREFIX @@ -343,6 +346,7 @@ function removeCachedBook(session, document) { 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; ) { @@ -356,6 +360,8 @@ async function allProgress(session) { assertReadingVersion(version, reading) const all = { items, next: null } 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`) diff --git a/app/src/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index 8d6ec63f..d9b641af 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -1165,3 +1165,37 @@ it('shares one native removal for overlapping logout attempts', async () => { 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 }) +}) From 4fffd8d58fb2e334a138d5dc31c5a0ac39c5610a Mon Sep 17 00:00:00 2001 From: jcapayne <7683622+jcapayne@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:23:45 -0400 Subject: [PATCH 26/26] Refresh clippings when the library book list changes --- app/src/Clippings.jsx | 2 +- app/src/Clippings.test.jsx | 39 ++++++++++++++++++++++++++++++++++ app/src/shortcutsCache.test.js | 13 ++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 app/src/Clippings.test.jsx diff --git a/app/src/Clippings.jsx b/app/src/Clippings.jsx index 6a7af78c..0a074b08 100644 --- a/app/src/Clippings.jsx +++ b/app/src/Clippings.jsx @@ -53,7 +53,7 @@ function Clip({ session, clip, book, onShare, onDelete, showBook }) { } 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 [query, setQuery] = useState('') const [only, setOnly] = useState(null) // document const [sharing, setSharing] = useState(null) diff --git a/app/src/Clippings.test.jsx b/app/src/Clippings.test.jsx new file mode 100644 index 00000000..1866d38c --- /dev/null +++ b/app/src/Clippings.test.jsx @@ -0,0 +1,39 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' + +vi.mock('./api.js', () => ({ api: { allClippings: vi.fn() }, cached: { clippings: () => null }, isApp: false })) +vi.mock('./shareCard.js', () => ({ saveFile: vi.fn() })) +vi.mock('./Book.jsx', () => ({ ClipMenu: () => null, ClipShare: () => null, DeleteClip: () => null, hold: () => ({}) })) +vi.mock('./tabbar.js', () => ({ useSheet: vi.fn() })) +vi.mock('./ui.jsx', async (original) => ({ ...await original(), Cover: () => null })) +const { api } = await import('./api.js') +const { default: Clippings } = await import('./Clippings.jsx') +const session = { server: 'https://sync.test', username: 'reader', key: 'secret' } +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 } +beforeEach(() => { api.allClippings.mockReset() }) +afterEach(cleanup) + +it('reloads all-book clippings when the refreshed library discovers another book', async () => { + 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.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/shortcutsCache.test.js b/app/src/shortcutsCache.test.js index d9b641af..1a9997c8 100644 --- a/app/src/shortcutsCache.test.js +++ b/app/src/shortcutsCache.test.js @@ -1199,3 +1199,16 @@ it('does not let a later offline library fallback supersede a pending online ref 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' }]) +})