Books — robust bundle lookup so ad-hoc device installs always seed

bundledBookJSONURLs() was relying solely on
Bundle.urls(forResourcesWithExtension:subdirectory:), the directory-
enumeration API. That API is observed to return empty in some on-device
configurations even when the resource is present, which would cause
seedBooks() to silently no-op and leave BookLibraryView showing "No
Books" despite the JSON being bundled correctly.

Switched to the same pattern textbook seeding uses: explicit per-slug
Bundle.url(forResource:withExtension:) with a bundleURL.appending-
PathComponent fallback. Directory enumeration is kept as a secondary so
future books bundled without code changes still get picked up.

Also added diagnostic prints to refreshBooksDataIfNeeded and the URL
discovery step so device logs reveal what happened if seeding still
falls through.

Bumped bookDataVersion to 3 so existing installs re-trigger
refreshBooksDataIfNeeded → seedBooks on next launch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-05-11 10:36:16 -05:00
parent 51067e23fd
commit 70d8299df8
+40 -8
View File
@@ -9,7 +9,7 @@ actor DataLoader {
static let textbookDataVersion = 14
static let textbookDataKey = "textbookDataVersion"
static let bookDataVersion = 2 // bump: vocab <li> bullets now extracted
static let bookDataVersion = 3 // bump: robust bundle lookup with explicit-slug fallback
static let bookDataKey = "bookDataVersion"
/// Quick check: does the DB need seeding or course data refresh?
@@ -163,7 +163,10 @@ actor DataLoader {
let shared = UserDefaults.standard
let context = ModelContext(container)
let existingCount = (try? context.fetchCount(FetchDescriptor<Book>())) ?? 0
let versionCurrent = shared.integer(forKey: bookDataKey) >= bookDataVersion
let storedVersion = shared.integer(forKey: bookDataKey)
let versionCurrent = storedVersion >= bookDataVersion
print("[DataLoader] refreshBooksDataIfNeeded: existing=\(existingCount) stored=\(storedVersion) target=\(bookDataVersion) versionCurrent=\(versionCurrent)")
if versionCurrent && existingCount > 0 { return }
@@ -182,7 +185,9 @@ actor DataLoader {
if seedBooks(context: context) {
shared.set(bookDataVersion, forKey: bookDataKey)
print("Book data re-seeded to version \(bookDataVersion)")
print("[DataLoader] Book data re-seeded to version \(bookDataVersion)")
} else {
print("[DataLoader] Book reseed produced no rows — leaving version key untouched")
}
}
@@ -637,18 +642,45 @@ actor DataLoader {
return true
}
/// Find every `book_*.json` resource in the app bundle.
/// Slugs of books bundled with the app. Kept explicit so device installs
/// don't depend on `Bundle.urls(forResourcesWithExtension:subdirectory:)`
/// successfully enumerating the bundle that API has been observed to
/// return empty for some iOS configurations even when the resource is
/// present, matching the same `bundleURL.appendingPathComponent` fallback
/// used by the textbook seed.
private static let bundledBookSlugs: [String] = [
"olly-vol2",
]
/// Resolve URLs for every bundled book. Uses the explicit-slug fast path
/// first (mirrors `seedTextbookData`'s lookup pattern), then falls back to
/// directory enumeration so newly-bundled books are picked up without a
/// code change.
private static func bundledBookJSONURLs() -> [URL] {
var seen = Set<String>()
var out: [URL] = []
let bundle = Bundle.main
for ext in ["json"] {
if let urls = bundle.urls(forResourcesWithExtension: ext, subdirectory: nil) {
for url in urls where url.lastPathComponent.hasPrefix("book_") {
if seen.insert(url.lastPathComponent).inserted { out.append(url) }
for slug in bundledBookSlugs {
let filename = "book_\(slug).json"
let url = bundle.url(forResource: "book_\(slug)", withExtension: "json")
?? bundle.bundleURL.appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: url.path),
seen.insert(filename).inserted {
out.append(url)
}
}
if let urls = bundle.urls(forResourcesWithExtension: "json", subdirectory: nil) {
for url in urls where url.lastPathComponent.hasPrefix("book_") {
if seen.insert(url.lastPathComponent).inserted {
out.append(url)
}
}
}
let names = out.map(\.lastPathComponent).joined(separator: ", ")
print("[DataLoader] bundledBookJSONURLs found \(out.count) files: [\(names)]")
return out.sorted { $0.lastPathComponent < $1.lastPathComponent }
}