Skip to content

Bookmarks tag index: SwiftData store, streaming sync + iPad chrome fix

jedmund requested to merge jedmund/bookmarks-index into main

Created by: jedmund

Summary

Lays the groundwork for filtering bookmarks by artwork tag. No tag-filter UI in this PR — just the durable index it's built on, plus the sync pipeline that keeps it current, plus a fix for the iPad Bookmarks chrome that broke after the UIKit tab-controller migration.

What's in it

Storage — SwiftData-backed bookmark index

  • IndexedBookmark / IndexedTag / BookmarkIndexMetadata @Model types; composite "<illustID>-<restrictRaw>" identity lets the same illust live in both public and private buckets independently.
  • BookmarkIndexSchemaV1 + BookmarkIndexMigrationPlan so schema changes get versioned migrations instead of ad-hoc JSON mangling. Escape hatch: resetStore() if a migration ever fails — the server's always authoritative.
  • BookmarksIndexStore — dedicated actor, not MainActor. Owns the ModelContext exclusively; only Sendable value-typed snapshots (BookmarkedIllustSnapshot, TagRef) cross the actor boundary.

Sync — delta + full + in-app hooks

  • Delta sync — walks page 1 (plus overflow if all IDs are new), stops at the first fully-known page. Average cost ≈ 1 API call. 60s cooldown.
  • Full sync — walks everything, prunes rows older than the sync start (bookmarks removed outside the app). Auto-picked after 7 days stale.
  • Auto modestartAutoSync(restrict:) picks full if stale else delta. Called from SignedInRoot when the Bookmarks tab becomes active.
  • Cross-restrict queue — public and private syncs are serialized through a single pending queue so they never fire concurrent requests at Pixiv's private API.
  • In-app mutationsrecordBookmark / recordUnbookmark wire into IllustDetailViewController.commitBookmark so every bookmark POST updates the index immediately, no waiting for the next sync.

Rate-limit discipline (to not get the account banned)

  • Strictly serial pagination per restrict.
  • Public and private never sync concurrently.
  • 150 ms between requests.
  • Exponential backoff on 429 / 5xx: 1→2→4→8→16s, cap 30s. Three consecutive failures → pause that restrict for one hour.
  • Triggered only by user intent (landing on Bookmarks). Never pre-fetches.

Sync toast UI

Transient glass-capsule card, pinned bottom-leading via GeometryReader + safeAreaInsets so it clears the iPhone home indicator. Up to two rows (one per restrict): syncing shows a 44pt thumbnail of the most recently indexed illust + fake-denominator progress bar + N indexed · #illustID; queued shows a clock placeholder and "Waiting for the other list to finish". Auto-hides when both restricts return to idle.

iPadOS 26 tab-controller fixes

Two separate bugs fell out of the UIKit tab migration, both verified against Console logs:

  1. tabBarController(_:didSelect:) delegate never fires on iPadOS 26 UITab-based selection. selectedIndex KVO goes silent after its initial firing. Fix: each per-tab PixillateNavigationController tags itself with its UITab.identifier and overrides viewWillAppear to report selection back — the one lifecycle event UIKit is forced to call. Drives both the sync-on-tab-appear trigger and the accessory refresh.
  2. navigationItem.trailingItemGroups doesn't render on the floating pill tab bar (that API is nav-controller-hosted, not tab-controller). Fix: reparent the BookmarksTabBarRowChrome hosted view directly onto UIWindow, anchored to safeAreaLayoutGuide top + trailing. Window subviews composite above rootViewController, which is the only layer that sits on top of UIKit's private tab strip.

Test plan

  • Sign in → open Bookmarks → sync toast appears, thumbnail updates per page, disappears after both public + private finish
  • Switch to Home mid-sync → toast follows (root-level overlay, not tab-local)
  • Open Bookmarks again within 60s → delta sync cooldown blocks re-run (no spam)
  • Leave app for ≥ 7 days, reopen Bookmarks → full sync auto-fires (catches bookmarks removed on web)
  • Bookmark an illust in the app → inspect SwiftData store, row exists with correct tags + restrict
  • Unbookmark an illust → row is removed; next delta sync doesn't re-add it
  • Switch restrict (public private) mid-sync → active row keeps going, requested other side shows "Queued"
  • Airplane mode mid-sync → backoff + eventual pause; toast surfaces the paused state (UI stub for now)
  • iPad: visibility menu + settings gear show in the top tab-bar row when on Bookmarks, disappear on other tabs and during detail pushes
  • iPhone compact: inline chrome at top of Bookmarks content (unchanged behavior)

Follow-ups (not in this PR)

  • Tag filter sheet that consumes publicTagHistogram / privateTagHistogram
  • Feed rendering from the in-memory index when a tag filter is active (so filtered feeds aren't sparse)
  • Manual "Refresh tag index" button in settings to force a full sync on demand
  • Settings toggle to disable indexing entirely (the kill switch the rate-limit doc references)

🤖 Generated with Claude Code

Merge request reports

Loading