From e41d8e1d1170657ca6b139aba9323cd34f271f4b Mon Sep 17 00:00:00 2001 From: Stephen Cooper <1970249+scooper4711@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:28:37 +0100 Subject: [PATCH] fix: name editions sensibly when the library gives a placeholder name Some entries carry a placeholder name such as "Test" or "Accidental duplicate package - disregard" while their file is the real book. Such an edition now takes the name of a properly named edition of the same kind, with its update date added, or else a name built from the product name, so its label and file name make sense. Closes #40 --- .../Classification/EntitlementGrouper.swift | 9 ++- .../Classification/PlaceholderNames.swift | 55 +++++++++++++++ .../PlaceholderNameTests.swift | 67 +++++++++++++++++++ docs/specs/library-manager/design.md | 9 +++ 4 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 Sources/ScrollkeeperKit/Classification/PlaceholderNames.swift create mode 100644 Tests/ScrollkeeperKitTests/PlaceholderNameTests.swift diff --git a/Sources/ScrollkeeperKit/Classification/EntitlementGrouper.swift b/Sources/ScrollkeeperKit/Classification/EntitlementGrouper.swift index 308cecb..646d9ff 100644 --- a/Sources/ScrollkeeperKit/Classification/EntitlementGrouper.swift +++ b/Sources/ScrollkeeperKit/Classification/EntitlementGrouper.swift @@ -41,7 +41,8 @@ public struct EntitlementGrouper: Sendable { } let reusable = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) return order.map { key in - let group = GroupInput(id: key, entitlements: groups[key] ?? [], snapshot: snapshot) + let entitlements = PlaceholderNames.repairing(groups[key] ?? []) + let group = GroupInput(id: key, entitlements: entitlements, snapshot: snapshot) if let existing = reusable[key], group.matches(existing) { return existing } @@ -128,8 +129,12 @@ public struct EntitlementGrouper: Sendable { let label: String if kind == .other { label = remainder.isEmpty ? fallbackLabel(entitlement) : remainder + } else if remainder.isEmpty { + label = kind.label } else { - label = remainder.isEmpty ? kind.label : "\(remainder) – \(kind.label)" + // "Single File (updated 2026-09-21)" already says what kind of edition it is. + let saysKind = remainder.range(of: kind.label, options: .caseInsensitive) != nil + label = saysKind ? remainder : "\(remainder) – \(kind.label)" } return Edition(entitlement: entitlement, kind: kind, label: label) } diff --git a/Sources/ScrollkeeperKit/Classification/PlaceholderNames.swift b/Sources/ScrollkeeperKit/Classification/PlaceholderNames.swift new file mode 100644 index 0000000..e5a192f --- /dev/null +++ b/Sources/ScrollkeeperKit/Classification/PlaceholderNames.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Gives sensible names to editions whose name in Paizo's library is a placeholder, such as "Test" or +/// "Accidental duplicate package - disregard", while the files behind them are the real thing. +/// +/// A placeholder edition takes the name of a properly named edition of the same product with the same kind of +/// file (same file type and number of files), with its own update date added so the two can be told apart: the +/// placeholder edition is sometimes the newer one. Without such an edition, the name is built from the product +/// name the way Paizo names editions: " - Single File" for a PDF, the product name otherwise. +enum PlaceholderNames { + private static let placeholder = TextPattern( + #"^\s*(test|tbd|placeholder)\s*$|\bdisregard\b|\bdo not (use|download)\b|\baccidental(ly)? (duplicate|upload)"# + ) + + static func isPlaceholder(_ displayName: String) -> Bool { + placeholder.matches(displayName) + } + + /// `entitlements` (one product's) with placeholder names replaced. + static func repairing(_ entitlements: [Entitlement]) -> [Entitlement] { + let named = entitlements.filter { !isPlaceholder($0.displayName) } + return entitlements.map { entitlement in + guard isPlaceholder(entitlement.displayName), !entitlement.productName.isEmpty else { return entitlement } + var repaired = entitlement + if let twin = named.first(where: { isSameKind($0, entitlement) }) { + let twinName = twin.displayName.trimmingCharacters(in: .whitespaces) + repaired.displayName = "\(twinName) (\(updated(entitlement)))" + } else { + repaired.displayName = entitlement.fileExtension == "pdf" + ? "\(entitlement.productName) - Single File" : entitlement.productName + } + return repaired + } + } + + private static func isSameKind(_ first: Entitlement, _ second: Entitlement) -> Bool { + first.fileExtension == second.fileExtension && first.assetCount == second.assetCount + } + + /// "updated 2026-09-21", or the package ID when Paizo gives no date. + private static func updated(_ entitlement: Entitlement) -> String { + guard let date = entitlement.dateUpdated ?? entitlement.dateGranted else { + return "package \(entitlement.packageID)" + } + return "updated " + dayFormatter.string(from: date) + } + + private static let dayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "UTC") + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} diff --git a/Tests/ScrollkeeperKitTests/PlaceholderNameTests.swift b/Tests/ScrollkeeperKitTests/PlaceholderNameTests.swift new file mode 100644 index 0000000..fdf87c2 --- /dev/null +++ b/Tests/ScrollkeeperKitTests/PlaceholderNameTests.swift @@ -0,0 +1,67 @@ +import Foundation +@testable import ScrollkeeperKit +import Testing + +@Suite struct PlaceholderNameTests { + private let grouper = EntitlementGrouper() + + @Test(arguments: [ + ("Test", true), (" test ", true), ("Accidental duplicate package - disregard", true), + ("Do not use", true), ("Placeholder", true), + ("Starfinder Player Core PDF - File per Chapter", false), ("Pathfinder Test of Ages", false), + ("Testament of Fire", false) + ]) + func recognizesPlaceholderNames(name: String, isPlaceholder: Bool) { + #expect(PlaceholderNames.isPlaceholder(name) == isPlaceholder) + } + + @Test func loneSingleFileIsNamedFromTheProduct() { + let entitlements = [ + Fixtures.entitlement("Accidental duplicate package - disregard", sku: "PZO22001E", file: "PZO22001E.pdf", + productName: "Starfinder Player Core PDF"), + Fixtures.entitlement("Starfinder Player Core PDF - File per Chapter", sku: "PZO22001E", file: "c.zip", + productName: "Starfinder Player Core PDF") + ] + let item = grouper.makeItems(from: CatalogSnapshot(entitlements: entitlements))[0] + #expect(item.title == "Starfinder Player Core") + #expect(item.editions.map(\.label) == ["Single File", "File per Chapter"]) + #expect(item.editions[0].entitlement.displayName == "Starfinder Player Core PDF - Single File") + } + + @Test func placeholderBesideANamedTwinTakesItsNameAndDate() throws { + var test = Fixtures.entitlement("Test", sku: "PZO13007E", file: "PZO13007E.pdf", + productName: "Pathfinder Lost Omens Draconic Codex PDF") + test.dateUpdated = try #require(ISO8601DateFormatter().date(from: "2026-09-21T10:00:00Z")) + let named = Fixtures.entitlement( + "Pathfinder Lost Omens Draconic Codex PDF - Single File ", sku: "PZO13007E", file: "x-PZO13007E.pdf", + productName: "Pathfinder Lost Omens Draconic Codex PDF" + ) + let item = grouper.makeItems(from: CatalogSnapshot(entitlements: [test, named]))[0] + let names = item.editions.map(\.entitlement.displayName) + #expect(names.contains("Pathfinder Lost Omens Draconic Codex PDF - Single File (updated 2026-09-21)")) + #expect(names.contains("Pathfinder Lost Omens Draconic Codex PDF - Single File ")) + #expect(Set(item.editions.map(\.label)) == ["Single File", "Single File (updated 2026-09-21)"]) + } + + @Test func archivesOnlyTakeTheNameOfArchivesWithTheSameNumberOfFiles() { + var test = Fixtures.entitlement("Test", sku: "S", file: "a.zip", productName: "Some Book PDF") + test.assetCount = 21 + let maps = Fixtures.entitlement("Some Book PDF - Maps", sku: "S", file: "m.zip", productName: "Some Book PDF") + let item = grouper.makeItems(from: CatalogSnapshot(entitlements: [test, maps]))[0] + #expect(item.editions.map(\.entitlement.displayName).contains("Some Book PDF")) + } + + @Test func undatedTwinsAreToldApartByPackage() { + let test = Fixtures.entitlement("Test", sku: "S", file: "a.pdf", productName: "Some Book PDF") + let named = Fixtures.entitlement("Some Book PDF - Single File", sku: "S", file: "b.pdf", + productName: "Some Book PDF") + let names = grouper.makeItems(from: CatalogSnapshot(entitlements: [test, named]))[0].editions + .map(\.entitlement.displayName) + #expect(names.contains("Some Book PDF - Single File (package pkg-Test)")) + } + + @Test func placeholderWithoutAProductNameIsLeftAlone() { + let entitlement = Fixtures.entitlement("Test", sku: "S") + #expect(PlaceholderNames.repairing([entitlement]) == [entitlement]) + } +} diff --git a/docs/specs/library-manager/design.md b/docs/specs/library-manager/design.md index 8667b96..2595e39 100644 --- a/docs/specs/library-manager/design.md +++ b/docs/specs/library-manager/design.md @@ -167,6 +167,15 @@ Views ──► LibraryStore ──► CatalogSynchronizer ──► LibraryCata otherwise call the packs adventures. - `TitleNormalizer.baseTitle` also drops a name given twice over, as Paizo does for some downloads. +- `PlaceholderNames` renames entitlements whose name is a placeholder ("Test", + "Accidental duplicate package - disregard") although their file is the real + book, before grouping, so both the edition label and the file name use the + new name. A placeholder takes the name of a properly named edition of the same + product with the same file type and number of files, with its update date + added ("… Single File (updated 2026-09-21)"): such a placeholder is sometimes + the newer upload. Otherwise it is named from the product name, as Paizo names + editions (" - Single File" for a PDF). A label that already names its + edition kind doesn't repeat it. - The title and download folder name are derived from entitlement data only, never from storefront metadata, so that paths stay stable when metadata arrives later.