Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down
55 changes: 55 additions & 0 deletions Sources/ScrollkeeperKit/Classification/PlaceholderNames.swift
Original file line number Diff line number Diff line change
@@ -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: "<product> - 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
}()
}
67 changes: 67 additions & 0 deletions Tests/ScrollkeeperKitTests/PlaceholderNameTests.swift
Original file line number Diff line number Diff line change
@@ -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])
}
}
9 changes: 9 additions & 0 deletions docs/specs/library-manager/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ("<product> - 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.
Expand Down
Loading