Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cde8b3e
Add offline iOS Shortcuts for books and clippings
jcapayne Oct 28, 2026
143e6e2
Batch legacy clipping snapshots for Shortcuts
jcapayne Oct 28, 2026
196fa75
Clear offline Shortcuts data when reading data is reset
jcapayne Oct 28, 2026
b503763
Remove deleted clippings from offline Shortcuts caches
jcapayne Oct 28, 2026
31564c6
Keep clipping deletion successful when native cache update fails
jcapayne Oct 28, 2026
2cd6247
Stage legacy clipping caches until the full batch succeeds
jcapayne Oct 28, 2026
6377f28
Cache successful empty legacy clipping collections
jcapayne Oct 28, 2026
1731b39
Skip Shortcuts snapshot work outside native iOS
jcapayne Oct 28, 2026
5794cec
Store one complete offline clipping representation
jcapayne Oct 28, 2026
bf1754b
Reject clipping reads that predate a deletion
jcapayne Oct 28, 2026
4b5e8d6
Prune offline book data immediately after removal
jcapayne Oct 28, 2026
2bdacae
Announce offline state for cached clipping fallback
jcapayne Oct 28, 2026
1a5ac46
Store clipping hub freshness atomically with payload
jcapayne Oct 28, 2026
c96ede6
Store library freshness atomically with book data
jcapayne Oct 28, 2026
aa3c5da
Invalidate clipping snapshots after merging books
jcapayne Oct 28, 2026
90a1b1b
Retire clipping copies disproven by complete hub refreshes
jcapayne Oct 28, 2026
688c86d
Preserve unrelated hub clippings during book merges
jcapayne Oct 28, 2026
cfa128a
Remove merged source books from the offline library
jcapayne Oct 28, 2026
01c5ede
Invalidate older library reads after successful book edits
jcapayne Oct 29, 2026
bf7a765
Patch offline books from confirmed edit responses
jcapayne Oct 29, 2026
c677a5c
Reject superseded overlapping clipping reads
jcapayne Oct 29, 2026
f0a6b21
Invalidate stale destination progress after book merges
jcapayne Oct 29, 2026
0d0fb0f
Await confirmed Shortcuts data removal before signing out
jcapayne Oct 29, 2026
7299c1b
Await startup snapshot cleanup before showing sign-in
jcapayne Oct 29, 2026
0ffdc47
Reject superseded overlapping library refreshes
jcapayne Oct 29, 2026
4fffd8d
Refresh clippings when the library book list changes
jcapayne Oct 29, 2026
2f81ea4
Merge main and preserve Shortcuts and Micro.blog clipping tests
jcapayne Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
31 changes: 31 additions & 0 deletions app/scripts/shortcuts-tests/main.swift
Original file line number Diff line number Diff line change
@@ -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")
Original file line number Diff line number Diff line change
@@ -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<BookEntity> {
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) })
}
}
Original file line number Diff line number Diff line change
@@ -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/<bundle identifier>.
// 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) }
}
}
Loading
Loading