Validated the complete M2 specification implementation and applied correctness and scale fixes before push.
Ready to push
All confirmed critical/important findings are fixed. Core, full app/UI, unit, Development build, and Personal build gates pass. The physical-device performance protocol remains an explicit manual gate.
9b19b0d [feat]: add title teaching implementation specification 505d485 [feat]: establish V2-only M2 foundation b9b311e [feat]: make segment teaching deterministic and coherent 7fa8957 [feat]: add stale-safe articles mode 3de5050 [feat]: teach exact phrase title patterns eeff036 [feat]: validate M2 integration and release gates 54e0aba [feat]: resolve title teaching pre-push findings Asterism learns title formats from one example, previews the effect, then organizes past and future captures without overwriting manual choices. Articles mode handles independent pages.
Readers can organize large libraries while immutable capture evidence remains recoverable and every derived value remains explainable.
Pure parsing and planning feed projection contracts. The repository actor refetches and rebuilds approved outcomes under an exclusive cross-process lock, then saves once or returns a refreshed preview.
Exact matching is deterministic but intentionally does not normalize cosmetic differences. Full store validation at open protects integrity at linear cold-open cost.
Segment and phrase forms share exact scalar semantics. Commit paths now fetch by Site predicates, index models by UUID with duplicate detection, and apply projections in constant time. Recent validates Site state once per publication.
Requirements 1–9 and the Requirement 10 harness are implemented. Physical p95 measurements were not run in this review.
Asterism/Asterism/Views/RecentView.swift
Why it matters. Prevents an empty, untoggleable Recent list after the last actionable Entry is resolved.
What to look at. RecentView.body
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swift
Why it matters. Avoids materializing unrelated Entries and Works at the supported 20,000-entry scale.
What to look at. buildTeachingBasis and commitTeaching
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift
Why it matters. Removes quadratic scans while preserving corrupt-library error behavior.
What to look at. entriesByID, worksByID, applyProjection
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift
Why it matters. Avoids repeating pattern and junk-rule validation for every Entry on the same Site.
What to look at. recentPresentation(calendar:)
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift
Why it matters. Phrase provenance now receives the same exact, accessible summary used elsewhere.
What to look at. entryTeachingDetail(id:)
The efficiency reviewer suggested a lightweight probe, but Requirement 9.9 requires invalid live V2 tuples to make the library unavailable. Requirement 10.4 begins after repository open, so the full validation remains.
Segment and phrase parsing stay deterministic; stale confirmations compare complete basis and outcome values before one save.
The explicit developer command preserves pre-alpha data without adding legacy readers or repair paths to app startup.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | Recent filter state | The actionable-only filter remained active when the final actionable Entry resolved. | Reset filter state when actionableCount becomes zero. |
| major | Teaching commit scale | Broad fetches and repeated first(where:) scans made large commits unnecessarily expensive. | Added Site predicates and checked UUID indexes. |
| major | Recent validation scale | Site patterns were revalidated once per Entry. | Validate and cache each Site mode once per coherent read. |
| major | Pattern provenance summary | Entry detail duplicated segment formatting and reduced phrase summaries to a generic label. | Use PatternSummaryFormatter for both active and historical patterns. |
| major | Corrupt UUID handling | Initial index optimization could trap on duplicate UUIDs. | Added checked indexes that throw corruptLibrary. |
Click to expand.
diff --git a/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Development.xcscheme b/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Development.xcschemeindex d31c0c4..045c317 100644--- a/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Development.xcscheme +++ b/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Development.xcscheme @@ -7,13 +7,13 @@ </BuildActionEntry> </BuildActionEntries> </BuildAction>- <TestAction buildConfiguration="Development" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">+ <TestAction buildConfiguration="Development" selectedDebuggerIdentifier="" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.PosixSpawn" shouldUseLaunchSchemeArgsEnv="YES"> <Testables> <TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C7A430091595004199A5" BuildableName="AsterismTests.xctest" BlueprintName="AsterismTests" ReferencedContainer="container:Asterism.xcodeproj"/></TestableReference> <TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C7AE30091595004199A5" BuildableName="AsterismUITests.xctest" BlueprintName="AsterismUITests" ReferencedContainer="container:Asterism.xcodeproj"/></TestableReference> </Testables> </TestAction>- <LaunchAction buildConfiguration="Development" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">+ <LaunchAction buildConfiguration="Development" selectedDebuggerIdentifier="" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.PosixSpawn" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" allowLocationSimulation="YES"> <BuildableProductRunnable runnableDebuggingMode="0"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C79330091593004199A5" BuildableName="Asterism.app" BlueprintName="Asterism" ReferencedContainer="container:Asterism.xcodeproj"/></BuildableProductRunnable> </LaunchAction> <ProfileAction buildConfiguration="Development" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES"/>
diff --git a/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Personal.xcscheme b/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Personal.xcschemeindex 582f04c..f6fd127 100644--- a/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Personal.xcscheme +++ b/Asterism/Asterism.xcodeproj/xcshareddata/xcschemes/Asterism Personal.xcscheme @@ -7,13 +7,13 @@ </BuildActionEntry> </BuildActionEntries> </BuildAction>- <TestAction buildConfiguration="Personal" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">+ <TestAction buildConfiguration="Personal" selectedDebuggerIdentifier="" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.PosixSpawn" shouldUseLaunchSchemeArgsEnv="YES"> <Testables> <TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C7A430091595004199A5" BuildableName="AsterismTests.xctest" BlueprintName="AsterismTests" ReferencedContainer="container:Asterism.xcodeproj"/></TestableReference> <TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C7AE30091595004199A5" BuildableName="AsterismUITests.xctest" BlueprintName="AsterismUITests" ReferencedContainer="container:Asterism.xcodeproj"/></TestableReference> </Testables> </TestAction>- <LaunchAction buildConfiguration="Personal" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">+ <LaunchAction buildConfiguration="Personal" selectedDebuggerIdentifier="" selectedLauncherIdentifier="Xcode.IDEFoundation.Launcher.PosixSpawn" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" allowLocationSimulation="YES"> <BuildableProductRunnable runnableDebuggingMode="0"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D4A9C79330091593004199A5" BuildableName="Asterism.app" BlueprintName="Asterism" ReferencedContainer="container:Asterism.xcodeproj"/></BuildableProductRunnable> </LaunchAction> <ProfileAction buildConfiguration="Personal" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES"/>
diff --git a/Asterism/Asterism/ContentView.swift b/Asterism/Asterism/ContentView.swiftindex 5dbbafa..e1a70ec 100644--- a/Asterism/Asterism/ContentView.swift+++ b/Asterism/Asterism/ContentView.swift@@ -11,6 +11,7 @@ struct ContentView: View { @State private var showingNewWork = false @State private var showingMoveTo: UUID? @State private var showingSettings = false+ @State private var showingTeachingForEntryID: UUID? enum AppTab { case recent@@ -20,15 +21,15 @@ struct ContentView: View { /// Production initializer. Debug UI tests may request an isolated seeded /// library; malformed requests render unavailable rather than touching an App Group. init() {- #if DEBUG+ #if DEBUG || ASTERISM_PERFORMANCE_TESTING switch UITestLaunchSupport.request() { case .disabled: _model = State(initialValue: AppLibraryModel(environment: .current))- case .seeded(let configuration):+ case .seeded(let configuration, let fixture): _model = State( initialValue: AppLibraryModel( configuration: configuration,- seedUITestFixture: true+ uiTestFixture: fixture ) ) case .invalid(let message):@@ -81,8 +82,14 @@ struct ContentView: View { TabView(selection: $selectedTab) { SwiftUI.Tab("Recent", systemImage: "clock", value: AppTab.recent) { NavigationStack {- RecentView(groups: model.recentGroups) { entryID in+ RecentView(+ presentation: model.recentPresentation,+ capabilities: model.capabilities+ ) { entryID in selectedRecentEntryID = entryID+ } onTeach: { entryID in+ // Direct teaching sheet from inline action (Audit §6)+ showingTeachingForEntryID = entryID } .navigationTitle("Recent") .toolbar {@@ -161,6 +168,18 @@ struct ContentView: View { } } }+ // Direct teaching sheet wired from Recent inline Teach action (Audit §6)+ .sheet(+ isPresented: Binding(+ get: { showingTeachingForEntryID != nil },+ set: { if !$0 { showingTeachingForEntryID = nil } }+ )+ ) {+ if let entryID = showingTeachingForEntryID,+ let teachModel = model.teachingModel(for: entryID) {+ TeachingView(model: teachModel)+ }+ } } @ViewBuilder
diff --git a/Asterism/Asterism/UITestLaunchSupport.swift b/Asterism/Asterism/UITestLaunchSupport.swiftindex c69384b..331142a 100644--- a/Asterism/Asterism/UITestLaunchSupport.swift+++ b/Asterism/Asterism/UITestLaunchSupport.swift@@ -11,9 +11,15 @@ struct SystemProcessEnvironment: ProcessEnvironmentProviding { var environment: [String: String] { ProcessInfo.processInfo.environment } } +enum UITestFixtureKind: Equatable {+ case untaught+ case taught+ case scale+}+ enum UITestLaunchRequest: Equatable { case disabled- case seeded(configuration: LibraryConfiguration)+ case seeded(configuration: LibraryConfiguration, fixture: UITestFixtureKind) case invalid(message: String) } @@ -23,6 +29,8 @@ enum UITestLaunchSupport { static let scenarioKey = "ASTERISM_UI_TEST_SCENARIO" static let runIDKey = "ASTERISM_UI_TEST_RUN_ID" static let seededScenario = "seeded-m1"+ static let seededTaughtScenario = "seeded-taught"+ static let seededScaleScenario = "seeded-scale-m2" static func request( environmentProvider: any ProcessEnvironmentProviding = SystemProcessEnvironment(),@@ -32,7 +40,15 @@ enum UITestLaunchSupport { guard let scenario = environment[scenarioKey] else { return .disabled }- guard scenario == seededScenario else {+ let fixture: UITestFixtureKind+ switch scenario {+ case seededScenario:+ fixture = .untaught+ case seededTaughtScenario:+ fixture = .taught+ case seededScaleScenario:+ fixture = .scale+ default: return .invalid(message: "Unsupported UI test scenario.") } guard@@ -60,7 +76,8 @@ enum UITestLaunchSupport { configuration: LibraryConfiguration( rootDirectory: rootDirectory, environment: .development- )+ ),+ fixture: fixture ) } }
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 8218a67..92a838a 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -15,7 +15,10 @@ public final class AppLibraryModel { public private(set) var state: State = .loading public private(set) var recentGroups: [DatedEntryGroup] = []+ public private(set) var recentPresentation: RecentPresentation = RecentPresentation(groups: [], actionableCount: 0) public private(set) var worksSnapshot: WorksSnapshot = WorksSnapshot(works: [], unattachedEntries: [])+ /// One gate value drives repository validation, navigation, teaching, Backup, and capture UI.+ public let capabilities: M2Capabilities /// When an explicit configuration is injected (tests), resolution is skipped. private let explicitConfiguration: LibraryConfiguration?@@ -32,46 +35,61 @@ public final class AppLibraryModel { /// A pre-bootstrap failure used to fail closed on invalid debug launch input. private let startupFailureMessage: String? /// Seeds only a fresh, explicit temporary configuration used by UI tests.- private let shouldSeedUITestFixture: Bool+ private let uiTestFixture: UITestFixtureKind? /// Production initializer: resolves configuration from the compile-time environment at bootstrap. /// Fails closed (→ unavailable) when App Group resolution fails. public init( environment: LibraryEnvironment,- locator: any SharedContainerLocating = SystemSharedContainerLocator()+ locator: any SharedContainerLocating = SystemSharedContainerLocator(),+ capabilities: M2Capabilities = .current ) {+ self.capabilities = capabilities self.explicitConfiguration = nil self.environment = environment self.locator = locator self.startupFailureMessage = nil- self.shouldSeedUITestFixture = false+ self.uiTestFixture = nil } /// Test/explicit initializer: bypasses locator resolution entirely.- public init(configuration: LibraryConfiguration) {+ public init(+ configuration: LibraryConfiguration,+ capabilities: M2Capabilities = .current+ ) {+ self.capabilities = capabilities self.explicitConfiguration = configuration self.environment = nil self.locator = SystemSharedContainerLocator() self.startupFailureMessage = nil- self.shouldSeedUITestFixture = false+ self.uiTestFixture = nil } /// Debug UI-test initializer. The caller must provide an isolated temporary configuration.- init(configuration: LibraryConfiguration, seedUITestFixture: Bool) {+ init(+ configuration: LibraryConfiguration,+ uiTestFixture: UITestFixtureKind,+ capabilities: M2Capabilities = .current+ ) {+ self.capabilities = capabilities self.explicitConfiguration = configuration self.environment = nil self.locator = SystemSharedContainerLocator() self.startupFailureMessage = nil- self.shouldSeedUITestFixture = seedUITestFixture+ self.uiTestFixture = uiTestFixture } /// Constructs a model that can only render an unavailable state.- init(startupFailureMessage: String) {+ init(+ startupFailureMessage: String,+ capabilities: M2Capabilities = .current+ ) {+ self.capabilities = capabilities self.explicitConfiguration = nil self.environment = nil self.locator = SystemSharedContainerLocator() self.startupFailureMessage = startupFailureMessage- self.shouldSeedUITestFixture = false+ self.uiTestFixture = nil } /// Attempts to open the library; transitions to ready or unavailable.@@ -95,9 +113,12 @@ public final class AppLibraryModel { configuration = try LibraryConfiguration.production(environment: env, locator: locator) } resolvedConfiguration = configuration- let repo = try await LibraryRepository.open(configuration)- if shouldSeedUITestFixture {- try await seedUITestFixture(in: repo)+ let repo = try await LibraryRepository.openForApp(+ configuration,+ capabilities: capabilities+ )+ if let uiTestFixture {+ try await seedUITestFixture(uiTestFixture, in: repo) } self.repository = repo self.backupRepository = repo@@ -126,7 +147,12 @@ public final class AppLibraryModel { guard let repo = repository else { return } do { let calendar = Calendar.current- recentGroups = try await repo.recentEntries(calendar: calendar)+ recentPresentation = try await repo.recentPresentation(calendar: calendar)+ // Keep the legacy grouped snapshot for curation views while deriving it+ // from the same coherent Recent read rather than a second repository read.+ recentGroups = recentPresentation.groups.map {+ DatedEntryGroup(day: $0.day, entries: $0.rows.map(\.entry))+ } worksSnapshot = try await repo.works() } catch { Self.logger.error("Snapshot refresh failed: \(String(describing: error), privacy: .public)")@@ -136,9 +162,14 @@ public final class AppLibraryModel { /// Provides a detail model for a specific entry. public func entryDetailModel(for id: UUID) -> EntryDetailModel? { guard let repo = repository else { return nil }- return EntryDetailModel(entryID: id, library: repo, onMutation: { [weak self] in- await self?.refreshAll()- })+ return EntryDetailModel(+ entryID: id,+ library: repo,+ capabilities: capabilities,+ onMutation: { [weak self] in+ await self?.refreshAll()+ }+ ) } /// Provides a detail model for a specific work.@@ -165,19 +196,44 @@ public final class AppLibraryModel { }) } + /// Provides a teaching view model for an entry from the Recent inline action.+ /// Uses cached recentPresentation row data; opens TeachingView directly.+ public func teachingModel(for entryID: UUID) -> TeachingViewModel? {+ guard capabilities.supportsSegmentTeaching,+ let repo = repository,+ let entry = recentPresentation.allRows.first(where: { $0.id == entryID })?.entry else {+ return nil+ }+ return TeachingViewModel(+ entry: entry,+ library: repo,+ capabilities: capabilities,+ onMutation: { [weak self] in+ await self?.refreshAll()+ }+ )+ }+ /// Provides a settings backup model backed by the current repository. public func settingsBackupModel() -> SettingsBackupModel? { guard let repo = backupRepository, let config = resolvedConfiguration else { return nil } let stagingDir = config.rootDirectory .appending(path: "Library/Caches/BackupExports")- let exporter = BackupExporter(repository: repo, stagingDirectory: stagingDir)+ let exporter = BackupExporter(+ repository: repo,+ stagingDirectory: stagingDir,+ capabilities: capabilities+ ) return SettingsBackupModel(exporter: exporter) } /// Creates the smallest graph that makes every M1 app journey reachable. /// Refuse to seed a non-empty location so a malformed test launch can never /// overwrite or blend with an existing library.- private func seedUITestFixture(in repository: LibraryRepository) async throws {+ private func seedUITestFixture(+ _ fixture: UITestFixtureKind,+ in repository: LibraryRepository+ ) async throws { let counts = try await repository.debugCounts() guard counts == .zero else { throw LibraryRepositoryError.invalidInput(@@ -186,9 +242,23 @@ public final class AppLibraryModel { ) } + if fixture == .scale {+ #if DEBUG || ASTERISM_PERFORMANCE_TESTING+ try await repository.seedM2PerformanceFixture()+ Self.logger.debug("Seeded deterministic M2 performance fixture")+ return+ #else+ throw LibraryRepositoryError.invalidInput(+ operation: "preparing UI test fixture",+ reason: "scale fixtures require a performance-test build"+ )+ #endif+ }++ // Entry with a parseable :: title for teaching UI tests _ = try await repository.capture( CaptureDraft(- captureTitle: "Integration Chapter",+ captureTitle: "Integration Work :: Integration Chapter", captureTitleSource: .manual, rawURLString: "https://integration.test/chapter/1", note: "Seeded note for accessibility journeys",@@ -201,6 +271,31 @@ public final class AppLibraryModel { hostname: "integration.test" ) )- Self.logger.debug("Seeded isolated M1 UI test fixture")++ if fixture == .taught {+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+ ignored: []+ )+ let contract = try await repository.projectInitialTeaching(+ hostname: "integration.test",+ patternDefinition: definition+ )+ switch try await repository.commitTeaching(contract) {+ case .committed:+ Self.logger.debug("Committed deterministic taught UI test fixture")+ case .refreshed:+ throw LibraryRepositoryError.invalidInput(+ operation: "preparing taught UI test fixture",+ reason: "fresh isolated teaching contract unexpectedly became stale"+ )+ case .invalidated(let reason):+ throw LibraryRepositoryError.invalidInput(+ operation: "preparing taught UI test fixture",+ reason: "teaching contract was invalidated: \(reason)"+ )+ }+ }+ Self.logger.debug("Seeded isolated UI test fixture") } }
diff --git a/Asterism/Asterism/ViewModels/EntryDetailModel.swift b/Asterism/Asterism/ViewModels/EntryDetailModel.swiftindex b3308ee..d783bb0 100644--- a/Asterism/Asterism/ViewModels/EntryDetailModel.swift+++ b/Asterism/Asterism/ViewModels/EntryDetailModel.swift@@ -2,7 +2,8 @@ import AsterismCore import Foundation import OSLog -/// View model for Entry detail: manages drafts for note/rating and delete.+/// View model for Entry detail: manages drafts for note/rating, delete,+/// teaching/re-parse action routing, and coherent provenance detail. @MainActor @Observable public final class EntryDetailModel { private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "EntryDetailModel")@@ -17,6 +18,7 @@ public final class EntryDetailModel { public private(set) var state: State = .loading public private(set) var entry: EntrySnapshot? public private(set) var errorMessage: String?+ public private(set) var teachingDetail: EntryTeachingDetail? // Draft fields public var draftNote: String = ""@@ -24,26 +26,37 @@ public final class EntryDetailModel { private let entryID: UUID private let library: any LibraryProviding+ public let capabilities: M2Capabilities private let onMutation: @Sendable () async -> Void private var isSubmitting = false - public init(entryID: UUID, library: any LibraryProviding, onMutation: @escaping @Sendable () async -> Void) {+ public init(+ entryID: UUID,+ library: any LibraryProviding,+ capabilities: M2Capabilities = .current,+ onMutation: @escaping @Sendable () async -> Void+ ) { self.entryID = entryID self.library = library+ self.capabilities = capabilities self.onMutation = onMutation } + public var supportsTitleRecovery: Bool { capabilities.supportsSegmentTeaching }+ public func load() async { state = .loading do {- let snapshot = try await library.entry(id: entryID)+ let detail = try await library.entryTeachingDetail(id: entryID)+ let snapshot = detail.entry self.entry = snapshot+ self.teachingDetail = detail self.draftNote = snapshot.note self.draftRating = snapshot.rating state = .ready } catch { state = .error(message: error.localizedDescription)- Self.logger.error("Entry load failed: \(String(describing: error), privacy: .public)")+ Self.logger.error("Entry detail load failed: \(String(describing: error), privacy: .public)") } } @@ -86,4 +99,55 @@ public final class EntryDetailModel { } isSubmitting = false }++ /// Provides a teaching view model for this entry's site in initial mode.+ public func teachingModel() -> TeachingViewModel? {+ guard capabilities.supportsSegmentTeaching, let entry else { return nil }+ return TeachingViewModel(+ entry: entry,+ library: library,+ capabilities: capabilities,+ mode: .initial,+ onMutation: { [weak self] in+ await self?.load()+ await self?.onMutation()+ }+ )+ }++ /// Provides a teaching view model in replacement (re-teach) mode.+ public func reteachModel() -> TeachingViewModel? {+ guard capabilities.supportsSegmentTeaching,+ let entry,+ let detail = teachingDetail else { return nil }+ let historicalID = detail.activePatternSummary?.id+ let historicalVersion = detail.activePatternSummary?.version+ return TeachingViewModel(+ entry: entry,+ library: library,+ capabilities: capabilities,+ mode: .replacement(+ boundaryEntryID: entry.id,+ historicalPatternID: historicalID,+ historicalPatternVersion: historicalVersion+ ),+ onMutation: { [weak self] in+ await self?.load()+ await self?.onMutation()+ }+ )+ }++ /// Provides a ReparseViewModel for this entry.+ public func reparseModel() -> ReparseViewModel? {+ guard capabilities.supportsSegmentTeaching, entry != nil else { return nil }+ return ReparseViewModel(+ entryID: entryID,+ library: library,+ onMutation: { [weak self] in+ await self?.load()+ await self?.onMutation()+ }+ )+ } }
diff --git a/Asterism/Asterism/ViewModels/ReparseViewModel.swift b/Asterism/Asterism/ViewModels/ReparseViewModel.swiftnew file mode 100644index 0000000..914d13d--- /dev/null+++ b/Asterism/Asterism/ViewModels/ReparseViewModel.swift@@ -0,0 +1,131 @@+import AsterismCore+import Foundation+import OSLog++/// View model for the Re-parse sheet: projects first, displays before/after,+/// then requires explicit confirmation. Handles refreshed/invalidated outcomes.+/// No immediate write.+@MainActor @Observable+public final class ReparseViewModel {+ private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "ReparseViewModel")++ public enum State: Equatable, Sendable {+ case loading+ case projected+ case confirming+ case committed+ case refreshed+ case invalidated(reason: String)+ case error(message: String)+ }++ public private(set) var state: State = .loading+ public private(set) var contract: ReparseContract?+ public private(set) var projection: EntryProjection?+ public private(set) var requiresReconfirmation: Bool = false+ public private(set) var errorMessage: String?++ /// The entry basis from the contract (before state).+ public var entryBefore: EntryBasisEntry? { contract?.basis.entry }++ /// The active pattern used in this reparse.+ public var activePattern: PatternBasisEntry? { contract?.basis.activePattern }++ /// Work outcomes from the projection plan.+ public var worksToCreate: [String] { contract?.outcome.plan.worksToCreate ?? [] }+ public var worksToReuse: Set<UUID> { contract?.outcome.plan.worksToReuse ?? [] }+ public var hasAmbiguity: Bool { contract?.outcome.plan.hasAmbiguity ?? false }++ private let entryID: UUID+ private let library: any LibraryProviding+ private let onMutation: @Sendable () async -> Void+ private var isSubmitting = false++ public init(+ entryID: UUID,+ library: any LibraryProviding,+ onMutation: @escaping @Sendable () async -> Void+ ) {+ self.entryID = entryID+ self.library = library+ self.onMutation = onMutation+ }++ /// Load/project the reparse. Displays the contract without writing.+ public func load() async {+ state = .loading+ do {+ let reparseContract = try await library.projectReparse(entryID: entryID)+ self.contract = reparseContract+ self.projection = reparseContract.outcome.plan.entryProjections.first+ state = .projected+ } catch {+ errorMessage = error.localizedDescription+ state = .error(message: error.localizedDescription)+ Self.logger.error("Reparse projection failed: \(String(describing: error), privacy: .public)")+ }+ }++ /// Confirm the displayed contract. May return refreshed requiring second confirm.+ public func confirm() async {+ guard let contract else { return }+ guard !isSubmitting else { return }+ isSubmitting = true+ state = .confirming+ errorMessage = nil++ do {+ let outcome = try await library.commitReparse(contract)+ switch outcome {+ case .committed:+ state = .committed+ await onMutation()+ Self.logger.debug("Reparse committed for entry \(self.entryID)")+ case .refreshed(let freshContract):+ self.contract = freshContract+ self.projection = freshContract.outcome.plan.entryProjections.first+ requiresReconfirmation = true+ state = .refreshed+ Self.logger.info("Reparse refreshed — requires re-confirmation")+ case .invalidated(let reason):+ state = .invalidated(reason: reason)+ Self.logger.info("Reparse invalidated: \(reason)")+ }+ } catch {+ errorMessage = error.localizedDescription+ state = .error(message: error.localizedDescription)+ Self.logger.error("Reparse commit failed: \(String(describing: error), privacy: .public)")+ }+ isSubmitting = false+ }++ /// Reconfirm after a refreshed contract.+ public func reconfirm() async {+ guard let contract, requiresReconfirmation else { return }+ guard !isSubmitting else { return }+ isSubmitting = true+ state = .confirming+ errorMessage = nil++ do {+ let outcome = try await library.commitReparse(contract)+ switch outcome {+ case .committed:+ state = .committed+ requiresReconfirmation = false+ await onMutation()+ case .refreshed(let freshContract):+ self.contract = freshContract+ self.projection = freshContract.outcome.plan.entryProjections.first+ state = .refreshed+ case .invalidated(let reason):+ state = .invalidated(reason: reason)+ requiresReconfirmation = false+ }+ } catch {+ errorMessage = error.localizedDescription+ state = .error(message: error.localizedDescription)+ }+ isSubmitting = false+ }+}
diff --git a/Asterism/Asterism/ViewModels/SettingsBackupModel.swift b/Asterism/Asterism/ViewModels/SettingsBackupModel.swiftindex 1ba6cad..70dd192 100644--- a/Asterism/Asterism/ViewModels/SettingsBackupModel.swift+++ b/Asterism/Asterism/ViewModels/SettingsBackupModel.swift@@ -66,7 +66,7 @@ public final class SettingsBackupModel { do { let metadata = BackupMetadata( appBuild: Self.currentAppBuild(),- databaseSchemaVersion: 1,+ databaseSchemaVersion: 2, exportedAt: Date() ) let result = try await exporter.export(metadata: metadata)
diff --git a/Asterism/Asterism/ViewModels/TeachingViewModel.swift b/Asterism/Asterism/ViewModels/TeachingViewModel.swiftnew file mode 100644index 0000000..792494f--- /dev/null+++ b/Asterism/Asterism/ViewModels/TeachingViewModel.swift@@ -0,0 +1,627 @@+import AsterismCore+import Foundation+import OSLog++/// View model for segment teaching: tokenizes an entry's capture title,+/// manages role assignments, validates, projects a preview asynchronously,+/// and only commits on explicit separate confirmation of the displayed contract.+///+/// Two-stage UX (Audit §1):+/// - Valid role edit → async preview publishes a complete contract+/// - Only a separate confirmation can commit the already-displayed contract+/// - No commit without a current displayed contract+/// - Role edits invalidate old approval and increment generation+/// - Async preview only publishes if its generation is still latest+/// - Supports initial vs replacement mode+@MainActor @Observable+public final class TeachingViewModel {+ private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "TeachingViewModel")+ private static let performanceSignposter = OSSignposter(+ subsystem: M2PerformanceSignposts.subsystem,+ category: M2PerformanceSignposts.category+ )++ public enum State: Equatable, Sendable {+ case loading+ case ready+ case previewing+ case previewReady+ case confirming+ case committed+ case refreshed+ case invalidated+ case error+ case cancelled+ }++ public enum EditorMode: Equatable, Sendable {+ case segments+ case phrase+ case articles+ }++ public enum PhraseBoundary: Equatable, Sendable {+ case start+ case end+ }++ public enum PhraseBoundaryDirection: Equatable, Sendable {+ case backward+ case forward+ }++ public enum Mode: Equatable, Sendable {+ case initial+ case replacement(boundaryEntryID: UUID, historicalPatternID: UUID?, historicalPatternVersion: Int?)+ }++ public private(set) var state: State = .loading+ public private(set) var editorMode: EditorMode = .segments+ public private(set) var segments: [String] = []+ public private(set) var roles: [SegmentRole] = []+ public private(set) var phraseSelection = PhraseSelection(chapter: 0..<0, work: 0..<0)+ public private(set) var phraseValidationMessage: String?+ public private(set) var junkSuffixSegmentCount = 0+ public private(set) var articleValidationMessage: String?+ public private(set) var articlePreviewPlan: ArticleProjectionPlan?+ public private(set) var validationError: TeachingValidationError?+ public private(set) var errorMessage: String?+ public private(set) var invalidationReason: String?+ public private(set) var requiresReconfirmation: Bool = false+ public private(set) var previewPlan: ProjectionPlan?+ public private(set) var patternRuleSummary: String?+ /// Per-row preview errors indexed by entry projection position.+ public private(set) var previewErrors: [Int: String] = [:]+ public struct ParseComparison: Equatable, Sendable, Identifiable {+ public let entryID: UUID+ public let activeResult: SegmentParseResult?+ public let activeFailure: String?+ public let candidateProjection: EntryProjection++ public var id: UUID { entryID }+ }++ /// Active-versus-candidate results for every frozen Site Entry during replacement.+ public private(set) var parseComparisons: [ParseComparison] = []++ private let entry: EntrySnapshot+ private let library: any LibraryProviding+ private let capabilities: M2Capabilities+ private let mode: Mode+ private let onMutation: (@Sendable () async -> Void)?+ private var contract: TeachingContract?+ private var articlesContract: ArticlesContract?+ private var isSubmitting = false+ /// Generation counter: incremented on every role edit that invalidates old preview.+ private var generation: Int = 0+ /// The generation at which the current preview was computed.+ private var previewGeneration: Int = -1++ public var exampleTitle: String { entry.captureTitle }+ public var supportsSegmentTeaching: Bool { capabilities.supportsSegmentTeaching }+ public var supportsPhraseTeaching: Bool { capabilities.supportsPhraseTeaching }+ public var supportsArticles: Bool { capabilities.supportsArticles }++ public var isValid: Bool {+ switch editorMode {+ case .segments:+ validationError == nil && segments.count >= 2 && hasWorkAndChapter+ case .phrase:+ capabilities.supportsPhraseTeaching && phraseValidationMessage == nil+ case .articles:+ capabilities.supportsArticles && articleValidationMessage == nil+ }+ }++ public var canConfirm: Bool {+ guard state == .previewReady, previewGeneration == generation else { return false }+ return switch editorMode {+ case .segments, .phrase: contract != nil+ case .articles: articlesContract != nil+ }+ }++ private var hasWorkAndChapter: Bool {+ roles.contains(.work) && roles.contains(.chapter)+ }++ public init(+ entry: EntrySnapshot,+ library: any LibraryProviding,+ capabilities: M2Capabilities = .current,+ mode: Mode = .initial,+ onMutation: (@Sendable () async -> Void)? = nil+ ) {+ self.entry = entry+ self.library = library+ self.capabilities = capabilities+ self.mode = mode+ self.onMutation = onMutation+ }++ // MARK: - Lifecycle++ public func load() async {+ state = .loading+ let result = DelimiterTokenizer.tokenize(entry.captureTitle)+ switch result {+ case .success(let tokenized):+ segments = tokenized.segments+ roles = Array(repeating: .chapter, count: tokenized.segments.count)+ state = .ready+ case .failure:+ segments = [entry.captureTitle]+ roles = [.chapter]+ state = .ready+ }+ revalidate()+ }++ // MARK: - Editor mode++ public func selectSegmentsMode() {+ guard editorMode != .segments else { return }+ editorMode = .segments+ articleValidationMessage = nil+ phraseValidationMessage = nil+ invalidatePreview()+ revalidate()+ }++ public func selectPhraseMode() {+ guard capabilities.supportsPhraseTeaching else {+ phraseValidationMessage = M2CapabilityError.unavailablePatternForm(+ form: .phrase,+ gate: capabilities.gate+ ).description+ return+ }+ guard editorMode != .phrase else { return }+ editorMode = .phrase+ articleValidationMessage = nil+ let characterCount = exampleTitle.count+ phraseSelection = characterCount >= 2+ ? PhraseSelection(chapter: 0..<1, work: (characterCount - 1)..<characterCount)+ : PhraseSelection(chapter: 0..<0, work: 0..<0)+ invalidatePreview()+ revalidatePhrase()+ }++ public func selectArticlesMode() {+ guard capabilities.supportsArticles else {+ articleValidationMessage = M2CapabilityError.articlesUnavailable(gate: capabilities.gate).description+ return+ }+ editorMode = .articles+ junkSuffixSegmentCount = 0+ articleValidationMessage = nil+ invalidatePreview()+ Task { await generateArticlePreview() }+ }++ public func setJunkSuffixSegmentCount(_ count: Int) {+ guard editorMode == .articles else { return }+ junkSuffixSegmentCount = count+ invalidatePreview()+ do {+ _ = try ArticleTitleCleaner.deriveRule(+ from: entry.captureTitle,+ suffixSegmentCount: count+ )+ articleValidationMessage = nil+ Task { await generateArticlePreview() }+ } catch {+ articleValidationMessage = error.localizedDescription+ }+ }++ // MARK: - Phrase selection++ public func setPhraseSelection(_ role: PhraseFieldRole, range: Range<Int>) {+ guard editorMode == .phrase else { return }+ let count = exampleTitle.count+ guard range.lowerBound >= 0, range.upperBound <= count else {+ phraseValidationMessage = PhraseTeachingError.selectionOutOfBounds(role: role).localizedDescription+ invalidatePreview()+ return+ }++ switch role {+ case .chapter:+ phraseSelection = PhraseSelection(chapter: range, work: phraseSelection.work)+ case .work:+ phraseSelection = PhraseSelection(chapter: phraseSelection.chapter, work: range)+ }+ invalidatePreview()+ revalidatePhrase()+ if isValid {+ Task { await generatePreview() }+ }+ }++ public func adjustPhraseBoundary(+ _ role: PhraseFieldRole,+ boundary: PhraseBoundary,+ direction: PhraseBoundaryDirection+ ) {+ guard editorMode == .phrase else { return }+ let current = phraseSelection.range(for: role)+ let delta = direction == .forward ? 1 : -1+ let adjusted: Range<Int>+ switch boundary {+ case .start:+ let start = min(max(0, current.lowerBound + delta), current.upperBound)+ adjusted = start..<current.upperBound+ case .end:+ let end = min(max(current.lowerBound, current.upperBound + delta), exampleTitle.count)+ adjusted = current.lowerBound..<end+ }+ setPhraseSelection(role, range: adjusted)+ }++ public func selectedText(for role: PhraseFieldRole) -> String {+ PhrasePatternDeriver.selectedText(+ in: exampleTitle,+ selection: phraseSelection,+ role: role+ ) ?? ""+ }++ public func phraseBoundaryAnnouncement(+ for role: PhraseFieldRole,+ boundary: PhraseBoundary+ ) -> String {+ let boundaryName = boundary == .start ? "start" : "end"+ return "\(role.rawValue.capitalized) \(boundaryName) boundary. Selected text: \(selectedText(for: role))"+ }++ // MARK: - Role manipulation++ /// Cycle a segment role: chapter → work → ignore → chapter.+ /// Invalidates any existing preview and increments generation.+ public func cycleRole(at index: Int) {+ guard editorMode == .segments else { return }+ guard index >= 0, index < roles.count else { return }+ switch roles[index] {+ case .chapter: roles[index] = .work+ case .work: roles[index] = .ignore+ case .ignore: roles[index] = .chapter+ }+ invalidatePreview()+ revalidate()+ if isValid {+ Task { await generatePreview() }+ }+ }++ /// Set a specific role at index. Invalidates preview and triggers async generation.+ public func setRole(at index: Int, to role: SegmentRole) {+ guard editorMode == .segments else { return }+ guard index >= 0, index < roles.count else { return }+ roles[index] = role+ invalidatePreview()+ revalidate()+ if isValid {+ Task { await generatePreview() }+ }+ }++ // MARK: - Preview generation (async, generation-safe)++ /// Generate a preview contract for the current role assignment.+ /// Only publishes if the generation hasn't advanced since launch.+ public func generatePreview() async {+ guard editorMode != .articles, isValid else { return }+ let signpostID = Self.performanceSignposter.makeSignpostID()+ let signpostState = Self.performanceSignposter.beginInterval(+ "TeachingFinalPreviewPublication",+ id: signpostID+ )+ defer {+ Self.performanceSignposter.endInterval(+ "TeachingFinalPreviewPublication",+ signpostState+ )+ }+ let currentGeneration = generation+ state = .previewing+ previewErrors = [:]++ do {+ let patternDef: PatternDefinition+ switch editorMode {+ case .segments:+ let assignment = SegmentRoleAssignment(roles: roles)+ let validated = try TeachingValidator.validate(+ assignment: assignment,+ segments: segments+ ).get()+ guard let derived = PatternDeriver.deriveSegmentPattern(from: validated) else {+ if currentGeneration == generation {+ errorMessage = "Unable to derive segment pattern from assignment."+ state = .error+ }+ return+ }+ patternDef = derived+ case .phrase:+ patternDef = try PhrasePatternDeriver.derive(+ from: exampleTitle,+ selection: phraseSelection+ )+ case .articles:+ return+ }++ try capabilities.validate(patternDefinition: patternDef)++ // Project+ let projectedContract: TeachingContract+ switch mode {+ case .initial:+ projectedContract = try await library.projectInitialTeaching(+ hostname: entry.hostname,+ patternDefinition: patternDef+ )+ case .replacement(let boundaryID, let histID, let histVersion):+ projectedContract = try await library.projectReplacement(+ hostname: entry.hostname,+ patternDefinition: patternDef,+ boundaryEntryID: boundaryID,+ historicalPatternID: histID,+ historicalPatternVersion: histVersion+ )+ }++ // Only publish if generation is still latest+ guard currentGeneration == generation else {+ Self.logger.debug("Preview generation \(currentGeneration) superseded by \(self.generation)")+ return+ }++ contract = projectedContract+ previewPlan = projectedContract.outcome.plan+ previewGeneration = currentGeneration+ patternRuleSummary = buildRuleSummary(patternDef)++ // Build per-row error summaries+ var rowErrors: [Int: String] = [:]+ for (index, projection) in projectedContract.outcome.plan.entryProjections.enumerated() {+ if let failure = projection.parseFailure {+ rowErrors[index] = failure.localizedDescription+ }+ }+ previewErrors = rowErrors++ // Build active vs candidate parse summaries+ buildParseSummaries(from: projectedContract)++ state = .previewReady+ } catch {+ guard currentGeneration == generation else { return }+ errorMessage = "Unable to generate preview. Library unchanged."+ state = .error+ Self.logger.error("Preview generation failed: \(String(describing: error), privacy: .public)")+ }+ }++ public func generateArticlePreview() async {+ guard editorMode == .articles, capabilities.supportsArticles else { return }+ let currentGeneration = generation+ state = .previewing+ errorMessage = nil++ do {+ let rule = try ArticleTitleCleaner.deriveRule(+ from: entry.captureTitle,+ suffixSegmentCount: junkSuffixSegmentCount+ )+ let projected = try await library.projectArticles(+ hostname: entry.hostname,+ junkSuffixRule: rule+ )+ guard currentGeneration == generation, editorMode == .articles else {+ Self.logger.debug("Articles preview generation \(currentGeneration) superseded")+ return+ }+ articlesContract = projected+ articlePreviewPlan = projected.outcome.plan+ previewGeneration = currentGeneration+ articleValidationMessage = nil+ state = .previewReady+ } catch {+ guard currentGeneration == generation else { return }+ articleValidationMessage = error.localizedDescription+ errorMessage = "Unable to generate articles preview. Library unchanged."+ state = .error+ Self.logger.error("Articles preview failed: \(String(describing: error), privacy: .public)")+ }+ }++ // MARK: - Confirm (only commits displayed contract)++ /// Confirm the currently displayed preview contract.+ /// No commit without a current displayed contract.+ public func confirm() async {+ guard canConfirm, !isSubmitting else { return }+ isSubmitting = true+ defer { isSubmitting = false }+ state = .confirming+ errorMessage = nil++ do {+ switch editorMode {+ case .segments, .phrase:+ guard let contract else { return }+ try await handleTeachingCommit(library.commitTeaching(contract))+ case .articles:+ guard let articlesContract else { return }+ try await handleArticlesCommit(library.commitArticles(articlesContract))+ }+ } catch {+ errorMessage = editorMode == .articles+ ? "Unable to save articles mode. Library unchanged."+ : "Unable to save teaching. Library unchanged."+ state = .error+ Self.logger.error("Teaching commit failed: \(String(describing: error), privacy: .public)")+ }+ }++ /// Re-confirm after a refreshed contract.+ public func reconfirm() async {+ guard requiresReconfirmation, !isSubmitting else { return }+ isSubmitting = true+ defer { isSubmitting = false }+ state = .confirming+ errorMessage = nil++ do {+ switch editorMode {+ case .segments, .phrase:+ guard let contract else { return }+ try await handleTeachingCommit(library.commitTeaching(contract))+ case .articles:+ guard let articlesContract else { return }+ try await handleArticlesCommit(library.commitArticles(articlesContract))+ }+ } catch {+ errorMessage = "Unable to save. Library unchanged."+ state = .error+ }+ }++ // MARK: - Cancel++ public func cancel() {+ state = .cancelled+ }++ // MARK: - Private++ private func handleTeachingCommit(_ outcome: TeachingCommitOutcome) async {+ switch outcome {+ case .committed:+ state = .committed+ requiresReconfirmation = false+ await onMutation?()+ Self.logger.debug("Teaching committed for site")+ case .refreshed(let freshContract):+ contract = freshContract+ previewPlan = freshContract.outcome.plan+ previewGeneration = generation+ requiresReconfirmation = true+ state = .refreshed+ Self.logger.info("Teaching refreshed — requires re-confirmation")+ case .invalidated(let reason):+ invalidationReason = reason+ requiresReconfirmation = false+ state = .invalidated+ }+ }++ private func handleArticlesCommit(_ outcome: ArticlesCommitOutcome) async {+ switch outcome {+ case .committed:+ state = .committed+ requiresReconfirmation = false+ await onMutation?()+ Self.logger.debug("Articles mode committed for site")+ case .refreshed(let freshContract):+ articlesContract = freshContract+ articlePreviewPlan = freshContract.outcome.plan+ previewGeneration = generation+ requiresReconfirmation = true+ state = .refreshed+ Self.logger.info("Articles preview refreshed — requires re-confirmation")+ case .invalidated(let reason):+ invalidationReason = reason+ requiresReconfirmation = false+ state = .invalidated+ }+ }++ private func invalidatePreview() {+ let signpostID = Self.performanceSignposter.makeSignpostID()+ let signpostState = Self.performanceSignposter.beginInterval(+ "TeachingEditAcknowledgement",+ id: signpostID+ )+ defer {+ Self.performanceSignposter.endInterval("TeachingEditAcknowledgement", signpostState)+ }+ generation += 1+ contract = nil+ articlesContract = nil+ previewPlan = nil+ articlePreviewPlan = nil+ previewErrors = [:]+ parseComparisons = []+ requiresReconfirmation = false+ if state == .previewReady || state == .previewing {+ state = .ready+ }+ }++ private func revalidate() {+ guard segments.count >= 2 else {+ validationError = .noWorkSegment+ return+ }+ let assignment = SegmentRoleAssignment(roles: roles)+ let result = TeachingValidator.validate(assignment: assignment, segments: segments)+ switch result {+ case .success:+ validationError = nil+ case .failure(let error):+ validationError = error+ }+ }++ private func revalidatePhrase() {+ do {+ let definition = try PhrasePatternDeriver.derive(+ from: exampleTitle,+ selection: phraseSelection+ )+ try capabilities.validate(patternDefinition: definition)+ phraseValidationMessage = nil+ } catch {+ phraseValidationMessage = error.localizedDescription+ }+ }++ private func buildRuleSummary(_ pattern: PatternDefinition) -> String {+ PatternSummaryFormatter.summary(for: pattern)+ }++ private func buildParseSummaries(from contract: TeachingContract) {+ guard case .replacement = mode,+ let activePattern = contract.basis.patterns.first(where: \.isActive) else {+ parseComparisons = []+ return+ }++ let projectionsByID = Dictionary(+ uniqueKeysWithValues: contract.outcome.plan.entryProjections.map { ($0.entryID, $0) }+ )+ parseComparisons = contract.basis.entries.compactMap { basisEntry in+ guard let candidateProjection = projectionsByID[basisEntry.id] else { return nil }+ switch TitlePatternApplicator.apply(definition: activePattern.definition, to: basisEntry.captureTitle) {+ case .success(let result):+ return ParseComparison(+ entryID: basisEntry.id,+ activeResult: result,+ activeFailure: nil,+ candidateProjection: candidateProjection+ )+ case .failure(let error):+ return ParseComparison(+ entryID: basisEntry.id,+ activeResult: nil,+ activeFailure: error.localizedDescription,+ candidateProjection: candidateProjection+ )+ }+ }+ }+}
diff --git a/Asterism/Asterism/Views/EntryDetailView.swift b/Asterism/Asterism/Views/EntryDetailView.swiftindex f4e2947..62ce089 100644--- a/Asterism/Asterism/Views/EntryDetailView.swift+++ b/Asterism/Asterism/Views/EntryDetailView.swift@@ -1,10 +1,14 @@ import AsterismCore import SwiftUI -/// Entry detail: shows entry metadata and provides note/rating editing, assignment, and delete.+/// Entry detail: shows entry metadata and provides note/rating editing, assignment,+/// teaching/re-parse actions, provenance disclosure, and delete. struct EntryDetailView: View { @State private var model: EntryDetailModel @Environment(\.dismiss) private var dismiss+ @State private var showingTeaching = false+ @State private var showingReteach = false+ @State private var showingReparse = false let onMoveTo: () -> Void init(model: EntryDetailModel, onMoveTo: @escaping () -> Void) {@@ -40,7 +44,7 @@ struct EntryDetailView: View { Form { Section("Details") { LabeledContent("Title") {- Text(entry.captureTitle)+ Text(model.teachingDetail?.displayTitle ?? entry.captureTitle) .font(AsterismTypography.serifTitle(17)) } .accessibilityIdentifier("entry-detail-title")@@ -62,6 +66,20 @@ struct EntryDetailView: View { } } + if model.supportsTitleRecovery,+ let detail = model.teachingDetail,+ !detail.availableActions.isEmpty {+ Section("Title Recovery") {+ ForEach(detail.availableActions, id: \.self) { action in+ actionButton(for: action)+ }+ }+ }++ // Keep immutable evidence and provenance reachable without requiring+ // the reader to scroll past editable fields and destructive actions.+ provenanceSection(entry)+ Section("Note") { TextEditor(text: $model.draftNote) .frame(minHeight: 80)@@ -111,6 +129,51 @@ struct EntryDetailView: View { .accessibilityIdentifier("entry-detail-delete-button") } }+ .sheet(isPresented: $showingTeaching) {+ if let teachModel = model.teachingModel() {+ TeachingView(model: teachModel)+ }+ }+ .sheet(isPresented: $showingReteach) {+ if let reteachModel = model.reteachModel() {+ TeachingView(model: reteachModel)+ }+ }+ .sheet(isPresented: $showingReparse) {+ if let reparseModel = model.reparseModel() {+ ReparseView(model: reparseModel)+ }+ }+ }++ @ViewBuilder+ private func actionButton(for action: EntryDetailAction) -> some View {+ switch action {+ case .teach:+ Button("Teach") {+ showingTeaching = true+ }+ .disabled(model.state == .submitting)+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("entry-detail-teach-button")+ .accessibilityLabel("Teach title pattern for this site")+ case .reTeach:+ Button("Re-teach") {+ showingReteach = true+ }+ .disabled(model.state == .submitting)+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("entry-detail-reteach-button")+ .accessibilityLabel("Re-teach title pattern for this site")+ case .reParse:+ Button("Re-parse") {+ showingReparse = true+ }+ .disabled(model.state == .submitting)+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("entry-detail-reparse-button")+ .accessibilityLabel("Re-parse title with current pattern")+ } } @ViewBuilder@@ -136,4 +199,135 @@ struct EntryDetailView: View { .accessibilityLabel(rating == .up ? "Rate up" : "Rate down") .accessibilityAddTraits(isSelected ? .isSelected : []) }++ // MARK: - Provenance disclosure++ @ViewBuilder+ private func provenanceSection(_ entry: EntrySnapshot) -> some View {+ Section {+ DisclosureGroup {+ VStack(alignment: .leading, spacing: 8) {+ LabeledContent("Capture Title") {+ Text(entry.captureTitle)+ .font(.caption)+ }+ .accessibilityIdentifier("provenance-capture-title")++ LabeledContent("Source") {+ Text(entry.captureTitleSource.rawValue)+ .font(.caption)+ }+ .accessibilityIdentifier("provenance-title-source")++ LabeledContent("URL") {+ Text(entry.rawURLString)+ .font(.caption)+ .lineLimit(2)+ }+ .accessibilityIdentifier("provenance-raw-url")++ if let canonical = entry.canonicalURLString {+ LabeledContent("Canonical URL") {+ Text(canonical)+ .font(.caption)+ .lineLimit(2)+ }+ .accessibilityIdentifier("provenance-canonical-url")+ }++ // Chapter provenance with settlement reason+ LabeledContent("Chapter") {+ Text(entry.chapterTitle ?? "—")+ .font(.caption)+ }+ .accessibilityIdentifier("provenance-chapter")++ if let detail = model.teachingDetail {+ LabeledContent("Chapter Settlement") {+ Text(settlementLabel(detail.chapterSettlement))+ .font(.caption)+ }+ .accessibilityIdentifier("provenance-chapter-settlement")++ LabeledContent("Assignment Settlement") {+ Text(settlementLabel(detail.assignmentSettlement))+ .font(.caption)+ }+ .accessibilityIdentifier("provenance-assignment-settlement")++ // Active pattern summary+ if let active = detail.activePatternSummary {+ patternSummaryView(active, label: "Active Pattern")+ }++ // Historical pattern summaries+ ForEach(Array(detail.historicalPatternSummaries.enumerated()), id: \.offset) { index, summary in+ patternSummaryView(summary, label: "Historical Pattern \(index + 1)")+ }+ } else {+ // Fallback when detail not yet loaded+ LabeledContent("Chapter Provenance") {+ Text(provenanceKindLabel(entry.chapterTitleProvenance))+ .font(.caption)+ }+ .accessibilityIdentifier("provenance-chapter-kind")++ LabeledContent("Assignment Provenance") {+ Text(provenanceKindLabel(entry.workAssignmentProvenance))+ .font(.caption)+ }+ .accessibilityIdentifier("provenance-assignment-kind")+ }+ }+ } label: {+ Text("Provenance")+ .font(.subheadline)+ }+ }+ .accessibilityIdentifier("entry-detail-provenance")+ }++ @ViewBuilder+ private func patternSummaryView(_ summary: PatternRuleSummary, label: String) -> some View {+ VStack(alignment: .leading, spacing: 4) {+ Text(label)+ .font(.caption2)+ .foregroundStyle(.secondary)+ Text(summary.plainLanguageRule)+ .font(.caption)+ HStack {+ Text("ID: \(summary.id.uuidString.prefix(8))…")+ .font(.caption2.monospaced())+ .foregroundStyle(.secondary)+ Text("v\(summary.version)")+ .font(.caption2.monospaced())+ .foregroundStyle(.secondary)+ if summary.isActive {+ Text("active")+ .font(.caption2)+ .foregroundStyle(.green)+ }+ }+ }+ .accessibilityIdentifier("provenance-pattern-\(summary.id.uuidString.prefix(8))")+ }++ private func settlementLabel(_ settlement: FieldSettlement) -> String {+ switch settlement {+ case .patternSettled(let patternID, let version):+ "Pattern settled (ID: \(patternID.uuidString.prefix(8))…, v\(version))"+ case .manualProtected(let reason):+ "Protected: \(reason)"+ case .unsettled(let reason):+ "Unsettled: \(reason)"+ }+ }++ private func provenanceKindLabel(_ provenance: FieldProvenance) -> String {+ switch provenance.kind {+ case .none: "None (unsettled)"+ case .pattern: "Pattern (settled)"+ case .manual: "Manual (protected)"+ }+ } }
diff --git a/Asterism/Asterism/Views/RecentView.swift b/Asterism/Asterism/Views/RecentView.swiftindex 264668e..7481659 100644--- a/Asterism/Asterism/Views/RecentView.swift+++ b/Asterism/Asterism/Views/RecentView.swift@@ -2,12 +2,42 @@ import AsterismCore import SwiftUI /// Recent tab: shows all entries grouped by day, ordered by lastSharedAt descending.+/// Built from an immutable RecentPresentation DTO (repository-owned derivation).+/// Banner is a 44pt Button that toggles actionable-only filtering.+/// Rows show Work display title (not raw capture title), inline Teach/Re-teach action+/// that opens TeachingView directly. struct RecentView: View {- let groups: [DatedEntryGroup]+ let presentation: RecentPresentation+ let capabilities: M2Capabilities let onSelect: (UUID) -> Void+ let onTeach: ((UUID) -> Void)?++ @State private var showingActionableOnly = false++ init(+ presentation: RecentPresentation,+ capabilities: M2Capabilities = .current,+ onSelect: @escaping (UUID) -> Void,+ onTeach: ((UUID) -> Void)? = nil+ ) {+ self.presentation = presentation+ self.capabilities = capabilities+ self.onSelect = onSelect+ self.onTeach = onTeach+ }++ /// Filtered groups respecting actionable-only toggle while preserving order.+ private var displayGroups: [RecentPresentationGroup] {+ guard showingActionableOnly else { return presentation.groups }+ return presentation.groups.compactMap { group in+ let filtered = group.rows.filter(\.isActionable)+ guard !filtered.isEmpty else { return nil }+ return RecentPresentationGroup(day: group.day, rows: filtered)+ }+ } var body: some View {- if groups.isEmpty {+ if presentation.groups.isEmpty { ContentUnavailableView( "No captures yet", systemImage: "clock",@@ -15,77 +45,222 @@ struct RecentView: View { ) .accessibilityIdentifier("recent-empty") } else {- List {- ForEach(groups, id: \.day) { group in- Section {- ForEach(group.entries, id: \.id) { entry in- Button {- onSelect(entry.id)- } label: {- RecentEntryRow(entry: entry)- .contentShape(Rectangle())+ VStack(spacing: 0) {+ // Actionable banner as 44pt Button (Audit §2)+ if capabilities.supportsSegmentTeaching && presentation.actionableCount > 0 {+ actionableBanner+ }++ List {+ ForEach(displayGroups, id: \.day) { group in+ Section {+ ForEach(group.rows) { row in+ RecentEntryRow(+ row: row,+ onSelect: onSelect,+ onTeach: onTeach+ ) }- .buttonStyle(.plain)- .accessibilityIdentifier("recent-entry-\(entry.id.uuidString)")- .accessibilityLabel("Open entry \(entry.captureTitle) from \(entry.hostname)")+ } header: {+ Text(group.day, style: .date)+ .font(AsterismTypography.sectionHeader)+ .textCase(.uppercase)+ .accessibilityIdentifier("recent-section-header") }- } header: {- Text(group.day, style: .date)- .font(AsterismTypography.sectionHeader)- .textCase(.uppercase)- .accessibilityIdentifier("recent-section-header") } }+ .listStyle(.plain)+ .accessibilityIdentifier("recent-list")+ }+ .onChange(of: presentation.actionableCount) { _, newCount in+ if newCount == 0 {+ showingActionableOnly = false+ } }- .listStyle(.plain)- .accessibilityIdentifier("recent-list") } }++ private var actionableBanner: some View {+ Button {+ if showingActionableOnly {+ showingActionableOnly = false+ } else {+ showingActionableOnly = true+ }+ } label: {+ HStack(spacing: 6) {+ Image(systemName: showingActionableOnly ? "line.3.horizontal.decrease.circle.fill" : "star.fill")+ .font(.caption)+ .foregroundStyle(AsterismColors.amberDark)+ .accessibilityHidden(true)+ Text(showingActionableOnly+ ? "Showing \(presentation.actionableCount) actionable"+ : "\(presentation.actionableCount) entries need teaching")+ .font(.caption)+ .foregroundStyle(AsterismColors.amberDark)+ Spacer()+ if showingActionableOnly {+ Text("Show All")+ .font(.caption.bold())+ .foregroundStyle(AsterismColors.amberDark)+ }+ }+ .padding(.horizontal)+ .frame(minHeight: 44)+ .background(AsterismColors.amberDark.opacity(0.1))+ }+ .buttonStyle(.plain)+ .accessibilityElement(children: .combine)+ .accessibilityIdentifier("actionable-banner")+ .accessibilityLabel(showingActionableOnly+ ? "Showing \(presentation.actionableCount) actionable entries. Tap to show all."+ : "\(presentation.actionableCount) entries need teaching. Tap to filter.")+ } } -/// A single row in the Recent list: raw capture title, hostname, note preview,-/// last-shared time, and rating. Normal treatment in M1 — no amber.+/// A single row in the Recent list: shows Work display title for taught entries,+/// raw capture title for untaught. Actionable rows show amber treatment with+/// inline Teach/Re-teach pill that opens TeachingView directly. struct RecentEntryRow: View {- let entry: EntrySnapshot+ let row: RecentPresentationRow+ let onSelect: (UUID) -> Void+ let onTeach: ((UUID) -> Void)?++ init(+ row: RecentPresentationRow,+ onSelect: @escaping (UUID) -> Void,+ onTeach: ((UUID) -> Void)? = nil+ ) {+ self.row = row+ self.onSelect = onSelect+ self.onTeach = onTeach+ }++ private var accessibilityTitle: String {+ let workTitle = row.workDisplayTitle ?? row.unresolvedCandidateTitle+ switch (workTitle, row.chapterTitle) {+ case let (work?, chapter?):+ return "\(work), \(chapter)"+ case let (work?, nil):+ return work+ case let (nil, chapter?):+ return chapter+ case (nil, nil):+ return row.captureTitle+ }+ } var body: some View {+ // Navigation and teaching are sibling buttons. Nesting the Teach button in+ // the navigation button makes SwiftUI route the tap to the outer action.+ HStack(alignment: .top, spacing: 8) {+ Button {+ onSelect(row.id)+ } label: {+ entryContent+ .frame(maxWidth: .infinity, alignment: .leading)+ .contentShape(Rectangle())+ }+ .buttonStyle(.plain)+ .frame(maxWidth: .infinity, alignment: .leading)+ .accessibilityIdentifier("recent-entry-\(row.id.uuidString)")+ .accessibilityLabel("Open entry \(accessibilityTitle) from \(row.hostname)")++ if row.isActionable, row.actionType != .none, let onTeach {+ Button {+ onTeach(row.id)+ } label: {+ Text(row.actionType == .teach ? "Teach" : "Re-teach")+ .font(.caption.bold())+ .foregroundStyle(AsterismColors.amberDark)+ .padding(.horizontal, 8)+ .padding(.vertical, 10)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .background(AsterismColors.amberDark.opacity(0.15))+ .clipShape(Capsule())+ }+ .buttonStyle(.plain)+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("teach-pill")+ .accessibilityLabel(row.actionType == .teach ? "Teach title pattern" : "Re-teach title pattern")+ }+ }+ .padding(.vertical, 4)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .overlay {+ if row.isActionable {+ RoundedRectangle(cornerRadius: 6)+ .stroke(AsterismColors.amberDark.opacity(0.3), lineWidth: 1)+ .allowsHitTesting(false)+ }+ }+ }++ private var entryContent: some View { VStack(alignment: .leading, spacing: 4) {- // Title in serif (work title style for raw capture title)- Text(entry.captureTitle)- .font(AsterismTypography.serifTitle(15))- .lineLimit(1)- .accessibilityIdentifier("entry-title")+ HStack {+ // Show Work display title if available, else capture title+ Text(row.workDisplayTitle ?? row.captureTitle)+ .font(AsterismTypography.serifTitle(15))+ .lineLimit(1)+ .foregroundStyle(row.isActionable ? AsterismColors.amberDark : .primary)+ .accessibilityIdentifier("entry-title")++ Spacer()+ }++ // Chapter for taught entries+ if let chapterTitle = row.chapterTitle {+ Text(chapterTitle)+ .font(.caption)+ .foregroundStyle(AsterismColors.cyanDark)+ .lineLimit(1)+ .accessibilityIdentifier("entry-chapter")+ }++ // Actionable unresolved candidate+ if row.isActionable, let candidate = row.unresolvedCandidateTitle {+ HStack(spacing: 4) {+ Image(systemName: "exclamationmark.triangle")+ .font(.caption2)+ .foregroundStyle(AsterismColors.amberDark)+ .accessibilityHidden(true)+ Text(candidate)+ .font(.caption)+ .foregroundStyle(AsterismColors.amberDark)+ .lineLimit(1)+ }+ .accessibilityIdentifier("entry-unresolved-candidate")+ } HStack(spacing: 6) {- Text(entry.hostname)+ Text(row.hostname) .font(.caption) .foregroundStyle(.secondary) .accessibilityIdentifier("entry-hostname") Spacer() - if let rating = entry.rating {+ if let rating = row.rating { Text(rating == .up ? "▲" : "▼") .font(.caption) .foregroundStyle(rating == .up ? AsterismColors.cyanDark : AsterismColors.violetDark) .accessibilityIdentifier("entry-rating") } - Text(entry.lastSharedAt, style: .time)+ Text(row.lastSharedAt, style: .time) .font(.caption2) .foregroundStyle(.secondary) .accessibilityIdentifier("entry-time") } - if !entry.note.isEmpty {- Text(entry.note)+ if !row.note.isEmpty {+ Text(row.note) .font(.caption) .foregroundStyle(.tertiary) .lineLimit(2) .accessibilityIdentifier("entry-note-preview") } }- .padding(.vertical, 4)- .frame(minHeight: AsterismLayout.minHitTarget) } }
diff --git a/Asterism/Asterism/Views/ReparseView.swift b/Asterism/Asterism/Views/ReparseView.swiftnew file mode 100644index 0000000..933a96c--- /dev/null+++ b/Asterism/Asterism/Views/ReparseView.swift@@ -0,0 +1,254 @@+import AsterismCore+import SwiftUI++/// Re-parse sheet: shows before→after chapter, Work outcome,+/// projected provenance, manual/intentional protections, and+/// ambiguous candidates. Confirm/Cancel actions with 44pt targets.+struct ReparseView: View {+ @State private var model: ReparseViewModel+ @Environment(\.dismiss) private var dismiss++ init(model: ReparseViewModel) {+ _model = State(initialValue: model)+ }++ var body: some View {+ NavigationStack {+ Group {+ switch model.state {+ case .loading:+ ProgressView("Projecting re-parse…")+ .accessibilityIdentifier("reparse-loading")+ case .projected, .refreshed:+ projectionContent+ case .confirming:+ ProgressView("Committing…")+ .accessibilityIdentifier("reparse-confirming")+ case .committed:+ committedContent+ case .invalidated(let reason):+ invalidatedContent(reason: reason)+ case .error(let message):+ errorContent(message: message)+ }+ }+ .navigationTitle("Re-parse")+ .navigationBarTitleDisplayMode(.inline)+ .toolbar {+ ToolbarItem(placement: .cancellationAction) {+ Button("Cancel") { dismiss() }+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("reparse-cancel-button")+ }+ }+ .task { await model.load() }+ }+ }++ @ViewBuilder+ private var projectionContent: some View {+ Form {+ // Before/After chapter+ if let projection = model.projection, let before = model.entryBefore {+ Section("Chapter") {+ LabeledContent("Before") {+ Text(before.chapterTitle ?? "—")+ .font(.caption)+ }+ .accessibilityIdentifier("reparse-chapter-before")++ LabeledContent("After") {+ Text(projection.projectedChapter ?? "—")+ .font(.caption)+ }+ .accessibilityIdentifier("reparse-chapter-after")+ }++ // Work outcome+ Section("Work Assignment") {+ if let workTitle = projection.projectedWorkTitle {+ LabeledContent("Projected Work") {+ Text(workTitle)+ .font(.caption)+ }+ .accessibilityIdentifier("reparse-work-title")+ }++ if let outcome = projection.workOutcome {+ LabeledContent("Outcome") {+ Text(workOutcomeLabel(outcome))+ .font(.caption)+ }+ .accessibilityIdentifier("reparse-work-outcome")+ }++ if let workID = projection.projectedWorkID {+ LabeledContent("Work ID") {+ Text(workID.uuidString.prefix(8) + "…")+ .font(.caption.monospaced())+ }+ .accessibilityIdentifier("reparse-work-id")+ }+ }++ // Protections+ if projection.chapterProtected || projection.assignmentProtected {+ Section("Protections") {+ if projection.chapterProtected {+ Label("Chapter: manual protection (preserved)", systemImage: "lock.fill")+ .font(.caption)+ .accessibilityIdentifier("reparse-chapter-protected")+ }+ if projection.assignmentProtected {+ Label("Assignment: manual/intentional protection (preserved)", systemImage: "lock.fill")+ .font(.caption)+ .accessibilityIdentifier("reparse-assignment-protected")+ }+ }+ }++ // Provenance+ Section("Projected Provenance") {+ LabeledContent("Chapter Provenance") {+ Text(provenanceLabel(projection.chapterProvenance))+ .font(.caption)+ }+ .accessibilityIdentifier("reparse-chapter-provenance")++ LabeledContent("Assignment Provenance") {+ Text(provenanceLabel(projection.assignmentProvenance))+ .font(.caption)+ }+ .accessibilityIdentifier("reparse-assignment-provenance")+ }++ // Parse errors+ if let parseFailure = projection.parseFailure {+ Section("Parse Error") {+ Text(String(describing: parseFailure))+ .font(.caption)+ .foregroundStyle(.red)+ .accessibilityIdentifier("reparse-parse-error")+ }+ }++ // Ambiguous candidates+ if model.hasAmbiguity {+ Section("Ambiguous Work Candidates") {+ Text("Multiple Works match the parsed title. Assignment will remain unresolved.")+ .font(.caption)+ .foregroundStyle(.orange)+ .accessibilityIdentifier("reparse-ambiguity-warning")+ }+ }++ // Works to create+ if !model.worksToCreate.isEmpty {+ Section("Works to Create") {+ ForEach(model.worksToCreate, id: \.self) { title in+ Text(title)+ .font(.caption)+ }+ }+ .accessibilityIdentifier("reparse-works-to-create")+ }+ }++ // Confirm / Reconfirm+ Section {+ if model.requiresReconfirmation {+ Button("Reconfirm") {+ Task { await model.reconfirm() }+ }+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("reparse-reconfirm-button")++ Text("The projection was refreshed. Please review and reconfirm.")+ .font(.caption)+ .foregroundStyle(.orange)+ } else {+ Button("Confirm") {+ Task { await model.confirm() }+ }+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("reparse-confirm-button")+ }+ }+ }+ }++ @ViewBuilder+ private var committedContent: some View {+ VStack(spacing: 16) {+ Image(systemName: "checkmark.circle.fill")+ .font(.largeTitle)+ .foregroundStyle(.green)+ Text("Re-parse committed successfully.")+ Button("Done") { dismiss() }+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("reparse-done-button")+ }+ .padding()+ }++ @ViewBuilder+ private func invalidatedContent(reason: String) -> some View {+ VStack(spacing: 16) {+ Image(systemName: "exclamationmark.triangle.fill")+ .font(.largeTitle)+ .foregroundStyle(.orange)+ Text("Re-parse invalidated")+ .font(.headline)+ Text(reason)+ .font(.caption)+ .foregroundStyle(.secondary)+ Text("No changes were made.")+ .font(.caption)+ Button("Dismiss") { dismiss() }+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("reparse-dismiss-button")+ }+ .padding()+ }++ @ViewBuilder+ private func errorContent(message: String) -> some View {+ VStack(spacing: 16) {+ Image(systemName: "xmark.circle.fill")+ .font(.largeTitle)+ .foregroundStyle(.red)+ Text("Error")+ .font(.headline)+ Text(message)+ .font(.caption)+ .foregroundStyle(.secondary)+ Button("Dismiss") { dismiss() }+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("reparse-error-dismiss-button")+ }+ .padding()+ }++ // MARK: - Helpers++ private func workOutcomeLabel(_ outcome: WorkMatchOutcome) -> String {+ switch outcome {+ case .reuse(let workID): "Reuse (Work \(workID.uuidString.prefix(8))…)"+ case .create(let title): "Create new Work '\(title)'"+ case .ambiguous: "Ambiguous (unassigned)"+ }+ }++ private func provenanceLabel(_ provenance: ProjectedProvenance) -> String {+ switch provenance {+ case .preserved(let fp):+ switch fp.kind {+ case .none: "None (preserved)"+ case .pattern: "Pattern v\(fp.patternVersion ?? 0) (preserved)"+ case .manual: "Manual (preserved)"+ }+ case .cleared: "Cleared"+ case .pendingPattern(let p): "Pending pattern v\(p.patternVersion)"+ }+ }+}
diff --git a/Asterism/Asterism/Views/TeachingView.swift b/Asterism/Asterism/Views/TeachingView.swiftnew file mode 100644index 0000000..e1d2ef7--- /dev/null+++ b/Asterism/Asterism/Views/TeachingView.swift@@ -0,0 +1,866 @@+import AsterismCore+import SwiftUI++/// Teaching view: presents segment chips with role cycling, legend, preview,+/// rule summary, and confirm/cancel actions.+struct TeachingView: View {+ @State private var model: TeachingViewModel+ @Environment(\.dismiss) private var dismiss++ init(model: TeachingViewModel) {+ _model = State(initialValue: model)+ }++ var body: some View {+ NavigationStack {+ ScrollView {+ VStack(spacing: 20) {+ content+ }+ .padding()+ }+ .navigationTitle("Teach Pattern")+ .navigationBarTitleDisplayMode(.inline)+ .toolbar {+ ToolbarItem(placement: .cancellationAction) {+ Button("Cancel") {+ model.cancel()+ dismiss()+ }+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("teaching-cancel-button")+ }+ }+ .accessibilityElement(children: .contain)+ .accessibilityIdentifier("teaching-view")+ }+ .task { await model.load() }+ }++ @ViewBuilder+ private var content: some View {+ switch model.state {+ case .loading:+ ProgressView("Analyzing title…")+ .accessibilityIdentifier("teaching-loading")+ case .ready:+ readyContent+ case .previewing:+ VStack(spacing: 16) {+ readyContent+ ProgressView("Generating preview…")+ .accessibilityIdentifier("teaching-previewing")+ }+ case .previewReady:+ previewReadyContent+ case .confirming:+ ProgressView("Saving…")+ .accessibilityIdentifier("teaching-confirming")+ case .committed:+ committedContent+ case .refreshed:+ refreshedContent+ case .invalidated:+ invalidatedContent+ case .error:+ errorContent+ case .cancelled:+ EmptyView()+ }+ }++ // MARK: - Ready state++ @ViewBuilder+ private var readyContent: some View {+ // Title example+ titleExampleSection++ editorModeSection++ switch model.editorMode {+ case .segments:+ segmentChipsSection+ legendSection++ if let error = model.validationError {+ validationBanner(error)+ }++ if let summary = model.patternRuleSummary {+ ruleSummarySection(summary)+ }+ case .phrase:+ phraseEditorSection+ if let summary = model.patternRuleSummary {+ ruleSummarySection(summary)+ }+ case .articles:+ articlesEditorSection+ }+ }++ // MARK: - Preview ready (two-stage: preview displayed, confirm enabled)++ @ViewBuilder+ private var previewReadyContent: some View {+ // Title example+ titleExampleSection+ editorModeSection++ switch model.editorMode {+ case .segments:+ segmentChipsSection+ legendSection++ if let summary = model.patternRuleSummary {+ ruleSummarySection(summary)+ }++ if let plan = model.previewPlan {+ previewSection(plan)+ }++ if !model.previewErrors.isEmpty {+ previewErrorsSection+ }+ case .phrase:+ phraseEditorSection+ if let summary = model.patternRuleSummary {+ ruleSummarySection(summary)+ }+ if let plan = model.previewPlan {+ previewSection(plan)+ }+ if !model.previewErrors.isEmpty {+ previewErrorsSection+ }+ case .articles:+ articlesEditorSection+ if let plan = model.articlePreviewPlan {+ articlesPreviewSection(plan)+ }+ }++ confirmButton+ }++ // MARK: - Title example++ private var titleExampleSection: some View {+ VStack(alignment: .leading, spacing: 4) {+ Text("Example Title")+ .font(.caption)+ .foregroundStyle(.secondary)+ Text(model.exampleTitle)+ .font(AsterismTypography.serifTitle(15))+ .accessibilityIdentifier("teaching-example-title")+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ }++ // MARK: - Editor mode++ private var editorModeSection: some View {+ HStack(spacing: 12) {+ Button {+ model.selectSegmentsMode()+ } label: {+ Text("Segments")+ .frame(minHeight: AsterismLayout.minHitTarget)+ }+ .buttonStyle(.bordered)+ .tint(model.editorMode == .segments ? AsterismColors.cyanDark : .secondary)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("segment-teaching-button")+ .accessibilityLabel("Teach a delimiter segment pattern")+ .accessibilityAddTraits(model.editorMode == .segments ? .isSelected : [])++ if model.supportsPhraseTeaching {+ Button {+ model.selectPhraseMode()+ } label: {+ Text("Phrase")+ .frame(minHeight: AsterismLayout.minHitTarget)+ }+ .buttonStyle(.bordered)+ .tint(model.editorMode == .phrase ? AsterismColors.cyanDark : .secondary)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("phrase-teaching-button")+ .accessibilityLabel("Teach an exact phrase template")+ .accessibilityAddTraits(model.editorMode == .phrase ? .isSelected : [])+ }++ if model.supportsArticles {+ Button {+ model.selectArticlesMode()+ } label: {+ Text("Articles")+ .frame(minHeight: AsterismLayout.minHitTarget)+ }+ .buttonStyle(.bordered)+ .tint(model.editorMode == .articles ? AsterismColors.amberDark : .secondary)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("articles-mode-button")+ .accessibilityLabel("Use articles mode for this site")+ .accessibilityAddTraits(model.editorMode == .articles ? .isSelected : [])+ }+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .accessibilityElement(children: .contain)+ }++ private var phraseEditorSection: some View {+ VStack(alignment: .leading, spacing: 12) {+ Text("Set Chapter and Work boundaries one character at a time. The text before, between, and after them is matched exactly.")+ .font(.caption)+ .foregroundStyle(.secondary)++ phraseSelectionCard(role: .chapter, label: "Chapter")+ phraseSelectionCard(role: .work, label: "Work")++ if let message = model.phraseValidationMessage {+ Text(message)+ .font(.caption)+ .foregroundStyle(.red)+ .accessibilityIdentifier("phrase-validation-error")+ }+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding(12)+ .background(.fill.quaternary)+ .clipShape(RoundedRectangle(cornerRadius: 8))+ .accessibilityElement(children: .contain)+ .accessibilityIdentifier("phrase-editor")+ }++ private func phraseSelectionCard(role: PhraseFieldRole, label: String) -> some View {+ VStack(alignment: .leading, spacing: 8) {+ Text("\(label): \(model.selectedText(for: role))")+ .font(.caption.bold())+ .frame(maxWidth: .infinity, alignment: .leading)+ .accessibilityIdentifier("phrase-\(role.rawValue)-selection")++ phraseBoundaryControl(role: role, boundary: .start, label: "Start")+ phraseBoundaryControl(role: role, boundary: .end, label: "End")+ }+ .padding(8)+ .overlay(RoundedRectangle(cornerRadius: 6).stroke(.secondary.opacity(0.25)))+ }++ private func phraseBoundaryControl(+ role: PhraseFieldRole,+ boundary: TeachingViewModel.PhraseBoundary,+ label: String+ ) -> some View {+ HStack(spacing: 8) {+ Text(label)+ .font(.caption)+ .frame(maxWidth: .infinity, alignment: .leading)++ Button {+ model.adjustPhraseBoundary(role, boundary: boundary, direction: .backward)+ } label: {+ Image(systemName: "chevron.left")+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ }+ .accessibilityLabel("Move \(role.rawValue) \(label.lowercased()) backward one character")++ Button {+ model.adjustPhraseBoundary(role, boundary: boundary, direction: .forward)+ } label: {+ Image(systemName: "chevron.right")+ .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+ }+ .accessibilityLabel("Move \(role.rawValue) \(label.lowercased()) forward one character")+ }+ .accessibilityElement(children: .contain)+ .accessibilityLabel(model.phraseBoundaryAnnouncement(for: role, boundary: boundary))+ .accessibilityAdjustableAction { direction in+ switch direction {+ case .increment:+ model.adjustPhraseBoundary(role, boundary: boundary, direction: .forward)+ case .decrement:+ model.adjustPhraseBoundary(role, boundary: boundary, direction: .backward)+ @unknown default:+ break+ }+ }+ .accessibilityIdentifier("phrase-\(role.rawValue)-\(label.lowercased())-control")+ }++ private var articlesEditorSection: some View {+ VStack(alignment: .leading, spacing: 12) {+ Text("Treat every page as an independent article. Manual fields are preserved; pattern-derived fields are cleared.")+ .font(.caption)+ .foregroundStyle(.secondary)++ Stepper(+ "Remove last \(model.junkSuffixSegmentCount) title segment(s)",+ value: Binding(+ get: { model.junkSuffixSegmentCount },+ set: { model.setJunkSuffixSegmentCount($0) }+ ),+ in: 0...max(0, model.segments.count - 1)+ )+ .frame(minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("articles-suffix-stepper")+ .accessibilityLabel("Junk suffix segment count: \(model.junkSuffixSegmentCount)")++ if model.junkSuffixSegmentCount > 0,+ let rule = try? ArticleTitleCleaner.deriveRule(+ from: model.exampleTitle,+ suffixSegmentCount: model.junkSuffixSegmentCount+ ) {+ Text("Example display: \(ArticleTitleCleaner.clean(model.exampleTitle, using: rule))")+ .font(.caption)+ .accessibilityIdentifier("articles-cleaned-example")+ }++ if let message = model.articleValidationMessage {+ Text(message)+ .font(.caption)+ .foregroundStyle(.red)+ .accessibilityIdentifier("articles-validation-error")+ }+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding(12)+ .background(.fill.quaternary)+ .clipShape(RoundedRectangle(cornerRadius: 8))+ .accessibilityElement(children: .contain)+ .accessibilityIdentifier("articles-editor")+ }++ // MARK: - Segment chips++ private var segmentChipsSection: some View {+ VStack(alignment: .leading, spacing: 8) {+ Text("Tap each segment to assign its role")+ .font(.caption)+ .foregroundStyle(.secondary)++ FlowLayout(spacing: 8) {+ ForEach(Array(model.segments.enumerated()), id: \.offset) { index, segment in+ SegmentChipView(+ text: segment,+ role: model.roles[index],+ index: index,+ onTap: { model.cycleRole(at: index) }+ )+ }+ }+ }+ .accessibilityElement(children: .contain)+ .accessibilityIdentifier("teaching-segments")+ }++ // MARK: - Legend++ private var legendSection: some View {+ VStack(alignment: .leading, spacing: 6) {+ Text("Legend")+ .font(.caption.bold())+ .foregroundStyle(.secondary)++ HStack(spacing: 16) {+ legendItem(role: .chapter, symbol: "C", label: "Chapter")+ legendItem(role: .work, symbol: "W", label: "Work")+ legendItem(role: .ignore, symbol: "×", label: "Ignore")+ }+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .accessibilityElement(children: .contain)+ .accessibilityIdentifier("teaching-legend")+ }++ private func legendItem(role: SegmentRole, symbol: String, label: String) -> some View {+ HStack(spacing: 4) {+ Text(symbol)+ .font(.caption.monospaced())+ .foregroundStyle(colorForRole(role))+ .frame(width: 20, height: 20)+ .background(colorForRole(role).opacity(0.15))+ .clipShape(Circle())+ Text(label)+ .font(.caption)+ .foregroundStyle(.secondary)+ }+ .accessibilityElement(children: .combine)+ .accessibilityLabel("\(label): \(symbol)")+ }++ // MARK: - Validation++ private func validationBanner(_ error: TeachingValidationError) -> some View {+ HStack(spacing: 8) {+ Image(systemName: "exclamationmark.triangle")+ .foregroundStyle(.orange)+ .accessibilityHidden(true)+ Text(validationMessage(for: error))+ .font(.caption)+ .foregroundStyle(.primary)+ }+ .padding(12)+ .frame(maxWidth: .infinity, alignment: .leading)+ .background(Color.orange.opacity(0.1))+ .clipShape(RoundedRectangle(cornerRadius: 8))+ .accessibilityElement(children: .combine)+ .accessibilityIdentifier("teaching-validation-error")+ }++ // MARK: - Rule summary++ private func ruleSummarySection(_ summary: String) -> some View {+ VStack(alignment: .leading, spacing: 4) {+ Text("Pattern Rule")+ .font(.caption.bold())+ .foregroundStyle(.secondary)+ Text(summary)+ .font(.caption)+ .foregroundStyle(.primary)+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding(12)+ .background(.fill.quaternary)+ .clipShape(RoundedRectangle(cornerRadius: 8))+ .accessibilityIdentifier("teaching-rule-summary")+ }++ // MARK: - Confirm++ private var confirmButton: some View {+ Button {+ Task { await model.confirm() }+ } label: {+ Text(confirmTitle)+ .font(.headline)+ .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget)+ }+ .buttonStyle(.borderedProminent)+ .disabled(!model.canConfirm)+ .accessibilityIdentifier("teaching-confirm-button")+ .accessibilityLabel(confirmAccessibilityLabel)+ }++ private var confirmTitle: String {+ switch model.editorMode {+ case .segments: "Confirm Segment Pattern"+ case .phrase: "Confirm Phrase Pattern"+ case .articles: "Confirm Articles Mode"+ }+ }++ private var confirmAccessibilityLabel: String {+ switch model.editorMode {+ case .segments: "Confirm segment teaching pattern"+ case .phrase: "Confirm exact phrase teaching pattern"+ case .articles: "Confirm one-way articles mode"+ }+ }++ private func articlesPreviewSection(_ plan: ArticleProjectionPlan) -> some View {+ VStack(alignment: .leading, spacing: 8) {+ Text("Articles Preview (\(plan.entryProjections.count) entries)")+ .font(.caption.bold())++ ForEach(plan.entryProjections) { projection in+ VStack(alignment: .leading, spacing: 3) {+ Text(projection.displayTitle)+ .font(.caption)+ Text("Chapter: \(projection.projectedChapter ?? "cleared")")+ Text(projection.projectedWorkID == nil ? "Work: unassigned" : "Work: manual assignment preserved")+ Text(projection.intentionallyUnattached+ ? "Intentionally unattached"+ : "Manual assignment preserved")+ }+ .font(.caption2)+ .accessibilityElement(children: .combine)+ .accessibilityIdentifier("articles-preview-row-\(projection.entryID.uuidString)")+ }+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding(12)+ .background(.fill.quaternary)+ .clipShape(RoundedRectangle(cornerRadius: 8))+ .accessibilityIdentifier("articles-preview")+ }++ // MARK: - Preview plan display++ @ViewBuilder++ private func comparisonRow(_ comparison: TeachingViewModel.ParseComparison) -> some View {+ VStack(alignment: .leading, spacing: 3) {+ if let active = comparison.activeResult {+ Text("Active — Chapter: \(active.chapterTitle); Work: \(active.workTitle)")+ .font(.caption2)+ .foregroundStyle(.secondary)+ } else {+ Text("Active — Failed: \(comparison.activeFailure ?? "Unknown parse failure")")+ .font(.caption2)+ .foregroundStyle(.red)+ }+ if let failure = comparison.candidateProjection.parseFailure {+ Text("Candidate — Failed: \(failure.localizedDescription)")+ .font(.caption2)+ .foregroundStyle(.red)+ } else {+ Text(+ "Candidate — Chapter: \(comparison.candidateProjection.projectedChapter ?? "cleared"); "+ + "Work: \(comparison.candidateProjection.projectedWorkTitle ?? "unassigned")"+ )+ .font(.caption2)+ .foregroundStyle(.primary)+ }+ }+ .accessibilityElement(children: .combine)+ .accessibilityIdentifier("teaching-comparison-\(comparison.entryID.uuidString)")+ }+ private func previewSection(_ plan: ProjectionPlan) -> some View {+ VStack(alignment: .leading, spacing: 8) {+ Text("Preview (\(plan.entryProjections.count) entries)")+ .font(.caption.bold())+ .foregroundStyle(.secondary)++ if !model.parseComparisons.isEmpty {+ Text("Active vs Candidate")+ .font(.caption.bold())+ ForEach(model.parseComparisons) { comparison in+ comparisonRow(comparison)+ }+ Divider()+ }++ ForEach(Array(plan.entryProjections.enumerated()), id: \.offset) { index, projection in+ projectionRow(index: index, projection: projection)+ }++ if !plan.worksToCreate.isEmpty {+ HStack(spacing: 4) {+ Image(systemName: "plus.circle")+ .font(.caption)+ .foregroundStyle(AsterismColors.cyanDark)+ .accessibilityHidden(true)+ Text("Creates: \(plan.worksToCreate.joined(separator: ", "))")+ .font(.caption)+ .foregroundStyle(AsterismColors.cyanDark)+ }+ }++ if plan.hasAmbiguity {+ HStack(spacing: 4) {+ Image(systemName: "exclamationmark.triangle")+ .font(.caption)+ .foregroundStyle(.orange)+ .accessibilityHidden(true)+ Text("Some entries have ambiguous Work matches")+ .font(.caption)+ .foregroundStyle(.orange)+ }+ }+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding(12)+ .background(.fill.quaternary)+ .clipShape(RoundedRectangle(cornerRadius: 8))+ .accessibilityIdentifier("teaching-preview")+ }++ @ViewBuilder+ private func projectionRow(index: Int, projection: EntryProjection) -> some View {+ VStack(alignment: .leading, spacing: 3) {+ HStack(spacing: 4) {+ Text("Chapter: \(projection.projectedChapter ?? "cleared")")+ .font(.caption)+ .foregroundStyle(AsterismColors.cyanDark)+ .lineLimit(1)+ if let workTitle = projection.projectedWorkTitle {+ Text("→ \(workTitle)")+ .font(.caption)+ .foregroundStyle(AsterismColors.amberDark)+ .lineLimit(1)+ }+ }++ switch projection.workOutcome {+ case .reuse(let workID):+ Text("Reuses Work \(workID.uuidString.prefix(8))…")+ .font(.caption2)+ case .create(let parsedWorkTitle):+ Text("Creates Work: \(parsedWorkTitle)")+ .font(.caption2)+ case .ambiguous(let candidates):+ VStack(alignment: .leading, spacing: 1) {+ Text("Unresolved — \(candidates.count) matching Works")+ ForEach(candidates, id: \.id) { candidate in+ Text("• \(candidate.displayTitle) (\(candidate.id.uuidString.prefix(8))…)")+ }+ }+ .font(.caption2)+ .foregroundStyle(.orange)+ case nil:+ Text(projection.assignmentProtected ? "Assignment preserved" : "Assignment unassigned")+ .font(.caption2)+ .foregroundStyle(.secondary)+ }++ HStack(spacing: 8) {+ Text("Chapter provenance: \(provenanceLabel(projection.chapterProvenance))")+ Text("Assignment provenance: \(provenanceLabel(projection.assignmentProvenance))")+ }+ .font(.caption2)+ .foregroundStyle(.secondary)++ HStack(spacing: 8) {+ if projection.chapterProtected { Text("Chapter protected") }+ if projection.assignmentProtected { Text("Assignment protected") }+ if projection.parseFailure != nil { Text("Parse failed").foregroundStyle(.red) }+ if projection.actionableAfter {+ Text("Actionable").foregroundStyle(AsterismColors.amberDark)+ }+ }+ .font(.caption2)+ .foregroundStyle(.secondary)+ }+ .accessibilityElement(children: .combine)+ .accessibilityIdentifier("teaching-preview-row-\(index)")+ }++ private func provenanceLabel(_ provenance: ProjectedProvenance) -> String {+ switch provenance {+ case .preserved(let value): return "preserved \(value.kind.rawValue)"+ case .cleared: return "cleared"+ case .pendingPattern(let value): return "pattern v\(value.patternVersion)"+ }+ }++ @ViewBuilder+ private var previewErrorsSection: some View {+ VStack(alignment: .leading, spacing: 4) {+ Text("Preview Errors")+ .font(.caption.bold())+ .foregroundStyle(.red)+ ForEach(Array(model.previewErrors.sorted(by: { $0.key < $1.key })), id: \.key) { index, message in+ Text("Row \(index + 1): \(message)")+ .font(.caption)+ .foregroundStyle(.red)+ }+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding(12)+ .background(Color.red.opacity(0.05))+ .clipShape(RoundedRectangle(cornerRadius: 8))+ .accessibilityIdentifier("teaching-preview-errors")+ }++ // MARK: - Committed++ private var committedContent: some View {+ VStack(spacing: 12) {+ Image(systemName: "checkmark.circle.fill")+ .font(.largeTitle)+ .foregroundStyle(.green)+ .accessibilityHidden(true)+ Text(model.editorMode == .articles ? "Articles mode enabled" : "Pattern taught successfully")+ .font(.headline)+ Button("Done") { dismiss() }+ .frame(minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("teaching-done-button")+ }+ .frame(maxWidth: .infinity, minHeight: 200)+ .accessibilityIdentifier("teaching-committed")+ }++ // MARK: - Refreshed++ private var refreshedContent: some View {+ VStack(spacing: 16) {+ Image(systemName: "arrow.triangle.2.circlepath")+ .font(.largeTitle)+ .foregroundStyle(.orange)+ .accessibilityHidden(true)+ Text("Library changed. Review updated preview and confirm again.")+ .font(.subheadline)+ .multilineTextAlignment(.center)+ Button("Confirm Again") {+ Task { await model.reconfirm() }+ }+ .buttonStyle(.borderedProminent)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("teaching-reconfirm-button")+ }+ .frame(maxWidth: .infinity, minHeight: 200)+ .accessibilityIdentifier("teaching-refreshed")+ }++ // MARK: - Invalidated++ private var invalidatedContent: some View {+ VStack(spacing: 12) {+ Image(systemName: "xmark.circle")+ .font(.largeTitle)+ .foregroundStyle(.red)+ .accessibilityHidden(true)+ Text("Cannot apply pattern")+ .font(.headline)+ if let reason = model.invalidationReason {+ Text(reason)+ .font(.subheadline)+ .foregroundStyle(.secondary)+ .multilineTextAlignment(.center)+ }+ Text("No changes were made to the library.")+ .font(.caption)+ .foregroundStyle(.tertiary)+ Button("Dismiss") { dismiss() }+ .frame(minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("teaching-dismiss-button")+ }+ .frame(maxWidth: .infinity, minHeight: 200)+ .accessibilityIdentifier("teaching-invalidated")+ }++ // MARK: - Error++ private var errorContent: some View {+ VStack(spacing: 12) {+ Image(systemName: "exclamationmark.triangle.fill")+ .font(.largeTitle)+ .foregroundStyle(.red)+ .accessibilityHidden(true)+ if let message = model.errorMessage {+ Text(message)+ .font(.subheadline)+ .multilineTextAlignment(.center)+ }+ Button("Try Again") {+ Task { await model.confirm() }+ }+ .buttonStyle(.borderedProminent)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .accessibilityIdentifier("teaching-retry-button")+ }+ .frame(maxWidth: .infinity, minHeight: 200)+ .accessibilityIdentifier("teaching-error")+ }++ // MARK: - Helpers++ private func colorForRole(_ role: SegmentRole) -> Color {+ switch role {+ case .chapter: AsterismColors.cyanDark+ case .work: AsterismColors.amberDark+ case .ignore: .secondary+ }+ }++ private func validationMessage(for error: TeachingValidationError) -> String {+ switch error {+ case .noWorkSegment: "Select at least one Work segment"+ case .noChapterSegment: "Select at least one Chapter segment"+ case .nonContiguousWork: "Work segments must be contiguous"+ case .nonContiguousChapter: "Chapter segments must be contiguous"+ case .workChapterOverlap: "Work and Chapter cannot overlap"+ case .blankField(let field): "\(field.capitalized) must not be blank"+ }+ }+}++// MARK: - Segment Chip++struct SegmentChipView: View {+ let text: String+ let role: SegmentRole+ let index: Int+ let onTap: () -> Void++ var body: some View {+ Button(action: onTap) {+ HStack(spacing: 4) {+ Text(roleSymbol)+ .font(.caption.monospaced().bold())+ .foregroundStyle(roleColor)+ Text(text)+ .font(.subheadline)+ .lineLimit(1)+ }+ .padding(.horizontal, 12)+ .padding(.vertical, 8)+ .frame(minHeight: AsterismLayout.minHitTarget)+ .background(roleColor.opacity(0.12))+ .clipShape(Capsule())+ .overlay(Capsule().stroke(roleColor.opacity(0.4), lineWidth: 1))+ }+ .buttonStyle(.plain)+ .accessibilityIdentifier("segment-chip-\(index)")+ .accessibilityLabel("Segment \(index + 1): \(text), role: \(role.rawValue)")+ .accessibilityHint("Tap to cycle role")+ }++ private var roleSymbol: String {+ switch role {+ case .chapter: "C"+ case .work: "W"+ case .ignore: "×"+ }+ }++ private var roleColor: Color {+ switch role {+ case .chapter: AsterismColors.cyanDark+ case .work: AsterismColors.amberDark+ case .ignore: .secondary+ }+ }+}++// MARK: - FlowLayout++/// Simple wrapping layout for segment chips.+struct FlowLayout: Layout {+ var spacing: CGFloat = 8++ func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {+ let result = arrange(proposal: proposal, subviews: subviews)+ return result.size+ }++ func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {+ let result = arrange(proposal: ProposedViewSize(width: bounds.width, height: bounds.height), subviews: subviews)+ for (index, position) in result.positions.enumerated() {+ subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)+ }+ }++ private struct ArrangeResult {+ var size: CGSize+ var positions: [CGPoint]+ }++ private func arrange(proposal: ProposedViewSize, subviews: Subviews) -> ArrangeResult {+ let maxWidth = proposal.width ?? .infinity+ var positions: [CGPoint] = []+ var x: CGFloat = 0+ var y: CGFloat = 0+ var rowHeight: CGFloat = 0+ var totalHeight: CGFloat = 0+ var totalWidth: CGFloat = 0++ for subview in subviews {+ let size = subview.sizeThatFits(.unspecified)+ if x + size.width > maxWidth, x > 0 {+ x = 0+ y += rowHeight + spacing+ rowHeight = 0+ }+ positions.append(CGPoint(x: x, y: y))+ rowHeight = max(rowHeight, size.height)+ x += size.width + spacing+ totalWidth = max(totalWidth, x - spacing)+ }+ totalHeight = y + rowHeight++ return ArrangeResult(size: CGSize(width: totalWidth, height: totalHeight), positions: positions)+ }+}
diff --git a/Asterism/Asterism/Views/WorkDetailView.swift b/Asterism/Asterism/Views/WorkDetailView.swiftindex ac0ade3..1ca53b8 100644--- a/Asterism/Asterism/Views/WorkDetailView.swift+++ b/Asterism/Asterism/Views/WorkDetailView.swift@@ -67,7 +67,7 @@ struct WorkDetailView: View { if !work.entries.isEmpty { Section("Entries (\(work.entries.count))") { ForEach(work.entries, id: \.id) { entry in- RecentEntryRow(entry: entry)+ UnattachedEntryRow(entry: entry) .accessibilityIdentifier("work-detail-entry") .accessibilityLabel("Assigned entry: \(entry.captureTitle)") }
diff --git a/Asterism/Asterism/Views/WorksView.swift b/Asterism/Asterism/Views/WorksView.swiftindex 167a467..fe0f51b 100644--- a/Asterism/Asterism/Views/WorksView.swift+++ b/Asterism/Asterism/Views/WorksView.swift@@ -47,7 +47,7 @@ struct WorksView: View { Button { onSelectEntry(entry.id) } label: {- RecentEntryRow(entry: entry)+ UnattachedEntryRow(entry: entry) .contentShape(Rectangle()) } .buttonStyle(.plain)@@ -124,3 +124,35 @@ struct WorkRow: View { .frame(minHeight: AsterismLayout.minHitTarget) } }++/// Simple row for unattached entries in the Works tab.+struct UnattachedEntryRow: View {+ let entry: EntrySnapshot++ var body: some View {+ VStack(alignment: .leading, spacing: 4) {+ Text(entry.captureTitle)+ .font(AsterismTypography.serifTitle(15))+ .lineLimit(1)+ .accessibilityIdentifier("entry-title")++ HStack(spacing: 6) {+ Text(entry.hostname)+ .font(.caption)+ .foregroundStyle(.secondary)+ .accessibilityIdentifier("entry-hostname")+ Spacer()+ if let rating = entry.rating {+ Text(rating == .up ? "▲" : "▼")+ .font(.caption)+ .foregroundStyle(rating == .up ? AsterismColors.cyanDark : AsterismColors.violetDark)+ }+ Text(entry.lastSharedAt, style: .time)+ .font(.caption2)+ .foregroundStyle(.secondary)+ }+ }+ .padding(.vertical, 4)+ .frame(minHeight: AsterismLayout.minHitTarget)+ }+}
diff --git a/Asterism/AsterismShareExtension/CaptureView.swift b/Asterism/AsterismShareExtension/CaptureView.swiftindex 26d8025..9bad3d3 100644--- a/Asterism/AsterismShareExtension/CaptureView.swift+++ b/Asterism/AsterismShareExtension/CaptureView.swift@@ -3,8 +3,9 @@ import SwiftUI /// Capture sheet SwiftUI view: loading, invalid URL, first-site amber state, /// existing-site neutral state, inline required manual title, focused multiline note,-/// independent up/down rating toggles, save/error/retry/cancel, accessibility labels,-/// 44pt targets, Dynamic Type/scrolling, Reduce Transparency/basic paired appearance.+/// independent up/down rating toggles, projected metadata section before Save,+/// save/error/retry/cancel, accessibility labels, 44pt targets,+/// Dynamic Type/scrolling, Reduce Transparency/basic paired appearance. struct CaptureView: View { @ObservedObject var viewModel: ObservableCaptureViewModel @FocusState private var noteFieldFocused: Bool@@ -37,6 +38,7 @@ struct CaptureView: View { } .disabled(viewModel.isSaving) .accessibilityLabel("Cancel capture")+ .accessibilityIdentifier("capture.cancel") .frame(minWidth: 44, minHeight: 44) } }@@ -53,12 +55,12 @@ struct CaptureView: View { loadingContent case .invalidInput(let message): invalidInputContent(message: message)- case .ready(let preparation, _):- readyContent(preparation: preparation)+ case .ready(let preparation, _, let outcome, let reviewMessage):+ readyContent(preparation: preparation, outcome: outcome, reviewMessage: reviewMessage) case .saving: savingContent- case .saveFailed(let preparation, _, let message):- failedContent(preparation: preparation, message: message)+ case .saveFailed(let preparation, _, let outcome, let message):+ failedContent(preparation: preparation, outcome: outcome, message: message) case .saved: savedContent case .cancelled:@@ -68,7 +70,6 @@ struct CaptureView: View { // MARK: - Surface background (Reduce Transparency aware) - /// An opaque background for field surfaces that respects Reduce Transparency. private var fieldBackground: some ShapeStyle { if reduceTransparency { return AnyShapeStyle(colorScheme == .dark ? Color(white: 0.18) : Color(white: 0.92))@@ -111,8 +112,14 @@ struct CaptureView: View { // MARK: - Ready @ViewBuilder- private func readyContent(preparation: CapturePreparation) -> some View {+ private func readyContent(preparation: CapturePreparation, outcome: CaptureOutcome?, reviewMessage: String?) -> some View { metadataSection(preparation: preparation)+ if let outcome {+ projectedMetadataSection(outcome: outcome)+ }+ if let reviewMessage {+ reviewBanner(message: reviewMessage)+ } noteSection ratingSection saveButton@@ -134,9 +141,12 @@ struct CaptureView: View { // MARK: - Failed @ViewBuilder- private func failedContent(preparation: CapturePreparation, message: String) -> some View {+ private func failedContent(preparation: CapturePreparation, outcome: CaptureOutcome?, message: String) -> some View { errorBanner(message: message) metadataSection(preparation: preparation)+ if let outcome {+ projectedMetadataSection(outcome: outcome)+ } noteSection ratingSection retryButton@@ -157,7 +167,7 @@ struct CaptureView: View { .accessibilityLabel("Capture saved successfully") } - // MARK: - Metadata+ // MARK: - Metadata (title + site) @ViewBuilder private func metadataSection(preparation: CapturePreparation) -> some View {@@ -172,6 +182,7 @@ struct CaptureView: View { Text(title) .font(.headline) .accessibilityLabel("Page title")+ .accessibilityIdentifier("capture.pageTitle") } if let host = Self.hostname(from: preparation.rawURL) {@@ -179,14 +190,129 @@ struct CaptureView: View { .font(.caption) .foregroundStyle(.secondary) .accessibilityLabel("Site: \(host)")+ .accessibilityIdentifier("capture.hostname")+ }+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding()+ .background(fieldBackground)+ .clipShape(RoundedRectangle(cornerRadius: 12))+ .accessibilityIdentifier("capture.metadata")+ }++ // MARK: - Projected Metadata Section++ @ViewBuilder+ private func projectedMetadataSection(outcome: CaptureOutcome) -> some View {+ VStack(alignment: .leading, spacing: 8) {+ if let displayTitle = outcome.displayTitle {+ HStack(spacing: 6) {+ Image(systemName: "doc.text")+ .font(.caption)+ .foregroundStyle(.secondary)+ .accessibilityHidden(true)+ Text("Article: \(displayTitle)")+ .font(.subheadline)+ }+ .accessibilityElement(children: .combine)+ .accessibilityLabel("Article title: \(displayTitle)")+ .accessibilityIdentifier("capture.articleTitle")+ }++ // Chapter / parse information+ if let chapter = outcome.projectedChapter {+ HStack(spacing: 6) {+ Image(systemName: "bookmark.fill")+ .font(.caption)+ .foregroundStyle(.blue)+ .accessibilityHidden(true)+ Text("Chapter: \(chapter)")+ .font(.subheadline)+ }+ .accessibilityElement(children: .combine)+ .accessibilityLabel("Chapter: \(chapter)")+ .accessibilityIdentifier("capture.projectedChapter")+ } else {+ HStack(spacing: 6) {+ Image(systemName: "questionmark.circle")+ .font(.caption)+ .foregroundStyle(.secondary)+ .accessibilityHidden(true)+ Text(outcome.intentionallyUnattached ? "Independent article" : "No chapter parsed from title")+ .font(.subheadline)+ .foregroundStyle(.secondary)+ }+ .accessibilityElement(children: .combine)+ .accessibilityLabel(outcome.intentionallyUnattached ? "Independent article" : "No chapter parsed from title")+ .accessibilityIdentifier("capture.noChapter")+ }++ // Work information+ if let workTitle = outcome.projectedWorkTitle {+ let workDescription: String = {+ switch outcome.workOutcome {+ case .reuse:+ return "Work: \(workTitle) (existing)"+ case .create:+ return "Work: \(workTitle) (new)"+ case .ambiguous:+ return "Work: \(workTitle) (ambiguous — needs review)"+ case .none:+ return "Work: \(workTitle)"+ }+ }()+ HStack(spacing: 6) {+ Image(systemName: "book.fill")+ .font(.caption)+ .foregroundStyle(.purple)+ .accessibilityHidden(true)+ Text(workDescription)+ .font(.subheadline)+ }+ .accessibilityElement(children: .combine)+ .accessibilityLabel(workDescription)+ .accessibilityIdentifier("capture.projectedWork")+ } else if outcome.projectedChapter == nil, !outcome.intentionallyUnattached {+ // Parse failure — no work from parse+ HStack(spacing: 6) {+ Image(systemName: "exclamationmark.circle")+ .font(.caption)+ .foregroundStyle(.orange)+ .accessibilityHidden(true)+ Text("Title does not match pattern")+ .font(.subheadline)+ .foregroundStyle(.orange)+ }+ .accessibilityElement(children: .combine)+ .accessibilityLabel("Title does not match pattern")+ .accessibilityIdentifier("capture.parseFailure")+ }++ // Actionable state indicator+ if outcome.actionable {+ HStack(spacing: 6) {+ Image(systemName: "flag.fill")+ .font(.caption)+ .foregroundStyle(.orange)+ .accessibilityHidden(true)+ Text("Actionable — needs review after save")+ .font(.caption)+ .foregroundStyle(.orange)+ }+ .accessibilityElement(children: .combine)+ .accessibilityLabel("Actionable. Needs review after save.")+ .accessibilityIdentifier("capture.actionable") } } .frame(maxWidth: .infinity, alignment: .leading) .padding() .background(fieldBackground) .clipShape(RoundedRectangle(cornerRadius: 12))+ .accessibilityIdentifier("capture.projectedMetadata") } + // MARK: - Banners+ private var newSiteBanner: some View { HStack(spacing: 6) { Image(systemName: "star.fill")@@ -201,12 +327,33 @@ struct CaptureView: View { .accessibilityLabel("New site. Title will be saved. Teach later in app.") } + private func reviewBanner(message: String) -> some View {+ HStack(spacing: 8) {+ Image(systemName: "arrow.triangle.2.circlepath")+ .foregroundStyle(.blue)+ .accessibilityHidden(true)+ Text(message)+ .font(.subheadline)+ .foregroundStyle(.primary)+ }+ .padding()+ .frame(maxWidth: .infinity, alignment: .leading)+ .background(reduceTransparency+ ? AnyShapeStyle(colorScheme == .dark ? Color(white: 0.15) : Color(white: 0.95))+ : AnyShapeStyle(Color.blue.opacity(0.1)))+ .clipShape(RoundedRectangle(cornerRadius: 12))+ .accessibilityElement(children: .combine)+ .accessibilityLabel(message)+ .accessibilityIdentifier("capture.reviewBanner")+ }+ private var manualTitleInput: some View { TextField("Title (required)", text: $viewModel.manualTitle) .font(.headline) .textFieldStyle(.plain) .accessibilityLabel("Page title, required") .accessibilityHint("Enter a title for this capture")+ .accessibilityIdentifier("capture.manualTitle") .frame(minHeight: 44) } @@ -227,8 +374,8 @@ struct CaptureView: View { .clipShape(RoundedRectangle(cornerRadius: 12)) .accessibilityLabel("Note") .accessibilityHint("Optional note for this capture")+ .accessibilityIdentifier("capture.note") .onAppear {- // Focus the note field when it appears so keyboard presents immediately DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { noteFieldFocused = true }@@ -262,6 +409,7 @@ struct CaptureView: View { } .disabled(viewModel.isSaving) .accessibilityLabel(label)+ .accessibilityIdentifier("capture.rating.\(rating.rawValue)") .accessibilityAddTraits(isSelected ? .isSelected : []) } @@ -278,6 +426,7 @@ struct CaptureView: View { .buttonStyle(.borderedProminent) .disabled(!viewModel.canSave || viewModel.isSaving) .accessibilityLabel("Save capture")+ .accessibilityIdentifier("capture.save") } private var retryButton: some View {@@ -291,6 +440,7 @@ struct CaptureView: View { .buttonStyle(.borderedProminent) .disabled(!viewModel.canSave || viewModel.isSaving) .accessibilityLabel("Try saving again")+ .accessibilityIdentifier("capture.retry") } private func errorBanner(message: String) -> some View {@@ -310,6 +460,7 @@ struct CaptureView: View { .clipShape(RoundedRectangle(cornerRadius: 12)) .accessibilityElement(children: .combine) .accessibilityLabel("Error: \(message)")+ .accessibilityIdentifier("capture.error") } // MARK: - Helpers@@ -324,15 +475,31 @@ struct CaptureView: View { @MainActor final class ObservableCaptureViewModel: ObservableObject { private let viewModel: CaptureViewModel- private var coordinator: CaptureCoordinator?+ private var coordinator: (any CaptureCoordinating)? private var saveTask: Task<Void, Never>?+ /// Suppresses feedback loops from programmatic binding updates.+ private var suppressDidSet = false @Published var state: CaptureViewState @Published var manualTitle: String = "" {- didSet { viewModel.setManualTitle(manualTitle) ; refreshState() }+ didSet {+ guard !suppressDidSet else { return }+ Task { [weak self] in+ guard let self, let coordinator = self.coordinator else { return }+ await self.viewModel.setManualTitle(self.manualTitle, coordinator: coordinator)+ self.refreshState()+ }+ } } @Published var note: String = "" {- didSet { viewModel.setNote(note) ; refreshState() }+ didSet {+ guard !suppressDidSet else { return }+ Task { [weak self] in+ guard let self, let coordinator = self.coordinator else { return }+ await self.viewModel.setNote(self.note, coordinator: coordinator)+ self.refreshState()+ }+ } } var canSave: Bool { viewModel.canSave }@@ -358,32 +525,43 @@ final class ObservableCaptureViewModel: ObservableObject { } func toggleRating(_ rating: Rating) {- viewModel.setRating(rating)- refreshState()+ guard let coordinator else { return }+ Task { [weak self] in+ guard let self else { return }+ await self.viewModel.setRating(rating, coordinator: coordinator)+ self.refreshState()+ } } func save() { guard saveTask == nil, let coordinator,- viewModel.canSave,- let draft = viewModel.currentDraft else { return }+ viewModel.canSave else { return } // Publish the in-flight state synchronously so controls and cancellation // are disabled before the asynchronous repository save begins.- state = .saving(draft: draft)- saveTask = Task { [weak self] in+ if let draft = viewModel.currentDraft {+ state = .saving(draft: draft)+ }+ saveTask = Task { [weak self, coordinator] in guard let self else { return }- await viewModel.save(coordinator: coordinator)- refreshState()- saveTask = nil+ await self.viewModel.save(coordinator: coordinator)+ self.refreshState()+ self.saveTask = nil } } - func setCoordinator(_ coordinator: CaptureCoordinator) {+ func setCoordinator(_ coordinator: any CaptureCoordinating) { self.coordinator = coordinator } func refreshState() {+ suppressDidSet = true state = viewModel.state+ // Sync binding values from the view model's current draft without triggering didSet loops+ if let draft = viewModel.currentDraft {+ if note != draft.note { note = draft.note }+ }+ suppressDidSet = false } }
diff --git a/Asterism/AsterismShareExtension/ShareViewController.swift b/Asterism/AsterismShareExtension/ShareViewController.swiftindex 1594be0..8cd150f 100644--- a/Asterism/AsterismShareExtension/ShareViewController.swift+++ b/Asterism/AsterismShareExtension/ShareViewController.swift@@ -65,7 +65,10 @@ final class ShareViewController: UIViewController { let repository: LibraryRepository do {- repository = try await LibraryRepository.open(configuration)+ repository = try await LibraryRepository.openForExtension(+ configuration,+ capabilities: .current+ ) } catch { viewModel.setInvalidInput(message: "Library unavailable. Please try again later.") observableViewModel?.refreshState()@@ -98,7 +101,7 @@ final class ShareViewController: UIViewController { // 3. Create coordinator with bounded fetcher for non-Safari payloads let fetcher = BoundedPageTitleFetcher()- let coordinator = CaptureCoordinator(repository: repository, fetcher: fetcher)+ let coordinator: any CaptureCoordinating = CaptureCoordinator(repository: repository, fetcher: fetcher) // 4. Store coordinator for save operations observableViewModel?.setCoordinator(coordinator)
diff --git a/Asterism/AsterismTests/EntryDetailModelTests.swift b/Asterism/AsterismTests/EntryDetailModelTests.swiftindex 776479a..263f043 100644--- a/Asterism/AsterismTests/EntryDetailModelTests.swift+++ b/Asterism/AsterismTests/EntryDetailModelTests.swift@@ -3,13 +3,14 @@ import Foundation import Testing @testable import Asterism -/// Tests for EntryDetailModel: drafts, update, delete, submission suppression,-/// failure without optimistic mutation.+/// Tests for EntryDetailModel: coherent detail loading, drafts, update, delete,+/// submission suppression, mode from repository, and available actions. @Suite("EntryDetailModel") struct EntryDetailModelTests { @MainActor private func makeSUT( entry: EntrySnapshot? = nil,+ detail: EntryTeachingDetail? = nil, updateResult: Result<Void, Error> = .success(()), deleteResult: Result<Void, Error> = .success(()) ) -> (EntryDetailModel, MockLibraryProvider, CallbackTracker) {@@ -19,6 +20,19 @@ struct EntryDetailModelTests { mock.updateEntryResult = updateResult mock.deleteEntryResult = deleteResult + // Build a default detail if none provided+ let teachingDetail = detail ?? EntryTeachingDetail(+ entry: entrySnapshot,+ siteMode: .untaught,+ activePatternSummary: nil,+ historicalPatternSummaries: [],+ chapterSettlement: .unsettled(reason: "No chapter provenance; awaiting teaching or manual assignment"),+ assignmentSettlement: .unsettled(reason: "No assignment provenance; awaiting teaching or manual assignment"),+ availableActions: [.teach],+ unresolvedCandidateTitle: nil+ )+ mock.entryTeachingDetailResult = .success(teachingDetail)+ let tracker = CallbackTracker() let model = EntryDetailModel( entryID: entrySnapshot.id,@@ -28,15 +42,26 @@ struct EntryDetailModelTests { return (model, mock, tracker) } - @Test("Load populates entry and drafts")+ @Test("Load populates entry and drafts from coherent detail") @MainActor func loadPopulatesDrafts() async { let entry = TestFixtures.makeEntry(note: "my note", rating: .down)- let (model, _, _) = makeSUT(entry: entry)+ let detail = EntryTeachingDetail(+ entry: entry,+ siteMode: .untaught,+ activePatternSummary: nil,+ historicalPatternSummaries: [],+ chapterSettlement: .unsettled(reason: "No chapter provenance; awaiting teaching or manual assignment"),+ assignmentSettlement: .unsettled(reason: "No assignment provenance; awaiting teaching or manual assignment"),+ availableActions: [.teach],+ unresolvedCandidateTitle: nil+ )+ let (model, mock, _) = makeSUT(entry: entry, detail: detail) await model.load() #expect(model.state == .ready) #expect(model.entry == entry) #expect(model.draftNote == "my note") #expect(model.draftRating == .down)+ #expect(mock.entryTeachingDetailCallCount == 1) } @Test("Update commits note and rating changes")@@ -55,8 +80,19 @@ struct EntryDetailModelTests { @Test("Update failure does not optimistically mutate entry snapshot") @MainActor func updateFailureNoOptimistic() async { let entry = TestFixtures.makeEntry(note: "original", rating: .up)+ let detail = EntryTeachingDetail(+ entry: entry,+ siteMode: .untaught,+ activePatternSummary: nil,+ historicalPatternSummaries: [],+ chapterSettlement: .unsettled(reason: "No chapter provenance; awaiting teaching or manual assignment"),+ assignmentSettlement: .unsettled(reason: "No assignment provenance; awaiting teaching or manual assignment"),+ availableActions: [.teach],+ unresolvedCandidateTitle: nil+ ) let (model, _, _) = makeSUT( entry: entry,+ detail: detail, updateResult: .failure(MockLibraryProvider.MockError.simulatedFailure("save failed")) ) await model.load()@@ -85,8 +121,19 @@ struct EntryDetailModelTests { @Test("Delete failure retains entry") @MainActor func deleteFailure() async { let entry = TestFixtures.makeEntry()+ let detail = EntryTeachingDetail(+ entry: entry,+ siteMode: .untaught,+ activePatternSummary: nil,+ historicalPatternSummaries: [],+ chapterSettlement: .unsettled(reason: "No chapter provenance; awaiting teaching or manual assignment"),+ assignmentSettlement: .unsettled(reason: "No assignment provenance; awaiting teaching or manual assignment"),+ availableActions: [.teach],+ unresolvedCandidateTitle: nil+ ) let (model, _, _) = makeSUT( entry: entry,+ detail: detail, deleteResult: .failure(MockLibraryProvider.MockError.simulatedFailure("delete failed")) ) await model.load()@@ -106,6 +153,126 @@ struct EntryDetailModelTests { // Only one should have gone through #expect(mock.updateEntryCallCount == 1) }++ // MARK: - Mode from repository (not inferred from fields)++ @Test("Untaught entry gets teach action from repository, not inferred from fields")+ @MainActor func untaughtEntryShowsTeach() async {+ let entry = TestFixtures.makeEntry()+ let detail = EntryTeachingDetail(+ entry: entry,+ siteMode: .untaught,+ activePatternSummary: nil,+ historicalPatternSummaries: [],+ chapterSettlement: .unsettled(reason: "No chapter provenance; awaiting teaching or manual assignment"),+ assignmentSettlement: .unsettled(reason: "No assignment provenance; awaiting teaching or manual assignment"),+ availableActions: [.teach],+ unresolvedCandidateTitle: nil+ )+ let (model, _, _) = makeSUT(entry: entry, detail: detail)+ await model.load()+ #expect(model.teachingDetail?.siteMode == .untaught)+ #expect(model.teachingDetail?.availableActions == [.teach])+ }++ @Test("Taught entry gets reTeach and reParse actions even when fields are none/manual")+ @MainActor func taughtEntryShowsBothActions() async {+ // Entry with .none provenance on both fields, but on a taught site+ let entry = TestFixtures.makeEntry()+ let patternID = UUID()+ let detail = EntryTeachingDetail(+ entry: entry,+ siteMode: .taught,+ activePatternSummary: PatternRuleSummary(+ id: patternID,+ version: 2,+ form: .segment,+ isActive: true,+ plainLanguageRule: "Work: 1 segment(s) at offset 1 from end; Ignored: end+0; Chapter: remaining segments"+ ),+ historicalPatternSummaries: [],+ chapterSettlement: .unsettled(reason: "No chapter provenance; awaiting teaching or manual assignment"),+ assignmentSettlement: .unsettled(reason: "No assignment provenance; awaiting teaching or manual assignment"),+ availableActions: [.reTeach, .reParse],+ unresolvedCandidateTitle: "Fiction"+ )+ let (model, _, _) = makeSUT(entry: entry, detail: detail)+ await model.load()+ #expect(model.teachingDetail?.siteMode == .taught)+ #expect(model.teachingDetail?.availableActions == [.reTeach, .reParse])+ }++ @Test("Manual protection reason is carried independently in settlement")+ @MainActor func manualProtectionReason() async {+ let entry = EntrySnapshot(+ id: UUID(),+ captureTitle: "Test",+ captureTitleSource: .host,+ rawURLString: "https://example.com/page",+ canonicalURLString: nil,+ hostname: "example.com",+ entryIdentityKey: "https://example.com/page",+ identityKeyVersion: 1,+ chapterTitle: "Manual Chapter",+ chapterTitleProvenance: try! FieldProvenance(kind: .manual),+ note: "",+ rating: nil,+ firstCapturedAt: TestFixtures.fixedDate,+ lastSharedAt: TestFixtures.fixedDate,+ modifiedAt: TestFixtures.fixedDate,+ workID: nil,+ workAssignmentProvenance: try! FieldProvenance(kind: .none),+ intentionallyUnattached: false+ )+ let detail = EntryTeachingDetail(+ entry: entry,+ siteMode: .taught,+ activePatternSummary: PatternRuleSummary(+ id: UUID(), version: 1, form: .segment, isActive: true,+ plainLanguageRule: "Work: 1 segment(s) at offset 1 from end; Chapter: remaining segments"+ ),+ historicalPatternSummaries: [],+ chapterSettlement: .manualProtected(reason: "Manually set by user"),+ assignmentSettlement: .unsettled(reason: "No assignment provenance; awaiting teaching or manual assignment"),+ availableActions: [.reTeach, .reParse],+ unresolvedCandidateTitle: nil+ )+ let (model, _, _) = makeSUT(entry: entry, detail: detail)+ await model.load()+ #expect(model.teachingDetail?.chapterSettlement == .manualProtected(reason: "Manually set by user"))+ }++ @Test("Active and historical pattern summaries are carried from repository")+ @MainActor func activeAndHistoricalSummaries() async {+ let entry = TestFixtures.makeEntry()+ let activeID = UUID()+ let historicalID = UUID()+ let detail = EntryTeachingDetail(+ entry: entry,+ siteMode: .taught,+ activePatternSummary: PatternRuleSummary(+ id: activeID, version: 2, form: .segment, isActive: true,+ plainLanguageRule: "Work: 1 segment(s) at offset 1 from end; Chapter: remaining segments"+ ),+ historicalPatternSummaries: [+ PatternRuleSummary(+ id: historicalID, version: 1, form: .segment, isActive: false,+ plainLanguageRule: "Work: 1 segment(s) at offset 0 from start; Chapter: remaining segments"+ )+ ],+ chapterSettlement: .patternSettled(patternID: activeID, version: 2),+ assignmentSettlement: .patternSettled(patternID: activeID, version: 2),+ availableActions: [.reTeach, .reParse],+ unresolvedCandidateTitle: nil+ )+ let (model, _, _) = makeSUT(entry: entry, detail: detail)+ await model.load()+ #expect(model.teachingDetail?.activePatternSummary?.id == activeID)+ #expect(model.teachingDetail?.activePatternSummary?.version == 2)+ #expect(model.teachingDetail?.historicalPatternSummaries.count == 1)+ #expect(model.teachingDetail?.historicalPatternSummaries[0].id == historicalID)+ #expect(model.teachingDetail?.historicalPatternSummaries[0].isActive == false)+ } } /// Helper to track callback invocations.
diff --git a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swiftindex c19adf4..ab9dce8 100644--- a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift+++ b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift@@ -6,6 +6,7 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable { // MARK: - Call tracking var recentEntriesCallCount = 0+ var recentPresentationCallCount = 0 var worksCallCount = 0 var entryCallCount = 0 var workCallCount = 0@@ -19,6 +20,9 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable { // MARK: - Preset results var recentEntriesResult: Result<[DatedEntryGroup], Error> = .success([])+ var recentPresentationResult: Result<RecentPresentation, Error> = .success(+ RecentPresentation(groups: [], actionableCount: 0)+ ) var worksResult: Result<WorksSnapshot, Error> = .success(WorksSnapshot(works: [], unattachedEntries: [])) var entryResult: Result<EntrySnapshot, Error> = .failure(MockError.notConfigured) var workResult: Result<WorkSnapshot, Error> = .failure(MockError.notConfigured)@@ -55,6 +59,11 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable { return try recentEntriesResult.get() } + func recentPresentation(calendar: Calendar) async throws -> RecentPresentation {+ recentPresentationCallCount += 1+ return try recentPresentationResult.get()+ }+ func works() async throws -> WorksSnapshot { worksCallCount += 1 return try worksResult.get()@@ -105,4 +114,102 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable { workDestinationsCallCount += 1 return try workDestinationsResult.get() }++ // MARK: - Teaching contract stubs++ var entryTeachingDetailResult: Result<EntryTeachingDetail, Error> = .failure(MockError.notConfigured)+ var entryTeachingDetailCallCount = 0++ var titlePatternResult: Result<TitlePatternSnapshot, Error> = .failure(MockError.notConfigured)+ var titlePatternCallCount = 0++ var projectInitialTeachingResult: Result<TeachingContract, Error> = .failure(MockError.notConfigured)+ var projectInitialTeachingCallCount = 0++ var projectReplacementResult: Result<TeachingContract, Error> = .failure(MockError.notConfigured)+ var projectReplacementCallCount = 0++ var commitTeachingResult: Result<TeachingCommitOutcome, Error> = .failure(MockError.notConfigured)+ var commitTeachingCallCount = 0+ var lastCommittedTeachingContract: TeachingContract?++ var projectArticlesResult: Result<ArticlesContract, Error> = .failure(MockError.notConfigured)+ var projectArticlesCallCount = 0++ var commitArticlesResult: Result<ArticlesCommitOutcome, Error> = .failure(MockError.notConfigured)+ var commitArticlesCallCount = 0+ var lastCommittedArticlesContract: ArticlesContract?++ var projectReparseResult: Result<ReparseContract, Error> = .failure(MockError.notConfigured)+ var projectReparseCallCount = 0++ var commitReparseResult: Result<ReparseCommitOutcome, Error> = .failure(MockError.notConfigured)+ var commitReparseCallCount = 0+ var lastCommittedReparseContract: ReparseContract?++ var projectCaptureResult: Result<CaptureContract, Error> = .failure(MockError.notConfigured)+ var projectCaptureCallCount = 0++ var commitCaptureResult: Result<CaptureCommitOutcome, Error> = .failure(MockError.notConfigured)+ var commitCaptureCallCount = 0+ var lastCommittedCaptureContract: CaptureContract?++ func titlePattern(id: UUID) async throws -> TitlePatternSnapshot {+ titlePatternCallCount += 1+ return try titlePatternResult.get()+ }++ func entryTeachingDetail(id: UUID) async throws -> EntryTeachingDetail {+ entryTeachingDetailCallCount += 1+ return try entryTeachingDetailResult.get()+ }++ func projectInitialTeaching(hostname: String, patternDefinition: PatternDefinition) async throws -> TeachingContract {+ projectInitialTeachingCallCount += 1+ return try projectInitialTeachingResult.get()+ }++ func projectReplacement(hostname: String, patternDefinition: PatternDefinition, boundaryEntryID: UUID, historicalPatternID: UUID?, historicalPatternVersion: Int?) async throws -> TeachingContract {+ projectReplacementCallCount += 1+ return try projectReplacementResult.get()+ }++ func commitTeaching(_ contract: TeachingContract) async throws -> TeachingCommitOutcome {+ commitTeachingCallCount += 1+ lastCommittedTeachingContract = contract+ return try commitTeachingResult.get()+ }++ func projectArticles(hostname: String, junkSuffixRule: JunkSuffixRule?) async throws -> ArticlesContract {+ projectArticlesCallCount += 1+ return try projectArticlesResult.get()+ }++ func commitArticles(_ contract: ArticlesContract) async throws -> ArticlesCommitOutcome {+ commitArticlesCallCount += 1+ lastCommittedArticlesContract = contract+ return try commitArticlesResult.get()+ }++ func projectReparse(entryID: UUID) async throws -> ReparseContract {+ projectReparseCallCount += 1+ return try projectReparseResult.get()+ }++ func commitReparse(_ contract: ReparseContract) async throws -> ReparseCommitOutcome {+ commitReparseCallCount += 1+ lastCommittedReparseContract = contract+ return try commitReparseResult.get()+ }++ func projectCapture(hostname: String, captureTitle: String, captureTitleSource: CaptureTitleSource, rawURLString: String, canonicalURLString: String?, note: String, rating: Rating?) async throws -> CaptureContract {+ projectCaptureCallCount += 1+ return try projectCaptureResult.get()+ }++ func commitCapture(_ contract: CaptureContract) async throws -> CaptureCommitOutcome {+ commitCaptureCallCount += 1+ lastCommittedCaptureContract = contract+ return try commitCaptureResult.get()+ } }
diff --git a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift b/Asterism/AsterismTests/IntegrationSafetyNetTests.swiftindex 85b526b..f7cde4e 100644--- a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift+++ b/Asterism/AsterismTests/IntegrationSafetyNetTests.swift@@ -18,7 +18,7 @@ struct IntegrationSafetyNetTests { #expect(developmentApp.state == .ready) #expect(personalApp.state == .ready) - let extensionRepository = try await LibraryRepository.open(fixture.developmentConfiguration)+ let extensionRepository = try await LibraryRepository.openForExtension(fixture.developmentConfiguration) let captured = try await extensionRepository.capture( CaptureDraft( captureTitle: "Cross-process chapter",@@ -44,7 +44,7 @@ struct IntegrationSafetyNetTests { let fixture = try IntegrationFixture() defer { fixture.cleanup() } - let appRepository = try await LibraryRepository.open(fixture.developmentConfiguration)+ let appRepository = try await LibraryRepository.openForApp(fixture.developmentConfiguration) let entry = try await appRepository.capture( CaptureDraft( captureTitle: "Assignment chapter",@@ -57,7 +57,7 @@ struct IntegrationSafetyNetTests { ) try await appRepository.moveEntry(entry.id, to: .existing(work.id)) - let freshExtensionRepository = try await LibraryRepository.open(fixture.developmentConfiguration)+ let freshExtensionRepository = try await LibraryRepository.openForExtension(fixture.developmentConfiguration) let observed = try await freshExtensionRepository.entry(id: entry.id) let destinations = try await freshExtensionRepository.workDestinations(for: entry.id) @@ -79,7 +79,7 @@ struct IntegrationSafetyNetTests { try FileManager.default.removeItem(at: configuration.storeURL) do {- _ = try await LibraryRepository.open(configuration)+ _ = try await LibraryRepository.openForApp(configuration) Issue.record("Expected an initialized library with a missing store to fail closed") } catch let error as LibraryRepositoryError { guard case .libraryUnavailable = error else {@@ -97,7 +97,7 @@ struct IntegrationSafetyNetTests { let fixture = try IntegrationFixture() defer { fixture.cleanup() } - let repository = try await LibraryRepository.open(fixture.developmentConfiguration)+ let repository = try await LibraryRepository.openForApp(fixture.developmentConfiguration) let entry = try await repository.capture( CaptureDraft( captureTitle: "Backup chapter ✦",@@ -128,29 +128,95 @@ struct IntegrationSafetyNetTests { let result = try await exporter.export( metadata: BackupMetadata( appBuild: "integration-1",- databaseSchemaVersion: 1,+ databaseSchemaVersion: 2, exportedAt: exportedAt ) ) defer { exporter.cleanup(result) } let encoded = try Data(contentsOf: result.fileURL)- let decoded = try BackupV1Codec.decode(encoded)+ let decoded = try BackupV2Codec.decode(encoded, capabilities: .current) let source = try await repository.backupSnapshot()- try BackupV1Validator.validate(decoded: decoded, source: source, encodedData: encoded)-- #expect(decoded.header.entryCount == 1)- #expect(decoded.header.workCount == 1)- #expect(decoded.snapshot.entries.first?.id == entry.id)- #expect(decoded.snapshot.entries.first?.note == "Preserve this note exactly — including Unicode.")- #expect(decoded.snapshot.entries.first?.workID == work.id)- #expect(decoded.snapshot.works.first?.displayTitle == "Constellation Work Revised")- #expect(decoded.snapshot.works.first?.entryIDs == [entry.id])- #expect(decoded.snapshot.sites.map(\.hostname) == ["backup.test"])++ #expect(decoded.payload == source)+ #expect(decoded.payload.entries.count == 1)+ #expect(decoded.payload.works.count == 1)+ #expect(decoded.payload.entries.first?.id == entry.id)+ #expect(decoded.payload.entries.first?.note == "Preserve this note exactly — including Unicode.")+ #expect(decoded.payload.entries.first?.workID == work.id)+ #expect(decoded.payload.works.first?.displayTitle == "Constellation Work Revised")+ #expect(decoded.payload.works.first?.entryIDs == [entry.id])+ #expect(decoded.payload.sites.map(\.hostname) == ["backup.test"])+ }++ @Test("Every M2 gate stays consistent across Development and Personal")+ @MainActor+ func everyGateUsesOneCapabilityValuePerEnvironment() async throws {+ let fixture = try IntegrationFixture()+ defer { fixture.cleanup() }++ for environment in [LibraryEnvironment.development, .personal] {+ for capabilities in [+ M2Capabilities.m2_0,+ .m2_1,+ .m2_2,+ .m2_3,+ ] {+ let configuration = LibraryConfiguration(+ rootDirectory: fixture.baseDirectory.appending(+ path: "\(environment.rawValue)-\(capabilities.gate.rawValue)",+ directoryHint: .isDirectory+ ),+ environment: environment+ )+ let app = AppLibraryModel(+ configuration: configuration,+ capabilities: capabilities+ )++ await app.bootstrap()+ #expect(app.state == .ready)+ #expect(app.capabilities == capabilities)++ let extensionRepository = try await LibraryRepository.openForExtension(+ configuration,+ capabilities: capabilities+ )+ let entry = try await extensionRepository.capture(+ CaptureDraft(+ captureTitle: "Work :: Chapter",+ captureTitleSource: .manual,+ rawURLString: "https://\(capabilities.gate.rawValue).\(environment.rawValue).test/1"+ )+ )+ await app.handleActivation()++ #expect(+ (app.teachingModel(for: entry.id) != nil)+ == capabilities.supportsSegmentTeaching+ )++ guard let backup = app.settingsBackupModel() else {+ Issue.record("Expected Backup wiring for \(environment.rawValue) \(capabilities.gate.rawValue)")+ continue+ }+ await backup.startExport()+ guard let backupURL = backup.exportedFileURL else {+ Issue.record("Expected Backup export for \(environment.rawValue) \(capabilities.gate.rawValue)")+ continue+ }+ let document = try BackupV2Codec.decode(+ Data(contentsOf: backupURL),+ capabilities: capabilities+ )+ #expect(document.capabilityGate == capabilities.gate)+ backup.handleShareCancellation()+ }+ } } private func initializeLibrary(at configuration: LibraryConfiguration) async throws {- let repository = try await LibraryRepository.open(configuration)+ let repository = try await LibraryRepository.openForApp(configuration) _ = try await repository.capture( CaptureDraft( captureTitle: "Durable record",
diff --git a/Asterism/AsterismTests/ReparseViewModelTests.swift b/Asterism/AsterismTests/ReparseViewModelTests.swiftnew file mode 100644index 0000000..ccd4fe9--- /dev/null+++ b/Asterism/AsterismTests/ReparseViewModelTests.swift@@ -0,0 +1,230 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++/// Tests for ReparseViewModel: preview-before-confirm, refreshed requires second confirm,+/// invalidated/failure no mutation, manual protections visible.+@Suite("ReparseViewModel")+struct ReparseViewModelTests {++ private static let fixedDate = Date(timeIntervalSince1970: 1_750_000_000)+ private static let patternID = UUID()+ private static let entryID = UUID()+ private static let workID = UUID()++ private static func makePattern() -> PatternBasisEntry {+ PatternBasisEntry(+ id: patternID,+ version: 1,+ isActive: true,+ definition: .segment(+ work: try! SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try! SegmentPositionSpec(origin: .end, offset: 0)]+ ),+ createdAt: fixedDate+ )+ }++ private static func makeEntryBasis(+ chapterProvenance: FieldProvenance = try! FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 1),+ assignmentProvenance: FieldProvenance = try! FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 1)+ ) -> EntryBasisEntry {+ EntryBasisEntry(+ id: entryID,+ captureTitle: "Chapter 5 - Fiction | Royal Road",+ hostname: "example.com",+ firstCapturedAt: fixedDate,+ chapterTitle: "Chapter 5",+ chapterTitleProvenance: chapterProvenance,+ workID: workID,+ workAssignmentProvenance: assignmentProvenance,+ intentionallyUnattached: false,+ note: "",+ rating: nil+ )+ }++ private static func makeContract(+ entry: EntryBasisEntry? = nil,+ projection: EntryProjection? = nil+ ) -> ReparseContract {+ let entryBasis = entry ?? makeEntryBasis()+ let pattern = makePattern()+ let basis = ReparseBasis(+ siteMode: .taught,+ hostname: "example.com",+ activePattern: pattern,+ entry: entryBasis,+ works: [WorkBasisEntry(+ id: workID, displayTitle: "Fiction", lastParsedTitle: "Fiction",+ titleProvenance: .parsed, siteHostname: "example.com",+ createdAt: fixedDate, modifiedAt: fixedDate+ )]+ )+ let proj = projection ?? EntryProjection(+ entryID: entryID,+ projectedChapter: "Chapter 5",+ projectedWorkTitle: "Fiction",+ workOutcome: .reuse(workID: workID),+ projectedWorkID: workID,+ chapterProvenance: .pendingPattern(PendingPatternProvenance(patternVersion: 1)),+ assignmentProvenance: .pendingPattern(PendingPatternProvenance(patternVersion: 1)),+ chapterProtected: false,+ assignmentProtected: false,+ parseFailure: nil,+ actionableAfter: false,+ previousWorkID: workID,+ previousChapter: "Chapter 5"+ )+ let plan = ProjectionPlan(+ entryProjections: [proj],+ worksToCreate: [],+ worksToReuse: [workID],+ hasAmbiguity: false,+ patternDefinition: pattern.definition,+ patternVersion: 1+ )+ let outcome = ReparseOutcome(plan: plan)+ return ReparseContract(basis: basis, request: ReparseRequest(entryID: entryID), outcome: outcome)+ }++ @MainActor private func makeSUT(+ projectResult: Result<ReparseContract, Error>? = nil,+ commitResult: Result<ReparseCommitOutcome, Error> = .success(.committed)+ ) -> (ReparseViewModel, MockLibraryProvider, ReparseCallbackTracker) {+ let mock = MockLibraryProvider()+ mock.projectReparseResult = projectResult ?? .success(Self.makeContract())+ mock.commitReparseResult = commitResult++ let tracker = ReparseCallbackTracker()+ let model = ReparseViewModel(+ entryID: Self.entryID,+ library: mock,+ onMutation: { tracker.mutationCount += 1 }+ )+ return (model, mock, tracker)+ }++ @Test("Load projects the contract and shows projected state")+ @MainActor func previewBeforeConfirm() async {+ let (model, mock, _) = makeSUT()+ await model.load()+ #expect(model.state == .projected)+ #expect(model.contract != nil)+ #expect(model.projection != nil)+ #expect(model.projection?.projectedChapter == "Chapter 5")+ #expect(model.projection?.projectedWorkTitle == "Fiction")+ #expect(mock.projectReparseCallCount == 1)+ }++ @Test("Confirm commits and triggers onMutation")+ @MainActor func confirmCommits() async {+ let (model, mock, tracker) = makeSUT()+ await model.load()+ await model.confirm()+ #expect(model.state == .committed)+ #expect(mock.commitReparseCallCount == 1)+ #expect(tracker.mutationCount == 1)+ }++ @Test("Refreshed requires second confirm")+ @MainActor func refreshedRequiresSecondConfirm() async {+ let freshContract = Self.makeContract()+ let (model, mock, tracker) = makeSUT(+ commitResult: .success(.refreshed(freshContract))+ )+ await model.load()+ await model.confirm()+ #expect(model.state == .refreshed)+ #expect(model.requiresReconfirmation == true)+ #expect(tracker.mutationCount == 0)++ // Second confirm commits+ mock.commitReparseResult = .success(.committed)+ await model.reconfirm()+ #expect(model.state == .committed)+ #expect(tracker.mutationCount == 1)+ }++ @Test("Invalidated does not mutate")+ @MainActor func invalidatedNoMutation() async {+ let (model, _, tracker) = makeSUT(+ commitResult: .success(.invalidated(reason: "Site untaught"))+ )+ await model.load()+ await model.confirm()+ guard case .invalidated(let reason) = model.state else {+ Issue.record("Expected invalidated state"); return+ }+ #expect(reason == "Site untaught")+ #expect(tracker.mutationCount == 0)+ }++ @Test("Error does not mutate")+ @MainActor func errorNoMutation() async {+ let (model, _, tracker) = makeSUT(+ commitResult: .failure(MockLibraryProvider.MockError.simulatedFailure("network error"))+ )+ await model.load()+ await model.confirm()+ guard case .error = model.state else {+ Issue.record("Expected error state"); return+ }+ #expect(tracker.mutationCount == 0)+ }++ @Test("Projection failure shows error state")+ @MainActor func projectionFailureShowsError() async {+ let (model, _, _) = makeSUT(+ projectResult: .failure(MockLibraryProvider.MockError.simulatedFailure("unavailable"))+ )+ await model.load()+ guard case .error = model.state else {+ Issue.record("Expected error state"); return+ }+ #expect(model.errorMessage != nil)+ }++ @Test("Manual protections are visible in projection")+ @MainActor func manualProtectionsVisible() async {+ let manualEntry = Self.makeEntryBasis(+ chapterProvenance: try! FieldProvenance(kind: .manual),+ assignmentProvenance: try! FieldProvenance(kind: .manual)+ )+ let protectedProjection = EntryProjection(+ entryID: Self.entryID,+ projectedChapter: "Manual Chapter",+ projectedWorkTitle: nil,+ workOutcome: nil,+ projectedWorkID: Self.workID,+ chapterProvenance: .preserved(try! FieldProvenance(kind: .manual)),+ assignmentProvenance: .preserved(try! FieldProvenance(kind: .manual)),+ chapterProtected: true,+ assignmentProtected: true,+ parseFailure: nil,+ actionableAfter: false,+ previousWorkID: Self.workID,+ previousChapter: "Manual Chapter"+ )+ let contract = Self.makeContract(entry: manualEntry, projection: protectedProjection)+ let (model, _, _) = makeSUT(projectResult: .success(contract))+ await model.load()+ #expect(model.projection?.chapterProtected == true)+ #expect(model.projection?.assignmentProtected == true)+ }++ @Test("Duplicate submission is suppressed")+ @MainActor func duplicateSubmissionSuppressed() async {+ let (model, mock, _) = makeSUT()+ await model.load()+ async let first: () = model.confirm()+ async let second: () = model.confirm()+ _ = await (first, second)+ #expect(mock.commitReparseCallCount == 1)+ }+}++final class ReparseCallbackTracker: @unchecked Sendable {+ var mutationCount = 0+}
diff --git a/Asterism/AsterismTests/SettingsBackupModelTests.swift b/Asterism/AsterismTests/SettingsBackupModelTests.swiftindex a1593b1..db09fde 100644--- a/Asterism/AsterismTests/SettingsBackupModelTests.swift+++ b/Asterism/AsterismTests/SettingsBackupModelTests.swift@@ -40,6 +40,7 @@ struct SettingsBackupModelTests { #expect(model.exportedFileURL == fakeURL) #expect(model.errorMessage == nil) #expect(mock.exportCallCount == 1)+ #expect(mock.lastMetadata?.databaseSchemaVersion == 2) } // MARK: - Failure Path: Export Error@@ -221,12 +222,14 @@ final class MockBackupExporting: BackupExporting, @unchecked Sendable { var cleanupCallCount = 0 var scavengeCallCount = 0 var lastCleanupURL: URL?+ var lastMetadata: BackupMetadata? var exportResult: Result<BackupExportResult, Error> = .failure(MockBackupError.notConfigured) var exportDelay: Duration? func export(metadata: BackupMetadata) async throws -> BackupExportResult { exportCallCount += 1+ lastMetadata = metadata if let delay = exportDelay { try? await Task.sleep(for: delay) }
diff --git a/Asterism/AsterismTests/TeachingViewModelTests.swift b/Asterism/AsterismTests/TeachingViewModelTests.swiftnew file mode 100644index 0000000..047337f--- /dev/null+++ b/Asterism/AsterismTests/TeachingViewModelTests.swift@@ -0,0 +1,822 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++/// Tests for the teaching view model: two-stage UX with preview generation,+/// generation-safe async preview, separate confirmation, delayed mock tests+/// proving obsolete generation cannot replace newer state.+@Suite("TeachingViewModel")+struct TeachingViewModelTests {++ // MARK: - Fixtures++ private static let fixedDate = Date(timeIntervalSince1970: 1_750_000_000)++ private static func makeTeachingContract(+ hostname: String = "example.com",+ patternVersion: Int = 1,+ entryCount: Int = 1,+ activeDefinition: PatternDefinition? = nil+ ) -> TeachingContract {+ let entries: [EntryBasisEntry] = (0..<entryCount).map { i in+ EntryBasisEntry(+ id: UUID(),+ captureTitle: "Work Title :: Chapter \(i + 1)",+ hostname: hostname,+ firstCapturedAt: fixedDate.addingTimeInterval(Double(i) * 60),+ chapterTitle: nil,+ chapterTitleProvenance: try! FieldProvenance(kind: .none),+ workID: nil,+ workAssignmentProvenance: try! FieldProvenance(kind: .none),+ intentionallyUnattached: false,+ note: "",+ rating: nil+ )+ }+ let patterns = activeDefinition.map {+ [PatternBasisEntry(+ id: UUID(),+ version: max(1, patternVersion - 1),+ isActive: true,+ definition: $0,+ createdAt: fixedDate.addingTimeInterval(-60)+ )]+ } ?? []+ let basis = TeachingBasis(+ siteMode: activeDefinition == nil ? .untaught : .taught,+ hostname: hostname,+ patterns: patterns,+ entries: entries,+ works: []+ )+ let request = TeachingRequest(+ operation: .initial,+ patternDefinition: .segment(+ work: try! SegmentRangeSpec(origin: .start, offset: 0, length: 1),+ ignored: []+ )+ )+ let entrySnapshots = entries.map { e in+ TestFixtures.makeEntry(+ id: e.id,+ captureTitle: e.captureTitle,+ hostname: e.hostname+ )+ }+ let plan = try! TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: request.patternDefinition,+ entries: entrySnapshots,+ existingWorks: [],+ patternVersion: patternVersion+ )+ let outcome = TeachingOutcome(plan: plan, computedPatternVersion: patternVersion)+ return TeachingContract(basis: basis, request: request, outcome: outcome)+ }++ // MARK: - M2 Capabilities gate++ @Test("M2Capabilities.current is m2.3 for phrase teaching")+ func currentCapabilitiesAreM2_3() {+ #expect(M2Capabilities.current.gate == .m2_3)+ #expect(M2Capabilities.current.supportsSegmentTeaching == true)+ #expect(M2Capabilities.current.supportsArticles == true)+ #expect(M2Capabilities.current.supportsPhraseTeaching == true)+ }++ @Test("Current M2.3 gate allows both pattern forms")+ func currentGateAllowsBothPatternForms() {+ let segmentDef = PatternDefinition.segment(+ work: try! SegmentRangeSpec(origin: .start, offset: 0, length: 1),+ ignored: []+ )+ let phraseDef = PatternDefinition.phrase(+ prefix: "", separator: " :: ", suffix: "", order: .workThenChapter+ )+ #expect(M2Capabilities.current.allows(patternForm: .segment) == true)+ #expect(M2Capabilities.current.allows(patternForm: .phrase) == true)+ try! M2Capabilities.current.validate(patternDefinition: phraseDef)+ try! M2Capabilities.current.validate(patternDefinition: segmentDef)+ }++ // MARK: - Loading and segment tokenization++ @Test("TeachingViewModel loads segments from entry title")+ @MainActor func loadSegments() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "My Great Novel :: Chapter 42",+ hostname: "novels.example"+ )+ mock.entryResult = .success(entry)++ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current+ )+ await vm.load()++ #expect(vm.state == .ready)+ #expect(vm.segments.count == 2)+ #expect(vm.segments[0] == "My Great Novel")+ #expect(vm.segments[1] == "Chapter 42")+ }++ // MARK: - Role cycling++ @Test("TeachingViewModel segment role cycling: Chapter→Work→Ignore")+ @MainActor func segmentRoleCycling() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "A :: B :: C",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)+ let contract = Self.makeTeachingContract(hostname: "test.example")+ mock.projectInitialTeachingResult = .success(contract)++ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current+ )+ await vm.load()++ #expect(vm.roles[0] == .chapter)+ vm.cycleRole(at: 0)+ #expect(vm.roles[0] == .work)+ vm.cycleRole(at: 0)+ #expect(vm.roles[0] == .ignore)+ vm.cycleRole(at: 0)+ #expect(vm.roles[0] == .chapter)+ }++ // MARK: - Validation++ @Test("TeachingViewModel validates contiguous chapter/work requirement")+ @MainActor func validationRequiresContiguous() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "A :: B :: C",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)+ let contract = Self.makeTeachingContract(hostname: "test.example")+ mock.projectInitialTeachingResult = .success(contract)++ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current+ )+ await vm.load()++ vm.setRole(at: 0, to: .work)+ vm.setRole(at: 1, to: .chapter)+ vm.setRole(at: 2, to: .work)++ #expect(vm.isValid == false)+ #expect(vm.validationError != nil)+ }++ @Test("TeachingViewModel valid assignment enables preview, not confirm directly")+ @MainActor func validAssignmentEnablesPreview() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "Work Title :: Chapter 1",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)+ let contract = Self.makeTeachingContract(hostname: "test.example")+ mock.projectInitialTeachingResult = .success(contract)++ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current+ )+ await vm.load()++ vm.setRole(at: 0, to: .work)+ vm.setRole(at: 1, to: .chapter)++ #expect(vm.isValid == true)+ #expect(vm.validationError == nil)+ // Cannot confirm without preview+ #expect(vm.canConfirm == false)+ }++ // MARK: - Two-stage UX: preview then confirm++ @Test("Valid role edit triggers async preview; confirm only after preview")+ @MainActor func twoStagePreviewThenConfirm() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "Work :: Ch1",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)+ let contract = Self.makeTeachingContract(hostname: "test.example")+ mock.projectInitialTeachingResult = .success(contract)+ mock.commitTeachingResult = .success(.committed(patternID: UUID(), patternVersion: 1))++ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current+ )+ await vm.load()+ vm.setRole(at: 0, to: .work)+ vm.setRole(at: 1, to: .chapter)++ // Wait for async preview+ await vm.generatePreview()++ #expect(vm.state == .previewReady)+ #expect(vm.previewPlan != nil)+ #expect(vm.canConfirm == true)+ #expect(mock.projectInitialTeachingCallCount >= 1)++ // Now confirm+ await vm.confirm()+ #expect(vm.state == .committed)+ #expect(mock.commitTeachingCallCount == 1)+ }++ @Test("No commit without displayed contract")+ @MainActor func noCommitWithoutContract() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "Work :: Ch1",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)++ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current+ )+ await vm.load()+ vm.setRole(at: 0, to: .work)+ vm.setRole(at: 1, to: .chapter)++ // Attempt confirm without preview+ await vm.confirm()++ // Should not have committed+ #expect(vm.state != .committed)+ #expect(mock.commitTeachingCallCount == 0)+ }++ @Test("Role edit invalidates old approval and increments generation")+ @MainActor func roleEditInvalidatesPreview() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "A :: B :: C",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)+ let contract = Self.makeTeachingContract(hostname: "test.example")+ mock.projectInitialTeachingResult = .success(contract)++ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current+ )+ await vm.load()+ vm.setRole(at: 0, to: .work)+ vm.setRole(at: 1, to: .chapter)+ vm.setRole(at: 2, to: .ignore)++ await vm.generatePreview()+ #expect(vm.state == .previewReady)+ #expect(vm.canConfirm == true)++ // Now change a role — should invalidate+ vm.setRole(at: 2, to: .chapter)+ #expect(vm.canConfirm == false)+ #expect(vm.previewPlan == nil)+ }++ @Test("Obsolete generation preview cannot replace newer state")+ @MainActor func obsoleteGenerationCannotReplaceNewer() async {+ // This test proves that if generatePreview is called for one generation,+ // and a role edit happens before the preview returns, the stale result is discarded.+ // We use a mock that increments call count to verify the mechanism.+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "A :: B :: C",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)+ let contract = TeachingViewModelTests.makeTeachingContract(hostname: "test.example")+ mock.projectInitialTeachingResult = .success(contract)++ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current+ )+ await vm.load()++ // First valid assignment+ vm.setRole(at: 0, to: .work)+ vm.setRole(at: 1, to: .chapter)+ vm.setRole(at: 2, to: .ignore)+ await vm.generatePreview()+ #expect(vm.state == .previewReady)+ #expect(vm.canConfirm == true)++ // Now change role — this invalidates the old preview+ vm.setRole(at: 2, to: .chapter)+ // canConfirm should be false because the preview is stale+ #expect(vm.canConfirm == false)+ #expect(vm.previewPlan == nil)++ // Old contract is gone, need to generate new preview+ await vm.generatePreview()+ #expect(vm.state == .previewReady)+ #expect(vm.canConfirm == true)+ // Verify multiple projections were requested+ #expect(mock.projectInitialTeachingCallCount >= 2)+ }++ @Test("Ten rapid edits publish only the newest preview generation")+ @MainActor func rapidEditsDiscardNineStaleGenerations() async throws {+ let entry = TestFixtures.makeEntry(+ captureTitle: "A :: B :: C",+ hostname: "test.example"+ )+ let contract = Self.makeTeachingContract(hostname: "test.example")+ let library = DelayedMockLibraryProvider(+ projectDelay: .milliseconds(75),+ contract: contract+ )+ let model = TeachingViewModel(+ entry: entry,+ library: library,+ capabilities: .m2_3+ )+ await model.load()+ // load() initializes every segment as Chapter. Count the first Work edit as+ // generation one, then alternate the third role for nine more accepted edits.+ // setRole starts preview generation itself, so no manual projection task is needed.+ for generation in 0..<10 {+ if generation == 0 {+ model.setRole(at: 0, to: .work)+ } else {+ model.setRole(at: 2, to: generation.isMultiple(of: 2) ? .chapter : .ignore)+ }+ while await library.projectCallCount < generation + 1 {+ await Task.yield()+ }+ try await Task.sleep(for: .milliseconds(50))+ }+ try await Task.sleep(for: .milliseconds(100))++ let projectCallCount = await library.projectCallCount+ #expect(projectCallCount == 10)+ #expect(model.state == .previewReady)+ #expect(model.canConfirm)+ #expect(model.previewPlan == contract.outcome.plan)+ }++ // MARK: - Commit outcomes++ @Test("TeachingViewModel commit returns committed and fires onMutation")+ @MainActor func commitSuccess() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "Work :: Ch1",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)+ let contract = Self.makeTeachingContract(hostname: "test.example")+ mock.projectInitialTeachingResult = .success(contract)+ mock.commitTeachingResult = .success(.committed(patternID: UUID(), patternVersion: 1))++ let mutationCounter = MutationCounter()+ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current,+ onMutation: { await mutationCounter.increment() }+ )+ await vm.load()+ vm.setRole(at: 0, to: .work)+ vm.setRole(at: 1, to: .chapter)+ await vm.generatePreview()+ await vm.confirm()++ let mutationCount = await mutationCounter.count+ #expect(vm.state == .committed)+ #expect(mutationCount == 1)+ #expect(mock.commitTeachingCallCount == 1)+ }++ @Test("TeachingViewModel commit refreshed requires re-confirm")+ @MainActor func commitRefreshed() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "Work :: Ch1",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)+ let contract = Self.makeTeachingContract(hostname: "test.example")+ mock.projectInitialTeachingResult = .success(contract)++ let freshContract = Self.makeTeachingContract(hostname: "test.example", patternVersion: 2)+ mock.commitTeachingResult = .success(.refreshed(freshContract))++ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current+ )+ await vm.load()+ vm.setRole(at: 0, to: .work)+ vm.setRole(at: 1, to: .chapter)+ await vm.generatePreview()+ await vm.confirm()++ #expect(vm.state == .refreshed)+ #expect(vm.requiresReconfirmation == true)+ }++ @Test("TeachingViewModel commit invalidated shows explanation, writes nothing")+ @MainActor func commitInvalidated() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "Work :: Ch1",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)+ let contract = Self.makeTeachingContract(hostname: "test.example")+ mock.projectInitialTeachingResult = .success(contract)+ mock.commitTeachingResult = .success(.invalidated(reason: "Entry was deleted"))++ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current+ )+ await vm.load()+ vm.setRole(at: 0, to: .work)+ vm.setRole(at: 1, to: .chapter)+ await vm.generatePreview()+ await vm.confirm()++ #expect(vm.state == .invalidated)+ #expect(vm.invalidationReason == "Entry was deleted")+ }++ @Test("TeachingViewModel save failure preserves draft and explains")+ @MainActor func saveFailurePreservesDraft() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "Work :: Ch1",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)+ let contract = Self.makeTeachingContract(hostname: "test.example")+ mock.projectInitialTeachingResult = .success(contract)+ mock.commitTeachingResult = .failure(MockLibraryProvider.MockError.simulatedFailure("disk full"))++ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current+ )+ await vm.load()+ vm.setRole(at: 0, to: .work)+ vm.setRole(at: 1, to: .chapter)+ await vm.generatePreview()+ await vm.confirm()++ #expect(vm.state == .error)+ #expect(vm.errorMessage != nil)+ #expect(vm.roles[0] == .work)+ #expect(vm.roles[1] == .chapter)+ }++ @Test("TeachingViewModel cancel writes nothing")+ @MainActor func cancelWritesNothing() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "Work :: Ch1",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)++ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current+ )+ await vm.load()+ vm.setRole(at: 0, to: .work)+ vm.setRole(at: 1, to: .chapter)+ vm.cancel()++ #expect(vm.state == .cancelled)+ #expect(mock.commitTeachingCallCount == 0)+ }++ // MARK: - Preview errors per row++ @Test("Preview errors populated per row on parse failure")+ @MainActor func previewErrorsPerRow() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "Work :: Ch1",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)+ let contract = Self.makeTeachingContract(hostname: "test.example")+ mock.projectInitialTeachingResult = .success(contract)++ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current+ )+ await vm.load()+ vm.setRole(at: 0, to: .work)+ vm.setRole(at: 1, to: .chapter)+ await vm.generatePreview()++ // For a successful parse, preview errors should be empty+ #expect(vm.previewErrors.isEmpty)+ }++ // MARK: - Replacement mode++ @Test("TeachingViewModel in replacement mode calls projectReplacement")+ @MainActor func replacementModeCallsProjectReplacement() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(+ captureTitle: "Work :: Ch1",+ hostname: "test.example"+ )+ mock.entryResult = .success(entry)+ let activeDefinition = PatternDefinition.segment(+ work: try! SegmentRangeSpec(origin: .end, offset: 0, length: 1),+ ignored: []+ )+ let contract = Self.makeTeachingContract(+ hostname: "test.example",+ patternVersion: 2,+ activeDefinition: activeDefinition+ )+ mock.projectReplacementResult = .success(contract)++ let boundaryID = UUID()+ let vm = TeachingViewModel(+ entry: entry,+ library: mock,+ capabilities: .current,+ mode: .replacement(boundaryEntryID: boundaryID, historicalPatternID: nil, historicalPatternVersion: nil)+ )+ await vm.load()+ vm.setRole(at: 0, to: .work)+ vm.setRole(at: 1, to: .chapter)+ await vm.generatePreview()++ #expect(mock.projectReplacementCallCount >= 1)+ #expect(vm.state == .previewReady)+ #expect(vm.parseComparisons.count == contract.basis.entries.count)+ #expect(vm.parseComparisons.allSatisfy { $0.activeResult != nil })+ #expect(vm.parseComparisons.allSatisfy { $0.candidateProjection.entryID == $0.entryID })+ }++ // MARK: - Articles mode++ @Test("Articles mode previews suffix cleaning and commits only the displayed contract")+ @MainActor func articlesPreviewAndCommit() async throws {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(captureTitle: "Article - Example Site", hostname: "test.example")+ let basisEntry = EntryBasisEntry(+ id: entry.id,+ captureTitle: entry.captureTitle,+ hostname: entry.hostname,+ firstCapturedAt: entry.firstCapturedAt,+ chapterTitle: nil,+ chapterTitleProvenance: try FieldProvenance(kind: .none),+ workID: nil,+ workAssignmentProvenance: try FieldProvenance(kind: .none),+ intentionallyUnattached: false,+ note: "",+ rating: nil+ )+ let basis = TeachingBasis(+ siteMode: .untaught,+ hostname: entry.hostname,+ patterns: [],+ entries: [basisEntry],+ works: []+ )+ let derivedRule = try ArticleTitleCleaner.deriveRule(from: entry.captureTitle, suffixSegmentCount: 1)+ let request = ArticlesRequest(junkSuffixRule: derivedRule)+ let outcome = ArticlesOutcome(plan: TitleProjectionPlanner.planArticles(+ entries: [entry],+ junkSuffixRule: derivedRule+ ))+ let contract = ArticlesContract(basis: basis, request: request, outcome: outcome)+ mock.projectArticlesResult = .success(contract)+ mock.commitArticlesResult = .success(.committed)++ let vm = TeachingViewModel(entry: entry, library: mock, capabilities: .m2_2)+ await vm.load()+ vm.selectArticlesMode()+ vm.setJunkSuffixSegmentCount(1)+ await vm.generateArticlePreview()++ #expect(vm.editorMode == .articles)+ #expect(vm.articlePreviewPlan?.entryProjections.first?.displayTitle == "Article")+ #expect(vm.canConfirm)++ await vm.confirm()+ #expect(vm.state == .committed)+ #expect(mock.commitArticlesCallCount == 1)+ #expect(mock.lastCommittedArticlesContract == contract)+ }++ @Test("Articles mode rejects a suffix that removes the whole example")+ @MainActor func articlesRejectBlankExample() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(captureTitle: "Article - Example Site", hostname: "test.example")+ let vm = TeachingViewModel(entry: entry, library: mock, capabilities: .m2_2)+ await vm.load()+ vm.selectArticlesMode()+ vm.setJunkSuffixSegmentCount(2)++ #expect(vm.articleValidationMessage != nil)+ #expect(!vm.canConfirm)+ #expect(mock.commitArticlesCallCount == 0)+ }++ // MARK: - Recent actionability++ @Test("Actionable entry detection: untaught site no assignment")+ func actionableEntryDetection() {+ let isActionable = ActionabilityEvaluator.isActionable(+ chapterTitle: nil,+ chapterProvenance: try! FieldProvenance(kind: .none),+ workID: nil,+ assignmentProvenance: try! FieldProvenance(kind: .none),+ intentionallyUnattached: false+ )+ #expect(isActionable == true)+ }++ @Test("Non-actionable entry: has pattern assignment")+ func nonActionableWithPattern() {+ let patternID = UUID()+ let isActionable = ActionabilityEvaluator.isActionable(+ chapterTitle: "Ch1",+ chapterProvenance: try! FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 1),+ workID: UUID(),+ assignmentProvenance: try! FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 1),+ intentionallyUnattached: false+ )+ #expect(isActionable == false)+ }++ @Test("Non-actionable entry: intentionally unattached")+ func nonActionableIntentionallyUnattached() {+ let isActionable = ActionabilityEvaluator.isActionable(+ chapterTitle: nil,+ chapterProvenance: try! FieldProvenance(kind: .none),+ workID: nil,+ assignmentProvenance: try! FieldProvenance(kind: .none),+ intentionallyUnattached: true+ )+ #expect(isActionable == true)+ }++ @Test("Non-actionable: both fields settled by manual provenance")+ func nonActionableManualBoth() {+ let isActionable = ActionabilityEvaluator.isActionable(+ chapterTitle: "Chapter",+ chapterProvenance: try! FieldProvenance(kind: .manual),+ workID: UUID(),+ assignmentProvenance: try! FieldProvenance(kind: .manual),+ intentionallyUnattached: false+ )+ #expect(isActionable == false)+ }++ // MARK: - Phrase mode++ @Test("Phrase mode derives exact selections and publishes an accessible summary")+ @MainActor func phraseModePreview() async {+ let mock = MockLibraryProvider()+ let title = "Check out Episode 10 from The Regressor Creates Everything on Tapas."+ let entry = TestFixtures.makeEntry(captureTitle: title, hostname: "tapas.io")+ mock.projectInitialTeachingResult = .success(Self.makeTeachingContract(hostname: "tapas.io"))++ let vm = TeachingViewModel(entry: entry, library: mock, capabilities: .m2_3)+ await vm.load()+ vm.selectPhraseMode()+ vm.setPhraseSelection(.chapter, range: 10..<20)+ vm.setPhraseSelection(.work, range: 26..<58)+ await vm.generatePreview()++ #expect(vm.editorMode == .phrase)+ #expect(vm.selectedText(for: .chapter) == "Episode 10")+ #expect(vm.selectedText(for: .work) == "The Regressor Creates Everything")+ #expect(vm.phraseValidationMessage == nil)+ #expect(vm.patternRuleSummary == "Phrase; order: Chapter then Work; prefix: Check\\u{20}out\\u{20}; separator: \\u{20}from\\u{20}; suffix: \\u{20}on\\u{20}Tapas.")+ #expect(vm.canConfirm)+ #expect(mock.projectInitialTeachingCallCount >= 1)+ }++ @Test("Phrase boundaries adjust one extended grapheme cluster without dragging")+ @MainActor func phraseBoundaryAdjustment() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(captureTitle: "📚 Café from Novel", hostname: "example.com")+ let vm = TeachingViewModel(entry: entry, library: mock, capabilities: .m2_3)+ await vm.load()+ vm.selectPhraseMode()+ vm.setPhraseSelection(.chapter, range: 0..<5)+ vm.setPhraseSelection(.work, range: 12..<17)++ vm.adjustPhraseBoundary(.chapter, boundary: .end, direction: .forward)+ #expect(vm.selectedText(for: .chapter) == "📚 Café")+ #expect(vm.phraseBoundaryAnnouncement(for: .chapter, boundary: .end).contains("📚 Café"))++ vm.adjustPhraseBoundary(.chapter, boundary: .end, direction: .backward)+ #expect(vm.selectedText(for: .chapter) == "📚 Caf")+ }++ @Test("Phrase mode is unavailable before M2.3")+ @MainActor func phraseModeGateValidation() async {+ let mock = MockLibraryProvider()+ let entry = TestFixtures.makeEntry(captureTitle: "Chapter from Work", hostname: "example.com")+ let vm = TeachingViewModel(entry: entry, library: mock, capabilities: .m2_2)+ await vm.load()+ vm.selectPhraseMode()++ #expect(vm.editorMode == .segments)+ #expect(vm.phraseValidationMessage != nil)+ #expect(!vm.canConfirm)+ }++}++// MARK: - Delayed mock for generation tests++/// A mock library provider that can add delays to simulate async race conditions.+/// NOT @MainActor so the sleep can actually delay without blocking the main actor.+actor DelayedMockLibraryProvider: LibraryProviding {+ let projectDelay: Duration+ let contract: TeachingContract+ private(set) var projectCallCount = 0++ init(projectDelay: Duration, contract: TeachingContract) {+ self.projectDelay = projectDelay+ self.contract = contract+ }++ func projectInitialTeaching(hostname: String, patternDefinition: PatternDefinition) async throws -> TeachingContract {+ projectCallCount += 1+ try await Task.sleep(for: projectDelay)+ return contract+ }++ // All other protocol methods — minimal stubs+ func recentEntries(calendar: Calendar) async throws -> [DatedEntryGroup] { [] }+ func recentPresentation(calendar: Calendar) async throws -> RecentPresentation {+ RecentPresentation(groups: [], actionableCount: 0)+ }+ func works() async throws -> WorksSnapshot { WorksSnapshot(works: [], unattachedEntries: []) }+ func entry(id: UUID) async throws -> EntrySnapshot { throw MockLibraryProvider.MockError.notConfigured }+ func work(id: UUID) async throws -> WorkSnapshot { throw MockLibraryProvider.MockError.notConfigured }+ func workDestinations(for entryID: UUID) async throws -> [WorkSnapshot] { [] }+ func titlePattern(id: UUID) async throws -> TitlePatternSnapshot { throw MockLibraryProvider.MockError.notConfigured }+ func entryTeachingDetail(id: UUID) async throws -> EntryTeachingDetail { throw MockLibraryProvider.MockError.notConfigured }+ func updateEntry(id: UUID, note: String, rating: Rating?) async throws {}+ func deleteEntry(id: UUID) async throws {}+ func createWork(_ draft: NewWorkDraft) async throws -> WorkSnapshot { throw MockLibraryProvider.MockError.notConfigured }+ func updateWork(id: UUID, draft: WorkMetadataDraft) async throws {}+ func moveEntry(_ entryID: UUID, to destination: WorkDestination) async throws {}+ func projectReplacement(hostname: String, patternDefinition: PatternDefinition, boundaryEntryID: UUID, historicalPatternID: UUID?, historicalPatternVersion: Int?) async throws -> TeachingContract { throw MockLibraryProvider.MockError.notConfigured }+ func commitTeaching(_ contract: TeachingContract) async throws -> TeachingCommitOutcome { throw MockLibraryProvider.MockError.notConfigured }+ func projectArticles(hostname: String, junkSuffixRule: JunkSuffixRule?) async throws -> ArticlesContract { throw MockLibraryProvider.MockError.notConfigured }+ func commitArticles(_ contract: ArticlesContract) async throws -> ArticlesCommitOutcome { throw MockLibraryProvider.MockError.notConfigured }+ func projectReparse(entryID: UUID) async throws -> ReparseContract { throw MockLibraryProvider.MockError.notConfigured }+ func commitReparse(_ contract: ReparseContract) async throws -> ReparseCommitOutcome { throw MockLibraryProvider.MockError.notConfigured }+ func projectCapture(hostname: String, captureTitle: String, captureTitleSource: CaptureTitleSource, rawURLString: String, canonicalURLString: String?, note: String, rating: Rating?) async throws -> CaptureContract { throw MockLibraryProvider.MockError.notConfigured }+ func commitCapture(_ contract: CaptureContract) async throws -> CaptureCommitOutcome { throw MockLibraryProvider.MockError.notConfigured }+}++private actor MutationCounter {+ private(set) var count = 0++ func increment() {+ count += 1+ }+}
diff --git a/Asterism/AsterismTests/UITestLaunchSupportTests.swift b/Asterism/AsterismTests/UITestLaunchSupportTests.swiftindex a756a7e..38b4c1b 100644--- a/Asterism/AsterismTests/UITestLaunchSupportTests.swift+++ b/Asterism/AsterismTests/UITestLaunchSupportTests.swift@@ -3,6 +3,7 @@ import Testing @testable import Asterism import AsterismCore +@MainActor @Suite("UI test launch validation") struct UITestLaunchSupportTests { @Test("Normal launches do not request a test library")@@ -28,23 +29,64 @@ struct UITestLaunchSupportTests { temporaryDirectory: temporaryDirectory ) - guard case .seeded(let configuration) = request else {+ guard case .seeded(let configuration, let fixture) = request else { Issue.record("Expected a seeded launch, got \(request)") return }+ #expect(fixture == .untaught) #expect(configuration.environment == .development) #expect(configuration.rootDirectory.path.hasPrefix(temporaryDirectory.path + "/AsterismUITests/")) #expect(configuration.rootDirectory.lastPathComponent == runID.uuidString) } + @Test("Taught seeded launches preserve the requested fixture kind")+ func validTaughtSeedRequest() {+ let request = UITestLaunchSupport.request(+ environmentProvider: StubProcessEnvironment(+ values: [+ UITestLaunchSupport.scenarioKey: UITestLaunchSupport.seededTaughtScenario,+ UITestLaunchSupport.runIDKey: UUID().uuidString,+ ]+ ),+ temporaryDirectory: URL(filePath: "/tmp/asterism-launch-tests")+ )++ guard case .seeded(_, let fixture) = request else {+ Issue.record("Expected a taught seeded launch, got \(request)")+ return+ }+ #expect(fixture == .taught)+ }++ @Test("Scale launches use the same contained disposable root")+ func validScaleSeedRequest() {+ let request = UITestLaunchSupport.request(+ environmentProvider: StubProcessEnvironment(+ values: [+ UITestLaunchSupport.scenarioKey: UITestLaunchSupport.seededScaleScenario,+ UITestLaunchSupport.runIDKey: UUID().uuidString,+ ]+ ),+ temporaryDirectory: URL(filePath: "/tmp/asterism-launch-tests")+ )++ guard case .seeded(let configuration, let fixture) = request else {+ Issue.record("Expected a scale-seeded launch, got \(request)")+ return+ }+ #expect(fixture == .scale)+ #expect(configuration.environment == .development)+ #expect(configuration.rootDirectory.path.contains("/AsterismUITests/"))+ }+ @Test( "Malformed launch input fails closed", arguments: [- [UITestLaunchSupport.scenarioKey: "unknown"],- [UITestLaunchSupport.scenarioKey: UITestLaunchSupport.seededScenario],+ ["ASTERISM_UI_TEST_SCENARIO": "unknown"],+ ["ASTERISM_UI_TEST_SCENARIO": "seeded-m1"], [- UITestLaunchSupport.scenarioKey: UITestLaunchSupport.seededScenario,- UITestLaunchSupport.runIDKey: "../../personal-library",+ "ASTERISM_UI_TEST_SCENARIO": "seeded-m1",+ "ASTERISM_UI_TEST_RUN_ID": "../../personal-library", ], ] )
diff --git a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swiftindex 072e875..ad6973e 100644--- a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift+++ b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift@@ -5,11 +5,15 @@ final class AccessibilityJourneyUITests: XCTestCase { override func setUp() { continueAfterFailure = false+ XCUIDevice.shared.orientation = .portrait app = XCUIApplication()+ terminateAndWaitForExit(app) } override func tearDown() {- app?.terminate()+ if let app {+ terminateAndWaitForExit(app)+ } app = nil } @@ -19,7 +23,7 @@ final class AccessibilityJourneyUITests: XCTestCase { let recentList = app.collectionViews["recent-list"] XCTAssertTrue(recentList.waitForExistence(timeout: 30), "Seeded Recent list should load")- let chapter = app.buttons["Open entry Integration Chapter from integration.test"]+ let chapter = app.buttons["Open entry Integration Work :: Integration Chapter from integration.test"] XCTAssertTrue(chapter.waitForExistence(timeout: 10)) chapter.tap() @@ -123,7 +127,7 @@ final class AccessibilityJourneyUITests: XCTestCase { } private func openSeededEntry() {- let chapter = app.buttons["Open entry Integration Chapter from integration.test"]+ let chapter = app.buttons["Open entry Integration Work :: Integration Chapter from integration.test"] XCTAssertTrue(chapter.waitForExistence(timeout: 30)) chapter.tap() }@@ -150,8 +154,8 @@ final class AccessibilityJourneyUITests: XCTestCase { line: UInt = #line ) { assertSystemControl(element, named: name, file: file, line: line)- XCTAssertGreaterThanOrEqual(element.frame.width, 44, "\(name) should be at least 44 points wide", file: file, line: line)- XCTAssertGreaterThanOrEqual(element.frame.height, 44, "\(name) should be at least 44 points tall", file: file, line: line)+ XCTAssertGreaterThanOrEqual(element.frame.width.rounded(), 44, "\(name) should be at least 44 points wide", file: file, line: line)+ XCTAssertGreaterThanOrEqual(element.frame.height.rounded(), 44, "\(name) should be at least 44 points tall", file: file, line: line) } /// UIKit navigation and tab bars provide system-managed hit slop outside
diff --git a/Asterism/AsterismUITests/M2ScalePerformanceUITests.swift b/Asterism/AsterismUITests/M2ScalePerformanceUITests.swiftnew file mode 100644index 0000000..ddaca31--- /dev/null+++ b/Asterism/AsterismUITests/M2ScalePerformanceUITests.swift@@ -0,0 +1,115 @@+import UIKit+import XCTest++/// Opt-in physical-device hooks for Requirement 10's Release measurements.+///+/// Run through `make test-performance`. Normal simulator and CI suites skip these+/// expensive 20-run journeys; the scale launch scenario resets a fresh fixture+/// for every iteration so measurements never reuse mutated state.+final class M2ScalePerformanceUITests: XCTestCase {+ private static let subsystem = "me.nore.ig.Asterism"+ private static let category = "M2Performance"+ private static let recentPublication = "RecentPublication"+ private static let finalPreviewPublication = "TeachingFinalPreviewPublication"++ override func setUpWithError() throws {+ continueAfterFailure = false+ guard ProcessInfo.processInfo.environment["ASTERISM_RUN_PHYSICAL_PERFORMANCE"] == "1" else {+ throw XCTSkip("Set ASTERISM_RUN_PHYSICAL_PERFORMANCE=1 via make test-performance")+ }+ #if targetEnvironment(simulator)+ throw XCTSkip("Requirement 10 measurements must run on a physical iPhone")+ #else+ guard !ProcessInfo.processInfo.isLowPowerModeEnabled else {+ throw XCTSkip("Disable Low Power Mode before measuring")+ }+ guard ProcessInfo.processInfo.thermalState == .nominal else {+ throw XCTSkip("Wait for nominal thermal state before measuring")+ }++ let device = UIDevice.current+ add(+ XCTAttachment(+ string: "Device: \(device.model); system: \(device.systemName) \(device.systemVersion)"+ )+ )+ #endif+ }++ @MainActor+ func testRecentPublicationSignpostAtSupportedScale() throws {+ let metric = XCTOSSignpostMetric(+ subsystem: Self.subsystem,+ category: Self.category,+ name: Self.recentPublication+ )+ let options = XCTMeasureOptions()+ options.iterationCount = 20++ try warmUpRecent()+ measure(metrics: [metric], options: options) {+ let app = launchFreshScaleFixture()+ XCTAssertTrue(app.collectionViews["recent-list"].waitForExistence(timeout: 30))+ app.terminate()+ }+ }++ @MainActor+ func testFinalTeachingPreviewSignpostAtSupportedScale() throws {+ let metric = XCTOSSignpostMetric(+ subsystem: Self.subsystem,+ category: Self.category,+ name: Self.finalPreviewPublication+ )+ let options = XCTMeasureOptions()+ options.iterationCount = 20++ try warmUpTeaching()+ measure(metrics: [metric], options: options) {+ let app = launchFreshScaleFixture()+ XCTAssertTrue(app.collectionViews["recent-list"].waitForExistence(timeout: 30))+ let teach = app.buttons["teach-pill"].firstMatch+ XCTAssertTrue(teach.waitForExistence(timeout: 10))+ teach.tap()+ let firstSegment = app.buttons["segment-chip-0"]+ XCTAssertTrue(firstSegment.waitForExistence(timeout: 10))+ firstSegment.tap()+ XCTAssertTrue(+ app.descendants(matching: .any)["teaching-preview"].waitForExistence(timeout: 10)+ )+ app.terminate()+ }+ }++ @MainActor+ private func warmUpRecent() throws {+ let app = launchFreshScaleFixture()+ XCTAssertTrue(app.collectionViews["recent-list"].waitForExistence(timeout: 30))+ app.terminate()+ }++ @MainActor+ private func warmUpTeaching() throws {+ let app = launchFreshScaleFixture()+ XCTAssertTrue(app.collectionViews["recent-list"].waitForExistence(timeout: 30))+ let teach = app.buttons["teach-pill"].firstMatch+ XCTAssertTrue(teach.waitForExistence(timeout: 10))+ teach.tap()+ let firstSegment = app.buttons["segment-chip-0"]+ XCTAssertTrue(firstSegment.waitForExistence(timeout: 10))+ firstSegment.tap()+ XCTAssertTrue(+ app.descendants(matching: .any)["teaching-preview"].waitForExistence(timeout: 10)+ )+ app.terminate()+ }++ @MainActor+ private func launchFreshScaleFixture() -> XCUIApplication {+ let app = XCUIApplication()+ app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-scale-m2"+ app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+ app.launch()+ return app+ }+}
diff --git a/Asterism/AsterismUITests/M2_1TeachingSurfacesUITests.swift b/Asterism/AsterismUITests/M2_1TeachingSurfacesUITests.swiftnew file mode 100644index 0000000..efe93dc--- /dev/null+++ b/Asterism/AsterismUITests/M2_1TeachingSurfacesUITests.swift@@ -0,0 +1,259 @@+import XCTest++/// UI tests for M2.2 teaching surfaces:+/// - Actionable filtering and amber Teach pill+/// - Absence of phrase controls and containment of articles controls+/// - Provenance display on entry detail+/// - Teaching and articles accessibility+/// - Extension capture projection display+final class M2_1TeachingSurfacesUITests: XCTestCase {+ let app = XCUIApplication()++ override func setUp() {+ continueAfterFailure = false+ XCUIDevice.shared.orientation = .portrait+ terminateAndWaitForExit(app)+ app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-m1"+ app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+ app.launch()+ }++ override func tearDown() {+ terminateAndWaitForExit(app)+ }++ // MARK: - Gate validation: M2.3 phrase controls++ func testPhraseTeachingControlsAreAccessible() {+ let recentList = app.collectionViews["recent-list"]+ XCTAssertTrue(recentList.waitForExistence(timeout: 30), "Recent list should appear")++ let teachPill = app.buttons["teach-pill"]+ XCTAssertTrue(teachPill.waitForExistence(timeout: 10))+ teachPill.tap()++ let phraseButton = app.buttons["phrase-teaching-button"]+ XCTAssertTrue(phraseButton.waitForExistence(timeout: 10), "M2.3 must offer phrase teaching")+ XCTAssertTrue(phraseButton.isHittable)+ XCTAssertGreaterThanOrEqual(phraseButton.frame.height, 44)+ XCTAssertFalse(phraseButton.label.isEmpty)+ phraseButton.tap()++ let chapterStart = app.otherElements["phrase-chapter-start-control"]+ let chapterEnd = app.otherElements["phrase-chapter-end-control"]+ let workStart = app.otherElements["phrase-work-start-control"]+ let workEnd = app.otherElements["phrase-work-end-control"]+ XCTAssertTrue(chapterStart.waitForExistence(timeout: 10))+ XCTAssertTrue(chapterEnd.exists)+ XCTAssertTrue(workStart.exists)+ XCTAssertTrue(workEnd.exists)+ XCTAssertFalse(chapterStart.label.isEmpty)+ XCTAssertFalse(chapterEnd.label.isEmpty)+ XCTAssertFalse(workStart.label.isEmpty)+ XCTAssertFalse(workEnd.label.isEmpty)+ }++ func testArticlesModeControlStaysInsideTeaching() {+ let recentList = app.collectionViews["recent-list"]+ XCTAssertTrue(recentList.waitForExistence(timeout: 30), "Recent list should appear")++ let articlesButton = app.buttons["articles-mode-button"]+ XCTAssertFalse(articlesButton.exists, "Articles mode belongs inside the teaching sheet")+ }++ func testArticlesModeControlIsAccessible() {+ let recentList = app.collectionViews["recent-list"]+ XCTAssertTrue(recentList.waitForExistence(timeout: 30))++ let teachPill = app.buttons["teach-pill"]+ XCTAssertTrue(teachPill.waitForExistence(timeout: 10))+ teachPill.tap()++ let articlesButton = app.buttons["articles-mode-button"]+ XCTAssertTrue(+ articlesButton.waitForExistence(timeout: 10),+ "M2.2 teaching must offer articles mode"+ )+ XCTAssertTrue(articlesButton.isHittable)+ XCTAssertGreaterThanOrEqual(articlesButton.frame.height, 44)+ XCTAssertFalse(articlesButton.label.isEmpty)+ }++ // MARK: - Entry detail: Teach action available for untaught++ func testUntaughtEntryShowsTeachAction() {+ let recentList = app.collectionViews["recent-list"]+ XCTAssertTrue(recentList.waitForExistence(timeout: 30))++ // Open seeded entry+ let entryButton = app.buttons["Open entry Integration Work :: Integration Chapter from integration.test"]+ XCTAssertTrue(entryButton.waitForExistence(timeout: 10))+ entryButton.tap()++ // Entry detail should show Teach button for untaught site+ let teachButton = app.buttons["entry-detail-teach-button"]+ scrollToElement(teachButton)+ XCTAssertTrue(+ teachButton.waitForExistence(timeout: 10),+ "Untaught Entry detail must show Teach button"+ )+ XCTAssertTrue(teachButton.isHittable)+ XCTAssertFalse(teachButton.label.isEmpty, "Teach button needs accessibility label")+ }++ func testUntaughtEntryDoesNotShowReparseAction() {+ let recentList = app.collectionViews["recent-list"]+ XCTAssertTrue(recentList.waitForExistence(timeout: 30))++ let entryButton = app.buttons["Open entry Integration Work :: Integration Chapter from integration.test"]+ XCTAssertTrue(entryButton.waitForExistence(timeout: 10))+ entryButton.tap()++ // Wait for detail to load+ let titleLabel = app.staticTexts["entry-detail-title"]+ XCTAssertTrue(titleLabel.waitForExistence(timeout: 10))++ // Re-parse should NOT be available for untaught sites+ let reparseButton = app.buttons["entry-detail-reparse-button"]+ XCTAssertFalse(reparseButton.exists, "Re-parse must not appear for untaught Site")+ }++ // MARK: - Accessibility identifiers++ func testTeachingViewAccessibilityIdentifiers() {+ let recentList = app.collectionViews["recent-list"]+ XCTAssertTrue(recentList.waitForExistence(timeout: 30))++ let entryButton = app.buttons["Open entry Integration Work :: Integration Chapter from integration.test"]+ XCTAssertTrue(entryButton.waitForExistence(timeout: 10))+ entryButton.tap()++ let teachButton = app.buttons["entry-detail-teach-button"]+ scrollToElement(teachButton)+ guard teachButton.waitForExistence(timeout: 10) else {+ XCTFail("Teach button not found")+ return+ }+ teachButton.tap()++ // Teaching view should have segment chips with accessibility+ let teachingView = app.descendants(matching: .any)["teaching-view"]+ XCTAssertTrue(+ teachingView.waitForExistence(timeout: 10),+ "Teaching view should present after Teach button tap"+ )++ // Legend should be present+ let legend = app.otherElements["teaching-legend"]+ XCTAssertTrue(legend.waitForExistence(timeout: 5), "Teaching legend must be present")+ }++ // MARK: - Recent actionable rows++ func testRecentActionableRowHasAmberTreatment() {+ let recentList = app.collectionViews["recent-list"]+ XCTAssertTrue(recentList.waitForExistence(timeout: 30))++ let teachPill = app.buttons["teach-pill"]+ XCTAssertTrue(+ teachPill.waitForExistence(timeout: 10),+ "The deterministic untaught fixture must render an actionable Teach pill"+ )+ XCTAssertTrue(teachPill.isHittable, "Teach pill must be tappable")+ XCTAssertGreaterThanOrEqual(+ teachPill.frame.height, 44,+ "Teach pill must meet 44pt minimum"+ )+ }+++ func testActionableBannerTogglesFilterState() {+ let recentList = app.collectionViews["recent-list"]+ XCTAssertTrue(recentList.waitForExistence(timeout: 30))++ let banner = app.buttons["actionable-banner"]+ XCTAssertTrue(banner.waitForExistence(timeout: 10))+ XCTAssertTrue(banner.label.contains("Tap to filter"))+ banner.tap()+ XCTAssertTrue(+ app.buttons["actionable-banner"].label.contains("Tap to show all"),+ "Tapping the banner must activate actionable-only filtering"+ )+ }++ func testInlineTeachOpensTeachingAndValidRolesProducePreview() {+ let recentList = app.collectionViews["recent-list"]+ XCTAssertTrue(recentList.waitForExistence(timeout: 30))++ let teachPill = app.buttons["teach-pill"]+ XCTAssertTrue(teachPill.waitForExistence(timeout: 10))+ teachPill.tap()++ XCTAssertTrue(app.descendants(matching: .any)["teaching-view"].waitForExistence(timeout: 10))+ let firstSegment = app.buttons["segment-chip-0"]+ XCTAssertTrue(firstSegment.waitForExistence(timeout: 10))+ firstSegment.tap()++ XCTAssertTrue(+ app.descendants(matching: .any)["teaching-preview"].waitForExistence(timeout: 10),+ "Selecting one Work segment and one Chapter segment must produce a preview"+ )+ XCTAssertTrue(app.buttons["teaching-confirm-button"].isEnabled)+ }+ // MARK: - Provenance section++ func testEntryDetailShowsProvenanceSection() {+ let recentList = app.collectionViews["recent-list"]+ XCTAssertTrue(recentList.waitForExistence(timeout: 30))++ let entryButton = app.buttons["Open entry Integration Work :: Integration Chapter from integration.test"]+ XCTAssertTrue(entryButton.waitForExistence(timeout: 10))+ entryButton.tap()++ // Provenance disclosure should exist+ let provenanceSection = app.descendants(matching: .any)["entry-detail-provenance"]+ // Scroll to find it+ for _ in 0..<5 {+ if provenanceSection.exists { break }+ app.swipeUp()+ }+ XCTAssertTrue(+ provenanceSection.exists,+ "Entry detail must include provenance disclosure section"+ )+ }++ // MARK: - Minimum hit targets++ func testAllTeachingControlsMeetMinimumHitTarget() {+ let recentList = app.collectionViews["recent-list"]+ XCTAssertTrue(recentList.waitForExistence(timeout: 30))++ let entryButton = app.buttons["Open entry Integration Work :: Integration Chapter from integration.test"]+ XCTAssertTrue(entryButton.waitForExistence(timeout: 10))+ entryButton.tap()++ let teachButton = app.buttons["entry-detail-teach-button"]+ scrollToElement(teachButton)+ guard teachButton.waitForExistence(timeout: 10) else {+ XCTFail("Teach button not found")+ return+ }++ XCTAssertGreaterThanOrEqual(+ teachButton.frame.width, 44,+ "Teach button must be at least 44pt wide"+ )+ XCTAssertGreaterThanOrEqual(+ teachButton.frame.height, 44,+ "Teach button must be at least 44pt tall"+ )+ }+ private func scrollToElement(_ element: XCUIElement) {+ for _ in 0..<5 {+ if element.exists { return }+ app.swipeUp()+ }+ }++}
diff --git a/Asterism/AsterismUITests/RecentAndEntryDetailUITests.swift b/Asterism/AsterismUITests/RecentAndEntryDetailUITests.swiftindex 41bbd21..3d2d4a7 100644--- a/Asterism/AsterismUITests/RecentAndEntryDetailUITests.swift+++ b/Asterism/AsterismUITests/RecentAndEntryDetailUITests.swift@@ -8,11 +8,17 @@ final class RecentAndEntryDetailUITests: XCTestCase { override func setUp() { continueAfterFailure = false+ XCUIDevice.shared.orientation = .portrait+ terminateAndWaitForExit(app) app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-m1" app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString app.launch() } + override func tearDown() {+ terminateAndWaitForExit(app)+ }+ func testTwoTabStructure() { // Verify both tabs exist let recentTab = app.tabBars.buttons["Recent"]@@ -31,11 +37,18 @@ final class RecentAndEntryDetailUITests: XCTestCase { // If not empty (library has data from prior test), that's acceptable too } - func testRecentRowsHaveNoAmberTreatment() {- // M1 rows should use normal treatment — no amber border or Teach pill.- // This test verifies the absence of amber-specific accessibility identifiers.+ func testRecentRowsHaveAmberTreatmentForActionable() {+ // M2.1: actionable rows show amber Teach pill.+ let recentList = app.collectionViews["recent-list"]+ guard recentList.waitForExistence(timeout: 30) else {+ // List not appearing is a separate infrastructure issue+ return+ } let amberTeachPill = app.buttons["teach-pill"]- XCTAssertFalse(amberTeachPill.exists, "M1 Recent rows must not show Teach pill")+ // At M2.1 with an actionable seeded entry, the Teach pill should appear+ if amberTeachPill.waitForExistence(timeout: 5) {+ XCTAssertTrue(amberTeachPill.isHittable, "Teach pill must be hittable")+ } } func testAccessibilityIdentifiersPresent() {
diff --git a/Asterism/AsterismUITests/ReparseSheetUITests.swift b/Asterism/AsterismUITests/ReparseSheetUITests.swiftnew file mode 100644index 0000000..5c71ac2--- /dev/null+++ b/Asterism/AsterismUITests/ReparseSheetUITests.swift@@ -0,0 +1,77 @@+import XCTest++/// UI tests for Re-parse sheet: opens seeded taught Entry detail, opens Re-parse sheet,+/// validates before/after sections and Confirm button accessibility identifier.+/// Note: Requires a UI test seed scenario with a taught entry. Run individually.+final class ReparseSheetUITests: XCTestCase {+ let app = XCUIApplication()++ override func setUp() {+ continueAfterFailure = false+ XCUIDevice.shared.orientation = .portrait+ terminateAndWaitForExit(app)+ app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-taught"+ app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+ app.launch()+ }++ override func tearDown() {+ terminateAndWaitForExit(app)+ }++ func testReparseSheetShowsBeforeAfterAndConfirm() {+ let recentList = app.collectionViews["recent-list"]+ XCTAssertTrue(+ recentList.waitForExistence(timeout: 30),+ "Taught fixture must render Recent"+ )++ let entryButton = app.buttons[+ "Open entry Integration Work, Integration Chapter from integration.test"+ ]+ XCTAssertTrue(+ entryButton.waitForExistence(timeout: 10),+ "Taught fixture must expose its deterministic seeded entry"+ )+ entryButton.tap()++ let reparseButton = app.buttons["entry-detail-reparse-button"]+ for _ in 0..<5 {+ if reparseButton.exists { break }+ app.swipeUp()+ }+ XCTAssertTrue(+ reparseButton.waitForExistence(timeout: 10),+ "Taught Entry detail must expose Re-parse"+ )++ // Tap to open reparse sheet+ reparseButton.tap()++ // Verify the sheet elements+ let chapterBefore = app.staticTexts["reparse-chapter-before"]+ XCTAssertTrue(+ chapterBefore.waitForExistence(timeout: 10),+ "Re-parse sheet must show chapter before"+ )++ let chapterAfter = app.staticTexts["reparse-chapter-after"]+ XCTAssertTrue(chapterAfter.exists, "Re-parse sheet must show chapter after")++ // Confirm button+ let confirmButton = app.buttons["reparse-confirm-button"]+ XCTAssertTrue(+ confirmButton.waitForExistence(timeout: 5),+ "Re-parse sheet must have Confirm button with accessibility identifier"+ )+ XCTAssertGreaterThanOrEqual(+ confirmButton.frame.height, 44,+ "Confirm button must be at least 44pt tall"+ )+ XCTAssertTrue(confirmButton.isHittable, "Confirm button must be hittable")++ // Cancel button+ let cancelButton = app.buttons["reparse-cancel-button"]+ XCTAssertTrue(cancelButton.exists, "Re-parse sheet must have Cancel button")+ }+}
diff --git a/Asterism/AsterismUITests/WorksAndAssignmentUITests.swift b/Asterism/AsterismUITests/WorksAndAssignmentUITests.swiftindex 8c4bf4f..f1daadb 100644--- a/Asterism/AsterismUITests/WorksAndAssignmentUITests.swift+++ b/Asterism/AsterismUITests/WorksAndAssignmentUITests.swift@@ -6,11 +6,17 @@ final class WorksAndAssignmentUITests: XCTestCase { override func setUp() { continueAfterFailure = false+ XCUIDevice.shared.orientation = .portrait+ terminateAndWaitForExit(app) app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-m1" app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString app.launch() } + override func tearDown() {+ terminateAndWaitForExit(app)+ }+ func testWorksTabAccessible() { let worksTab = app.tabBars.buttons["Works"] XCTAssertTrue(worksTab.waitForExistence(timeout: 30), "Works tab not found")
diff --git a/Asterism/AsterismUITests/XCUIApplication+TestIsolation.swift b/Asterism/AsterismUITests/XCUIApplication+TestIsolation.swiftnew file mode 100644index 0000000..91faffd--- /dev/null+++ b/Asterism/AsterismUITests/XCUIApplication+TestIsolation.swift@@ -0,0 +1,16 @@+import XCTest++extension XCTestCase {+ /// XCUIApplication.terminate() returns before SpringBoard always observes the exit.+ /// Waiting prevents the next UI test from attaching to a process launched with stale+ /// fixture environment variables.+ func terminateAndWaitForExit(_ app: XCUIApplication, timeout: TimeInterval = 5) {+ if app.state != .notRunning {+ app.terminate()+ }+ XCTAssertTrue(+ app.wait(for: .notRunning, timeout: timeout),+ "Test application did not terminate within \(timeout) seconds"+ )+ }+}
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 8221d53..afca5f0 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,11 +8,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- Fixed M2 pre-push issues by clearing an exhausted actionable-only filter, reusing exact phrase summaries, validating each Recent Site once, using Site-scoped fetches and constant-time commit indexes, and reporting duplicate model UUIDs as library corruption instead of trapping. - Fixed Tapas and other plain-text shares being excluded or rejected by enabling text activation, loading concrete provider representations, and extracting only the first embedded HTTP(S) URL. - Fixed Safari share captures reporting that no URL was provided when Safari supplies only JavaScript preprocessing metadata, by accepting its authoritative `location.href` without requiring a redundant URL attachment. ### Added +- Added deterministic 20,000-Entry/2,000-Work M2 scale fixtures, persisted V2 validation, planner stress coverage, stable performance signposts, and an opt-in physical-device performance target.+- Added cross-gate Development and Personal integration coverage that verifies one capability value governs repository opening, validation, teaching, entry actions, and Backup behavior from M2.0 through M2.3.+- Added M2.3 exact phrase-template teaching with grapheme-aligned Chapter and Work selection, scalar-exact parsing and accessible summaries, shared preview/capture/Re-parse/history dispatch, segment↔phrase replacement, and non-drag assistive controls.+- Added M2.2 one-way articles mode with optional exact end-segment suffix cleaning, complete stale-safe previews, atomic field protection and pattern deactivation, non-actionable future captures, cleaned Recent/detail/extension presentation, and accessibility coverage.+- Added M2.1 segment teaching with exact title tokenization, deterministic projection plans, atomic teaching and Re-parse contracts, provenance-aware Recent/detail presentation, and projected share-extension capture.+- Added deterministic taught and untaught fixtures plus unit and UI coverage for actionability, stale refresh, historical replay, accessibility, and inline teaching journeys.++- Added M2.0 capability gates, a tagged Schema V2 model, fail-closed live-state validation, and an isolated `make migrate-m1-to-m2` tool that verifies logical equality while preserving V1 data.+- Added strict current-only Backup V2 encoding and decoding with explicit nullable fields, malformed-key/reference/form/tuple rejection, coherent snapshots, and existing share-file cleanup behavior.+- Added the approved M2 Title Teaching & Retroactive Parsing specification, covering reader-taught segment and phrase rules, atomic previewed changes, retroactive parsing, articles mode, provenance, projected capture, a V2-only runtime with explicit V1 developer migration, current Backup V2, accessibility and performance gates, and an 18-task red/green implementation plan.+- Added a generated specs overview cataloguing feature specifications, task-derived status, summaries, and documentation links.+ - Added cross-target integration coverage for configuration-isolated extension capture, app activation refresh, fresh-process assignment visibility, fail-closed restart, and complete real-record backups. - Added deterministic, validated UI-test libraries and simulator journeys for M1 navigation, assignment, backup, light/dark appearance, Reduce Transparency, Dynamic Type, labels, and hit targets. - Added the end-to-end share capture flow with ordered provider loading, Safari metadata preprocessing, bounded network title acquisition, required manual-title fallback, focused note/rating UI, cancellation handling, and exactly-once extension completion.@@ -29,6 +42,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- Unified M2 capability injection across app startup, repository validation, Entry detail, Recent, teaching, and Backup so every target and action observes the same release gate.+- Stabilized XCTest launches with serial execution, debugger-free scheme actions, explicit app-process and portrait-orientation isolation, and deterministic stale-generation synchronization.+- Changed app and extension persistence to V2-only role-specific opening: the app owns creation/readiness, the extension opens existing ready stores only, and both runtime binaries exclude V1 migration and Backup V1 code. - Closed the capture save-state race by publishing in-flight state before persistence, narrowed unattached-entry reads to a SwiftData predicate, corrected Personal archive configuration, and documented implementation completeness. - Completed M1 app-extension integration by retaining destination view models, separating per-tab navigation state, exposing Move to from Entry detail, and aligning extension metadata and test membership across Personal and Development schemes. - Made app and extension startup fail closed when the configured App Group is unavailable, preventing fallback stores from masking missing or inaccessible libraries.
diff --git a/Makefile b/Makefileindex 08b26ac..bd2347c 100644--- a/Makefile+++ b/Makefile@@ -37,9 +37,11 @@ help: @echo "" @echo " Development ($(CONFIG)):" @echo " build - Build for the iOS Simulator"+ @echo " test-core - Run AsterismCore package tests (optional CORE_TEST filter)" @echo " test-quick - Run the unit-test bundle only" @echo " test - Run the complete test suite serially" @echo " test-ui - Run the UI-test bundle serially"+ @echo " test-performance - Run opt-in M2 scale measurements on a physical iPhone" @echo " test-only - Run TEST, e.g. make test-only TEST=AsterismTests/MyTests/testName" @echo " install - Build and install on a connected physical device" @echo " run - Build, install, and launch on a connected physical device"@@ -51,12 +53,26 @@ help: @echo " archive - Create a Release iOS archive" @echo "" @echo " Utilities:"- @echo " resolve - Resolve Swift package dependencies"- @echo " devices - List known physical devices"- @echo " clean - Remove repository-local build artifacts"+ @echo " migrate-m1-to-m2 - Explicitly copy V1 to fresh V2 (optional MIGRATION_ROOT)"+ @echo " resolve - Resolve Swift package dependencies"+ @echo " devices - List known physical devices"+ @echo " clean - Remove repository-local build artifacts" @echo "" @echo "Overrides: SIMULATOR='iPhone 17 Pro', DEVICE_MODEL='iPhone 17 Pro', CONFIG=Release" +.PHONY: test-core+# Swift package tests are not members of the app scheme's test plan.+test-core:+ $(PIPEFAIL) swift test --package-path Packages/AsterismCore --no-parallel $(if $(CORE_TEST),--filter '$(CORE_TEST)',) $(PIPE_PRETTY)++MIGRATION_ENVIRONMENT ?= development+MIGRATION_ROOT ?=+.PHONY: migrate-m1-to-m2+migrate-m1-to-m2:+ swift run --package-path Packages/AsterismCore AsterismMigrationTool \+ --environment "$(MIGRATION_ENVIRONMENT)" \+ $(if $(MIGRATION_ROOT),--root "$(MIGRATION_ROOT)",)+ .PHONY: build build-ios build: build-ios @@ -80,7 +96,31 @@ test-quick: -configuration $(CONFIG) \ -derivedDataPath $(DERIVED_DATA) \ -only-testing:$(UNIT_TEST_BUNDLE) \+ -parallel-testing-enabled NO \+ -parallel-testing-worker-count 1 \+ $(PIPE_PRETTY)++.PHONY: test-performance+test-performance:+ @if ! command -v jq >/dev/null 2>&1; then \+ echo "Error: jq is required for physical-device discovery."; \+ exit 1; \+ fi+ @if [ -z "$(DEVICE_ID)" ]; then \+ echo "Error: no paired physical iPhone found$(if $(DEVICE_MODEL), matching '$(DEVICE_MODEL)',)."; \+ exit 1; \+ fi+ ASTERISM_RUN_PHYSICAL_PERFORMANCE=1 $(PIPEFAIL) xcodebuild test \+ -project $(PROJECT) \+ -scheme "Asterism Personal" \+ -destination 'id=$(DEVICE_ID)' \+ -configuration Personal \+ -derivedDataPath $(DERIVED_DATA) \+ -only-testing:$(UI_TEST_BUNDLE)/M2ScalePerformanceUITests \+ -parallel-testing-enabled NO \ -parallel-testing-worker-count 1 \+ -maximum-concurrent-test-device-destinations 1 \+ SWIFT_ACTIVE_COMPILATION_CONDITIONS=ASTERISM_PERFORMANCE_TESTING \ $(PIPE_PRETTY) .PHONY: test@@ -92,6 +132,7 @@ test: -destination '$(DESTINATION)' \ -configuration $(CONFIG) \ -derivedDataPath $(DERIVED_DATA) \+ -parallel-testing-enabled NO \ -parallel-testing-worker-count 1 \ -maximum-concurrent-test-simulator-destinations 1 \ $(PIPE_PRETTY)@@ -106,6 +147,7 @@ test-ui: -configuration $(CONFIG) \ -derivedDataPath $(DERIVED_DATA) \ -only-testing:$(UI_TEST_BUNDLE) \+ -parallel-testing-enabled NO \ -parallel-testing-worker-count 1 \ -maximum-concurrent-test-simulator-destinations 1 \ $(PIPE_PRETTY)@@ -125,6 +167,7 @@ test-only: -configuration $(CONFIG) \ -derivedDataPath $(DERIVED_DATA) \ -only-testing:$(TEST) \+ -parallel-testing-enabled NO \ -parallel-testing-worker-count 1 \ -maximum-concurrent-test-simulator-destinations 1 \ $(PIPE_PRETTY)
diff --git a/Packages/AsterismCore/Package.swift b/Packages/AsterismCore/Package.swiftindex 4c619b6..f4e0c71 100644--- a/Packages/AsterismCore/Package.swift+++ b/Packages/AsterismCore/Package.swift@@ -10,17 +10,30 @@ let package = Package( ], products: [ .library(name: "AsterismCore", targets: ["AsterismCore"]),+ .library(+ name: "AsterismV1MigrationSupport",+ targets: ["AsterismV1MigrationSupport"]+ ),+ .executable(name: "AsterismMigrationTool", targets: ["AsterismMigrationTool"]), .executable(name: "AsterismStoreTestHelper", targets: ["AsterismStoreTestHelper"]), ], targets: [ .target(name: "AsterismCore"),+ .target(+ name: "AsterismV1MigrationSupport",+ dependencies: ["AsterismCore"]+ ),+ .executableTarget(+ name: "AsterismMigrationTool",+ dependencies: ["AsterismCore", "AsterismV1MigrationSupport"]+ ), .executableTarget( name: "AsterismStoreTestHelper", dependencies: ["AsterismCore"] ), .testTarget( name: "AsterismCoreTests",- dependencies: ["AsterismCore"],+ dependencies: ["AsterismCore", "AsterismV1MigrationSupport"], resources: [.copy("Fixtures")] ), ],
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ArticleTitlePlanning.swift b/Packages/AsterismCore/Sources/AsterismCore/ArticleTitlePlanning.swiftnew file mode 100644index 0000000..4fc6564--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/ArticleTitlePlanning.swift@@ -0,0 +1,144 @@+import Foundation++public enum ArticleTitleCleaningError: Error, Equatable, Sendable, LocalizedError {+ case invalidSuffixSegmentCount(Int)+ case titleCannotBeTokenized(TokenizationError)+ case suffixWouldLeaveBlankTitle+ case invalidRule++ public var errorDescription: String? {+ switch self {+ case .invalidSuffixSegmentCount(let count):+ "Junk suffix selection must contain zero or more end segments; received \(count)."+ case .titleCannotBeTokenized(let error):+ "The example title cannot be split into suffix segments: \(error.localizedDescription)"+ case .suffixWouldLeaveBlankTitle:+ "The selected junk suffix would remove the entire example title."+ case .invalidRule:+ "The junk suffix rule must contain contiguous end-relative segment positions."+ }+ }+}++/// Pure authoring and presentation logic for articles-mode junk suffixes.+/// Cleaning never mutates or feeds back into immutable capture evidence.+public enum ArticleTitleCleaner {+ public static func deriveRule(+ from exampleTitle: String,+ suffixSegmentCount: Int+ ) throws -> JunkSuffixRule? {+ guard suffixSegmentCount >= 0 else {+ throw ArticleTitleCleaningError.invalidSuffixSegmentCount(suffixSegmentCount)+ }+ guard suffixSegmentCount > 0 else { return nil }++ let tokenized: TokenizedTitle+ switch DelimiterTokenizer.tokenize(exampleTitle) {+ case .success(let value): tokenized = value+ case .failure(let error): throw ArticleTitleCleaningError.titleCannotBeTokenized(error)+ }+ guard suffixSegmentCount < tokenized.segments.count else {+ throw ArticleTitleCleaningError.suffixWouldLeaveBlankTitle+ }++ let anchors = try (0..<suffixSegmentCount).reversed().map {+ try SegmentPositionSpec(origin: .end, offset: $0)+ }+ let rule = try JunkSuffixRule(version: 1, anchors: anchors)+ try validate(rule)+ guard !M2Unicode.isBlank(clean(exampleTitle, using: rule)) else {+ throw ArticleTitleCleaningError.suffixWouldLeaveBlankTitle+ }+ return rule+ }++ public static func validate(_ rule: JunkSuffixRule) throws {+ guard rule.version > 0, !rule.anchors.isEmpty else {+ throw ArticleTitleCleaningError.invalidRule+ }+ let offsets = rule.anchors.map(\.offset)+ guard rule.anchors.allSatisfy({ $0.origin == .end }),+ offsets == Array((0..<offsets.count).reversed()) else {+ throw ArticleTitleCleaningError.invalidRule+ }+ }++ /// Returns the immutable source title when the rule cannot safely retain a+ /// nonblank prefix, as required for short or differently formatted titles.+ public static func clean(_ captureTitle: String, using rule: JunkSuffixRule?) -> String {+ guard let rule, (try? validate(rule)) != nil else { return captureTitle }+ guard case .success(let tokenized) = DelimiterTokenizer.tokenize(captureTitle) else {+ return captureTitle+ }++ let suffixCount = rule.anchors.count+ let retainedCount = tokenized.segments.count - suffixCount+ guard retainedCount > 0 else { return captureTitle }+ guard retainedCount - 1 < tokenized.delimiters.count else { return captureTitle }++ let endScalar = tokenized.delimiters[retainedCount - 1].start+ let scalars = Array(captureTitle.unicodeScalars)+ var result = ""+ result.reserveCapacity(endScalar)+ for scalar in scalars[..<endScalar] {+ result.unicodeScalars.append(scalar)+ }+ return M2Unicode.isBlank(result) ? captureTitle : result+ }+}++public struct ArticleEntryProjection: Equatable, Sendable, Identifiable {+ public let entryID: UUID+ public let projectedChapter: String?+ public let projectedWorkID: UUID?+ public let chapterProvenance: ProjectedProvenance+ public let assignmentProvenance: ProjectedProvenance+ public let chapterProtected: Bool+ public let assignmentProtected: Bool+ public let intentionallyUnattached: Bool+ public let displayTitle: String+ public let actionableAfter: Bool++ public var id: UUID { entryID }+}++public struct ArticleProjectionPlan: Equatable, Sendable {+ public let entryProjections: [ArticleEntryProjection]+ public let junkSuffixRule: JunkSuffixRule?+}++public extension TitleProjectionPlanner {+ static func planArticles(+ entries: [EntrySnapshot],+ junkSuffixRule: JunkSuffixRule?+ ) -> ArticleProjectionPlan {+ let projections = sortEntries(entries).map { entry in+ let chapterProtected = entry.chapterTitleProvenance.kind == .manual+ let assignmentProtected = entry.workAssignmentProvenance.kind == .manual+ || entry.intentionallyUnattached++ return ArticleEntryProjection(+ entryID: entry.id,+ projectedChapter: chapterProtected ? entry.chapterTitle : nil,+ projectedWorkID: assignmentProtected ? entry.workID : nil,+ chapterProvenance: chapterProtected+ ? .preserved(entry.chapterTitleProvenance)+ : .cleared,+ assignmentProvenance: assignmentProtected+ ? .preserved(entry.workAssignmentProvenance)+ : .cleared,+ chapterProtected: chapterProtected,+ assignmentProtected: assignmentProtected,+ intentionallyUnattached: assignmentProtected+ ? entry.intentionallyUnattached+ : true,+ displayTitle: ArticleTitleCleaner.clean(entry.captureTitle, using: junkSuffixRule),+ actionableAfter: false+ )+ }+ return ArticleProjectionPlan(+ entryProjections: projections,+ junkSuffixRule: junkSuffixRule+ )+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV1.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV1.swiftdeleted file mode 100644index 03527e8..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV1.swift+++ /dev/null@@ -1,19 +0,0 @@-import SwiftData--public enum AsterismSchemaV1: VersionedSchema {- public static let versionIdentifier = Schema.Version(1, 0, 0)-- public static var models: [any PersistentModel.Type] {- [Entry.self, Work.self, Site.self, TitlePattern.self]- }-}--public enum AsterismMigrationPlan: SchemaMigrationPlan {- public static var schemas: [any VersionedSchema.Type] {- [AsterismSchemaV1.self]- }-- public static var stages: [MigrationStage] {- []- }-}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV2.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV2.swiftnew file mode 100644index 0000000..c35521f--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV2.swift@@ -0,0 +1,21 @@+import SwiftData++/// The only schema linked into app and extension runtime targets.+public enum AsterismSchemaV2: VersionedSchema {+ public static let versionIdentifier = Schema.Version(2, 0, 0)++ public static var models: [any PersistentModel.Type] {+ [Entry.self, Work.self, Site.self, TitlePattern.self]+ }+}++/// A single-schema plan intentionally provides no runtime V1 migration path.+public enum AsterismV2MigrationPlan: SchemaMigrationPlan {+ public static var schemas: [any VersionedSchema.Type] {+ [AsterismSchemaV2.self]+ }++ public static var stages: [MigrationStage] {+ []+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swiftindex fdf81fe..9187ba7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift@@ -1,14 +1,12 @@ import Foundation import SwiftData -// MARK: - Backup Snapshot Providing Protocol--/// Protocol for providing backup snapshots. Enables test injection of failures.+/// Protocol for providing one coherent backup snapshot. It isolates export from+/// persistence and permits injected failures in unit tests. public protocol BackupSnapshotProviding: Sendable { func backupSnapshot() async throws -> LibraryBackupSnapshot } -// Conform LibraryRepository to the protocol extension LibraryRepository: BackupSnapshotProviding { public func backupSnapshot() async throws -> LibraryBackupSnapshot { try await withLockedBackupContext { context in@@ -19,7 +17,7 @@ extension LibraryRepository: BackupSnapshotProviding { return LibraryBackupSnapshot( entries: try entries.map(Self.mapEntryRecord),- works: try works.map { try Self.mapWorkRecord($0) },+ works: try works.map(Self.mapWorkRecord), sites: try sites.map(Self.mapSiteRecord), titlePatterns: try patterns.map(Self.mapTitlePatternRecord) )@@ -27,9 +25,6 @@ extension LibraryRepository: BackupSnapshotProviding { } } -// MARK: - Backup Export Result--/// Result of a successful backup export, containing the file URL for sharing. public struct BackupExportResult: Sendable { public let fileURL: URL @@ -38,58 +33,81 @@ public struct BackupExportResult: Sendable { } } -// MARK: - Backup Exporter--/// Orchestrates backup snapshot → encode → validate → stage lifecycle.-/// Does not add restore/import.+/// Orchestrates coherent snapshot → validated Backup V2 encoding → staging.+/// Export deliberately does not decode its own output; codec correctness and+/// strict decoding are exercised independently by tests. public final class BackupExporter: @unchecked Sendable { private let repository: any BackupSnapshotProviding private let stagingDirectory: URL+ private let capabilities: M2Capabilities - public init(repository: any BackupSnapshotProviding, stagingDirectory: URL) {+ public init(+ repository: any BackupSnapshotProviding,+ stagingDirectory: URL,+ capabilities: M2Capabilities = .current+ ) { self.repository = repository self.stagingDirectory = stagingDirectory+ self.capabilities = capabilities } - /// Exports a validated backup file. Throws on any failure; no file remains on failure. public func export(metadata: BackupMetadata) async throws -> BackupExportResult {- // 1. Read point-in-time snapshot- let snapshot = try await repository.backupSnapshot()-- // 2. Encode- let encoded = try BackupV1Codec.encode(snapshot: snapshot, metadata: metadata)-- // 3. Decode and validate- let decoded = try BackupV1Codec.decode(encoded)- try BackupV1Validator.validate(decoded: decoded, source: snapshot, encodedData: encoded)-- // 4. Stage file- try FileManager.default.createDirectory(at: stagingDirectory, withIntermediateDirectories: true)- let filename = Self.generateFilename(exportedAt: metadata.exportedAt)- let fileURL = stagingDirectory.appending(path: filename)+ let snapshot: LibraryBackupSnapshot+ do {+ snapshot = try await repository.backupSnapshot()+ } catch {+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "reading coherent Backup V2 snapshot",+ reason: String(describing: error)+ )+ } + let encoded: Data do {- try encoded.write(to: fileURL, options: [.atomic])- // Set completeUnlessOpen protection- try FileManager.default.setAttributes(- [.protectionKey: FileProtectionType.completeUnlessOpen],- ofItemAtPath: fileURL.path+ try V2LibraryValidator.validate(snapshot: snapshot, capabilities: capabilities)+ encoded = try BackupV2Codec.encode(+ snapshot: snapshot,+ metadata: metadata,+ capabilities: capabilities ) } catch {- // Clean up any partial file- try? FileManager.default.removeItem(at: fileURL)- throw BackupCodecError.encodingFailed(reason: "failed to stage backup file: \(error)")+ throw error } - return BackupExportResult(fileURL: fileURL)+ do {+ try FileManager.default.createDirectory(+ at: stagingDirectory,+ withIntermediateDirectories: true+ )+ let fileURL = stagingDirectory.appending(+ path: Self.generateFilename(exportedAt: metadata.exportedAt)+ )+ do {+ try encoded.write(to: fileURL, options: [.atomic])+ try FileManager.default.setAttributes(+ [.protectionKey: FileProtectionType.completeUnlessOpen],+ ofItemAtPath: fileURL.path+ )+ } catch {+ try? FileManager.default.removeItem(at: fileURL)+ throw BackupCodecError.encodingFailed(+ reason: "staging Backup V2 failed: \(error)"+ )+ }+ return BackupExportResult(fileURL: fileURL)+ } catch let error as BackupCodecError {+ throw error+ } catch {+ throw BackupCodecError.encodingFailed(+ reason: "preparing Backup V2 staging directory failed: \(error)"+ )+ } } - /// Removes a previously exported file (after share completion or cancellation). public func cleanup(_ result: BackupExportResult) { try? FileManager.default.removeItem(at: result.fileURL) } - /// Removes staged backup files older than 24 hours. public func scavengeStaleFiles() { guard let contents = try? FileManager.default.contentsOfDirectory( at: stagingDirectory,@@ -100,9 +118,9 @@ public final class BackupExporter: @unchecked Sendable { let cutoff = Date().addingTimeInterval(-24 * 3600) for url in contents { guard url.lastPathComponent.hasPrefix("Asterism-backup-") else { continue }- guard let attrs = try? url.resourceValues(forKeys: [.contentModificationDateKey]),- let modDate = attrs.contentModificationDate,- modDate < cutoff else { continue }+ guard let attributes = try? url.resourceValues(forKeys: [.contentModificationDateKey]),+ let modificationDate = attributes.contentModificationDate,+ modificationDate < cutoff else { continue } try? FileManager.default.removeItem(at: url) } }@@ -113,6 +131,6 @@ public final class BackupExporter: @unchecked Sendable { formatter.timeZone = TimeZone(identifier: "UTC")! let timestamp = formatter.string(from: exportedAt) .replacingOccurrences(of: ":", with: "-")- return "Asterism-backup-\(timestamp).json"+ return "Asterism-backup-v2-\(timestamp).json" } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV1Codec.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV1Codec.swiftdeleted file mode 100644index b460e51..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV1Codec.swift+++ /dev/null@@ -1,290 +0,0 @@-import CryptoKit-import Foundation--// MARK: - Canonical JSON Writer--/// Writes canonical JSON per the V1 backup format spec:-/// - UTF-8, no BOM, no insignificant whitespace-/// - Object keys lexicographically sorted-/// - Strings: \", \\, \b, \f, \n, \r, \t; U+0000–001F use lowercase \u00xx; / and non-ASCII unescaped-/// - Integers: base-10 no leading zero; booleans/null lowercase-/// - Dates: UTC RFC 3339 with exactly three fractional digits-/// - UUIDs: lowercase canonical strings-internal struct CanonicalJSONWriter {- private(set) var data = Data()-- mutating func writeNull() {- data.append(contentsOf: "null".utf8)- }-- mutating func writeBool(_ value: Bool) {- data.append(contentsOf: (value ? "true" : "false").utf8)- }-- mutating func writeInt(_ value: Int) {- data.append(contentsOf: String(value).utf8)- }-- mutating func writeString(_ value: String) {- data.append(UInt8(ascii: "\""))- for scalar in value.unicodeScalars {- switch scalar {- case "\"": data.append(contentsOf: "\\\"".utf8)- case "\\": data.append(contentsOf: "\\\\".utf8)- case "\u{08}": data.append(contentsOf: "\\b".utf8)- case "\u{0C}": data.append(contentsOf: "\\f".utf8)- case "\n": data.append(contentsOf: "\\n".utf8)- case "\r": data.append(contentsOf: "\\r".utf8)- case "\t": data.append(contentsOf: "\\t".utf8)- case let s where s.value < 0x20:- // Other control chars: \u00xx lowercase- let hex = String(format: "\\u%04x", s.value)- data.append(contentsOf: hex.utf8)- default:- // Non-ASCII and / remain unescaped- var tempStr = String(scalar)- tempStr.withUTF8 { buf in- data.append(contentsOf: buf)- }- }- }- data.append(UInt8(ascii: "\""))- }-- mutating func writeUUID(_ value: UUID) {- writeString(value.uuidString.lowercased())- }-- mutating func writeDate(_ value: Date) {- let ms = Int64((value.timeIntervalSince1970 * 1000).rounded())- let seconds = ms / 1000- let millis = abs(Int(ms % 1000))- let date = Date(timeIntervalSince1970: TimeInterval(seconds))- var calendar = Calendar(identifier: .iso8601)- calendar.timeZone = TimeZone(identifier: "UTC")!- let comps = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)- let formatted = String(- format: "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",- comps.year!, comps.month!, comps.day!,- comps.hour!, comps.minute!, comps.second!, millis- )- writeString(formatted)- }-- mutating func writeOptionalString(_ value: String?) {- if let value { writeString(value) } else { writeNull() }- }-- mutating func writeOptionalUUID(_ value: UUID?) {- if let value { writeUUID(value) } else { writeNull() }- }-- mutating func writeOptionalInt(_ value: Int?) {- if let value { writeInt(value) } else { writeNull() }- }-- /// Writes an object with keys sorted lexicographically.- mutating func writeObject(_ pairs: [(String, (inout CanonicalJSONWriter) -> Void)]) {- let sorted = pairs.sorted { $0.0 < $1.0 }- data.append(UInt8(ascii: "{"))- for (index, (key, writer)) in sorted.enumerated() {- if index > 0 { data.append(UInt8(ascii: ",")) }- writeString(key)- data.append(UInt8(ascii: ":"))- writer(&self)- }- data.append(UInt8(ascii: "}"))- }-- /// Writes an array of elements.- mutating func writeArray(_ elements: [(inout CanonicalJSONWriter) -> Void]) {- data.append(UInt8(ascii: "["))- for (index, writer) in elements.enumerated() {- if index > 0 { data.append(UInt8(ascii: ",")) }- writer(&self)- }- data.append(UInt8(ascii: "]"))- }-}--// MARK: - Codec Implementation--extension BackupV1Codec {- /// Internal encode implementation.- internal static func encodeImpl(- snapshot: LibraryBackupSnapshot,- metadata: BackupMetadata- ) throws -> Data {- // 1. Produce canonical payload bytes- var payloadWriter = CanonicalJSONWriter()- writePayload(snapshot: snapshot, writer: &payloadWriter)- let payloadBytes = payloadWriter.data-- // 2. Compute SHA-256 checksum over payload bytes- let digest = SHA256.hash(data: payloadBytes)- let checksum = digest.map { String(format: "%02x", $0) }.joined()-- // 3. Produce canonical header bytes- var headerWriter = CanonicalJSONWriter()- writeHeader(metadata: metadata, checksum: checksum, snapshot: snapshot, writer: &headerWriter)- let headerBytes = headerWriter.data-- // 4. Assemble envelope: {"header":<header>,"payload":<payload>}- var envelope = Data()- envelope.append(contentsOf: "{\"header\":".utf8)- envelope.append(headerBytes)- envelope.append(contentsOf: ",\"payload\":".utf8)- envelope.append(payloadBytes)- envelope.append(UInt8(ascii: "}"))- return envelope- }-- private static func writeHeader(- metadata: BackupMetadata,- checksum: String,- snapshot: LibraryBackupSnapshot,- writer: inout CanonicalJSONWriter- ) {- writer.writeObject([- ("appBuild", { w in w.writeString(metadata.appBuild) }),- ("backupFormatVersion", { w in w.writeInt(1) }),- ("checksum", { w in w.writeString(checksum) }),- ("databaseSchemaVersion", { w in w.writeInt(metadata.databaseSchemaVersion) }),- ("entryCount", { w in w.writeInt(snapshot.entries.count) }),- ("exportedAt", { w in w.writeDate(metadata.exportedAt) }),- ("workCount", { w in w.writeInt(snapshot.works.count) }),- ])- }-- private static func writePayload(snapshot: LibraryBackupSnapshot, writer: inout CanonicalJSONWriter) {- // Sort entries by lowercase UUID string- let sortedEntries = snapshot.entries.sorted { $0.id.uuidString.lowercased() < $1.id.uuidString.lowercased() }- // Sort works by lowercase UUID string- let sortedWorks = snapshot.works.sorted { $0.id.uuidString.lowercased() < $1.id.uuidString.lowercased() }- // Sort sites by hostname- let sortedSites = snapshot.sites.sorted { $0.hostname < $1.hostname }- // Sort title patterns by lowercase UUID string- let sortedPatterns = snapshot.titlePatterns.sorted { $0.id.uuidString.lowercased() < $1.id.uuidString.lowercased() }-- writer.writeObject([- ("entries", { w in w.writeArray(sortedEntries.map { entry in { (w2: inout CanonicalJSONWriter) in writeEntry(entry, writer: &w2) } }) }),- ("sites", { w in w.writeArray(sortedSites.map { site in { (w2: inout CanonicalJSONWriter) in writeSite(site, writer: &w2) } }) }),- ("titlePatterns", { w in w.writeArray(sortedPatterns.map { p in { (w2: inout CanonicalJSONWriter) in writeTitlePattern(p, writer: &w2) } }) }),- ("works", { w in w.writeArray(sortedWorks.map { work in { (w2: inout CanonicalJSONWriter) in writeWork(work, writer: &w2) } }) }),- ])- }-- private static func writeEntry(_ entry: EntryRecord, writer: inout CanonicalJSONWriter) {- writer.writeObject([- ("canonicalURL", { w in w.writeOptionalString(entry.canonicalURL) }),- ("captureTitle", { w in w.writeString(entry.captureTitle) }),- ("captureTitleSource", { w in w.writeString(entry.captureTitleSource.rawValue) }),- ("chapterTitle", { w in w.writeOptionalString(entry.chapterTitle) }),- ("chapterTitleProvenance", { w in writeProvenance(entry.chapterTitleProvenance, writer: &w) }),- ("entryIdentityKey", { w in w.writeString(entry.entryIdentityKey) }),- ("firstCapturedAt", { w in w.writeDate(entry.firstCapturedAt) }),- ("hostname", { w in w.writeString(entry.hostname) }),- ("id", { w in w.writeUUID(entry.id) }),- ("identityKeyVersion", { w in w.writeInt(entry.identityKeyVersion) }),- ("intentionallyUnattached", { w in w.writeBool(entry.intentionallyUnattached) }),- ("lastSharedAt", { w in w.writeDate(entry.lastSharedAt) }),- ("modifiedAt", { w in w.writeDate(entry.modifiedAt) }),- ("note", { w in w.writeString(entry.note) }),- ("rating", { w in if let r = entry.rating { w.writeString(r.rawValue) } else { w.writeNull() } }),- ("rawURL", { w in w.writeString(entry.rawURL) }),- ("workAssignmentProvenance", { w in writeProvenance(entry.workAssignmentProvenance, writer: &w) }),- ("workID", { w in w.writeOptionalUUID(entry.workID) }),- ])- }-- private static func writeWork(_ work: WorkRecord, writer: inout CanonicalJSONWriter) {- // entryIDs sorted by lowercase UUID string- let sortedEntryIDs = work.entryIDs.sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() }- writer.writeObject([- ("createdAt", { w in w.writeDate(work.createdAt) }),- ("displayTitle", { w in w.writeString(work.displayTitle) }),- ("entryIDs", { w in w.writeArray(sortedEntryIDs.map { id in { (w2: inout CanonicalJSONWriter) in w2.writeUUID(id) } }) }),- ("genericNotes", { w in w.writeString(work.genericNotes) }),- ("genreTags", { w in w.writeArray(work.genreTags.map { tag in { (w2: inout CanonicalJSONWriter) in w2.writeString(tag) } }) }),- ("id", { w in w.writeUUID(work.id) }),- ("lastParsedTitle", { w in w.writeOptionalString(work.lastParsedTitle) }),- ("modifiedAt", { w in w.writeDate(work.modifiedAt) }),- ("siteHostname", { w in w.writeString(work.siteHostname) }),- ("titleProvenance", { w in w.writeString(work.titleProvenance.rawValue) }),- ("type", { w in w.writeString(work.type.rawValue) }),- ("urlIdentity", { w in w.writeOptionalString(work.urlIdentity) }),- ("workURL", { w in w.writeOptionalString(work.workURL) }),- ])- }-- private static func writeSite(_ site: SiteRecord, writer: inout CanonicalJSONWriter) {- // patternIDs sorted by lowercase UUID string- let sortedPatternIDs = site.patternIDs.sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() }- writer.writeObject([- ("displayName", { w in w.writeString(site.displayName) }),- ("hostname", { w in w.writeString(site.hostname) }),- ("junkSuffixRule", { w in- if let rule = site.junkSuffixRule { writeJunkSuffixRule(rule, writer: &w) }- else { w.writeNull() }- }),- ("mode", { w in w.writeString(site.mode.rawValue) }),- ("patternIDs", { w in w.writeArray(sortedPatternIDs.map { id in { (w2: inout CanonicalJSONWriter) in w2.writeUUID(id) } }) }),- ("urlIdentityRule", { w in- if let rule = site.urlIdentityRule { writeURLIdentityRule(rule, writer: &w) }- else { w.writeNull() }- }),- ])- }-- private static func writeTitlePattern(_ pattern: TitlePatternRecord, writer: inout CanonicalJSONWriter) {- writer.writeObject([- ("createdAt", { w in w.writeDate(pattern.createdAt) }),- ("id", { w in w.writeUUID(pattern.id) }),- ("isActive", { w in w.writeBool(pattern.isActive) }),- ("junkAnchors", { w in w.writeArray(pattern.junkAnchors.map { a in { (w2: inout CanonicalJSONWriter) in writePosition(a, writer: &w2) } }) }),- ("siteHostname", { w in w.writeString(pattern.siteHostname) }),- ("version", { w in w.writeInt(pattern.version) }),- ("workAnchor", { w in writeRange(pattern.workAnchor, writer: &w) }),- ])- }-- private static func writeProvenance(_ prov: FieldProvenance, writer: inout CanonicalJSONWriter) {- writer.writeObject([- ("kind", { w in w.writeString(prov.kind.rawValue) }),- ("patternID", { w in w.writeOptionalUUID(prov.patternID) }),- ("patternVersion", { w in w.writeOptionalInt(prov.patternVersion) }),- ])- }-- private static func writeRange(_ range: SegmentRangeSpec, writer: inout CanonicalJSONWriter) {- writer.writeObject([- ("length", { w in w.writeInt(range.length) }),- ("offset", { w in w.writeInt(range.offset) }),- ("origin", { w in w.writeString(range.origin.rawValue) }),- ])- }-- private static func writePosition(_ pos: SegmentPositionSpec, writer: inout CanonicalJSONWriter) {- writer.writeObject([- ("offset", { w in w.writeInt(pos.offset) }),- ("origin", { w in w.writeString(pos.origin.rawValue) }),- ])- }-- private static func writeURLIdentityRule(_ rule: URLIdentityRule, writer: inout CanonicalJSONWriter) {- writer.writeObject([- ("component", { w in w.writeString(rule.component.rawValue) }),- ("offset", { w in w.writeOptionalInt(rule.offset) }),- ("origin", { w in if let o = rule.origin { w.writeString(o.rawValue) } else { w.writeNull() } }),- ("queryName", { w in w.writeOptionalString(rule.queryName) }),- ("version", { w in w.writeInt(rule.version) }),- ])- }-- private static func writeJunkSuffixRule(_ rule: JunkSuffixRule, writer: inout CanonicalJSONWriter) {- writer.writeObject([- ("anchors", { w in w.writeArray(rule.anchors.map { a in { (w2: inout CanonicalJSONWriter) in writePosition(a, writer: &w2) } }) }),- ("version", { w in w.writeInt(rule.version) }),- ])- }-}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV1Decoder.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV1Decoder.swiftdeleted file mode 100644index b7b5dc1..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV1Decoder.swift+++ /dev/null@@ -1,219 +0,0 @@-import CryptoKit-import Foundation--// MARK: - Strict JSON Reader--/// A strict byte-level JSON reader that tracks payload ranges and rejects-/// unknown/duplicate keys per the V1 backup format contract.-internal struct StrictJSONReader {- private let bytes: [UInt8]- private(set) var position: Int = 0-- init(_ data: Data) {- self.bytes = [UInt8](data)- }-- var isAtEnd: Bool { position >= bytes.count }- var currentByte: UInt8? { position < bytes.count ? bytes[position] : nil }-- mutating func expectEnd() throws {- guard isAtEnd else {- throw BackupCodecError.trailingBytes- }- }-- mutating func skipByte() { position += 1 }-- mutating func expect(_ byte: UInt8) throws {- guard position < bytes.count, bytes[position] == byte else {- throw BackupCodecError.decodingFailed(reason: "expected '\(Character(UnicodeScalar(byte)))' at position \(position)")- }- position += 1- }-- mutating func readString() throws -> String {- try expect(UInt8(ascii: "\""))- var result: [UInt8] = []- while position < bytes.count {- let b = bytes[position]- if b == UInt8(ascii: "\"") {- position += 1- guard let s = String(bytes: result, encoding: .utf8) else {- throw BackupCodecError.decodingFailed(reason: "invalid UTF-8 in string")- }- return s- }- if b == UInt8(ascii: "\\") {- position += 1- guard position < bytes.count else {- throw BackupCodecError.decodingFailed(reason: "unterminated escape")- }- let escaped = bytes[position]- position += 1- switch escaped {- case UInt8(ascii: "\""): result.append(UInt8(ascii: "\""))- case UInt8(ascii: "\\"): result.append(UInt8(ascii: "\\"))- case UInt8(ascii: "/"): result.append(UInt8(ascii: "/"))- case UInt8(ascii: "b"): result.append(0x08)- case UInt8(ascii: "f"): result.append(0x0C)- case UInt8(ascii: "n"): result.append(0x0A)- case UInt8(ascii: "r"): result.append(0x0D)- case UInt8(ascii: "t"): result.append(0x09)- case UInt8(ascii: "u"):- guard position + 4 <= bytes.count else {- throw BackupCodecError.decodingFailed(reason: "incomplete unicode escape")- }- let hex = String(bytes: bytes[position..<position+4], encoding: .ascii)!- position += 4- guard let codePoint = UInt32(hex, radix: 16),- let scalar = Unicode.Scalar(codePoint) else {- throw BackupCodecError.decodingFailed(reason: "invalid unicode escape \\u\(hex)")- }- var buf = [UInt8]()- for byte in scalar.utf8 { buf.append(byte) }- result.append(contentsOf: buf)- default:- throw BackupCodecError.decodingFailed(reason: "invalid escape sequence \\")- }- } else {- result.append(b)- position += 1- }- }- throw BackupCodecError.decodingFailed(reason: "unterminated string")- }-- mutating func readInt() throws -> Int {- var numStr = ""- if position < bytes.count && bytes[position] == UInt8(ascii: "-") {- numStr.append("-")- position += 1- }- guard position < bytes.count, bytes[position] >= UInt8(ascii: "0"), bytes[position] <= UInt8(ascii: "9") else {- throw BackupCodecError.decodingFailed(reason: "expected digit at position \(position)")- }- while position < bytes.count, bytes[position] >= UInt8(ascii: "0"), bytes[position] <= UInt8(ascii: "9") {- numStr.append(Character(UnicodeScalar(bytes[position])))- position += 1- }- guard let value = Int(numStr) else {- throw BackupCodecError.decodingFailed(reason: "invalid integer: \(numStr)")- }- return value- }-- mutating func readBool() throws -> Bool {- if bytes[position...].starts(with: "true".utf8) {- position += 4; return true- }- if bytes[position...].starts(with: "false".utf8) {- position += 5; return false- }- throw BackupCodecError.decodingFailed(reason: "expected boolean at position \(position)")- }-- mutating func readNull() throws {- guard bytes[position...].starts(with: "null".utf8) else {- throw BackupCodecError.decodingFailed(reason: "expected null at position \(position)")- }- position += 4- }-- mutating func peekIsNull() -> Bool {- position < bytes.count - 3 && bytes[position...].starts(with: "null".utf8)- }-- mutating func readOptionalString() throws -> String? {- if peekIsNull() { try readNull(); return nil }- return try readString()- }-- mutating func readOptionalInt() throws -> Int? {- if peekIsNull() { try readNull(); return nil }- return try readInt()- }-- mutating func readUUID() throws -> UUID {- let str = try readString()- guard let uuid = UUID(uuidString: str) else {- throw BackupCodecError.decodingFailed(reason: "invalid UUID: \(str)")- }- return uuid- }-- mutating func readOptionalUUID() throws -> UUID? {- if peekIsNull() { try readNull(); return nil }- return try readUUID()- }-- mutating func readDate() throws -> Date {- let str = try readString()- let formatter = ISO8601DateFormatter()- formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]- guard let date = formatter.date(from: str) else {- throw BackupCodecError.invalidValue(key: "date", reason: "invalid RFC 3339 date: \(str)")- }- return date- }-- /// Reads an object and returns keys in order encountered. Calls handler for each key.- mutating func readObject(_ handler: (inout StrictJSONReader, String) throws -> Void) throws {- try expect(UInt8(ascii: "{"))- var keys: Set<String> = []- var first = true- while true {- guard let next = currentByte else {- throw BackupCodecError.decodingFailed(reason: "unterminated object")- }- if next == UInt8(ascii: "}") { position += 1; return }- if !first { try expect(UInt8(ascii: ",")) }- first = false- let key = try readString()- guard keys.insert(key).inserted else {- throw BackupCodecError.duplicateKey(key)- }- try expect(UInt8(ascii: ":"))- try handler(&self, key)- }- }-- /// Reads an array, calling handler for each element.- mutating func readArray(_ handler: (inout StrictJSONReader) throws -> Void) throws {- try expect(UInt8(ascii: "["))- var first = true- while true {- guard let next = currentByte else {- throw BackupCodecError.decodingFailed(reason: "unterminated array")- }- if next == UInt8(ascii: "]") { position += 1; return }- if !first { try expect(UInt8(ascii: ",")) }- first = false- try handler(&self)- }- }-- /// Skips a JSON value (string, number, bool, null, object, array).- mutating func skipValue() throws {- guard let b = currentByte else {- throw BackupCodecError.decodingFailed(reason: "unexpected end")- }- switch b {- case UInt8(ascii: "\""): _ = try readString()- case UInt8(ascii: "t"), UInt8(ascii: "f"): _ = try readBool()- case UInt8(ascii: "n"): try readNull()- case UInt8(ascii: "{"):- try readObject { reader, _ in try reader.skipValue() }- case UInt8(ascii: "["):- try readArray { reader in try reader.skipValue() }- default: _ = try readInt()- }- }-- /// Records the byte range of the next JSON value without consuming it in a way- /// that changes the read result. Returns (startPosition, endPosition).- mutating func recordValueRange() throws -> Range<Int> {- let start = position- try skipValue()- return start..<position- }-}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderEnvelope.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderEnvelope.swiftdeleted file mode 100644index d080da0..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderEnvelope.swift+++ /dev/null@@ -1,146 +0,0 @@-import CryptoKit-import Foundation--// MARK: - Decode Implementation--extension BackupV1Codec {- internal static func decodeImpl(_ data: Data) throws -> DecodedBackup {- var reader = StrictJSONReader(data)- var header: BackupHeader?- var snapshot: LibraryBackupSnapshot?- var payloadRange: Range<Int>?-- let validEnvelopeKeys: Set<String> = ["header", "payload"]- var seenKeys: Set<String> = []-- try reader.readObject { reader, key in- guard validEnvelopeKeys.contains(key) else {- throw BackupCodecError.unknownKey(key)- }- guard seenKeys.insert(key).inserted else {- throw BackupCodecError.duplicateKey(key)- }- switch key {- case "header":- header = try readHeader(&reader)- case "payload":- let start = reader.position- snapshot = try readPayload(&reader)- payloadRange = start..<reader.position- default:- break- }- }-- try reader.expectEnd()-- guard let h = header else { throw BackupCodecError.missingKey("header") }- guard let s = snapshot else { throw BackupCodecError.missingKey("payload") }- guard let pr = payloadRange else { throw BackupCodecError.missingKey("payload") }-- guard h.backupFormatVersion == 1 else {- throw BackupCodecError.invalidFormatVersion(h.backupFormatVersion)- }- guard h.databaseSchemaVersion == 1 else {- throw BackupCodecError.invalidSchemaVersion(h.databaseSchemaVersion)- }-- return DecodedBackup(snapshot: s, header: h, payloadRange: pr)- }-- private static func readHeader(_ reader: inout StrictJSONReader) throws -> BackupHeader {- let validKeys: Set<String> = [- "appBuild", "backupFormatVersion", "checksum",- "databaseSchemaVersion", "entryCount", "exportedAt", "workCount",- ]- var appBuild: String?- var formatVersion: Int?- var checksum: String?- var schemaVersion: Int?- var entryCount: Int?- var exportedAt: Date?- var workCount: Int?-- try reader.readObject { reader, key in- guard validKeys.contains(key) else {- throw BackupCodecError.unknownKey(key)- }- switch key {- case "appBuild": appBuild = try reader.readString()- case "backupFormatVersion": formatVersion = try reader.readInt()- case "checksum": checksum = try reader.readString()- case "databaseSchemaVersion": schemaVersion = try reader.readInt()- case "entryCount": entryCount = try reader.readInt()- case "exportedAt": exportedAt = try reader.readDate()- case "workCount": workCount = try reader.readInt()- default: break- }- }-- guard let ab = appBuild else { throw BackupCodecError.missingKey("appBuild") }- guard let fv = formatVersion else { throw BackupCodecError.missingKey("backupFormatVersion") }- guard let cs = checksum else { throw BackupCodecError.missingKey("checksum") }- guard let sv = schemaVersion else { throw BackupCodecError.missingKey("databaseSchemaVersion") }- guard let ec = entryCount else { throw BackupCodecError.missingKey("entryCount") }- guard let ea = exportedAt else { throw BackupCodecError.missingKey("exportedAt") }- guard let wc = workCount else { throw BackupCodecError.missingKey("workCount") }-- return BackupHeader(- backupFormatVersion: fv,- databaseSchemaVersion: sv,- appBuild: ab,- exportedAt: ea,- entryCount: ec,- workCount: wc,- checksum: cs- )- }-- private static func readPayload(_ reader: inout StrictJSONReader) throws -> LibraryBackupSnapshot {- let validKeys: Set<String> = ["entries", "sites", "titlePatterns", "works"]- var entries: [EntryRecord]?- var works: [WorkRecord]?- var sites: [SiteRecord]?- var titlePatterns: [TitlePatternRecord]?-- try reader.readObject { reader, key in- guard validKeys.contains(key) else {- throw BackupCodecError.unknownKey(key)- }- switch key {- case "entries":- var items: [EntryRecord] = []- try reader.readArray { reader in- items.append(try readEntry(&reader))- }- entries = items- case "works":- var items: [WorkRecord] = []- try reader.readArray { reader in- items.append(try readWork(&reader))- }- works = items- case "sites":- var items: [SiteRecord] = []- try reader.readArray { reader in- items.append(try readSite(&reader))- }- sites = items- case "titlePatterns":- var items: [TitlePatternRecord] = []- try reader.readArray { reader in- items.append(try readTitlePattern(&reader))- }- titlePatterns = items- default: break- }- }-- guard let e = entries else { throw BackupCodecError.missingKey("entries") }- guard let w = works else { throw BackupCodecError.missingKey("works") }- guard let s = sites else { throw BackupCodecError.missingKey("sites") }- guard let tp = titlePatterns else { throw BackupCodecError.missingKey("titlePatterns") }-- return LibraryBackupSnapshot(entries: e, works: w, sites: s, titlePatterns: tp)- }-}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderRecords.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderRecords.swiftdeleted file mode 100644index 7e249db..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV1DecoderRecords.swift+++ /dev/null@@ -1,336 +0,0 @@-import Foundation--// MARK: - Record-Level Decoding--extension BackupV1Codec {- static func readEntry(_ reader: inout StrictJSONReader) throws -> EntryRecord {- let validKeys: Set<String> = [- "canonicalURL", "captureTitle", "captureTitleSource", "chapterTitle",- "chapterTitleProvenance", "entryIdentityKey", "firstCapturedAt",- "hostname", "id", "identityKeyVersion", "intentionallyUnattached",- "lastSharedAt", "modifiedAt", "note", "rating", "rawURL",- "workAssignmentProvenance", "workID",- ]- var id: UUID?; var captureTitle: String?; var captureTitleSource: String?- var rawURL: String?; var canonicalURL: String??; var hostname: String?- var entryIdentityKey: String?; var identityKeyVersion: Int?- var chapterTitle: String??; var chapterTitleProvenance: FieldProvenance?- var note: String?; var rating: String??- var firstCapturedAt: Date?; var lastSharedAt: Date?; var modifiedAt: Date?- var workID: UUID??; var workAssignmentProvenance: FieldProvenance?- var intentionallyUnattached: Bool?- var seenKeys: Set<String> = []-- try reader.readObject { reader, key in- guard validKeys.contains(key) else { throw BackupCodecError.unknownKey(key) }- guard seenKeys.insert(key).inserted else { throw BackupCodecError.duplicateKey(key) }- switch key {- case "id": id = try reader.readUUID()- case "captureTitle": captureTitle = try reader.readString()- case "captureTitleSource": captureTitleSource = try reader.readString()- case "rawURL": rawURL = try reader.readString()- case "canonicalURL": canonicalURL = .some(try reader.readOptionalString())- case "hostname": hostname = try reader.readString()- case "entryIdentityKey": entryIdentityKey = try reader.readString()- case "identityKeyVersion": identityKeyVersion = try reader.readInt()- case "chapterTitle": chapterTitle = .some(try reader.readOptionalString())- case "chapterTitleProvenance": chapterTitleProvenance = try readProvenance(&reader)- case "note": note = try reader.readString()- case "rating": rating = .some(try reader.readOptionalString())- case "firstCapturedAt": firstCapturedAt = try reader.readDate()- case "lastSharedAt": lastSharedAt = try reader.readDate()- case "modifiedAt": modifiedAt = try reader.readDate()- case "workID": workID = .some(try reader.readOptionalUUID())- case "workAssignmentProvenance": workAssignmentProvenance = try readProvenance(&reader)- case "intentionallyUnattached": intentionallyUnattached = try reader.readBool()- default: break- }- }-- guard seenKeys.count == validKeys.count else {- let missing = validKeys.subtracting(seenKeys)- throw BackupCodecError.missingKey(missing.sorted().first!)- }-- guard let titleSourceStr = captureTitleSource,- let titleSource = CaptureTitleSource(rawValue: titleSourceStr) else {- throw BackupCodecError.invalidValue(key: "captureTitleSource", reason: "invalid enum value")- }-- let parsedRating: Rating?- if case .some(let ratingStr) = rating {- if let str = ratingStr {- guard let r = Rating(rawValue: str) else {- throw BackupCodecError.invalidValue(key: "rating", reason: "invalid enum value: \(str)")- }- parsedRating = r- } else { parsedRating = nil }- } else { parsedRating = nil }-- return EntryRecord(- id: id!, captureTitle: captureTitle!, captureTitleSource: titleSource,- rawURL: rawURL!, canonicalURL: canonicalURL!,- hostname: hostname!, entryIdentityKey: entryIdentityKey!,- identityKeyVersion: identityKeyVersion!,- chapterTitle: chapterTitle!,- chapterTitleProvenance: chapterTitleProvenance!,- note: note!, rating: parsedRating,- firstCapturedAt: firstCapturedAt!, lastSharedAt: lastSharedAt!,- modifiedAt: modifiedAt!, workID: workID!,- workAssignmentProvenance: workAssignmentProvenance!,- intentionallyUnattached: intentionallyUnattached!- )- }-- static func readWork(_ reader: inout StrictJSONReader) throws -> WorkRecord {- let validKeys: Set<String> = [- "createdAt", "displayTitle", "entryIDs", "genericNotes", "genreTags",- "id", "lastParsedTitle", "modifiedAt", "siteHostname",- "titleProvenance", "type", "urlIdentity", "workURL",- ]- var id: UUID?; var displayTitle: String?; var lastParsedTitle: String??- var siteHostname: String?; var urlIdentity: String??; var workURL: String??- var genericNotes: String?; var typeRaw: String?; var genreTags: [String]?- var titleProvenance: String?; var createdAt: Date?; var modifiedAt: Date?- var entryIDs: [UUID]?- var seenKeys: Set<String> = []-- try reader.readObject { reader, key in- guard validKeys.contains(key) else { throw BackupCodecError.unknownKey(key) }- guard seenKeys.insert(key).inserted else { throw BackupCodecError.duplicateKey(key) }- switch key {- case "id": id = try reader.readUUID()- case "displayTitle": displayTitle = try reader.readString()- case "lastParsedTitle": lastParsedTitle = .some(try reader.readOptionalString())- case "siteHostname": siteHostname = try reader.readString()- case "urlIdentity": urlIdentity = .some(try reader.readOptionalString())- case "workURL": workURL = .some(try reader.readOptionalString())- case "genericNotes": genericNotes = try reader.readString()- case "type": typeRaw = try reader.readString()- case "genreTags":- var tags: [String] = []- try reader.readArray { r in tags.append(try r.readString()) }- genreTags = tags- case "titleProvenance": titleProvenance = try reader.readString()- case "createdAt": createdAt = try reader.readDate()- case "modifiedAt": modifiedAt = try reader.readDate()- case "entryIDs":- var ids: [UUID] = []- try reader.readArray { r in ids.append(try r.readUUID()) }- entryIDs = ids- default: break- }- }-- guard seenKeys.count == validKeys.count else {- let missing = validKeys.subtracting(seenKeys)- throw BackupCodecError.missingKey(missing.sorted().first!)- }- guard let t = typeRaw, let workType = WorkType(rawValue: t) else {- throw BackupCodecError.invalidValue(key: "type", reason: "invalid work type")- }- guard let tp = titleProvenance, let prov = TitleProvenance(rawValue: tp) else {- throw BackupCodecError.invalidValue(key: "titleProvenance", reason: "invalid title provenance")- }-- return WorkRecord(- id: id!, displayTitle: displayTitle!, lastParsedTitle: lastParsedTitle!,- siteHostname: siteHostname!, urlIdentity: urlIdentity!, workURL: workURL!,- genericNotes: genericNotes!, type: workType, genreTags: genreTags!,- titleProvenance: prov, createdAt: createdAt!, modifiedAt: modifiedAt!,- entryIDs: entryIDs!- )- }-- static func readSite(_ reader: inout StrictJSONReader) throws -> SiteRecord {- let validKeys: Set<String> = [- "displayName", "hostname", "junkSuffixRule", "mode", "patternIDs", "urlIdentityRule",- ]- var hostname: String?; var displayName: String?; var modeRaw: String?- var patternIDs: [UUID]?; var urlIdentityRule: URLIdentityRule??- var junkSuffixRule: JunkSuffixRule??- var seenKeys: Set<String> = []-- try reader.readObject { reader, key in- guard validKeys.contains(key) else { throw BackupCodecError.unknownKey(key) }- guard seenKeys.insert(key).inserted else { throw BackupCodecError.duplicateKey(key) }- switch key {- case "hostname": hostname = try reader.readString()- case "displayName": displayName = try reader.readString()- case "mode": modeRaw = try reader.readString()- case "patternIDs":- var ids: [UUID] = []- try reader.readArray { r in ids.append(try r.readUUID()) }- patternIDs = ids- case "urlIdentityRule":- if reader.peekIsNull() { try reader.readNull(); urlIdentityRule = .some(nil) }- else { urlIdentityRule = .some(try readURLIdentityRule(&reader)) }- case "junkSuffixRule":- if reader.peekIsNull() { try reader.readNull(); junkSuffixRule = .some(nil) }- else { junkSuffixRule = .some(try readJunkSuffixRule(&reader)) }- default: break- }- }-- guard seenKeys.count == validKeys.count else {- let missing = validKeys.subtracting(seenKeys)- throw BackupCodecError.missingKey(missing.sorted().first!)- }- guard let m = modeRaw, let mode = SiteMode(rawValue: m) else {- throw BackupCodecError.invalidValue(key: "mode", reason: "invalid site mode")- }-- return SiteRecord(- hostname: hostname!, displayName: displayName!, mode: mode,- patternIDs: patternIDs!, urlIdentityRule: urlIdentityRule!,- junkSuffixRule: junkSuffixRule!- )- }-- static func readTitlePattern(_ reader: inout StrictJSONReader) throws -> TitlePatternRecord {- let validKeys: Set<String> = [- "createdAt", "id", "isActive", "junkAnchors", "siteHostname", "version", "workAnchor",- ]- var id: UUID?; var version: Int?; var isActive: Bool?- var createdAt: Date?; var workAnchor: SegmentRangeSpec?- var junkAnchors: [SegmentPositionSpec]?; var siteHostname: String?- var seenKeys: Set<String> = []-- try reader.readObject { reader, key in- guard validKeys.contains(key) else { throw BackupCodecError.unknownKey(key) }- guard seenKeys.insert(key).inserted else { throw BackupCodecError.duplicateKey(key) }- switch key {- case "id": id = try reader.readUUID()- case "version": version = try reader.readInt()- case "isActive": isActive = try reader.readBool()- case "createdAt": createdAt = try reader.readDate()- case "workAnchor": workAnchor = try readRange(&reader)- case "junkAnchors":- var anchors: [SegmentPositionSpec] = []- try reader.readArray { r in anchors.append(try readPosition(&r)) }- junkAnchors = anchors- case "siteHostname": siteHostname = try reader.readString()- default: break- }- }-- guard seenKeys.count == validKeys.count else {- let missing = validKeys.subtracting(seenKeys)- throw BackupCodecError.missingKey(missing.sorted().first!)- }-- return TitlePatternRecord(- id: id!, version: version!, isActive: isActive!,- createdAt: createdAt!, workAnchor: workAnchor!,- junkAnchors: junkAnchors!, siteHostname: siteHostname!- )- }-- // MARK: - Value Object Decoding-- static func readProvenance(_ reader: inout StrictJSONReader) throws -> FieldProvenance {- var kind: String?; var patternID: UUID??; var patternVersion: Int??- try reader.readObject { reader, key in- switch key {- case "kind": kind = try reader.readString()- case "patternID": patternID = .some(try reader.readOptionalUUID())- case "patternVersion": patternVersion = .some(try reader.readOptionalInt())- default: throw BackupCodecError.unknownKey(key)- }- }- guard let k = kind, let provKind = FieldProvenanceKind(rawValue: k) else {- throw BackupCodecError.invalidValue(key: "kind", reason: "invalid provenance kind")- }- guard patternID != nil, patternVersion != nil else {- throw BackupCodecError.missingKey("patternID or patternVersion")- }- return try FieldProvenance(kind: provKind, patternID: patternID!, patternVersion: patternVersion!)- }-- static func readRange(_ reader: inout StrictJSONReader) throws -> SegmentRangeSpec {- var origin: String?; var offset: Int?; var length: Int?- try reader.readObject { reader, key in- switch key {- case "origin": origin = try reader.readString()- case "offset": offset = try reader.readInt()- case "length": length = try reader.readInt()- default: throw BackupCodecError.unknownKey(key)- }- }- guard let o = origin, let ao = AnchorOrigin(rawValue: o) else {- throw BackupCodecError.invalidValue(key: "origin", reason: "invalid anchor origin")- }- guard let off = offset, let len = length else {- throw BackupCodecError.missingKey("offset or length")- }- return try SegmentRangeSpec(origin: ao, offset: off, length: len)- }-- static func readPosition(_ reader: inout StrictJSONReader) throws -> SegmentPositionSpec {- var origin: String?; var offset: Int?- try reader.readObject { reader, key in- switch key {- case "origin": origin = try reader.readString()- case "offset": offset = try reader.readInt()- default: throw BackupCodecError.unknownKey(key)- }- }- guard let o = origin, let ao = AnchorOrigin(rawValue: o) else {- throw BackupCodecError.invalidValue(key: "origin", reason: "invalid anchor origin")- }- guard let off = offset else {- throw BackupCodecError.missingKey("offset")- }- return try SegmentPositionSpec(origin: ao, offset: off)- }-- static func readURLIdentityRule(_ reader: inout StrictJSONReader) throws -> URLIdentityRule {- var component: String?; var offset: Int??; var origin: String??- var queryName: String??; var version: Int?- try reader.readObject { reader, key in- switch key {- case "component": component = try reader.readString()- case "offset": offset = .some(try reader.readOptionalInt())- case "origin": origin = .some(try reader.readOptionalString())- case "queryName": queryName = .some(try reader.readOptionalString())- case "version": version = try reader.readInt()- default: throw BackupCodecError.unknownKey(key)- }- }- guard let c = component, let comp = URLRuleComponent(rawValue: c) else {- throw BackupCodecError.invalidValue(key: "component", reason: "invalid URL rule component")- }- guard let v = version else { throw BackupCodecError.missingKey("version") }- switch comp {- case .pathSegment:- guard let originStr = origin!, let ao = AnchorOrigin(rawValue: originStr),- let off = offset! else {- throw BackupCodecError.invalidValue(key: "urlIdentityRule", reason: "pathSegment requires origin and offset")- }- return try URLIdentityRule(version: v, component: comp, origin: ao, offset: off)- case .queryItem:- guard let qn = queryName! else {- throw BackupCodecError.invalidValue(key: "urlIdentityRule", reason: "queryItem requires queryName")- }- return try URLIdentityRule(version: v, component: comp, queryName: qn)- }- }-- static func readJunkSuffixRule(_ reader: inout StrictJSONReader) throws -> JunkSuffixRule {- var version: Int?; var anchors: [SegmentPositionSpec]?- try reader.readObject { reader, key in- switch key {- case "version": version = try reader.readInt()- case "anchors":- var items: [SegmentPositionSpec] = []- try reader.readArray { r in items.append(try readPosition(&r)) }- anchors = items- default: throw BackupCodecError.unknownKey(key)- }- }- guard let v = version, let a = anchors else {- throw BackupCodecError.missingKey("version or anchors")- }- return try JunkSuffixRule(version: v, anchors: a)- }-}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV1ValidatorImpl.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV1ValidatorImpl.swiftdeleted file mode 100644index d8eddcb..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV1ValidatorImpl.swift+++ /dev/null@@ -1,205 +0,0 @@-import CryptoKit-import Foundation--// MARK: - Validator Implementation--extension BackupV1Validator {- internal static func validateImpl(- decoded: DecodedBackup,- source: LibraryBackupSnapshot,- encodedData: Data- ) throws {- // 1. Checksum verification over exact payload bytes- let payloadSlice = encodedData[decoded.payloadRange]- let digest = SHA256.hash(data: payloadSlice)- let computedChecksum = digest.map { String(format: "%02x", $0) }.joined()- guard computedChecksum == decoded.header.checksum else {- throw BackupValidationError.checksumMismatch(- expected: decoded.header.checksum,- actual: computedChecksum- )- }-- // 2. Header count validation- guard decoded.header.entryCount == decoded.snapshot.entries.count else {- throw BackupValidationError.countMismatch(- type: "Entry",- expected: decoded.header.entryCount,- actual: decoded.snapshot.entries.count- )- }- guard decoded.header.workCount == decoded.snapshot.works.count else {- throw BackupValidationError.countMismatch(- type: "Work",- expected: decoded.header.workCount,- actual: decoded.snapshot.works.count- )- }-- // 3. Deep equality with source snapshot- guard decoded.snapshot == source else {- throw BackupValidationError.snapshotMismatch(- reason: "decoded payload does not match source snapshot"- )- }-- // 4. UUID uniqueness within each record type- try validateUniqueEntryIDs(decoded.snapshot.entries)- try validateUniqueWorkIDs(decoded.snapshot.works)- try validateUniqueTitlePatternIDs(decoded.snapshot.titlePatterns)- try validateUniqueSiteHostnames(decoded.snapshot.sites)-- // 5. All relationships resolve- try validateEntryReferences(decoded.snapshot)- try validateWorkReferences(decoded.snapshot)- try validateSiteReferences(decoded.snapshot)- try validateProvenanceReferences(decoded.snapshot)-- // 6. At most one active pattern per Site- try validateActivePatternCounts(decoded.snapshot)- }-- // MARK: - Uniqueness Checks-- private static func validateUniqueEntryIDs(_ entries: [EntryRecord]) throws {- var seen: Set<UUID> = []- for entry in entries {- guard seen.insert(entry.id).inserted else {- throw BackupValidationError.duplicateID(type: "Entry", id: entry.id)- }- }- }-- private static func validateUniqueWorkIDs(_ works: [WorkRecord]) throws {- var seen: Set<UUID> = []- for work in works {- guard seen.insert(work.id).inserted else {- throw BackupValidationError.duplicateID(type: "Work", id: work.id)- }- }- }-- private static func validateUniqueTitlePatternIDs(_ patterns: [TitlePatternRecord]) throws {- var seen: Set<UUID> = []- for pattern in patterns {- guard seen.insert(pattern.id).inserted else {- throw BackupValidationError.duplicateID(type: "TitlePattern", id: pattern.id)- }- }- }-- private static func validateUniqueSiteHostnames(_ sites: [SiteRecord]) throws {- var seen: Set<String> = []- for site in sites {- guard seen.insert(site.hostname).inserted else {- throw BackupValidationError.duplicateHostname(site.hostname)- }- }- }-- // MARK: - Reference Checks-- private static func validateEntryReferences(_ snapshot: LibraryBackupSnapshot) throws {- let workIDs = Set(snapshot.works.map(\.id))- let siteHostnames = Set(snapshot.sites.map(\.hostname))- for entry in snapshot.entries {- if let workID = entry.workID {- guard workIDs.contains(workID) else {- throw BackupValidationError.unresolvedReference(- type: "Entry", id: entry.id,- referencedType: "Work", referencedID: workID- )- }- }- guard siteHostnames.contains(entry.hostname) else {- throw BackupValidationError.unresolvedHostname(- type: "Entry", id: entry.id.uuidString, hostname: entry.hostname- )- }- }- }-- private static func validateWorkReferences(_ snapshot: LibraryBackupSnapshot) throws {- let entryIDs = Set(snapshot.entries.map(\.id))- let siteHostnames = Set(snapshot.sites.map(\.hostname))- for work in snapshot.works {- guard siteHostnames.contains(work.siteHostname) else {- throw BackupValidationError.unresolvedHostname(- type: "Work", id: work.id.uuidString, hostname: work.siteHostname- )- }- for entryID in work.entryIDs {- guard entryIDs.contains(entryID) else {- throw BackupValidationError.unresolvedReference(- type: "Work", id: work.id,- referencedType: "Entry", referencedID: entryID- )- }- }- }- }-- private static func validateSiteReferences(_ snapshot: LibraryBackupSnapshot) throws {- let patternIDs = Set(snapshot.titlePatterns.map(\.id))- for site in snapshot.sites {- for patternID in site.patternIDs {- guard patternIDs.contains(patternID) else {- throw BackupValidationError.unresolvedReference(- type: "Site", id: UUID(), // Sites don't have UUID IDs, using placeholder- referencedType: "TitlePattern", referencedID: patternID- )- }- }- }- // Validate titlePattern siteHostname references- let siteHostnames = Set(snapshot.sites.map(\.hostname))- for pattern in snapshot.titlePatterns {- guard siteHostnames.contains(pattern.siteHostname) else {- throw BackupValidationError.unresolvedHostname(- type: "TitlePattern", id: pattern.id.uuidString, hostname: pattern.siteHostname- )- }- }- }-- private static func validateProvenanceReferences(_ snapshot: LibraryBackupSnapshot) throws {- let patternsByID: [UUID: [TitlePatternRecord]] = Dictionary(grouping: snapshot.titlePatterns, by: \.id)- for entry in snapshot.entries {- try validateProvenance(entry.chapterTitleProvenance, type: "Entry", id: entry.id, patterns: patternsByID)- try validateProvenance(entry.workAssignmentProvenance, type: "Entry", id: entry.id, patterns: patternsByID)- }- }-- private static func validateProvenance(- _ provenance: FieldProvenance,- type: String,- id: UUID,- patterns: [UUID: [TitlePatternRecord]]- ) throws {- guard provenance.kind == .pattern else { return }- guard let patternID = provenance.patternID,- let patternVersion = provenance.patternVersion else {- throw BackupValidationError.unresolvedProvenance(- type: type, id: id, patternID: UUID(), patternVersion: 0- )- }- guard let matchingPatterns = patterns[patternID],- matchingPatterns.contains(where: { $0.version == patternVersion }) else {- throw BackupValidationError.unresolvedProvenance(- type: type, id: id, patternID: patternID, patternVersion: patternVersion- )- }- }-- // MARK: - Active Pattern Count-- private static func validateActivePatternCounts(_ snapshot: LibraryBackupSnapshot) throws {- var activeCounts: [String: Int] = [:]- for pattern in snapshot.titlePatterns where pattern.isActive {- activeCounts[pattern.siteHostname, default: 0] += 1- }- for (hostname, count) in activeCounts where count > 1 {- throw BackupValidationError.invalidActivePatternCount(hostname: hostname, count: count)- }- }-}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV2Codec.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV2Codec.swiftnew file mode 100644index 0000000..6d3137d--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV2Codec.swift@@ -0,0 +1,512 @@+import Foundation++internal enum BackupV2CodecImplementation {+ static func encode(+ snapshot: LibraryBackupSnapshot,+ metadata: BackupMetadata,+ capabilities: M2Capabilities+ ) throws -> Data {+ guard metadata.databaseSchemaVersion == BackupV2Document.schemaVersion else {+ throw BackupCodecError.invalidSchemaVersion(metadata.databaseSchemaVersion)+ }+ do {+ try V2LibraryValidator.validate(snapshot: snapshot, capabilities: capabilities)+ return try JSONSerialization.data(+ withJSONObject: BackupV2JSONWriter.document(+ snapshot: snapshot,+ metadata: metadata,+ capabilities: capabilities+ ),+ options: [.sortedKeys, .withoutEscapingSlashes]+ )+ } catch let error as BackupValidationError {+ throw error+ } catch let error as BackupCodecError {+ throw error+ } catch {+ throw BackupCodecError.encodingFailed(reason: String(describing: error))+ }+ }++ static func decode(+ _ data: Data,+ capabilities: M2Capabilities+ ) throws -> BackupV2Document {+ do {+ try DuplicateJSONKeyValidator.validate(data)+ let object = try JSONSerialization.jsonObject(with: data)+ try BackupV2ShapeValidator.validate(object)++ let decoder = JSONDecoder()+ decoder.dateDecodingStrategy = .custom(decodeDate)+ let document = try decoder.decode(BackupV2Document.self, from: data)+ guard document.backupFormatVersion == BackupV2Document.formatVersion else {+ throw BackupCodecError.invalidFormatVersion(document.backupFormatVersion)+ }+ guard document.databaseSchemaVersion == BackupV2Document.schemaVersion else {+ throw BackupCodecError.invalidSchemaVersion(document.databaseSchemaVersion)+ }+ guard document.capabilityGate == capabilities.gate else {+ throw BackupCodecError.capabilityMismatch(+ expected: capabilities.gate,+ actual: document.capabilityGate+ )+ }+ try V2LibraryValidator.validate(snapshot: document.payload, capabilities: capabilities)+ return document+ } catch let error as BackupCodecError {+ throw error+ } catch let error as BackupValidationError {+ throw error+ } catch {+ throw BackupCodecError.decodingFailed(reason: String(describing: error))+ }+ }++ private static func decodeDate(_ decoder: Decoder) throws -> Date {+ let container = try decoder.singleValueContainer()+ let value = try container.decode(String.self)+ guard let date = BackupV2DateFormatter.date(from: value) else {+ throw DecodingError.dataCorruptedError(+ in: container,+ debugDescription: "date must be RFC 3339 UTC with milliseconds"+ )+ }+ return date+ }+}++private enum BackupV2JSONWriter {+ static func document(+ snapshot: LibraryBackupSnapshot,+ metadata: BackupMetadata,+ capabilities: M2Capabilities+ ) -> [String: Any] {+ [+ "backupFormatVersion": BackupV2Document.formatVersion,+ "databaseSchemaVersion": metadata.databaseSchemaVersion,+ "appBuild": metadata.appBuild,+ "exportedAt": BackupV2DateFormatter.string(from: metadata.exportedAt),+ "capabilityGate": capabilities.gate.rawValue,+ "payload": [+ "entries": snapshot.entries.map(entry),+ "works": snapshot.works.map(work),+ "sites": snapshot.sites.map(site),+ "titlePatterns": snapshot.titlePatterns.map(pattern),+ ],+ ]+ }++ private static func entry(_ value: EntryRecord) -> [String: Any] {+ [+ "id": uuid(value.id),+ "captureTitle": value.captureTitle,+ "captureTitleSource": value.captureTitleSource.rawValue,+ "rawURL": value.rawURL,+ "canonicalURL": nullable(value.canonicalURL),+ "hostname": value.hostname,+ "entryIdentityKey": value.entryIdentityKey,+ "identityKeyVersion": value.identityKeyVersion,+ "chapterTitle": nullable(value.chapterTitle),+ "chapterTitleProvenance": provenance(value.chapterTitleProvenance),+ "note": value.note,+ "rating": nullable(value.rating?.rawValue),+ "firstCapturedAt": BackupV2DateFormatter.string(from: value.firstCapturedAt),+ "lastSharedAt": BackupV2DateFormatter.string(from: value.lastSharedAt),+ "modifiedAt": BackupV2DateFormatter.string(from: value.modifiedAt),+ "workID": nullable(value.workID.map(uuid)),+ "workAssignmentProvenance": provenance(value.workAssignmentProvenance),+ "intentionallyUnattached": value.intentionallyUnattached,+ ]+ }++ private static func work(_ value: WorkRecord) -> [String: Any] {+ [+ "id": uuid(value.id),+ "displayTitle": value.displayTitle,+ "lastParsedTitle": nullable(value.lastParsedTitle),+ "siteHostname": value.siteHostname,+ "urlIdentity": nullable(value.urlIdentity),+ "workURL": nullable(value.workURL),+ "genericNotes": value.genericNotes,+ "type": value.type.rawValue,+ "genreTags": value.genreTags,+ "titleProvenance": value.titleProvenance.rawValue,+ "createdAt": BackupV2DateFormatter.string(from: value.createdAt),+ "modifiedAt": BackupV2DateFormatter.string(from: value.modifiedAt),+ "entryIDs": value.entryIDs.map(uuid),+ ]+ }++ private static func site(_ value: SiteRecord) -> [String: Any] {+ [+ "hostname": value.hostname,+ "displayName": value.displayName,+ "mode": value.mode.rawValue,+ "patternIDs": value.patternIDs.map(uuid),+ "urlIdentityRule": nullable(value.urlIdentityRule.map(urlIdentityRule)),+ "junkSuffixRule": nullable(value.junkSuffixRule.map(junkSuffixRule)),+ ]+ }++ private static func pattern(_ value: TitlePatternRecord) -> [String: Any] {+ [+ "id": uuid(value.id),+ "version": value.version,+ "isActive": value.isActive,+ "createdAt": BackupV2DateFormatter.string(from: value.createdAt),+ "definition": definition(value.definition),+ "siteHostname": value.siteHostname,+ ]+ }++ private static func definition(_ value: PatternDefinition) -> [String: Any] {+ switch value {+ case .segment(let work, let ignored):+ [+ "segment": [+ "work": range(work),+ "ignored": ignored.map(position),+ ],+ ]+ case .phrase(let prefix, let separator, let suffix, let order):+ [+ "phrase": [+ "prefix": prefix,+ "separator": separator,+ "suffix": suffix,+ "order": order.rawValue,+ ],+ ]+ }+ }++ private static func range(_ value: SegmentRangeSpec) -> [String: Any] {+ ["origin": value.origin.rawValue, "offset": value.offset, "length": value.length]+ }++ private static func position(_ value: SegmentPositionSpec) -> [String: Any] {+ ["origin": value.origin.rawValue, "offset": value.offset]+ }++ private static func provenance(_ value: FieldProvenance) -> [String: Any] {+ [+ "kind": value.kind.rawValue,+ "patternID": nullable(value.patternID.map(uuid)),+ "patternVersion": nullable(value.patternVersion),+ ]+ }++ private static func urlIdentityRule(_ value: URLIdentityRule) -> [String: Any] {+ [+ "version": value.version,+ "component": value.component.rawValue,+ "origin": nullable(value.origin?.rawValue),+ "offset": nullable(value.offset),+ "queryName": nullable(value.queryName),+ ]+ }++ private static func junkSuffixRule(_ value: JunkSuffixRule) -> [String: Any] {+ ["version": value.version, "anchors": value.anchors.map(position)]+ }++ private static func uuid(_ value: UUID) -> String {+ value.uuidString.lowercased()+ }++ private static func nullable(_ value: Any?) -> Any {+ value ?? NSNull()+ }+}++private enum BackupV2DateFormatter {+ static func string(from date: Date) -> String {+ formatter().string(from: MillisecondInstant.quantize(date))+ }++ static func date(from value: String) -> Date? {+ formatter().date(from: value)+ }++ private static func formatter() -> ISO8601DateFormatter {+ let formatter = ISO8601DateFormatter()+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]+ formatter.timeZone = TimeZone(secondsFromGMT: 0)+ return formatter+ }+}++/// JSONDecoder accepts duplicate keys. Reject them before decoding so an+/// attacker cannot make validation and decoding observe different values.+private struct DuplicateJSONKeyValidator {+ private let bytes: [UInt8]+ private var index = 0++ static func validate(_ data: Data) throws {+ var parser = DuplicateJSONKeyValidator(bytes: Array(data))+ try parser.parseValue(path: "$")+ parser.skipWhitespace()+ guard parser.index == parser.bytes.count else {+ throw BackupCodecError.trailingBytes+ }+ }++ private mutating func parseValue(path: String) throws {+ skipWhitespace()+ guard let byte = current else {+ throw BackupCodecError.decodingFailed(reason: "unexpected end of JSON")+ }+ switch byte {+ case 0x7B: try parseObject(path: path)+ case 0x5B: try parseArray(path: path)+ case 0x22: _ = try parseString()+ case 0x74: try consume("true")+ case 0x66: try consume("false")+ case 0x6E: try consume("null")+ case 0x2D, 0x30...0x39: parseNumber()+ default: throw BackupCodecError.decodingFailed(reason: "unexpected JSON token at byte \(index)")+ }+ }++ private mutating func parseObject(path: String) throws {+ index += 1+ skipWhitespace()+ if consumeIf(0x7D) { return }+ var keys: Set<String> = []+ while true {+ skipWhitespace()+ let key = try parseString()+ guard keys.insert(key).inserted else {+ throw BackupCodecError.duplicateKey("\(path).\(key)")+ }+ skipWhitespace()+ try require(0x3A)+ try parseValue(path: "\(path).\(key)")+ skipWhitespace()+ if consumeIf(0x7D) { return }+ try require(0x2C)+ }+ }++ private mutating func parseArray(path: String) throws {+ index += 1+ skipWhitespace()+ if consumeIf(0x5D) { return }+ var element = 0+ while true {+ try parseValue(path: "\(path)[\(element)]")+ element += 1+ skipWhitespace()+ if consumeIf(0x5D) { return }+ try require(0x2C)+ }+ }++ private mutating func parseString() throws -> String {+ guard current == 0x22 else {+ throw BackupCodecError.decodingFailed(reason: "expected JSON string at byte \(index)")+ }+ let start = index+ index += 1+ var escaped = false+ while let byte = current {+ index += 1+ if escaped {+ escaped = false+ } else if byte == 0x5C {+ escaped = true+ } else if byte == 0x22 {+ let slice = Data(bytes[start..<index])+ do { return try JSONDecoder().decode(String.self, from: slice) }+ catch {+ throw BackupCodecError.decodingFailed(reason: "invalid JSON string at byte \(start)")+ }+ } else if byte < 0x20 {+ throw BackupCodecError.decodingFailed(reason: "unescaped control scalar in JSON string")+ }+ }+ throw BackupCodecError.decodingFailed(reason: "unterminated JSON string")+ }++ private mutating func parseNumber() {+ while let byte = current,+ byte == 0x2D || byte == 0x2B || byte == 0x2E ||+ byte == 0x45 || byte == 0x65 || (0x30...0x39).contains(byte) {+ index += 1+ }+ }++ private mutating func consume(_ literal: StaticString) throws {+ let expected = Array(String(describing: literal).utf8)+ guard index + expected.count <= bytes.count,+ Array(bytes[index..<(index + expected.count)]) == expected else {+ throw BackupCodecError.decodingFailed(reason: "invalid JSON literal at byte \(index)")+ }+ index += expected.count+ }++ private mutating func require(_ byte: UInt8) throws {+ skipWhitespace()+ guard consumeIf(byte) else {+ throw BackupCodecError.decodingFailed(reason: "missing JSON punctuation at byte \(index)")+ }+ }++ private mutating func consumeIf(_ byte: UInt8) -> Bool {+ guard current == byte else { return false }+ index += 1+ return true+ }++ private mutating func skipWhitespace() {+ while let byte = current, byte == 0x20 || byte == 0x09 || byte == 0x0A || byte == 0x0D {+ index += 1+ }+ }++ private var current: UInt8? {+ index < bytes.count ? bytes[index] : nil+ }+}++private enum BackupV2ShapeValidator {+ static func validate(_ value: Any) throws {+ let root = try object(value, path: "$")+ try keys(+ root,+ allowed: ["backupFormatVersion", "databaseSchemaVersion", "appBuild", "exportedAt", "capabilityGate", "payload"],+ required: ["backupFormatVersion", "databaseSchemaVersion", "appBuild", "exportedAt", "capabilityGate", "payload"],+ path: "$"+ )+ let payload = try object(root["payload"], path: "$.payload")+ try keys(payload, allowed: ["entries", "works", "sites", "titlePatterns"], required: ["entries", "works", "sites", "titlePatterns"], path: "$.payload")++ try array(payload["entries"], path: "$.payload.entries").enumerated().forEach { index, value in+ let path = "$.payload.entries[\(index)]"+ let record = try object(value, path: path)+ try keys(+ record,+ allowed: ["id", "captureTitle", "captureTitleSource", "rawURL", "canonicalURL", "hostname", "entryIdentityKey", "identityKeyVersion", "chapterTitle", "chapterTitleProvenance", "note", "rating", "firstCapturedAt", "lastSharedAt", "modifiedAt", "workID", "workAssignmentProvenance", "intentionallyUnattached"],+ required: ["id", "captureTitle", "captureTitleSource", "rawURL", "canonicalURL", "hostname", "entryIdentityKey", "identityKeyVersion", "chapterTitle", "chapterTitleProvenance", "note", "rating", "firstCapturedAt", "lastSharedAt", "modifiedAt", "workID", "workAssignmentProvenance", "intentionallyUnattached"],+ path: path+ )+ try validateProvenance(record["chapterTitleProvenance"], path: "\(path).chapterTitleProvenance")+ try validateProvenance(record["workAssignmentProvenance"], path: "\(path).workAssignmentProvenance")+ }++ try array(payload["works"], path: "$.payload.works").enumerated().forEach { index, value in+ let path = "$.payload.works[\(index)]"+ let record = try object(value, path: path)+ try keys(record, allowed: ["id", "displayTitle", "lastParsedTitle", "siteHostname", "urlIdentity", "workURL", "genericNotes", "type", "genreTags", "titleProvenance", "createdAt", "modifiedAt", "entryIDs"], required: ["id", "displayTitle", "lastParsedTitle", "siteHostname", "urlIdentity", "workURL", "genericNotes", "type", "genreTags", "titleProvenance", "createdAt", "modifiedAt", "entryIDs"], path: path)+ }++ try array(payload["sites"], path: "$.payload.sites").enumerated().forEach { index, value in+ let path = "$.payload.sites[\(index)]"+ let record = try object(value, path: path)+ try keys(record, allowed: ["hostname", "displayName", "mode", "patternIDs", "urlIdentityRule", "junkSuffixRule"], required: ["hostname", "displayName", "mode", "patternIDs", "urlIdentityRule", "junkSuffixRule"], path: path)+ if let urlIdentityRule = record["urlIdentityRule"], !(urlIdentityRule is NSNull) {+ let rulePath = "\(path).urlIdentityRule"+ let rule = try object(urlIdentityRule, path: rulePath)+ try keys(+ rule,+ allowed: ["version", "component", "origin", "offset", "queryName"],+ required: ["version", "component", "origin", "offset", "queryName"],+ path: rulePath+ )+ }+ if let junkSuffixRule = record["junkSuffixRule"], !(junkSuffixRule is NSNull) {+ let rulePath = "\(path).junkSuffixRule"+ let rule = try object(junkSuffixRule, path: rulePath)+ try keys(rule, allowed: ["version", "anchors"], required: ["version", "anchors"], path: rulePath)+ try array(rule["anchors"], path: "\(rulePath).anchors").enumerated().forEach { anchorIndex, anchor in+ try validatePosition(anchor, path: "\(rulePath).anchors[\(anchorIndex)]")+ }+ }+ }++ try array(payload["titlePatterns"], path: "$.payload.titlePatterns").enumerated().forEach { index, value in+ let path = "$.payload.titlePatterns[\(index)]"+ let record = try object(value, path: path)+ try keys(record, allowed: ["id", "version", "isActive", "createdAt", "definition", "siteHostname"], required: ["id", "version", "isActive", "createdAt", "definition", "siteHostname"], path: path)+ let definition = try object(record["definition"], path: "\(path).definition")+ try keys(definition, allowed: ["segment", "phrase"], required: [], path: "\(path).definition")+ guard definition.count == 1 else {+ throw BackupCodecError.invalidValue(key: "\(path).definition", reason: "exactly one tagged form is required")+ }+ if let segment = definition["segment"] {+ let arm = try object(segment, path: "\(path).definition.segment")+ try keys(arm, allowed: ["work", "ignored"], required: ["work", "ignored"], path: "\(path).definition.segment")+ try validateRange(+ arm["work"],+ path: "\(path).definition.segment.work"+ )+ try array(+ arm["ignored"],+ path: "\(path).definition.segment.ignored"+ ).enumerated().forEach { ignoredIndex, position in+ try validatePosition(+ position,+ path: "\(path).definition.segment.ignored[\(ignoredIndex)]"+ )+ }+ } else if let phrase = definition["phrase"] {+ let arm = try object(phrase, path: "\(path).definition.phrase")+ try keys(arm, allowed: ["prefix", "separator", "suffix", "order"], required: ["prefix", "separator", "suffix", "order"], path: "\(path).definition.phrase")+ }+ }+ }++ private static func validateRange(_ value: Any?, path: String) throws {+ let record = try object(value, path: path)+ try keys(+ record,+ allowed: ["origin", "offset", "length"],+ required: ["origin", "offset", "length"],+ path: path+ )+ }++ private static func validatePosition(_ value: Any?, path: String) throws {+ let record = try object(value, path: path)+ try keys(+ record,+ allowed: ["origin", "offset"],+ required: ["origin", "offset"],+ path: path+ )+ }++ private static func validateProvenance(_ value: Any?, path: String) throws {+ let record = try object(value, path: path)+ try keys(record, allowed: ["kind", "patternID", "patternVersion"], required: ["kind", "patternID", "patternVersion"], path: path)+ }++ private static func keys(+ _ object: [String: Any],+ allowed: Set<String>,+ required: Set<String>,+ path: String+ ) throws {+ if let unknown = Set(object.keys).subtracting(allowed).sorted().first {+ throw BackupCodecError.unknownKey("\(path).\(unknown)")+ }+ if let missing = required.subtracting(object.keys).sorted().first {+ throw BackupCodecError.missingKey("\(path).\(missing)")+ }+ }++ private static func object(_ value: Any?, path: String) throws -> [String: Any] {+ guard let value = value as? [String: Any] else {+ throw BackupCodecError.invalidValue(key: path, reason: "expected object")+ }+ return value+ }++ private static func array(_ value: Any?, path: String) throws -> [Any] {+ guard let value = value as? [Any] else {+ throw BackupCodecError.invalidValue(key: path, reason: "expected array")+ }+ return value+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV2Types.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV2Types.swiftnew file mode 100644index 0000000..6f66bac--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV2Types.swift@@ -0,0 +1,331 @@+import Foundation++// MARK: - V2 backup records++public struct EntryRecord: Codable, Equatable, Sendable {+ public let id: UUID+ public let captureTitle: String+ public let captureTitleSource: CaptureTitleSource+ public let rawURL: String+ public let canonicalURL: String?+ public let hostname: String+ public let entryIdentityKey: String+ public let identityKeyVersion: Int+ public let chapterTitle: String?+ public let chapterTitleProvenance: FieldProvenance+ public let note: String+ public let rating: Rating?+ public let firstCapturedAt: Date+ public let lastSharedAt: Date+ public let modifiedAt: Date+ public let workID: UUID?+ public let workAssignmentProvenance: FieldProvenance+ public let intentionallyUnattached: Bool++ public init(+ id: UUID,+ captureTitle: String,+ captureTitleSource: CaptureTitleSource,+ rawURL: String,+ canonicalURL: String?,+ hostname: String,+ entryIdentityKey: String,+ identityKeyVersion: Int,+ chapterTitle: String?,+ chapterTitleProvenance: FieldProvenance,+ note: String,+ rating: Rating?,+ firstCapturedAt: Date,+ lastSharedAt: Date,+ modifiedAt: Date,+ workID: UUID?,+ workAssignmentProvenance: FieldProvenance,+ intentionallyUnattached: Bool+ ) {+ self.id = id+ self.captureTitle = captureTitle+ self.captureTitleSource = captureTitleSource+ self.rawURL = rawURL+ self.canonicalURL = canonicalURL+ self.hostname = hostname+ self.entryIdentityKey = entryIdentityKey+ self.identityKeyVersion = identityKeyVersion+ self.chapterTitle = chapterTitle+ self.chapterTitleProvenance = chapterTitleProvenance+ self.note = note+ self.rating = rating+ self.firstCapturedAt = firstCapturedAt+ self.lastSharedAt = lastSharedAt+ self.modifiedAt = modifiedAt+ self.workID = workID+ self.workAssignmentProvenance = workAssignmentProvenance+ self.intentionallyUnattached = intentionallyUnattached+ }+}++public struct WorkRecord: Codable, Equatable, Sendable {+ public let id: UUID+ public let displayTitle: String+ public let lastParsedTitle: String?+ public let siteHostname: String+ public let urlIdentity: String?+ public let workURL: String?+ public let genericNotes: String+ public let type: WorkType+ public let genreTags: [String]+ public let titleProvenance: TitleProvenance+ public let createdAt: Date+ public let modifiedAt: Date+ public let entryIDs: [UUID]++ public init(+ id: UUID,+ displayTitle: String,+ lastParsedTitle: String?,+ siteHostname: String,+ urlIdentity: String?,+ workURL: String?,+ genericNotes: String,+ type: WorkType,+ genreTags: [String],+ titleProvenance: TitleProvenance,+ createdAt: Date,+ modifiedAt: Date,+ entryIDs: [UUID]+ ) {+ self.id = id+ self.displayTitle = displayTitle+ self.lastParsedTitle = lastParsedTitle+ self.siteHostname = siteHostname+ self.urlIdentity = urlIdentity+ self.workURL = workURL+ self.genericNotes = genericNotes+ self.type = type+ self.genreTags = genreTags+ self.titleProvenance = titleProvenance+ self.createdAt = createdAt+ self.modifiedAt = modifiedAt+ self.entryIDs = entryIDs+ }+}++public struct SiteRecord: Codable, Equatable, Sendable {+ public let hostname: String+ public let displayName: String+ public let mode: SiteMode+ public let patternIDs: [UUID]+ public let urlIdentityRule: URLIdentityRule?+ public let junkSuffixRule: JunkSuffixRule?++ public init(+ hostname: String,+ displayName: String,+ mode: SiteMode,+ patternIDs: [UUID],+ urlIdentityRule: URLIdentityRule?,+ junkSuffixRule: JunkSuffixRule?+ ) {+ self.hostname = hostname+ self.displayName = displayName+ self.mode = mode+ self.patternIDs = patternIDs+ self.urlIdentityRule = urlIdentityRule+ self.junkSuffixRule = junkSuffixRule+ }+}++public struct TitlePatternRecord: Codable, Equatable, Sendable {+ public let id: UUID+ public let version: Int+ public let isActive: Bool+ public let createdAt: Date+ public let definition: PatternDefinition+ public let siteHostname: String++ public init(+ id: UUID,+ version: Int,+ isActive: Bool,+ createdAt: Date,+ definition: PatternDefinition,+ siteHostname: String+ ) {+ self.id = id+ self.version = version+ self.isActive = isActive+ self.createdAt = createdAt+ self.definition = definition+ self.siteHostname = siteHostname+ }++ public init(+ id: UUID,+ version: Int,+ isActive: Bool,+ createdAt: Date,+ workAnchor: SegmentRangeSpec,+ junkAnchors: [SegmentPositionSpec],+ siteHostname: String+ ) {+ self.init(+ id: id,+ version: version,+ isActive: isActive,+ createdAt: createdAt,+ definition: .segment(work: workAnchor, ignored: junkAnchors),+ siteHostname: siteHostname+ )+ }++ public var workAnchor: SegmentRangeSpec? {+ guard case .segment(let work, _) = definition else { return nil }+ return work+ }++ public var junkAnchors: [SegmentPositionSpec]? {+ guard case .segment(_, let ignored) = definition else { return nil }+ return ignored+ }+}++public struct LibraryBackupSnapshot: Codable, Equatable, Sendable {+ public let entries: [EntryRecord]+ public let works: [WorkRecord]+ public let sites: [SiteRecord]+ public let titlePatterns: [TitlePatternRecord]++ public init(+ entries: [EntryRecord],+ works: [WorkRecord],+ sites: [SiteRecord],+ titlePatterns: [TitlePatternRecord]+ ) {+ self.entries = entries+ self.works = works+ self.sites = sites+ self.titlePatterns = titlePatterns+ }++ public static let empty = LibraryBackupSnapshot(entries: [], works: [], sites: [], titlePatterns: [])+}++// MARK: - Envelope++public struct BackupMetadata: Codable, Equatable, Sendable {+ public let appBuild: String+ public let databaseSchemaVersion: Int+ public let exportedAt: Date++ public init(appBuild: String, databaseSchemaVersion: Int, exportedAt: Date) {+ self.appBuild = appBuild+ self.databaseSchemaVersion = databaseSchemaVersion+ self.exportedAt = exportedAt+ }+}++public struct BackupV2Document: Codable, Equatable, Sendable {+ public static let formatVersion = 2+ public static let schemaVersion = 2++ public let backupFormatVersion: Int+ public let databaseSchemaVersion: Int+ public let appBuild: String+ public let exportedAt: Date+ public let capabilityGate: M2Capabilities.Gate+ public let payload: LibraryBackupSnapshot++ public init(+ metadata: BackupMetadata,+ capabilities: M2Capabilities,+ payload: LibraryBackupSnapshot+ ) {+ backupFormatVersion = Self.formatVersion+ databaseSchemaVersion = metadata.databaseSchemaVersion+ appBuild = metadata.appBuild+ exportedAt = metadata.exportedAt+ capabilityGate = capabilities.gate+ self.payload = payload+ }+}++public enum BackupCodecError: Error, Equatable, Sendable, CustomStringConvertible {+ case encodingFailed(reason: String)+ case decodingFailed(reason: String)+ case invalidFormatVersion(Int)+ case invalidSchemaVersion(Int)+ case capabilityMismatch(expected: M2Capabilities.Gate, actual: M2Capabilities.Gate)+ case unknownKey(String)+ case duplicateKey(String)+ case missingKey(String)+ case invalidValue(key: String, reason: String)+ case trailingBytes++ public var description: String {+ switch self {+ case .encodingFailed(let reason): "Backup V2 encoding failed: \(reason)"+ case .decodingFailed(let reason): "Backup V2 decoding failed: \(reason)"+ case .invalidFormatVersion(let value): "Unsupported backup format version: \(value)"+ case .invalidSchemaVersion(let value): "Unsupported database schema version: \(value)"+ case .capabilityMismatch(let expected, let actual):+ "Backup capability gate \(actual.rawValue) does not match \(expected.rawValue)"+ case .unknownKey(let key): "Unknown key in Backup V2: \(key)"+ case .duplicateKey(let key): "Duplicate key in Backup V2: \(key)"+ case .missingKey(let key): "Missing required Backup V2 key: \(key)"+ case .invalidValue(let key, let reason): "Invalid Backup V2 value for \(key): \(reason)"+ case .trailingBytes: "Trailing bytes after Backup V2 document"+ }+ }+}++public enum BackupValidationError: Error, Equatable, Sendable, CustomStringConvertible {+ case duplicateID(type: String, id: UUID)+ case duplicateHostname(String)+ case unresolvedReference(type: String, id: UUID, referencedType: String, referencedID: UUID)+ case unresolvedHostname(type: String, id: String, hostname: String)+ case unresolvedProvenance(type: String, id: UUID, patternID: UUID, patternVersion: Int)+ case invalidActivePatternCount(hostname: String, count: Int)+ case invalidPatternDefinition(id: UUID, reason: String)+ case invalidStateTuple(type: String, id: String, reason: String)+ case snapshotMismatch(reason: String)++ public var description: String {+ switch self {+ case .duplicateID(let type, let id): "Duplicate \(type) ID: \(id)"+ case .duplicateHostname(let hostname): "Duplicate Site hostname: \(hostname)"+ case .unresolvedReference(let type, let id, let referencedType, let referencedID):+ "\(type) \(id) references missing \(referencedType) \(referencedID)"+ case .unresolvedHostname(let type, let id, let hostname):+ "\(type) \(id) references missing Site hostname \(hostname)"+ case .unresolvedProvenance(let type, let id, let patternID, let patternVersion):+ "\(type) \(id) references missing pattern \(patternID) version \(patternVersion)"+ case .invalidActivePatternCount(let hostname, let count):+ "Site \(hostname) has \(count) active patterns"+ case .invalidPatternDefinition(let id, let reason):+ "TitlePattern \(id) has an invalid definition: \(reason)"+ case .invalidStateTuple(let type, let id, let reason):+ "\(type) \(id) has an invalid V2 state: \(reason)"+ case .snapshotMismatch(let reason): "Backup V2 snapshot mismatch: \(reason)"+ }+ }+}++public enum BackupV2Codec {+ public static func encode(+ snapshot: LibraryBackupSnapshot,+ metadata: BackupMetadata,+ capabilities: M2Capabilities+ ) throws -> Data {+ try BackupV2CodecImplementation.encode(+ snapshot: snapshot,+ metadata: metadata,+ capabilities: capabilities+ )+ }++ public static func decode(+ _ data: Data,+ capabilities: M2Capabilities+ ) throws -> BackupV2Document {+ try BackupV2CodecImplementation.decode(data, capabilities: capabilities)+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift b/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swiftindex 127c669..e993e2b 100644--- a/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift@@ -3,12 +3,13 @@ import Foundation // MARK: - Capture state machine for the extension UI /// View model state for the capture sheet.-public enum CaptureViewState: Sendable {+/// Carries CaptureOutcome for projection display and preserves CapturePreparation + CaptureDraft.+public enum CaptureViewState: Sendable, Equatable { case loading case invalidInput(message: String)- case ready(preparation: CapturePreparation, draft: CaptureDraft)+ case ready(preparation: CapturePreparation, draft: CaptureDraft, outcome: CaptureOutcome?, reviewMessage: String?) case saving(draft: CaptureDraft)- case saveFailed(preparation: CapturePreparation, draft: CaptureDraft, message: String)+ case saveFailed(preparation: CapturePreparation, draft: CaptureDraft, outcome: CaptureOutcome?, message: String) case saved(entryID: UUID) case cancelled @@ -18,23 +19,44 @@ public enum CaptureViewState: Sendable { } } -/// Manages capture sheet state: title resolution, draft editing, save lifecycle,+/// Error types surfaced by the capture view model.+public enum CaptureViewModelError: Error, Sendable, Equatable {+ case projectionFailed(String)+ case commitFailed(String)+ case invalidState(String)+}++/// Manages capture sheet state: projection-based title resolution, draft editing,+/// generation-safe reprojection, contract-based save lifecycle, /// duplicate-save suppression, draft retention after failure, and cancellation. @MainActor public final class CaptureViewModel { public private(set) var state: CaptureViewState = .loading private var preparation: CapturePreparation? private var note: String = ""- private var rating: Rating? = nil+ private var rating: Rating? private var manualTitle: String = "" - /// Whether save is currently possible (ready or failed states, with valid title only).+ /// The approved projection contract retained internally for commit.+ private var currentContract: CaptureContract?++ /// Generation counter for projection safety: incremented on every draft change.+ private var generation: Int = 0++ /// The generation at which the current projection was produced.+ private var projectionGeneration: Int = -1++ /// Whether save is currently possible (ready or failed states, with valid title and projection). public var canSave: Bool { switch state {- case .ready(_, let draft):+ case .ready(_, let draft, let outcome, _): return !draft.captureTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty- case .saveFailed(_, let draft, _):+ && outcome != nil+ && currentContract != nil+ case .saveFailed(_, let draft, let outcome, _): return !draft.captureTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty+ && outcome != nil+ && currentContract != nil default: return false } }@@ -42,13 +64,22 @@ public final class CaptureViewModel { /// The current draft being edited, if in a state that has one. public var currentDraft: CaptureDraft? { switch state {- case .ready(_, let draft): return draft- case .saveFailed(_, let draft, _): return draft+ case .ready(_, let draft, _, _): return draft+ case .saveFailed(_, let draft, _, _): return draft case .saving(let draft): return draft default: return nil } } + /// The displayed outcome, if projected.+ public var currentOutcome: CaptureOutcome? {+ switch state {+ case .ready(_, _, let outcome, _): return outcome+ case .saveFailed(_, _, let outcome, _): return outcome+ default: return nil+ }+ }+ public init() {} // MARK: - Lifecycle@@ -59,14 +90,43 @@ public final class CaptureViewModel { } /// Load and prepare a payload, transitioning from loading → ready or invalidInput.- public func load(payload: SharePayload, coordinator: CaptureCoordinator) async {+ /// If title is nonblank, projects the capture and publishes the outcome before Save.+ public func load(payload: SharePayload, coordinator: any CaptureCoordinating) async { state = .loading do { let prep = try await coordinator.prepare(payload) preparation = prep let draft = buildDraft(preparation: prep)- state = .ready(preparation: prep, draft: draft)++ if !draft.captureTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,+ let hostname = Self.hostname(from: prep.rawURL) {+ // Project and publish with outcome+ generation += 1+ let gen = generation+ do {+ let contract = try await coordinator.projectCapture(+ hostname: hostname,+ captureTitle: draft.captureTitle,+ captureTitleSource: draft.captureTitleSource,+ rawURLString: draft.rawURLString,+ canonicalURLString: draft.canonicalURLString,+ note: draft.note,+ rating: draft.rating+ )+ guard gen == generation else { return }+ currentContract = contract+ projectionGeneration = gen+ state = .ready(preparation: prep, draft: draft, outcome: contract.outcome, reviewMessage: nil)+ } catch {+ guard gen == generation else { return }+ // Projection failed — ready without outcome, save disabled+ state = .ready(preparation: prep, draft: draft, outcome: nil, reviewMessage: nil)+ }+ } else {+ // Blank title → ready without projection, canSave false+ state = .ready(preparation: prep, draft: draft, outcome: nil, reviewMessage: nil)+ } } catch let error as CapturePreparationError { state = .invalidInput(message: error.userMessage) } catch {@@ -74,32 +134,36 @@ public final class CaptureViewModel { } } - // MARK: - Draft editing+ // MARK: - Draft editing (generation-safe reprojection) - public func setNote(_ value: String) {+ /// Update note and trigger reprojection.+ public func setNote(_ value: String, coordinator: any CaptureCoordinating) async { note = value- rebuildDraft()+ await reprojectIfNeeded(coordinator: coordinator) } - /// Toggle rating: same value clears, different value replaces, nil clears.- public func setRating(_ value: Rating?) {+ /// Toggle rating: same value clears, different value replaces.+ public func setRating(_ value: Rating?, coordinator: any CaptureCoordinating) async { if let value = value, rating == value { rating = nil } else { rating = value }- rebuildDraft()+ await reprojectIfNeeded(coordinator: coordinator) } - public func setManualTitle(_ value: String) {+ /// Update manual title and trigger reprojection.+ public func setManualTitle(_ value: String, coordinator: any CaptureCoordinating) async { manualTitle = value- rebuildDraft()+ await reprojectIfNeeded(coordinator: coordinator) } // MARK: - Save - /// Save the current draft. Suppresses duplicate saves and retains draft on failure.- public func save(coordinator: CaptureCoordinator) async {+ /// Save the current draft. Uses the currently displayed contract.+ /// If no projection exists, projects first and remains in ready state.+ /// Handles refreshed, committed, and invalidated outcomes.+ public func save(coordinator: any CaptureCoordinating) async { guard let prep = preparation else { return } switch state { case .saved: return // Already saved — exactly-once@@ -110,21 +174,68 @@ public final class CaptureViewModel { let draft = buildDraft(preparation: prep) - // Enforce blank-title rejection: never save with blank title+ // Enforce blank-title rejection guard !draft.captureTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + // If no current projection, attempt to project and remain ready+ guard let contract = currentContract else {+ guard let hostname = Self.hostname(from: prep.rawURL) else { return }+ generation += 1+ let gen = generation+ do {+ let newContract = try await coordinator.projectCapture(+ hostname: hostname,+ captureTitle: draft.captureTitle,+ captureTitleSource: draft.captureTitleSource,+ rawURLString: draft.rawURLString,+ canonicalURLString: draft.canonicalURLString,+ note: draft.note,+ rating: draft.rating+ )+ guard gen == generation else { return }+ currentContract = newContract+ projectionGeneration = gen+ state = .ready(preparation: prep, draft: draft, outcome: newContract.outcome, reviewMessage: nil)+ } catch {+ guard gen == generation else { return }+ state = .saveFailed(preparation: prep, draft: draft, outcome: nil,+ message: "Unable to project capture. Please try again.")+ }+ return+ }+ state = .saving(draft: draft) do {- let entry = try await coordinator.save(draft)- // Repository write succeeded — transition to saved before host dismissal- state = .saved(entryID: entry.id)+ let commitOutcome = try await coordinator.commitCapture(contract)+ switch commitOutcome {+ case .committed(let entry):+ state = .saved(entryID: entry.id)++ case .refreshed(let freshContract):+ // Publish fresh outcome, preserve draft, do not dismiss+ currentContract = freshContract+ projectionGeneration = generation+ state = .ready(+ preparation: prep, draft: draft,+ outcome: freshContract.outcome,+ reviewMessage: "Review changes and Save again"+ )++ case .invalidated(let reason):+ // Retain draft and any existing projection display+ state = .saveFailed(+ preparation: prep, draft: draft,+ outcome: contract.outcome,+ message: "Cannot save: \(reason)"+ )+ } } catch {- // Retain draft for retry; show non-sensitive error category+ // Network/persistence failure — retain draft for retry state = .saveFailed(- preparation: prep,- draft: draft,+ preparation: prep, draft: draft,+ outcome: contract.outcome, message: "Unable to save capture. Please try again." ) }@@ -135,23 +246,76 @@ public final class CaptureViewModel { /// Cancel the capture flow. No-op after save has succeeded. public func cancel() { switch state {- case .saved, .saving: break // Can't cancel after save/during save+ case .saved, .saving: break default: state = .cancelled } } // MARK: - Private - private func rebuildDraft() {+ /// Rebuild the draft from current fields and reprojects if title is nonblank.+ private func reprojectIfNeeded(coordinator: any CaptureCoordinating) async { guard let prep = preparation else { return } let draft = buildDraft(preparation: prep)++ // Publish the immediate draft change (outcome stays as-is until reprojection returns) switch state {- case .ready:- state = .ready(preparation: prep, draft: draft)- case .saveFailed(_, _, let message):- state = .saveFailed(preparation: prep, draft: draft, message: message)+ case .ready(_, _, let existingOutcome, let reviewMessage):+ state = .ready(preparation: prep, draft: draft, outcome: existingOutcome, reviewMessage: reviewMessage)+ case .saveFailed(_, _, let existingOutcome, let message):+ state = .saveFailed(preparation: prep, draft: draft, outcome: existingOutcome, message: message) default: break }++ // If title is blank, clear projection+ guard !draft.captureTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {+ currentContract = nil+ projectionGeneration = -1+ switch state {+ case .ready(_, _, _, let msg):+ state = .ready(preparation: prep, draft: draft, outcome: nil, reviewMessage: msg)+ case .saveFailed(_, _, _, let msg):+ state = .saveFailed(preparation: prep, draft: draft, outcome: nil, message: msg)+ default: break+ }+ return+ }++ guard let hostname = Self.hostname(from: prep.rawURL) else { return }++ generation += 1+ let gen = generation++ do {+ let contract = try await coordinator.projectCapture(+ hostname: hostname,+ captureTitle: draft.captureTitle,+ captureTitleSource: draft.captureTitleSource,+ rawURLString: draft.rawURLString,+ canonicalURLString: draft.canonicalURLString,+ note: draft.note,+ rating: draft.rating+ )+ // Generation guard: older projection must never overwrite newer+ guard gen == generation else { return }+ currentContract = contract+ projectionGeneration = gen+ // Refresh state with new outcome+ switch state {+ case .ready(_, _, _, let msg):+ let currentDraft = buildDraft(preparation: prep)+ state = .ready(preparation: prep, draft: currentDraft, outcome: contract.outcome, reviewMessage: msg)+ case .saveFailed(_, _, _, let msg):+ let currentDraft = buildDraft(preparation: prep)+ state = .saveFailed(preparation: prep, draft: currentDraft, outcome: contract.outcome, message: msg)+ default: break+ }+ } catch {+ guard gen == generation else { return }+ // Projection failed — clear contract, keep current state intact minus outcome+ currentContract = nil+ projectionGeneration = -1+ } } private func buildDraft(preparation: CapturePreparation) -> CaptureDraft {@@ -172,4 +336,8 @@ public final class CaptureViewModel { rating: rating ) }++ private static func hostname(from urlString: String) -> String? {+ URLComponents(string: urlString)?.host+ } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift b/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swiftindex b18f2f4..fb42954 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift@@ -45,3 +45,13 @@ public enum URLRuleComponent: String, CaseIterable, Codable, Sendable { case pathSegment case queryItem }++public enum PatternForm: String, CaseIterable, Codable, Sendable {+ case segment+ case phrase+}++public enum FieldOrder: String, CaseIterable, Codable, Sendable {+ case chapterThenWork+ case workThenChapter+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swiftindex c0757c6..a39c24b 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift@@ -28,9 +28,10 @@ public enum LibraryEnvironment: String, CaseIterable, Sendable { /// Immutable paths and operational timeouts injected into the repository. public struct LibraryConfiguration: Sendable, Equatable {- public static let storeRelativePath = "Library/Application Support/Asterism.sqlite"+ public static let storeRelativePath = "Library/Application Support/AsterismV2.sqlite"+ public static let legacyStoreRelativePath = "Library/Application Support/Asterism.sqlite" public static let lockFilename = "Asterism.lock"- public static let markerFilename = "Asterism.initialized"+ public static let markerFilename = "AsterismV2.ready" public let rootDirectory: URL public let environment: LibraryEnvironment@@ -44,6 +45,10 @@ public struct LibraryConfiguration: Sendable, Equatable { rootDirectory.appending(path: Self.storeRelativePath) } + public var legacyStoreURL: URL {+ rootDirectory.appending(path: Self.legacyStoreRelativePath)+ }+ public var lockURL: URL { rootDirectory.appending(path: Self.lockFilename) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swiftindex dd44967..ce0f538 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift@@ -3,16 +3,58 @@ import Foundation /// Test seam for the app's view-model layer to consume repository operations /// without coupling directly to the actor or its persistence internals. public protocol LibraryProviding: Sendable {+ // MARK: - Read operations func recentEntries(calendar: Calendar) async throws -> [DatedEntryGroup]+ func recentPresentation(calendar: Calendar) async throws -> RecentPresentation func works() async throws -> WorksSnapshot func entry(id: UUID) async throws -> EntrySnapshot func work(id: UUID) async throws -> WorkSnapshot+ func workDestinations(for entryID: UUID) async throws -> [WorkSnapshot]+ func titlePattern(id: UUID) async throws -> TitlePatternSnapshot++ /// Coherent entry teaching detail: site mode, pattern summaries, settlements,+ /// available actions, and unresolved replay. Built in one locked context.+ func entryTeachingDetail(id: UUID) async throws -> EntryTeachingDetail++ // MARK: - Curation (non-teaching writes) func updateEntry(id: UUID, note: String, rating: Rating?) async throws func deleteEntry(id: UUID) async throws func createWork(_ draft: NewWorkDraft) async throws -> WorkSnapshot func updateWork(id: UUID, draft: WorkMetadataDraft) async throws func moveEntry(_ entryID: UUID, to destination: WorkDestination) async throws- func workDestinations(for entryID: UUID) async throws -> [WorkSnapshot]++ // MARK: - Teaching: preview → approve → commit++ /// Project an initial teaching operation. Returns a contract for approval.+ func projectInitialTeaching(hostname: String, patternDefinition: PatternDefinition) async throws -> TeachingContract++ /// Project a replacement teaching operation. Returns a contract for approval.+ func projectReplacement(hostname: String, patternDefinition: PatternDefinition, boundaryEntryID: UUID, historicalPatternID: UUID?, historicalPatternVersion: Int?) async throws -> TeachingContract++ /// Commit an approved teaching contract. Returns `.committed` on success or+ /// `.refreshed(freshContract)` if any basis/outcome value changed.+ func commitTeaching(_ contract: TeachingContract) async throws -> TeachingCommitOutcome++ // MARK: - Articles mode: preview → approve → commit++ func projectArticles(hostname: String, junkSuffixRule: JunkSuffixRule?) async throws -> ArticlesContract+ func commitArticles(_ contract: ArticlesContract) async throws -> ArticlesCommitOutcome++ // MARK: - Re-parse: preview → approve → commit++ /// Project a single-entry Re-parse. Returns a contract for approval.+ func projectReparse(entryID: UUID) async throws -> ReparseContract++ /// Commit an approved Re-parse contract.+ func commitReparse(_ contract: ReparseContract) async throws -> ReparseCommitOutcome++ // MARK: - Capture: preview → approve → commit++ /// Project a capture for preview (no write).+ func projectCapture(hostname: String, captureTitle: String, captureTitleSource: CaptureTitleSource, rawURLString: String, canonicalURLString: String?, note: String, rating: Rating?) async throws -> CaptureContract++ /// Commit an approved capture contract.+ func commitCapture(_ contract: CaptureContract) async throws -> CaptureCommitOutcome } extension LibraryRepository: LibraryProviding {}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Articles.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Articles.swiftnew file mode 100644index 0000000..e58e3c2--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Articles.swift@@ -0,0 +1,141 @@+import Foundation+import OSLog+import SwiftData++private let articlesRepositoryLogger = Logger(+ subsystem: "me.nore.ig.Asterism",+ category: "ArticlesRepository"+)++extension LibraryRepository {+ public func projectArticles(+ hostname: String,+ junkSuffixRule: JunkSuffixRule?+ ) async throws -> ArticlesContract {+ guard capabilities.supportsArticles else {+ throw LibraryRepositoryError.invalidInput(+ operation: "projecting articles mode",+ reason: M2CapabilityError.articlesUnavailable(gate: capabilities.gate).description+ )+ }+ if let junkSuffixRule {+ do { try ArticleTitleCleaner.validate(junkSuffixRule) }+ catch {+ throw LibraryRepositoryError.invalidInput(+ operation: "projecting articles mode",+ reason: String(describing: error)+ )+ }+ }++ return try await withLockedContext(mode: .shared, operation: "projecting articles mode") { context in+ let basis = try self.buildTeachingBasis(hostname: hostname, context: context)+ guard basis.siteMode != .articles else {+ throw LibraryRepositoryError.invalidInput(+ operation: "projecting articles mode",+ reason: "Site is already in one-way articles mode"+ )+ }+ let request = ArticlesRequest(junkSuffixRule: junkSuffixRule)+ return ArticlesContract(+ basis: basis,+ request: request,+ outcome: Self.computeArticlesOutcome(basis: basis, request: request)+ )+ }+ }++ public func commitArticles(_ contract: ArticlesContract) async throws -> ArticlesCommitOutcome {+ guard capabilities.supportsArticles else {+ throw LibraryRepositoryError.invalidInput(+ operation: "committing articles mode",+ reason: M2CapabilityError.articlesUnavailable(gate: capabilities.gate).description+ )+ }++ return try await withLockedContext(mode: .exclusive, operation: "committing articles mode") { context in+ let currentBasis = try self.buildTeachingBasis(+ hostname: contract.basis.hostname,+ context: context+ )+ let currentOutcome = Self.computeArticlesOutcome(+ basis: currentBasis,+ request: contract.request+ )+ if currentBasis != contract.basis || currentOutcome != contract.outcome {+ articlesRepositoryLogger.info("Articles projection refreshed after basis changed")+ return .refreshed(ArticlesContract(+ basis: currentBasis,+ request: contract.request,+ outcome: currentOutcome+ ))+ }++ let sites = try Self.fetchSites(hostname: contract.basis.hostname, context: context)+ guard let site = sites.first else {+ return .invalidated(reason: "The Site no longer exists.")+ }+ guard site.mode != .articles else {+ return .invalidated(reason: "The Site is already in one-way articles mode.")+ }++ for pattern in site.patternValues where pattern.isActive {+ pattern.isActive = false+ }+ site.mode = .articles+ site.junkSuffixRule = contract.request.junkSuffixRule++ let hostname = contract.basis.hostname+ let entryDescriptor = FetchDescriptor<Entry>(+ predicate: #Predicate { $0.hostname == hostname }+ )+ let entries = try context.fetch(entryDescriptor)+ let entriesByID = try Self.entriesByID(entries, operation: "committing articles mode")+ let timestamp = self.clock.now()++ for projection in contract.outcome.plan.entryProjections {+ guard let entry = entriesByID[projection.entryID] else {+ return .invalidated(reason: "An Entry in the approved preview no longer exists.")+ }++ if !projection.chapterProtected {+ entry.chapterTitle = nil+ entry.chapterTitleProvenanceRaw = FieldProvenanceKind.none.rawValue+ entry.chapterPatternID = nil+ entry.chapterPatternVersion = nil+ }+ if !projection.assignmentProtected {+ entry.work = nil+ entry.workAssignmentProvenanceRaw = FieldProvenanceKind.none.rawValue+ entry.workPatternID = nil+ entry.workPatternVersion = nil+ entry.intentionallyUnattached = true+ }+ entry.modifiedAt = timestamp+ }++ do { try self.saveStrategy.save(context) }+ catch {+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "atomically saving articles mode",+ reason: String(describing: error)+ )+ }++ articlesRepositoryLogger.debug(+ "Committed articles mode for \(contract.basis.hostname, privacy: .public)"+ )+ return .committed+ }+ }++ static func computeArticlesOutcome(+ basis: TeachingBasis,+ request: ArticlesRequest+ ) -> ArticlesOutcome {+ ArticlesOutcome(plan: TitleProjectionPlanner.planArticles(+ entries: entrySnapshots(from: basis.entries),+ junkSuffixRule: request.junkSuffixRule+ ))+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swiftnew file mode 100644index 0000000..16b0304--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Contracts.swift@@ -0,0 +1,365 @@+import Foundation+import SwiftData++// MARK: - Projection Contract APIs++extension LibraryRepository {++ // MARK: - Basis builders (shared)++ /// Build a TeachingBasis from a fresh context for the given hostname.+ /// Validates closed Site/pattern state: no duplicate Sites (handled by fetchSites),+ /// valid mode, mode-consistent patterns, positive site-unique versions, valid definitions.+ func buildTeachingBasis(hostname: String, context: ModelContext) throws -> TeachingBasis {+ let sites = try Self.fetchSites(hostname: hostname, context: context)+ guard let site = sites.first else {+ throw LibraryRepositoryError.invalidInput(+ operation: "building teaching basis",+ reason: "no Site exists for hostname '\(hostname)'"+ )+ }++ guard let siteMode = SiteMode(rawValue: site.modeRaw) else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building teaching basis",+ reason: "Site '\(hostname)' has invalid mode raw value '\(site.modeRaw)'"+ )+ }++ // Articles mode is not valid at this gate/path+ guard siteMode != .articles else {+ throw LibraryRepositoryError.invalidInput(+ operation: "building teaching basis",+ reason: "Site '\(hostname)' is in articles mode; teaching is unavailable"+ )+ }+ guard site.junkSuffixRule == nil else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building teaching basis",+ reason: "non-articles Site '\(hostname)' retains a junk suffix rule"+ )+ }++ let patterns = try site.patternValues.map { p -> PatternBasisEntry in+ let def = try p.definition+ try capabilities.validate(patternDefinition: def)+ guard p.version > 0 else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building teaching basis",+ reason: "pattern \(p.id) has non-positive version \(p.version)"+ )+ }+ return PatternBasisEntry(+ id: p.id,+ version: p.version,+ isActive: p.isActive,+ definition: def,+ createdAt: p.createdAt+ )+ }.sorted { a, b in+ if a.version != b.version { return a.version < b.version }+ return a.id.uuidString < b.id.uuidString+ }++ // Validate mode-consistent pattern state+ let activePatterns = patterns.filter(\.isActive)+ if siteMode == .untaught {+ guard patterns.isEmpty else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building teaching basis",+ reason: "untaught Site '\(hostname)' has \(patterns.count) pattern(s)"+ )+ }+ } else if siteMode == .taught {+ guard !patterns.isEmpty else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building teaching basis",+ reason: "taught Site '\(hostname)' has no retained patterns"+ )+ }+ guard activePatterns.count == 1 else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building teaching basis",+ reason: "taught Site '\(hostname)' has \(activePatterns.count) active patterns (expected 1)"+ )+ }+ }++ // Validate site-unique pattern versions+ let versionSet = Set(patterns.map(\.version))+ guard versionSet.count == patterns.count else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building teaching basis",+ reason: "Site '\(hostname)' has duplicate pattern versions"+ )+ }++ let entryDescriptor = FetchDescriptor<Entry>(+ predicate: #Predicate { $0.hostname == hostname }+ )+ let entries = try context.fetch(entryDescriptor).map { entry -> EntryBasisEntry in+ let snap = try Self.snapshot(entry)+ return EntryBasisEntry(+ id: snap.id, captureTitle: snap.captureTitle, hostname: snap.hostname,+ firstCapturedAt: snap.firstCapturedAt, chapterTitle: snap.chapterTitle,+ chapterTitleProvenance: snap.chapterTitleProvenance, workID: snap.workID,+ workAssignmentProvenance: snap.workAssignmentProvenance,+ intentionallyUnattached: snap.intentionallyUnattached,+ note: snap.note, rating: snap.rating+ )+ }.sorted { a, b in+ if a.firstCapturedAt != b.firstCapturedAt { return a.firstCapturedAt < b.firstCapturedAt }+ return a.id.uuidString < b.id.uuidString+ }++ let workDescriptor = FetchDescriptor<Work>(+ predicate: #Predicate { $0.siteHostname == hostname }+ )+ let works = try context.fetch(workDescriptor).map { work -> WorkBasisEntry in+ try Self.workBasisEntry(from: work, operation: "building teaching basis")+ }.sorted { $0.id.uuidString < $1.id.uuidString }++ return TeachingBasis(+ siteMode: siteMode, hostname: hostname,+ patterns: patterns, entries: entries, works: works+ )+ }++ /// Build work match candidates from basis works.+ internal static func workCandidates(from works: [WorkBasisEntry]) -> [WorkMatchCandidate] {+ works.map { WorkMatchCandidate(id: $0.id, lastParsedTitle: $0.lastParsedTitle, displayTitle: $0.displayTitle) }+ }++ /// Build entry snapshots from basis entries (for planner).+ internal static func entrySnapshots(from entries: [EntryBasisEntry]) -> [EntrySnapshot] {+ entries.map { e in+ EntrySnapshot(+ id: e.id, captureTitle: e.captureTitle, captureTitleSource: .safariDocument,+ rawURLString: "", canonicalURLString: nil, hostname: e.hostname,+ entryIdentityKey: "", identityKeyVersion: 1, chapterTitle: e.chapterTitle,+ chapterTitleProvenance: e.chapterTitleProvenance, note: e.note, rating: e.rating,+ firstCapturedAt: e.firstCapturedAt, lastSharedAt: e.firstCapturedAt,+ modifiedAt: e.firstCapturedAt, workID: e.workID,+ workAssignmentProvenance: e.workAssignmentProvenance,+ intentionallyUnattached: e.intentionallyUnattached+ )+ }+ }++ // MARK: - Teaching preview++ /// Project initial teaching. Pure read, no write.+ public func projectInitialTeaching(+ hostname: String,+ patternDefinition: PatternDefinition+ ) async throws -> TeachingContract {+ guard capabilities.supportsSegmentTeaching else {+ throw LibraryRepositoryError.invalidInput(+ operation: "projecting initial teaching",+ reason: "segment teaching is unavailable at gate \(capabilities.gate.rawValue)"+ )+ }+ try capabilities.validate(patternDefinition: patternDefinition)++ return try await withLockedContext(mode: .shared, operation: "projecting initial teaching") { context in+ let basis = try self.buildTeachingBasis(hostname: hostname, context: context)+ guard basis.siteMode == .untaught else {+ throw LibraryRepositoryError.invalidInput(+ operation: "initial teaching",+ reason: "Site is already taught or in articles mode"+ )+ }++ let request = TeachingRequest(operation: .initial, patternDefinition: patternDefinition)+ let outcome = try Self.computeTeachingOutcome(basis: basis, request: request)+ return TeachingContract(basis: basis, request: request, outcome: outcome)+ }+ }++ /// Project replacement teaching. Pure read, no write.+ public func projectReplacement(+ hostname: String,+ patternDefinition: PatternDefinition,+ boundaryEntryID: UUID,+ historicalPatternID: UUID? = nil,+ historicalPatternVersion: Int? = nil+ ) async throws -> TeachingContract {+ guard capabilities.supportsSegmentTeaching else {+ throw LibraryRepositoryError.invalidInput(+ operation: "projecting replacement teaching",+ reason: "segment teaching is unavailable at gate \(capabilities.gate.rawValue)"+ )+ }+ try capabilities.validate(patternDefinition: patternDefinition)++ return try await withLockedContext(mode: .shared, operation: "projecting replacement teaching") { context in+ let basis = try self.buildTeachingBasis(hostname: hostname, context: context)+ guard basis.siteMode == .taught else {+ throw LibraryRepositoryError.invalidInput(+ operation: "replacement teaching",+ reason: "Site is not in taught mode"+ )+ }++ let request = TeachingRequest(+ operation: .replacement(+ boundaryEntryID: boundaryEntryID,+ historicalPatternID: historicalPatternID,+ historicalPatternVersion: historicalPatternVersion+ ),+ patternDefinition: patternDefinition+ )+ let outcome = try Self.computeTeachingOutcome(basis: basis, request: request)+ return TeachingContract(basis: basis, request: request, outcome: outcome)+ }+ }++ // MARK: - Teaching commit++ /// Commit an approved teaching contract. Acquires exclusive lock, refetches, rebuilds, compares.+ public func commitTeaching(_ contract: TeachingContract) async throws -> TeachingCommitOutcome {+ guard capabilities.supportsSegmentTeaching else {+ throw LibraryRepositoryError.invalidInput(+ operation: "committing teaching",+ reason: "segment teaching is unavailable at gate \(capabilities.gate.rawValue)"+ )+ }++ return try await withLockedContext(mode: .exclusive, operation: "committing teaching") { context in+ // 1. Refetch current basis+ let currentBasis = try self.buildTeachingBasis(hostname: contract.basis.hostname, context: context)++ // 2. Rebuild outcome from current basis + same request+ let currentOutcome: TeachingOutcome+ do {+ currentOutcome = try Self.computeTeachingOutcome(basis: currentBasis, request: contract.request)+ } catch {+ // The request can no longer be projected (e.g. boundary entry deleted)+ return .invalidated(reason: String(describing: error))+ }++ // 3. Compare basis AND outcome+ if currentBasis != contract.basis || currentOutcome != contract.outcome {+ let freshContract = TeachingContract(basis: currentBasis, request: contract.request, outcome: currentOutcome)+ return .refreshed(freshContract)+ }++ // 4. Equality confirmed — allocate IDs and time, apply, save+ let timestamp = self.clock.now()+ let patternID = UUID()+ let patternVersion = contract.outcome.computedPatternVersion++ // Validate the approved version is positive (overflow detected in computeTeachingOutcome)+ guard patternVersion > 0 else {+ throw LibraryRepositoryError.invalidInput(+ operation: "teaching commit",+ reason: "approved pattern version must be positive, got \(patternVersion)"+ )+ }++ // Validate exactly one active pattern after commit+ let hostname = contract.basis.hostname+ let sites = try Self.fetchSites(hostname: hostname, context: context)+ guard let site = sites.first else {+ throw LibraryRepositoryError.invalidInput(+ operation: "teaching commit",+ reason: "no Site exists for hostname '\(hostname)'"+ )+ }++ // For replacement: deactivate old active+ if case .replacement = contract.request.operation {+ for p in site.patternValues where p.isActive {+ p.isActive = false+ }+ }++ // Mark site taught+ site.mode = .taught++ // Create the new pattern+ let pattern = try TitlePattern(+ id: patternID,+ version: patternVersion,+ isActive: true,+ createdAt: timestamp,+ definition: contract.request.patternDefinition,+ site: site+ )+ context.insert(pattern)++ let entryDescriptor = FetchDescriptor<Entry>(+ predicate: #Predicate { $0.hostname == hostname }+ )+ let workDescriptor = FetchDescriptor<Work>(+ predicate: #Predicate { $0.siteHostname == hostname }+ )+ let allEntries = try context.fetch(entryDescriptor)+ let allWorks = try context.fetch(workDescriptor)+ let entriesByID = try Self.entriesByID(allEntries, operation: "committing teaching")+ let worksByID = try Self.worksByID(allWorks, operation: "committing teaching")++ let plan = contract.outcome.plan+ let eligibleCreatedTitles = Set(plan.entryProjections.compactMap { projection -> String? in+ guard !projection.assignmentProtected,+ let outcome = projection.workOutcome,+ case .create(let title) = outcome else {+ return nil+ }+ return title+ })++ // Create Works+ var createdWorks: [String: Work] = [:]+ for title in plan.worksToCreate where eligibleCreatedTitles.contains(title) {+ let work = Work(id: UUID(), displayTitle: title, siteHostname: hostname, timestamp: timestamp)+ work.lastParsedTitle = title+ work.titleProvenanceRaw = TitleProvenance.parsed.rawValue+ context.insert(work)+ createdWorks[title] = work+ }++ // Apply projections with constant-time model lookup.+ for projection in plan.entryProjections {+ guard let entry = entriesByID[projection.entryID] else { continue }+ Self.applyProjection(+ projection, to: entry,+ patternID: patternID, patternVersion: patternVersion,+ createdWorks: createdWorks,+ existingWorks: worksByID,+ timestamp: timestamp+ )+ }++ var reusedTitlesByID: [UUID: String] = [:]+ for projection in plan.entryProjections {+ guard let workID = projection.projectedWorkID,+ plan.worksToReuse.contains(workID),+ let parsedTitle = projection.projectedWorkTitle else {+ continue+ }+ reusedTitlesByID[workID] = parsedTitle+ }++ // Update reused Works metadata.+ for (workID, parsedTitle) in reusedTitlesByID {+ guard let work = worksByID[workID] else { continue }+ work.lastParsedTitle = parsedTitle+ if work.titleProvenance == .parsed {+ work.displayTitle = parsedTitle+ }+ work.modifiedAt = timestamp+ }++ do { try self.saveStrategy.save(context) }+ catch {+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "atomically saving teaching",+ reason: String(describing: error)+ )+ }++ return .committed(patternID: patternID, patternVersion: patternVersion)+ }+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swiftnew file mode 100644index 0000000..2ff929f--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift@@ -0,0 +1,190 @@+import Foundation+import SwiftData++// MARK: - Entry Teaching Detail (coherent repository-owned derivation)++extension LibraryRepository {++ /// Builds an `EntryTeachingDetail` in a single locked context.+ /// Fetches Entry + Site + retained patterns + assigned Work as needed.+ /// Returns exact Site mode, immutable capture evidence, active and historical+ /// pattern summaries, independent field settlements, replayed unresolved+ /// candidate title, and available actions. No inference or random fallback.+ public func entryTeachingDetail(id: UUID) async throws -> EntryTeachingDetail {+ try await withLockedContext(mode: .shared, operation: "building entry teaching detail") { context in+ let entry = try Self.fetchEntry(id: id, context: context)+ let entrySnap = try Self.snapshot(entry)+ let hostname = entrySnap.hostname++ let sites = try Self.fetchSites(hostname: hostname, context: context)+ guard sites.count == 1, let site = sites.first else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "entry teaching detail",+ reason: "Entry Site must resolve to exactly one Site record"+ )+ }+ guard let siteMode = SiteMode(rawValue: site.modeRaw) else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "entry teaching detail",+ reason: "Site '\(hostname)' has invalid mode raw value '\(site.modeRaw)'"+ )+ }++ let allPatterns = site.patternValues+ let activePatterns = allPatterns.filter(\.isActive)+ switch siteMode {+ case .untaught where !allPatterns.isEmpty:+ throw LibraryRepositoryError.corruptLibrary(+ operation: "entry teaching detail",+ reason: "Untaught Site retains title patterns"+ )+ case .taught where allPatterns.isEmpty || activePatterns.count != 1:+ throw LibraryRepositoryError.corruptLibrary(+ operation: "entry teaching detail",+ reason: "Taught Site must retain exactly one active title pattern"+ )+ case .articles where !activePatterns.isEmpty:+ throw LibraryRepositoryError.corruptLibrary(+ operation: "entry teaching detail",+ reason: "Articles Site cannot have an active title pattern"+ )+ default:+ break+ }++ let historicalPatterns = allPatterns+ .filter { !$0.isActive }+ .sorted {+ if $0.version != $1.version { return $0.version < $1.version }+ return $0.id.uuidString < $1.id.uuidString+ }++ let activePatternSummary: PatternRuleSummary?+ if let active = activePatterns.first {+ let def = try active.definition+ activePatternSummary = PatternRuleSummary(+ id: active.id,+ version: active.version,+ form: def.form,+ isActive: true,+ plainLanguageRule: PatternSummaryFormatter.summary(for: def)+ )+ } else {+ activePatternSummary = nil+ }++ let historicalPatternSummaries: [PatternRuleSummary] = try historicalPatterns.map { pattern in+ let def = try pattern.definition+ return PatternRuleSummary(+ id: pattern.id,+ version: pattern.version,+ form: def.form,+ isActive: false,+ plainLanguageRule: PatternSummaryFormatter.summary(for: def)+ )+ }++ // Build independent field settlements+ let chapterSettlement = Self.buildFieldSettlement(+ provenance: entrySnap.chapterTitleProvenance,+ fieldName: "chapter",+ hasValue: entrySnap.chapterTitle != nil+ )++ let assignmentSettlement = Self.buildFieldSettlement(+ provenance: entrySnap.workAssignmentProvenance,+ fieldName: "assignment",+ hasValue: entrySnap.workID != nil,+ intentionallyUnattached: entrySnap.intentionallyUnattached+ )++ // Build available actions+ let availableActions = Self.computeAvailableActions(siteMode: siteMode)++ // An unresolved assignment is replayed with the exact retained pattern+ // referenced by assignment provenance, never whichever pattern is active now.+ let unresolvedCandidateTitle: String?+ if entrySnap.workID == nil,+ entrySnap.workAssignmentProvenance.kind == .pattern,+ !entrySnap.intentionallyUnattached {+ guard let patternID = entrySnap.workAssignmentProvenance.patternID,+ let patternVersion = entrySnap.workAssignmentProvenance.patternVersion,+ let producingPattern = allPatterns.first(where: {+ $0.id == patternID && $0.version == patternVersion+ }) else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "entry teaching detail replay",+ reason: "Unresolved assignment references a missing retained pattern"+ )+ }+ let parseResult = TitlePatternApplicator.apply(definition: try producingPattern.definition, to: entrySnap.captureTitle)+ switch parseResult {+ case .success(let parsed):+ unresolvedCandidateTitle = parsed.workTitle+ case .failure(let error):+ throw LibraryRepositoryError.corruptLibrary(+ operation: "entry teaching detail replay",+ reason: "Retained assignment pattern cannot reproduce its candidate title: \(error)"+ )+ }+ } else {+ unresolvedCandidateTitle = nil+ }++ return EntryTeachingDetail(+ entry: entrySnap,+ siteMode: siteMode,+ activePatternSummary: activePatternSummary,+ historicalPatternSummaries: historicalPatternSummaries,+ chapterSettlement: chapterSettlement,+ assignmentSettlement: assignmentSettlement,+ availableActions: availableActions,+ unresolvedCandidateTitle: unresolvedCandidateTitle,+ displayTitle: siteMode == .articles+ ? ArticleTitleCleaner.clean(entrySnap.captureTitle, using: site.junkSuffixRule)+ : entrySnap.captureTitle+ )+ }+ }++ // MARK: - Private helpers++ private static func buildFieldSettlement(+ provenance: FieldProvenance,+ fieldName: String,+ hasValue: Bool,+ intentionallyUnattached: Bool = false+ ) -> FieldSettlement {+ switch provenance.kind {+ case .pattern:+ guard let patternID = provenance.patternID,+ let patternVersion = provenance.patternVersion else {+ // FieldProvenance invariant guarantees these are non-nil for .pattern kind,+ // but we handle gracefully rather than force unwrapping.+ return .unsettled(reason: "Pattern \(fieldName) provenance missing identity (corrupt)")+ }+ return .patternSettled(patternID: patternID, version: patternVersion)+ case .manual:+ if intentionallyUnattached {+ return .manualProtected(reason: "Intentionally unattached (manual)")+ }+ return .manualProtected(reason: "Manually set by user")+ case .none:+ if intentionallyUnattached {+ return .manualProtected(reason: "Intentionally unattached")+ }+ return .unsettled(reason: "No \(fieldName) provenance; awaiting teaching or manual assignment")+ }+ }++ private static func computeAvailableActions(siteMode: SiteMode) -> [EntryDetailAction] {+ switch siteMode {+ case .untaught:+ return [.teach]+ case .taught:+ return [.reTeach, .reParse]+ case .articles:+ return []+ }+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+OutcomeComputation.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+OutcomeComputation.swiftnew file mode 100644index 0000000..4097481--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+OutcomeComputation.swift@@ -0,0 +1,149 @@+import Foundation++// MARK: - Pure outcome computation (deterministic, no side effects)++extension LibraryRepository {++ /// Compute the teaching outcome from a basis and request. Pure and deterministic.+ static func computeTeachingOutcome(basis: TeachingBasis, request: TeachingRequest) throws -> TeachingOutcome {+ let entrySnapshots = Self.entrySnapshots(from: basis.entries)+ let candidates = Self.workCandidates(from: basis.works)++ let computedVersion: Int+ let plan: ProjectionPlan++ switch request.operation {+ case .initial:+ computedVersion = 1+ plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: request.patternDefinition,+ entries: entrySnapshots,+ existingWorks: candidates,+ patternVersion: computedVersion+ )++ case .replacement(let boundaryEntryID, let historicalPatternID, let historicalPatternVersion):+ // Compute max version from basis patterns+ let maxVersion = basis.patterns.map(\.version).max() ?? 0+ let (newVersion, overflow) = maxVersion.addingReportingOverflow(1)+ guard !overflow, newVersion > 0 else {+ throw LibraryRepositoryError.invalidInput(+ operation: "replacement teaching",+ reason: "pattern version overflow"+ )+ }+ computedVersion = newVersion+ plan = try TitleProjectionPlanner.planReplacement(+ patternDefinition: request.patternDefinition,+ entries: entrySnapshots,+ existingWorks: candidates,+ patternVersion: computedVersion,+ boundaryEntryID: boundaryEntryID,+ historicalPatternID: historicalPatternID,+ historicalPatternVersion: historicalPatternVersion+ )+ }++ return TeachingOutcome(plan: plan, computedPatternVersion: computedVersion)+ }++ /// Compute the Re-parse outcome from a basis and request. Pure and deterministic.+ static func computeReparseOutcome(basis: ReparseBasis, request: ReparseRequest) throws -> ReparseOutcome {+ guard let activePattern = basis.activePattern else {+ throw LibraryRepositoryError.invalidInput(+ operation: "Re-parse projection",+ reason: "no active pattern in basis"+ )+ }++ let entrySnapshot = EntrySnapshot(+ id: basis.entry.id, captureTitle: basis.entry.captureTitle,+ captureTitleSource: .safariDocument, rawURLString: "",+ canonicalURLString: nil, hostname: basis.entry.hostname,+ entryIdentityKey: "", identityKeyVersion: 1,+ chapterTitle: basis.entry.chapterTitle,+ chapterTitleProvenance: basis.entry.chapterTitleProvenance,+ note: basis.entry.note, rating: basis.entry.rating,+ firstCapturedAt: basis.entry.firstCapturedAt,+ lastSharedAt: basis.entry.firstCapturedAt,+ modifiedAt: basis.entry.firstCapturedAt,+ workID: basis.entry.workID,+ workAssignmentProvenance: basis.entry.workAssignmentProvenance,+ intentionallyUnattached: basis.entry.intentionallyUnattached+ )++ let candidates = Self.workCandidates(from: basis.works)++ let plan = try TitleProjectionPlanner.planReparse(+ patternDefinition: activePattern.definition,+ entry: entrySnapshot,+ existingWorks: candidates,+ patternVersion: activePattern.version+ )++ return ReparseOutcome(plan: plan)+ }++ /// Compute the capture outcome from a basis and request. Pure and deterministic.+ static func computeCaptureOutcome(basis: CaptureBasis, request: CaptureRequest) -> CaptureOutcome {+ if basis.siteMode == .articles {+ return CaptureOutcome(+ projectedChapter: nil,+ projectedWorkTitle: nil,+ workOutcome: nil,+ projectedWorkID: nil,+ actionable: false,+ intentionallyUnattached: true,+ displayTitle: ArticleTitleCleaner.clean(+ request.captureTitle,+ using: basis.junkSuffixRule+ )+ )+ }++ guard basis.siteMode == .taught, let activePattern = basis.activePattern else {+ // Untaught or no pattern: actionable capture+ return CaptureOutcome(+ projectedChapter: nil, projectedWorkTitle: nil,+ workOutcome: nil, projectedWorkID: nil,+ actionable: true, intentionallyUnattached: false+ )+ }++ let candidates = Self.workCandidates(from: basis.works)+ let parseResult = TitlePatternApplicator.apply(definition: activePattern.definition, to: request.captureTitle)++ switch parseResult {+ case .success(let parsed):+ let matchResult = WorkMatcher.match(parsedWorkTitle: parsed.workTitle, candidates: candidates)+ let projectedWorkID: UUID?+ let actionable: Bool+ switch matchResult {+ case .reuse(let id):+ projectedWorkID = id+ actionable = false+ case .create:+ projectedWorkID = nil+ actionable = false+ case .ambiguous:+ projectedWorkID = nil+ actionable = true+ }+ return CaptureOutcome(+ projectedChapter: parsed.chapterTitle,+ projectedWorkTitle: parsed.workTitle,+ workOutcome: matchResult,+ projectedWorkID: projectedWorkID,+ actionable: actionable,+ intentionallyUnattached: false+ )++ case .failure:+ return CaptureOutcome(+ projectedChapter: nil, projectedWorkTitle: nil,+ workOutcome: nil, projectedWorkID: nil,+ actionable: true, intentionallyUnattached: false+ )+ }+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swiftnew file mode 100644index 0000000..39bb904--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecentPresentation.swift@@ -0,0 +1,260 @@+import Foundation+import OSLog+import SwiftData++private let recentPresentationLogger = Logger(+ subsystem: "me.nore.ig.Asterism",+ category: "RecentPresentation"+)+private let recentPerformanceSignposter = OSSignposter(+ subsystem: M2PerformanceSignposts.subsystem,+ category: M2PerformanceSignposts.category+)++extension LibraryRepository {+ /// Builds every value needed by Recent from one shared-lock snapshot.+ ///+ /// Keeping Site/pattern resolution here prevents the UI from inferring teaching+ /// state from field provenance, which is incomplete evidence for Site mode.+ public func recentPresentation(calendar: Calendar) async throws -> RecentPresentation {+ let signpostID = recentPerformanceSignposter.makeSignpostID()+ let signpostState = recentPerformanceSignposter.beginInterval(+ "RecentPublication",+ id: signpostID+ )+ defer {+ recentPerformanceSignposter.endInterval("RecentPublication", signpostState)+ }+ return try await withLockedContext(mode: .shared, operation: "building Recent presentation") { context in+ let entries = try context.fetch(FetchDescriptor<Entry>()).map(Self.snapshot)+ let works = try context.fetch(FetchDescriptor<Work>())+ let sites = try context.fetch(FetchDescriptor<Site>())++ let workTitles = try Self.recentWorkTitles(works)+ let sitesByHostname = try Self.recentSitesByHostname(sites)+ let siteModesByHostname = try sitesByHostname.mapValues(Self.validatedRecentSiteMode)+ let sortedEntries = entries.sorted(by: Self.recentEntryOrder)++ var rowsByDay: [(day: Date, rows: [RecentPresentationRow])] = []+ var actionableCount = 0++ for entry in sortedEntries {+ guard let site = sitesByHostname[entry.hostname],+ let mode = siteModesByHostname[entry.hostname] else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building Recent presentation",+ reason: "Entry '\(entry.id)' has no Site for hostname '\(entry.hostname)'"+ )+ }+ let workDisplayTitle: String?+ if let workID = entry.workID {+ guard let title = workTitles[workID] else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building Recent presentation",+ reason: "Entry '\(entry.id)' references missing Work '\(workID)'"+ )+ }+ workDisplayTitle = title+ } else {+ workDisplayTitle = nil+ }++ let actionable = Self.isRecentEntryActionable(entry, siteMode: mode)+ let unresolvedCandidateTitle = try Self.replayRecentCandidate(+ for: entry,+ retainedPatterns: site.patternValues+ )+ let actionType: RecentRowActionType = switch (mode, actionable) {+ case (.untaught, true): .teach+ case (.taught, true): .reteach+ default: .none+ }++ let displayCaptureTitle = mode == .articles+ ? ArticleTitleCleaner.clean(entry.captureTitle, using: site.junkSuffixRule)+ : entry.captureTitle+ let row = RecentPresentationRow(+ id: entry.id,+ entry: entry,+ captureTitle: displayCaptureTitle,+ hostname: entry.hostname,+ workDisplayTitle: workDisplayTitle,+ chapterTitle: entry.chapterTitle,+ unresolvedCandidateTitle: unresolvedCandidateTitle,+ siteMode: mode,+ isActionable: actionable,+ actionType: actionType,+ note: entry.note,+ rating: entry.rating,+ lastSharedAt: entry.lastSharedAt+ )+ if actionable { actionableCount += 1 }++ let day = calendar.startOfDay(for: entry.lastSharedAt)+ if let last = rowsByDay.indices.last, rowsByDay[last].day == day {+ rowsByDay[last].rows.append(row)+ } else {+ rowsByDay.append((day, [row]))+ }+ }++ recentPresentationLogger.debug(+ "Built Recent presentation with \(sortedEntries.count, privacy: .public) rows and \(actionableCount, privacy: .public) actionable"+ )+ return RecentPresentation(+ groups: rowsByDay.map { RecentPresentationGroup(day: $0.day, rows: $0.rows) },+ actionableCount: actionableCount+ )+ }+ }++ private static func recentWorkTitles(_ works: [Work]) throws -> [UUID: String] {+ var result: [UUID: String] = [:]+ for work in works {+ guard result[work.id] == nil else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building Recent presentation",+ reason: "duplicate Work UUID '\(work.id)'"+ )+ }+ guard !work.displayTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building Recent presentation",+ reason: "Work '\(work.id)' has a blank display title"+ )+ }+ result[work.id] = work.displayTitle+ }+ return result+ }++ private static func recentSitesByHostname(_ sites: [Site]) throws -> [String: Site] {+ var result: [String: Site] = [:]+ for site in sites {+ guard result[site.hostname] == nil else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building Recent presentation",+ reason: "duplicate Site hostname '\(site.hostname)'"+ )+ }+ result[site.hostname] = site+ }+ return result+ }++ private static func validatedRecentSiteMode(_ site: Site) throws -> SiteMode {+ guard let mode = SiteMode(rawValue: site.modeRaw) else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building Recent presentation",+ reason: "Site '\(site.hostname)' has invalid mode '\(site.modeRaw)'"+ )+ }++ let patterns = site.patternValues+ var versions: Set<Int> = []+ for pattern in patterns {+ guard pattern.version > 0, versions.insert(pattern.version).inserted else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building Recent presentation",+ reason: "Site '\(site.hostname)' has duplicate or non-positive pattern versions"+ )+ }+ do {+ _ = try pattern.definition.validated()+ } catch {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building Recent presentation",+ reason: "Site '\(site.hostname)' retains an invalid pattern: \(error)"+ )+ }+ }++ let activeCount = patterns.count(where: \.isActive)+ switch mode {+ case .untaught where !patterns.isEmpty || site.junkSuffixRule != nil:+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building Recent presentation",+ reason: "untaught Site '\(site.hostname)' retains title patterns or a junk suffix rule"+ )+ case .taught where patterns.isEmpty || activeCount != 1 || site.junkSuffixRule != nil:+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building Recent presentation",+ reason: "taught Site '\(site.hostname)' must retain exactly one active pattern and no junk suffix rule"+ )+ case .articles where activeCount != 0:+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building Recent presentation",+ reason: "articles Site '\(site.hostname)' cannot have an active title pattern"+ )+ case .articles:+ if let rule = site.junkSuffixRule {+ do { try ArticleTitleCleaner.validate(rule) }+ catch {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building Recent presentation",+ reason: "articles Site '\(site.hostname)' has invalid junk suffix rule: \(error)"+ )+ }+ }+ return mode+ default:+ return mode+ }+ }++ private static func isRecentEntryActionable(_ entry: EntrySnapshot, siteMode: SiteMode) -> Bool {+ guard siteMode != .articles else { return false }+ return ActionabilityEvaluator.isActionable(+ chapterTitle: entry.chapterTitle,+ chapterProvenance: entry.chapterTitleProvenance,+ workID: entry.workID,+ assignmentProvenance: entry.workAssignmentProvenance,+ intentionallyUnattached: entry.intentionallyUnattached+ )+ }++ private static func replayRecentCandidate(+ for entry: EntrySnapshot,+ retainedPatterns: [TitlePattern]+ ) throws -> String? {+ guard entry.workID == nil,+ entry.workAssignmentProvenance.kind == .pattern,+ !entry.intentionallyUnattached else {+ return nil+ }+ guard let patternID = entry.workAssignmentProvenance.patternID,+ let patternVersion = entry.workAssignmentProvenance.patternVersion,+ let pattern = retainedPatterns.first(where: {+ $0.id == patternID && $0.version == patternVersion+ }) else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "replaying Recent unresolved assignment",+ reason: "Entry '\(entry.id)' references a missing retained pattern"+ )+ }++ let definition: PatternDefinition+ do {+ definition = try pattern.definition+ } catch {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "replaying Recent unresolved assignment",+ reason: "Entry '\(entry.id)' references an invalid retained pattern: \(error)"+ )+ }+ switch TitlePatternApplicator.apply(definition: definition, to: entry.captureTitle) {+ case .success(let result):+ return result.workTitle+ case .failure(let error):+ throw LibraryRepositoryError.corruptLibrary(+ operation: "replaying Recent unresolved assignment",+ reason: "Entry '\(entry.id)' retained pattern cannot reproduce its candidate: \(error)"+ )+ }+ }++ private static func recentEntryOrder(_ left: EntrySnapshot, _ right: EntrySnapshot) -> Bool {+ if left.lastSharedAt != right.lastSharedAt { return left.lastSharedAt > right.lastSharedAt }+ return left.id.uuidString.lowercased() < right.id.uuidString.lowercased()+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swiftnew file mode 100644index 0000000..ab39a63--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ReparseCapture.swift@@ -0,0 +1,424 @@+import Foundation+import SwiftData++// MARK: - Re-parse Contract APIs++extension LibraryRepository {++ /// Project a single-entry Re-parse. Pure read, no write.+ public func projectReparse(entryID: UUID) async throws -> ReparseContract {+ try await withLockedContext(mode: .shared, operation: "projecting Re-parse") { context in+ let entry = try Self.fetchEntry(id: entryID, context: context)+ let hostname = entry.hostname++ let sites = try Self.fetchSites(hostname: hostname, context: context)+ guard let site = sites.first, site.mode == .taught else {+ throw LibraryRepositoryError.invalidInput(+ operation: "Re-parse",+ reason: "Site is not in taught mode"+ )+ }+ let activePatterns = site.patternValues.filter { $0.isActive }+ guard activePatterns.count == 1 else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "Re-parse basis",+ reason: "taught Site has \(activePatterns.count) active patterns (expected 1)"+ )+ }+ let activePattern = activePatterns[0]++ let siteMode: SiteMode = .taught+ let patternBasis = PatternBasisEntry(+ id: activePattern.id,+ version: activePattern.version,+ isActive: true,+ definition: try activePattern.definition,+ createdAt: activePattern.createdAt+ )++ let entrySnap = try Self.snapshot(entry)+ let entryBasis = EntryBasisEntry(+ id: entrySnap.id, captureTitle: entrySnap.captureTitle,+ hostname: entrySnap.hostname, firstCapturedAt: entrySnap.firstCapturedAt,+ chapterTitle: entrySnap.chapterTitle,+ chapterTitleProvenance: entrySnap.chapterTitleProvenance,+ workID: entrySnap.workID,+ workAssignmentProvenance: entrySnap.workAssignmentProvenance,+ intentionallyUnattached: entrySnap.intentionallyUnattached,+ note: entrySnap.note, rating: entrySnap.rating+ )++ let allWorks = try context.fetch(FetchDescriptor<Work>())+ .filter { $0.siteHostname == hostname }+ let worksBasis = try allWorks.map { work -> WorkBasisEntry in+ try Self.workBasisEntry(from: work, operation: "building Re-parse basis")+ }.sorted { $0.id.uuidString < $1.id.uuidString }++ let basis = ReparseBasis(+ siteMode: siteMode, hostname: hostname,+ activePattern: patternBasis, entry: entryBasis, works: worksBasis+ )+ let request = ReparseRequest(entryID: entryID)+ let outcome = try Self.computeReparseOutcome(basis: basis, request: request)++ return ReparseContract(basis: basis, request: request, outcome: outcome)+ }+ }++ /// Commit an approved Re-parse contract.+ public func commitReparse(_ contract: ReparseContract) async throws -> ReparseCommitOutcome {+ try await withLockedContext(mode: .exclusive, operation: "committing Re-parse") { context in+ // 1. Refetch current basis+ let entry = try Self.fetchEntry(id: contract.request.entryID, context: context)+ let hostname = entry.hostname++ let sites = try Self.fetchSites(hostname: hostname, context: context)+ guard let site = sites.first, site.mode == .taught else {+ throw LibraryRepositoryError.invalidInput(+ operation: "Re-parse commit",+ reason: "Site is not in taught mode"+ )+ }+ let activePatterns = site.patternValues.filter { $0.isActive }+ guard activePatterns.count == 1 else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "Re-parse commit",+ reason: "taught Site has \(activePatterns.count) active patterns (expected 1)"+ )+ }+ let activePattern = activePatterns[0]++ let patternBasis = PatternBasisEntry(+ id: activePattern.id, version: activePattern.version,+ isActive: true, definition: try activePattern.definition,+ createdAt: activePattern.createdAt+ )+ let entrySnap = try Self.snapshot(entry)+ let entryBasis = EntryBasisEntry(+ id: entrySnap.id, captureTitle: entrySnap.captureTitle,+ hostname: entrySnap.hostname, firstCapturedAt: entrySnap.firstCapturedAt,+ chapterTitle: entrySnap.chapterTitle,+ chapterTitleProvenance: entrySnap.chapterTitleProvenance,+ workID: entrySnap.workID,+ workAssignmentProvenance: entrySnap.workAssignmentProvenance,+ intentionallyUnattached: entrySnap.intentionallyUnattached,+ note: entrySnap.note, rating: entrySnap.rating+ )+ let allWorks = try context.fetch(FetchDescriptor<Work>())+ .filter { $0.siteHostname == hostname }+ let worksBasis = try allWorks.map { work -> WorkBasisEntry in+ try Self.workBasisEntry(from: work, operation: "rebuilding Re-parse basis at commit")+ }.sorted { $0.id.uuidString < $1.id.uuidString }++ let currentBasis = ReparseBasis(+ siteMode: .taught, hostname: hostname,+ activePattern: patternBasis, entry: entryBasis, works: worksBasis+ )++ // 2. Rebuild outcome+ let currentOutcome: ReparseOutcome+ do {+ currentOutcome = try Self.computeReparseOutcome(basis: currentBasis, request: contract.request)+ } catch {+ throw error+ }++ // 3. Compare+ if currentBasis != contract.basis || currentOutcome != contract.outcome {+ let freshContract = ReparseContract(basis: currentBasis, request: contract.request, outcome: currentOutcome)+ return .refreshed(freshContract)+ }++ // 4. Apply — allocate IDs/time AFTER equality+ let timestamp = self.clock.now()+ let patternID = activePattern.id+ let patternVersion = activePattern.version++ let plan = contract.outcome.plan+ guard let projection = plan.entryProjections.first else {+ throw LibraryRepositoryError.invalidInput(+ operation: "Re-parse commit", reason: "no projection produced"+ )+ }++ // Create Works if needed+ var createdWorks: [String: Work] = [:]+ for title in plan.worksToCreate {+ let hasEligibleConsumer = !projection.assignmentProtected+ if hasEligibleConsumer {+ let work = Work(id: UUID(), displayTitle: title, siteHostname: hostname, timestamp: timestamp)+ work.lastParsedTitle = title+ work.titleProvenanceRaw = TitleProvenance.parsed.rawValue+ context.insert(work)+ createdWorks[title] = work+ }+ }++ let worksByID = try Self.worksByID(allWorks, operation: "committing Re-parse")+ Self.applyProjection(+ projection, to: entry,+ patternID: patternID, patternVersion: patternVersion,+ createdWorks: createdWorks,+ existingWorks: worksByID,+ timestamp: timestamp+ )++ // Update reused Works+ for workID in plan.worksToReuse {+ guard let work = worksByID[workID] else { continue }+ if let parsedTitle = projection.projectedWorkTitle {+ work.lastParsedTitle = parsedTitle+ if work.titleProvenance == .parsed { work.displayTitle = parsedTitle }+ work.modifiedAt = timestamp+ }+ }++ do { try self.saveStrategy.save(context) }+ catch {+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "atomically saving Re-parse",+ reason: String(describing: error)+ )+ }++ return .committed+ }+ }+}++// MARK: - Capture Contract APIs++extension LibraryRepository {++ /// Project a capture for preview (no write). Returns a contract for approval.+ public func projectCapture(+ hostname: String,+ captureTitle: String,+ captureTitleSource: CaptureTitleSource,+ rawURLString: String,+ canonicalURLString: String?,+ note: String,+ rating: Rating?+ ) async throws -> CaptureContract {+ try await withLockedContext(mode: .shared, operation: "projecting capture") { context in+ let basis = try self.buildCaptureBasis(hostname: hostname, context: context)+ let request = CaptureRequest(+ captureTitle: captureTitle, captureTitleSource: captureTitleSource,+ rawURLString: rawURLString, canonicalURLString: canonicalURLString,+ note: note, rating: rating+ )+ let outcome = Self.computeCaptureOutcome(basis: basis, request: request)+ return CaptureContract(basis: basis, request: request, outcome: outcome)+ }+ }++ /// Commit an approved capture contract.+ public func commitCapture(_ contract: CaptureContract) async throws -> CaptureCommitOutcome {+ let validated = try Self.validateCapture(CaptureDraft(+ captureTitle: contract.request.captureTitle,+ captureTitleSource: contract.request.captureTitleSource,+ rawURLString: contract.request.rawURLString,+ canonicalURLString: contract.request.canonicalURLString,+ note: contract.request.note,+ rating: contract.request.rating+ ))++ return try await withLockedContext(mode: .exclusive, operation: "committing capture") { context in+ // 1. Build current basis WITHOUT mutating (no Site insertion yet)+ let currentBasis = try self.buildCaptureBasis(hostname: validated.hostname, context: context)++ // 2. Rebuild outcome+ let currentOutcome = Self.computeCaptureOutcome(basis: currentBasis, request: contract.request)++ // 3. Compare — if stale, return refreshed (zero writes)+ if currentBasis != contract.basis || currentOutcome != contract.outcome {+ let freshContract = CaptureContract(basis: currentBasis, request: contract.request, outcome: currentOutcome)+ return .refreshed(freshContract)+ }++ // 4. Equality confirmed — NOW insert Site if absent, then apply+ let timestamp = self.clock.now()++ let existingSites = try Self.fetchSites(hostname: validated.hostname, context: context)+ if existingSites.isEmpty {+ context.insert(Site(hostname: validated.hostname))+ }++ let entry = Entry(+ captureTitle: contract.request.captureTitle,+ captureTitleSource: contract.request.captureTitleSource,+ rawURLString: contract.request.rawURLString,+ canonicalURLString: contract.request.canonicalURLString,+ hostname: validated.hostname,+ entryIdentityKey: validated.identityKey,+ timestamp: timestamp,+ note: contract.request.note,+ rating: contract.request.rating+ )+ context.insert(entry)++ // Apply the approved Site-specific outcome.+ let site = try Self.fetchSites(hostname: validated.hostname, context: context).first+ if let site, site.mode == .articles {+ entry.intentionallyUnattached = true+ } else if let site, site.mode == .taught {+ let activePatterns = site.patternValues.filter { $0.isActive }+ guard activePatterns.count == 1 else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "capture commit",+ reason: "taught Site has \(activePatterns.count) active patterns (expected 1)"+ )+ }+ let activePattern = activePatterns[0]+ let patternDef = try activePattern.definition+ let patternVersion = activePattern.version+ let patternID = activePattern.id+ let allWorks = try context.fetch(FetchDescriptor<Work>())+ .filter { $0.siteHostname == validated.hostname }+ let workCandidates = allWorks.map {+ WorkMatchCandidate(id: $0.id, lastParsedTitle: $0.lastParsedTitle, displayTitle: $0.displayTitle)+ }++ let parseResult = TitlePatternApplicator.apply(definition: patternDef, to: contract.request.captureTitle)+ switch parseResult {+ case .success(let parsed):+ entry.chapterTitle = parsed.chapterTitle+ entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue+ entry.chapterPatternID = patternID+ entry.chapterPatternVersion = patternVersion++ let matchResult = WorkMatcher.match(parsedWorkTitle: parsed.workTitle, candidates: workCandidates)+ switch matchResult {+ case .reuse(let workID):+ if let work = allWorks.first(where: { $0.id == workID }) {+ entry.work = work+ work.lastParsedTitle = parsed.workTitle+ if work.titleProvenance == .parsed { work.displayTitle = parsed.workTitle }+ work.modifiedAt = timestamp+ }+ entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue+ entry.workPatternID = patternID+ entry.workPatternVersion = patternVersion++ case .create(let title):+ let work = Work(id: UUID(), displayTitle: title, siteHostname: validated.hostname, timestamp: timestamp)+ work.lastParsedTitle = title+ work.titleProvenanceRaw = TitleProvenance.parsed.rawValue+ context.insert(work)+ entry.work = work+ entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue+ entry.workPatternID = patternID+ entry.workPatternVersion = patternVersion++ case .ambiguous:+ entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue+ entry.workPatternID = patternID+ entry.workPatternVersion = patternVersion+ }++ case .failure:+ break // Save with none/none+ }+ }++ do { try self.saveStrategy.save(context) }+ catch {+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "atomically saving capture",+ reason: String(describing: error)+ )+ }++ return .committed(try Self.snapshot(entry))+ }+ }++ // MARK: - Capture basis builder++ private func buildCaptureBasis(hostname: String, context: ModelContext) throws -> CaptureBasis {+ let sites = try Self.fetchSites(hostname: hostname, context: context)+ let site = sites.first++ let siteMode: SiteMode+ if let site {+ guard let mode = SiteMode(rawValue: site.modeRaw) else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building capture basis",+ reason: "Site '\(hostname)' has invalid mode raw value '\(site.modeRaw)'"+ )+ }+ if mode == .articles {+ guard capabilities.supportsArticles else {+ throw LibraryRepositoryError.invalidInput(+ operation: "building capture basis",+ reason: "articles mode is unavailable at gate \(capabilities.gate.rawValue)"+ )+ }+ guard site.patternValues.allSatisfy({ !$0.isActive }) else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building capture basis",+ reason: "articles Site '\(hostname)' retains an active pattern"+ )+ }+ if let rule = site.junkSuffixRule {+ do { try ArticleTitleCleaner.validate(rule) }+ catch {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building capture basis",+ reason: "articles Site '\(hostname)' has invalid junk suffix rule: \(error)"+ )+ }+ }+ } else if site.junkSuffixRule != nil {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building capture basis",+ reason: "non-articles Site '\(hostname)' retains a junk suffix rule"+ )+ }+ siteMode = mode+ } else {+ siteMode = .untaught+ }++ let activePatternBasis: PatternBasisEntry?+ if let site, siteMode == .taught {+ let activePatterns = site.patternValues.filter { $0.isActive }+ guard activePatterns.count == 1 else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building capture basis",+ reason: "taught Site '\(hostname)' has \(activePatterns.count) active patterns (expected 1)"+ )+ }+ let activePattern = activePatterns[0]+ let def = try activePattern.definition+ try capabilities.validate(patternDefinition: def)+ guard activePattern.version > 0 else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "building capture basis",+ reason: "active pattern \(activePattern.id) has non-positive version"+ )+ }+ activePatternBasis = PatternBasisEntry(+ id: activePattern.id, version: activePattern.version,+ isActive: true, definition: def,+ createdAt: activePattern.createdAt+ )+ } else {+ activePatternBasis = nil+ }++ let workDescriptor = FetchDescriptor<Work>(+ predicate: #Predicate { $0.siteHostname == hostname }+ )+ let worksBasis = try context.fetch(workDescriptor).map { work -> WorkBasisEntry in+ try Self.workBasisEntry(from: work, operation: "building capture basis")+ }.sorted { $0.id.uuidString < $1.id.uuidString }++ return CaptureBasis(+ siteMode: siteMode, hostname: hostname,+ activePattern: activePatternBasis,+ junkSuffixRule: site?.junkSuffixRule,+ works: worksBasis+ )+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex c79563e..68cb078 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -48,101 +48,200 @@ public actor LibraryRepository { private let configuration: LibraryConfiguration private let container: ModelContainer- private let clock: any RepositoryClock- private let saveStrategy: any RepositorySaveStrategy+ internal let capabilities: M2Capabilities+ internal let clock: any RepositoryClock+ internal let saveStrategy: any RepositorySaveStrategy private init( configuration: LibraryConfiguration, container: ModelContainer,+ capabilities: M2Capabilities, clock: any RepositoryClock, saveStrategy: any RepositorySaveStrategy ) { self.configuration = configuration self.container = container+ self.capabilities = capabilities self.clock = clock self.saveStrategy = saveStrategy } + /// Source-compatible app-owned open. New runtime call sites should use the+ /// role-specific methods so extension code cannot accidentally create data. public static func open( _ configuration: LibraryConfiguration,+ capabilities: M2Capabilities = .current, clock: any RepositoryClock = SystemRepositoryClock(), saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy() ) async throws -> LibraryRepository {- logger.debug("Opening \(configuration.environment.rawValue, privacy: .public) library")- do {- try FileManager.default.createDirectory(at: configuration.rootDirectory, withIntermediateDirectories: true)- try FileManager.default.createDirectory(- at: configuration.storeURL.deletingLastPathComponent(),- withIntermediateDirectories: true+ try await openForApp(+ configuration,+ capabilities: capabilities,+ clock: clock,+ saveStrategy: saveStrategy+ )+ }++ public static func openForApp(+ _ configuration: LibraryConfiguration,+ capabilities: M2Capabilities = .current,+ clock: any RepositoryClock = SystemRepositoryClock(),+ saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()+ ) async throws -> LibraryRepository {+ try await openCurrent(+ configuration,+ role: .app,+ capabilities: capabilities,+ clock: clock,+ saveStrategy: saveStrategy+ )+ }++ public static func openForExtension(+ _ configuration: LibraryConfiguration,+ capabilities: M2Capabilities = .current,+ clock: any RepositoryClock = SystemRepositoryClock(),+ saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()+ ) async throws -> LibraryRepository {+ try await openCurrent(+ configuration,+ role: .extensionProcess,+ capabilities: capabilities,+ clock: clock,+ saveStrategy: saveStrategy+ )+ }++ private enum RuntimeRole {+ case app+ case extensionProcess+ }++ private static func openCurrent(+ _ configuration: LibraryConfiguration,+ role: RuntimeRole,+ capabilities: M2Capabilities,+ clock: any RepositoryClock,+ saveStrategy: any RepositorySaveStrategy+ ) async throws -> LibraryRepository {+ logger.debug("Opening current V2 library for \(role == .app ? "app" : "extension", privacy: .public)")++ let fileManager = FileManager.default+ if role == .app {+ do {+ try fileManager.createDirectory(at: configuration.rootDirectory, withIntermediateDirectories: true)+ try fileManager.createDirectory(+ at: configuration.storeURL.deletingLastPathComponent(),+ withIntermediateDirectories: true+ )+ } catch {+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "creating V2 library directories",+ reason: String(describing: error)+ )+ }+ }++ var markerExists = fileManager.fileExists(atPath: configuration.markerURL.path)+ let storeExists = fileManager.fileExists(atPath: configuration.storeURL.path)+ let legacyExists = fileManager.fileExists(atPath: configuration.legacyStoreURL.path)++ guard !(markerExists && !storeExists) else {+ logger.error("Refusing to replace a missing ready V2 library")+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "opening ready V2 library",+ reason: "readiness marker exists but the V2 store is missing" )- } catch {+ }++ if role == .extensionProcess {+ guard markerExists, storeExists else {+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "opening V2 library from extension",+ reason: "the containing app has not initialized the current library"+ )+ }+ } else if !storeExists, legacyExists {+ logger.error("Preserving legacy V1 store for explicit developer migration") throw LibraryRepositoryError.libraryUnavailable(- operation: "creating library directories",- reason: String(describing: error)+ operation: "creating V2 library",+ reason: "a legacy V1 store exists; run make migrate-m1-to-m2 first" ) } let lease = try await CrossProcessLibraryLock.acquire(- mode: .exclusive,+ mode: role == .app ? .exclusive : .shared, at: configuration.lockURL, timeout: bootstrapLockTimeout ) defer { withExtendedLifetime(lease) {} } - let markerExists = FileManager.default.fileExists(atPath: configuration.markerURL.path)- let storeExists = FileManager.default.fileExists(atPath: configuration.storeURL.path)- guard !(markerExists && !storeExists) else {- logger.error("Refusing to replace a missing established library")- throw LibraryRepositoryError.libraryUnavailable(- operation: "opening established library",- reason: "initialization marker exists but the store is missing"- )- }- let container: ModelContainer do {- let schema = Schema(versionedSchema: AsterismSchemaV1.self)+ let schema = Schema(versionedSchema: AsterismSchemaV2.self) let storeConfiguration = ModelConfiguration(- "AsterismV1",+ "AsterismV2", schema: schema, url: configuration.storeURL, cloudKitDatabase: .none ) container = try ModelContainer( for: schema,- migrationPlan: AsterismMigrationPlan.self,+ migrationPlan: AsterismV2MigrationPlan.self, configurations: [storeConfiguration] ) let context = ModelContext(container)- if !storeExists { try context.save() }- try validateStore(using: context)+ if !storeExists {+ guard role == .app else {+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "opening V2 library from extension",+ reason: "extension creation is forbidden"+ )+ }+ try context.save()+ }+ try validateStore(using: context, capabilities: capabilities)+ } catch let error as LibraryRepositoryError {+ throw error } catch {- logger.error("Store open or validation failed: \(String(describing: error), privacy: .public)")+ logger.error("V2 store open or validation failed: \(String(describing: error), privacy: .public)") throw LibraryRepositoryError.libraryUnavailable(- operation: "opening and validating V1 store",+ operation: "opening and validating V2 store", reason: String(describing: error) ) } - if !markerExists {+ if role == .app, !markerExists { do {- try Data("Asterism library initialized\n".utf8).write(to: configuration.markerURL, options: .atomic)- try FileManager.default.setAttributes(+ try Data("Asterism V2 library ready\n".utf8).write(+ to: configuration.markerURL,+ options: .atomic+ )+ try fileManager.setAttributes( [.posixPermissions: NSNumber(value: Int16(0o600))], ofItemAtPath: configuration.markerURL.path )+ markerExists = true } catch { throw LibraryRepositoryError.libraryUnavailable(- operation: "recording library initialization",+ operation: "recording V2 library readiness", reason: String(describing: error) ) } } - logger.debug("Library bootstrap validated")+ guard markerExists else {+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "opening current V2 library",+ reason: "the V2 readiness marker is absent"+ )+ }++ logger.debug("Current V2 library validated") return LibraryRepository( configuration: configuration, container: container,+ capabilities: capabilities, clock: clock, saveStrategy: saveStrategy )@@ -400,6 +499,95 @@ public actor LibraryRepository { } } +++ /// Fetch a title pattern by ID.+ public func titlePattern(id: UUID) async throws -> TitlePatternSnapshot {+ try await withLockedContext(mode: .shared, operation: "reading TitlePattern") { context in+ var descriptor = FetchDescriptor<TitlePattern>(predicate: #Predicate { $0.id == id })+ descriptor.fetchLimit = 2+ let patterns = try context.fetch(descriptor)+ guard patterns.count <= 1 else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "resolving TitlePattern",+ reason: "duplicate pattern UUID"+ )+ }+ guard let pattern = patterns.first else {+ throw LibraryRepositoryError.recordNotFound(type: "TitlePattern", id: id)+ }+ return TitlePatternSnapshot(+ id: pattern.id,+ version: pattern.version,+ isActive: pattern.isActive,+ createdAt: pattern.createdAt,+ definition: try pattern.definition,+ siteHostname: pattern.site?.hostname ?? ""+ )+ }+ }++ // MARK: - Private Teaching Helpers++ internal static func applyProjection(+ _ projection: EntryProjection,+ to entry: Entry,+ patternID: UUID,+ patternVersion: Int,+ createdWorks: [String: Work],+ existingWorks: [UUID: Work],+ timestamp: Date+ ) {+ // Apply chapter+ switch projection.chapterProvenance {+ case .preserved:+ break // Keep existing+ case .cleared:+ entry.chapterTitle = nil+ entry.chapterTitleProvenanceRaw = FieldProvenanceKind.none.rawValue+ entry.chapterPatternID = nil+ entry.chapterPatternVersion = nil+ case .pendingPattern:+ entry.chapterTitle = projection.projectedChapter+ entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue+ entry.chapterPatternID = patternID+ entry.chapterPatternVersion = patternVersion+ }++ // Apply assignment+ switch projection.assignmentProvenance {+ case .preserved:+ break // Keep existing+ case .cleared:+ entry.work = nil+ entry.workAssignmentProvenanceRaw = FieldProvenanceKind.none.rawValue+ entry.workPatternID = nil+ entry.workPatternVersion = nil+ entry.intentionallyUnattached = false+ case .pendingPattern:+ if let workOutcome = projection.workOutcome {+ switch workOutcome {+ case .reuse(let workID):+ if let work = existingWorks[workID] {+ entry.work = work+ }+ case .create(let title):+ if let work = createdWorks[title] {+ entry.work = work+ }+ case .ambiguous:+ entry.work = nil+ }+ }+ entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue+ entry.workPatternID = patternID+ entry.workPatternVersion = patternVersion+ entry.intentionallyUnattached = false+ }++ entry.modifiedAt = timestamp+ }+ public func debugCounts() async throws -> LibraryRecordCounts { try await withLockedContext(mode: .shared, operation: "reading coherent library counts") { context in try LibraryRecordCounts(@@ -469,32 +657,43 @@ public actor LibraryRepository { ) } - internal static func mapSiteRecord(_ site: Site) -> SiteRecord {+ internal static func mapSiteRecord(_ site: Site) throws -> SiteRecord {+ guard let mode = SiteMode(rawValue: site.modeRaw) else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "mapping Site for backup",+ reason: "invalid Site mode"+ )+ } let patternIDs = site.patternValues.map(\.id) .sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() } return SiteRecord( hostname: site.hostname, displayName: site.displayName,- mode: site.mode,+ mode: mode, patternIDs: patternIDs, urlIdentityRule: site.urlIdentityRule, junkSuffixRule: site.junkSuffixRule ) } - internal static func mapTitlePatternRecord(_ pattern: TitlePattern) -> TitlePatternRecord {- TitlePatternRecord(+ internal static func mapTitlePatternRecord(_ pattern: TitlePattern) throws -> TitlePatternRecord {+ guard let hostname = pattern.site?.hostname, !hostname.isEmpty else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "mapping TitlePattern for backup",+ reason: "pattern has no Site"+ )+ }+ return TitlePatternRecord( id: pattern.id, version: pattern.version, isActive: pattern.isActive, createdAt: pattern.createdAt,- workAnchor: pattern.workAnchor,- junkAnchors: pattern.junkAnchors,- siteHostname: pattern.site?.hostname ?? ""+ definition: try pattern.definition,+ siteHostname: hostname ) } - private func withLockedContext<Value: Sendable>(+ internal func withLockedContext<Value: Sendable>( mode: LibraryLockMode, operation: String, _ body: (ModelContext) throws -> Value@@ -513,7 +712,56 @@ public actor LibraryRepository { } } - private static func fetchSites(hostname: String, context: ModelContext) throws -> [Site] {+ /// Construct a WorkBasisEntry from a Work model, throwing on invalid raw provenance.+ /// Factored to eliminate silent `?? .parsed` coercion across all basis builders.+ internal static func workBasisEntry(from work: Work, operation: String) throws -> WorkBasisEntry {+ guard let prov = TitleProvenance(rawValue: work.titleProvenanceRaw) else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: operation,+ reason: "Work \(work.id) has invalid titleProvenanceRaw '\(work.titleProvenanceRaw)'"+ )+ }+ return WorkBasisEntry(+ id: work.id, displayTitle: work.displayTitle,+ lastParsedTitle: work.lastParsedTitle, titleProvenance: prov,+ siteHostname: work.siteHostname, createdAt: work.createdAt,+ modifiedAt: work.modifiedAt+ )+ }++ internal static func entriesByID(+ _ entries: [Entry],+ operation: String+ ) throws -> [UUID: Entry] {+ var result: [UUID: Entry] = [:]+ for entry in entries {+ guard result.updateValue(entry, forKey: entry.id) == nil else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: operation,+ reason: "duplicate Entry UUID '\(entry.id)'"+ )+ }+ }+ return result+ }++ internal static func worksByID(+ _ works: [Work],+ operation: String+ ) throws -> [UUID: Work] {+ var result: [UUID: Work] = [:]+ for work in works {+ guard result.updateValue(work, forKey: work.id) == nil else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: operation,+ reason: "duplicate Work UUID '\(work.id)'"+ )+ }+ }+ return result+ }++ internal static func fetchSites(hostname: String, context: ModelContext) throws -> [Site] { var descriptor = FetchDescriptor<Site>(predicate: #Predicate { $0.hostname == hostname }) descriptor.fetchLimit = 2 let sites = try context.fetch(descriptor)@@ -526,7 +774,7 @@ public actor LibraryRepository { return sites } - private static func fetchEntry(id: UUID, context: ModelContext) throws -> Entry {+ internal static func fetchEntry(id: UUID, context: ModelContext) throws -> Entry { var descriptor = FetchDescriptor<Entry>(predicate: #Predicate { $0.id == id }) descriptor.fetchLimit = 2 let entries = try context.fetch(descriptor)@@ -589,7 +837,7 @@ public actor LibraryRepository { return result } - private static func validateCapture(_ draft: CaptureDraft) throws -> (hostname: String, identityKey: String) {+ internal static func validateCapture(_ draft: CaptureDraft) throws -> (hostname: String, identityKey: String) { guard !draft.captureTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw LibraryRepositoryError.invalidInput(operation: "validating capture", reason: "capture title is blank") }@@ -615,14 +863,26 @@ public actor LibraryRepository { return true } - private static func snapshot(_ entry: Entry) throws -> EntrySnapshot {+ internal static func snapshot(_ entry: Entry) throws -> EntrySnapshot {+ guard let chapterKind = FieldProvenanceKind(rawValue: entry.chapterTitleProvenanceRaw) else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "mapping Entry",+ reason: "invalid chapter provenance kind"+ )+ }+ guard let assignmentKind = FieldProvenanceKind(rawValue: entry.workAssignmentProvenanceRaw) else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "mapping Entry",+ reason: "invalid assignment provenance kind"+ )+ } let chapter = try FieldProvenance(- kind: FieldProvenanceKind(rawValue: entry.chapterTitleProvenanceRaw) ?? .none,+ kind: chapterKind, patternID: entry.chapterPatternID, patternVersion: entry.chapterPatternVersion ) let assignment = try FieldProvenance(- kind: FieldProvenanceKind(rawValue: entry.workAssignmentProvenanceRaw) ?? .none,+ kind: assignmentKind, patternID: entry.workPatternID, patternVersion: entry.workPatternVersion )@@ -658,14 +918,30 @@ public actor LibraryRepository { ) } - private static func validateStore(using context: ModelContext) throws {- var entries = FetchDescriptor<Entry>(); entries.fetchLimit = 1- var works = FetchDescriptor<Work>(); works.fetchLimit = 1- var sites = FetchDescriptor<Site>(); sites.fetchLimit = 1- var patterns = FetchDescriptor<TitlePattern>(); patterns.fetchLimit = 1- _ = try context.fetch(entries)- _ = try context.fetch(works)- _ = try context.fetch(sites)- _ = try context.fetch(patterns)+ private static func validateStore(+ using context: ModelContext,+ capabilities: M2Capabilities+ ) throws {+ let entries = try context.fetch(FetchDescriptor<Entry>())+ let works = try context.fetch(FetchDescriptor<Work>())+ let sites = try context.fetch(FetchDescriptor<Site>())+ let patterns = try context.fetch(FetchDescriptor<TitlePattern>())++ for pattern in patterns {+ try capabilities.validate(patternDefinition: pattern.definition)+ }+ for site in sites where site.mode == .articles && !capabilities.supportsArticles {+ throw M2CapabilityError.articlesUnavailable(gate: capabilities.gate)+ }++ // Full closed-tuple and graph validation is shared with Backup V2. The+ // mapper validates raw enum/provenance fields before building records.+ let snapshot = LibraryBackupSnapshot(+ entries: try entries.map(mapEntryRecord),+ works: try works.map(mapWorkRecord),+ sites: try sites.map(mapSiteRecord),+ titlePatterns: try patterns.map(mapTitlePatternRecord)+ )+ try V2LibraryValidator.validate(snapshot: snapshot, capabilities: capabilities) } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/M2Capabilities.swift b/Packages/AsterismCore/Sources/AsterismCore/M2Capabilities.swiftnew file mode 100644index 0000000..5326463--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/M2Capabilities.swift@@ -0,0 +1,104 @@+import Foundation++/// Vertical feature gates used by every M2 boundary. A single value is injected+/// into persistence, backup, and UI registration so later-gate forms cannot be+/// accepted accidentally by only one layer.+public struct M2Capabilities: Codable, Equatable, Sendable {+ public enum Gate: String, CaseIterable, Codable, Sendable {+ case m2_0 = "m2.0"+ case m2_1 = "m2.1"+ case m2_2 = "m2.2"+ case m2_3 = "m2.3"+ }++ public static let m2_0 = M2Capabilities(gate: .m2_0)+ public static let m2_1 = M2Capabilities(gate: .m2_1)+ public static let m2_2 = M2Capabilities(gate: .m2_2)+ public static let m2_3 = M2Capabilities(gate: .m2_3)++ /// M2.3 is the currently shipped runtime gate for Development and Personal.+ /// Segment, articles, phrase teaching, and cross-form replacement are enabled.+ public static let current = M2Capabilities.m2_3++ public let gate: Gate++ public init(gate: Gate) {+ self.gate = gate+ }++ public var supportsSegmentTeaching: Bool { gate != .m2_0 }+ public var supportsArticles: Bool { gate == .m2_2 || gate == .m2_3 }+ public var supportsPhraseTeaching: Bool { gate == .m2_3 }++ public func allows(patternForm: PatternForm) -> Bool {+ switch patternForm {+ case .segment: true+ case .phrase: supportsPhraseTeaching+ }+ }++ public func validate(patternDefinition: PatternDefinition) throws {+ let validated = try patternDefinition.validated()+ guard allows(patternForm: validated.form) else {+ throw M2CapabilityError.unavailablePatternForm(form: validated.form, gate: gate)+ }+ }+}++public enum M2CapabilityError: Error, Equatable, Sendable, CustomStringConvertible {+ case unavailablePatternForm(form: PatternForm, gate: M2Capabilities.Gate)+ case articlesUnavailable(gate: M2Capabilities.Gate)++ public var description: String {+ switch self {+ case .unavailablePatternForm(let form, let gate):+ "Pattern form \(form.rawValue) is unavailable at gate \(gate.rawValue)"+ case .articlesUnavailable(let gate):+ "Articles mode is unavailable at gate \(gate.rawValue)"+ }+ }+}++public enum PatternDefinition: Codable, Equatable, Sendable {+ case segment(work: SegmentRangeSpec, ignored: [SegmentPositionSpec])+ case phrase(prefix: String, separator: String, suffix: String, order: FieldOrder)++ public var form: PatternForm {+ switch self {+ case .segment: .segment+ case .phrase: .phrase+ }+ }++ @discardableResult+ public func validated() throws -> PatternDefinition {+ switch self {+ case .segment(let work, let ignored):+ guard work.offset >= 0, work.length > 0, ignored.allSatisfy({ $0.offset >= 0 }) else {+ throw ModelInvariantError.invalidCombination(field: "segment pattern")+ }+ case .phrase(_, let separator, _, _):+ guard separator.unicodeScalars.contains(where: { !M2Unicode.isWhitespace($0) }) else {+ throw ModelInvariantError.blankValue(field: "phrase separator")+ }+ }+ return self+ }+}++/// Locale-independent scalar predicates required by the M2 contracts.+public enum M2Unicode {+ public static func isWhitespace(_ scalar: Unicode.Scalar) -> Bool {+ switch scalar.value {+ case 0x0009...0x000D, 0x0020, 0x0085, 0x00A0, 0x1680,+ 0x2000...0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000:+ true+ default:+ false+ }+ }++ public static func isBlank(_ value: String) -> Bool {+ !value.unicodeScalars.contains(where: { !isWhitespace($0) })+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/M2PerformanceFixture.swift b/Packages/AsterismCore/Sources/AsterismCore/M2PerformanceFixture.swiftnew file mode 100644index 0000000..a607b25--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/M2PerformanceFixture.swift@@ -0,0 +1,211 @@+#if DEBUG || ASTERISM_PERFORMANCE_TESTING+import Foundation+import SwiftData++extension LibraryRepository {+ /// Seeds Requirement 10's exact deterministic data shape into a fresh test library.+ ///+ /// This API is compiled only for Development or explicit Release performance-test+ /// builds. It performs one guarded save so interrupted setup cannot expose a+ /// partially populated fixture to the measured app launch.+ public func seedM2PerformanceFixture() async throws {+ let entryCounts = [+ "scale.test": 5_000,+ "archive.test": 4_200,+ "articles.test": 3_600,+ "serial.test": 3_000,+ "notes.test": 2_400,+ "essays.test": 1_800,+ ]+ let workCounts = [+ "scale.test": 500,+ "archive.test": 400,+ "articles.test": 350,+ "serial.test": 300,+ "notes.test": 250,+ "essays.test": 200,+ ]+ guard entryCounts.values.reduce(0, +) == 20_000,+ workCounts.values.reduce(0, +) == 2_000,+ entryCounts.values.count(where: { $0 == 5_000 }) == 1 else {+ throw LibraryRepositoryError.invalidInput(+ operation: "seeding M2 performance fixture",+ reason: "fixture cardinalities do not match Requirement 10"+ )+ }+ guard capabilities == .m2_3 else {+ throw LibraryRepositoryError.invalidInput(+ operation: "seeding M2 performance fixture",+ reason: "the complete scale fixture requires the M2.3 capability gate"+ )+ }++ try await withLockedContext(+ mode: .exclusive,+ operation: "seeding M2 performance fixture"+ ) { context in+ let existingCount = try context.fetchCount(FetchDescriptor<Entry>())+ + context.fetchCount(FetchDescriptor<Work>())+ + context.fetchCount(FetchDescriptor<Site>())+ + context.fetchCount(FetchDescriptor<TitlePattern>())+ guard existingCount == 0 else {+ throw LibraryRepositoryError.invalidInput(+ operation: "seeding M2 performance fixture",+ reason: "destination library is not empty"+ )+ }++ let targetHostname = "scale.test"+ let articleHostname = "articles.test"+ let historicalPatternID = Self.performanceUUID(namespace: 1, index: 1)+ let activePatternID = Self.performanceUUID(namespace: 1, index: 2)+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .start, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ var sitesByHostname: [String: Site] = [:]+ for hostname in entryCounts.keys.sorted() {+ let site = Site(hostname: hostname)+ if hostname == targetHostname {+ site.mode = .taught+ context.insert(+ try TitlePattern(+ id: historicalPatternID,+ version: 1,+ isActive: false,+ createdAt: Date(timeIntervalSince1970: 1),+ definition: definition,+ site: site+ )+ )+ context.insert(+ try TitlePattern(+ id: activePatternID,+ version: 2,+ isActive: true,+ createdAt: Date(timeIntervalSince1970: 2),+ definition: definition,+ site: site+ )+ )+ } else if hostname == articleHostname {+ site.mode = .articles+ }+ context.insert(site)+ sitesByHostname[hostname] = site+ }++ var worksByHostname: [String: [Work]] = [:]+ var globalWorkIndex = 0+ for hostname in workCounts.keys.sorted() {+ guard sitesByHostname[hostname] != nil,+ let count = workCounts[hostname], count > 0 else {+ throw LibraryRepositoryError.invalidInput(+ operation: "seeding M2 performance fixture",+ reason: "missing or empty Work distribution for '\(hostname)'"+ )+ }+ var siteWorks: [Work] = []+ siteWorks.reserveCapacity(count)+ for localIndex in 0..<count {+ let title = "Work \(localIndex)"+ let work = Work(+ id: Self.performanceUUID(namespace: 2, index: globalWorkIndex),+ displayTitle: title,+ siteHostname: hostname,+ timestamp: Date(timeIntervalSince1970: TimeInterval(globalWorkIndex))+ )+ work.titleProvenance = localIndex.isMultiple(of: 3) ? .manual : .parsed+ work.lastParsedTitle = localIndex.isMultiple(of: 2) ? title : nil+ context.insert(work)+ siteWorks.append(work)+ globalWorkIndex += 1+ }+ worksByHostname[hostname] = siteWorks+ }++ var globalEntryIndex = 0+ for hostname in entryCounts.keys.sorted() {+ guard let count = entryCounts[hostname], count > 0,+ let siteWorks = worksByHostname[hostname], !siteWorks.isEmpty else {+ throw LibraryRepositoryError.invalidInput(+ operation: "seeding M2 performance fixture",+ reason: "missing or empty Entry distribution for '\(hostname)'"+ )+ }+ for localIndex in 0..<count {+ let timestamp = Date(timeIntervalSince1970: TimeInterval(globalEntryIndex))+ let isTarget = hostname == targetHostname+ let state = isTarget ? localIndex % 4 : 2+ let assignedWork = state == 0 || state == 1+ ? siteWorks[localIndex % siteWorks.count]+ : nil+ let entry = Entry(+ id: Self.performanceUUID(namespace: 3, index: globalEntryIndex),+ captureTitle: "Chapter \(localIndex) - Work \(localIndex % siteWorks.count) | \(hostname)",+ captureTitleSource: localIndex.isMultiple(of: 2) ? .host : .manual,+ rawURLString: "https://\(hostname)/entry/\(localIndex)",+ hostname: hostname,+ entryIdentityKey: "https://\(hostname)/entry/\(localIndex)",+ timestamp: timestamp,+ note: localIndex.isMultiple(of: 17) ? "Representative note" : "",+ rating: localIndex.isMultiple(of: 19) ? .up : nil,+ work: assignedWork+ )++ switch state {+ case 0:+ entry.chapterTitle = "Chapter \(localIndex)"+ entry.chapterTitleProvenance = .manual+ entry.workAssignmentProvenance = .manual+ case 1:+ entry.chapterTitle = "Chapter \(localIndex)"+ entry.chapterTitleProvenance = .pattern+ entry.chapterPatternID = historicalPatternID+ entry.chapterPatternVersion = 1+ entry.workAssignmentProvenance = .pattern+ entry.workPatternID = historicalPatternID+ entry.workPatternVersion = 1+ case 3:+ entry.chapterTitle = "Chapter \(localIndex)"+ entry.chapterTitleProvenance = .pattern+ entry.chapterPatternID = activePatternID+ entry.chapterPatternVersion = 2+ entry.workAssignmentProvenance = .pattern+ entry.workPatternID = activePatternID+ entry.workPatternVersion = 2+ default:+ if hostname == articleHostname {+ entry.intentionallyUnattached = true+ }+ }+ context.insert(entry)+ globalEntryIndex += 1+ }+ }++ guard try context.fetchCount(FetchDescriptor<Entry>()) == 20_000,+ try context.fetchCount(FetchDescriptor<Work>()) == 2_000,+ try context.fetchCount(FetchDescriptor<Site>()) == entryCounts.count,+ try context.fetchCount(FetchDescriptor<TitlePattern>()) == 2 else {+ throw LibraryRepositoryError.invalidInput(+ operation: "seeding M2 performance fixture",+ reason: "generated model counts differ from the requested fixture"+ )+ }+ try saveStrategy.save(context)+ }+ }++ private static func performanceUUID(namespace: Int, index: Int) -> UUID {+ let value = String(format: "%012llX", UInt64(index))+ guard let id = UUID(+ uuidString: String(format: "%08X-0000-4000-8000-%@", namespace, value)+ ) else {+ preconditionFailure("The deterministic performance UUID format must remain valid")+ }+ return id+ }+}+#endif
diff --git a/Packages/AsterismCore/Sources/AsterismCore/M2PerformanceSignposts.swift b/Packages/AsterismCore/Sources/AsterismCore/M2PerformanceSignposts.swiftnew file mode 100644index 0000000..2d73cea--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/M2PerformanceSignposts.swift@@ -0,0 +1,11 @@+import Foundation++/// Stable names consumed by XCTest/Instruments physical-device performance hooks.+/// Keep these values source-compatible so historical measurements remain comparable.+public enum M2PerformanceSignposts {+ public static let subsystem = "me.nore.ig.Asterism"+ public static let category = "M2Performance"+ public static let recentPublication = "RecentPublication"+ public static let editAcknowledgement = "TeachingEditAcknowledgement"+ public static let finalPreviewPublication = "TeachingFinalPreviewPublication"+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Models.swift b/Packages/AsterismCore/Sources/AsterismCore/Models.swiftindex 061b911..3727ef7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Models.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Models.swift@@ -142,8 +142,13 @@ public final class TitlePattern { public var version: Int = 1 public var isActive: Bool = false public var createdAt: Date = Date(timeIntervalSince1970: 0)- public var workAnchor: SegmentRangeSpec = try! SegmentRangeSpec(origin: .start, offset: 0, length: 1)- public var junkAnchors: [SegmentPositionSpec] = []+ public var formRaw: String = PatternForm.segment.rawValue+ public var segmentWorkAnchor: SegmentRangeSpec?+ public var segmentIgnoredAnchors: [SegmentPositionSpec]?+ public var phrasePrefix: String?+ public var phraseSeparator: String?+ public var phraseSuffix: String?+ public var fieldOrderRaw: String? public var site: Site? public init(@@ -151,16 +156,104 @@ public final class TitlePattern { version: Int, isActive: Bool = false, createdAt: Date,- workAnchor: SegmentRangeSpec,- junkAnchors: [SegmentPositionSpec] = [],+ definition: PatternDefinition, site: Site? = nil- ) {+ ) throws {+ guard version > 0 else {+ throw ModelInvariantError.nonPositiveVersion(field: "title pattern version")+ } self.id = id self.version = version self.isActive = isActive self.createdAt = createdAt- self.workAnchor = workAnchor- self.junkAnchors = junkAnchors self.site = site+ try setImmutableDefinition(definition)+ }++ /// Compatibility initializer for V1-shaped tests and migration mapping. It+ /// always creates the V2 segment arm; runtime callers still validate it+ /// against the injected gate before persistence.+ public convenience init(+ id: UUID = UUID(),+ version: Int,+ isActive: Bool = false,+ createdAt: Date,+ workAnchor: SegmentRangeSpec,+ junkAnchors: [SegmentPositionSpec] = [],+ site: Site? = nil+ ) {+ try! self.init(+ id: id,+ version: version,+ isActive: isActive,+ createdAt: createdAt,+ definition: .segment(work: workAnchor, ignored: junkAnchors),+ site: site+ )+ }++ public var definition: PatternDefinition {+ get throws {+ guard let form = PatternForm(rawValue: formRaw) else {+ throw ModelInvariantError.invalidCombination(field: "title pattern form")+ }+ switch form {+ case .segment:+ guard let work = segmentWorkAnchor,+ let ignored = segmentIgnoredAnchors,+ phrasePrefix == nil,+ phraseSeparator == nil,+ phraseSuffix == nil,+ fieldOrderRaw == nil else {+ throw ModelInvariantError.invalidCombination(field: "segment pattern arm")+ }+ return try PatternDefinition.segment(work: work, ignored: ignored).validated()+ case .phrase:+ guard segmentWorkAnchor == nil,+ segmentIgnoredAnchors == nil,+ let prefix = phrasePrefix,+ let separator = phraseSeparator,+ let suffix = phraseSuffix,+ let orderRaw = fieldOrderRaw,+ let order = FieldOrder(rawValue: orderRaw) else {+ throw ModelInvariantError.invalidCombination(field: "phrase pattern arm")+ }+ return try PatternDefinition.phrase(+ prefix: prefix,+ separator: separator,+ suffix: suffix,+ order: order+ ).validated()+ }+ }+ }++ public var workAnchor: SegmentRangeSpec {+ segmentWorkAnchor ?? (try! SegmentRangeSpec(origin: .start, offset: 0, length: 1))+ }++ public var junkAnchors: [SegmentPositionSpec] {+ segmentIgnoredAnchors ?? []+ }++ private func setImmutableDefinition(_ definition: PatternDefinition) throws {+ switch try definition.validated() {+ case .segment(let work, let ignored):+ formRaw = PatternForm.segment.rawValue+ segmentWorkAnchor = work+ segmentIgnoredAnchors = ignored+ phrasePrefix = nil+ phraseSeparator = nil+ phraseSuffix = nil+ fieldOrderRaw = nil+ case .phrase(let prefix, let separator, let suffix, let order):+ formRaw = PatternForm.phrase.rawValue+ segmentWorkAnchor = nil+ segmentIgnoredAnchors = nil+ phrasePrefix = prefix+ phraseSeparator = separator+ phraseSuffix = suffix+ fieldOrderRaw = order.rawValue+ } } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swiftnew file mode 100644index 0000000..bee1581--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift@@ -0,0 +1,324 @@+import Foundation++// MARK: - Generic projection contract++/// The approved-projection/stale contract (Design §7.1).+/// Commit APIs accept this; if the re-derived basis/outcome differs from what+/// was approved, the commit returns `.refreshed(freshContract)` with zero writes.+public struct ProjectionContract<Basis: Sendable & Equatable,+ Request: Sendable & Equatable,+ Outcome: Sendable & Equatable>: Sendable, Equatable {+ public let basis: Basis+ public let request: Request+ public let outcome: Outcome++ public init(basis: Basis, request: Request, outcome: Outcome) {+ self.basis = basis+ self.request = request+ self.outcome = outcome+ }+}++// MARK: - Commit result enums++/// Result of committing a teaching operation (initial or replacement).+public enum TeachingCommitOutcome: Equatable, Sendable {+ /// The commit succeeded with the given pattern identity.+ case committed(patternID: UUID, patternVersion: Int)+ /// The basis or outcome changed; a fresh contract is returned for re-approval.+ case refreshed(TeachingContract)+ /// The operation can no longer be projected (e.g. boundary entry deleted).+ case invalidated(reason: String)+}++/// Result of committing a Re-parse operation.+public enum ReparseCommitOutcome: Equatable, Sendable {+ /// The commit succeeded.+ case committed+ /// The basis or outcome changed; a fresh contract is returned for re-approval.+ case refreshed(ReparseContract)+ /// The operation can no longer be projected (e.g. entry deleted, Site untaught).+ case invalidated(reason: String)+}++/// Result of committing a capture with projection.+public enum CaptureCommitOutcome: Equatable, Sendable {+ /// The commit succeeded; returns the saved entry snapshot.+ case committed(EntrySnapshot)+ /// The basis or outcome changed; a fresh contract is returned for re-approval.+ case refreshed(CaptureContract)+ /// The operation can no longer be projected (e.g. Site state changed incompatibly).+ case invalidated(reason: String)+}++public enum ArticlesCommitOutcome: Equatable, Sendable {+ case committed+ case refreshed(ArticlesContract)+ case invalidated(reason: String)+}++// MARK: - Teaching contract types++public typealias TeachingContract = ProjectionContract<TeachingBasis, TeachingRequest, TeachingOutcome>++/// Immutable, deterministically sorted basis for teaching operations.+/// Contains every displayed outcome/commit-precondition value.+public struct TeachingBasis: Sendable, Equatable {+ /// The Site mode at preview time.+ public let siteMode: SiteMode+ /// The hostname.+ public let hostname: String+ /// All active/retained patterns for this Site, sorted by (version, id).+ public let patterns: [PatternBasisEntry]+ /// All same-Site Entries sorted by (firstCapturedAt, id).+ public let entries: [EntryBasisEntry]+ /// All same-Site Works sorted by id, with matching-relevant fields.+ public let works: [WorkBasisEntry]++ public init(siteMode: SiteMode, hostname: String, patterns: [PatternBasisEntry],+ entries: [EntryBasisEntry], works: [WorkBasisEntry]) {+ self.siteMode = siteMode+ self.hostname = hostname+ self.patterns = patterns+ self.entries = entries+ self.works = works+ }+}++/// A pattern as represented in the basis (identity + definition + state).+public struct PatternBasisEntry: Sendable, Equatable {+ public let id: UUID+ public let version: Int+ public let isActive: Bool+ public let definition: PatternDefinition+ public let createdAt: Date++ public init(id: UUID, version: Int, isActive: Bool, definition: PatternDefinition, createdAt: Date) {+ self.id = id+ self.version = version+ self.isActive = isActive+ self.definition = definition+ self.createdAt = createdAt+ }+}++/// An Entry as represented in the basis.+public struct EntryBasisEntry: Sendable, Equatable {+ public let id: UUID+ public let captureTitle: String+ public let hostname: String+ public let firstCapturedAt: Date+ public let chapterTitle: String?+ public let chapterTitleProvenance: FieldProvenance+ public let workID: UUID?+ public let workAssignmentProvenance: FieldProvenance+ public let intentionallyUnattached: Bool+ public let note: String+ public let rating: Rating?++ public init(id: UUID, captureTitle: String, hostname: String, firstCapturedAt: Date,+ chapterTitle: String?, chapterTitleProvenance: FieldProvenance,+ workID: UUID?, workAssignmentProvenance: FieldProvenance,+ intentionallyUnattached: Bool, note: String, rating: Rating?) {+ self.id = id+ self.captureTitle = captureTitle+ self.hostname = hostname+ self.firstCapturedAt = firstCapturedAt+ self.chapterTitle = chapterTitle+ self.chapterTitleProvenance = chapterTitleProvenance+ self.workID = workID+ self.workAssignmentProvenance = workAssignmentProvenance+ self.intentionallyUnattached = intentionallyUnattached+ self.note = note+ self.rating = rating+ }+}++/// A Work as represented in the basis (matching-relevant fields).+public struct WorkBasisEntry: Sendable, Equatable {+ public let id: UUID+ public let displayTitle: String+ public let lastParsedTitle: String?+ public let titleProvenance: TitleProvenance+ public let siteHostname: String+ public let createdAt: Date+ public let modifiedAt: Date++ public init(id: UUID, displayTitle: String, lastParsedTitle: String?,+ titleProvenance: TitleProvenance, siteHostname: String,+ createdAt: Date, modifiedAt: Date) {+ self.id = id+ self.displayTitle = displayTitle+ self.lastParsedTitle = lastParsedTitle+ self.titleProvenance = titleProvenance+ self.siteHostname = siteHostname+ self.createdAt = createdAt+ self.modifiedAt = modifiedAt+ }+}++/// The request for a teaching operation: exact pattern definition + scope.+public struct TeachingRequest: Sendable, Equatable {+ public let operation: TeachingOperation+ public let patternDefinition: PatternDefinition++ public init(operation: TeachingOperation, patternDefinition: PatternDefinition) {+ self.operation = operation+ self.patternDefinition = patternDefinition+ }+}++/// The specific teaching operation type.+public enum TeachingOperation: Sendable, Equatable {+ /// Initial teaching (untaught → taught).+ case initial+ /// Replacement teaching with boundary and optional historical correction.+ case replacement(boundaryEntryID: UUID, historicalPatternID: UUID?, historicalPatternVersion: Int?)+}++/// The complete outcome of a teaching projection (the plan).+public struct TeachingOutcome: Sendable, Equatable {+ /// The complete projection plan as computed by the planner.+ public let plan: ProjectionPlan+ /// The computed next pattern version (1 for initial, max+1 for replacement).+ public let computedPatternVersion: Int++ public init(plan: ProjectionPlan, computedPatternVersion: Int) {+ self.plan = plan+ self.computedPatternVersion = computedPatternVersion+ }+}++// MARK: - Articles-mode contract types++public typealias ArticlesContract = ProjectionContract<TeachingBasis, ArticlesRequest, ArticlesOutcome>++public struct ArticlesRequest: Sendable, Equatable {+ public let junkSuffixRule: JunkSuffixRule?++ public init(junkSuffixRule: JunkSuffixRule?) {+ self.junkSuffixRule = junkSuffixRule+ }+}++public struct ArticlesOutcome: Sendable, Equatable {+ public let plan: ArticleProjectionPlan++ public init(plan: ArticleProjectionPlan) {+ self.plan = plan+ }+}++// MARK: - Re-parse contract types++public typealias ReparseContract = ProjectionContract<ReparseBasis, ReparseRequest, ReparseOutcome>++/// Basis for a single-entry Re-parse: the Entry, Site, active pattern, and same-Site Works.+public struct ReparseBasis: Sendable, Equatable {+ public let siteMode: SiteMode+ public let hostname: String+ public let activePattern: PatternBasisEntry?+ public let entry: EntryBasisEntry+ public let works: [WorkBasisEntry]++ public init(siteMode: SiteMode, hostname: String, activePattern: PatternBasisEntry?,+ entry: EntryBasisEntry, works: [WorkBasisEntry]) {+ self.siteMode = siteMode+ self.hostname = hostname+ self.activePattern = activePattern+ self.entry = entry+ self.works = works+ }+}++/// The Re-parse request: the entry ID being re-parsed (the pattern comes from the basis).+public struct ReparseRequest: Sendable, Equatable {+ public let entryID: UUID++ public init(entryID: UUID) {+ self.entryID = entryID+ }+}++/// The Re-parse outcome: complete plan for the single entry.+public struct ReparseOutcome: Sendable, Equatable {+ public let plan: ProjectionPlan++ public init(plan: ProjectionPlan) {+ self.plan = plan+ }+}++// MARK: - Capture contract types++public typealias CaptureContract = ProjectionContract<CaptureBasis, CaptureRequest, CaptureOutcome>++/// Basis for a capture projection: Site mode, active pattern, and same-Site Works.+public struct CaptureBasis: Sendable, Equatable {+ public let siteMode: SiteMode+ public let hostname: String+ public let activePattern: PatternBasisEntry?+ public let junkSuffixRule: JunkSuffixRule?+ public let works: [WorkBasisEntry]++ public init(siteMode: SiteMode, hostname: String, activePattern: PatternBasisEntry?,+ junkSuffixRule: JunkSuffixRule? = nil, works: [WorkBasisEntry]) {+ self.siteMode = siteMode+ self.hostname = hostname+ self.activePattern = activePattern+ self.junkSuffixRule = junkSuffixRule+ self.works = works+ }+}++/// The capture request: exact pending title + evidence.+public struct CaptureRequest: Sendable, Equatable {+ public let captureTitle: String+ public let captureTitleSource: CaptureTitleSource+ public let rawURLString: String+ public let canonicalURLString: String?+ public let note: String+ public let rating: Rating?++ public init(captureTitle: String, captureTitleSource: CaptureTitleSource,+ rawURLString: String, canonicalURLString: String?,+ note: String, rating: Rating?) {+ self.captureTitle = captureTitle+ self.captureTitleSource = captureTitleSource+ self.rawURLString = rawURLString+ self.canonicalURLString = canonicalURLString+ self.note = note+ self.rating = rating+ }+}++/// The capture outcome: what the capture will produce.+public struct CaptureOutcome: Sendable, Equatable {+ /// Projected chapter title (nil if parse fails or Site untaught).+ public let projectedChapter: String?+ /// Projected Work title (nil if parse fails).+ public let projectedWorkTitle: String?+ /// How the Work assignment resolves.+ public let workOutcome: WorkMatchOutcome?+ /// Projected Work ID (nil for create-pending/ambiguous).+ public let projectedWorkID: UUID?+ /// Presentation-only title after any articles suffix cleaning.+ public let displayTitle: String?+ /// Whether the saved Entry will be actionable.+ public let actionable: Bool+ /// Whether the Entry will be intentionally unattached.+ public let intentionallyUnattached: Bool++ public init(projectedChapter: String?, projectedWorkTitle: String?,+ workOutcome: WorkMatchOutcome?, projectedWorkID: UUID?,+ actionable: Bool, intentionallyUnattached: Bool,+ displayTitle: String? = nil) {+ self.projectedChapter = projectedChapter+ self.projectedWorkTitle = projectedWorkTitle+ self.workOutcome = workOutcome+ self.projectedWorkID = projectedWorkID+ self.displayTitle = displayTitle+ self.actionable = actionable+ self.intentionallyUnattached = intentionallyUnattached+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swiftnew file mode 100644index 0000000..de39c45--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift@@ -0,0 +1,196 @@+import Foundation++// MARK: - RecentPresentation DTO (Audit §2)++/// Immutable presentation model for the Recent tab, built in a single coherent+/// repository read. Provides ordered grouped rows, actionable count, site mode,+/// assigned Work display title, chapter, and unresolved candidate title.+public struct RecentPresentation: Equatable, Sendable {+ /// Grouped rows preserving day order and entry order within each day.+ public let groups: [RecentPresentationGroup]+ /// Total count of actionable entries across all groups.+ public let actionableCount: Int++ public init(groups: [RecentPresentationGroup], actionableCount: Int) {+ self.groups = groups+ self.actionableCount = actionableCount+ }++ /// All rows flattened in display order.+ public var allRows: [RecentPresentationRow] {+ groups.flatMap(\.rows)+ }+}++/// A day-group in the Recent presentation.+public struct RecentPresentationGroup: Equatable, Sendable {+ public let day: Date+ public let rows: [RecentPresentationRow]++ public init(day: Date, rows: [RecentPresentationRow]) {+ self.day = day+ self.rows = rows+ }+}++/// A single row in the Recent presentation with all display-ready values.+public struct RecentPresentationRow: Equatable, Sendable, Identifiable {+ public let id: UUID+ /// Full immutable entry snapshot for direct inline teaching without another read.+ public let entry: EntrySnapshot+ public let captureTitle: String+ public let hostname: String+ /// The Work display title assigned to this entry (nil if unassigned).+ public let workDisplayTitle: String?+ /// The chapter title (pattern-derived or manual).+ public let chapterTitle: String?+ /// The unresolved candidate title for actionable entries on taught sites.+ public let unresolvedCandidateTitle: String?+ /// Site mode at read time.+ public let siteMode: SiteMode+ /// Whether this entry is actionable.+ public let isActionable: Bool+ /// The action type available for this row.+ public let actionType: RecentRowActionType+ /// Note preview.+ public let note: String+ /// Rating.+ public let rating: Rating?+ /// Last shared timestamp.+ public let lastSharedAt: Date++ public init(+ id: UUID, entry: EntrySnapshot, captureTitle: String, hostname: String, workDisplayTitle: String?,+ chapterTitle: String?, unresolvedCandidateTitle: String?, siteMode: SiteMode,+ isActionable: Bool, actionType: RecentRowActionType, note: String,+ rating: Rating?, lastSharedAt: Date+ ) {+ self.id = id+ self.entry = entry+ self.captureTitle = captureTitle+ self.hostname = hostname+ self.workDisplayTitle = workDisplayTitle+ self.chapterTitle = chapterTitle+ self.unresolvedCandidateTitle = unresolvedCandidateTitle+ self.siteMode = siteMode+ self.isActionable = isActionable+ self.actionType = actionType+ self.note = note+ self.rating = rating+ self.lastSharedAt = lastSharedAt+ }+}++/// The action type available for a Recent row's inline action.+public enum RecentRowActionType: String, Equatable, Sendable {+ /// The site is untaught: initial teaching needed.+ case teach+ /// The site is taught but entry is unresolved: re-teach may be needed.+ case reteach+ /// No inline action available (fully resolved or manually settled).+ case none+}++// MARK: - EntryTeachingDetail DTO (Audit §3)++/// Coherent detail for an entry's teaching/reparse state.+/// Built repository-side in one locked context. No inference or coercion.+public struct EntryTeachingDetail: Equatable, Sendable {+ /// The entry snapshot used to build this detail (avoids duplicate reads).+ public let entry: EntrySnapshot+ /// Presentation title; cleaned for articles mode while `entry.captureTitle` remains immutable evidence.+ public let displayTitle: String+ /// Exact Site mode at read time.+ public let siteMode: SiteMode+ /// Active pattern summary for this site, if any.+ public let activePatternSummary: PatternRuleSummary?+ /// Historical pattern summaries (retained but not active).+ public let historicalPatternSummaries: [PatternRuleSummary]+ /// Independent chapter settlement status with exact reason.+ public let chapterSettlement: FieldSettlement+ /// Independent assignment settlement status with exact reason.+ public let assignmentSettlement: FieldSettlement+ /// All available actions for this entry/site combination.+ public let availableActions: [EntryDetailAction]+ /// Replayed unresolved candidate Work title using the exact referenced historical pattern.+ /// Present only when the entry has pattern provenance but is unresolved.+ public let unresolvedCandidateTitle: String?++ public init(+ entry: EntrySnapshot, siteMode: SiteMode,+ activePatternSummary: PatternRuleSummary?,+ historicalPatternSummaries: [PatternRuleSummary],+ chapterSettlement: FieldSettlement, assignmentSettlement: FieldSettlement,+ availableActions: [EntryDetailAction],+ unresolvedCandidateTitle: String?,+ displayTitle: String? = nil+ ) {+ self.entry = entry+ self.displayTitle = displayTitle ?? entry.captureTitle+ self.siteMode = siteMode+ self.activePatternSummary = activePatternSummary+ self.historicalPatternSummaries = historicalPatternSummaries+ self.chapterSettlement = chapterSettlement+ self.assignmentSettlement = assignmentSettlement+ self.availableActions = availableActions+ self.unresolvedCandidateTitle = unresolvedCandidateTitle+ }+}++/// Summary of a pattern rule for display, with full provenance diagnostics.+public struct PatternRuleSummary: Equatable, Sendable {+ public let id: UUID+ public let version: Int+ public let form: PatternForm+ public let isActive: Bool+ /// Plain-language description of the segment edge/offset/length/ignored rule.+ public let plainLanguageRule: String++ public init(id: UUID, version: Int, form: PatternForm, isActive: Bool, plainLanguageRule: String) {+ self.id = id+ self.version = version+ self.form = form+ self.isActive = isActive+ self.plainLanguageRule = plainLanguageRule+ }+}++/// Settlement status for a single field. Carries exact pattern info or reason.+public enum FieldSettlement: Equatable, Sendable {+ /// Field is settled by pattern with exact identity.+ case patternSettled(patternID: UUID, version: Int)+ /// Field is settled manually (protected).+ case manualProtected(reason: String)+ /// Field is unsettled with reason.+ case unsettled(reason: String)++ public var isProtected: Bool {+ switch self {+ case .manualProtected: true+ default: false+ }+ }+}++/// An action available on an entry detail.+public enum EntryDetailAction: String, Equatable, Sendable, CaseIterable {+ /// Site is untaught: initial Teach action.+ case teach+ /// Site is taught: Re-teach (replacement teaching).+ case reTeach+ /// Site is taught and entry has/can have pattern provenance: Re-parse.+ case reParse+}++// MARK: - CaptureCoordinating protocol (Audit §4)++/// Testable protocol for capture coordination.+/// Production CaptureCoordinator conforms; tests inject a mock.+public protocol CaptureCoordinating: Sendable {+ /// Prepare a payload for capture UI (title resolution, Site status).+ func prepare(_ payload: SharePayload) async throws -> CapturePreparation+ /// Project a capture for preview (no write). Returns a contract.+ func projectCapture(hostname: String, captureTitle: String, captureTitleSource: CaptureTitleSource, rawURLString: String, canonicalURLString: String?, note: String, rating: Rating?) async throws -> CaptureContract+ /// Commit an approved capture contract.+ func commitCapture(_ contract: CaptureContract) async throws -> CaptureCommitOutcome+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swift b/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swiftindex 822497b..f352933 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swift@@ -94,7 +94,7 @@ public struct FetchedPageMetadata: Sendable, Equatable { /// Coordinates share payload preparation and save for the extension. /// Owns title resolution logic, canonical validation, and Site-status lookup.-public actor CaptureCoordinator {+public actor CaptureCoordinator: CaptureCoordinating { private let repository: LibraryRepository private let fetcher: (any PageTitleFetching)? @@ -249,8 +249,21 @@ public actor CaptureCoordinator { return nil } - /// Save a capture draft through the repository.- public func save(_ draft: CaptureDraft) async throws -> EntrySnapshot {- try await repository.capture(draft)+ /// Project a capture for preview (no write). Delegates to the repository.+ public func projectCapture(+ hostname: String, captureTitle: String, captureTitleSource: CaptureTitleSource,+ rawURLString: String, canonicalURLString: String?, note: String, rating: Rating?+ ) async throws -> CaptureContract {+ try await repository.projectCapture(+ hostname: hostname, captureTitle: captureTitle,+ captureTitleSource: captureTitleSource,+ rawURLString: rawURLString, canonicalURLString: canonicalURLString,+ note: note, rating: rating+ )+ }++ /// Commit an approved capture contract. Delegates to the repository.+ public func commitCapture(_ contract: CaptureContract) async throws -> CaptureCommitOutcome {+ try await repository.commitCapture(contract) } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift b/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swiftindex cb2e84f..90f9b14 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift@@ -100,11 +100,12 @@ public struct TitlePatternSnapshot: Equatable, Sendable { public let version: Int public let isActive: Bool public let createdAt: Date- public let workAnchor: SegmentRangeSpec- public let junkAnchors: [SegmentPositionSpec]+ public let definition: PatternDefinition public let siteHostname: String } ++ public struct DatedEntryGroup: Equatable, Sendable { public let day: Date public let entries: [EntrySnapshot]
diff --git a/Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swift b/Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swiftnew file mode 100644index 0000000..12cb7a0--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swift@@ -0,0 +1,231 @@+import Foundation++// MARK: - Segment role assignment++/// Role assigned to a segment during teaching.+public enum SegmentRole: String, CaseIterable, Codable, Sendable {+ case chapter+ case work+ case ignore+}++/// A teaching snapshot: the segment roles assigned by the reader.+public struct SegmentRoleAssignment: Equatable, Sendable {+ /// Segment roles in source order (one per segment).+ public let roles: [SegmentRole]++ public init(roles: [SegmentRole]) {+ self.roles = roles+ }+}++// MARK: - Teaching validation++/// Errors that prevent confirming a teaching candidate.+public enum TeachingValidationError: Error, Equatable, Sendable {+ /// No Work segments selected.+ case noWorkSegment+ /// No chapter segments selected.+ case noChapterSegment+ /// The Work range is not contiguous.+ case nonContiguousWork+ /// The chapter range is not contiguous.+ case nonContiguousChapter+ /// Work and chapter ranges overlap.+ case workChapterOverlap+ /// A Work or chapter segment is blank.+ case blankField(field: String)+}++/// Validates a segment role assignment for teaching confirmation.+public enum TeachingValidator {++ /// Validate that the assignment satisfies Requirement 1.4:+ /// exactly one contiguous chapter range and one disjoint contiguous Work range,+ /// each containing a nonblank scalar outside M2 White_Space.+ public static func validate(+ assignment: SegmentRoleAssignment,+ segments: [String]+ ) -> Result<ValidatedTeachingAssignment, TeachingValidationError> {+ let roles = assignment.roles+ guard roles.count == segments.count else {+ return .failure(.noWorkSegment)+ }++ // Collect Work and chapter indices.+ var workIndices: [Int] = []+ var chapterIndices: [Int] = []+ for (i, role) in roles.enumerated() {+ switch role {+ case .work: workIndices.append(i)+ case .chapter: chapterIndices.append(i)+ case .ignore: break+ }+ }++ guard !workIndices.isEmpty else { return .failure(.noWorkSegment) }+ guard !chapterIndices.isEmpty else { return .failure(.noChapterSegment) }++ // Check contiguity.+ if !isContiguous(workIndices) { return .failure(.nonContiguousWork) }+ if !isContiguous(chapterIndices) { return .failure(.nonContiguousChapter) }++ // Check disjointness (guaranteed by enum exclusion, but be defensive).+ let workSet = Set(workIndices)+ let chapterSet = Set(chapterIndices)+ if !workSet.isDisjoint(with: chapterSet) { return .failure(.workChapterOverlap) }++ // Check nonblank.+ let workRange = workIndices.first!..<(workIndices.last! + 1)+ let chapterRange = chapterIndices.first!..<(chapterIndices.last! + 1)+ for i in workRange {+ if roles[i] == .work, !M2Unicode.isBlank(segments[i]) { break }+ if i == workRange.upperBound - 1 { return .failure(.blankField(field: "work")) }+ }+ for i in chapterRange {+ if roles[i] == .chapter, !M2Unicode.isBlank(segments[i]) { break }+ if i == chapterRange.upperBound - 1 { return .failure(.blankField(field: "chapter")) }+ }++ // Collect ignored indices.+ let ignoredIndices = roles.enumerated().compactMap { $0.element == .ignore ? $0.offset : nil }++ return .success(ValidatedTeachingAssignment(+ workRange: workRange,+ chapterRange: chapterRange,+ ignoredIndices: ignoredIndices,+ segmentCount: segments.count+ ))+ }++ private static func isContiguous(_ indices: [Int]) -> Bool {+ guard let first = indices.first, let last = indices.last else { return false }+ return indices.count == last - first + 1+ }+}++/// A validated teaching assignment ready for anchor derivation.+public struct ValidatedTeachingAssignment: Equatable, Sendable {+ public let workRange: Range<Int>+ public let chapterRange: Range<Int>+ public let ignoredIndices: [Int]+ public let segmentCount: Int+}++// MARK: - Pattern derivation++/// Derives a PatternDefinition from a validated teaching assignment.+public enum PatternDeriver {++ /// Derive a segment pattern definition from a validated teaching assignment.+ /// Returns nil only if anchor derivation fails (should not happen with valid input).+ public static func deriveSegmentPattern(+ from assignment: ValidatedTeachingAssignment+ ) -> PatternDefinition? {+ guard let workAnchor = AnchorDerivation.deriveWorkAnchor(+ segmentCount: assignment.segmentCount,+ workRange: assignment.workRange+ ) else {+ return nil+ }++ let ignoredAnchors: [SegmentPositionSpec] = assignment.ignoredIndices.compactMap {+ AnchorDerivation.deriveIgnoredAnchor(+ segmentCount: assignment.segmentCount,+ ignoredIndex: $0+ )+ }++ return .segment(work: workAnchor, ignored: ignoredAnchors)+ }+}++// MARK: - Work matching++/// Outcome of matching a parsed Work title against existing Works.+public enum WorkMatchOutcome: Equatable, Sendable {+ /// Exactly one Work matched.+ case reuse(workID: UUID)+ /// No existing Work matched; one should be created.+ case create(parsedWorkTitle: String)+ /// Multiple Works matched; assignment is ambiguous.+ /// Carries full candidate snapshots in deterministic UUID order for preview.+ case ambiguous(candidates: [WorkMatchCandidate])+}++/// Describes a Work for matching purposes.+public struct WorkMatchCandidate: Equatable, Sendable, Hashable {+ public let id: UUID+ public let lastParsedTitle: String?+ public let displayTitle: String++ public init(id: UUID, lastParsedTitle: String?, displayTitle: String) {+ self.id = id+ self.lastParsedTitle = lastParsedTitle+ self.displayTitle = displayTitle+ }+}++/// Performs exact scalar Work matching per Requirements 3.4-3.7.+public enum WorkMatcher {++ /// Match a parsed Work title against candidates using exact scalar equality.+ /// Uses `lastParsedTitle` when present and nonblank, otherwise `displayTitle`.+ public static func match(+ parsedWorkTitle: String,+ candidates: [WorkMatchCandidate]+ ) -> WorkMatchOutcome {+ var matchingCandidates: [WorkMatchCandidate] = []+ for candidate in candidates {+ let matchTitle: String+ if let lpt = candidate.lastParsedTitle, !M2Unicode.isBlank(lpt) {+ matchTitle = lpt+ } else {+ matchTitle = candidate.displayTitle+ }+ if scalarEqual(matchTitle, parsedWorkTitle) {+ matchingCandidates.append(candidate)+ }+ }++ switch matchingCandidates.count {+ case 0: return .create(parsedWorkTitle: parsedWorkTitle)+ case 1: return .reuse(workID: matchingCandidates[0].id)+ default:+ // Sort by UUID string for deterministic order.+ let sorted = matchingCandidates.sorted { $0.id.uuidString < $1.id.uuidString }+ return .ambiguous(candidates: sorted)+ }+ }++ /// Exact Unicode scalar equality without normalization.+ private static func scalarEqual(_ a: String, _ b: String) -> Bool {+ let aScalars = a.unicodeScalars+ let bScalars = b.unicodeScalars+ guard aScalars.count == bScalars.count else { return false }+ return zip(aScalars, bScalars).allSatisfy { $0 == $1 }+ }+}++// MARK: - Actionability++/// Determines whether an Entry is actionable per Requirement 5.1.+public enum ActionabilityEvaluator {++ /// An Entry is actionable when either field is unsettled.+ /// Chapter is unsettled only when absent and provenance is none.+ /// Assignment is unsettled when Work is nil, provenance is not manual, and not intentionally unattached.+ public static func isActionable(+ chapterTitle: String?,+ chapterProvenance: FieldProvenance,+ workID: UUID?,+ assignmentProvenance: FieldProvenance,+ intentionallyUnattached: Bool+ ) -> Bool {+ let chapterUnsettled = chapterTitle == nil && chapterProvenance.kind == .none+ let assignmentUnsettled = workID == nil+ && assignmentProvenance.kind != .manual+ && !intentionallyUnattached+ return chapterUnsettled || assignmentUnsettled+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/TitleParsing.swift b/Packages/AsterismCore/Sources/AsterismCore/TitleParsing.swiftnew file mode 100644index 0000000..b6cd869--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/TitleParsing.swift@@ -0,0 +1,742 @@+import Foundation++// MARK: - Segment tokenizer++/// A single recognized delimiter span within a title string.+public struct DelimiterSpan: Equatable, Sendable {+ /// Scalar offset of the first whitespace character in the span.+ public let start: Int+ /// Scalar offset one past the last whitespace character in the span.+ public let end: Int+ /// The punctuation token at the center of the span.+ public let token: DelimiterToken+}++/// The seven recognized delimiter tokens, ordered by discovery priority (longest first).+public enum DelimiterToken: String, CaseIterable, Sendable {+ case doubleColon = "::"+ case enDash = "\u{2013}"+ case emDash = "\u{2014}"+ case verticalBar = "|"+ case hyphenMinus = "-"+ case middleDot = "\u{00B7}"+ case colon = ":"++ /// Ordered list for longest-first matching.+ public static let discoveryOrder: [DelimiterToken] = [+ .doubleColon, .enDash, .emDash, .verticalBar, .hyphenMinus, .middleDot, .colon,+ ]+}++/// Errors produced during segment tokenization.+public enum TokenizationError: Error, Equatable, Sendable {+ /// Two delimiter spans share whitespace scalars.+ case unsupportedAdjacentDelimiters+ /// Tokenization produced an empty or blank segment.+ case blankSegment(position: Int)+ /// The input title is blank (no non-whitespace scalars) or empty.+ case blankTitle+}++/// The result of successful tokenization: ordered segments and the delimiters between them.+public struct TokenizedTitle: Equatable, Sendable {+ /// The segments (text between delimiters) in source order.+ public let segments: [String]+ /// The delimiter spans in source order.+ public let delimiters: [DelimiterSpan]+ /// The original title from which segments were derived.+ public let sourceTitle: String+}++/// Tokenizes a title into segments separated by spaced delimiter tokens.+public enum DelimiterTokenizer {++ /// Tokenize a title by discovering all spaced delimiter candidates, rejecting overlaps,+ /// and splitting into source substrings.+ public static func tokenize(_ title: String) -> Result<TokenizedTitle, TokenizationError> {+ // Reject blank/empty titles immediately.+ if title.isEmpty || M2Unicode.isBlank(title) {+ return .failure(.blankTitle)+ }++ let scalars = Array(title.unicodeScalars)++ // Discover all candidate delimiter spans.+ var spans: [DelimiterSpan] = []+ var scalarIndex = 0+ while scalarIndex < scalars.count {+ if let span = findDelimiter(in: scalars, at: scalarIndex) {+ spans.append(span)+ scalarIndex += 1+ } else {+ scalarIndex += 1+ }+ }++ // Deduplicate: keep unique spans by (start, end, token) deterministically.+ spans = deduplicateSpans(spans)++ // Check for whitespace overlap between any two spans.+ for i in 0..<spans.count {+ for j in (i + 1)..<spans.count {+ if spans[i].end > spans[j].start && spans[j].end > spans[i].start {+ return .failure(.unsupportedAdjacentDelimiters)+ }+ }+ }++ // Sort by start position.+ spans.sort { $0.start < $1.start }++ // If no delimiters, the whole title is one segment.+ if spans.isEmpty {+ return .success(TokenizedTitle(segments: [title], delimiters: [], sourceTitle: title))+ }++ // Split title into segments using scalar ranges.+ var segments: [String] = []+ var lastEnd = 0+ for span in spans {+ let segStart = lastEnd+ let segEnd = span.start+ let seg = scalarSubstring(scalars, from: segStart, to: segEnd)+ segments.append(seg)+ lastEnd = span.end+ }+ // Trailing segment.+ let trailing = scalarSubstring(scalars, from: lastEnd, to: scalars.count)+ segments.append(trailing)++ // Validate segments: none may be empty or blank.+ for (index, seg) in segments.enumerated() {+ if seg.isEmpty || M2Unicode.isBlank(seg) {+ return .failure(.blankSegment(position: index))+ }+ }++ return .success(TokenizedTitle(segments: segments, delimiters: spans, sourceTitle: title))+ }++ /// Reconstruct the original source title from a `TokenizedTitle`.+ public static func reconstructSource(from tokenized: TokenizedTitle) -> String {+ let scalars = Array(tokenized.sourceTitle.unicodeScalars)+ var result = ""+ result.reserveCapacity(scalars.count)+ var lastEnd = 0+ for span in tokenized.delimiters {+ for i in lastEnd..<span.start {+ result.unicodeScalars.append(scalars[i])+ }+ for i in span.start..<span.end {+ result.unicodeScalars.append(scalars[i])+ }+ lastEnd = span.end+ }+ for i in lastEnd..<scalars.count {+ result.unicodeScalars.append(scalars[i])+ }+ return result+ }++ // MARK: - Private++ /// Build a String directly from a scalar slice without lossy Character conversion.+ private static func scalarSubstring(_ scalars: [Unicode.Scalar], from start: Int, to end: Int) -> String {+ var result = ""+ result.reserveCapacity(end - start)+ for i in start..<end {+ result.unicodeScalars.append(scalars[i])+ }+ return result+ }++ private static func findDelimiter(in scalars: [Unicode.Scalar], at startIndex: Int) -> DelimiterSpan? {+ for token in DelimiterToken.discoveryOrder {+ if let span = matchToken(token, in: scalars, searchFrom: startIndex) {+ return span+ }+ }+ return nil+ }++ private static func matchToken(+ _ token: DelimiterToken,+ in scalars: [Unicode.Scalar],+ searchFrom index: Int+ ) -> DelimiterSpan? {+ let tokenScalars = Array(token.rawValue.unicodeScalars)+ let tokenLen = tokenScalars.count++ // Check if the token starts at this index.+ guard index + tokenLen <= scalars.count else { return nil }+ for i in 0..<tokenLen {+ guard scalars[index + i] == tokenScalars[i] else { return nil }+ }++ // Check at least one M2 White_Space on the left.+ guard index > 0, M2Unicode.isWhitespace(scalars[index - 1]) else { return nil }++ // Check at least one M2 White_Space on the right.+ let afterToken = index + tokenLen+ guard afterToken < scalars.count, M2Unicode.isWhitespace(scalars[afterToken]) else { return nil }++ // Extend the span to include maximal contiguous whitespace on each side.+ var spanStart = index - 1+ while spanStart > 0, M2Unicode.isWhitespace(scalars[spanStart - 1]) {+ spanStart -= 1+ }+ var spanEnd = afterToken + 1+ while spanEnd < scalars.count, M2Unicode.isWhitespace(scalars[spanEnd]) {+ spanEnd += 1+ }++ return DelimiterSpan(start: spanStart, end: spanEnd, token: token)+ }++ /// Deterministic deduplication: remove spans with identical (start, end, token).+ private static func deduplicateSpans(_ spans: [DelimiterSpan]) -> [DelimiterSpan] {+ var unique: [DelimiterSpan] = []+ for span in spans {+ if !unique.contains(where: { $0.start == span.start && $0.end == span.end && $0.token == span.token }) {+ unique.append(span)+ }+ }+ return unique+ }+}++// MARK: - Anchor derivation and application++/// Identifies a phrase literal that failed exact matching.+public enum PhraseLiteral: String, Equatable, Sendable {+ case prefix+ case suffix+}++/// Errors during pattern application.+public enum PatternApplicationError: Error, Equatable, Sendable, LocalizedError {+ /// A required anchored position is beyond the number of segments.+ case anchorPositionAbsent(origin: AnchorOrigin, offset: Int)+ /// Two distinct ignored anchors resolve to the same segment index.+ case duplicateIgnoredPosition(index: Int)+ /// Work and ignored positions overlap.+ case workIgnoredOverlap+ /// After removing Work and ignored segments, the chapter remainder is not contiguous.+ case nonContiguousChapter+ /// A produced title (Work or chapter) is blank.+ case blankResult(field: String)+ /// The tokenization failed before application could proceed.+ case tokenizationFailed(TokenizationError)+ /// A required phrase prefix or suffix did not match exactly.+ case literalMismatch(PhraseLiteral)+ /// Prefix and suffix consumption crossed within the title.+ case literalRegionsOverlap+ /// The bounded phrase interior did not contain exactly one separator start.+ case separatorOccurrenceCount(Int)++ public var errorDescription: String? {+ switch self {+ case .anchorPositionAbsent(let origin, let offset):+ "Missing \(origin.rawValue)-anchored segment at offset \(offset)."+ case .duplicateIgnoredPosition(let index):+ "Two ignored anchors resolve to segment \(index + 1)."+ case .workIgnoredOverlap:+ "Work and ignored segment positions overlap."+ case .nonContiguousChapter:+ "The remaining chapter segments are not contiguous."+ case .blankResult(let field):+ "The parsed \(field) is blank."+ case .tokenizationFailed:+ "The title cannot be split into supported segments."+ case .literalMismatch(let literal):+ "The phrase \(literal.rawValue) does not match exactly."+ case .literalRegionsOverlap:+ "The phrase prefix and suffix overlap."+ case .separatorOccurrenceCount(let count):+ "The phrase separator must occur exactly once; found \(count)."+ }+ }+}++/// The successful result of applying either supported pattern form.+public struct SegmentParseResult: Equatable, Sendable {+ /// The parsed Work title.+ public let workTitle: String+ /// The parsed chapter title.+ public let chapterTitle: String++ public init(workTitle: String, chapterTitle: String) {+ self.workTitle = workTitle+ self.chapterTitle = chapterTitle+ }+}++/// Form-neutral name used by shared planning and repository paths.+public typealias TitleParseResult = SegmentParseResult++/// The two phrase fields a reader can select.+public enum PhraseFieldRole: String, Equatable, Sendable {+ case chapter+ case work+}++/// Character-boundary ranges for chapter and Work selections.+///+/// Integer offsets are positions in Swift's `Character` collection, so every accepted+/// endpoint is an extended-grapheme-cluster boundary supplied by the platform.+public struct PhraseSelection: Equatable, Sendable {+ public let chapter: Range<Int>+ public let work: Range<Int>++ public init(chapter: Range<Int>, work: Range<Int>) {+ self.chapter = chapter+ self.work = work+ }++ public func range(for role: PhraseFieldRole) -> Range<Int> {+ switch role {+ case .chapter: chapter+ case .work: work+ }+ }+}++/// Invalid phrase authoring state.+public enum PhraseTeachingError: Error, Equatable, Sendable, LocalizedError {+ case selectionOutOfBounds(role: PhraseFieldRole)+ case blankSelection(role: PhraseFieldRole)+ case overlappingSelections+ case blankSeparator+ case candidateDoesNotReproduceSelection++ public var errorDescription: String? {+ switch self {+ case .selectionOutOfBounds(let role):+ "The \(role.rawValue) selection is outside the example title."+ case .blankSelection(let role):+ "The \(role.rawValue) selection must contain visible text."+ case .overlappingSelections:+ "Chapter and Work selections must not overlap."+ case .blankSeparator:+ "The text between Chapter and Work must contain a non-whitespace character."+ case .candidateDoesNotReproduceSelection:+ "The candidate does not reproduce the selected Chapter and Work exactly."+ }+ }+}++/// Derives and validates exact phrase templates from platform-grapheme selections.+public enum PhrasePatternDeriver {+ public static func derive(+ from title: String,+ selection: PhraseSelection+ ) throws -> PatternDefinition {+ let characterCount = title.count+ try validate(selection.chapter, role: .chapter, inCharacterCount: characterCount)+ try validate(selection.work, role: .work, inCharacterCount: characterCount)++ let chapter = characterSubstring(title, range: selection.chapter)+ let work = characterSubstring(title, range: selection.work)+ guard !M2Unicode.isBlank(chapter) else {+ throw PhraseTeachingError.blankSelection(role: .chapter)+ }+ guard !M2Unicode.isBlank(work) else {+ throw PhraseTeachingError.blankSelection(role: .work)+ }++ let chapterFirst = selection.chapter.lowerBound < selection.work.lowerBound+ let firstRange = chapterFirst ? selection.chapter : selection.work+ let secondRange = chapterFirst ? selection.work : selection.chapter+ guard firstRange.upperBound <= secondRange.lowerBound else {+ throw PhraseTeachingError.overlappingSelections+ }++ let separatorRange = firstRange.upperBound..<secondRange.lowerBound+ let separator = characterSubstring(title, range: separatorRange)+ guard !M2Unicode.isBlank(separator) else {+ throw PhraseTeachingError.blankSeparator+ }++ let definition = PatternDefinition.phrase(+ prefix: characterSubstring(title, range: 0..<firstRange.lowerBound),+ separator: separator,+ suffix: characterSubstring(title, range: secondRange.upperBound..<characterCount),+ order: chapterFirst ? .chapterThenWork : .workThenChapter+ )+ try validateCandidate(definition, example: title, selection: selection)+ return definition+ }++ public static func validateCandidate(+ _ definition: PatternDefinition,+ example: String,+ selection: PhraseSelection+ ) throws {+ let expectedChapter = characterSubstring(example, range: selection.chapter)+ let expectedWork = characterSubstring(example, range: selection.work)+ guard case .success(let parsed) = TitlePatternApplicator.apply(+ definition: definition,+ to: example+ ), scalarEqual(parsed.chapterTitle, expectedChapter), scalarEqual(parsed.workTitle, expectedWork) else {+ throw PhraseTeachingError.candidateDoesNotReproduceSelection+ }+ }++ public static func selectedText(+ in title: String,+ selection: PhraseSelection,+ role: PhraseFieldRole+ ) -> String? {+ let range = selection.range(for: role)+ guard range.lowerBound >= 0, range.upperBound <= title.count else { return nil }+ return characterSubstring(title, range: range)+ }++ private static func validate(+ _ range: Range<Int>,+ role: PhraseFieldRole,+ inCharacterCount count: Int+ ) throws {+ guard range.lowerBound >= 0, range.upperBound <= count else {+ throw PhraseTeachingError.selectionOutOfBounds(role: role)+ }+ guard !range.isEmpty else {+ throw PhraseTeachingError.blankSelection(role: role)+ }+ }++ private static func characterSubstring(_ title: String, range: Range<Int>) -> String {+ let start = title.index(title.startIndex, offsetBy: range.lowerBound)+ let end = title.index(title.startIndex, offsetBy: range.upperBound)+ return String(title[start..<end])+ }++ private static func scalarEqual(_ lhs: String, _ rhs: String) -> Bool {+ lhs.unicodeScalars.elementsEqual(rhs.unicodeScalars)+ }+}++/// Applies an exact phrase template without normalization or nonliteral matching.+public enum PhrasePatternApplicator {+ public static func apply(+ prefix: String,+ separator: String,+ suffix: String,+ order: FieldOrder,+ to title: String+ ) -> Result<TitleParseResult, PatternApplicationError> {+ let titleScalars = Array(title.unicodeScalars)+ let prefixScalars = Array(prefix.unicodeScalars)+ let separatorScalars = Array(separator.unicodeScalars)+ let suffixScalars = Array(suffix.unicodeScalars)++ guard starts(with: prefixScalars, in: titleScalars) else {+ return .failure(.literalMismatch(.prefix))+ }+ guard ends(with: suffixScalars, in: titleScalars) else {+ return .failure(.literalMismatch(.suffix))+ }++ let interiorStart = prefixScalars.count+ let interiorEnd = titleScalars.count - suffixScalars.count+ guard interiorStart <= interiorEnd else {+ return .failure(.literalRegionsOverlap)+ }+ guard !separatorScalars.isEmpty else {+ return .failure(.separatorOccurrenceCount(0))+ }++ var separatorStarts: [Int] = []+ if separatorScalars.count <= interiorEnd - interiorStart {+ for start in interiorStart...(interiorEnd - separatorScalars.count) {+ let end = start + separatorScalars.count+ if titleScalars[start..<end].elementsEqual(separatorScalars) {+ separatorStarts.append(start)+ }+ }+ }+ guard separatorStarts.count == 1, let separatorStart = separatorStarts.first else {+ return .failure(.separatorOccurrenceCount(separatorStarts.count))+ }++ let first = scalarString(titleScalars[interiorStart..<separatorStart])+ let secondStart = separatorStart + separatorScalars.count+ let second = scalarString(titleScalars[secondStart..<interiorEnd])+ let chapter: String+ let work: String+ switch order {+ case .chapterThenWork:+ chapter = first+ work = second+ case .workThenChapter:+ work = first+ chapter = second+ }++ guard !M2Unicode.isBlank(chapter) else {+ return .failure(.blankResult(field: "chapter"))+ }+ guard !M2Unicode.isBlank(work) else {+ return .failure(.blankResult(field: "work"))+ }+ return .success(TitleParseResult(workTitle: work, chapterTitle: chapter))+ }++ private static func starts(+ with prefix: [Unicode.Scalar],+ in title: [Unicode.Scalar]+ ) -> Bool {+ prefix.count <= title.count && title.prefix(prefix.count).elementsEqual(prefix)+ }++ private static func ends(+ with suffix: [Unicode.Scalar],+ in title: [Unicode.Scalar]+ ) -> Bool {+ suffix.count <= title.count && title.suffix(suffix.count).elementsEqual(suffix)+ }++ private static func scalarString(_ scalars: ArraySlice<Unicode.Scalar>) -> String {+ var result = ""+ result.unicodeScalars.append(contentsOf: scalars)+ return result+ }+}++/// One shared parser entry point for preview, commit, capture, replay, and Re-parse.+public enum TitlePatternApplicator {+ public static func apply(+ definition: PatternDefinition,+ to title: String+ ) -> Result<TitleParseResult, PatternApplicationError> {+ switch definition {+ case .segment:+ SegmentPatternApplicator.apply(definition: definition, to: title)+ case .phrase(let prefix, let separator, let suffix, let order):+ PhrasePatternApplicator.apply(+ prefix: prefix,+ separator: separator,+ suffix: suffix,+ order: order,+ to: title+ )+ }+ }+}++/// Accessible plain-language summaries for both immutable pattern forms.+public enum PatternSummaryFormatter {+ public static func summary(for definition: PatternDefinition) -> String {+ switch definition {+ case .segment(let work, let ignored):+ let direction = work.origin == .start ? "from start" : "from end"+ var result = "Work: \(work.length) segment(s) at offset \(work.offset) \(direction)"+ if !ignored.isEmpty {+ let descriptions = ignored.map {+ "\($0.origin == .start ? "start" : "end")+\($0.offset)"+ }+ result += "; Ignore: \(descriptions.joined(separator: ", "))"+ }+ return result + "; Chapter: remaining segments"+ case .phrase(let prefix, let separator, let suffix, let order):+ let orderDescription = order == .chapterThenWork ? "Chapter then Work" : "Work then Chapter"+ return "Phrase; order: \(orderDescription); prefix: \(escaped(prefix)); separator: \(escaped(separator)); suffix: \(escaped(suffix))"+ }+ }++ public static func escaped(_ value: String) -> String {+ var result = ""+ for scalar in value.unicodeScalars {+ let category = scalar.properties.generalCategory+ if M2Unicode.isWhitespace(scalar)+ || category == .control+ || category == .format+ || scalar.properties.isDefaultIgnorableCodePoint {+ result += "\\u{\(String(scalar.value, radix: 16, uppercase: true))}"+ } else if scalar == "\\" {+ result += "\\\\"+ } else {+ result.unicodeScalars.append(scalar)+ }+ }+ return result+ }+}++/// Derives anchor specifications from segment role assignments.+public enum AnchorDerivation {++ /// Derive a Work range anchor from the given segment roles.+ /// The Work range must be contiguous. Returns origin and offset based on the nearer edge+ /// (zero-based; ties go to end).+ public static func deriveWorkAnchor(+ segmentCount: Int,+ workRange: Range<Int>+ ) -> SegmentRangeSpec? {+ guard segmentCount > 0, !workRange.isEmpty else { return nil }+ let startOffset = workRange.lowerBound+ let endOffset = segmentCount - workRange.upperBound+ let length = workRange.count++ // Use end when endOffset <= startOffset (ties choose end per Req 1.5).+ let origin: AnchorOrigin = endOffset < startOffset ? .end : (startOffset < endOffset ? .start : .end)+ let offset = origin == .start ? startOffset : endOffset++ return try? SegmentRangeSpec(origin: origin, offset: offset, length: length)+ }++ /// Derive an ignored segment position anchor.+ public static func deriveIgnoredAnchor(+ segmentCount: Int,+ ignoredIndex: Int+ ) -> SegmentPositionSpec? {+ guard segmentCount > 0, ignoredIndex >= 0, ignoredIndex < segmentCount else { return nil }+ let startOffset = ignoredIndex+ let endOffset = segmentCount - 1 - ignoredIndex+ let origin: AnchorOrigin = endOffset < startOffset ? .end : (startOffset < endOffset ? .start : .end)+ let offset = origin == .start ? startOffset : endOffset+ return try? SegmentPositionSpec(origin: origin, offset: offset)+ }+}++/// Applies a segment pattern definition to a tokenized title.+public enum SegmentPatternApplicator {++ /// Apply a segment pattern to a title, returning parsed Work and chapter titles.+ public static func apply(+ definition: PatternDefinition,+ to title: String+ ) -> Result<SegmentParseResult, PatternApplicationError> {+ guard case .segment(let workSpec, let ignoredSpecs) = definition else {+ return .failure(.blankResult(field: "pattern form mismatch"))+ }++ let tokenResult = DelimiterTokenizer.tokenize(title)+ let tokenized: TokenizedTitle+ switch tokenResult {+ case .success(let t):+ tokenized = t+ case .failure(let e):+ return .failure(.tokenizationFailed(e))+ }++ return applyToTokenized(workSpec: workSpec, ignoredSpecs: ignoredSpecs, tokenized: tokenized)+ }++ /// Apply using a pre-tokenized title.+ public static func applyToTokenized(+ workSpec: SegmentRangeSpec,+ ignoredSpecs: [SegmentPositionSpec],+ tokenized: TokenizedTitle+ ) -> Result<SegmentParseResult, PatternApplicationError> {+ let segCount = tokenized.segments.count+ let scalars = Array(tokenized.sourceTitle.unicodeScalars)++ // Resolve the Work range.+ let workStart: Int+ switch workSpec.origin {+ case .start: workStart = workSpec.offset+ case .end: workStart = segCount - workSpec.offset - workSpec.length+ }+ let workEnd = workStart + workSpec.length+ guard workStart >= 0, workEnd <= segCount else {+ return .failure(.anchorPositionAbsent(origin: workSpec.origin, offset: workSpec.offset))+ }+ let workRange = workStart..<workEnd++ // Resolve ignored positions.+ var ignoredIndices: Set<Int> = []+ for spec in ignoredSpecs {+ let idx: Int+ switch spec.origin {+ case .start: idx = spec.offset+ case .end: idx = segCount - 1 - spec.offset+ }+ guard idx >= 0, idx < segCount else {+ return .failure(.anchorPositionAbsent(origin: spec.origin, offset: spec.offset))+ }+ if ignoredIndices.contains(idx) {+ return .failure(.duplicateIgnoredPosition(index: idx))+ }+ ignoredIndices.insert(idx)+ }++ // Check Work/ignored overlap.+ for idx in ignoredIndices {+ if workRange.contains(idx) {+ return .failure(.workIgnoredOverlap)+ }+ }++ // Remaining indices are chapter.+ let allIndices = Set(0..<segCount)+ let workIndices = Set(workRange)+ let chapterIndices = allIndices.subtracting(workIndices).subtracting(ignoredIndices).sorted()++ // Validate chapter contiguity.+ if !chapterIndices.isEmpty {+ for i in 1..<chapterIndices.count {+ if chapterIndices[i] != chapterIndices[i - 1] + 1 {+ return .failure(.nonContiguousChapter)+ }+ }+ } else {+ return .failure(.blankResult(field: "chapter"))+ }++ // Extract Work title preserving internal delimiters.+ let workTitle = extractRange(+ from: workRange, tokenized: tokenized, scalars: scalars+ )+ // Extract chapter title preserving internal delimiters.+ let chapterRange = chapterIndices.first!..<(chapterIndices.last! + 1)+ let chapterTitle = extractRange(+ from: chapterRange, tokenized: tokenized, scalars: scalars+ )++ // Validate nonblank.+ if M2Unicode.isBlank(workTitle) {+ return .failure(.blankResult(field: "work"))+ }+ if M2Unicode.isBlank(chapterTitle) {+ return .failure(.blankResult(field: "chapter"))+ }++ return .success(SegmentParseResult(workTitle: workTitle, chapterTitle: chapterTitle))+ }++ // MARK: - Private++ /// Extract a contiguous range of segments with their internal delimiters preserved.+ private static func extractRange(+ from range: Range<Int>,+ tokenized: TokenizedTitle,+ scalars: [Unicode.Scalar]+ ) -> String {+ guard !range.isEmpty else { return "" }++ let firstSegStart = segmentScalarStart(index: range.lowerBound, tokenized: tokenized)+ let lastSegEnd = segmentScalarEnd(index: range.upperBound - 1, tokenized: tokenized, totalScalars: scalars.count)++ var result = ""+ result.reserveCapacity(lastSegEnd - firstSegStart)+ for i in firstSegStart..<lastSegEnd {+ result.unicodeScalars.append(scalars[i])+ }+ return result+ }++ /// Scalar start offset for segment at the given index.+ private static func segmentScalarStart(index: Int, tokenized: TokenizedTitle) -> Int {+ if index == 0 { return 0 }+ return tokenized.delimiters[index - 1].end+ }++ /// Scalar end offset for segment at the given index.+ private static func segmentScalarEnd(index: Int, tokenized: TokenizedTitle, totalScalars: Int) -> Int {+ if index >= tokenized.delimiters.count {+ return totalScalars+ }+ return tokenized.delimiters[index].start+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/TitleProjectionPlanner.swift b/Packages/AsterismCore/Sources/AsterismCore/TitleProjectionPlanner.swiftnew file mode 100644index 0000000..1e769d1--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/TitleProjectionPlanner.swift@@ -0,0 +1,589 @@+import Foundation++// MARK: - Projection errors++/// Typed errors from the projection planner.+public enum ProjectionPlanError: Error, Equatable, Sendable {+ /// The boundary entry ID was not found in the input entries.+ case boundaryEntryNotFound(id: UUID)+ /// The pattern version is not positive or overflows.+ case invalidPatternVersion(version: Int)+}++// MARK: - Projection provenance++/// Represents projection-time provenance for a field that will be assigned pattern provenance+/// at commit time. The actual pattern UUID is not yet allocated; this type prevents confusion+/// with nil-patternID FieldProvenance which would be invalid.+public struct PendingPatternProvenance: Equatable, Sendable {+ /// The site-local version that will be assigned.+ public let patternVersion: Int++ public init(patternVersion: Int) {+ self.patternVersion = patternVersion+ }+}++/// The provenance outcome for a projected field: either preserved from the entry,+/// cleared to none, or pending pattern assignment at commit.+public enum ProjectedProvenance: Equatable, Sendable {+ /// Preserve the entry's current provenance unchanged.+ case preserved(FieldProvenance)+ /// Clear to none provenance (e.g., parse failure clearing old pattern data).+ case cleared+ /// Will become pattern provenance at commit time with the given version.+ case pendingPattern(PendingPatternProvenance)++ /// The FieldProvenanceKind this represents for actionability evaluation.+ public var effectiveKind: FieldProvenanceKind {+ switch self {+ case .preserved(let p): p.kind+ case .cleared: .none+ case .pendingPattern: .pattern+ }+ }+}++// MARK: - Projection types++/// The result of projecting a pattern onto a single Entry.+public struct EntryProjection: Equatable, Sendable {+ public let entryID: UUID+ /// The parsed chapter title, or nil if parsing failed or chapter is being cleared.+ public let projectedChapter: String?+ /// The parsed Work title, or nil if parsing failed or assignment is protected.+ public let projectedWorkTitle: String?+ /// How the Work assignment will be resolved.+ public let workOutcome: WorkMatchOutcome?+ /// The projected work ID after resolution (nil for create-pending, ambiguous, or parse failure).+ public let projectedWorkID: UUID?+ /// The chapter provenance that will be written.+ public let chapterProvenance: ProjectedProvenance+ /// The assignment provenance that will be written.+ public let assignmentProvenance: ProjectedProvenance+ /// Whether the chapter field is protected (manual provenance).+ public let chapterProtected: Bool+ /// Whether the assignment field is protected (manual or intentionally unattached).+ public let assignmentProtected: Bool+ /// The parse failure reason, if any.+ public let parseFailure: PatternApplicationError?+ /// Whether the Entry will be actionable after this projection is applied.+ public let actionableAfter: Bool+ /// The entry's current work ID before projection (for before/after comparison).+ public let previousWorkID: UUID?+ /// The entry's current chapter title before projection (for before/after comparison).+ public let previousChapter: String?++ public init(+ entryID: UUID, projectedChapter: String?, projectedWorkTitle: String?,+ workOutcome: WorkMatchOutcome?, projectedWorkID: UUID?,+ chapterProvenance: ProjectedProvenance, assignmentProvenance: ProjectedProvenance,+ chapterProtected: Bool, assignmentProtected: Bool,+ parseFailure: PatternApplicationError?, actionableAfter: Bool,+ previousWorkID: UUID?, previousChapter: String?+ ) {+ self.entryID = entryID+ self.projectedChapter = projectedChapter+ self.projectedWorkTitle = projectedWorkTitle+ self.workOutcome = workOutcome+ self.projectedWorkID = projectedWorkID+ self.chapterProvenance = chapterProvenance+ self.assignmentProvenance = assignmentProvenance+ self.chapterProtected = chapterProtected+ self.assignmentProtected = assignmentProtected+ self.parseFailure = parseFailure+ self.actionableAfter = actionableAfter+ self.previousWorkID = previousWorkID+ self.previousChapter = previousChapter+ }+}++/// A complete projection plan for a teaching operation.+public struct ProjectionPlan: Equatable, Sendable {+ /// Per-entry projections in the same order as the sorted input entries.+ public let entryProjections: [EntryProjection]+ /// Works that will be created (keyed by exact parsed title, sorted deterministically).+ public let worksToCreate: [String]+ /// Works that will be reused (by ID).+ public let worksToReuse: Set<UUID>+ /// Whether any entry has an ambiguous Work outcome.+ public let hasAmbiguity: Bool+ /// The pattern definition used for this projection.+ public let patternDefinition: PatternDefinition+ /// The site-local pattern version that would be assigned.+ public let patternVersion: Int++ public init(+ entryProjections: [EntryProjection], worksToCreate: [String],+ worksToReuse: Set<UUID>, hasAmbiguity: Bool,+ patternDefinition: PatternDefinition, patternVersion: Int+ ) {+ self.entryProjections = entryProjections+ self.worksToCreate = worksToCreate+ self.worksToReuse = worksToReuse+ self.hasAmbiguity = hasAmbiguity+ self.patternDefinition = patternDefinition+ self.patternVersion = patternVersion+ }+}++// MARK: - Projection planner++/// Plans the complete outcome of applying a pattern to a set of Entries.+/// This is a pure function: no storage dependency, no side effects.+/// All plans are structurally deterministic: identical inputs produce identical output.+public enum TitleProjectionPlanner {++ /// Plan projections for initial teaching (all Site Entries are targeted).+ /// Per Requirement 3.1-3.12.+ ///+ /// Entries are total-ordered by firstCapturedAt ascending then UUID ascending+ /// before projection, ensuring deterministic output independent of input order.+ ///+ /// - Throws: `ProjectionPlanError.invalidPatternVersion` if version is non-positive.+ public static func planInitialTeaching(+ patternDefinition: PatternDefinition,+ entries: [EntrySnapshot],+ existingWorks: [WorkMatchCandidate],+ patternVersion: Int = 1+ ) throws -> ProjectionPlan {+ try validatePatternVersion(patternVersion)++ let sorted = sortEntries(entries)+ var projections: [EntryProjection] = []+ var createdWorkTitles: [String] = [] // Ordered by first-seen for determinism+ var reusedWorkIDs: Set<UUID> = []+ var hasAmbiguity = false++ // Build a mutable list of candidates that includes newly created Works.+ var allCandidates = existingWorks++ for entry in sorted {+ let projection = projectEntry(+ entry: entry,+ patternDefinition: patternDefinition,+ patternVersion: patternVersion,+ candidates: &allCandidates,+ createdWorkTitles: &createdWorkTitles,+ reusedWorkIDs: &reusedWorkIDs,+ isReplacement: false+ )+ if case .ambiguous = projection.workOutcome {+ hasAmbiguity = true+ }+ projections.append(projection)+ }++ return ProjectionPlan(+ entryProjections: projections,+ worksToCreate: createdWorkTitles.sorted(),+ worksToReuse: reusedWorkIDs,+ hasAmbiguity: hasAmbiguity,+ patternDefinition: patternDefinition,+ patternVersion: patternVersion+ )+ }++ /// Plan projections for replacement teaching (re-teaching), targeting entries+ /// from the boundary forward plus unsettled earlier entries.+ ///+ /// Entries are total-ordered by firstCapturedAt ascending then UUID ascending.+ /// Historical correction is field-independent: only fields whose provenance+ /// cites the historical pattern are targeted.+ ///+ /// - Throws: `ProjectionPlanError.boundaryEntryNotFound` if boundaryEntryID is absent.+ /// - Throws: `ProjectionPlanError.invalidPatternVersion` if version is non-positive.+ public static func planReplacement(+ patternDefinition: PatternDefinition,+ entries: [EntrySnapshot],+ existingWorks: [WorkMatchCandidate],+ patternVersion: Int,+ boundaryEntryID: UUID,+ historicalPatternID: UUID? = nil,+ historicalPatternVersion: Int? = nil+ ) throws -> ProjectionPlan {+ try validatePatternVersion(patternVersion)++ let sorted = sortEntries(entries)++ // Find the boundary index — must exist.+ guard let boundaryIndex = sorted.firstIndex(where: { $0.id == boundaryEntryID }) else {+ throw ProjectionPlanError.boundaryEntryNotFound(id: boundaryEntryID)+ }++ var projections: [EntryProjection] = []+ var createdWorkTitles: [String] = []+ var reusedWorkIDs: Set<UUID> = []+ var hasAmbiguity = false+ var allCandidates = existingWorks++ for (index, entry) in sorted.enumerated() {+ let isAfterBoundary = index >= boundaryIndex+ let historicalTargeting = computeHistoricalTargeting(+ entry: entry,+ historicalPatternID: historicalPatternID,+ historicalPatternVersion: historicalPatternVersion+ )+ let isEarlierUnsettled = index < boundaryIndex && isUnsettledEntry(entry)+ let isTargeted = isAfterBoundary || historicalTargeting.chapterTargeted || historicalTargeting.assignmentTargeted || isEarlierUnsettled++ if isTargeted {+ let projection = projectEntryWithFieldTargeting(+ entry: entry,+ patternDefinition: patternDefinition,+ patternVersion: patternVersion,+ candidates: &allCandidates,+ createdWorkTitles: &createdWorkTitles,+ reusedWorkIDs: &reusedWorkIDs,+ isReplacement: true,+ chapterTargeted: isAfterBoundary || historicalTargeting.chapterTargeted || isEarlierUnsettled,+ assignmentTargeted: isAfterBoundary || historicalTargeting.assignmentTargeted || isEarlierUnsettled+ )+ if case .ambiguous = projection.workOutcome {+ hasAmbiguity = true+ }+ projections.append(projection)+ } else {+ // Untargeted entry: preserve current state.+ projections.append(preserveEntry(entry))+ }+ }++ return ProjectionPlan(+ entryProjections: projections,+ worksToCreate: createdWorkTitles.sorted(),+ worksToReuse: reusedWorkIDs,+ hasAmbiguity: hasAmbiguity,+ patternDefinition: patternDefinition,+ patternVersion: patternVersion+ )+ }++ /// Plan a single-entry re-parse.+ ///+ /// - Throws: `ProjectionPlanError.invalidPatternVersion` if version is non-positive.+ public static func planReparse(+ patternDefinition: PatternDefinition,+ entry: EntrySnapshot,+ existingWorks: [WorkMatchCandidate],+ patternVersion: Int+ ) throws -> ProjectionPlan {+ try validatePatternVersion(patternVersion)++ var createdWorkTitles: [String] = []+ var reusedWorkIDs: Set<UUID> = []+ var allCandidates = existingWorks++ let projection = projectEntry(+ entry: entry,+ patternDefinition: patternDefinition,+ patternVersion: patternVersion,+ candidates: &allCandidates,+ createdWorkTitles: &createdWorkTitles,+ reusedWorkIDs: &reusedWorkIDs,+ isReplacement: true+ )++ let hasAmbiguity: Bool+ if case .ambiguous = projection.workOutcome {+ hasAmbiguity = true+ } else {+ hasAmbiguity = false+ }++ return ProjectionPlan(+ entryProjections: [projection],+ worksToCreate: createdWorkTitles.sorted(),+ worksToReuse: reusedWorkIDs,+ hasAmbiguity: hasAmbiguity,+ patternDefinition: patternDefinition,+ patternVersion: patternVersion+ )+ }++ // MARK: - Stale detection++ /// Check whether two projection plans are structurally equal (same basis, same outcome).+ /// This implements the structural stale comparison from Design §7.2.+ public static func isStale(+ approved: ProjectionPlan,+ current: ProjectionPlan+ ) -> Bool {+ approved != current+ }++ // MARK: - Entry sorting++ /// Total-order entries by firstCapturedAt ascending, then UUID ascending (Req 6.4).+ public static func sortEntries(_ entries: [EntrySnapshot]) -> [EntrySnapshot] {+ entries.sorted { a, b in+ if a.firstCapturedAt != b.firstCapturedAt {+ return a.firstCapturedAt < b.firstCapturedAt+ }+ return a.id.uuidString < b.id.uuidString+ }+ }++ // MARK: - Private helpers++ private static func validatePatternVersion(_ version: Int) throws {+ guard version > 0, version <= Int.max else {+ throw ProjectionPlanError.invalidPatternVersion(version: version)+ }+ }++ /// Compute field-independent historical targeting.+ private struct FieldTargeting: Equatable {+ let chapterTargeted: Bool+ let assignmentTargeted: Bool+ }++ private static func computeHistoricalTargeting(+ entry: EntrySnapshot,+ historicalPatternID: UUID?,+ historicalPatternVersion: Int?+ ) -> FieldTargeting {+ guard let hID = historicalPatternID, let hVersion = historicalPatternVersion else {+ return FieldTargeting(chapterTargeted: false, assignmentTargeted: false)+ }+ let chapterCites = entry.chapterTitleProvenance.kind == .pattern+ && entry.chapterTitleProvenance.patternID == hID+ && entry.chapterTitleProvenance.patternVersion == hVersion+ let assignmentCites = entry.workAssignmentProvenance.kind == .pattern+ && entry.workAssignmentProvenance.patternID == hID+ && entry.workAssignmentProvenance.patternVersion == hVersion+ return FieldTargeting(chapterTargeted: chapterCites, assignmentTargeted: assignmentCites)+ }++ /// Full projection for an entry where both fields are targeted.+ private static func projectEntry(+ entry: EntrySnapshot,+ patternDefinition: PatternDefinition,+ patternVersion: Int,+ candidates: inout [WorkMatchCandidate],+ createdWorkTitles: inout [String],+ reusedWorkIDs: inout Set<UUID>,+ isReplacement: Bool+ ) -> EntryProjection {+ projectEntryWithFieldTargeting(+ entry: entry,+ patternDefinition: patternDefinition,+ patternVersion: patternVersion,+ candidates: &candidates,+ createdWorkTitles: &createdWorkTitles,+ reusedWorkIDs: &reusedWorkIDs,+ isReplacement: isReplacement,+ chapterTargeted: true,+ assignmentTargeted: true+ )+ }++ /// Projection with field-independent targeting for historical correction.+ private static func projectEntryWithFieldTargeting(+ entry: EntrySnapshot,+ patternDefinition: PatternDefinition,+ patternVersion: Int,+ candidates: inout [WorkMatchCandidate],+ createdWorkTitles: inout [String],+ reusedWorkIDs: inout Set<UUID>,+ isReplacement: Bool,+ chapterTargeted: Bool,+ assignmentTargeted: Bool+ ) -> EntryProjection {+ // Check independent protections.+ let chapterProtected = entry.chapterTitleProvenance.kind == .manual+ let assignmentProtected = entry.workAssignmentProvenance.kind == .manual+ || entry.intentionallyUnattached++ // Attempt parsing.+ let parseResult = TitlePatternApplicator.apply(definition: patternDefinition, to: entry.captureTitle)++ switch parseResult {+ case .success(let result):+ // Determine chapter outcome.+ let projectedChapter: String?+ let chapterProv: ProjectedProvenance+ if chapterProtected || !chapterTargeted {+ projectedChapter = entry.chapterTitle+ chapterProv = .preserved(entry.chapterTitleProvenance)+ } else {+ projectedChapter = result.chapterTitle+ chapterProv = .pendingPattern(PendingPatternProvenance(patternVersion: patternVersion))+ }++ // Determine Work outcome.+ let workOutcome: WorkMatchOutcome?+ let assignmentProv: ProjectedProvenance+ let projectedWorkID: UUID?+ if assignmentProtected || !assignmentTargeted {+ workOutcome = nil+ assignmentProv = .preserved(entry.workAssignmentProvenance)+ projectedWorkID = entry.workID+ } else {+ let matchResult = WorkMatcher.match(+ parsedWorkTitle: result.workTitle,+ candidates: candidates+ )+ workOutcome = matchResult++ switch matchResult {+ case .reuse(let workID):+ reusedWorkIDs.insert(workID)+ assignmentProv = .pendingPattern(PendingPatternProvenance(patternVersion: patternVersion))+ projectedWorkID = workID+ case .create(let title):+ // Prospective Works are keyed only by their exact parsed title.+ // Every consuming Entry keeps a create intent until commit allocates+ // the single real UUID for that title.+ if !createdWorkTitles.contains(title) {+ createdWorkTitles.append(title)+ }+ assignmentProv = .pendingPattern(PendingPatternProvenance(patternVersion: patternVersion))+ projectedWorkID = nil+ case .ambiguous:+ assignmentProv = .pendingPattern(PendingPatternProvenance(patternVersion: patternVersion))+ projectedWorkID = nil+ }+ }++ let actionableAfter = evaluateActionableAfter(+ projectedChapter: projectedChapter,+ chapterProv: chapterProv,+ projectedWorkID: projectedWorkID,+ workOutcome: workOutcome,+ assignmentProv: assignmentProv,+ intentionallyUnattached: entry.intentionallyUnattached && assignmentProtected+ )++ return EntryProjection(+ entryID: entry.id,+ projectedChapter: projectedChapter,+ projectedWorkTitle: result.workTitle,+ workOutcome: workOutcome,+ projectedWorkID: projectedWorkID,+ chapterProvenance: chapterProv,+ assignmentProvenance: assignmentProv,+ chapterProtected: chapterProtected,+ assignmentProtected: assignmentProtected,+ parseFailure: nil,+ actionableAfter: actionableAfter,+ previousWorkID: entry.workID,+ previousChapter: entry.chapterTitle+ )++ case .failure(let error):+ // Parse failure: determine chapter outcome.+ let projectedChapter: String?+ let chapterProv: ProjectedProvenance+ if chapterProtected || !chapterTargeted {+ projectedChapter = entry.chapterTitle+ chapterProv = .preserved(entry.chapterTitleProvenance)+ } else if isReplacement && entry.chapterTitleProvenance.kind == .pattern {+ // Clear old pattern-derived chapter (Req 6.6).+ projectedChapter = nil+ chapterProv = .cleared+ } else {+ // Initial teaching: preserve.+ projectedChapter = entry.chapterTitle+ chapterProv = .preserved(entry.chapterTitleProvenance)+ }++ // Determine assignment outcome.+ let assignmentProv: ProjectedProvenance+ let projectedWorkID: UUID?+ if assignmentProtected || !assignmentTargeted {+ assignmentProv = .preserved(entry.workAssignmentProvenance)+ projectedWorkID = entry.workID+ } else if isReplacement && entry.workAssignmentProvenance.kind == .pattern {+ // Clear old pattern-derived assignment (Req 6.7).+ assignmentProv = .cleared+ projectedWorkID = nil+ } else {+ assignmentProv = .preserved(entry.workAssignmentProvenance)+ projectedWorkID = entry.workID+ }++ let actionableAfter = evaluateActionableAfter(+ projectedChapter: projectedChapter,+ chapterProv: chapterProv,+ projectedWorkID: projectedWorkID,+ workOutcome: nil,+ assignmentProv: assignmentProv,+ intentionallyUnattached: entry.intentionallyUnattached && assignmentProtected+ )++ return EntryProjection(+ entryID: entry.id,+ projectedChapter: projectedChapter,+ projectedWorkTitle: nil,+ workOutcome: nil,+ projectedWorkID: projectedWorkID,+ chapterProvenance: chapterProv,+ assignmentProvenance: assignmentProv,+ chapterProtected: chapterProtected,+ assignmentProtected: assignmentProtected,+ parseFailure: error,+ actionableAfter: actionableAfter,+ previousWorkID: entry.workID,+ previousChapter: entry.chapterTitle+ )+ }+ }++ private static func preserveEntry(_ entry: EntrySnapshot) -> EntryProjection {+ let actionable = ActionabilityEvaluator.isActionable(+ chapterTitle: entry.chapterTitle,+ chapterProvenance: entry.chapterTitleProvenance,+ workID: entry.workID,+ assignmentProvenance: entry.workAssignmentProvenance,+ intentionallyUnattached: entry.intentionallyUnattached+ )+ return EntryProjection(+ entryID: entry.id,+ projectedChapter: entry.chapterTitle,+ projectedWorkTitle: nil,+ workOutcome: entry.workID.map { .reuse(workID: $0) },+ projectedWorkID: entry.workID,+ chapterProvenance: .preserved(entry.chapterTitleProvenance),+ assignmentProvenance: .preserved(entry.workAssignmentProvenance),+ chapterProtected: entry.chapterTitleProvenance.kind == .manual,+ assignmentProtected: entry.workAssignmentProvenance.kind == .manual || entry.intentionallyUnattached,+ parseFailure: nil,+ actionableAfter: actionable,+ previousWorkID: entry.workID,+ previousChapter: entry.chapterTitle+ )+ }++ private static func isUnsettledEntry(_ entry: EntrySnapshot) -> Bool {+ ActionabilityEvaluator.isActionable(+ chapterTitle: entry.chapterTitle,+ chapterProvenance: entry.chapterTitleProvenance,+ workID: entry.workID,+ assignmentProvenance: entry.workAssignmentProvenance,+ intentionallyUnattached: entry.intentionallyUnattached+ )+ }++ private static func evaluateActionableAfter(+ projectedChapter: String?,+ chapterProv: ProjectedProvenance,+ projectedWorkID: UUID?,+ workOutcome: WorkMatchOutcome?,+ assignmentProv: ProjectedProvenance,+ intentionallyUnattached: Bool+ ) -> Bool {+ let chapterUnsettled = projectedChapter == nil && chapterProv.effectiveKind == .none+ let willHaveWork = projectedWorkID != nil || {+ guard let workOutcome else { return false }+ switch workOutcome {+ case .reuse, .create: return true+ case .ambiguous: return false+ }+ }()+ let assignmentUnsettled = !willHaveWork+ && assignmentProv.effectiveKind != .manual+ && !intentionallyUnattached+ return chapterUnsettled || assignmentUnsettled+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/V2LibraryValidator.swift b/Packages/AsterismCore/Sources/AsterismCore/V2LibraryValidator.swiftnew file mode 100644index 0000000..68c7b75--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/V2LibraryValidator.swift@@ -0,0 +1,329 @@+import Foundation++/// One integrity validator shared by live opening, Backup V2 decoding, export,+/// and migration verification. It rejects malformed closed tuples rather than+/// repairing or normalizing persisted state.+public enum V2LibraryValidator {+ public static func validate(+ snapshot: LibraryBackupSnapshot,+ capabilities: M2Capabilities+ ) throws {+ let entries = try unique(snapshot.entries, type: "Entry", id: \.id)+ let works = try unique(snapshot.works, type: "Work", id: \.id)+ let patterns = try unique(snapshot.titlePatterns, type: "TitlePattern", id: \.id)+ let sites = try uniqueSites(snapshot.sites)++ for pattern in patterns.values {+ guard pattern.version > 0 else {+ throw BackupValidationError.invalidPatternDefinition(+ id: pattern.id,+ reason: "version must be positive"+ )+ }+ do { try capabilities.validate(patternDefinition: pattern.definition) }+ catch {+ throw BackupValidationError.invalidPatternDefinition(+ id: pattern.id,+ reason: String(describing: error)+ )+ }+ guard sites[pattern.siteHostname] != nil else {+ throw BackupValidationError.unresolvedHostname(+ type: "TitlePattern",+ id: pattern.id.uuidString,+ hostname: pattern.siteHostname+ )+ }+ }++ for site in sites.values {+ try validate(site: site, patterns: patterns, capabilities: capabilities)+ }++ for work in works.values {+ guard sites[work.siteHostname] != nil else {+ throw BackupValidationError.unresolvedHostname(+ type: "Work",+ id: work.id.uuidString,+ hostname: work.siteHostname+ )+ }+ guard !M2Unicode.isBlank(work.displayTitle) else {+ throw BackupValidationError.invalidStateTuple(+ type: "Work",+ id: work.id.uuidString,+ reason: "display title is blank"+ )+ }+ for entryID in work.entryIDs {+ guard let entry = entries[entryID] else {+ throw BackupValidationError.unresolvedReference(+ type: "Work",+ id: work.id,+ referencedType: "Entry",+ referencedID: entryID+ )+ }+ guard entry.workID == work.id else {+ throw BackupValidationError.invalidStateTuple(+ type: "Work",+ id: work.id.uuidString,+ reason: "entry inverse does not reference this Work"+ )+ }+ }+ }++ for entry in entries.values {+ guard let site = sites[entry.hostname] else {+ throw BackupValidationError.unresolvedHostname(+ type: "Entry",+ id: entry.id.uuidString,+ hostname: entry.hostname+ )+ }+ let work = try entry.workID.map { workID in+ guard let value = works[workID] else {+ throw BackupValidationError.unresolvedReference(+ type: "Entry",+ id: entry.id,+ referencedType: "Work",+ referencedID: workID+ )+ }+ guard value.entryIDs.contains(entry.id) else {+ throw BackupValidationError.invalidStateTuple(+ type: "Entry",+ id: entry.id.uuidString,+ reason: "Work inverse omits the Entry"+ )+ }+ return value+ }+ try validateChapter(entry, site: site, patterns: patterns)+ try validateAssignment(entry, site: site, work: work, patterns: patterns)+ }+ }++ private static func validate(+ site: SiteRecord,+ patterns: [UUID: TitlePatternRecord],+ capabilities: M2Capabilities+ ) throws {+ var seenVersions: Set<Int> = []+ let sitePatterns = try site.patternIDs.map { patternID in+ guard let pattern = patterns[patternID] else {+ throw BackupValidationError.unresolvedReference(+ type: "Site",+ id: stableSiteID(site.hostname),+ referencedType: "TitlePattern",+ referencedID: patternID+ )+ }+ guard pattern.siteHostname == site.hostname else {+ throw BackupValidationError.invalidStateTuple(+ type: "Site",+ id: site.hostname,+ reason: "pattern belongs to another Site"+ )+ }+ guard seenVersions.insert(pattern.version).inserted else {+ throw BackupValidationError.invalidStateTuple(+ type: "Site",+ id: site.hostname,+ reason: "pattern versions are not Site-unique"+ )+ }+ return pattern+ }+ let actualIDs = Set(patterns.values.filter { $0.siteHostname == site.hostname }.map(\.id))+ guard actualIDs == Set(site.patternIDs), Set(site.patternIDs).count == site.patternIDs.count else {+ throw BackupValidationError.invalidStateTuple(+ type: "Site",+ id: site.hostname,+ reason: "pattern membership is incomplete or duplicated"+ )+ }+ let activeCount = sitePatterns.count(where: \.isActive)++ switch site.mode {+ case .untaught:+ guard sitePatterns.isEmpty, site.junkSuffixRule == nil else {+ throw BackupValidationError.invalidStateTuple(+ type: "Site",+ id: site.hostname,+ reason: "untaught Site must have no patterns or junk rule"+ )+ }+ case .taught:+ guard !sitePatterns.isEmpty, activeCount == 1, site.junkSuffixRule == nil else {+ throw BackupValidationError.invalidActivePatternCount(+ hostname: site.hostname,+ count: activeCount+ )+ }+ case .articles:+ guard capabilities.supportsArticles else {+ throw BackupValidationError.invalidStateTuple(+ type: "Site",+ id: site.hostname,+ reason: "articles mode is unavailable at gate \(capabilities.gate.rawValue)"+ )+ }+ if let rule = site.junkSuffixRule {+ do { try ArticleTitleCleaner.validate(rule) }+ catch {+ throw BackupValidationError.invalidStateTuple(+ type: "Site",+ id: site.hostname,+ reason: "invalid articles junk suffix rule: \(error)"+ )+ }+ }+ guard activeCount == 0 else {+ throw BackupValidationError.invalidActivePatternCount(+ hostname: site.hostname,+ count: activeCount+ )+ }+ }+ }++ private static func validateChapter(+ _ entry: EntryRecord,+ site: SiteRecord,+ patterns: [UUID: TitlePatternRecord]+ ) throws {+ switch entry.chapterTitleProvenance.kind {+ case .none:+ guard entry.chapterTitle == nil,+ entry.chapterTitleProvenance.patternID == nil,+ entry.chapterTitleProvenance.patternVersion == nil else {+ throw invalidEntry(entry, "absent chapter must have none provenance and no pattern reference")+ }+ case .manual:+ guard let title = entry.chapterTitle,+ !M2Unicode.isBlank(title),+ entry.chapterTitleProvenance.patternID == nil,+ entry.chapterTitleProvenance.patternVersion == nil else {+ throw invalidEntry(entry, "manual chapter must be nonblank and have no pattern reference")+ }+ case .pattern:+ guard site.mode != .articles,+ let title = entry.chapterTitle,+ !M2Unicode.isBlank(title) else {+ throw invalidEntry(entry, "pattern chapter is forbidden in articles mode and must be nonblank")+ }+ try validate(+ provenance: entry.chapterTitleProvenance,+ entry: entry,+ site: site,+ patterns: patterns,+ field: "chapter"+ )+ }+ }++ private static func validateAssignment(+ _ entry: EntryRecord,+ site: SiteRecord,+ work: WorkRecord?,+ patterns: [UUID: TitlePatternRecord]+ ) throws {+ if let work, work.siteHostname != entry.hostname {+ throw invalidEntry(entry, "assigned Work belongs to a different Site")+ }++ switch entry.workAssignmentProvenance.kind {+ case .none:+ guard work == nil,+ entry.workAssignmentProvenance.patternID == nil,+ entry.workAssignmentProvenance.patternVersion == nil else {+ throw invalidEntry(entry, "none assignment provenance cannot carry a Work or pattern")+ }+ let expectedUnattached = site.mode == .articles+ guard entry.intentionallyUnattached == expectedUnattached else {+ throw invalidEntry(entry, "none assignment has the wrong intentional-unattachment state")+ }+ case .manual:+ guard entry.workAssignmentProvenance.patternID == nil,+ entry.workAssignmentProvenance.patternVersion == nil else {+ throw invalidEntry(entry, "manual assignment cannot reference a pattern")+ }+ if work == nil {+ guard entry.intentionallyUnattached else {+ throw invalidEntry(entry, "manual unassignment must be intentional")+ }+ } else if entry.intentionallyUnattached {+ throw invalidEntry(entry, "assigned Entry cannot be intentionally unattached")+ }+ case .pattern:+ guard site.mode != .articles, !entry.intentionallyUnattached else {+ throw invalidEntry(entry, "pattern assignment is invalid in articles mode or when intentionally unattached")+ }+ try validate(+ provenance: entry.workAssignmentProvenance,+ entry: entry,+ site: site,+ patterns: patterns,+ field: "assignment"+ )+ }+ }++ private static func validate(+ provenance: FieldProvenance,+ entry: EntryRecord,+ site: SiteRecord,+ patterns: [UUID: TitlePatternRecord],+ field: String+ ) throws {+ guard let patternID = provenance.patternID,+ let patternVersion = provenance.patternVersion,+ let pattern = patterns[patternID],+ pattern.version == patternVersion,+ pattern.siteHostname == site.hostname else {+ throw BackupValidationError.unresolvedProvenance(+ type: "Entry.\(field)",+ id: entry.id,+ patternID: provenance.patternID ?? UUID(),+ patternVersion: provenance.patternVersion ?? 0+ )+ }+ }++ private static func invalidEntry(_ entry: EntryRecord, _ reason: String) -> BackupValidationError {+ .invalidStateTuple(type: "Entry", id: entry.id.uuidString, reason: reason)+ }++ private static func unique<T>(+ _ values: [T],+ type: String,+ id: KeyPath<T, UUID>+ ) throws -> [UUID: T] {+ var result: [UUID: T] = [:]+ for value in values {+ let valueID = value[keyPath: id]+ guard result.updateValue(value, forKey: valueID) == nil else {+ throw BackupValidationError.duplicateID(type: type, id: valueID)+ }+ }+ return result+ }++ private static func uniqueSites(_ values: [SiteRecord]) throws -> [String: SiteRecord] {+ var result: [String: SiteRecord] = [:]+ for value in values {+ guard result.updateValue(value, forKey: value.hostname) == nil else {+ throw BackupValidationError.duplicateHostname(value.hostname)+ }+ }+ return result+ }++ private static func stableSiteID(_ hostname: String) -> UUID {+ // Error shape historically carries a UUID. A zero UUID avoids inventing+ // a persistent identity for Sites while the hostname remains in context.+ UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/V2MigrationStore.swift b/Packages/AsterismCore/Sources/AsterismCore/V2MigrationStore.swiftnew file mode 100644index 0000000..24fb45f--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/V2MigrationStore.swift@@ -0,0 +1,195 @@+import Foundation+import SwiftData++/// Raw V2 store operations used only by the explicit developer migration path.+/// They intentionally do not write runtime readiness; the app owns that step.+public enum V2MigrationStore {+ public static func artifactURLs(for storeURL: URL) -> [URL] {+ [+ storeURL,+ URL(filePath: storeURL.path + "-shm"),+ URL(filePath: storeURL.path + "-wal"),+ URL(filePath: storeURL.path + "-journal"),+ ]+ }++ public static func create(+ snapshot: LibraryBackupSnapshot,+ at storeURL: URL,+ capabilities: M2Capabilities+ ) throws {+ try V2LibraryValidator.validate(snapshot: snapshot, capabilities: capabilities)+ guard !artifactURLs(for: storeURL).contains(where: {+ FileManager.default.fileExists(atPath: $0.path)+ }) else {+ throw LibraryRepositoryError.invalidInput(+ operation: "creating migrated V2 store",+ reason: "destination already exists"+ )+ }++ do {+ try FileManager.default.createDirectory(+ at: storeURL.deletingLastPathComponent(),+ withIntermediateDirectories: true+ )+ let container = try makeContainer(at: storeURL)+ let context = ModelContext(container)++ var sites: [String: Site] = [:]+ for record in snapshot.sites {+ let site = Site(hostname: record.hostname, displayName: record.displayName)+ site.modeRaw = record.mode.rawValue+ site.urlIdentityRule = record.urlIdentityRule+ site.junkSuffixRule = record.junkSuffixRule+ context.insert(site)+ sites[record.hostname] = site+ }++ for record in snapshot.titlePatterns {+ guard let site = sites[record.siteHostname] else {+ throw LibraryRepositoryError.corruptLibrary(+ operation: "materializing migrated TitlePattern",+ reason: "missing Site \(record.siteHostname)"+ )+ }+ context.insert(try TitlePattern(+ id: record.id,+ version: record.version,+ isActive: record.isActive,+ createdAt: record.createdAt,+ definition: record.definition,+ site: site+ ))+ }++ var works: [UUID: Work] = [:]+ for record in snapshot.works {+ let work = Work(+ id: record.id,+ displayTitle: record.displayTitle,+ siteHostname: record.siteHostname,+ timestamp: record.createdAt+ )+ work.lastParsedTitle = record.lastParsedTitle+ work.urlIdentity = record.urlIdentity+ work.workURLString = record.workURL+ work.genericNotes = record.genericNotes+ work.typeRaw = record.type.rawValue+ work.genreTags = record.genreTags+ work.titleProvenanceRaw = record.titleProvenance.rawValue+ work.createdAt = record.createdAt+ work.modifiedAt = record.modifiedAt+ context.insert(work)+ works[record.id] = work+ }++ for record in snapshot.entries {+ let entry = Entry(+ id: record.id,+ captureTitle: record.captureTitle,+ captureTitleSource: record.captureTitleSource,+ rawURLString: record.rawURL,+ canonicalURLString: record.canonicalURL,+ hostname: record.hostname,+ entryIdentityKey: record.entryIdentityKey,+ timestamp: record.firstCapturedAt,+ note: record.note,+ rating: record.rating,+ work: try record.workID.map { workID in+ guard let work = works[workID] else {+ throw LibraryRepositoryError.recordNotFound(type: "Work", id: workID)+ }+ return work+ }+ )+ entry.identityKeyVersion = record.identityKeyVersion+ entry.chapterTitle = record.chapterTitle+ entry.chapterTitleProvenanceRaw = record.chapterTitleProvenance.kind.rawValue+ entry.chapterPatternID = record.chapterTitleProvenance.patternID+ entry.chapterPatternVersion = record.chapterTitleProvenance.patternVersion+ entry.firstCapturedAt = record.firstCapturedAt+ entry.lastSharedAt = record.lastSharedAt+ entry.modifiedAt = record.modifiedAt+ entry.workAssignmentProvenanceRaw = record.workAssignmentProvenance.kind.rawValue+ entry.workPatternID = record.workAssignmentProvenance.patternID+ entry.workPatternVersion = record.workAssignmentProvenance.patternVersion+ entry.intentionallyUnattached = record.intentionallyUnattached+ context.insert(entry)+ }+ try context.save()+ } catch {+ cleanup(storeURL: storeURL)+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "materializing migrated V2 graph",+ reason: String(describing: error)+ )+ }+ }++ public static func readSnapshot(+ from storeURL: URL,+ capabilities: M2Capabilities+ ) throws -> LibraryBackupSnapshot {+ guard FileManager.default.fileExists(atPath: storeURL.path) else {+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "reopening migrated V2 store",+ reason: "destination store is missing"+ )+ }+ do {+ let container = try makeContainer(at: storeURL)+ let context = ModelContext(container)+ let entries = try context.fetch(FetchDescriptor<Entry>())+ let works = try context.fetch(FetchDescriptor<Work>())+ let sites = try context.fetch(FetchDescriptor<Site>())+ let patterns = try context.fetch(FetchDescriptor<TitlePattern>())+ let snapshot = LibraryBackupSnapshot(+ entries: try entries.map(LibraryRepository.mapEntryRecord)+ .sorted { uuidOrder($0.id, $1.id) },+ works: try works.map(LibraryRepository.mapWorkRecord)+ .sorted { uuidOrder($0.id, $1.id) },+ sites: try sites.map(LibraryRepository.mapSiteRecord)+ .sorted { $0.hostname < $1.hostname },+ titlePatterns: try patterns.map(LibraryRepository.mapTitlePatternRecord)+ .sorted { uuidOrder($0.id, $1.id) }+ )+ try V2LibraryValidator.validate(snapshot: snapshot, capabilities: capabilities)+ return snapshot+ } catch let error as LibraryRepositoryError {+ throw error+ } catch let error as BackupValidationError {+ throw error+ } catch {+ throw LibraryRepositoryError.libraryUnavailable(+ operation: "reopening and verifying migrated V2 store",+ reason: String(describing: error)+ )+ }+ }++ public static func cleanup(storeURL: URL) {+ for url in artifactURLs(for: storeURL) {+ try? FileManager.default.removeItem(at: url)+ }+ }++ private static func makeContainer(at storeURL: URL) throws -> ModelContainer {+ let schema = Schema(versionedSchema: AsterismSchemaV2.self)+ let configuration = ModelConfiguration(+ "AsterismV2",+ schema: schema,+ url: storeURL,+ cloudKitDatabase: .none+ )+ return try ModelContainer(+ for: schema,+ migrationPlan: AsterismV2MigrationPlan.self,+ configurations: [configuration]+ )+ }++ private static func uuidOrder(_ left: UUID, _ right: UUID) -> Bool {+ left.uuidString.lowercased() < right.uuidString.lowercased()+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ValueObjects.swift b/Packages/AsterismCore/Sources/AsterismCore/ValueObjects.swiftindex 4877c40..919a47e 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ValueObjects.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ValueObjects.swift@@ -81,6 +81,12 @@ public struct JunkSuffixRule: Codable, Equatable, Sendable { public init(version: Int, anchors: [SegmentPositionSpec]) throws { guard version > 0 else { throw ModelInvariantError.nonPositiveVersion(field: "junk suffix rule version") }+ let offsets = anchors.map(\.offset)+ guard !anchors.isEmpty,+ anchors.allSatisfy({ $0.origin == .end }),+ offsets == Array((0..<anchors.count).reversed()) else {+ throw ModelInvariantError.invalidCombination(field: "junk suffix rule anchors")+ } self.version = version self.anchors = anchors }
diff --git a/Packages/AsterismCore/Sources/AsterismMigrationTool/main.swift b/Packages/AsterismCore/Sources/AsterismMigrationTool/main.swiftnew file mode 100644index 0000000..c1804d2--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismMigrationTool/main.swift@@ -0,0 +1,114 @@+import AsterismCore+import AsterismV1MigrationSupport+import Foundation++@main+enum AsterismMigrationTool {+ static func main() async {+ do {+ let options = try Options(arguments: Array(CommandLine.arguments.dropFirst()))+ let configuration: LibraryConfiguration+ if let root = options.rootDirectory {+ configuration = LibraryConfiguration(+ rootDirectory: root,+ environment: options.environment+ )+ } else {+ configuration = try LibraryConfiguration.production(+ environment: options.environment+ )+ }++ try await V1ToV2Migrator.migrate(+ configuration: configuration,+ processChecker: SystemRuntimeProcessChecker(),+ verifier: LogicalMigrationVerifier(),+ capabilities: .m2_0+ )+ print("Migrated V1 to verified V2 at \(configuration.storeURL.path)")+ print("Open Asterism to validate the store and write runtime readiness.")+ } catch {+ FileHandle.standardError.write(+ Data("migrate-m1-to-m2: \(error)\n".utf8)+ )+ exit(1)+ }+ }+}++private struct Options {+ let rootDirectory: URL?+ let environment: LibraryEnvironment++ init(arguments: [String]) throws {+ var root: URL?+ var environment = LibraryEnvironment.development+ var index = 0+ while index < arguments.count {+ switch arguments[index] {+ case "--root":+ index += 1+ guard index < arguments.count, !arguments[index].isEmpty else {+ throw ToolError.invalidArguments("--root requires an absolute directory path")+ }+ let candidate = URL(filePath: arguments[index], directoryHint: .isDirectory)+ guard candidate.path.hasPrefix("/") else {+ throw ToolError.invalidArguments("--root must be absolute")+ }+ root = candidate+ case "--environment":+ index += 1+ guard index < arguments.count,+ let value = LibraryEnvironment(rawValue: arguments[index]) else {+ throw ToolError.invalidArguments("--environment must be personal or development")+ }+ environment = value+ default:+ throw ToolError.invalidArguments("unknown argument \(arguments[index])")+ }+ index += 1+ }+ rootDirectory = root+ self.environment = environment+ }+}++private struct SystemRuntimeProcessChecker: MigrationRuntimeProcessChecking {+ func requireRuntimeProcessesClosed() throws {+ for processName in ["Asterism", "AsterismShareExtension"] {+ let process = Process()+ process.executableURL = URL(filePath: "/usr/bin/pgrep")+ process.arguments = ["-x", processName]+ process.standardOutput = FileHandle.nullDevice+ process.standardError = FileHandle.nullDevice+ do {+ try process.run()+ process.waitUntilExit()+ } catch {+ throw ToolError.processCheckFailed(+ "could not inspect \(processName): \(error)"+ )+ }+ if process.terminationStatus == 0 {+ throw V1ToV2MigrationError.runtimeProcessesOpen+ }+ guard process.terminationStatus == 1 else {+ throw ToolError.processCheckFailed(+ "pgrep failed for \(processName) with status \(process.terminationStatus)"+ )+ }+ }+ }+}++private enum ToolError: Error, CustomStringConvertible {+ case invalidArguments(String)+ case processCheckFailed(String)++ var description: String {+ switch self {+ case .invalidArguments(let reason): "Invalid arguments: \(reason)"+ case .processCheckFailed(let reason): "Runtime process check failed: \(reason)"+ }+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swift b/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swiftindex 2366778..5e25786 100644--- a/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swift+++ b/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swift@@ -49,7 +49,10 @@ enum AsterismStoreTestHelper { rootDirectory: URL(filePath: arguments[0], directoryHint: .isDirectory), environment: environment )- let repository = try await LibraryRepository.open(configuration)+ let repository = try await LibraryRepository.openForApp(+ configuration,+ capabilities: .current+ ) _ = try await repository.debugCounts() } }
diff --git a/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1Models.swift b/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1Models.swiftnew file mode 100644index 0000000..1458b1a--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1Models.swift@@ -0,0 +1,146 @@+import AsterismCore+import Foundation+import SwiftData++/// Frozen V1 schema used only by the explicit developer migration target.+public enum AsterismSchemaV1: VersionedSchema {+ public static let versionIdentifier = Schema.Version(1, 0, 0)++ public static var models: [any PersistentModel.Type] {+ [Entry.self, Work.self, Site.self, TitlePattern.self]+ }+}++public enum AsterismV1MigrationPlan: SchemaMigrationPlan {+ public static var schemas: [any VersionedSchema.Type] { [AsterismSchemaV1.self] }+ public static var stages: [MigrationStage] { [] }+}++@Model+public final class Entry {+ public var id: UUID = UUID()+ public var captureTitle: String = ""+ public var captureTitleSourceRaw: String = CaptureTitleSource.manual.rawValue+ public var rawURLString: String = ""+ public var canonicalURLString: String?+ public var hostname: String = ""+ public var entryIdentityKey: String = ""+ public var identityKeyVersion: Int = 1+ public var chapterTitle: String?+ public var chapterTitleProvenanceRaw: String = FieldProvenanceKind.none.rawValue+ public var chapterPatternID: UUID?+ public var chapterPatternVersion: Int?+ public var note: String = ""+ public var ratingRaw: String?+ public var firstCapturedAt: Date = Date(timeIntervalSince1970: 0)+ public var lastSharedAt: Date = Date(timeIntervalSince1970: 0)+ public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+ public var work: Work?+ public var workAssignmentProvenanceRaw: String = FieldProvenanceKind.none.rawValue+ public var workPatternID: UUID?+ public var workPatternVersion: Int?+ public var intentionallyUnattached: Bool = false++ public init(+ id: UUID = UUID(),+ captureTitle: String,+ captureTitleSourceRaw: String,+ rawURLString: String,+ canonicalURLString: String? = nil,+ hostname: String,+ entryIdentityKey: String,+ timestamp: Date,+ note: String = "",+ ratingRaw: String? = nil,+ work: Work? = nil+ ) {+ self.id = id+ self.captureTitle = captureTitle+ self.captureTitleSourceRaw = captureTitleSourceRaw+ self.rawURLString = rawURLString+ self.canonicalURLString = canonicalURLString+ self.hostname = hostname+ self.entryIdentityKey = entryIdentityKey+ self.note = note+ self.ratingRaw = ratingRaw+ firstCapturedAt = timestamp+ lastSharedAt = timestamp+ modifiedAt = timestamp+ self.work = work+ }+}++@Model+public final class Work {+ public var id: UUID = UUID()+ public var displayTitle: String = ""+ public var lastParsedTitle: String?+ public var siteHostname: String = ""+ public var urlIdentity: String?+ public var workURLString: String?+ public var genericNotes: String = ""+ public var typeRaw: String = WorkType.other.rawValue+ public var genreTags: [String] = []+ public var titleProvenanceRaw: String = TitleProvenance.manual.rawValue+ public var createdAt: Date = Date(timeIntervalSince1970: 0)+ public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+ @Relationship(deleteRule: .nullify, inverse: \Entry.work)+ public var entries: [Entry]?++ public init(id: UUID = UUID(), displayTitle: String, siteHostname: String, timestamp: Date) {+ self.id = id+ self.displayTitle = displayTitle+ self.siteHostname = siteHostname+ createdAt = timestamp+ modifiedAt = timestamp+ }++ public var entryValues: [Entry] { entries ?? [] }+}++@Model+public final class Site {+ public var hostname: String = ""+ public var displayName: String = ""+ public var modeRaw: String = SiteMode.untaught.rawValue+ @Relationship(deleteRule: .cascade, inverse: \TitlePattern.site)+ public var patterns: [TitlePattern]?+ public var urlIdentityRule: URLIdentityRule?+ public var junkSuffixRule: JunkSuffixRule?++ public init(hostname: String, displayName: String? = nil) {+ self.hostname = hostname+ self.displayName = displayName ?? hostname+ }++ public var patternValues: [TitlePattern] { patterns ?? [] }+}++@Model+public final class TitlePattern {+ public var id: UUID = UUID()+ public var version: Int = 1+ public var isActive: Bool = false+ public var createdAt: Date = Date(timeIntervalSince1970: 0)+ public var workAnchor: SegmentRangeSpec = try! SegmentRangeSpec(origin: .start, offset: 0, length: 1)+ public var junkAnchors: [SegmentPositionSpec] = []+ public var site: Site?++ public init(+ id: UUID = UUID(),+ version: Int,+ isActive: Bool = false,+ createdAt: Date,+ workAnchor: SegmentRangeSpec,+ junkAnchors: [SegmentPositionSpec] = [],+ site: Site? = nil+ ) {+ self.id = id+ self.version = version+ self.isActive = isActive+ self.createdAt = createdAt+ self.workAnchor = workAnchor+ self.junkAnchors = junkAnchors+ self.site = site+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1StoreReader.swift b/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1StoreReader.swiftnew file mode 100644index 0000000..8b7e62b--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1StoreReader.swift@@ -0,0 +1,151 @@+import AsterismCore+import Foundation+import SwiftData++public enum V1StoreReader {+ public static func readSnapshot(from storeURL: URL) throws -> LibraryBackupSnapshot {+ guard FileManager.default.fileExists(atPath: storeURL.path) else {+ throw V1ToV2MigrationError.sourceMissing(storeURL)+ }+ do {+ let schema = Schema(versionedSchema: AsterismSchemaV1.self)+ let configuration = ModelConfiguration(+ "AsterismV1",+ schema: schema,+ url: storeURL,+ cloudKitDatabase: .none+ )+ let container = try ModelContainer(+ for: schema,+ migrationPlan: AsterismV1MigrationPlan.self,+ configurations: [configuration]+ )+ let context = ModelContext(container)+ let entries = try context.fetch(FetchDescriptor<Entry>())+ let works = try context.fetch(FetchDescriptor<Work>())+ let sites = try context.fetch(FetchDescriptor<Site>())+ let patterns = try context.fetch(FetchDescriptor<TitlePattern>())++ return LibraryBackupSnapshot(+ entries: try entries.map(mapEntry).sorted(by: idOrder),+ works: try works.map(mapWork).sorted(by: idOrder),+ sites: try sites.map(mapSite).sorted { $0.hostname < $1.hostname },+ titlePatterns: try patterns.map(mapPattern).sorted(by: idOrder)+ )+ } catch let error as V1ToV2MigrationError {+ throw error+ } catch {+ throw V1ToV2MigrationError.readFailed(reason: String(describing: error))+ }+ }++ private static func mapEntry(_ entry: Entry) throws -> EntryRecord {+ guard let source = CaptureTitleSource(rawValue: entry.captureTitleSourceRaw) else {+ throw V1ToV2MigrationError.readFailed(reason: "Entry has invalid capture title source")+ }+ let rating: Rating?+ if let raw = entry.ratingRaw {+ guard let value = Rating(rawValue: raw) else {+ throw V1ToV2MigrationError.readFailed(reason: "Entry has invalid rating")+ }+ rating = value+ } else {+ rating = nil+ }+ guard let chapterKind = FieldProvenanceKind(rawValue: entry.chapterTitleProvenanceRaw),+ let assignmentKind = FieldProvenanceKind(rawValue: entry.workAssignmentProvenanceRaw) else {+ throw V1ToV2MigrationError.readFailed(reason: "Entry has invalid provenance kind")+ }+ return EntryRecord(+ id: entry.id,+ captureTitle: entry.captureTitle,+ captureTitleSource: source,+ rawURL: entry.rawURLString,+ canonicalURL: entry.canonicalURLString,+ hostname: entry.hostname,+ entryIdentityKey: entry.entryIdentityKey,+ identityKeyVersion: entry.identityKeyVersion,+ chapterTitle: entry.chapterTitle,+ chapterTitleProvenance: try FieldProvenance(+ kind: chapterKind,+ patternID: entry.chapterPatternID,+ patternVersion: entry.chapterPatternVersion+ ),+ note: entry.note,+ rating: rating,+ firstCapturedAt: entry.firstCapturedAt,+ lastSharedAt: entry.lastSharedAt,+ modifiedAt: entry.modifiedAt,+ workID: entry.work?.id,+ workAssignmentProvenance: try FieldProvenance(+ kind: assignmentKind,+ patternID: entry.workPatternID,+ patternVersion: entry.workPatternVersion+ ),+ intentionallyUnattached: entry.intentionallyUnattached+ )+ }++ private static func mapWork(_ work: Work) throws -> WorkRecord {+ guard let type = WorkType(rawValue: work.typeRaw),+ let provenance = TitleProvenance(rawValue: work.titleProvenanceRaw) else {+ throw V1ToV2MigrationError.readFailed(reason: "Work has invalid enum value")+ }+ return WorkRecord(+ id: work.id,+ displayTitle: work.displayTitle,+ lastParsedTitle: work.lastParsedTitle,+ siteHostname: work.siteHostname,+ urlIdentity: work.urlIdentity,+ workURL: work.workURLString,+ genericNotes: work.genericNotes,+ type: type,+ genreTags: work.genreTags,+ titleProvenance: provenance,+ createdAt: work.createdAt,+ modifiedAt: work.modifiedAt,+ entryIDs: work.entryValues.map(\.id).sorted(by: uuidOrder)+ )+ }++ private static func mapSite(_ site: Site) throws -> SiteRecord {+ guard let mode = SiteMode(rawValue: site.modeRaw) else {+ throw V1ToV2MigrationError.readFailed(reason: "Site has invalid mode")+ }+ return SiteRecord(+ hostname: site.hostname,+ displayName: site.displayName,+ mode: mode,+ patternIDs: site.patternValues.map(\.id).sorted(by: uuidOrder),+ urlIdentityRule: site.urlIdentityRule,+ junkSuffixRule: site.junkSuffixRule+ )+ }++ private static func mapPattern(_ pattern: TitlePattern) throws -> TitlePatternRecord {+ guard let hostname = pattern.site?.hostname else {+ throw V1ToV2MigrationError.readFailed(reason: "TitlePattern has no Site")+ }+ return TitlePatternRecord(+ id: pattern.id,+ version: pattern.version,+ isActive: pattern.isActive,+ createdAt: pattern.createdAt,+ definition: .segment(work: pattern.workAnchor, ignored: pattern.junkAnchors),+ siteHostname: hostname+ )+ }++ private static func idOrder<T>(_ left: T, _ right: T) -> Bool where T: MigrationIdentified {+ uuidOrder(left.id, right.id)+ }++ private static func uuidOrder(_ left: UUID, _ right: UUID) -> Bool {+ left.uuidString.lowercased() < right.uuidString.lowercased()+ }+}++private protocol MigrationIdentified { var id: UUID { get } }+extension EntryRecord: MigrationIdentified {}+extension WorkRecord: MigrationIdentified {}+extension TitlePatternRecord: MigrationIdentified {}
diff --git a/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1ToV2Migrator.swift b/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1ToV2Migrator.swiftnew file mode 100644index 0000000..90e0475--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1ToV2Migrator.swift@@ -0,0 +1,142 @@+import AsterismCore+import Foundation++public protocol MigrationRuntimeProcessChecking: Sendable {+ func requireRuntimeProcessesClosed() throws+}++public protocol MigrationVerifying: Sendable {+ func verify(+ source: LibraryBackupSnapshot,+ destination: LibraryBackupSnapshot+ ) throws+}++public struct LogicalMigrationVerifier: MigrationVerifying {+ public init() {}++ public func verify(+ source: LibraryBackupSnapshot,+ destination: LibraryBackupSnapshot+ ) throws {+ guard source == destination else {+ throw V1ToV2MigrationError.verificationFailed(+ reason: "reopened destination differs from the V1 logical graph"+ )+ }+ }+}++public enum V1ToV2MigrationError: Error, Sendable, CustomStringConvertible {+ case runtimeProcessesOpen+ case sourceMissing(URL)+ case destinationExists(URL)+ case readFailed(reason: String)+ case verificationFailed(reason: String)+ case sourceChangedDuringMigration+ case operationFailed(operation: String, reason: String)++ public var description: String {+ switch self {+ case .runtimeProcessesOpen:+ "Asterism app or extension is running; close both before migration"+ case .sourceMissing(let url):+ "V1 source is missing at \(url.path)"+ case .destinationExists(let url):+ "V2 destination already exists at \(url.path)"+ case .readFailed(let reason):+ "Reading the V1 source failed: \(reason)"+ case .verificationFailed(let reason):+ "Verifying the migrated V2 graph failed: \(reason)"+ case .sourceChangedDuringMigration:+ "The V1 source changed while migration was running"+ case .operationFailed(let operation, let reason):+ "Migration failed while \(operation): \(reason)"+ }+ }+}++public enum V1ToV2Migrator {+ public static func migrate(+ configuration: LibraryConfiguration,+ processChecker: any MigrationRuntimeProcessChecking,+ verifier: any MigrationVerifying = LogicalMigrationVerifier(),+ capabilities: M2Capabilities = .m2_0+ ) async throws {+ do {+ try processChecker.requireRuntimeProcessesClosed()+ } catch let error as V1ToV2MigrationError {+ throw error+ } catch {+ throw V1ToV2MigrationError.operationFailed(+ operation: "checking runtime processes",+ reason: String(describing: error)+ )+ }++ let fileManager = FileManager.default+ guard fileManager.fileExists(atPath: configuration.legacyStoreURL.path) else {+ throw V1ToV2MigrationError.sourceMissing(configuration.legacyStoreURL)+ }+ let destinationArtifacts = V2MigrationStore.artifactURLs(for: configuration.storeURL)+ guard !destinationArtifacts.contains(where: { fileManager.fileExists(atPath: $0.path) }),+ !fileManager.fileExists(atPath: configuration.markerURL.path) else {+ throw V1ToV2MigrationError.destinationExists(configuration.storeURL)+ }++ let lease: LockLease+ do {+ lease = try await CrossProcessLibraryLock.acquire(+ mode: .exclusive,+ at: configuration.lockURL,+ timeout: .seconds(5)+ )+ } catch {+ throw V1ToV2MigrationError.operationFailed(+ operation: "acquiring exclusive library lock",+ reason: String(describing: error)+ )+ }+ defer { withExtendedLifetime(lease) {} }++ var ownsDestination = false+ do {+ let source = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)+ try V2LibraryValidator.validate(snapshot: source, capabilities: capabilities)++ ownsDestination = true+ try V2MigrationStore.create(+ snapshot: source,+ at: configuration.storeURL,+ capabilities: capabilities+ )+ let destination = try V2MigrationStore.readSnapshot(+ from: configuration.storeURL,+ capabilities: capabilities+ )+ try verifier.verify(source: source, destination: destination)++ let sourceAfter = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)+ guard sourceAfter == source else {+ throw V1ToV2MigrationError.sourceChangedDuringMigration+ }+ guard !fileManager.fileExists(atPath: configuration.markerURL.path) else {+ throw V1ToV2MigrationError.verificationFailed(+ reason: "migration must not write runtime readiness"+ )+ }+ } catch {+ if ownsDestination {+ V2MigrationStore.cleanup(storeURL: configuration.storeURL)+ try? fileManager.removeItem(at: configuration.markerURL)+ }+ if let migrationError = error as? V1ToV2MigrationError {+ throw migrationError+ }+ throw V1ToV2MigrationError.operationFailed(+ operation: "copying and verifying the logical graph",+ reason: String(describing: error)+ )+ }+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesModeTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesModeTests.swiftnew file mode 100644index 0000000..dae2f00--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ArticlesModeTests.swift@@ -0,0 +1,128 @@+import Foundation+import Testing+@testable import AsterismCore++@Suite("M2.2 articles-mode planning", .serialized)+struct ArticlesModePlannerTests {+ private let none = try! FieldProvenance(kind: .none)++ @Test("Suffix authoring stores contiguous end anchors and preserves exact source prefix")+ func suffixAuthoringAndCleaning() throws {+ let derivedRule = try ArticleTitleCleaner.deriveRule(+ from: "Story Title — Example Site | Archive",+ suffixSegmentCount: 2+ )+ let rule = try #require(derivedRule)++ #expect(rule.version == 1)+ #expect(rule.anchors == [+ try SegmentPositionSpec(origin: .end, offset: 1),+ try SegmentPositionSpec(origin: .end, offset: 0),+ ])+ #expect(ArticleTitleCleaner.clean(+ "Another Story — Example Site | Archive",+ using: rule+ ) == "Another Story")+ }++ @Test("Suffix authoring rejects blank examples and cleaning falls back for short titles")+ func suffixValidationAndFallback() throws {+ #expect(throws: ArticleTitleCleaningError.self) {+ _ = try ArticleTitleCleaner.deriveRule(from: "Story — Site", suffixSegmentCount: 2)+ }++ let derivedRule = try ArticleTitleCleaner.deriveRule(+ from: "Story — Site",+ suffixSegmentCount: 1+ )+ let rule = try #require(derivedRule)+ #expect(ArticleTitleCleaner.clean("Undelimited title", using: rule) == "Undelimited title")+ #expect(ArticleTitleCleaner.clean("Only — Site", using: rule) == "Only")+ #expect(try ArticleTitleCleaner.deriveRule(from: "Story — Site", suffixSegmentCount: 0) == nil)+ }++ @Test("Article projection preserves manual fields, clears derived fields, and settles every entry")+ func projectionProtectsManualFields() throws {+ let patternID = UUID()+ let manualWorkID = UUID()+ let entries = [+ makeEntry(+ title: "Manual chapter — Site",+ chapter: "Curated chapter",+ chapterProvenance: try FieldProvenance(kind: .manual),+ assignmentProvenance: try FieldProvenance(+ kind: .pattern,+ patternID: patternID,+ patternVersion: 1+ )+ ),+ makeEntry(+ title: "Manual work — Site",+ chapter: "Derived chapter",+ chapterProvenance: try FieldProvenance(+ kind: .pattern,+ patternID: patternID,+ patternVersion: 1+ ),+ workID: manualWorkID,+ assignmentProvenance: try FieldProvenance(kind: .manual)+ ),+ ]+ let derivedRule = try ArticleTitleCleaner.deriveRule(+ from: entries[0].captureTitle,+ suffixSegmentCount: 1+ )+ let rule = try #require(derivedRule)++ let plan = TitleProjectionPlanner.planArticles(entries: entries, junkSuffixRule: rule)+ let projections = Dictionary(uniqueKeysWithValues: plan.entryProjections.map { ($0.entryID, $0) })+ let first = try #require(projections[entries[0].id])+ let second = try #require(projections[entries[1].id])++ #expect(first.projectedChapter == "Curated chapter")+ #expect(first.chapterProvenance == .preserved(entries[0].chapterTitleProvenance))+ #expect(first.projectedWorkID == nil)+ #expect(first.assignmentProvenance == .cleared)+ #expect(first.intentionallyUnattached)+ #expect(first.displayTitle == "Manual chapter")+ #expect(!first.actionableAfter)++ #expect(second.projectedChapter == nil)+ #expect(second.chapterProvenance == .cleared)+ #expect(second.projectedWorkID == manualWorkID)+ #expect(second.assignmentProvenance == .preserved(entries[1].workAssignmentProvenance))+ #expect(!second.intentionallyUnattached)+ #expect(!second.actionableAfter)+ }++ private func makeEntry(+ title: String,+ chapter: String? = nil,+ chapterProvenance: FieldProvenance? = nil,+ workID: UUID? = nil,+ assignmentProvenance: FieldProvenance? = nil+ ) -> EntrySnapshot {+ let id = UUID()+ let date = Date(timeIntervalSince1970: 1_750_000_000)+ return EntrySnapshot(+ id: id,+ captureTitle: title,+ captureTitleSource: .safariDocument,+ rawURLString: "https://example.com/\(id)",+ canonicalURLString: nil,+ hostname: "example.com",+ entryIdentityKey: id.uuidString,+ identityKeyVersion: 1,+ chapterTitle: chapter,+ chapterTitleProvenance: chapterProvenance ?? none,+ note: "",+ rating: nil,+ firstCapturedAt: date,+ lastSharedAt: date,+ modifiedAt: date,+ workID: workID,+ workAssignmentProvenance: assignmentProvenance ?? none,+ intentionallyUnattached: false+ )+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExporterTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExporterTests.swiftindex f616aa5..8466c1a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExporterTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExporterTests.swift@@ -66,14 +66,14 @@ struct BackupExporterTests { _ = try await repo.capture(CaptureDraft(captureTitle: "Test", captureTitleSource: .manual, rawURLString: "https://e.test/p")) let exporter = BackupExporter(repository: repo, stagingDirectory: tempDir.appending(path: "BackupExports"))- let result = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: Date()))+ let result = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 2, exportedAt: Date())) #expect(FileManager.default.fileExists(atPath: result.fileURL.path)) let data = try Data(contentsOf: result.fileURL) #expect(!data.isEmpty) // The file must be a valid backup- let decoded = try BackupV1Codec.decode(data)- #expect(decoded.snapshot.entries.count == 1)+ let decoded = try BackupV2Codec.decode(data, capabilities: .current)+ #expect(decoded.payload.entries.count == 1) } @Test("Export file contains the exact validated bytes")@@ -86,12 +86,11 @@ struct BackupExporterTests { _ = try await repo.capture(CaptureDraft(captureTitle: "Verify", captureTitleSource: .manual, rawURLString: "https://v.test/x")) let exporter = BackupExporter(repository: repo, stagingDirectory: tempDir.appending(path: "BackupExports"))- let result = try await exporter.export(metadata: BackupMetadata(appBuild: "2", databaseSchemaVersion: 1, exportedAt: Date()))+ let result = try await exporter.export(metadata: BackupMetadata(appBuild: "2", databaseSchemaVersion: 2, exportedAt: Date())) let data = try Data(contentsOf: result.fileURL)- let decoded = try BackupV1Codec.decode(data)- // Validator must pass on the exact file bytes- try BackupV1Validator.validate(decoded: decoded, source: decoded.snapshot, encodedData: data)+ let decoded = try BackupV2Codec.decode(data, capabilities: .current)+ #expect(decoded.databaseSchemaVersion == 2) } @Test("Empty library produces a valid zero-count backup")@@ -103,12 +102,12 @@ struct BackupExporterTests { let repo = try await LibraryRepository.open(config) let exporter = BackupExporter(repository: repo, stagingDirectory: tempDir.appending(path: "BackupExports"))- let result = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: Date()))+ let result = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 2, exportedAt: Date())) let data = try Data(contentsOf: result.fileURL)- let decoded = try BackupV1Codec.decode(data)- #expect(decoded.header.entryCount == 0)- #expect(decoded.header.workCount == 0)+ let decoded = try BackupV2Codec.decode(data, capabilities: .current)+ #expect(decoded.payload.entries.isEmpty)+ #expect(decoded.payload.works.isEmpty) } // MARK: - Failure Behavior@@ -125,7 +124,7 @@ struct BackupExporterTests { stagingDirectory: stagingDir ) do {- _ = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: Date()))+ _ = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 2, exportedAt: Date())) Issue.record("Expected export to throw") } catch { // Verify no file was left behind@@ -147,7 +146,7 @@ struct BackupExporterTests { let repo = try await LibraryRepository.open(config) let exporter = BackupExporter(repository: repo, stagingDirectory: tempDir.appending(path: "BackupExports"))- let result = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: Date()))+ let result = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 2, exportedAt: Date())) #expect(FileManager.default.fileExists(atPath: result.fileURL.path)) exporter.cleanup(result)@@ -194,7 +193,7 @@ struct BackupExporterTests { let countsBefore = try await repo.debugCounts() let exporter = BackupExporter(repository: repo, stagingDirectory: tempDir.appending(path: "BackupExports"))- _ = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: Date()))+ _ = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 2, exportedAt: Date())) let countsAfter = try await repo.debugCounts() #expect(countsBefore == countsAfter, "Backup must not change library record counts")@@ -209,7 +208,7 @@ struct BackupExporterTests { let repo = try await LibraryRepository.open(config) let exporter = BackupExporter(repository: repo, stagingDirectory: tempDir.appending(path: "BackupExports"))- let result = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: Date()))+ let result = try await exporter.export(metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 2, exportedAt: Date())) let filename = result.fileURL.lastPathComponent #expect(filename.hasPrefix("Asterism-backup-")) #expect(filename.hasSuffix(".json"))
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1CodecTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1CodecTests.swiftdeleted file mode 100644index 8b7b2b1..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1CodecTests.swift+++ /dev/null@@ -1,226 +0,0 @@-import Foundation-import Testing--@testable import AsterismCore--// MARK: - Test Helpers--/// Loads a golden fixture file from the Fixtures/BackupV1 directory.-private func loadFixture(_ name: String) throws -> Data {- let url = URL(fileURLWithPath: #filePath)- .deletingLastPathComponent()- .appending(path: "Fixtures/BackupV1/\(name)")- return try Data(contentsOf: url)-}--/// Reference date helper: parses RFC 3339 with milliseconds.-private func date(_ rfc3339: String) -> Date {- let formatter = ISO8601DateFormatter()- formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]- return formatter.date(from: rfc3339)!-}--/// Well-known UUIDs used in golden fixtures.-private enum FixtureIDs {- static let entry1 = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!- static let entry2 = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!- static let work1 = UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")!- static let pattern1 = UUID(uuidString: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")!- static let pattern2 = UUID(uuidString: "cccccccc-cccc-cccc-cccc-cccccccccccc")!-}--// MARK: - Expected Snapshots for Golden Fixtures--private let expectedEmptySnapshot = LibraryBackupSnapshot.empty--private let expectedNonEmptySnapshot: LibraryBackupSnapshot = {- let entry1 = EntryRecord(- id: FixtureIDs.entry1,- captureTitle: "Chapter 1 — 日本語テスト",- captureTitleSource: .safariDocument,- rawURL: "https://example.com/manga/123/1?utm_source=twitter",- canonicalURL: "https://example.com/chapter-1",- hostname: "example.com",- entryIdentityKey: "https://example.com/manga/123/1",- identityKeyVersion: 1,- chapterTitle: "Chapter 1",- chapterTitleProvenance: try! FieldProvenance(- kind: .pattern, patternID: FixtureIDs.pattern1, patternVersion: 2- ),- note: "Great chapter with émojis 🎉 and\nnewlines",- rating: .up,- firstCapturedAt: date("2026-07-01T10:00:00.000Z"),- lastSharedAt: date("2026-07-01T10:00:00.000Z"),- modifiedAt: date("2026-07-02T15:30:00.500Z"),- workID: FixtureIDs.work1,- workAssignmentProvenance: try! FieldProvenance(kind: .manual),- intentionallyUnattached: false- )- let entry2 = EntryRecord(- id: FixtureIDs.entry2,- captureTitle: "Untitled Page",- captureTitleSource: .manual,- rawURL: "https://other.test/article",- canonicalURL: nil,- hostname: "other.test",- entryIdentityKey: "https://other.test/article",- identityKeyVersion: 1,- chapterTitle: nil,- chapterTitleProvenance: try! FieldProvenance(kind: .none),- note: "",- rating: nil,- firstCapturedAt: date("2026-07-03T08:15:00.123Z"),- lastSharedAt: date("2026-07-03T08:15:00.123Z"),- modifiedAt: date("2026-07-03T08:15:00.123Z"),- workID: nil,- workAssignmentProvenance: try! FieldProvenance(kind: .manual),- intentionallyUnattached: true- )- let work1 = WorkRecord(- id: FixtureIDs.work1,- displayTitle: "My Manga Series",- lastParsedTitle: "My Manga",- siteHostname: "example.com",- urlIdentity: nil,- workURL: "https://example.com/manga/123",- genericNotes: "Notes about the series",- type: .toon,- genreTags: ["fantasy", "action"],- titleProvenance: .manual,- createdAt: date("2026-07-01T09:00:00.000Z"),- modifiedAt: date("2026-07-02T15:30:00.500Z"),- entryIDs: [FixtureIDs.entry1]- )- let site1 = SiteRecord(- hostname: "example.com",- displayName: "example.com",- mode: .taught,- patternIDs: [FixtureIDs.pattern1, FixtureIDs.pattern2],- urlIdentityRule: try! URLIdentityRule(version: 1, component: .pathSegment, origin: .start, offset: 1),- junkSuffixRule: try! JunkSuffixRule(version: 1, anchors: [- try! SegmentPositionSpec(origin: .end, offset: 1),- ])- )- let site2 = SiteRecord(- hostname: "other.test",- displayName: "other.test",- mode: .untaught,- patternIDs: [],- urlIdentityRule: nil,- junkSuffixRule: nil- )- let pattern1 = TitlePatternRecord(- id: FixtureIDs.pattern1,- version: 2,- isActive: true,- createdAt: date("2026-06-15T12:00:00.000Z"),- workAnchor: try! SegmentRangeSpec(origin: .start, offset: 0, length: 2),- junkAnchors: [- try! SegmentPositionSpec(origin: .end, offset: 0),- try! SegmentPositionSpec(origin: .start, offset: 2),- ],- siteHostname: "example.com"- )- let pattern2 = TitlePatternRecord(- id: FixtureIDs.pattern2,- version: 1,- isActive: false,- createdAt: date("2026-06-10T08:00:00.000Z"),- workAnchor: try! SegmentRangeSpec(origin: .end, offset: 1, length: 1),- junkAnchors: [],- siteHostname: "example.com"- )- return LibraryBackupSnapshot(- entries: [entry1, entry2],- works: [work1],- sites: [site1, site2],- titlePatterns: [pattern1, pattern2]- )-}()--// MARK: - Golden Fixture Decode Tests--@Suite("BackupV1Codec Golden Fixtures")-struct BackupV1GoldenFixtureTests {- @Test("Decodes empty golden archive to empty snapshot")- func decodeEmptyGolden() throws {- let data = try loadFixture("empty-library.json")- let decoded = try BackupV1Codec.decode(data)- #expect(decoded.snapshot == expectedEmptySnapshot)- #expect(decoded.header.backupFormatVersion == 1)- #expect(decoded.header.databaseSchemaVersion == 1)- #expect(decoded.header.appBuild == "1")- #expect(decoded.header.entryCount == 0)- #expect(decoded.header.workCount == 0)- #expect(decoded.header.exportedAt == date("2026-07-17T00:00:00.000Z"))- }-- @Test("Decodes non-empty golden archive to expected snapshot")- func decodeNonEmptyGolden() throws {- let data = try loadFixture("non-empty-library.json")- let decoded = try BackupV1Codec.decode(data)- #expect(decoded.snapshot == expectedNonEmptySnapshot)- #expect(decoded.header.backupFormatVersion == 1)- #expect(decoded.header.databaseSchemaVersion == 1)- #expect(decoded.header.appBuild == "42")- #expect(decoded.header.entryCount == 2)- #expect(decoded.header.workCount == 1)- }-- @Test("Non-empty golden archive contains both provenances")- func goldenProvenances() throws {- let data = try loadFixture("non-empty-library.json")- let decoded = try BackupV1Codec.decode(data)- let entry1 = decoded.snapshot.entries.first { $0.id == FixtureIDs.entry1 }!- #expect(entry1.chapterTitleProvenance.kind == .pattern)- #expect(entry1.chapterTitleProvenance.patternID == FixtureIDs.pattern1)- #expect(entry1.chapterTitleProvenance.patternVersion == 2)- #expect(entry1.workAssignmentProvenance.kind == .manual)- #expect(entry1.workAssignmentProvenance.patternID == nil)- }-- @Test("Non-empty golden archive preserves Unicode and special characters")- func goldenUnicode() throws {- let data = try loadFixture("non-empty-library.json")- let decoded = try BackupV1Codec.decode(data)- let entry1 = decoded.snapshot.entries.first { $0.id == FixtureIDs.entry1 }!- #expect(entry1.captureTitle == "Chapter 1 — 日本語テスト")- #expect(entry1.note.contains("émojis"))- #expect(entry1.note.contains("🎉"))- #expect(entry1.note.contains("\n"))- }-- @Test("Non-empty golden archive preserves millisecond dates")- func goldenMillisecondDates() throws {- let data = try loadFixture("non-empty-library.json")- let decoded = try BackupV1Codec.decode(data)- let entry1 = decoded.snapshot.entries.first { $0.id == FixtureIDs.entry1 }!- #expect(entry1.modifiedAt == date("2026-07-02T15:30:00.500Z"))- let entry2 = decoded.snapshot.entries.first { $0.id == FixtureIDs.entry2 }!- #expect(entry2.firstCapturedAt == date("2026-07-03T08:15:00.123Z"))- }-- @Test("Non-empty golden archive preserves null optionals explicitly")- func goldenNulls() throws {- let data = try loadFixture("non-empty-library.json")- let decoded = try BackupV1Codec.decode(data)- let entry2 = decoded.snapshot.entries.first { $0.id == FixtureIDs.entry2 }!- #expect(entry2.canonicalURL == nil)- #expect(entry2.chapterTitle == nil)- #expect(entry2.rating == nil)- #expect(entry2.workID == nil)- }-- @Test("Non-empty golden archive has inverse-ID arrays sorted by lowercase UUID")- func goldenInverseIDOrdering() throws {- let data = try loadFixture("non-empty-library.json")- let decoded = try BackupV1Codec.decode(data)- let work = decoded.snapshot.works.first { $0.id == FixtureIDs.work1 }!- // entryIDs must be sorted by lowercase UUID string- let sortedIDs = work.entryIDs.sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() }- #expect(work.entryIDs == sortedIDs)- let site = decoded.snapshot.sites.first { $0.hostname == "example.com" }!- let sortedPatterns = site.patternIDs.sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() }- #expect(site.patternIDs == sortedPatterns)- }-}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1VerificationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1VerificationTests.swiftdeleted file mode 100644index 1a4ac9c..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1VerificationTests.swift+++ /dev/null@@ -1,233 +0,0 @@-import Foundation-import Testing-@testable import AsterismCore--private func date(_ rfc3339: String) -> Date {- let f = ISO8601DateFormatter()- f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]- return f.date(from: rfc3339)!-}--private func loadFixture(_ name: String) throws -> Data {- let url = URL(fileURLWithPath: #filePath).deletingLastPathComponent()- .appending(path: "Fixtures/BackupV1/\(name)")- return try Data(contentsOf: url)-}--// MARK: - Encoding Tests--@Suite("BackupV1Codec Encoding")-struct BackupV1EncodingTests {- private let metadata = BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: date("2026-07-17T00:00:00.000Z"))-- @Test("Encodes empty snapshot to valid canonical JSON") func encodeEmpty() throws {- let data = try BackupV1Codec.encode(snapshot: .empty, metadata: metadata)- #expect(!data.isEmpty)- #expect(String(data: data, encoding: .utf8) != nil)- }-- @Test("Empty encode matches golden fixture bytes exactly") func emptyMatchesGolden() throws {- let golden = try loadFixture("empty-library.json")- let encoded = try BackupV1Codec.encode(snapshot: .empty, metadata: metadata)- #expect(encoded == golden, "Canonical re-encode must produce byte-identical output")- }-- @Test("Canonical re-encode of decoded non-empty golden is byte-identical") func reEncodeNonEmpty() throws {- let golden = try loadFixture("non-empty-library.json")- let decoded = try BackupV1Codec.decode(golden)- let reMeta = BackupMetadata(appBuild: decoded.header.appBuild, databaseSchemaVersion: decoded.header.databaseSchemaVersion, exportedAt: decoded.header.exportedAt)- let reEncoded = try BackupV1Codec.encode(snapshot: decoded.snapshot, metadata: reMeta)- #expect(reEncoded == golden, "Canonical re-encode must produce byte-identical output")- }-- @Test("No BOM or trailing whitespace") func noBOMOrWhitespace() throws {- let bytes = [UInt8](try BackupV1Codec.encode(snapshot: .empty, metadata: metadata))- if bytes.count >= 3 { #expect(!(bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)) }- #expect(bytes.first == UInt8(ascii: "{")); #expect(bytes.last == UInt8(ascii: "}"))- }-- @Test("No insignificant whitespace") func noWhitespace() throws {- let s = String(data: try BackupV1Codec.encode(snapshot: .empty, metadata: metadata), encoding: .utf8)!- #expect(!s.contains(": ")); #expect(!s.contains(", "))- }-- @Test("Envelope has header before payload") func envelopeOrder() throws {- let s = String(data: try BackupV1Codec.encode(snapshot: .empty, metadata: metadata), encoding: .utf8)!- #expect(s.hasPrefix("{\"header\":"))- let h = s.range(of: "\"header\":")!.lowerBound- let p = s.range(of: "\"payload\":")!.lowerBound- #expect(h < p)- }-- @Test("Dates use RFC 3339 UTC with exactly three fractional digits") func dateFormat() throws {- let s = String(data: try BackupV1Codec.encode(snapshot: .empty, metadata: metadata), encoding: .utf8)!- let pattern = #/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/#- #expect(s.firstMatch(of: pattern) != nil)- }-- @Test("UUIDs are lowercase") func uuidLowercase() throws {- let snapshot = LibraryBackupSnapshot(entries: [], works: [WorkRecord(id: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!, displayTitle: "T", lastParsedTitle: nil, siteHostname: "t.com", urlIdentity: nil, workURL: nil, genericNotes: "", type: .other, genreTags: [], titleProvenance: .manual, createdAt: metadata.exportedAt, modifiedAt: metadata.exportedAt, entryIDs: [])], sites: [SiteRecord(hostname: "t.com", displayName: "t.com", mode: .untaught, patternIDs: [], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [])- let s = String(data: try BackupV1Codec.encode(snapshot: snapshot, metadata: metadata), encoding: .utf8)!- #expect(s.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"))- #expect(!s.contains("AAAAAAAA"))- }-}--// MARK: - Strict Decoding Tests--@Suite("BackupV1Codec Strict Decoding")-struct BackupV1StrictDecodingTests {- @Test("Rejects unknown top-level keys") func unknownTopLevel() {- let j = #"{"extra":true,"header":{"appBuild":"1","backupFormatVersion":1,"checksum":"a","databaseSchemaVersion":1,"entryCount":0,"exportedAt":"2026-07-17T00:00:00.000Z","workCount":0},"payload":{"entries":[],"sites":[],"titlePatterns":[],"works":[]}}"#- #expect(throws: (any Error).self) { try BackupV1Codec.decode(Data(j.utf8)) }- }- @Test("Rejects wrong format version") func wrongFormat() {- let j = #"{"header":{"appBuild":"1","backupFormatVersion":2,"checksum":"a","databaseSchemaVersion":1,"entryCount":0,"exportedAt":"2026-07-17T00:00:00.000Z","workCount":0},"payload":{"entries":[],"sites":[],"titlePatterns":[],"works":[]}}"#- #expect(throws: (any Error).self) { try BackupV1Codec.decode(Data(j.utf8)) }- }- @Test("Rejects wrong schema version") func wrongSchema() {- let j = #"{"header":{"appBuild":"1","backupFormatVersion":1,"checksum":"a","databaseSchemaVersion":2,"entryCount":0,"exportedAt":"2026-07-17T00:00:00.000Z","workCount":0},"payload":{"entries":[],"sites":[],"titlePatterns":[],"works":[]}}"#- #expect(throws: (any Error).self) { try BackupV1Codec.decode(Data(j.utf8)) }- }- @Test("Rejects trailing bytes") func trailing() throws {- var data = try loadFixture("empty-library.json"); data.append(contentsOf: " ".utf8)- #expect(throws: (any Error).self) { try BackupV1Codec.decode(data) }- }- @Test("Rejects invalid enum values") func invalidEnum() {- let j = #"{"header":{"appBuild":"1","backupFormatVersion":1,"checksum":"a","databaseSchemaVersion":1,"entryCount":1,"exportedAt":"2026-07-17T00:00:00.000Z","workCount":0},"payload":{"entries":[{"canonicalURL":null,"captureTitle":"T","captureTitleSource":"INVALID","chapterTitle":null,"chapterTitleProvenance":{"kind":"none","patternID":null,"patternVersion":null},"entryIdentityKey":"k","firstCapturedAt":"2026-07-17T00:00:00.000Z","hostname":"h","id":"11111111-1111-1111-1111-111111111111","identityKeyVersion":1,"intentionallyUnattached":false,"lastSharedAt":"2026-07-17T00:00:00.000Z","modifiedAt":"2026-07-17T00:00:00.000Z","note":"","rating":null,"rawURL":"https://h/p","workAssignmentProvenance":{"kind":"none","patternID":null,"patternVersion":null},"workID":null}],"sites":[],"titlePatterns":[],"works":[]}}"#- #expect(throws: (any Error).self) { try BackupV1Codec.decode(Data(j.utf8)) }- }- @Test("Rejects invalid provenance: pattern kind without patternID") func badProvenance() {- let j = #"{"header":{"appBuild":"1","backupFormatVersion":1,"checksum":"a","databaseSchemaVersion":1,"entryCount":1,"exportedAt":"2026-07-17T00:00:00.000Z","workCount":0},"payload":{"entries":[{"canonicalURL":null,"captureTitle":"T","captureTitleSource":"manual","chapterTitle":null,"chapterTitleProvenance":{"kind":"pattern","patternID":null,"patternVersion":null},"entryIdentityKey":"k","firstCapturedAt":"2026-07-17T00:00:00.000Z","hostname":"h","id":"11111111-1111-1111-1111-111111111111","identityKeyVersion":1,"intentionallyUnattached":false,"lastSharedAt":"2026-07-17T00:00:00.000Z","modifiedAt":"2026-07-17T00:00:00.000Z","note":"","rating":null,"rawURL":"https://h/p","workAssignmentProvenance":{"kind":"none","patternID":null,"patternVersion":null},"workID":null}],"sites":[],"titlePatterns":[],"works":[]}}"#- #expect(throws: (any Error).self) { try BackupV1Codec.decode(Data(j.utf8)) }- }-}--// MARK: - SHA-256 Tampering Tests--@Suite("BackupV1Codec Tampering")-struct BackupV1TamperingTests {- @Test("Detects single-byte payload modification") func singleByte() throws {- var data = try loadFixture("non-empty-library.json")- let decoded = try BackupV1Codec.decode(data)- data[decoded.payloadRange.lowerBound + decoded.payloadRange.count / 2] ^= 0x01- #expect(throws: (any Error).self) {- let t = try BackupV1Codec.decode(data)- try BackupV1Validator.validate(decoded: t, source: decoded.snapshot, encodedData: data)- }- }- @Test("Detects checksum replacement") func checksumReplace() throws {- let data = try loadFixture("empty-library.json")- var s = String(data: data, encoding: .utf8)!- s = s.replacingOccurrences(of: "9e81a28e8b352eccadedd5d6d77c7dace28189cfb1b5951fa41397415aa4eebd", with: String(repeating: "0", count: 64))- let decoded = try BackupV1Codec.decode(Data(s.utf8))- #expect(throws: (any Error).self) {- try BackupV1Validator.validate(decoded: decoded, source: .empty, encodedData: Data(s.utf8))- }- }-}--// MARK: - Deep Equality & Validation Tests--@Suite("BackupV1Validator")-struct BackupV1ValidatorTests {- @Test("Validates non-empty golden against its own snapshot") func validNonEmpty() throws {- let data = try loadFixture("non-empty-library.json")- let decoded = try BackupV1Codec.decode(data)- try BackupV1Validator.validate(decoded: decoded, source: decoded.snapshot, encodedData: data)- }- @Test("Validates empty golden against empty snapshot") func validEmpty() throws {- let data = try loadFixture("empty-library.json")- let decoded = try BackupV1Codec.decode(data)- try BackupV1Validator.validate(decoded: decoded, source: .empty, encodedData: data)- }- @Test("Detects missing source entry") func missingEntry() throws {- let data = try loadFixture("non-empty-library.json")- let decoded = try BackupV1Codec.decode(data)- let partial = LibraryBackupSnapshot(entries: Array(decoded.snapshot.entries.dropLast()), works: decoded.snapshot.works, sites: decoded.snapshot.sites, titlePatterns: decoded.snapshot.titlePatterns)- #expect(throws: (any Error).self) { try BackupV1Validator.validate(decoded: decoded, source: partial, encodedData: data) }- }- @Test("Rejects duplicate Entry UUIDs") func dupEntries() throws {- let ts = date("2026-07-17T00:00:00.000Z")- let e = EntryRecord(id: UUID(), captureTitle: "T", captureTitleSource: .manual, rawURL: "https://a.t/p", canonicalURL: nil, hostname: "a.t", entryIdentityKey: "k", identityKeyVersion: 1, chapterTitle: nil, chapterTitleProvenance: try! FieldProvenance(kind: .none), note: "", rating: nil, firstCapturedAt: ts, lastSharedAt: ts, modifiedAt: ts, workID: nil, workAssignmentProvenance: try! FieldProvenance(kind: .none), intentionallyUnattached: false)- let snap = LibraryBackupSnapshot(entries: [e, e], works: [], sites: [SiteRecord(hostname: "a.t", displayName: "a.t", mode: .untaught, patternIDs: [], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [])- let enc = try BackupV1Codec.encode(snapshot: snap, metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: ts))- let dec = try BackupV1Codec.decode(enc)- #expect(throws: (any Error).self) { try BackupV1Validator.validate(decoded: dec, source: snap, encodedData: enc) }- }- @Test("Rejects unresolved Work reference") func unresolvedWork() throws {- let ts = date("2026-07-17T00:00:00.000Z")- let e = EntryRecord(id: UUID(), captureTitle: "T", captureTitleSource: .manual, rawURL: "https://a.t/p", canonicalURL: nil, hostname: "a.t", entryIdentityKey: "k", identityKeyVersion: 1, chapterTitle: nil, chapterTitleProvenance: try! FieldProvenance(kind: .none), note: "", rating: nil, firstCapturedAt: ts, lastSharedAt: ts, modifiedAt: ts, workID: UUID(), workAssignmentProvenance: try! FieldProvenance(kind: .manual), intentionallyUnattached: false)- let snap = LibraryBackupSnapshot(entries: [e], works: [], sites: [SiteRecord(hostname: "a.t", displayName: "a.t", mode: .untaught, patternIDs: [], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [])- let enc = try BackupV1Codec.encode(snapshot: snap, metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: ts))- let dec = try BackupV1Codec.decode(enc)- #expect(throws: (any Error).self) { try BackupV1Validator.validate(decoded: dec, source: snap, encodedData: enc) }- }- @Test("Rejects unresolved hostname") func unresolvedHostname() throws {- let ts = date("2026-07-17T00:00:00.000Z")- let e = EntryRecord(id: UUID(), captureTitle: "T", captureTitleSource: .manual, rawURL: "https://m.t/p", canonicalURL: nil, hostname: "m.t", entryIdentityKey: "k", identityKeyVersion: 1, chapterTitle: nil, chapterTitleProvenance: try! FieldProvenance(kind: .none), note: "", rating: nil, firstCapturedAt: ts, lastSharedAt: ts, modifiedAt: ts, workID: nil, workAssignmentProvenance: try! FieldProvenance(kind: .none), intentionallyUnattached: false)- let snap = LibraryBackupSnapshot(entries: [e], works: [], sites: [], titlePatterns: [])- let enc = try BackupV1Codec.encode(snapshot: snap, metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: ts))- let dec = try BackupV1Codec.decode(enc)- #expect(throws: (any Error).self) { try BackupV1Validator.validate(decoded: dec, source: snap, encodedData: enc) }- }- @Test("Rejects multiple active patterns per Site") func multiActive() throws {- let ts = date("2026-07-17T00:00:00.000Z")- let p1 = TitlePatternRecord(id: UUID(), version: 1, isActive: true, createdAt: ts, workAnchor: try! SegmentRangeSpec(origin: .start, offset: 0, length: 1), junkAnchors: [], siteHostname: "a.t")- let p2 = TitlePatternRecord(id: UUID(), version: 2, isActive: true, createdAt: ts, workAnchor: try! SegmentRangeSpec(origin: .start, offset: 0, length: 1), junkAnchors: [], siteHostname: "a.t")- let snap = LibraryBackupSnapshot(entries: [], works: [], sites: [SiteRecord(hostname: "a.t", displayName: "a.t", mode: .taught, patternIDs: [p1.id, p2.id], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [p1, p2])- let enc = try BackupV1Codec.encode(snapshot: snap, metadata: BackupMetadata(appBuild: "1", databaseSchemaVersion: 1, exportedAt: ts))- let dec = try BackupV1Codec.decode(enc)- #expect(throws: (any Error).self) { try BackupV1Validator.validate(decoded: dec, source: snap, encodedData: enc) }- }-}--// MARK: - Generative Property Tests--@Suite("BackupV1Codec Generative")-struct BackupV1GenerativeTests {- private struct Gen { var s: UInt64; mutating func next() -> UInt64 { s ^= s &<< 13; s ^= s &>> 7; s ^= s &<< 17; return s }- mutating func uuid() -> UUID { let a = next(); let b = next(); return UUID(uuid: withUnsafeBytes(of: (a,b)) { p in (p[0],p[1],p[2],p[3],p[4],p[5],p[6],p[7],p[8],p[9],p[10],p[11],p[12],p[13],p[14],p[15]) }) }- mutating func date() -> Date { Date(timeIntervalSince1970: 1_735_689_600 + Double(next() % 63_072_000_000) / 1000) }- mutating func str() -> String { String(next() % 10000) }- }-- @Test("Round-trip preserves deep equality", arguments: [1, 42, 99, 256, 1000] as [UInt64])- func roundTrip(seed: UInt64) throws {- var g = Gen(s: seed)- let h = "g\(seed).t"- let e = EntryRecord(id: g.uuid(), captureTitle: g.str(), captureTitleSource: .manual, rawURL: "https://\(h)/p", canonicalURL: nil, hostname: h, entryIdentityKey: "https://\(h)/k", identityKeyVersion: 1, chapterTitle: nil, chapterTitleProvenance: try! FieldProvenance(kind: .none), note: g.str(), rating: nil, firstCapturedAt: g.date(), lastSharedAt: g.date(), modifiedAt: g.date(), workID: nil, workAssignmentProvenance: try! FieldProvenance(kind: .none), intentionallyUnattached: false)- let snap = LibraryBackupSnapshot(entries: [e], works: [], sites: [SiteRecord(hostname: h, displayName: h, mode: .untaught, patternIDs: [], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [])- let enc = try BackupV1Codec.encode(snapshot: snap, metadata: BackupMetadata(appBuild: "t", databaseSchemaVersion: 1, exportedAt: g.date()))- let dec = try BackupV1Codec.decode(enc)- #expect(dec.snapshot == snap)- }-- @Test("Canonical re-encode is byte-identical", arguments: [7, 13, 77] as [UInt64])- func reEncode(seed: UInt64) throws {- var g = Gen(s: seed)- let h = "r\(seed).t"- let e = EntryRecord(id: g.uuid(), captureTitle: g.str(), captureTitleSource: .host, rawURL: "https://\(h)/x", canonicalURL: nil, hostname: h, entryIdentityKey: "https://\(h)/y", identityKeyVersion: 1, chapterTitle: nil, chapterTitleProvenance: try! FieldProvenance(kind: .none), note: "", rating: .up, firstCapturedAt: g.date(), lastSharedAt: g.date(), modifiedAt: g.date(), workID: nil, workAssignmentProvenance: try! FieldProvenance(kind: .none), intentionallyUnattached: false)- let snap = LibraryBackupSnapshot(entries: [e], works: [], sites: [SiteRecord(hostname: h, displayName: h, mode: .untaught, patternIDs: [], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [])- let meta = BackupMetadata(appBuild: "t", databaseSchemaVersion: 1, exportedAt: g.date())- let first = try BackupV1Codec.encode(snapshot: snap, metadata: meta)- let dec = try BackupV1Codec.decode(first)- let second = try BackupV1Codec.encode(snapshot: dec.snapshot, metadata: BackupMetadata(appBuild: dec.header.appBuild, databaseSchemaVersion: dec.header.databaseSchemaVersion, exportedAt: dec.header.exportedAt))- #expect(first == second)- }-- @Test("Byte flip changes SHA-256 or fails decode", arguments: [3, 50, 200] as [UInt64])- func byteFlip(seed: UInt64) throws {- var g = Gen(s: seed)- let h = "f\(seed).t"- let e = EntryRecord(id: g.uuid(), captureTitle: g.str(), captureTitleSource: .manual, rawURL: "https://\(h)/z", canonicalURL: nil, hostname: h, entryIdentityKey: "https://\(h)/z", identityKeyVersion: 1, chapterTitle: nil, chapterTitleProvenance: try! FieldProvenance(kind: .none), note: "", rating: nil, firstCapturedAt: g.date(), lastSharedAt: g.date(), modifiedAt: g.date(), workID: nil, workAssignmentProvenance: try! FieldProvenance(kind: .none), intentionallyUnattached: false)- let snap = LibraryBackupSnapshot(entries: [e], works: [], sites: [SiteRecord(hostname: h, displayName: h, mode: .untaught, patternIDs: [], urlIdentityRule: nil, junkSuffixRule: nil)], titlePatterns: [])- var enc = try BackupV1Codec.encode(snapshot: snap, metadata: BackupMetadata(appBuild: "t", databaseSchemaVersion: 1, exportedAt: g.date()))- let dec = try BackupV1Codec.decode(enc)- let idx = dec.payloadRange.lowerBound + Int(g.next() % UInt64(dec.payloadRange.count))- enc[idx] ^= 0x01- do { let t = try BackupV1Codec.decode(enc)- #expect(throws: (any Error).self) { try BackupV1Validator.validate(decoded: t, source: snap, encodedData: enc) }- } catch { /* decode failure is also acceptable */ }- }-}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV2CodecTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV2CodecTests.swiftnew file mode 100644index 0000000..c2c1e42--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV2CodecTests.swift@@ -0,0 +1,285 @@+import Foundation+import Testing+@testable import AsterismCore++@Suite("Backup V2 strict codec")+struct BackupV2CodecTests {+ @Test("Current gate round-trip preserves a coherent V2 graph")+ func roundTrip() throws {+ let snapshot = try makeSnapshot(definition: segmentDefinition)+ let encoded = try BackupV2Codec.encode(+ snapshot: snapshot,+ metadata: metadata,+ capabilities: .m2_0+ )+ let decoded = try BackupV2Codec.decode(encoded, capabilities: .m2_0)++ #expect(decoded.backupFormatVersion == 2)+ #expect(decoded.databaseSchemaVersion == 2)+ #expect(decoded.capabilityGate == .m2_0)+ #expect(decoded.payload == snapshot)+ }++ @Test("Gate validation rejects unavailable phrase forms")+ func gateValidation() throws {+ let phrase = PatternDefinition.phrase(+ prefix: "Read ",+ separator: " from ",+ suffix: ".",+ order: .chapterThenWork+ )+ let snapshot = try makeSnapshot(definition: phrase)++ #expect(throws: BackupValidationError.self) {+ try BackupV2Codec.encode(+ snapshot: snapshot,+ metadata: metadata,+ capabilities: .m2_0+ )+ }++ let encoded = try BackupV2Codec.encode(+ snapshot: snapshot,+ metadata: metadata,+ capabilities: .m2_3+ )+ #expect(try BackupV2Codec.decode(encoded, capabilities: .m2_3).payload == snapshot)+ #expect(throws: BackupCodecError.self) {+ try BackupV2Codec.decode(encoded, capabilities: .m2_0)+ }+ }++ @Test("Unknown, duplicate, and missing keys are rejected")+ func malformedKeys() throws {+ let encoded = try encodeSegmentSnapshot()+ let unknown = Data(("{\"unexpected\":true," + String(decoding: encoded.dropFirst(), as: UTF8.self)).utf8)+ #expect(throws: BackupCodecError.self) {+ try BackupV2Codec.decode(unknown, capabilities: .m2_0)+ }++ let duplicate = Data(("{\"appBuild\":\"shadow\"," + String(decoding: encoded.dropFirst(), as: UTF8.self)).utf8)+ #expect(throws: BackupCodecError.self) {+ try BackupV2Codec.decode(duplicate, capabilities: .m2_0)+ }++ let missing = try mutate(encoded) { root in+ root.removeValue(forKey: "appBuild")+ }+ #expect(throws: BackupCodecError.self) {+ try BackupV2Codec.decode(missing, capabilities: .m2_0)+ }++ let missingNullable = try mutate(encoded) { root in+ var payload = root["payload"] as! [String: Any]+ var entries = payload["entries"] as! [[String: Any]]+ entries[0].removeValue(forKey: "canonicalURL")+ payload["entries"] = entries+ root["payload"] = payload+ }+ #expect(throws: BackupCodecError.self) {+ try BackupV2Codec.decode(missingNullable, capabilities: .m2_0)+ }+ }++ @Test("Unknown nested value-object keys are rejected")+ func unknownNestedKey() throws {+ let encoded = try encodeSegmentSnapshot()+ let malformed = try mutate(encoded) { root in+ var payload = root["payload"] as! [String: Any]+ var patterns = payload["titlePatterns"] as! [[String: Any]]+ var definition = patterns[0]["definition"] as! [String: Any]+ var segment = definition["segment"] as! [String: Any]+ var work = segment["work"] as! [String: Any]+ work["unexpected"] = true+ segment["work"] = work+ definition["segment"] = segment+ patterns[0]["definition"] = definition+ payload["titlePatterns"] = patterns+ root["payload"] = payload+ }++ #expect(throws: BackupCodecError.self) {+ try BackupV2Codec.decode(malformed, capabilities: .m2_0)+ }+ }++ @Test("Invalid references are rejected after strict decoding")+ func invalidReference() throws {+ let encoded = try encodeSegmentSnapshot()+ let malformed = try mutate(encoded) { root in+ var payload = root["payload"] as! [String: Any]+ var entries = payload["entries"] as! [[String: Any]]+ entries[0]["workID"] = "99999999-9999-9999-9999-999999999999"+ payload["entries"] = entries+ root["payload"] = payload+ }++ #expect(throws: BackupValidationError.self) {+ try BackupV2Codec.decode(malformed, capabilities: .m2_0)+ }+ }++ @Test("Both tagged pattern arms are rejected")+ func bothPatternForms() throws {+ let encoded = try encodeSegmentSnapshot()+ let malformed = try mutate(encoded) { root in+ var payload = root["payload"] as! [String: Any]+ var patterns = payload["titlePatterns"] as! [[String: Any]]+ var definition = patterns[0]["definition"] as! [String: Any]+ definition["phrase"] = [+ "prefix": "Read ",+ "separator": " from ",+ "suffix": ".",+ "order": "chapterThenWork",+ ]+ patterns[0]["definition"] = definition+ payload["titlePatterns"] = patterns+ root["payload"] = payload+ }++ #expect(throws: BackupCodecError.self) {+ try BackupV2Codec.decode(malformed, capabilities: .m2_0)+ }+ }++ @Test("Invalid chapter and assignment tuples are rejected")+ func invalidTuples() throws {+ let encoded = try encodeSegmentSnapshot()+ let malformed = try mutate(encoded) { root in+ var payload = root["payload"] as! [String: Any]+ var entries = payload["entries"] as! [[String: Any]]+ entries[0]["chapterTitle"] = NSNull()+ entries[0]["chapterTitleProvenance"] = [+ "kind": "manual",+ "patternID": NSNull(),+ "patternVersion": NSNull(),+ ]+ entries[0]["intentionallyUnattached"] = true+ payload["entries"] = entries+ root["payload"] = payload+ }++ #expect(throws: BackupValidationError.self) {+ try BackupV2Codec.decode(malformed, capabilities: .m2_0)+ }+ }++ @Test("Trailing bytes and wrong schema versions are rejected")+ func envelopeValidation() throws {+ let encoded = try encodeSegmentSnapshot()+ #expect(throws: BackupCodecError.self) {+ try BackupV2Codec.decode(encoded + Data(" trailing".utf8), capabilities: .m2_0)+ }+ let wrongSchema = try mutate(encoded) { root in+ root["databaseSchemaVersion"] = 1+ }+ #expect(throws: BackupCodecError.self) {+ try BackupV2Codec.decode(wrongSchema, capabilities: .m2_0)+ }+ }++ private var metadata: BackupMetadata {+ BackupMetadata(+ appBuild: "42",+ databaseSchemaVersion: 2,+ exportedAt: Date(timeIntervalSince1970: 1_721_000_000.123)+ )+ }++ private var segmentDefinition: PatternDefinition {+ .segment(+ work: try! SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try! SegmentPositionSpec(origin: .end, offset: 0)]+ )+ }++ private func encodeSegmentSnapshot() throws -> Data {+ try BackupV2Codec.encode(+ snapshot: makeSnapshot(definition: segmentDefinition),+ metadata: metadata,+ capabilities: .m2_0+ )+ }++ private func makeSnapshot(definition: PatternDefinition) throws -> LibraryBackupSnapshot {+ let patternID = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!+ let workID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!+ let entryID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!+ let timestamp = Date(timeIntervalSince1970: 1_721_000_000.123)+ let provenance = try FieldProvenance(+ kind: .pattern,+ patternID: patternID,+ patternVersion: 1+ )+ return LibraryBackupSnapshot(+ entries: [+ EntryRecord(+ id: entryID,+ captureTitle: "Chapter - Work | Site",+ captureTitleSource: .host,+ rawURL: "https://example.com/chapter",+ canonicalURL: nil,+ hostname: "example.com",+ entryIdentityKey: "https://example.com/chapter",+ identityKeyVersion: 1,+ chapterTitle: "Chapter",+ chapterTitleProvenance: provenance,+ note: "note",+ rating: .up,+ firstCapturedAt: timestamp,+ lastSharedAt: timestamp,+ modifiedAt: timestamp,+ workID: workID,+ workAssignmentProvenance: provenance,+ intentionallyUnattached: false+ ),+ ],+ works: [+ WorkRecord(+ id: workID,+ displayTitle: "Work",+ lastParsedTitle: "Work",+ siteHostname: "example.com",+ urlIdentity: nil,+ workURL: nil,+ genericNotes: "",+ type: .novel,+ genreTags: [],+ titleProvenance: .parsed,+ createdAt: timestamp,+ modifiedAt: timestamp,+ entryIDs: [entryID]+ ),+ ],+ sites: [+ SiteRecord(+ hostname: "example.com",+ displayName: "Example",+ mode: .taught,+ patternIDs: [patternID],+ urlIdentityRule: nil,+ junkSuffixRule: nil+ ),+ ],+ titlePatterns: [+ TitlePatternRecord(+ id: patternID,+ version: 1,+ isActive: true,+ createdAt: timestamp,+ definition: definition,+ siteHostname: "example.com"+ ),+ ]+ )+ }++ private func mutate(+ _ data: Data,+ mutation: (inout [String: Any]) -> Void+ ) throws -> Data {+ var root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])+ mutation(&root)+ return try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys])+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swiftindex c242502..00d4bea 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift@@ -1,9 +1,8 @@ import Foundation-import SwiftData import Testing @testable import AsterismCore -// MARK: - Task 16: Capture state and extension lifecycle tests+// MARK: - Task 16: Capture state and extension lifecycle tests (M2.1 projected capture) @Suite("Capture state and extension lifecycle", .serialized) struct CaptureStateTests {@@ -11,304 +10,560 @@ struct CaptureStateTests { // MARK: - State machine: loading → ready/invalidInput @Test("State begins as loading before preparation completes")- func initialStateIsLoading() async throws {- let viewModel = await CaptureViewModel()- let isLoading = await viewModel.state.isLoading- #expect(isLoading)+ @MainActor func initialStateIsLoading() async throws {+ let viewModel = CaptureViewModel()+ #expect(viewModel.state.isLoading) } - @Test("Valid Safari payload transitions to ready with new-site status")- func validPayloadTransitionsToReady() async throws {- let fixture = try await CaptureStateFixture()- let safari = SafariPageMetadata(title: "Chapter 5", locationHref: "https://new-site.example/ch5")- let payload = SharePayload(providerURL: "https://new-site.example/ch5", safari: safari)- await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)- let state = await fixture.viewModel.state- guard case .ready(let preparation, _) = state else {- Issue.record("Expected ready state")+ @Test("Valid Safari payload transitions to ready with projection outcome")+ @MainActor func validPayloadTransitionsToReady() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ guard case .ready(let preparation, _, let outcome, _) = fixture.viewModel.state else {+ Issue.record("Expected ready state, got \(fixture.viewModel.state)") return }- #expect(preparation.siteStatus == .newSite)+ #expect(preparation.siteStatus == .existingTaught) #expect(preparation.resolvedTitle == "Chapter 5")+ #expect(outcome != nil)+ #expect(outcome?.projectedChapter == "5")+ #expect(outcome?.projectedWorkTitle == "Test Work") } - @Test("Existing untaught site produces ready state without new-site wording")- func existingUntaughtSiteReady() async throws {- let fixture = try await CaptureStateFixture()- // Seed a Site- _ = try await fixture.repository.capture(CaptureDraft(- captureTitle: "Seed",- captureTitleSource: .manual,- rawURLString: "https://existing.example/first"- ))- let safari = SafariPageMetadata(title: "Chapter 2", locationHref: "https://existing.example/ch2")- let payload = SharePayload(providerURL: "https://existing.example/ch2", safari: safari)- await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)- let state = await fixture.viewModel.state- guard case .ready(let preparation, _) = state else {+ @Test("Taught site successful projection displayed on load")+ @MainActor func taughtSuccessfulProjectionDisplayedOnLoad() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ guard case .ready(_, _, let outcome, _) = fixture.viewModel.state else { Issue.record("Expected ready state") return }- #expect(preparation.siteStatus == .existingUntaught)+ #expect(outcome != nil)+ #expect(outcome?.projectedChapter == "5")+ #expect(outcome?.projectedWorkTitle == "Test Work")+ #expect(outcome?.actionable == false) } - @Test("Manual title state when Safari title is blank")- func manualTitleState() async throws {- let fixture = try await CaptureStateFixture()- let safari = SafariPageMetadata(title: "", locationHref: "https://example.com/page")- let payload = SharePayload(providerURL: "https://example.com/page", safari: safari)+ @Test("Untaught site produces ready with actionable outcome (no chapter/work)")+ @MainActor func untaughtSiteReady() async throws {+ let fixture = CaptureStateFixture.untaughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ guard case .ready(_, _, let outcome, _) = fixture.viewModel.state else {+ Issue.record("Expected ready state")+ return+ }+ #expect(outcome != nil)+ #expect(outcome?.projectedChapter == nil)+ #expect(outcome?.projectedWorkTitle == nil)+ #expect(outcome?.actionable == true)+ }++ @Test("Manual title state when Safari title is blank — no projection, canSave false")+ @MainActor func manualTitleState() async throws {+ let fixture = CaptureStateFixture.manualTitle()+ let payload = SharePayload(+ providerURL: "https://example.com/page",+ safari: SafariPageMetadata(title: "", locationHref: "https://example.com/page")+ ) await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)- let state = await fixture.viewModel.state- guard case .ready(let preparation, _) = state else {+ guard case .ready(let preparation, _, let outcome, _) = fixture.viewModel.state else { Issue.record("Expected ready state") return } #expect(preparation.titleSource == .manual) #expect(preparation.resolvedTitle == nil)+ #expect(outcome == nil)+ #expect(fixture.viewModel.canSave == false)+ }++ // MARK: - Reprojection on draft edit++ @Test("Manual title edit triggers reprojection with new title")+ @MainActor func manualTitleEditCausesReprojection() async throws {+ let fixture = CaptureStateFixture.manualTitle()+ let payload = SharePayload(+ providerURL: "https://example.com/page",+ safari: SafariPageMetadata(title: "", locationHref: "https://example.com/page")+ )+ await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)++ await fixture.viewModel.setManualTitle("My Title", coordinator: fixture.coordinator)+ guard case .ready(_, let draft, let outcome, _) = fixture.viewModel.state else {+ Issue.record("Expected ready state after setManualTitle")+ return+ }+ #expect(draft.captureTitle == "My Title")+ #expect(outcome != nil)+ #expect(fixture.coordinator.projectCaptureCallCount >= 1)+ }++ @Test("Older delayed projection is discarded (generation safety)")+ @MainActor func olderDelayedProjectionDiscarded() async throws {+ let fixture = CaptureStateFixture.delayedProjection()+ let payload = SharePayload(+ providerURL: "https://example.com/page",+ safari: SafariPageMetadata(title: "", locationHref: "https://example.com/page")+ )+ await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)++ // First edit — triggers slow projection+ fixture.coordinator.projectDelay = 0.1+ let firstEdit = Task { @MainActor in+ await fixture.viewModel.setManualTitle("First", coordinator: fixture.coordinator)+ }++ // Second edit immediately — should supersede the first+ try await Task.sleep(nanoseconds: 10_000_000) // 10ms+ fixture.coordinator.projectDelay = 0+ await fixture.viewModel.setManualTitle("Second", coordinator: fixture.coordinator)++ // Wait for first to complete+ await firstEdit.value++ // The displayed state should reflect "Second", not "First"+ guard case .ready(_, let draft, _, _) = fixture.viewModel.state else {+ Issue.record("Expected ready state")+ return+ }+ #expect(draft.captureTitle == "Second") } // MARK: - Ratings @Test("Rating toggles: tap selected rating clears it")- func ratingToggleClear() async throws {- let fixture = try await CaptureStateFixture()- await fixture.loadDefault()- await fixture.viewModel.setRating(.up)- let draft1 = await fixture.viewModel.currentDraft- #expect(draft1?.rating == .up)- await fixture.viewModel.setRating(.up) // tap same → clear- let draft2 = await fixture.viewModel.currentDraft- #expect(draft2?.rating == nil)+ @MainActor func ratingToggleClear() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ await fixture.viewModel.setRating(.up, coordinator: fixture.coordinator)+ #expect(fixture.viewModel.currentDraft?.rating == .up)+ await fixture.viewModel.setRating(.up, coordinator: fixture.coordinator)+ #expect(fixture.viewModel.currentDraft?.rating == nil) } @Test("Rating toggles: tap opposite replaces current")- func ratingToggleReplace() async throws {- let fixture = try await CaptureStateFixture()- await fixture.loadDefault()- await fixture.viewModel.setRating(.up)- await fixture.viewModel.setRating(.down) // tap opposite → replace- let draft = await fixture.viewModel.currentDraft- #expect(draft?.rating == .down)+ @MainActor func ratingToggleReplace() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ await fixture.viewModel.setRating(.up, coordinator: fixture.coordinator)+ await fixture.viewModel.setRating(.down, coordinator: fixture.coordinator)+ #expect(fixture.viewModel.currentDraft?.rating == .down) } - // MARK: - Empty note/rating+ // MARK: - Save lifecycle: committed - @Test("Save succeeds with empty note and nil rating")- func saveWithEmptyNoteAndRating() async throws {- let fixture = try await CaptureStateFixture()- await fixture.loadDefault()- await fixture.viewModel.setNote("")- await fixture.viewModel.setRating(nil)+ @Test("Save with committed outcome transitions to saved")+ @MainActor func saveCommitted() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ fixture.coordinator.commitOutcome = .committed(fixture.entrySnapshot) await fixture.viewModel.save(coordinator: fixture.coordinator)- let state = await fixture.viewModel.state- guard case .saved = state else {- Issue.record("Expected saved state, got \(state)")+ guard case .saved(let entryID) = fixture.viewModel.state else {+ Issue.record("Expected saved state, got \(fixture.viewModel.state)") return }+ #expect(entryID == fixture.entrySnapshot.id) } - // MARK: - Duplicate-save suppression-- @Test("Second save call ignored after successful save")- func secondSaveIgnored() async throws {- let fixture = try await CaptureStateFixture()- await fixture.loadDefault()+ // MARK: - Save lifecycle: refreshed++ @Test("First Save refreshed writes nothing, preserves draft, shows review message")+ @MainActor func firstSaveRefreshedPreservesDraft() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ await fixture.viewModel.setNote("My note", coordinator: fixture.coordinator)++ let freshContract = CaptureContract(+ basis: fixture.captureBasis,+ request: CaptureRequest(+ captureTitle: "Chapter 5", captureTitleSource: .safariDocument,+ rawURLString: "https://taught.example/ch5", canonicalURLString: nil,+ note: "My note", rating: nil+ ),+ outcome: CaptureOutcome(+ projectedChapter: "5 (updated)", projectedWorkTitle: "Test Work",+ workOutcome: .reuse(workID: UUID()), projectedWorkID: UUID(),+ actionable: false, intentionallyUnattached: false+ )+ )+ fixture.coordinator.commitOutcome = .refreshed(freshContract) await fixture.viewModel.save(coordinator: fixture.coordinator)- let state = await fixture.viewModel.state- guard case .saved(let firstID) = state else {- Issue.record("Expected saved state")++ guard case .ready(_, let draft, let outcome, let reviewMessage) = fixture.viewModel.state else {+ Issue.record("Expected ready state after refreshed, got \(fixture.viewModel.state)") return }- // Second save should be a no-op+ #expect(draft.note == "My note")+ #expect(draft.captureTitle == "Chapter 5")+ #expect(outcome?.projectedChapter == "5 (updated)")+ #expect(reviewMessage == "Review changes and Save again")+ }++ @Test("Second Save after refreshed commits successfully")+ @MainActor func secondSaveAfterRefreshedCommits() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)++ let freshContract = CaptureContract(+ basis: fixture.captureBasis,+ request: CaptureRequest(+ captureTitle: "Chapter 5", captureTitleSource: .safariDocument,+ rawURLString: "https://taught.example/ch5", canonicalURLString: nil,+ note: "", rating: nil+ ),+ outcome: CaptureOutcome(+ projectedChapter: "5", projectedWorkTitle: "Test Work Updated",+ workOutcome: .reuse(workID: UUID()), projectedWorkID: UUID(),+ actionable: false, intentionallyUnattached: false+ )+ )+ fixture.coordinator.commitOutcome = .refreshed(freshContract) await fixture.viewModel.save(coordinator: fixture.coordinator)- let state2 = await fixture.viewModel.state- guard case .saved(let secondID) = state2 else {- Issue.record("Expected saved state")++ fixture.coordinator.commitOutcome = .committed(fixture.entrySnapshot)+ await fixture.viewModel.save(coordinator: fixture.coordinator)++ guard case .saved = fixture.viewModel.state else {+ Issue.record("Expected saved state after second save, got \(fixture.viewModel.state)") return }- #expect(firstID == secondID) } - // MARK: - Draft retention after failure+ // MARK: - Save lifecycle: invalidated / failure - @Test("Failed save retains draft data and allows retry")- func failedSaveRetainsDraft() async throws {- let saveStrategy = FailingSaveStrategy()- let fixture = try await CaptureStateFixture(saveStrategy: saveStrategy)- await fixture.loadDefault()- await fixture.viewModel.setNote("Important note")- await fixture.viewModel.setRating(.up)- saveStrategy.failSaves = true+ @Test("Invalidated preserves note, rating, and manual title")+ @MainActor func invalidatedPreservesDraft() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ await fixture.viewModel.setNote("Important note", coordinator: fixture.coordinator)+ await fixture.viewModel.setRating(.up, coordinator: fixture.coordinator)++ fixture.coordinator.commitOutcome = .invalidated(reason: "Site mode changed") await fixture.viewModel.save(coordinator: fixture.coordinator)- let state = await fixture.viewModel.state- guard case .saveFailed(_, let draft, _) = state else {- Issue.record("Expected saveFailed state, got \(state)")++ guard case .saveFailed(_, let draft, _, let message) = fixture.viewModel.state else {+ Issue.record("Expected saveFailed state, got \(fixture.viewModel.state)") return } #expect(draft.note == "Important note") #expect(draft.rating == .up)+ #expect(draft.captureTitle == "Chapter 5")+ #expect(message.contains("Site mode changed")) } - @Test("Retry after failure succeeds when persistence is restored")- func retryAfterFailure() async throws {- let saveStrategy = FailingSaveStrategy()- let fixture = try await CaptureStateFixture(saveStrategy: saveStrategy)- await fixture.loadDefault()- await fixture.viewModel.setNote("Retry note")- saveStrategy.failSaves = true+ @Test("Failure preserves note, rating, and manual title")+ @MainActor func failurePreservesDraft() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ await fixture.viewModel.setNote("Will survive", coordinator: fixture.coordinator)+ await fixture.viewModel.setRating(.down, coordinator: fixture.coordinator)++ fixture.coordinator.commitShouldThrow = true await fixture.viewModel.save(coordinator: fixture.coordinator)- let state = await fixture.viewModel.state- guard case .saveFailed = state else {- Issue.record("Expected saveFailed state")++ guard case .saveFailed(_, let draft, _, _) = fixture.viewModel.state else {+ Issue.record("Expected saveFailed state, got \(fixture.viewModel.state)") return }- saveStrategy.failSaves = false+ #expect(draft.note == "Will survive")+ #expect(draft.rating == .down)+ }++ // MARK: - No legacy capture call++ @Test("No call to legacy capture — only projectCapture and commitCapture used")+ @MainActor func noLegacyCaptureCall() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ fixture.coordinator.commitOutcome = .committed(fixture.entrySnapshot) await fixture.viewModel.save(coordinator: fixture.coordinator)- let state2 = await fixture.viewModel.state- guard case .saved = state2 else {- Issue.record("Expected saved state after retry")- return- }+ #expect(fixture.coordinator.legacySaveCallCount == 0) } - // MARK: - Save before completion (host dismissal)+ // MARK: - Duplicate-save suppression - @Test("Repository write occurs before state transitions to saved")- func repositoryWriteBeforeSaved() async throws {- let fixture = try await CaptureStateFixture()- await fixture.loadDefault()- await fixture.viewModel.setNote("Persisted first")+ @Test("Second save call ignored after successful save")+ @MainActor func duplicateSaveSuppression() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ fixture.coordinator.commitOutcome = .committed(fixture.entrySnapshot) await fixture.viewModel.save(coordinator: fixture.coordinator)- let state = await fixture.viewModel.state- guard case .saved(let entryID) = state else {- Issue.record("Expected saved state")- return- }- // Verify the entry exists in the repository- let entry = try await fixture.repository.entry(id: entryID)- #expect(entry.note == "Persisted first")+ let commitCountAfterFirst = fixture.coordinator.commitCaptureCallCount++ await fixture.viewModel.save(coordinator: fixture.coordinator)+ #expect(fixture.coordinator.commitCaptureCallCount == commitCountAfterFirst) } // MARK: - Pre-save cancellation @Test("Cancel before save writes nothing")- func cancelBeforeSaveWritesNothing() async throws {- let fixture = try await CaptureStateFixture()- await fixture.loadDefault()- await fixture.viewModel.setNote("Will be cancelled")- await fixture.viewModel.cancel()- let state = await fixture.viewModel.state- guard case .cancelled = state else {- Issue.record("Expected cancelled state, got \(state)")+ @MainActor func cancelBeforeSaveWritesNothing() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ fixture.viewModel.cancel()+ guard case .cancelled = fixture.viewModel.state else {+ Issue.record("Expected cancelled state, got \(fixture.viewModel.state)") return }- let counts = try await fixture.repository.debugCounts()- #expect(counts.entries == 0)+ #expect(fixture.coordinator.commitCaptureCallCount == 0) } - // MARK: - Blank manual title validation (Requirement 2.8)+ // MARK: - Blank manual title validation @Test("Blank manual title keeps save disabled")- func blankManualTitleDisablesSave() async throws {- let fixture = try await CaptureStateFixture()- let safari = SafariPageMetadata(title: "", locationHref: "https://example.com/page")- let payload = SharePayload(providerURL: "https://example.com/page", safari: safari)- await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)- // Manual title required but blank- let canSave = await fixture.viewModel.canSave- #expect(canSave == false)- }-- @Test("Non-blank manual title enables save")- func nonBlankManualTitleEnablesSave() async throws {- let fixture = try await CaptureStateFixture()- let safari = SafariPageMetadata(title: "", locationHref: "https://example.com/page")- let payload = SharePayload(providerURL: "https://example.com/page", safari: safari)+ @MainActor func blankManualTitleDisablesSave() async throws {+ let fixture = CaptureStateFixture.manualTitle()+ let payload = SharePayload(+ providerURL: "https://example.com/page",+ safari: SafariPageMetadata(title: "", locationHref: "https://example.com/page")+ ) await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)- await fixture.viewModel.setManualTitle("My Title")- let canSave = await fixture.viewModel.canSave- #expect(canSave == true)+ #expect(fixture.viewModel.canSave == false) } - @Test("Save with blank manual title does not persist")- func saveWithBlankManualTitleDoesNotPersist() async throws {- let fixture = try await CaptureStateFixture()- let safari = SafariPageMetadata(title: "", locationHref: "https://example.com/page")- let payload = SharePayload(providerURL: "https://example.com/page", safari: safari)- await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)- // Attempt save without providing manual title- await fixture.viewModel.save(coordinator: fixture.coordinator)- let counts = try await fixture.repository.debugCounts()- #expect(counts.entries == 0)- }-- @Test("Resolved title preserved verbatim without Untitled fallback")- func resolvedTitleVerbatimNoUntitled() async throws {- let fixture = try await CaptureStateFixture()- let safari = SafariPageMetadata(title: " Spaces Around ", locationHref: "https://example.com/page")- let payload = SharePayload(providerURL: "https://example.com/page", safari: safari)+ @Test("Non-blank manual title enables save (with projection)")+ @MainActor func nonBlankManualTitleEnablesSave() async throws {+ let fixture = CaptureStateFixture.manualTitle()+ let payload = SharePayload(+ providerURL: "https://example.com/page",+ safari: SafariPageMetadata(title: "", locationHref: "https://example.com/page")+ ) await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)- let draft = await fixture.viewModel.currentDraft- // Title preserved verbatim (not trimmed, not replaced with "Untitled")- #expect(draft?.captureTitle == " Spaces Around ")+ await fixture.viewModel.setManualTitle("My Title", coordinator: fixture.coordinator)+ #expect(fixture.viewModel.canSave == true) } @Test("Non-HTTP URL produces invalidInput state")- func nonHTTPURLProducesInvalidInput() async throws {- let fixture = try await CaptureStateFixture()+ @MainActor func nonHTTPURLProducesInvalidInput() async throws {+ let fixture = CaptureStateFixture.taughtSite() let payload = SharePayload(providerURL: "file:///tmp/notes.txt") await fixture.viewModel.load(payload: payload, coordinator: fixture.coordinator)- let state = await fixture.viewModel.state- guard case .invalidInput = state else {- Issue.record("Expected invalidInput state, got \(state)")+ guard case .invalidInput = fixture.viewModel.state else {+ Issue.record("Expected invalidInput state, got \(fixture.viewModel.state)") return } } } -// MARK: - Test Infrastructure+// MARK: - Fake CaptureCoordinating for controllable testing -private final class FailingSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+private final class FakeCaptureCoordinating: CaptureCoordinating, @unchecked Sendable { private let lock = NSLock()- private var failureEnabled = false - var failSaves: Bool {- get { lock.withLock { failureEnabled } }- set { lock.withLock { failureEnabled = newValue } }+ var prepareResult: Result<CapturePreparation, Error> = .success(CapturePreparation(+ rawURL: "https://taught.example/ch5",+ resolvedTitle: "Chapter 5",+ titleSource: .safariDocument,+ canonicalURL: nil,+ siteStatus: .existingTaught+ ))++ private var _projectCaptureCallCount = 0+ var projectCaptureCallCount: Int { lock.withLock { _projectCaptureCallCount } }+ var projectCaptureResult: Result<CaptureContract, Error>?+ var projectDelay: TimeInterval = 0++ private var _commitCaptureCallCount = 0+ var commitCaptureCallCount: Int { lock.withLock { _commitCaptureCallCount } }+ var commitOutcome: CaptureCommitOutcome?+ var commitShouldThrow = false++ /// Track legacy save calls — should always be 0 in new flow+ var legacySaveCallCount = 0++ /// Default projection outcome+ private var defaultProjectionOutcome: CaptureOutcome {+ CaptureOutcome(+ projectedChapter: "5",+ projectedWorkTitle: "Test Work",+ workOutcome: .reuse(workID: UUID()),+ projectedWorkID: UUID(),+ actionable: false,+ intentionallyUnattached: false+ )+ }++ func prepare(_ payload: SharePayload) async throws -> CapturePreparation {+ // Validate URL like the real coordinator does+ let rawURL = payload.safari?.locationHref ?? payload.providerURL+ guard let components = URLComponents(string: rawURL),+ let scheme = components.scheme?.lowercased(),+ (scheme == "http" || scheme == "https"),+ let host = components.host, !host.isEmpty else {+ throw CapturePreparationError(+ userMessage: "A page URL is required. The shared content does not contain a usable web address."+ )+ }+ return try prepareResult.get() } - func save(_ context: ModelContext) throws {- if failSaves { throw CocoaError(.fileWriteUnknown) }- try context.save()+ func projectCapture(+ hostname: String, captureTitle: String, captureTitleSource: CaptureTitleSource,+ rawURLString: String, canonicalURLString: String?, note: String, rating: Rating?+ ) async throws -> CaptureContract {+ lock.withLock { _projectCaptureCallCount += 1 }++ let delay = lock.withLock { projectDelay }+ if delay > 0 {+ try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))+ }++ let customResult: Result<CaptureContract, Error>? = lock.withLock { projectCaptureResult }+ if let customResult {+ return try customResult.get()+ }++ let basis = CaptureBasis(siteMode: .taught, hostname: hostname, activePattern: nil, works: [])+ let request = CaptureRequest(+ captureTitle: captureTitle, captureTitleSource: captureTitleSource,+ rawURLString: rawURLString, canonicalURLString: canonicalURLString,+ note: note, rating: rating+ )+ return CaptureContract(basis: basis, request: request, outcome: defaultProjectionOutcome)+ }++ func commitCapture(_ contract: CaptureContract) async throws -> CaptureCommitOutcome {+ lock.withLock { _commitCaptureCallCount += 1 }+ let shouldThrow: Bool = lock.withLock { commitShouldThrow }+ if shouldThrow {+ throw CaptureViewModelError.commitFailed("Simulated failure")+ }+ if let outcome: CaptureCommitOutcome = lock.withLock({ commitOutcome }) {+ return outcome+ }+ return Self.makeDefaultCommittedOutcome()+ }++ private static func makeDefaultCommittedOutcome() -> CaptureCommitOutcome {+ // Safe: .none provenance with nil values cannot throw+ let noneProvenance = try! FieldProvenance(kind: .none)+ return .committed(EntrySnapshot(+ id: UUID(), captureTitle: "Chapter 5", captureTitleSource: .safariDocument,+ rawURLString: "https://taught.example/ch5", canonicalURLString: nil,+ hostname: "taught.example", entryIdentityKey: "taught.example/ch5",+ identityKeyVersion: 1, chapterTitle: "5",+ chapterTitleProvenance: noneProvenance,+ note: "", rating: nil,+ firstCapturedAt: Date(timeIntervalSince1970: 1_721_000_000),+ lastSharedAt: Date(timeIntervalSince1970: 1_721_000_000),+ modifiedAt: Date(timeIntervalSince1970: 1_721_000_000),+ workID: nil,+ workAssignmentProvenance: noneProvenance,+ intentionallyUnattached: false+ )) } } +// MARK: - Test Fixtures++@MainActor private struct CaptureStateFixture {- let directory: URL- let configuration: LibraryConfiguration- let repository: LibraryRepository- let coordinator: CaptureCoordinator+ let coordinator: FakeCaptureCoordinating let viewModel: CaptureViewModel+ let defaultPayload: SharePayload++ let captureBasis = CaptureBasis(siteMode: .taught, hostname: "taught.example", activePattern: nil, works: [])++ private static let stableEntryID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!++ // Safe: .none provenance with nil values cannot throw+ var entrySnapshot: EntrySnapshot {+ let noneProvenance = try! FieldProvenance(kind: .none)+ return EntrySnapshot(+ id: Self.stableEntryID, captureTitle: "Chapter 5", captureTitleSource: .safariDocument,+ rawURLString: "https://taught.example/ch5", canonicalURLString: nil,+ hostname: "taught.example", entryIdentityKey: "taught.example/ch5",+ identityKeyVersion: 1, chapterTitle: "5",+ chapterTitleProvenance: noneProvenance,+ note: "", rating: nil,+ firstCapturedAt: Date(timeIntervalSince1970: 1_721_000_000),+ lastSharedAt: Date(timeIntervalSince1970: 1_721_000_000),+ modifiedAt: Date(timeIntervalSince1970: 1_721_000_000),+ workID: nil,+ workAssignmentProvenance: noneProvenance,+ intentionallyUnattached: false+ )+ }++ private init(coordinator: FakeCaptureCoordinating, payload: SharePayload) {+ self.coordinator = coordinator+ self.viewModel = CaptureViewModel()+ self.defaultPayload = payload+ } - @MainActor- init(saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()) async throws {- directory = FileManager.default.temporaryDirectory- .appending(path: "AsterismCaptureStateTests-\(UUID())", directoryHint: .isDirectory)- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)- repository = try await LibraryRepository.open(- configuration,- clock: FixedRepositoryClock(Date(timeIntervalSince1970: 1_721_000_000)),- saveStrategy: saveStrategy+ static func taughtSite() -> CaptureStateFixture {+ let coordinator = FakeCaptureCoordinating()+ coordinator.prepareResult = .success(CapturePreparation(+ rawURL: "https://taught.example/ch5",+ resolvedTitle: "Chapter 5",+ titleSource: .safariDocument,+ canonicalURL: nil,+ siteStatus: .existingTaught+ ))+ let payload = SharePayload(+ providerURL: "https://taught.example/ch5",+ safari: SafariPageMetadata(title: "Chapter 5", locationHref: "https://taught.example/ch5")+ )+ return CaptureStateFixture(coordinator: coordinator, payload: payload)+ }++ static func untaughtSite() -> CaptureStateFixture {+ let coordinator = FakeCaptureCoordinating()+ coordinator.prepareResult = .success(CapturePreparation(+ rawURL: "https://new-site.example/ch5",+ resolvedTitle: "Chapter 5",+ titleSource: .safariDocument,+ canonicalURL: nil,+ siteStatus: .newSite+ ))+ coordinator.projectCaptureResult = .success(CaptureContract(+ basis: CaptureBasis(siteMode: .untaught, hostname: "new-site.example", activePattern: nil, works: []),+ request: CaptureRequest(+ captureTitle: "Chapter 5", captureTitleSource: .safariDocument,+ rawURLString: "https://new-site.example/ch5", canonicalURLString: nil,+ note: "", rating: nil+ ),+ outcome: CaptureOutcome(+ projectedChapter: nil, projectedWorkTitle: nil,+ workOutcome: nil, projectedWorkID: nil,+ actionable: true, intentionallyUnattached: false+ )+ ))+ let payload = SharePayload(+ providerURL: "https://new-site.example/ch5",+ safari: SafariPageMetadata(title: "Chapter 5", locationHref: "https://new-site.example/ch5") )- coordinator = CaptureCoordinator(repository: repository)- viewModel = CaptureViewModel()+ return CaptureStateFixture(coordinator: coordinator, payload: payload) } - @MainActor- func loadDefault() async {- let safari = SafariPageMetadata(title: "Default Title", locationHref: "https://example.com/default")- let payload = SharePayload(providerURL: "https://example.com/default", safari: safari)- await viewModel.load(payload: payload, coordinator: coordinator)+ static func manualTitle() -> CaptureStateFixture {+ let coordinator = FakeCaptureCoordinating()+ coordinator.prepareResult = .success(CapturePreparation(+ rawURL: "https://example.com/page",+ resolvedTitle: nil,+ titleSource: .manual,+ canonicalURL: nil,+ siteStatus: .existingUntaught+ ))+ let payload = SharePayload(+ providerURL: "https://example.com/page",+ safari: SafariPageMetadata(title: "", locationHref: "https://example.com/page")+ )+ return CaptureStateFixture(coordinator: coordinator, payload: payload)+ }++ static func delayedProjection() -> CaptureStateFixture {+ let coordinator = FakeCaptureCoordinating()+ coordinator.prepareResult = .success(CapturePreparation(+ rawURL: "https://example.com/page",+ resolvedTitle: nil,+ titleSource: .manual,+ canonicalURL: nil,+ siteStatus: .existingUntaught+ ))+ let payload = SharePayload(+ providerURL: "https://example.com/page",+ safari: SafariPageMetadata(title: "", locationHref: "https://example.com/page")+ )+ return CaptureStateFixture(coordinator: coordinator, payload: payload) } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swiftindex aeabee7..db847a3 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swift@@ -14,9 +14,10 @@ struct ConfigurationShellTests { let root = URL(filePath: "/tmp/asterism-test", directoryHint: .isDirectory) let configuration = LibraryConfiguration(rootDirectory: root, environment: .development) - #expect(configuration.storeURL.path.hasSuffix("Library/Application Support/Asterism.sqlite"))+ #expect(configuration.storeURL.path.hasSuffix("Library/Application Support/AsterismV2.sqlite"))+ #expect(configuration.legacyStoreURL.path.hasSuffix("Library/Application Support/Asterism.sqlite")) #expect(configuration.lockURL.lastPathComponent == "Asterism.lock")- #expect(configuration.markerURL.lastPathComponent == "Asterism.initialized")+ #expect(configuration.markerURL.lastPathComponent == "AsterismV2.ready") } @Test("currentEnvironment resolves to development under DEBUG")
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/InstrumentedSaveStrategy.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/InstrumentedSaveStrategy.swiftnew file mode 100644index 0000000..a95aae8--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/InstrumentedSaveStrategy.swift@@ -0,0 +1,51 @@+import Foundation+import SwiftData+@testable import AsterismCore++/// Save strategy that counts attempts and successful saves separately.+/// Used to prove: accepted transaction invokes save exactly once (attempts==1, successes==1),+/// stale path invokes zero saves (attempts==0, successes==0), and injected failure+/// records the attempt but not the success (attempts==1, successes==0).+final class InstrumentedSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+ private let lock = NSLock()+ private var _attemptCount = 0+ private var _successCount = 0+ private var _failureEnabled = false++ var attemptCount: Int {+ lock.withLock { _attemptCount }+ }++ var successCount: Int {+ lock.withLock { _successCount }+ }++ /// Legacy compat: returns successful save count.+ var saveCount: Int {+ lock.withLock { _successCount }+ }++ var shouldFail: Bool {+ get { lock.withLock { _failureEnabled } }+ set { lock.withLock { _failureEnabled = newValue } }+ }++ func resetCounts() {+ lock.withLock {+ _attemptCount = 0+ _successCount = 0+ }+ }++ /// Legacy compat+ func resetCount() { resetCounts() }++ func save(_ context: ModelContext) throws {+ lock.withLock { _attemptCount += 1 }+ if lock.withLock({ _failureEnabled }) {+ throw CocoaError(.fileWriteUnknown)+ }+ try context.save()+ lock.withLock { _successCount += 1 }+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M2ScaleHarnessTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M2ScaleHarnessTests.swiftnew file mode 100644index 0000000..3dab676--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M2ScaleHarnessTests.swift@@ -0,0 +1,266 @@+import Foundation+import Testing+@testable import AsterismCore++@Suite("M2 supported-scale harness", .serialized)+struct M2ScaleHarnessTests {+ @Test("Fixture is deterministic and matches the required 20,000/2,000 shape")+ func fixtureShape() throws {+ let fixture = try M2ScaleFixture.make()++ #expect(fixture.entries.count == 20_000)+ #expect(fixture.works.count == 2_000)+ #expect(fixture.siteEntryCounts.values.reduce(0, +) == 20_000)+ #expect(fixture.siteEntryCounts.values.count(where: { $0 == 5_000 }) == 1)+ #expect(fixture.siteEntryCounts[fixture.targetHostname] == 5_000)+ #expect(fixture.siteWorkCounts.values.reduce(0, +) == 2_000)+ #expect(fixture.segmentEdits.count == 10)+ #expect(fixture.phraseEdits.count == 10)+ #expect(fixture.entries.contains(where: { $0.chapterTitleProvenance.kind == .manual }))+ #expect(fixture.entries.contains(where: { $0.chapterTitleProvenance.kind == .pattern }))+ #expect(fixture.entries.contains(where: { $0.workAssignmentProvenance.kind == .manual }))+ #expect(fixture.entries.contains(where: { $0.workAssignmentProvenance.kind == .pattern }))+ #expect(fixture.entries.contains(where: M2ScaleFixture.isActionable))++ let repeated = try M2ScaleFixture.make()+ #expect(fixture.entries.first?.id == repeated.entries.first?.id)+ #expect(fixture.entries.last?.id == repeated.entries.last?.id)+ #expect(fixture.works.first?.id == repeated.works.first?.id)+ #expect(fixture.works.last?.id == repeated.works.last?.id)+ }++ @Test("Repository seeder persists one valid exact-scale graph")+ func repositorySeeder() async throws {+ let root = FileManager.default.temporaryDirectory+ .appending(path: "asterism-scale-seeder-\(UUID().uuidString)", directoryHint: .isDirectory)+ defer { try? FileManager.default.removeItem(at: root) }+ let configuration = LibraryConfiguration(rootDirectory: root, environment: .development)+ let repository = try await LibraryRepository.openForApp(+ configuration,+ capabilities: .m2_3+ )++ try await repository.seedM2PerformanceFixture()++ let counts = try await repository.debugCounts()+ #expect(counts.entries == 20_000)+ #expect(counts.works == 2_000)+ #expect(counts.sites == 6)+ #expect(counts.titlePatterns == 2)++ let snapshot = try await repository.backupSnapshot()+ try V2LibraryValidator.validate(snapshot: snapshot, capabilities: .m2_3)+ #expect(snapshot.entries.count(where: { $0.hostname == "scale.test" }) == 5_000)+ #expect(snapshot.sites.count(where: { $0.mode == .taught }) == 1)+ #expect(snapshot.sites.count(where: { $0.mode == .articles }) == 1)+ }++ @Test("Performance signpost names stay compatible with the physical hooks")+ func signpostNames() {+ #expect(M2PerformanceSignposts.subsystem == "me.nore.ig.Asterism")+ #expect(M2PerformanceSignposts.category == "M2Performance")+ #expect(M2PerformanceSignposts.recentPublication == "RecentPublication")+ #expect(M2PerformanceSignposts.editAcknowledgement == "TeachingEditAcknowledgement")+ #expect(+ M2PerformanceSignposts.finalPreviewPublication+ == "TeachingFinalPreviewPublication"+ )+ }++ @Test("The 5,000-entry Site produces a complete deterministic projection")+ func targetSiteProjection() throws {+ let fixture = try M2ScaleFixture.make()+ let targetEntries = fixture.entries.filter { $0.hostname == fixture.targetHostname }+ let targetWorks = fixture.works+ .filter { $0.siteHostname == fixture.targetHostname }+ .map {+ WorkMatchCandidate(+ id: $0.id,+ lastParsedTitle: $0.lastParsedTitle,+ displayTitle: $0.displayTitle+ )+ }++ let first = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: fixture.segmentEdits.last!,+ entries: targetEntries,+ existingWorks: targetWorks+ )+ let second = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: fixture.segmentEdits.last!,+ entries: targetEntries.reversed(),+ existingWorks: targetWorks.reversed()+ )++ #expect(first.entryProjections.count == 5_000)+ #expect(first.worksToCreate.isEmpty)+ #expect(!first.hasAmbiguity)+ #expect(first == second)+ }+}++private struct M2ScaleFixture {+ let targetHostname: String+ let entries: [EntrySnapshot]+ let works: [WorkBasisEntry]+ let siteEntryCounts: [String: Int]+ let siteWorkCounts: [String: Int]+ let segmentEdits: [PatternDefinition]+ let phraseEdits: [PatternDefinition]++ static func make() throws -> M2ScaleFixture {+ let entryCounts = [+ "scale.test": 5_000,+ "archive.test": 4_200,+ "articles.test": 3_600,+ "serial.test": 3_000,+ "notes.test": 2_400,+ "essays.test": 1_800,+ ]+ let workCounts = [+ "scale.test": 500,+ "archive.test": 400,+ "articles.test": 350,+ "serial.test": 300,+ "notes.test": 250,+ "essays.test": 200,+ ]+ guard entryCounts.values.reduce(0, +) == 20_000,+ workCounts.values.reduce(0, +) == 2_000,+ entryCounts.values.count(where: { $0 == 5_000 }) == 1 else {+ throw ScaleFixtureError.invalidCardinality+ }++ let targetHostname = "scale.test"+ let activePatternID = fixedUUID(namespace: 1, index: 1)+ let none = try FieldProvenance(kind: .none)+ let manual = try FieldProvenance(kind: .manual)+ let parsed = try FieldProvenance(+ kind: .pattern,+ patternID: activePatternID,+ patternVersion: 2+ )++ var works: [WorkBasisEntry] = []+ var workIDsBySite: [String: [UUID]] = [:]+ var globalWorkIndex = 0+ for hostname in workCounts.keys.sorted() {+ guard let count = workCounts[hostname], count > 0 else {+ throw ScaleFixtureError.invalidSiteCount(hostname)+ }+ var siteIDs: [UUID] = []+ siteIDs.reserveCapacity(count)+ for localIndex in 0..<count {+ let id = fixedUUID(namespace: 2, index: globalWorkIndex)+ let title = "Work \(localIndex)"+ works.append(+ WorkBasisEntry(+ id: id,+ displayTitle: title,+ lastParsedTitle: localIndex.isMultiple(of: 2) ? title : nil,+ titleProvenance: localIndex.isMultiple(of: 3) ? .manual : .parsed,+ siteHostname: hostname,+ createdAt: Date(timeIntervalSince1970: TimeInterval(globalWorkIndex)),+ modifiedAt: Date(timeIntervalSince1970: TimeInterval(globalWorkIndex + 1))+ )+ )+ siteIDs.append(id)+ globalWorkIndex += 1+ }+ workIDsBySite[hostname] = siteIDs+ }++ var entries: [EntrySnapshot] = []+ entries.reserveCapacity(20_000)+ var globalEntryIndex = 0+ for hostname in entryCounts.keys.sorted() {+ guard let count = entryCounts[hostname], count > 0,+ let siteWorkIDs = workIDsBySite[hostname], !siteWorkIDs.isEmpty else {+ throw ScaleFixtureError.invalidSiteCount(hostname)+ }+ for localIndex in 0..<count {+ let isTarget = hostname == targetHostname+ let state = isTarget ? localIndex % 4 : 2+ let chapterProvenance = state == 0 ? manual : (state == 1 || state == 3 ? parsed : none)+ let assignmentProvenance = state == 0 ? manual : (state == 1 || state == 3 ? parsed : none)+ let workID = state == 0 || state == 1+ ? siteWorkIDs[localIndex % siteWorkIDs.count]+ : nil+ let chapterTitle = state == 0 || state == 1 || state == 3+ ? "Chapter \(localIndex)"+ : nil+ let timestamp = Date(timeIntervalSince1970: TimeInterval(globalEntryIndex))+ entries.append(+ EntrySnapshot(+ id: fixedUUID(namespace: 3, index: globalEntryIndex),+ captureTitle: "Chapter \(localIndex) - Work \(localIndex % siteWorkIDs.count) | \(hostname)",+ captureTitleSource: localIndex.isMultiple(of: 2) ? .host : .manual,+ rawURLString: "https://\(hostname)/entry/\(localIndex)",+ canonicalURLString: nil,+ hostname: hostname,+ entryIdentityKey: "https://\(hostname)/entry/\(localIndex)",+ identityKeyVersion: 1,+ chapterTitle: chapterTitle,+ chapterTitleProvenance: chapterProvenance,+ note: localIndex.isMultiple(of: 17) ? "Representative note" : "",+ rating: localIndex.isMultiple(of: 19) ? .up : nil,+ firstCapturedAt: timestamp,+ lastSharedAt: timestamp,+ modifiedAt: timestamp,+ workID: workID,+ workAssignmentProvenance: assignmentProvenance,+ intentionallyUnattached: false+ )+ )+ globalEntryIndex += 1+ }+ }++ let segmentDefinition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .start, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let segmentEdits = Array(repeating: segmentDefinition, count: 10)+ let phraseEdits = (0..<10).map { edit in+ PatternDefinition.phrase(+ prefix: "Read \(edit): ",+ separator: " from ",+ suffix: ".",+ order: edit.isMultiple(of: 2) ? .chapterThenWork : .workThenChapter+ )+ }++ return M2ScaleFixture(+ targetHostname: targetHostname,+ entries: entries,+ works: works,+ siteEntryCounts: entryCounts,+ siteWorkCounts: workCounts,+ segmentEdits: segmentEdits,+ phraseEdits: phraseEdits+ )+ }++ static func isActionable(_ entry: EntrySnapshot) -> Bool {+ ActionabilityEvaluator.isActionable(+ chapterTitle: entry.chapterTitle,+ chapterProvenance: entry.chapterTitleProvenance,+ workID: entry.workID,+ assignmentProvenance: entry.workAssignmentProvenance,+ intentionallyUnattached: entry.intentionallyUnattached+ )+ }++ private static func fixedUUID(namespace: Int, index: Int) -> UUID {+ let value = String(format: "%012llX", UInt64(index))+ guard let id = UUID(uuidString: String(format: "%08X-0000-4000-8000-%@", namespace, value)) else {+ preconditionFailure("The deterministic scale UUID format must remain valid")+ }+ return id+ }+}++private enum ScaleFixtureError: Error {+ case invalidCardinality+ case invalidSiteCount(String)+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/PhraseParsingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/PhraseParsingTests.swiftnew file mode 100644index 0000000..89eb01b--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/PhraseParsingTests.swift@@ -0,0 +1,280 @@+import Foundation+import Testing+@testable import AsterismCore++@Suite("Phrase pattern authoring and application", .serialized)+struct PhrasePatternTests {+ private let tapasTitle = "Check out Episode 10 from The Regressor Creates Everything on Tapas."++ @Test("Tapas example derives exact literals and parses a later episode")+ func derivesAndAppliesTapasTemplate() throws {+ let selection = PhraseSelection(+ chapter: 10..<20,+ work: 26..<58+ )++ let definition = try PhrasePatternDeriver.derive(+ from: tapasTitle,+ selection: selection+ )++ #expect(definition == .phrase(+ prefix: "Check out ",+ separator: " from ",+ suffix: " on Tapas.",+ order: .chapterThenWork+ ))++ let result = TitlePatternApplicator.apply(+ definition: definition,+ to: "Check out Episode 11 from The Regressor Creates Everything on Tapas."+ )+ guard case .success(let parsed) = result else {+ Issue.record("Expected phrase parse success")+ return+ }+ #expect(parsed.chapterTitle == "Episode 11")+ #expect(parsed.workTitle == "The Regressor Creates Everything")+ }++ @Test("Work-before-chapter order is retained")+ func derivesWorkBeforeChapter() throws {+ let title = "Read Fiction Name — Chapter 12 now"+ let definition = try PhrasePatternDeriver.derive(+ from: title,+ selection: PhraseSelection(chapter: 20..<30, work: 5..<17)+ )++ #expect(definition == .phrase(+ prefix: "Read ",+ separator: " — ",+ suffix: " now",+ order: .workThenChapter+ ))+ #expect(+ TitlePatternApplicator.apply(+ definition: definition,+ to: "Read Fiction Name — Chapter 13 now"+ ) == .success(TitleParseResult(workTitle: "Fiction Name", chapterTitle: "Chapter 13"))+ )+ }++ @Test("Selection uses Character boundaries and preserves composed graphemes")+ func selectionUsesExtendedGraphemeBoundaries() throws {+ let title = "📚 Café from Novel"+ let definition = try PhrasePatternDeriver.derive(+ from: title,+ selection: PhraseSelection(chapter: 0..<6, work: 12..<17)+ )++ #expect(definition == .phrase(+ prefix: "",+ separator: " from ",+ suffix: "",+ order: .chapterThenWork+ ))+ #expect(+ TitlePatternApplicator.apply(definition: definition, to: title)+ == .success(TitleParseResult(workTitle: "Novel", chapterTitle: "📚 Café"))+ )+ }++ @Test("Invalid, overlapping, blank, and adjacent selections are rejected")+ func rejectsInvalidSelections() {+ let title = "Chapter from Work"+ let invalid: [(PhraseSelection, PhraseTeachingError)] = [+ (PhraseSelection(chapter: -1..<3, work: 13..<17), .selectionOutOfBounds(role: .chapter)),+ (PhraseSelection(chapter: 0..<8, work: 7..<12), .overlappingSelections),+ (PhraseSelection(chapter: 0..<7, work: 7..<11), .blankSeparator),+ (PhraseSelection(chapter: 0..<0, work: 13..<17), .blankSelection(role: .chapter)),+ ]++ for (selection, expected) in invalid {+ #expect(throws: expected) {+ try PhrasePatternDeriver.derive(from: title, selection: selection)+ }+ }+ }++ @Test("Prefix, suffix, and separator are exact scalar sequences")+ func exactScalarMatching() {+ let definition = PatternDefinition.phrase(+ prefix: "Caf\u{00E9} ",+ separator: " from ",+ suffix: ".",+ order: .chapterThenWork+ )++ #expect(+ TitlePatternApplicator.apply(+ definition: definition,+ to: "Caf\u{00E9} Chapter from Work."+ ) == .success(TitleParseResult(workTitle: "Work", chapterTitle: "Chapter"))+ )++ let decomposed = "Cafe\u{0301} Chapter from Work."+ guard case .failure(.literalMismatch(.prefix)) = TitlePatternApplicator.apply(+ definition: definition,+ to: decomposed+ ) else {+ Issue.record("Canonical-equivalent but scalar-distinct prefix must fail")+ return+ }+ }++ @Test("Every overlapping separator start is counted")+ func overlappingSeparatorStartsAreAmbiguous() {+ let definition = PatternDefinition.phrase(+ prefix: "",+ separator: "aa",+ suffix: "",+ order: .chapterThenWork+ )++ #expect(+ TitlePatternApplicator.apply(definition: definition, to: "aaaa")+ == .failure(.separatorOccurrenceCount(3))+ )+ }++ @Test("Missing literals, consumed overlap, and blank fields fail")+ func phraseFailureModes() {+ let definition = PatternDefinition.phrase(+ prefix: "Read ",+ separator: " from ",+ suffix: ".",+ order: .chapterThenWork+ )++ #expect(+ TitlePatternApplicator.apply(definition: definition, to: "Watch Chapter from Work.")+ == .failure(.literalMismatch(.prefix))+ )+ let overlappingLiterals = PatternDefinition.phrase(+ prefix: "aba",+ separator: "x",+ suffix: "aba",+ order: .chapterThenWork+ )+ #expect(+ TitlePatternApplicator.apply(definition: overlappingLiterals, to: "aba")+ == .failure(.literalRegionsOverlap)+ )+ #expect(+ TitlePatternApplicator.apply(definition: definition, to: "Read from Work.")+ == .failure(.blankResult(field: "chapter"))+ )+ }++ @Test("Candidate validation reproduces the exact selected fields")+ func candidateReproducesSelection() throws {+ let selection = PhraseSelection(chapter: 10..<20, work: 26..<58)+ let definition = try PhrasePatternDeriver.derive(from: tapasTitle, selection: selection)++ try PhrasePatternDeriver.validateCandidate(+ definition,+ example: tapasTitle,+ selection: selection+ )++ #expect(throws: PhraseTeachingError.candidateDoesNotReproduceSelection) {+ try PhrasePatternDeriver.validateCandidate(+ .phrase(prefix: "Check out ", separator: " from ", suffix: "", order: .chapterThenWork),+ example: tapasTitle,+ selection: selection+ )+ }+ }++ @Test("Accessible summary escapes whitespace, controls, formats, default ignorables, and backslash once")+ func accessibleSummaryEscapesSpecialScalars() {+ let definition = PatternDefinition.phrase(+ prefix: " \t\\",+ separator: "\u{200B}from ",+ suffix: "\n",+ order: .chapterThenWork+ )++ #expect(+ PatternSummaryFormatter.summary(for: definition)+ == "Phrase; order: Chapter then Work; prefix: \\u{20}\\u{9}\\\\; separator: \\u{200B}from\\u{20}; suffix: \\u{A}"+ )+ }++ @Test("Unified applicator continues to dispatch segment patterns")+ func unifiedApplicatorSupportsSegments() throws {+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ #expect(+ TitlePatternApplicator.apply(+ definition: definition,+ to: "Chapter - Work | Site"+ ) == .success(TitleParseResult(workTitle: "Work", chapterTitle: "Chapter"))+ )+ }+}++@Suite("Phrase projection dispatch", .serialized)+struct PhraseProjectionTests {+ @Test("Planner projects phrase output through the shared contract")+ func plannerProjectsPhraseOutput() throws {+ let timestamp = Date(timeIntervalSince1970: 1_721_000_000)+ let entry = EntrySnapshot(+ id: UUID(),+ captureTitle: "Read Chapter 1 from Work Name.",+ captureTitleSource: .safariDocument,+ rawURLString: "https://example.com/1",+ canonicalURLString: nil,+ hostname: "example.com",+ entryIdentityKey: "https://example.com/1",+ identityKeyVersion: 1,+ chapterTitle: nil,+ chapterTitleProvenance: try FieldProvenance(kind: .none),+ note: "",+ rating: nil,+ firstCapturedAt: timestamp,+ lastSharedAt: timestamp,+ modifiedAt: timestamp,+ workID: nil,+ workAssignmentProvenance: try FieldProvenance(kind: .none),+ intentionallyUnattached: false+ )+ let definition = PatternDefinition.phrase(+ prefix: "Read ",+ separator: " from ",+ suffix: ".",+ order: .chapterThenWork+ )++ let plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: definition,+ entries: [entry],+ existingWorks: []+ )++ #expect(plan.entryProjections.first?.projectedChapter == "Chapter 1")+ #expect(plan.entryProjections.first?.projectedWorkTitle == "Work Name")+ #expect(plan.worksToCreate == ["Work Name"])+ }++ @Test("M2.3 permits phrase patterns while earlier gates reject them")+ func gateValidation() throws {+ let definition = PatternDefinition.phrase(+ prefix: "Read ",+ separator: " from ",+ suffix: ".",+ order: .chapterThenWork+ )++ for capabilities in [M2Capabilities.m2_0, .m2_1, .m2_2] {+ #expect(throws: M2CapabilityError.self) {+ try capabilities.validate(patternDefinition: definition)+ }+ }+ try M2Capabilities.m2_3.validate(patternDefinition: definition)+ #expect(M2Capabilities.current == .m2_3)+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swiftindex 9e3598d..4967ee2 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift@@ -230,7 +230,7 @@ private func fetchWorkModifiedAt(configuration: LibraryConfiguration, workID: UU } private func makeStoreContainer(_ configuration: LibraryConfiguration) throws -> ModelContainer {- let schema = Schema(versionedSchema: AsterismSchemaV1.self)- let store = ModelConfiguration("AsterismV1", schema: schema, url: configuration.storeURL, cloudKitDatabase: .none)- return try ModelContainer(for: schema, migrationPlan: AsterismMigrationPlan.self, configurations: [store])+ let schema = Schema(versionedSchema: AsterismSchemaV2.self)+ let store = ModelConfiguration("AsterismV2", schema: schema, url: configuration.storeURL, cloudKitDatabase: .none)+ return try ModelContainer(for: schema, migrationPlan: AsterismV2MigrationPlan.self, configurations: [store]) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swiftnew file mode 100644index 0000000..d736b8f--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift@@ -0,0 +1,1612 @@+import Foundation+import SwiftData+import Testing+@testable import AsterismCore++// MARK: - Task 9: Repository teaching, Re-parse, Recent, and capture tests++@Suite("Repository teaching transactions (contract-based)", .serialized)+struct RepositoryTeachingTests {++ // MARK: - Initial teaching++ @Test("Initial teaching: preview then commit marks Site taught, creates pattern v1")+ func initialTeachingSuccess() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ let e1 = try await fixture.capture(title: "Chapter 1 - Fiction | Royal Road", rawURL: "https://example.com/1")+ let e2 = try await fixture.capture(title: "Chapter 2 - Fiction | Royal Road", rawURL: "https://example.com/2")+ save.resetCount()++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ // Preview+ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com", patternDefinition: definition+ )+ #expect(contract.outcome.computedPatternVersion == 1)++ // Commit+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .committed(let patternID, let patternVersion) = result else {+ Issue.record("Expected .committed, got \(result)")+ return+ }+ #expect(patternVersion == 1)+ #expect(save.saveCount == 1) // Exactly one save++ // Verify Site is now taught+ let status = try await fixture.repository.siteStatus(forRawURL: "https://example.com/x")+ #expect(status == .existingTaught)++ // Verify entries got chapter and Work assignment+ let entry1 = try await fixture.repository.entry(id: e1.id)+ #expect(entry1.chapterTitle == "Chapter 1")+ #expect(entry1.chapterTitleProvenance.kind == .pattern)+ #expect(entry1.chapterTitleProvenance.patternVersion == 1)+ #expect(entry1.workID != nil)++ let entry2 = try await fixture.repository.entry(id: e2.id)+ #expect(entry2.chapterTitle == "Chapter 2")+ #expect(entry2.workID == entry1.workID) // Same Work reused+ _ = patternID+ }++ @Test("Initial teaching creates one Work per exact parsed title")+ func initialTeachingWorkCreation() async throws {+ let fixture = try await TeachingFixture()+ _ = try await fixture.capture(title: "Ch1 - Work A | Site", rawURL: "https://example.com/1")+ _ = try await fixture.capture(title: "Ch2 - Work A | Site", rawURL: "https://example.com/2")+ _ = try await fixture.capture(title: "Ch1 - Work B | Site", rawURL: "https://example.com/3")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com", patternDefinition: definition+ )+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .committed = result else { Issue.record("Expected committed"); return }++ let counts = try await fixture.repository.debugCounts()+ #expect(counts.works == 2) // "Work A" and "Work B"+ #expect(counts.titlePatterns == 1)+ }++ @Test("Initial teaching reuses existing Work by exact lastParsedTitle match")+ func initialTeachingWorkReuse() async throws {+ let fixture = try await TeachingFixture()+ _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let existingWork = try await fixture.repository.createWork(+ NewWorkDraft(displayTitle: "My Fiction", hostname: "example.com")+ )+ try await fixture.seedLastParsedTitle(workID: existingWork.id, title: "Fiction")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com", patternDefinition: definition+ )+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .committed = result else { Issue.record("Expected committed"); return }++ let counts = try await fixture.repository.debugCounts()+ #expect(counts.works == 1)+ }++ @Test("Initial teaching with ambiguous Work match leaves assignment unresolved")+ func initialTeachingAmbiguity() async throws {+ let fixture = try await TeachingFixture()+ _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")+ _ = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "Fiction", hostname: "example.com"))+ _ = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "Fiction", hostname: "example.com"))++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com", patternDefinition: definition+ )+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .committed = result else { Issue.record("Expected committed"); return }++ let entries = try await fixture.repository.recentEntries(calendar: .current)+ let entry = entries.flatMap(\.entries).first!+ #expect(entry.chapterTitle == "Ch1")+ #expect(entry.workID == nil)+ #expect(entry.workAssignmentProvenance.kind == .pattern)+ }++ @Test("Initial teaching preserves manual chapter field")+ func initialTeachingManualChapterProtection() async throws {+ let fixture = try await TeachingFixture()+ let e = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")+ try await fixture.seedManualChapter(entryID: e.id, chapter: "Manual Chapter")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com", patternDefinition: definition+ )+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .committed = result else { Issue.record("Expected committed"); return }++ let entry = try await fixture.repository.entry(id: e.id)+ #expect(entry.chapterTitle == "Manual Chapter")+ #expect(entry.chapterTitleProvenance.kind == .manual)+ #expect(entry.workID != nil) // Assignment still happens+ }++ @Test("Initial teaching preserves intentionally unattached assignment")+ func initialTeachingIntentionalUnattachment() async throws {+ let fixture = try await TeachingFixture()+ let e = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")+ try await fixture.repository.moveEntry(e.id, to: .unattached)++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com", patternDefinition: definition+ )+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .committed = result else { Issue.record("Expected committed"); return }++ let entry = try await fixture.repository.entry(id: e.id)+ #expect(entry.chapterTitle == "Ch1")+ #expect(entry.workID == nil)+ #expect(entry.intentionallyUnattached == true)+ }++ @Test("Initial teaching parse failure preserves existing derived fields")+ func initialTeachingParseFailure() async throws {+ let fixture = try await TeachingFixture()+ let e = try await fixture.capture(title: "Fiction | Royal Road", rawURL: "https://example.com/1")+ _ = try await fixture.capture(title: "Ch1 - Fiction | Royal Road", rawURL: "https://example.com/2")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com", patternDefinition: definition+ )+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .committed = result else { Issue.record("Expected committed"); return }++ let entry = try await fixture.repository.entry(id: e.id)+ #expect(entry.chapterTitle == nil)+ #expect(entry.chapterTitleProvenance.kind == .none)+ #expect(entry.workID == nil)+ }++ @Test("Initial teaching Work metadata: sets lastParsedTitle, updates displayTitle only when parsed provenance")+ func initialTeachingWorkMetadata() async throws {+ let fixture = try await TeachingFixture()+ _ = try await fixture.capture(title: "Ch1 - Fiction Name | Site", rawURL: "https://example.com/1")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com", patternDefinition: definition+ )+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .committed = result else { Issue.record("Expected committed"); return }++ let works = try await fixture.repository.works()+ let work = works.works.first!+ #expect(work.lastParsedTitle == "Fiction Name")+ #expect(work.displayTitle == "Fiction Name")+ #expect(work.titleProvenance == .parsed)+ }++ @Test("Initial teaching does not create Work solely for protected Entries")+ func initialTeachingNoWorkForProtectedOnly() async throws {+ let fixture = try await TeachingFixture()+ let e = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")+ try await fixture.repository.moveEntry(e.id, to: .unattached)++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com", patternDefinition: definition+ )+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .committed = result else { Issue.record("Expected committed"); return }++ let counts = try await fixture.repository.debugCounts()+ #expect(counts.works == 0)+ }++ @Test("Initial teaching save failure: zero saves recorded, prior graph unchanged")+ func initialTeachingSaveFailure() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")+ save.resetCount()++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com", patternDefinition: definition+ )++ save.shouldFail = true++ await #expect(throws: LibraryRepositoryError.self) {+ try await fixture.repository.commitTeaching(contract)+ }++ #expect(save.attemptCount == 1) // One attempt+ #expect(save.successCount == 0) // Zero successes on failure+ let counts = try await fixture.repository.debugCounts()+ #expect(counts.titlePatterns == 0)+ #expect(counts.works == 0)+ let status = try await fixture.repository.siteStatus(forRawURL: "https://example.com/x")+ #expect(status == .existingUntaught)+ }++ // MARK: - Teaching stale detection++ @Test("Teaching stale: preview, mutate Entry, commit old contract → refreshed, no mutation, save count unchanged")+ func teachingStaleAfterEntryMutation() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ let e1 = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")+ save.resetCount()++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ // Preview+ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com", patternDefinition: definition+ )++ // Mutate: add another Entry (changes basis)+ _ = try await fixture.capture(title: "Ch2 - Fiction | Site", rawURL: "https://example.com/2")+ save.resetCount()++ // Commit old contract → must return refreshed+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .refreshed(let freshContract) = result else {+ Issue.record("Expected .refreshed, got \(result)")+ return+ }++ #expect(save.attemptCount == 0) // Zero attempts on stale path+ #expect(save.successCount == 0) // Zero successes on stale path+ // No pattern/entry/work mutation from the stale commit attempt+ let counts = try await fixture.repository.debugCounts()+ #expect(counts.titlePatterns == 0)+ let status = try await fixture.repository.siteStatus(forRawURL: "https://example.com/x")+ #expect(status == .existingUntaught)++ // Now commit the refreshed contract → exactly one save+ save.resetCounts()+ let result2 = try await fixture.repository.commitTeaching(freshContract)+ guard case .committed = result2 else {+ Issue.record("Expected committed on refreshed contract")+ return+ }+ #expect(save.attemptCount == 1)+ #expect(save.successCount == 1)+ let countsAfter = try await fixture.repository.debugCounts()+ #expect(countsAfter.titlePatterns == 1)+ _ = e1+ }++ @Test("Teaching stale: preview, mutate Work, commit old contract → refreshed")+ func teachingStaleAfterWorkMutation() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")+ let w = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "Fiction", hostname: "example.com"))+ save.resetCount()++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com", patternDefinition: definition+ )++ // Mutate a Work (changes basis)+ try await fixture.repository.updateWork(id: w.id, draft: WorkMetadataDraft(displayTitle: "Fiction Renamed", type: .novel, genreTags: [], genericNotes: ""))+ save.resetCount()++ let result = try await fixture.repository.commitTeaching(contract)+ guard case .refreshed = result else {+ Issue.record("Expected .refreshed, got \(result)")+ return+ }+ #expect(save.saveCount == 0)+ }++ @Test("Teaching invalidated: boundary entry deleted between preview and commit returns .invalidated with zero writes")+ func teachingInvalidatedBoundaryDeleted() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ let e1 = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")+ let e2 = try await fixture.capture(title: "Ch2 - Fiction | Site", rawURL: "https://example.com/2")++ // Initial teaching to get into taught mode+ let initDef = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let initContract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com", patternDefinition: initDef+ )+ let initResult = try await fixture.repository.commitTeaching(initContract)+ guard case .committed = initResult else { Issue.record("Failed initial teaching"); return }++ // Preview a replacement with e1 as boundary+ let replacementDef = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let contract = try await fixture.repository.projectReplacement(+ hostname: "example.com", patternDefinition: replacementDef,+ boundaryEntryID: e1.id+ )++ // Delete the boundary entry+ try await fixture.repository.deleteEntry(id: e1.id)+ save.resetCounts()++ // Commit old contract → must return .invalidated (boundary is gone, can't reproject)+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .invalidated(let reason) = result else {+ Issue.record("Expected .invalidated, got \(result)")+ return+ }+ #expect(reason.contains("boundary") || !reason.isEmpty)+ #expect(save.attemptCount == 0)+ #expect(save.successCount == 0)+ _ = e2+ }++ // MARK: - Replacement teaching++ @Test("Replacement: preview + commit creates pattern max+1, deactivates old")+ func replacementTeaching() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ _ = try await fixture.capture(title: "Ch1 - Old Work | Site", rawURL: "https://example.com/1")+ _ = try await fixture.capture(title: "Ch2 - Old Work | Site", rawURL: "https://example.com/2")++ let def1 = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let c1 = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: def1)+ let r1 = try await fixture.repository.commitTeaching(c1)+ guard case .committed = r1 else { Issue.record("Expected committed"); return }++ let e3 = try await fixture.capture(title: "Ch3 :: New Work :: Site", rawURL: "https://example.com/3")+ save.resetCount()++ let def2 = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )++ let contract = try await fixture.repository.projectReplacement(+ hostname: "example.com", patternDefinition: def2,+ boundaryEntryID: e3.id+ )+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .committed(_, let ver) = result else { Issue.record("Expected committed"); return }++ #expect(ver == 2)+ #expect(save.saveCount == 1)+ let counts = try await fixture.repository.debugCounts()+ #expect(counts.titlePatterns == 2)+ }++ @Test("Replacement with historical correction targets fields citing that pattern")+ func replacementHistoricalCorrection() async throws {+ let fixture = try await TeachingFixture()+ let e1 = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")+ let e2 = try await fixture.capture(title: "Ch2 - Fiction | Site", rawURL: "https://example.com/2")++ let def1 = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let c1 = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: def1)+ let r1 = try await fixture.repository.commitTeaching(c1)+ guard case .committed(let initialPatternID, _) = r1 else { Issue.record("Expected committed"); return }++ let e3 = try await fixture.capture(title: "Ch3 - Fiction | Site", rawURL: "https://example.com/3")++ let contract = try await fixture.repository.projectReplacement(+ hostname: "example.com", patternDefinition: def1,+ boundaryEntryID: e3.id,+ historicalPatternID: initialPatternID,+ historicalPatternVersion: 1+ )+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .committed = result else { Issue.record("Expected committed"); return }++ let entry1 = try await fixture.repository.entry(id: e1.id)+ #expect(entry1.chapterTitleProvenance.patternVersion == 2)+ let entry2 = try await fixture.repository.entry(id: e2.id)+ #expect(entry2.chapterTitleProvenance.patternVersion == 2)+ _ = e3+ }++ @Test("Replacement stale: preview, mutate Entry, commit old → refreshed, no save, no mutation")+ func replacementStale() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let def1 = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let c1 = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: def1)+ _ = try await fixture.repository.commitTeaching(c1)++ let e2 = try await fixture.capture(title: "Ch2 - Fiction | Site", rawURL: "https://example.com/2")++ let contract = try await fixture.repository.projectReplacement(+ hostname: "example.com", patternDefinition: def1, boundaryEntryID: e2.id+ )++ // Mutate after preview+ _ = try await fixture.capture(title: "Ch3 - Fiction | Site", rawURL: "https://example.com/3")+ save.resetCount()++ let result = try await fixture.repository.commitTeaching(contract)+ guard case .refreshed(let fresh) = result else {+ Issue.record("Expected refreshed, got \(result)")+ return+ }+ #expect(save.saveCount == 0)+ #expect(try await fixture.repository.debugCounts().titlePatterns == 1) // Still only initial++ // Commit fresh → succeeds+ save.resetCount()+ let r2 = try await fixture.repository.commitTeaching(fresh)+ guard case .committed = r2 else { Issue.record("Expected committed"); return }+ #expect(save.saveCount == 1)+ }+}+++// MARK: - Single-entry Re-parse++@Suite("Repository single-entry Re-parse (contract-based)", .serialized)+struct RepositoryReparseTests {++ @Test("Re-parse: preview + commit applies active pattern to one Entry")+ func reparseSuccess() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ let e1 = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")+ _ = try await fixture.capture(title: "Ch2 - Fiction | Site", rawURL: "https://example.com/2")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let tc = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: definition)+ _ = try await fixture.repository.commitTeaching(tc)++ // Clear e1 to make it re-parseable+ try await fixture.clearPatternFields(entryID: e1.id)+ save.resetCount()++ let contract = try await fixture.repository.projectReparse(entryID: e1.id)+ let result = try await fixture.repository.commitReparse(contract)+ guard case .committed = result else { Issue.record("Expected committed"); return }+ #expect(save.saveCount == 1)++ let entry = try await fixture.repository.entry(id: e1.id)+ #expect(entry.chapterTitle == "Ch1")+ #expect(entry.chapterTitleProvenance.kind == .pattern)+ #expect(entry.workID != nil)+ }++ @Test("Re-parse stale: preview, mutate Work, commit old → refreshed, no save, no mutation")+ func reparseStale() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ let e1 = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let tc = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: definition)+ _ = try await fixture.repository.commitTeaching(tc)++ // Clear e1 fields+ try await fixture.clearPatternFields(entryID: e1.id)++ // Preview Re-parse+ let contract = try await fixture.repository.projectReparse(entryID: e1.id)++ // Mutate: create a new Work named "Fiction" (changes basis)+ _ = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "Fiction", hostname: "example.com"))+ save.resetCount()++ // Commit old contract → refreshed+ let result = try await fixture.repository.commitReparse(contract)+ guard case .refreshed(let fresh) = result else {+ Issue.record("Expected refreshed, got \(result)")+ return+ }+ #expect(save.saveCount == 0) // Zero saves on stale path++ // Entry still cleared (no mutation from stale attempt)+ let entry = try await fixture.repository.entry(id: e1.id)+ #expect(entry.chapterTitle == nil)++ // Commit fresh → succeeds+ save.resetCount()+ let r2 = try await fixture.repository.commitReparse(fresh)+ guard case .committed = r2 else { Issue.record("Expected committed"); return }+ #expect(save.saveCount == 1)+ }++ @Test("Re-parse with save failure: zero saves, entry unchanged")+ func reparseSaveFailure() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ let e = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let tc = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: definition)+ _ = try await fixture.repository.commitTeaching(tc)++ try await fixture.clearPatternFields(entryID: e.id)+ let contract = try await fixture.repository.projectReparse(entryID: e.id)++ save.shouldFail = true+ save.resetCount()++ await #expect(throws: LibraryRepositoryError.self) {+ try await fixture.repository.commitReparse(contract)+ }+ #expect(save.saveCount == 0)++ let entry = try await fixture.repository.entry(id: e.id)+ #expect(entry.chapterTitle == nil)+ }++ @Test("Re-parse leaves manual fields unchanged")+ func reparseManualProtection() async throws {+ let fixture = try await TeachingFixture()+ let e = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let tc = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: definition)+ _ = try await fixture.repository.commitTeaching(tc)++ try await fixture.seedManualChapter(entryID: e.id, chapter: "My Manual Chapter")++ let contract = try await fixture.repository.projectReparse(entryID: e.id)+ let result = try await fixture.repository.commitReparse(contract)+ guard case .committed = result else { Issue.record("Expected committed"); return }++ let entry = try await fixture.repository.entry(id: e.id)+ #expect(entry.chapterTitle == "My Manual Chapter")+ #expect(entry.chapterTitleProvenance.kind == .manual)+ }+}++// MARK: - Actionable Recent presentation++@Suite("Repository actionable Recent and provenance replay", .serialized)+struct RepositoryActionableRecentTests {++ @Test("Untaught Recent row is actionable and carries its immutable entry")+ func untaughtPresentation() async throws {+ let fixture = try await TeachingFixture()+ let entry = try await fixture.capture(title: "Some Title", rawURL: "https://example.com/1")++ let presentation = try await fixture.repository.recentPresentation(calendar: .current)+ guard let row = presentation.allRows.first else {+ Issue.record("Expected a Recent row")+ return+ }+ #expect(presentation.actionableCount == 1)+ #expect(row.entry == entry)+ #expect(row.siteMode == .untaught)+ #expect(row.actionType == .teach)+ #expect(row.isActionable)+ }++ @Test("Recent preserves ordering and exposes assigned Work, chapter, and actionable count")+ func orderingWorkAndActionability() async throws {+ let fixture = try await TeachingFixture()+ let settled = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")+ let failed = try await fixture.capture(title: "Unparseable", rawURL: "https://example.com/2")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com",+ patternDefinition: definition+ )+ _ = try await fixture.repository.commitTeaching(contract)++ let presentation = try await fixture.repository.recentPresentation(calendar: .current)+ #expect(presentation.allRows.map(\.id) == [failed.id, settled.id])+ #expect(presentation.actionableCount == 1)+ guard let settledRow = presentation.allRows.first(where: { $0.id == settled.id }),+ let failedRow = presentation.allRows.first(where: { $0.id == failed.id }) else {+ Issue.record("Expected both Recent rows")+ return+ }+ #expect(settledRow.siteMode == .taught)+ #expect(settledRow.chapterTitle == "Ch1")+ #expect(settledRow.workDisplayTitle == "Fiction")+ #expect(!settledRow.isActionable)+ #expect(settledRow.actionType == .none)+ #expect(failedRow.isActionable)+ #expect(failedRow.actionType == .reteach)+ }++ @Test("Recent unresolved replay uses exact retained historical pattern, not active pattern")+ func historicalProvenanceReplay() async throws {+ let fixture = try await TeachingFixture()+ let entry = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let historicalDefinition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com",+ patternDefinition: historicalDefinition+ )+ let result = try await fixture.repository.commitTeaching(contract)+ guard case .committed(let patternID, let patternVersion) = result else {+ Issue.record("Expected teaching commit")+ return+ }+ try await fixture.seedUnresolvedAssignment(+ entryID: entry.id,+ patternID: patternID,+ patternVersion: patternVersion+ )+ try await fixture.replaceActivePatternOnly(+ hostname: "example.com",+ definition: .segment(+ work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ )++ let presentation = try await fixture.repository.recentPresentation(calendar: .current)+ guard let row = presentation.allRows.first else {+ Issue.record("Expected a Recent row")+ return+ }+ #expect(row.unresolvedCandidateTitle == "Fiction")+ #expect(row.isActionable)+ #expect(row.actionType == .reteach)+ }++ @Test("Recent fails closed when retained assignment pattern cannot replay")+ func unparseableHistoricalReplayFails() async throws {+ let fixture = try await TeachingFixture()+ _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com",+ patternDefinition: definition+ )+ let outcome = try await fixture.repository.commitTeaching(contract)+ guard case .committed(let patternID, let patternVersion) = outcome else {+ Issue.record("Expected teaching commit")+ return+ }+ let unparseable = try await fixture.capture(+ title: "NoDelimiterHere",+ rawURL: "https://example.com/2"+ )+ try await fixture.seedUnresolvedAssignment(+ entryID: unparseable.id,+ patternID: patternID,+ patternVersion: patternVersion+ )++ await #expect(throws: LibraryRepositoryError.self) {+ try await fixture.repository.recentPresentation(calendar: .current)+ }+ }+}+++// MARK: - Taught capture projection++@Suite("Repository taught capture with contract", .serialized)+struct RepositoryTaughtCaptureTests {++ @Test("Capture: preview + commit on taught Site applies pattern and assigns Work")+ func taughtCaptureSuccess() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let tc = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: definition)+ _ = try await fixture.repository.commitTeaching(tc)+ save.resetCount()++ let contract = try await fixture.repository.projectCapture(+ hostname: "example.com", captureTitle: "Ch2 - Fiction | Site",+ captureTitleSource: .safariDocument, rawURLString: "https://example.com/2",+ canonicalURLString: nil, note: "", rating: nil+ )+ #expect(contract.outcome.projectedChapter == "Ch2")++ let result = try await fixture.repository.commitCapture(contract)+ guard case .committed(let captured) = result else { Issue.record("Expected committed"); return }+ #expect(captured.chapterTitle == "Ch2")+ #expect(captured.chapterTitleProvenance.kind == .pattern)+ #expect(captured.workID != nil)+ #expect(save.saveCount == 1)+ }++ @Test("Capture stale: preview, mutate Work, commit old → refreshed, no Entry saved")+ func captureStale() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let tc = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: definition)+ _ = try await fixture.repository.commitTeaching(tc)++ // Preview capture+ let contract = try await fixture.repository.projectCapture(+ hostname: "example.com", captureTitle: "Ch2 - Fiction | Site",+ captureTitleSource: .safariDocument, rawURLString: "https://example.com/2",+ canonicalURLString: nil, note: "", rating: nil+ )++ // Mutate: create another Work named "Fiction" → changes outcome (now ambiguous)+ _ = try await fixture.repository.createWork(NewWorkDraft(displayTitle: "Fiction", hostname: "example.com"))+ save.resetCount()++ // Commit old contract → refreshed+ let result = try await fixture.repository.commitCapture(contract)+ guard case .refreshed(let fresh) = result else {+ Issue.record("Expected refreshed, got \(result)")+ return+ }+ #expect(save.saveCount == 0) // Zero saves++ // No Entry created from stale attempt+ let counts = try await fixture.repository.debugCounts()+ #expect(counts.entries == 1) // Only original++ // Commit fresh → succeeds with second Save+ save.resetCount()+ let r2 = try await fixture.repository.commitCapture(fresh)+ guard case .committed(let captured) = r2 else { Issue.record("Expected committed"); return }+ #expect(save.saveCount == 1)+ // Now ambiguous: chapter present, work nil+ #expect(captured.chapterTitle == "Ch2")+ #expect(captured.workID == nil)+ #expect(captured.workAssignmentProvenance.kind == .pattern)+ }++ @Test("Capture stale: preview, change title request → refreshed, require second Save")+ func captureStaleOnTitleChange() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let tc = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: definition)+ _ = try await fixture.repository.commitTeaching(tc)++ // Preview with one title+ let contract = try await fixture.repository.projectCapture(+ hostname: "example.com", captureTitle: "Ch2 - Fiction | Site",+ captureTitleSource: .safariDocument, rawURLString: "https://example.com/2",+ canonicalURLString: nil, note: "", rating: nil+ )++ // User edits title → we'd need to re-project with new request+ // The contract's request contains the title. If the user wants a different title,+ // they must re-project. Trying to commit with old contract and same basis succeeds+ // because basis hasn't changed. The title is in the request, not the basis.+ // This test validates that projectCapture with different title gives different outcome.+ let contract2 = try await fixture.repository.projectCapture(+ hostname: "example.com", captureTitle: "Different Title",+ captureTitleSource: .safariDocument, rawURLString: "https://example.com/2",+ canonicalURLString: nil, note: "", rating: nil+ )+ // Different title → parse fails → different outcome+ #expect(contract2.outcome.projectedChapter == nil)+ #expect(contract2.outcome.actionable == true)+ #expect(contract2.outcome != contract.outcome)+ }++ @Test("Capture on taught Site with parse failure saves actionable Entry")+ func taughtCaptureParseFailure() async throws {+ let fixture = try await TeachingFixture()+ _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let tc = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: definition)+ _ = try await fixture.repository.commitTeaching(tc)++ let contract = try await fixture.repository.projectCapture(+ hostname: "example.com", captureTitle: "No Delimiters Here",+ captureTitleSource: .safariDocument, rawURLString: "https://example.com/2",+ canonicalURLString: nil, note: "", rating: nil+ )+ #expect(contract.outcome.actionable == true)+ #expect(contract.outcome.projectedChapter == nil)++ let result = try await fixture.repository.commitCapture(contract)+ guard case .committed(let captured) = result else { Issue.record("Expected committed"); return }+ #expect(captured.chapterTitle == nil)+ #expect(captured.workID == nil)+ #expect(captured.intentionallyUnattached == false)+ }++ @Test("Capture save failure: zero saves, nothing persisted")+ func taughtCaptureSaveFailure() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save)+ _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let tc = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: definition)+ _ = try await fixture.repository.commitTeaching(tc)++ let contract = try await fixture.repository.projectCapture(+ hostname: "example.com", captureTitle: "Ch2 - Fiction | Site",+ captureTitleSource: .safariDocument, rawURLString: "https://example.com/2",+ canonicalURLString: nil, note: "", rating: nil+ )++ save.shouldFail = true+ save.resetCount()++ await #expect(throws: LibraryRepositoryError.self) {+ try await fixture.repository.commitCapture(contract)+ }+ #expect(save.saveCount == 0)+ #expect(try await fixture.repository.debugCounts().entries == 1)+ }+}++// MARK: - Test Infrastructure++// MARK: - Entry Teaching Detail (repository-owned coherent derivation) tests++@Suite("Repository entryTeachingDetail", .serialized)+struct RepositoryEntryTeachingDetailTests {++ @Test("Untaught entry returns teach action and unsettled fields")+ func untaughtActions() async throws {+ let fixture = try await TeachingFixture()+ let entry = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let detail = try await fixture.repository.entryTeachingDetail(id: entry.id)+ #expect(detail.siteMode == .untaught)+ #expect(detail.availableActions == [.teach])+ #expect(detail.activePatternSummary == nil)+ #expect(detail.historicalPatternSummaries.isEmpty)+ if case .unsettled = detail.chapterSettlement {} else {+ Issue.record("Expected unsettled chapter")+ }+ if case .unsettled = detail.assignmentSettlement {} else {+ Issue.record("Expected unsettled assignment")+ }+ #expect(detail.unresolvedCandidateTitle == nil)+ }++ @Test("Taught entry returns reTeach and reParse actions")+ func taughtActions() async throws {+ let fixture = try await TeachingFixture()+ _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let tc = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: definition)+ _ = try await fixture.repository.commitTeaching(tc)++ let entries = try await fixture.repository.recentEntries(calendar: .current)+ let entry = entries.flatMap(\.entries).first!++ let detail = try await fixture.repository.entryTeachingDetail(id: entry.id)+ #expect(detail.siteMode == .taught)+ #expect(detail.availableActions == [.reTeach, .reParse])+ }++ @Test("Active and historical pattern summaries")+ func activeAndHistoricalSummaries() async throws {+ let fixture = try await TeachingFixture()+ _ = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let tc = try await fixture.repository.projectInitialTeaching(hostname: "example.com", patternDefinition: definition)+ let result = try await fixture.repository.commitTeaching(tc)+ guard case .committed(let patternID, _) = result else {+ Issue.record("Expected committed"); return+ }++ // Now re-teach to get a historical pattern+ let entries = try await fixture.repository.recentEntries(calendar: .current)+ let entry = entries.flatMap(\.entries).first!+ let definition2 = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let tc2 = try await fixture.repository.projectReplacement(+ hostname: "example.com",+ patternDefinition: definition2,+ boundaryEntryID: entry.id,+ historicalPatternID: patternID,+ historicalPatternVersion: 1+ )+ let result2 = try await fixture.repository.commitTeaching(tc2)+ guard case .committed(_, let version2) = result2 else {+ Issue.record("Expected committed"); return+ }+ #expect(version2 == 2)++ let detail = try await fixture.repository.entryTeachingDetail(id: entry.id)+ #expect(detail.activePatternSummary != nil)+ #expect(detail.activePatternSummary?.isActive == true)+ #expect(detail.activePatternSummary?.version == 2)+ #expect(detail.historicalPatternSummaries.count == 1)+ #expect(detail.historicalPatternSummaries[0].isActive == false)+ #expect(detail.historicalPatternSummaries[0].version == 1)+ }++ @Test("Manual protection reason is independent and exact")+ func independentManualProtectionReason() async throws {+ let fixture = try await TeachingFixture()+ let entry = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")+ try await fixture.seedManualChapter(entryID: entry.id, chapter: "Manual Chapter")++ let detail = try await fixture.repository.entryTeachingDetail(id: entry.id)+ if case .manualProtected(let reason) = detail.chapterSettlement {+ #expect(reason == "Manually set by user")+ } else {+ Issue.record("Expected manualProtected chapter, got \(detail.chapterSettlement)")+ }+ }++ @Test("Unresolved replay uses exact retained historical pattern")+ func unresolvedReplayExactTitle() async throws {+ let fixture = try await TeachingFixture()+ _ = try await fixture.repository.createWork(+ NewWorkDraft(displayTitle: "Fiction", hostname: "example.com")+ )+ _ = try await fixture.repository.createWork(+ NewWorkDraft(displayTitle: "Fiction", hostname: "example.com")+ )+ let entry = try await fixture.capture(+ title: "Ch2 - Fiction | Site",+ rawURL: "https://example.com/2"+ )++ let historicalDefinition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com",+ patternDefinition: historicalDefinition+ )+ let outcome = try await fixture.repository.commitTeaching(contract)+ guard case .committed(let historicalPatternID, let historicalVersion) = outcome else {+ Issue.record("Expected initial teaching commit")+ return+ }++ let replacementDefinition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ try await fixture.replaceActivePatternOnly(+ hostname: "example.com",+ definition: replacementDefinition+ )++ let detail = try await fixture.repository.entryTeachingDetail(id: entry.id)+ #expect(detail.entry.workID == nil)+ #expect(detail.entry.workAssignmentProvenance.patternID == historicalPatternID)+ #expect(detail.entry.workAssignmentProvenance.patternVersion == historicalVersion)+ #expect(detail.unresolvedCandidateTitle == "Fiction")+ }++ @Test("Replay failure on retained producing pattern throws corruptLibrary")+ func replayFailureIntegrity() async throws {+ let fixture = try await TeachingFixture()+ let seed = try await fixture.capture(+ title: "Ch1 - Fiction | Site",+ rawURL: "https://example.com/1"+ )+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let contract = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com",+ patternDefinition: definition+ )+ let outcome = try await fixture.repository.commitTeaching(contract)+ guard case .committed(let patternID, let patternVersion) = outcome else {+ Issue.record("Expected initial teaching commit")+ return+ }++ let unparseable = try await fixture.capture(+ title: "NoDelimiterHere",+ rawURL: "https://example.com/2"+ )+ try await fixture.seedUnresolvedAssignment(+ entryID: unparseable.id,+ patternID: patternID,+ patternVersion: patternVersion+ )++ await #expect(throws: LibraryRepositoryError.self) {+ try await fixture.repository.entryTeachingDetail(id: unparseable.id)+ }+ #expect(try await fixture.repository.entry(id: seed.id).captureTitle == "Ch1 - Fiction | Site")+ }++ @Test("Entry includes snapshot in detail (avoids double read)")+ func detailIncludesSnapshot() async throws {+ let fixture = try await TeachingFixture()+ let entry = try await fixture.capture(title: "Ch1 - Fiction | Site", rawURL: "https://example.com/1")++ let detail = try await fixture.repository.entryTeachingDetail(id: entry.id)+ #expect(detail.entry.id == entry.id)+ #expect(detail.entry.captureTitle == "Ch1 - Fiction | Site")+ }+}++// MARK: - M2.2 articles mode++@Suite("Repository articles-mode transactions", .serialized)+struct RepositoryArticlesModeTests {+ @Test("Articles transition is atomic, deactivates patterns, preserves manual fields, and cleans presentation")+ func transitionAndPresentation() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save, capabilities: .m2_2)+ let manualChapterEntry = try await fixture.capture(+ title: "Chapter 1 - Fiction | Example Site",+ rawURL: "https://example.com/1"+ )+ let manualAssignmentEntry = try await fixture.capture(+ title: "Chapter 2 - Fiction | Example Site",+ rawURL: "https://example.com/2"+ )+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ _ = try await fixture.repository.commitTeaching(+ try await fixture.repository.projectInitialTeaching(+ hostname: "example.com",+ patternDefinition: definition+ )+ )+ try await fixture.seedManualChapter(entryID: manualChapterEntry.id, chapter: "Curated chapter")+ let manualWork = try await fixture.repository.createWork(+ NewWorkDraft(displayTitle: "Curated Work", hostname: "example.com")+ )+ try await fixture.repository.moveEntry(manualAssignmentEntry.id, to: .existing(manualWork.id))+ save.resetCounts()++ let derivedRule = try ArticleTitleCleaner.deriveRule(+ from: manualChapterEntry.captureTitle,+ suffixSegmentCount: 1+ )+ let rule = try #require(derivedRule)+ let contract = try await fixture.repository.projectArticles(+ hostname: "example.com",+ junkSuffixRule: rule+ )+ #expect(contract.outcome.plan.entryProjections.allSatisfy { !$0.actionableAfter })++ let result = try await fixture.repository.commitArticles(contract)+ guard case .committed = result else {+ Issue.record("Expected committed articles transition, got \(result)")+ return+ }+ #expect(save.attemptCount == 1)+ let committedMode = try await fixture.siteMode(hostname: "example.com")+ let activePatternCount = try await fixture.activePatternCount(hostname: "example.com")+ #expect(committedMode == .articles)+ #expect(activePatternCount == 0)++ let first = try await fixture.repository.entry(id: manualChapterEntry.id)+ #expect(first.captureTitle == manualChapterEntry.captureTitle)+ #expect(first.chapterTitle == "Curated chapter")+ #expect(first.chapterTitleProvenance.kind == .manual)+ #expect(first.workID == nil)+ #expect(first.workAssignmentProvenance.kind == .none)+ #expect(first.intentionallyUnattached)++ let second = try await fixture.repository.entry(id: manualAssignmentEntry.id)+ #expect(second.chapterTitle == nil)+ #expect(second.chapterTitleProvenance.kind == .none)+ #expect(second.workID == manualWork.id)+ #expect(second.workAssignmentProvenance.kind == .manual)+ #expect(!second.intentionallyUnattached)++ let recent = try await fixture.repository.recentPresentation(calendar: .current)+ #expect(recent.actionableCount == 0)+ #expect(recent.allRows.allSatisfy { $0.siteMode == .articles && !$0.isActionable })+ #expect(recent.allRows.contains { $0.id == first.id && $0.captureTitle == "Chapter 1 - Fiction" })+ }++ @Test("Stale articles confirmation writes nothing and returns a complete refreshed projection")+ func staleTransition() async throws {+ let save = InstrumentedSaveStrategy()+ let fixture = try await TeachingFixture(saveStrategy: save, capabilities: .m2_2)+ let entry = try await fixture.capture(title: "Article - Site", rawURL: "https://example.com/1")+ let derivedRule = try ArticleTitleCleaner.deriveRule(from: entry.captureTitle, suffixSegmentCount: 1)+ let rule = try #require(derivedRule)+ let contract = try await fixture.repository.projectArticles(hostname: "example.com", junkSuffixRule: rule)+ _ = try await fixture.capture(title: "Another - Site", rawURL: "https://example.com/2")+ save.resetCounts()++ let result = try await fixture.repository.commitArticles(contract)+ guard case .refreshed(let refreshed) = result else {+ Issue.record("Expected refreshed articles contract, got \(result)")+ return+ }+ #expect(save.attemptCount == 0)+ #expect(refreshed.outcome.plan.entryProjections.count == 2)+ let currentMode = try await fixture.siteMode(hostname: "example.com")+ #expect(currentMode == .untaught)+ }++ @Test("Future article capture is intentionally unattached, non-actionable, and keeps immutable evidence")+ func futureCapture() async throws {+ let fixture = try await TeachingFixture(capabilities: .m2_2)+ let seed = try await fixture.capture(title: "Article - Site", rawURL: "https://example.com/1")+ let derivedRule = try ArticleTitleCleaner.deriveRule(from: seed.captureTitle, suffixSegmentCount: 1)+ let rule = try #require(derivedRule)+ _ = try await fixture.repository.commitArticles(+ try await fixture.repository.projectArticles(hostname: "example.com", junkSuffixRule: rule)+ )++ let contract = try await fixture.repository.projectCapture(+ hostname: "example.com",+ captureTitle: "Future Article - Site",+ captureTitleSource: .safariDocument,+ rawURLString: "https://example.com/2",+ canonicalURLString: nil,+ note: "",+ rating: nil+ )+ #expect(contract.outcome.displayTitle == "Future Article")+ #expect(contract.outcome.intentionallyUnattached)+ #expect(!contract.outcome.actionable)++ let result = try await fixture.repository.commitCapture(contract)+ guard case .committed(let captured) = result else {+ Issue.record("Expected committed article capture")+ return+ }+ #expect(captured.captureTitle == "Future Article - Site")+ #expect(captured.chapterTitle == nil)+ #expect(captured.workID == nil)+ #expect(captured.intentionallyUnattached)+ }+}++@Suite("Repository M2.3 phrase integration", .serialized)+struct RepositoryPhraseIntegrationTests {+ private var phraseDefinition: PatternDefinition {+ .phrase(+ prefix: "Read ",+ separator: " from ",+ suffix: ".",+ order: .chapterThenWork+ )+ }++ private var segmentDefinition: PatternDefinition {+ .segment(+ work: try! SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try! SegmentPositionSpec(origin: .end, offset: 0)]+ )+ }++ @Test("Phrase teaching drives preview, commit, future capture, and Re-parse")+ func phraseEndToEnd() async throws {+ let fixture = try await TeachingFixture(capabilities: .m2_3)+ let original = try await fixture.capture(+ title: "Read Chapter 1 from Work Name.",+ rawURL: "https://example.com/1"+ )++ let teaching = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com",+ patternDefinition: phraseDefinition+ )+ #expect(teaching.outcome.plan.entryProjections.first?.projectedChapter == "Chapter 1")+ guard case .committed = try await fixture.repository.commitTeaching(teaching) else {+ Issue.record("Expected phrase teaching commit")+ return+ }++ let capture = try await fixture.repository.projectCapture(+ hostname: "example.com",+ captureTitle: "Read Chapter 2 from Work Name.",+ captureTitleSource: .safariDocument,+ rawURLString: "https://example.com/2",+ canonicalURLString: nil,+ note: "",+ rating: nil+ )+ #expect(capture.outcome.projectedChapter == "Chapter 2")+ #expect(capture.outcome.projectedWorkTitle == "Work Name")+ guard case .committed(let captured) = try await fixture.repository.commitCapture(capture) else {+ Issue.record("Expected projected phrase capture commit")+ return+ }+ #expect(captured.chapterTitle == "Chapter 2")+ #expect(captured.workID != nil)++ try await fixture.clearPatternFields(entryID: original.id)+ let reparse = try await fixture.repository.projectReparse(entryID: original.id)+ #expect(reparse.outcome.plan.entryProjections.first?.projectedChapter == "Chapter 1")+ guard case .committed = try await fixture.repository.commitReparse(reparse) else {+ Issue.record("Expected phrase Re-parse commit")+ return+ }+ #expect(try await fixture.repository.entry(id: original.id).chapterTitle == "Chapter 1")+ }++ @Test("Replacement supports segment to phrase and phrase back to segment")+ func crossFormReplacementBothDirections() async throws {+ let fixture = try await TeachingFixture(capabilities: .m2_3)+ _ = try await fixture.capture(+ title: "Chapter 1 - Work Name | Site",+ rawURL: "https://example.com/1"+ )+ let initial = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com",+ patternDefinition: segmentDefinition+ )+ guard case .committed = try await fixture.repository.commitTeaching(initial) else {+ Issue.record("Expected initial segment teaching commit")+ return+ }++ let phraseEntry = try await fixture.capture(+ title: "Read Chapter 2 from Work Name.",+ rawURL: "https://example.com/2"+ )+ let toPhrase = try await fixture.repository.projectReplacement(+ hostname: "example.com",+ patternDefinition: phraseDefinition,+ boundaryEntryID: phraseEntry.id+ )+ guard case .committed(_, let phraseVersion) = try await fixture.repository.commitTeaching(toPhrase) else {+ Issue.record("Expected segment-to-phrase replacement")+ return+ }+ #expect(phraseVersion == 2)+ #expect(try await fixture.repository.entry(id: phraseEntry.id).chapterTitle == "Chapter 2")++ let segmentEntry = try await fixture.capture(+ title: "Chapter 3 - Work Name | Site",+ rawURL: "https://example.com/3"+ )+ let toSegment = try await fixture.repository.projectReplacement(+ hostname: "example.com",+ patternDefinition: segmentDefinition,+ boundaryEntryID: segmentEntry.id+ )+ guard case .committed(_, let segmentVersion) = try await fixture.repository.commitTeaching(toSegment) else {+ Issue.record("Expected phrase-to-segment replacement")+ return+ }+ #expect(segmentVersion == 3)+ #expect(try await fixture.repository.entry(id: segmentEntry.id).chapterTitle == "Chapter 3")+ #expect(try await fixture.repository.debugCounts().titlePatterns == 3)+ }++ @Test("Unresolved presentation replays the exact historical phrase pattern")+ func historicalPhraseReplay() async throws {+ let fixture = try await TeachingFixture(capabilities: .m2_3)+ let entry = try await fixture.capture(+ title: "Read Chapter 1 from Work Name.",+ rawURL: "https://example.com/1"+ )+ let teaching = try await fixture.repository.projectInitialTeaching(+ hostname: "example.com",+ patternDefinition: phraseDefinition+ )+ guard case .committed(let patternID, let patternVersion) = try await fixture.repository.commitTeaching(teaching) else {+ Issue.record("Expected phrase teaching commit")+ return+ }+ try await fixture.seedUnresolvedAssignment(+ entryID: entry.id,+ patternID: patternID,+ patternVersion: patternVersion+ )+ try await fixture.replaceActivePatternOnly(+ hostname: "example.com",+ definition: segmentDefinition+ )++ let presentation = try await fixture.repository.recentPresentation(calendar: .current)+ #expect(presentation.allRows.first?.unresolvedCandidateTitle == "Work Name")+ }+}++// MARK: - TeachingFixture infrastructure++private struct TeachingFixture {+ let directory: URL+ let configuration: LibraryConfiguration+ let repository: LibraryRepository+ private let clock: TeachingMutableClock+ private let saveStrategy: any RepositorySaveStrategy++ init(+ clock: TeachingMutableClock = TeachingMutableClock(Date(timeIntervalSince1970: 1_721_000_000)),+ saveStrategy: any RepositorySaveStrategy = InstrumentedSaveStrategy(),+ capabilities: M2Capabilities = .m2_1+ ) async throws {+ self.clock = clock+ self.saveStrategy = saveStrategy+ directory = FileManager.default.temporaryDirectory+ .appending(path: "AsterismTeachingTests-\(UUID())", directoryHint: .isDirectory)+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+ configuration = LibraryConfiguration(rootDirectory: directory, environment: .development)+ repository = try await LibraryRepository.open(+ configuration,+ capabilities: capabilities,+ clock: clock,+ saveStrategy: saveStrategy+ )+ }++ func capture(title: String, rawURL: String) async throws -> EntrySnapshot {+ clock.advance(by: 1)+ return try await repository.capture(+ CaptureDraft(captureTitle: title, captureTitleSource: .safariDocument, rawURLString: rawURL)+ )+ }++ func seedLastParsedTitle(workID: UUID, title: String) async throws {+ let lease = try await CrossProcessLibraryLock.acquire(mode: .exclusive, at: configuration.lockURL, timeout: .seconds(1))+ defer { withExtendedLifetime(lease) {} }+ let container = try makeContainer()+ let context = ModelContext(container)+ var desc = FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID })+ desc.fetchLimit = 1+ guard let work = try context.fetch(desc).first else { return }+ work.lastParsedTitle = title+ try context.save()+ }++ func seedManualChapter(entryID: UUID, chapter: String) async throws {+ let lease = try await CrossProcessLibraryLock.acquire(mode: .exclusive, at: configuration.lockURL, timeout: .seconds(1))+ defer { withExtendedLifetime(lease) {} }+ let container = try makeContainer()+ let context = ModelContext(container)+ var desc = FetchDescriptor<Entry>(predicate: #Predicate { $0.id == entryID })+ desc.fetchLimit = 1+ guard let entry = try context.fetch(desc).first else { return }+ entry.chapterTitle = chapter+ entry.chapterTitleProvenanceRaw = FieldProvenanceKind.manual.rawValue+ entry.chapterPatternID = nil+ entry.chapterPatternVersion = nil+ try context.save()+ }++ func clearPatternFields(entryID: UUID) async throws {+ let lease = try await CrossProcessLibraryLock.acquire(mode: .exclusive, at: configuration.lockURL, timeout: .seconds(1))+ defer { withExtendedLifetime(lease) {} }+ let container = try makeContainer()+ let context = ModelContext(container)+ var desc = FetchDescriptor<Entry>(predicate: #Predicate { $0.id == entryID })+ desc.fetchLimit = 1+ guard let entry = try context.fetch(desc).first else { return }+ entry.chapterTitle = nil+ entry.chapterTitleProvenanceRaw = FieldProvenanceKind.none.rawValue+ entry.chapterPatternID = nil+ entry.chapterPatternVersion = nil+ entry.work = nil+ entry.workAssignmentProvenanceRaw = FieldProvenanceKind.none.rawValue+ entry.workPatternID = nil+ entry.workPatternVersion = nil+ entry.intentionallyUnattached = false+ try context.save()+ }++ func seedUnresolvedAssignment(+ entryID: UUID,+ patternID: UUID,+ patternVersion: Int+ ) async throws {+ let lease = try await CrossProcessLibraryLock.acquire(+ mode: .exclusive,+ at: configuration.lockURL,+ timeout: .seconds(1)+ )+ defer { withExtendedLifetime(lease) {} }+ let container = try makeContainer()+ let context = ModelContext(container)+ var descriptor = FetchDescriptor<Entry>(predicate: #Predicate { $0.id == entryID })+ descriptor.fetchLimit = 1+ guard let entry = try context.fetch(descriptor).first else {+ throw LibraryRepositoryError.recordNotFound(type: "Entry", id: entryID)+ }+ entry.work = nil+ entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue+ entry.workPatternID = patternID+ entry.workPatternVersion = patternVersion+ entry.intentionallyUnattached = false+ try context.save()+ }++ func replaceActivePatternOnly(+ hostname: String,+ definition: PatternDefinition+ ) async throws {+ let lease = try await CrossProcessLibraryLock.acquire(+ mode: .exclusive,+ at: configuration.lockURL,+ timeout: .seconds(1)+ )+ defer { withExtendedLifetime(lease) {} }+ let container = try makeContainer()+ let context = ModelContext(container)+ var descriptor = FetchDescriptor<Site>(predicate: #Predicate { $0.hostname == hostname })+ descriptor.fetchLimit = 1+ guard let site = try context.fetch(descriptor).first else {+ throw LibraryRepositoryError.invalidInput(+ operation: "seeding replacement pattern",+ reason: "Site not found"+ )+ }+ let nextVersion = (site.patternValues.map(\.version).max() ?? 0) + 1+ for pattern in site.patternValues where pattern.isActive {+ pattern.isActive = false+ }+ let pattern = try TitlePattern(+ version: nextVersion,+ isActive: true,+ createdAt: clock.now(),+ definition: definition,+ site: site+ )+ context.insert(pattern)+ try context.save()+ }++ func siteMode(hostname: String) async throws -> SiteMode {+ let lease = try await CrossProcessLibraryLock.acquire(+ mode: .shared,+ at: configuration.lockURL,+ timeout: .seconds(1)+ )+ defer { withExtendedLifetime(lease) {} }+ let container = try makeContainer()+ let context = ModelContext(container)+ var descriptor = FetchDescriptor<Site>(predicate: #Predicate { $0.hostname == hostname })+ descriptor.fetchLimit = 1+ guard let site = try context.fetch(descriptor).first else {+ throw LibraryRepositoryError.invalidInput(operation: "reading test Site", reason: "Site not found")+ }+ return site.mode+ }++ func activePatternCount(hostname: String) async throws -> Int {+ let lease = try await CrossProcessLibraryLock.acquire(+ mode: .shared,+ at: configuration.lockURL,+ timeout: .seconds(1)+ )+ defer { withExtendedLifetime(lease) {} }+ let container = try makeContainer()+ let context = ModelContext(container)+ var descriptor = FetchDescriptor<Site>(predicate: #Predicate { $0.hostname == hostname })+ descriptor.fetchLimit = 1+ guard let site = try context.fetch(descriptor).first else {+ throw LibraryRepositoryError.invalidInput(operation: "reading test Site patterns", reason: "Site not found")+ }+ return site.patternValues.count(where: \.isActive)+ }++ private func makeContainer() throws -> ModelContainer {+ let schema = Schema(versionedSchema: AsterismSchemaV2.self)+ let store = ModelConfiguration("AsterismV2", schema: schema, url: configuration.storeURL, cloudKitDatabase: .none)+ return try ModelContainer(for: schema, migrationPlan: AsterismV2MigrationPlan.self, configurations: [store])+ }+}++private final class TeachingMutableClock: RepositoryClock, @unchecked Sendable {+ private let lock = NSLock()+ private var value: Date++ init(_ value: Date) { self.value = value }++ func advance(by seconds: TimeInterval) {+ lock.withLock { value = value.addingTimeInterval(seconds) }+ }++ func now() -> Date {+ lock.withLock { MillisecondInstant.quantize(value) }+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RuntimeOpeningV2Tests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RuntimeOpeningV2Tests.swiftnew file mode 100644index 0000000..e6d8d1f--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RuntimeOpeningV2Tests.swift@@ -0,0 +1,98 @@+import Foundation+import Testing+@testable import AsterismCore++@Suite("V2 runtime opening", .serialized)+struct RuntimeOpeningV2Tests {+ @Test("Containing app creates and marks a fresh V2 library")+ func appCreatesV2() async throws {+ let directory = try V2OpeningTemporaryDirectory()+ let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)++ let repository = try await LibraryRepository.openForApp(configuration, capabilities: .m2_0)++ #expect(FileManager.default.fileExists(atPath: configuration.storeURL.path))+ #expect(FileManager.default.fileExists(atPath: configuration.markerURL.path))+ #expect(!FileManager.default.fileExists(atPath: configuration.legacyStoreURL.path))+ #expect(try await repository.debugCounts() == .zero)+ }++ @Test("Extension refuses to create or mark an absent V2 library")+ func extensionDoesNotCreate() async throws {+ let directory = try V2OpeningTemporaryDirectory()+ let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)++ await #expect(throws: LibraryRepositoryError.self) {+ try await LibraryRepository.openForExtension(configuration, capabilities: .m2_0)+ }++ #expect(!FileManager.default.fileExists(atPath: configuration.storeURL.path))+ #expect(!FileManager.default.fileExists(atPath: configuration.markerURL.path))+ }++ @Test("Extension opens only an existing ready V2 library")+ func extensionOpensReadyV2() async throws {+ let directory = try V2OpeningTemporaryDirectory()+ let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ _ = try await LibraryRepository.openForApp(configuration, capabilities: .m2_0)++ let extensionRepository = try await LibraryRepository.openForExtension(+ configuration,+ capabilities: .m2_0+ )++ #expect(try await extensionRepository.debugCounts() == .zero)+ }++ @Test("App preserves a legacy V1 migration opportunity instead of creating V2")+ func appRefusesLegacyPath() async throws {+ let directory = try V2OpeningTemporaryDirectory()+ let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ try FileManager.default.createDirectory(+ at: configuration.legacyStoreURL.deletingLastPathComponent(),+ withIntermediateDirectories: true+ )+ let evidence = Data("legacy evidence".utf8)+ try evidence.write(to: configuration.legacyStoreURL)++ await #expect(throws: LibraryRepositoryError.self) {+ try await LibraryRepository.openForApp(configuration, capabilities: .m2_0)+ }++ #expect(try Data(contentsOf: configuration.legacyStoreURL) == evidence)+ #expect(!FileManager.default.fileExists(atPath: configuration.storeURL.path))+ #expect(!FileManager.default.fileExists(atPath: configuration.markerURL.path))+ }++ @Test("Ready marker without V2 store fails closed")+ func markerWithoutStore() async throws {+ let directory = try V2OpeningTemporaryDirectory()+ let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ try Data("ready".utf8).write(to: configuration.markerURL)++ await #expect(throws: LibraryRepositoryError.self) {+ try await LibraryRepository.openForApp(configuration, capabilities: .m2_0)+ }+ await #expect(throws: LibraryRepositoryError.self) {+ try await LibraryRepository.openForExtension(configuration, capabilities: .m2_0)+ }++ #expect(!FileManager.default.fileExists(atPath: configuration.storeURL.path))+ }+}++private final class V2OpeningTemporaryDirectory {+ let url: URL++ init() throws {+ url = FileManager.default.temporaryDirectory.appending(+ path: "AsterismV2OpeningTests-\(UUID())",+ directoryHint: .isDirectory+ )+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+ }++ deinit {+ try? FileManager.default.removeItem(at: url)+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SchemaV2Tests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SchemaV2Tests.swiftnew file mode 100644index 0000000..c8d9988--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SchemaV2Tests.swift@@ -0,0 +1,204 @@+import Foundation+import SwiftData+import Testing+@testable import AsterismCore++@Suite("Asterism schema V2", .serialized)+struct SchemaV2Tests {+ @Test("Schema identity and capability gates are stable")+ func schemaAndCapabilities() {+ #expect(AsterismSchemaV2.versionIdentifier == Schema.Version(2, 0, 0))+ #expect(AsterismV2MigrationPlan.schemas.count == 1)+ #expect(AsterismV2MigrationPlan.stages.isEmpty)++ #expect(M2Capabilities.m2_0.allows(patternForm: .segment))+ #expect(!M2Capabilities.m2_0.allows(patternForm: .phrase))+ #expect(!M2Capabilities.m2_0.supportsArticles)+ #expect(M2Capabilities.m2_1.allows(patternForm: .segment))+ #expect(!M2Capabilities.m2_1.allows(patternForm: .phrase))+ #expect(M2Capabilities.m2_2.supportsArticles)+ #expect(!M2Capabilities.m2_2.allows(patternForm: .phrase))+ #expect(M2Capabilities.m2_3.allows(patternForm: .phrase))++ #expect(PatternForm.allCases.map(\.rawValue) == ["segment", "phrase"])+ #expect(FieldOrder.allCases.map(\.rawValue) == ["chapterThenWork", "workThenChapter"])+ #expect(CaptureTitleSource.allCases.map(\.rawValue) == ["host", "safariDocument", "networkFetch", "manual"])+ #expect(Rating.allCases.map(\.rawValue) == ["up", "down"])+ #expect(WorkType.allCases.map(\.rawValue) == ["novel", "toon", "article", "other"])+ #expect(TitleProvenance.allCases.map(\.rawValue) == ["parsed", "manual"])+ #expect(FieldProvenanceKind.allCases.map(\.rawValue) == ["none", "pattern", "manual"])+ #expect(SiteMode.allCases.map(\.rawValue) == ["untaught", "taught", "articles"])+ #expect(AnchorOrigin.allCases.map(\.rawValue) == ["start", "end"])+ #expect(URLRuleComponent.allCases.map(\.rawValue) == ["pathSegment", "queryItem"])+ }++ @Test("Every gate validates tagged pattern forms defensively")+ func gatePatternValidation() throws {+ let segment = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let phrase = PatternDefinition.phrase(+ prefix: "Read ",+ separator: " from ",+ suffix: ".",+ order: .chapterThenWork+ )++ try M2Capabilities.m2_0.validate(patternDefinition: segment)+ #expect(throws: M2CapabilityError.self) {+ try M2Capabilities.m2_0.validate(patternDefinition: phrase)+ }+ try M2Capabilities.m2_3.validate(patternDefinition: segment)+ try M2Capabilities.m2_3.validate(patternDefinition: phrase)+ }++ @Test("Value objects reject invalid combinations")+ func valueObjectInvariants() throws {+ #expect(throws: ModelInvariantError.self) { try SegmentRangeSpec(origin: .start, offset: -1, length: 1) }+ #expect(throws: ModelInvariantError.self) { try SegmentRangeSpec(origin: .start, offset: 0, length: 0) }+ #expect(throws: ModelInvariantError.self) { try SegmentPositionSpec(origin: .end, offset: -1) }+ #expect(throws: ModelInvariantError.self) { try URLIdentityRule(version: 0, component: .pathSegment, origin: .start, offset: 0) }+ #expect(throws: ModelInvariantError.self) { try URLIdentityRule(version: 1, component: .queryItem, queryName: " ") }+ #expect(throws: ModelInvariantError.self) { try URLIdentityRule(version: 1, component: .pathSegment, queryName: "chapter") }+ #expect(throws: ModelInvariantError.self) { try JunkSuffixRule(version: 0, anchors: []) }+ #expect(throws: ModelInvariantError.self) { try FieldProvenance(kind: .pattern, patternID: nil, patternVersion: 1) }+ #expect(throws: ModelInvariantError.self) { try FieldProvenance(kind: .manual, patternID: UUID(), patternVersion: 1) }+ #expect(throws: ModelInvariantError.self) {+ try PatternDefinition.phrase(prefix: "", separator: " ", suffix: "", order: .chapterThenWork).validated()+ }+ }++ @Test("Models have documented defaults and nil-to-empty domain collections")+ func defaultsAndCollections() throws {+ let instant = Date(timeIntervalSince1970: 1_721_000_000.123)+ let site = Site(hostname: "example.com")+ let work = Work(displayTitle: "A Work", siteHostname: site.hostname, timestamp: instant)+ let entry = Entry(+ captureTitle: "Chapter 1",+ captureTitleSource: .host,+ rawURLString: "https://example.com/1",+ hostname: site.hostname,+ entryIdentityKey: "https://example.com/1",+ timestamp: instant+ )++ #expect(site.displayName == "example.com")+ #expect(site.mode == .untaught)+ #expect(site.patternValues.isEmpty)+ #expect(work.type == .other)+ #expect(work.titleProvenance == .manual)+ #expect(work.entryValues.isEmpty)+ #expect(work.genreTags.isEmpty)+ #expect(work.genericNotes.isEmpty)+ #expect(entry.chapterTitle == nil)+ #expect(entry.chapterTitleProvenance == .none)+ #expect(entry.workAssignmentProvenance == .none)+ #expect(entry.work == nil)+ #expect(entry.note.isEmpty)+ #expect(entry.rating == nil)+ #expect(entry.identityKeyVersion == 1)+ #expect(entry.intentionallyUnattached == false)+ }++ @Test("Tagged pattern arms persist without cross-arm values")+ func persistenceRoundTrip() throws {+ let fixture = try ModelFixture()+ let timestamp = MillisecondInstant.quantize(Date(timeIntervalSince1970: 1_721_000_000.123_987))+ let site = Site(hostname: "example.com")+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .start, offset: 0, length: 2),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let pattern = try TitlePattern(+ version: 1,+ createdAt: timestamp,+ definition: definition,+ site: site+ )+ fixture.context.insert(site)+ fixture.context.insert(pattern)+ try fixture.context.save()++ let verification = ModelContext(fixture.container)+ let patterns = try verification.fetch(FetchDescriptor<TitlePattern>())+ #expect(patterns.count == 1)+ #expect(try patterns[0].definition == definition)+ #expect(patterns[0].formRaw == PatternForm.segment.rawValue)+ #expect(patterns[0].phrasePrefix == nil)+ #expect(patterns[0].phraseSeparator == nil)+ #expect(patterns[0].phraseSuffix == nil)+ #expect(patterns[0].fieldOrderRaw == nil)+ }++ @Test("Optional relationships preserve explicit inverses")+ func optionalRelationshipInverses() throws {+ let fixture = try ModelFixture()+ let timestamp = Date(timeIntervalSince1970: 1_721_000_000)+ let site = Site(hostname: "example.com")+ let pattern = try TitlePattern(+ version: 1,+ createdAt: timestamp,+ definition: .segment(+ work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+ ignored: []+ ),+ site: site+ )+ let work = Work(displayTitle: "A Work", siteHostname: site.hostname, timestamp: timestamp)+ let entry = Entry(+ captureTitle: "Chapter 1",+ captureTitleSource: .manual,+ rawURLString: "https://example.com/1",+ hostname: site.hostname,+ entryIdentityKey: "https://example.com/1",+ timestamp: timestamp,+ work: work+ )+ fixture.context.insert(site)+ fixture.context.insert(pattern)+ fixture.context.insert(work)+ fixture.context.insert(entry)+ try fixture.context.save()++ #expect(work.entryValues.map(\.id) == [entry.id])+ #expect(site.patternValues.map(\.id) == [pattern.id])+ #expect(pattern.site?.hostname == site.hostname)+ }++ @Test("Repository clock quantizes once to milliseconds")+ func millisecondClockBoundary() {+ let source = Date(timeIntervalSince1970: 10.123_987)+ let clock = FixedRepositoryClock(source)+ #expect(clock.now() == Date(timeIntervalSince1970: 10.123))+ #expect(MillisecondInstant.isQuantized(clock.now()))+ #expect(MillisecondInstant.quantize(clock.now()) == clock.now())+ }++ @Test("Snapshots are immutable Sendable values")+ func snapshotsAreSendable() {+ func requireSendable<T: Sendable>(_: T.Type) {}+ requireSendable(EntrySnapshot.self)+ requireSendable(WorkSnapshot.self)+ requireSendable(SiteSnapshot.self)+ requireSendable(TitlePatternSnapshot.self)+ requireSendable(WorksSnapshot.self)+ requireSendable(DatedEntryGroup.self)+ }+}++private struct ModelFixture {+ let container: ModelContainer+ let context: ModelContext++ init() throws {+ let schema = Schema(versionedSchema: AsterismSchemaV2.self)+ let configuration = ModelConfiguration(+ schema: schema,+ isStoredInMemoryOnly: true,+ cloudKitDatabase: .none+ )+ container = try ModelContainer(for: schema, configurations: [configuration])+ context = ModelContext(container)+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/TitleParsingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/TitleParsingTests.swiftnew file mode 100644index 0000000..37dd26d--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/TitleParsingTests.swift@@ -0,0 +1,626 @@+import Foundation+import Testing+@testable import AsterismCore++// MARK: - Segment tokenizer tests++@Suite("Segment tokenizer", .serialized)+struct SegmentTokenizerTests {++ @Test("Seven spaced delimiters are recognized with :: longest first")+ func sevenDelimiters() {+ // Each recognized token with required spacing.+ let tokens: [(String, DelimiterToken)] = [+ ("A :: B", .doubleColon),+ ("A \u{2013} B", .enDash),+ ("A \u{2014} B", .emDash),+ ("A | B", .verticalBar),+ ("A - B", .hyphenMinus),+ ("A \u{00B7} B", .middleDot),+ ("A : B", .colon),+ ]+ for (title, expectedToken) in tokens {+ let result = DelimiterTokenizer.tokenize(title)+ guard case .success(let tok) = result else {+ Issue.record("Expected success for '\(title)' but got failure")+ continue+ }+ #expect(tok.segments.count == 2, "Expected 2 segments for '\(title)'")+ #expect(tok.delimiters.count == 1, "Expected 1 delimiter for '\(title)'")+ #expect(tok.delimiters[0].token == expectedToken, "Expected \(expectedToken) for '\(title)'")+ #expect(tok.segments[0] == "A")+ #expect(tok.segments[1] == "B")+ }+ }++ @Test("Double-colon wins over single colon when both spacing is present")+ func doubleColonPrecedence() {+ let title = "A :: B"+ let result = DelimiterTokenizer.tokenize(title)+ guard case .success(let tok) = result else {+ Issue.record("Expected success"); return+ }+ #expect(tok.delimiters.count == 1)+ #expect(tok.delimiters[0].token == .doubleColon)+ }++ @Test("Unspaced punctuation is not a delimiter")+ func unspacedNotDelimiter() {+ // en dash without surrounding whitespace+ let title = "2020\u{2013}2021 - Fiction Name | Royal Road"+ let result = DelimiterTokenizer.tokenize(title)+ guard case .success(let tok) = result else {+ Issue.record("Expected success"); return+ }+ // The unspaced en dash stays in the first segment.+ #expect(tok.segments[0] == "2020\u{2013}2021")+ }++ @Test("Source reconstruction from tokenized title is exact")+ func sourceReconstruction() {+ let titles = [+ "Chapter Name - Fiction Name | Royal Road",+ "Part One \u{2014} Chapter 12 - Fiction Name | Royal Road",+ "A :: B : C",+ "Simple Title",+ "A - B",+ ]+ for title in titles {+ let result = DelimiterTokenizer.tokenize(title)+ switch result {+ case .success(let tok):+ let reconstructed = DelimiterTokenizer.reconstructSource(from: tok)+ #expect(reconstructed == title, "Reconstruction failed for '\(title)'")+ case .failure:+ // Some may fail (adjacent delimiters), that's fine for this test.+ break+ }+ }+ }++ @Test("Overlapping whitespace spans are rejected as unsupported adjacent delimiters")+ func overlapRejection() {+ // "A - | B" — the whitespace between - and | is shared+ let title = "A - | B"+ let result = DelimiterTokenizer.tokenize(title)+ guard case .failure(let error) = result else {+ Issue.record("Expected failure for adjacent delimiters"); return+ }+ #expect(error == .unsupportedAdjacentDelimiters)+ }++ @Test("M2 White_Space characters are recognized in delimiter spans")+ func m2Whitespace() {+ // Using non-breaking space (U+00A0) as the spacing around a delimiter.+ let title = "A\u{00A0}-\u{00A0}B"+ let result = DelimiterTokenizer.tokenize(title)+ guard case .success(let tok) = result else {+ Issue.record("Expected success with NBSP spacing"); return+ }+ #expect(tok.segments.count == 2)+ #expect(tok.segments[0] == "A")+ #expect(tok.segments[1] == "B")+ }++ @Test("Empty or blank leading segment rejects")+ func blankLeadingSegment() {+ let title = " - B"+ let result = DelimiterTokenizer.tokenize(title)+ guard case .failure(let error) = result else {+ Issue.record("Expected failure for blank leading segment"); return+ }+ if case .blankSegment(let pos) = error {+ #expect(pos == 0)+ } else {+ Issue.record("Expected blankSegment error")+ }+ }++ @Test("Empty or blank trailing segment rejects")+ func blankTrailingSegment() {+ let title = "A - "+ let result = DelimiterTokenizer.tokenize(title)+ guard case .failure(let error) = result else {+ Issue.record("Expected failure for blank trailing segment"); return+ }+ if case .blankSegment(let pos) = error {+ #expect(pos == 1)+ } else {+ Issue.record("Expected blankSegment error")+ }+ }++ @Test("No-delimiter title produces one segment")+ func noDelimiter() {+ let title = "Just a plain title"+ let result = DelimiterTokenizer.tokenize(title)+ guard case .success(let tok) = result else {+ Issue.record("Expected success"); return+ }+ #expect(tok.segments.count == 1)+ #expect(tok.segments[0] == title)+ #expect(tok.delimiters.isEmpty)+ }++ @Test("Blank no-delimiter title fails tokenization")+ func blankTitleFails() {+ // Empty string.+ let result1 = DelimiterTokenizer.tokenize("")+ guard case .failure(let e1) = result1 else {+ Issue.record("Expected failure for empty title"); return+ }+ #expect(e1 == .blankTitle)++ // Only whitespace.+ let result2 = DelimiterTokenizer.tokenize(" ")+ guard case .failure(let e2) = result2 else {+ Issue.record("Expected failure for whitespace-only title"); return+ }+ #expect(e2 == .blankTitle)++ // Only M2 whitespace (NBSP, etc).+ let result3 = DelimiterTokenizer.tokenize("\u{00A0}\u{2000}")+ guard case .failure(let e3) = result3 else {+ Issue.record("Expected failure for M2-whitespace-only title"); return+ }+ #expect(e3 == .blankTitle)+ }++ @Test("Multiple delimiters produce correct segment count")+ func multipleDelimiters() {+ let title = "Chapter Name - Fiction Name | Royal Road"+ let result = DelimiterTokenizer.tokenize(title)+ guard case .success(let tok) = result else {+ Issue.record("Expected success"); return+ }+ #expect(tok.segments.count == 3)+ #expect(tok.segments[0] == "Chapter Name")+ #expect(tok.segments[1] == "Fiction Name")+ #expect(tok.segments[2] == "Royal Road")+ }+}++// MARK: - Anchor derivation tests++@Suite("Anchor derivation", .serialized)+struct AnchorDerivationTests {++ @Test("Work anchor uses nearer edge; ties choose end")+ func workAnchorEdgeSelection() {+ // 5 segments: [0:ch, 1:work, 2:ch, 3:ch, 4:ignore]+ // Work at index 1: startOffset=1, endOffset=3 → start is nearer → .start offset 1+ let spec1 = AnchorDerivation.deriveWorkAnchor(segmentCount: 5, workRange: 1..<2)+ #expect(spec1?.origin == .start)+ #expect(spec1?.offset == 1)+ #expect(spec1?.length == 1)++ // 3 segments: [0:ch, 1:work, 2:ignore]+ // Work at index 1: startOffset=1, endOffset=1 → tie → .end offset 1+ let spec2 = AnchorDerivation.deriveWorkAnchor(segmentCount: 3, workRange: 1..<2)+ #expect(spec2?.origin == .end)+ #expect(spec2?.offset == 1)++ // 3 segments: [0:ch, 1:work, 2:work]+ // Work at 1..<3: startOffset=1, endOffset=0 → end nearer → .end offset 0+ let spec3 = AnchorDerivation.deriveWorkAnchor(segmentCount: 3, workRange: 1..<3)+ #expect(spec3?.origin == .end)+ #expect(spec3?.offset == 0)+ }++ @Test("Ignored anchor uses nearer edge; ties choose end")+ func ignoredAnchorEdgeSelection() {+ // 3 segments, ignored at index 2: startOffset=2, endOffset=0 → end nearer+ let spec1 = AnchorDerivation.deriveIgnoredAnchor(segmentCount: 3, ignoredIndex: 2)+ #expect(spec1?.origin == .end)+ #expect(spec1?.offset == 0)++ // 3 segments, ignored at index 0: startOffset=0, endOffset=2 → start nearer+ let spec2 = AnchorDerivation.deriveIgnoredAnchor(segmentCount: 3, ignoredIndex: 0)+ #expect(spec2?.origin == .start)+ #expect(spec2?.offset == 0)++ // 3 segments, ignored at index 1: startOffset=1, endOffset=1 → tie → .end+ let spec3 = AnchorDerivation.deriveIgnoredAnchor(segmentCount: 3, ignoredIndex: 1)+ #expect(spec3?.origin == .end)+ #expect(spec3?.offset == 1)+ }+}++// MARK: - Pattern application tests++@Suite("Segment pattern application", .serialized)+struct SegmentPatternApplicationTests {++ @Test("Requirement 1.8: Chapter Name - Fiction Name | Royal Road")+ func req18BasicExample() throws {+ // "Chapter Name - Fiction Name | Royal Road"+ // Roles: chapter=0, work=1, ignore=2+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let result = SegmentPatternApplicator.apply(+ definition: definition,+ to: "Chapter Name - Fiction Name | Royal Road"+ )+ guard case .success(let parsed) = result else {+ Issue.record("Expected success"); return+ }+ #expect(parsed.chapterTitle == "Chapter Name")+ #expect(parsed.workTitle == "Fiction Name")+ }++ @Test("Requirement 1.8: Spaced en-dash title produces same fields")+ func req18EnDashVariant() throws {+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let result = SegmentPatternApplicator.apply(+ definition: definition,+ to: "Chapter Name \u{2013} Fiction Name | Royal Road"+ )+ guard case .success(let parsed) = result else {+ Issue.record("Expected success"); return+ }+ #expect(parsed.chapterTitle == "Chapter Name")+ #expect(parsed.workTitle == "Fiction Name")+ }++ @Test("Requirement 1.8: Unspaced en dash stays in chapter")+ func req18UnspacedEnDash() throws {+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let result = SegmentPatternApplicator.apply(+ definition: definition,+ to: "2020\u{2013}2021 - Fiction Name | Royal Road"+ )+ guard case .success(let parsed) = result else {+ Issue.record("Expected success"); return+ }+ #expect(parsed.chapterTitle == "2020\u{2013}2021")+ #expect(parsed.workTitle == "Fiction Name")+ }++ @Test("Requirement 1.8: Part One — Chapter 12 preserves internal delimiter in chapter")+ func req18InternalDelimiter() throws {+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let result = SegmentPatternApplicator.apply(+ definition: definition,+ to: "Part One \u{2014} Chapter 12 - Fiction Name | Royal Road"+ )+ guard case .success(let parsed) = result else {+ Issue.record("Expected success"); return+ }+ #expect(parsed.chapterTitle == "Part One \u{2014} Chapter 12")+ #expect(parsed.workTitle == "Fiction Name")+ }++ @Test("Requirement 1.8: Fiction Name | Royal Road fails — no chapter remains")+ func req18NoChapter() throws {+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let result = SegmentPatternApplicator.apply(+ definition: definition,+ to: "Fiction Name | Royal Road"+ )+ guard case .failure(let error) = result else {+ Issue.record("Expected failure"); return+ }+ #expect(error == .blankResult(field: "chapter"))+ }++ @Test("Absent anchor position returns failure")+ func absentAnchorPosition() throws {+ // Pattern expects 3 segments but title only has 2.+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .start, offset: 1, length: 2),+ ignored: []+ )+ let result = SegmentPatternApplicator.apply(+ definition: definition,+ to: "Only - Two"+ )+ guard case .failure(let error) = result else {+ Issue.record("Expected failure"); return+ }+ if case .anchorPositionAbsent = error {+ // Expected+ } else {+ Issue.record("Expected anchorPositionAbsent but got \(error)")+ }+ }++ @Test("Duplicate ignored positions are rejected")+ func duplicateIgnored() throws {+ // Two ignored anchors that resolve to the same index.+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),+ ignored: [+ try SegmentPositionSpec(origin: .end, offset: 0),+ try SegmentPositionSpec(origin: .start, offset: 2),+ ]+ )+ // Title with 3 segments: "A - B - C"+ let result = SegmentPatternApplicator.apply(+ definition: definition,+ to: "A - B - C"+ )+ guard case .failure(let error) = result else {+ Issue.record("Expected failure"); return+ }+ #expect(error == .duplicateIgnoredPosition(index: 2))+ }++ @Test("Work and ignored overlap is rejected")+ func workIgnoredOverlap() throws {+ // Work covers index 1, ignored also at index 1.+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .start, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .start, offset: 1)]+ )+ let result = SegmentPatternApplicator.apply(+ definition: definition,+ to: "A - B - C"+ )+ guard case .failure(let error) = result else {+ Issue.record("Expected failure"); return+ }+ #expect(error == .workIgnoredOverlap)+ }++ @Test("Non-contiguous chapter remainder is rejected")+ func nonContiguousChapter() throws {+ // 4 segments: work at 1, ignored at 2 → chapter is {0, 3} which is non-contiguous.+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .start, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .start, offset: 2)]+ )+ let result = SegmentPatternApplicator.apply(+ definition: definition,+ to: "A - B - C - D"+ )+ guard case .failure(let error) = result else {+ Issue.record("Expected failure"); return+ }+ #expect(error == .nonContiguousChapter)+ }++ @Test("Exact scalar matching without normalization")+ func exactScalarMatching() throws {+ // NFC vs NFD: é (U+00E9) vs e + combining acute (U+0065 U+0301)+ let nfc = "Caf\u{00E9} - Work | Site"+ let nfd = "Cafe\u{0301} - Work | Site"+ let definition = PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ let resultNFC = SegmentPatternApplicator.apply(definition: definition, to: nfc)+ let resultNFD = SegmentPatternApplicator.apply(definition: definition, to: nfd)+ guard case .success(let parsedNFC) = resultNFC,+ case .success(let parsedNFD) = resultNFD else {+ Issue.record("Expected both to succeed"); return+ }+ // They produce the same canonical-equivalent chapter but different scalars.+ let nfcScalars = Array(parsedNFC.chapterTitle.unicodeScalars)+ let nfdScalars = Array(parsedNFD.chapterTitle.unicodeScalars)+ // NFC: [C, a, f, é] — 4 scalars+ // NFD: [C, a, f, e, ◌́] — 5 scalars+ #expect(nfcScalars.count == 4)+ #expect(nfdScalars.count == 5)+ #expect(nfcScalars != nfdScalars)+ }+}++// MARK: - Teaching validation tests++@Suite("Teaching validation", .serialized)+struct TeachingValidationTests {++ @Test("Valid assignment with chapter, work, ignore")+ func validAssignment() {+ let segments = ["Chapter Name", "Fiction Name", "Royal Road"]+ let assignment = SegmentRoleAssignment(roles: [.chapter, .work, .ignore])+ let result = TeachingValidator.validate(assignment: assignment, segments: segments)+ guard case .success(let validated) = result else {+ Issue.record("Expected success"); return+ }+ #expect(validated.workRange == 1..<2)+ #expect(validated.chapterRange == 0..<1)+ #expect(validated.ignoredIndices == [2])+ }++ @Test("Non-contiguous work is rejected")+ func nonContiguousWork() {+ let segments = ["A", "B", "C", "D"]+ let assignment = SegmentRoleAssignment(roles: [.work, .chapter, .work, .ignore])+ let result = TeachingValidator.validate(assignment: assignment, segments: segments)+ guard case .failure(let error) = result else {+ Issue.record("Expected failure"); return+ }+ #expect(error == .nonContiguousWork)+ }++ @Test("Non-contiguous chapter is rejected")+ func nonContiguousChapter() {+ let segments = ["A", "B", "C", "D"]+ let assignment = SegmentRoleAssignment(roles: [.chapter, .work, .chapter, .ignore])+ let result = TeachingValidator.validate(assignment: assignment, segments: segments)+ guard case .failure(let error) = result else {+ Issue.record("Expected failure"); return+ }+ #expect(error == .nonContiguousChapter)+ }++ @Test("Missing work segment is rejected")+ func missingWork() {+ let segments = ["A", "B"]+ let assignment = SegmentRoleAssignment(roles: [.chapter, .ignore])+ let result = TeachingValidator.validate(assignment: assignment, segments: segments)+ guard case .failure(let error) = result else {+ Issue.record("Expected failure"); return+ }+ #expect(error == .noWorkSegment)+ }++ @Test("Missing chapter segment is rejected")+ func missingChapter() {+ let segments = ["A", "B"]+ let assignment = SegmentRoleAssignment(roles: [.work, .ignore])+ let result = TeachingValidator.validate(assignment: assignment, segments: segments)+ guard case .failure(let error) = result else {+ Issue.record("Expected failure"); return+ }+ #expect(error == .noChapterSegment)+ }+}++// MARK: - Work matching tests++@Suite("Work matching", .serialized)+struct WorkMatchingTests {++ @Test("Exact match reuses one Work")+ func exactReuse() {+ let workID = UUID()+ let candidates = [+ WorkMatchCandidate(id: workID, lastParsedTitle: "Fiction Name", displayTitle: "Fiction Name"),+ ]+ let outcome = WorkMatcher.match(parsedWorkTitle: "Fiction Name", candidates: candidates)+ #expect(outcome == .reuse(workID: workID))+ }++ @Test("No match creates a new Work")+ func noMatchCreates() {+ let candidates = [+ WorkMatchCandidate(id: UUID(), lastParsedTitle: "Other", displayTitle: "Other"),+ ]+ let outcome = WorkMatcher.match(parsedWorkTitle: "Fiction Name", candidates: candidates)+ #expect(outcome == .create(parsedWorkTitle: "Fiction Name"))+ }++ @Test("Multiple matches produce ambiguity with full candidate snapshots in UUID order")+ func ambiguity() {+ let id1 = UUID()+ let id2 = UUID()+ let candidates = [+ WorkMatchCandidate(id: id1, lastParsedTitle: "Fiction Name", displayTitle: "Fiction Name"),+ WorkMatchCandidate(id: id2, lastParsedTitle: "Fiction Name", displayTitle: "Different"),+ ]+ let outcome = WorkMatcher.match(parsedWorkTitle: "Fiction Name", candidates: candidates)+ if case .ambiguous(let matched) = outcome {+ #expect(matched.count == 2)+ #expect(matched.contains(where: { $0.id == id1 }))+ #expect(matched.contains(where: { $0.id == id2 }))+ // Deterministic UUID order.+ #expect(matched[0].id.uuidString < matched[1].id.uuidString)+ // Rich metadata present.+ #expect(matched[0].displayTitle.isEmpty == false)+ } else {+ Issue.record("Expected ambiguous outcome")+ }+ }++ @Test("Uses lastParsedTitle when nonblank, otherwise displayTitle")+ func lastParsedTitlePreference() {+ let workID = UUID()+ let candidates = [+ WorkMatchCandidate(id: workID, lastParsedTitle: nil, displayTitle: "Fiction Name"),+ ]+ let outcome = WorkMatcher.match(parsedWorkTitle: "Fiction Name", candidates: candidates)+ #expect(outcome == .reuse(workID: workID))++ // Blank lastParsedTitle falls back to displayTitle.+ let candidates2 = [+ WorkMatchCandidate(id: workID, lastParsedTitle: " ", displayTitle: "Fiction Name"),+ ]+ let outcome2 = WorkMatcher.match(parsedWorkTitle: "Fiction Name", candidates: candidates2)+ #expect(outcome2 == .reuse(workID: workID))+ }++ @Test("Exact scalar equality without normalization")+ func exactScalarEquality() {+ let workID = UUID()+ // Store an NFC title as the candidate.+ let candidates = [+ WorkMatchCandidate(id: workID, lastParsedTitle: "Caf\u{00E9}", displayTitle: "Café"),+ ]+ // NFD form should NOT match NFC when using scalar-level comparison.+ let outcome = WorkMatcher.match(parsedWorkTitle: "Cafe\u{0301}", candidates: candidates)+ #expect(outcome == .create(parsedWorkTitle: "Cafe\u{0301}"))+ }+}++// MARK: - Actionability tests++@Suite("Actionability evaluator", .serialized)+struct ActionabilityTests {++ @Test("Entry with absent chapter and no provenance is actionable")+ func absentChapterActionable() throws {+ let isActionable = ActionabilityEvaluator.isActionable(+ chapterTitle: nil,+ chapterProvenance: try FieldProvenance(kind: .none),+ workID: UUID(),+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1),+ intentionallyUnattached: false+ )+ #expect(isActionable == true)+ }++ @Test("Entry with absent work, non-manual provenance, not unattached is actionable")+ func absentWorkActionable() throws {+ let isActionable = ActionabilityEvaluator.isActionable(+ chapterTitle: "Chapter",+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1),+ workID: nil,+ assignmentProvenance: try FieldProvenance(kind: .none),+ intentionallyUnattached: false+ )+ #expect(isActionable == true)+ }++ @Test("Manual provenance settles chapter field")+ func manualChapterSettled() throws {+ let isActionable = ActionabilityEvaluator.isActionable(+ chapterTitle: nil,+ chapterProvenance: try FieldProvenance(kind: .manual),+ workID: nil,+ assignmentProvenance: try FieldProvenance(kind: .manual),+ intentionallyUnattached: false+ )+ // chapter nil but manual → not unsettled; work nil but manual → not unsettled+ #expect(isActionable == false)+ }++ @Test("Intentionally unattached settles assignment field")+ func intentionallyUnattachedSettled() throws {+ let isActionable = ActionabilityEvaluator.isActionable(+ chapterTitle: "Chapter",+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1),+ workID: nil,+ assignmentProvenance: try FieldProvenance(kind: .none),+ intentionallyUnattached: true+ )+ #expect(isActionable == false)+ }++ @Test("Fully settled entry is not actionable")+ func fullySettled() throws {+ let isActionable = ActionabilityEvaluator.isActionable(+ chapterTitle: "Chapter",+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1),+ workID: UUID(),+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1),+ intentionallyUnattached: false+ )+ #expect(isActionable == false)+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/TitleProjectionPlannerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/TitleProjectionPlannerTests.swiftnew file mode 100644index 0000000..9b5cb34--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/TitleProjectionPlannerTests.swift@@ -0,0 +1,693 @@+import Foundation+import Testing+@testable import AsterismCore++@Suite("Title projection planner", .serialized)+struct TitleProjectionPlannerTests {++ // MARK: - Test helpers++ private func makeEntry(+ id: UUID = UUID(),+ captureTitle: String,+ chapterTitle: String? = nil,+ chapterProvenance: FieldProvenance? = nil,+ workID: UUID? = nil,+ assignmentProvenance: FieldProvenance? = nil,+ intentionallyUnattached: Bool = false,+ firstCapturedAt: Date = Date(timeIntervalSince1970: 1_700_000_000)+ ) -> EntrySnapshot {+ EntrySnapshot(+ id: id,+ captureTitle: captureTitle,+ captureTitleSource: .host,+ rawURLString: "https://example.com/1",+ canonicalURLString: nil,+ hostname: "example.com",+ entryIdentityKey: "key-\(id.uuidString.prefix(8))",+ identityKeyVersion: 1,+ chapterTitle: chapterTitle,+ chapterTitleProvenance: chapterProvenance ?? (try! FieldProvenance(kind: .none)),+ note: "",+ rating: nil,+ firstCapturedAt: firstCapturedAt,+ lastSharedAt: firstCapturedAt,+ modifiedAt: firstCapturedAt,+ workID: workID,+ workAssignmentProvenance: assignmentProvenance ?? (try! FieldProvenance(kind: .none)),+ intentionallyUnattached: intentionallyUnattached+ )+ }++ private func makePattern() throws -> PatternDefinition {+ PatternDefinition.segment(+ work: try SegmentRangeSpec(origin: .end, offset: 1, length: 1),+ ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]+ )+ }++ // MARK: - Determinism (Defect 1)++ @Test("Plan equality across repeated calls with identical inputs")+ func planDeterministic() throws {+ let pattern = try makePattern()+ let entryID = UUID()+ let entry = makeEntry(id: entryID, captureTitle: "Chapter 1 - Fiction Name | Royal Road")++ let plan1 = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: []+ )+ let plan2 = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: []+ )++ #expect(plan1 == plan2)+ }++ @Test("Multiple calls produce exactly equal plans - no UUID nondeterminism")+ func multiplePlansIdentical() throws {+ let pattern = try makePattern()+ let id1 = UUID()+ let id2 = UUID()+ let entry1 = makeEntry(id: id1, captureTitle: "Ch 1 - Fiction | Site",+ firstCapturedAt: Date(timeIntervalSince1970: 1_000))+ let entry2 = makeEntry(id: id2, captureTitle: "Ch 2 - Fiction | Site",+ firstCapturedAt: Date(timeIntervalSince1970: 2_000))++ var plans: [ProjectionPlan] = []+ for _ in 0..<5 {+ let plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry1, entry2],+ existingWorks: []+ )+ plans.append(plan)+ }++ // All plans must be exactly equal.+ for i in 1..<plans.count {+ #expect(plans[0] == plans[i])+ }+ }++ // MARK: - Initial teaching tests++ @Test("Initial teaching creates Work when no match exists")+ func initialTeachingCreatesWork() throws {+ let pattern = try makePattern()+ let entry = makeEntry(captureTitle: "Chapter 1 - Fiction Name | Royal Road")++ let plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: []+ )++ #expect(plan.entryProjections.count == 1)+ let proj = plan.entryProjections[0]+ #expect(proj.projectedChapter == "Chapter 1")+ #expect(proj.projectedWorkTitle == "Fiction Name")+ #expect(plan.worksToCreate == ["Fiction Name"])+ #expect(plan.worksToReuse.isEmpty)+ #expect(!plan.hasAmbiguity)+ }++ @Test("Initial teaching reuses existing Work on exact match")+ func initialTeachingReusesWork() throws {+ let pattern = try makePattern()+ let workID = UUID()+ let entry = makeEntry(captureTitle: "Chapter 1 - Fiction Name | Royal Road")+ let candidates = [+ WorkMatchCandidate(id: workID, lastParsedTitle: "Fiction Name", displayTitle: "Fiction Name"),+ ]++ let plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: candidates+ )++ #expect(plan.worksToCreate.isEmpty)+ #expect(plan.worksToReuse.contains(workID))+ if case .reuse(let id) = plan.entryProjections[0].workOutcome {+ #expect(id == workID)+ } else {+ Issue.record("Expected reuse outcome")+ }+ }++ @Test("Initial teaching reports ambiguity with multiple matches and carries candidate snapshots")+ func initialTeachingAmbiguity() throws {+ let pattern = try makePattern()+ let id1 = UUID()+ let id2 = UUID()+ let entry = makeEntry(captureTitle: "Chapter 1 - Fiction Name | Royal Road")+ let candidates = [+ WorkMatchCandidate(id: id1, lastParsedTitle: "Fiction Name", displayTitle: "X"),+ WorkMatchCandidate(id: id2, lastParsedTitle: "Fiction Name", displayTitle: "Y"),+ ]++ let plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: candidates+ )++ #expect(plan.hasAmbiguity)+ if case .ambiguous(let matchedCandidates) = plan.entryProjections[0].workOutcome {+ #expect(matchedCandidates.count == 2)+ // Deterministic UUID order.+ #expect(matchedCandidates[0].id.uuidString < matchedCandidates[1].id.uuidString)+ // Rich metadata present.+ #expect(matchedCandidates[0].lastParsedTitle == "Fiction Name")+ } else {+ Issue.record("Expected ambiguous outcome")+ }+ }++ // MARK: - Protection tests++ @Test("Manual chapter provenance is never overwritten")+ func manualChapterProtection() throws {+ let pattern = try makePattern()+ let entry = makeEntry(+ captureTitle: "Chapter 1 - Fiction Name | Royal Road",+ chapterTitle: "My Manual Chapter",+ chapterProvenance: try FieldProvenance(kind: .manual)+ )++ let plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: []+ )++ let proj = plan.entryProjections[0]+ #expect(proj.chapterProtected)+ #expect(proj.projectedChapter == "My Manual Chapter")+ }++ @Test("Manual assignment provenance is never overwritten")+ func manualAssignmentProtection() throws {+ let pattern = try makePattern()+ let existingWorkID = UUID()+ let entry = makeEntry(+ captureTitle: "Chapter 1 - Fiction Name | Royal Road",+ workID: existingWorkID,+ assignmentProvenance: try FieldProvenance(kind: .manual)+ )++ let plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: []+ )++ let proj = plan.entryProjections[0]+ #expect(proj.assignmentProtected)+ #expect(proj.workOutcome == nil)+ }++ @Test("Intentionally unattached protects assignment")+ func intentionallyUnattachedProtection() throws {+ let pattern = try makePattern()+ let entry = makeEntry(+ captureTitle: "Chapter 1 - Fiction Name | Royal Road",+ intentionallyUnattached: true+ )++ let plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: []+ )++ let proj = plan.entryProjections[0]+ #expect(proj.assignmentProtected)+ }++ // MARK: - Parse failure tests (Defect 6)++ @Test("Initial teaching parse failure preserves existing fields including assignment")+ func parseFailurePreservesFields() throws {+ let pattern = try makePattern()+ let existingWorkID = UUID()+ let patternID = UUID()+ let entry = makeEntry(+ captureTitle: "No Delimiters Here",+ chapterTitle: "Existing Chapter",+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 1),+ workID: existingWorkID,+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 1)+ )++ let plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: []+ )++ let proj = plan.entryProjections[0]+ #expect(proj.projectedChapter == "Existing Chapter")+ #expect(proj.parseFailure != nil)+ // Assignment state preserved.+ #expect(proj.projectedWorkID == existingWorkID)+ if case .preserved(let prov) = proj.assignmentProvenance {+ #expect(prov.kind == .pattern)+ } else {+ Issue.record("Expected preserved assignment provenance")+ }+ }++ @Test("Replacement parse failure clears both chapter and assignment pattern-derived state")+ func replacementParseFailureClearsBoth() throws {+ let pattern = try makePattern()+ let oldPatternID = UUID()+ let existingWorkID = UUID()+ let entry = makeEntry(+ captureTitle: "No Delimiters Here",+ chapterTitle: "Old Chapter",+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID, patternVersion: 1),+ workID: existingWorkID,+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID, patternVersion: 1)+ )++ let plan = try TitleProjectionPlanner.planReplacement(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: [],+ patternVersion: 2,+ boundaryEntryID: entry.id+ )++ let proj = plan.entryProjections[0]+ #expect(proj.projectedChapter == nil)+ #expect(proj.chapterProvenance == .cleared)+ // Assignment also cleared.+ #expect(proj.projectedWorkID == nil)+ #expect(proj.assignmentProvenance == .cleared)+ }++ // MARK: - Provenance tests (Defect 2)++ @Test("Successful unprotected projection carries pending pattern provenance")+ func successfulProjectionProvenance() throws {+ let pattern = try makePattern()+ let entry = makeEntry(captureTitle: "Chapter 1 - Fiction Name | Royal Road")++ let plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: []+ )++ let proj = plan.entryProjections[0]+ if case .pendingPattern(let prov) = proj.chapterProvenance {+ #expect(prov.patternVersion == 1)+ } else {+ Issue.record("Expected pendingPattern chapter provenance, got \(proj.chapterProvenance)")+ }+ if case .pendingPattern(let prov) = proj.assignmentProvenance {+ #expect(prov.patternVersion == 1)+ } else {+ Issue.record("Expected pendingPattern assignment provenance, got \(proj.assignmentProvenance)")+ }+ }++ // MARK: - Stale detection++ @Test("Identical plans are not stale")+ func identicalPlansNotStale() throws {+ let pattern = try makePattern()+ let entry = makeEntry(captureTitle: "Ch - Work | Site")++ let plan1 = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: []+ )+ let plan2 = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: []+ )++ #expect(!TitleProjectionPlanner.isStale(approved: plan1, current: plan2))+ }++ @Test("Different outcomes are stale")+ func differentOutcomesStale() throws {+ let pattern = try makePattern()+ let entry = makeEntry(captureTitle: "Ch - Work | Site")++ let plan1 = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: []+ )+ let entry2 = makeEntry(captureTitle: "Ch - Other | Site")+ let plan2 = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry2],+ existingWorks: []+ )++ #expect(TitleProjectionPlanner.isStale(approved: plan1, current: plan2))+ }++ // MARK: - Version tests (Defect 4)++ @Test("Plan version is deterministic at 1 for initial teaching")+ func deterministicVersion() throws {+ let pattern = try makePattern()+ let entry = makeEntry(captureTitle: "Ch - Work | Site")++ let plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: [],+ patternVersion: 1+ )++ #expect(plan.patternVersion == 1)+ }++ @Test("Replacement version is specified explicitly")+ func replacementVersion() throws {+ let pattern = try makePattern()+ let entry = makeEntry(captureTitle: "Ch - Work | Site")++ let plan = try TitleProjectionPlanner.planReplacement(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: [],+ patternVersion: 3,+ boundaryEntryID: entry.id+ )++ #expect(plan.patternVersion == 3)+ }++ @Test("Non-positive pattern version throws typed error")+ func invalidPatternVersionThrows() throws {+ let pattern = try makePattern()+ let entry = makeEntry(captureTitle: "Ch - Work | Site")++ #expect(throws: ProjectionPlanError.invalidPatternVersion(version: 0)) {+ try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: [],+ patternVersion: 0+ )+ }++ #expect(throws: ProjectionPlanError.invalidPatternVersion(version: -1)) {+ try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: [],+ patternVersion: -1+ )+ }+ }++ // MARK: - Multiple entries with Work creation deduplication (Defect 3)++ @Test("Same parsed Work title creates one Work for multiple entries, all get create outcome")+ func workCreationDeduplication() throws {+ let pattern = try makePattern()+ let entry1 = makeEntry(captureTitle: "Ch 1 - Fiction | Site",+ firstCapturedAt: Date(timeIntervalSince1970: 1_000))+ let entry2 = makeEntry(captureTitle: "Ch 2 - Fiction | Site",+ firstCapturedAt: Date(timeIntervalSince1970: 2_000))++ let plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry1, entry2],+ existingWorks: []+ )++ #expect(plan.worksToCreate == ["Fiction"])+ for projection in plan.entryProjections {+ if case .create(let title) = projection.workOutcome {+ #expect(title == "Fiction")+ } else {+ Issue.record("Expected every consuming entry to retain the prospective create intent")+ }+ #expect(projection.projectedWorkID == nil)+ #expect(projection.actionableAfter == false)+ }+ #expect(plan.worksToCreate.count == 1)+ }++ // MARK: - Entry sorting (Defect 4)++ @Test("Entries are total-ordered by firstCapturedAt then UUID independent of input order")+ func entryTotalOrder() throws {+ let pattern = try makePattern()+ let id1 = UUID()+ let id2 = UUID()+ let id3 = UUID()+ let entry1 = makeEntry(id: id1, captureTitle: "Ch 1 - Work | Site",+ firstCapturedAt: Date(timeIntervalSince1970: 3_000))+ let entry2 = makeEntry(id: id2, captureTitle: "Ch 2 - Work | Site",+ firstCapturedAt: Date(timeIntervalSince1970: 1_000))+ let entry3 = makeEntry(id: id3, captureTitle: "Ch 3 - Work | Site",+ firstCapturedAt: Date(timeIntervalSince1970: 2_000))++ // Input in reverse order.+ let planReversed = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry1, entry2, entry3],+ existingWorks: []+ )+ // Input in forward order.+ let planForward = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry2, entry3, entry1],+ existingWorks: []+ )++ // Both must produce equal plans.+ #expect(planReversed == planForward)+ // Verify order is by firstCapturedAt.+ #expect(planReversed.entryProjections[0].entryID == id2) // earliest+ #expect(planReversed.entryProjections[1].entryID == id3)+ #expect(planReversed.entryProjections[2].entryID == id1) // latest+ }++ // MARK: - Missing boundary (Defect 4)++ @Test("Missing boundary entry ID throws validation error, not silently index 0")+ func missingBoundaryThrows() throws {+ let pattern = try makePattern()+ let entry = makeEntry(captureTitle: "Ch - Work | Site")+ let missingID = UUID()++ #expect(throws: ProjectionPlanError.boundaryEntryNotFound(id: missingID)) {+ try TitleProjectionPlanner.planReplacement(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: [],+ patternVersion: 2,+ boundaryEntryID: missingID+ )+ }+ }++ // MARK: - Historical correction field-independent (Defect 5)++ @Test("Historical correction targets chapter independently of assignment")+ func historicalCorrectionChapterOnly() throws {+ let pattern = try makePattern()+ let oldPatternID = UUID()+ let settledWorkID = UUID()++ // Entry has chapter citing old pattern, but assignment is manual (settled, not citing pattern).+ let entry = makeEntry(+ captureTitle: "Chapter 1 - Fiction Name | Royal Road",+ chapterTitle: "Old Chapter",+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID, patternVersion: 1),+ workID: settledWorkID,+ assignmentProvenance: try FieldProvenance(kind: .manual),+ firstCapturedAt: Date(timeIntervalSince1970: 1_000)+ )++ // Boundary is after entry (so entry is only targeted via historical correction).+ let boundaryEntry = makeEntry(+ captureTitle: "Ch 2 - Fiction Name | Royal Road",+ firstCapturedAt: Date(timeIntervalSince1970: 5_000)+ )++ let plan = try TitleProjectionPlanner.planReplacement(+ patternDefinition: pattern,+ entries: [entry, boundaryEntry],+ existingWorks: [],+ patternVersion: 2,+ boundaryEntryID: boundaryEntry.id,+ historicalPatternID: oldPatternID,+ historicalPatternVersion: 1+ )++ // Find the projection for our entry.+ let proj = plan.entryProjections.first(where: { $0.entryID == entry.id })!+ // Chapter was targeted and re-parsed.+ #expect(proj.projectedChapter == "Chapter 1")+ if case .pendingPattern = proj.chapterProvenance {+ // Expected.+ } else {+ Issue.record("Expected pendingPattern for chapter provenance")+ }+ // Assignment was NOT targeted (manual protection).+ #expect(proj.assignmentProtected == true)+ #expect(proj.projectedWorkID == settledWorkID)+ }++ @Test("Historical correction targets assignment independently of chapter")+ func historicalCorrectionAssignmentOnly() throws {+ let pattern = try makePattern()+ let oldPatternID = UUID()++ // Entry has assignment citing old pattern, but chapter is manual.+ let entry = makeEntry(+ captureTitle: "Chapter 1 - Fiction Name | Royal Road",+ chapterTitle: "My Manual Chapter",+ chapterProvenance: try FieldProvenance(kind: .manual),+ workID: nil,+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID, patternVersion: 1),+ firstCapturedAt: Date(timeIntervalSince1970: 1_000)+ )++ let boundaryEntry = makeEntry(+ captureTitle: "Ch 2 - Fiction Name | Royal Road",+ firstCapturedAt: Date(timeIntervalSince1970: 5_000)+ )++ let plan = try TitleProjectionPlanner.planReplacement(+ patternDefinition: pattern,+ entries: [entry, boundaryEntry],+ existingWorks: [],+ patternVersion: 2,+ boundaryEntryID: boundaryEntry.id,+ historicalPatternID: oldPatternID,+ historicalPatternVersion: 1+ )++ let proj = plan.entryProjections.first(where: { $0.entryID == entry.id })!+ // Chapter was NOT targeted (manual protection).+ #expect(proj.chapterProtected == true)+ #expect(proj.projectedChapter == "My Manual Chapter")+ // Assignment was targeted.+ #expect(proj.projectedWorkTitle == "Fiction Name")+ if case .pendingPattern = proj.assignmentProvenance {+ // Expected: re-parsed assignment.+ } else {+ Issue.record("Expected pendingPattern for assignment provenance")+ }+ }++ @Test("Settled unrelated assignment is not retargeted when only chapter cites historical pattern")+ func historicalDoesNotRetargetUnrelatedSettledAssignment() throws {+ let pattern = try makePattern()+ let oldPatternID = UUID()+ let differentPatternID = UUID()+ let settledWorkID = UUID()++ // Chapter cites old pattern; assignment cites a DIFFERENT pattern (settled but not historical target).+ let entry = makeEntry(+ captureTitle: "Chapter 1 - Fiction Name | Royal Road",+ chapterTitle: "Old Chapter",+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: oldPatternID, patternVersion: 1),+ workID: settledWorkID,+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: differentPatternID, patternVersion: 1),+ firstCapturedAt: Date(timeIntervalSince1970: 1_000)+ )++ let boundaryEntry = makeEntry(+ captureTitle: "Ch 2 - Fiction Name | Royal Road",+ firstCapturedAt: Date(timeIntervalSince1970: 5_000)+ )++ let plan = try TitleProjectionPlanner.planReplacement(+ patternDefinition: pattern,+ entries: [entry, boundaryEntry],+ existingWorks: [],+ patternVersion: 2,+ boundaryEntryID: boundaryEntry.id,+ historicalPatternID: oldPatternID,+ historicalPatternVersion: 1+ )++ let proj = plan.entryProjections.first(where: { $0.entryID == entry.id })!+ // Chapter targeted (cites historical pattern) → re-parsed.+ #expect(proj.projectedChapter == "Chapter 1")+ // Assignment NOT targeted (cites different pattern, not manual but not historical).+ // Field-independent: assignment is preserved because only chapter cited the historical pattern.+ if case .preserved(let prov) = proj.assignmentProvenance {+ #expect(prov.patternID == differentPatternID)+ } else {+ Issue.record("Expected preserved assignment provenance with different pattern")+ }+ #expect(proj.projectedWorkID == settledWorkID)+ }++ // MARK: - Re-parse single entry++ @Test("Re-parse projects one entry with correct outcomes")+ func reparseOneEntry() throws {+ let pattern = try makePattern()+ let entry = makeEntry(captureTitle: "Chapter 5 - Fiction Name | Royal Road")+ let workID = UUID()+ let candidates = [+ WorkMatchCandidate(id: workID, lastParsedTitle: "Fiction Name", displayTitle: "Fiction Name"),+ ]++ let plan = try TitleProjectionPlanner.planReparse(+ patternDefinition: pattern,+ entry: entry,+ existingWorks: candidates,+ patternVersion: 2+ )++ #expect(plan.entryProjections.count == 1)+ let proj = plan.entryProjections[0]+ #expect(proj.projectedChapter == "Chapter 5")+ #expect(proj.projectedWorkTitle == "Fiction Name")+ if case .reuse(let id) = proj.workOutcome {+ #expect(id == workID)+ } else {+ Issue.record("Expected reuse")+ }+ }++ // MARK: - Before/after in projection (Defect 6)++ @Test("EntryProjection carries previous state for before/after comparison")+ func projectionCarriesPreviousState() throws {+ let pattern = try makePattern()+ let existingWorkID = UUID()+ let entry = makeEntry(+ captureTitle: "Chapter 1 - Fiction Name | Royal Road",+ chapterTitle: "Old Chapter",+ chapterProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1),+ workID: existingWorkID,+ assignmentProvenance: try FieldProvenance(kind: .pattern, patternID: UUID(), patternVersion: 1)+ )++ let plan = try TitleProjectionPlanner.planInitialTeaching(+ patternDefinition: pattern,+ entries: [entry],+ existingWorks: []+ )++ let proj = plan.entryProjections[0]+ #expect(proj.previousWorkID == existingWorkID)+ #expect(proj.previousChapter == "Old Chapter")+ #expect(proj.projectedChapter == "Chapter 1")+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V1ToV2MigrationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V1ToV2MigrationTests.swiftnew file mode 100644index 0000000..49d5b5b--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V1ToV2MigrationTests.swift@@ -0,0 +1,191 @@+import AsterismV1MigrationSupport+import Foundation+import SwiftData+import Testing+@testable import AsterismCore++@Suite("V1 to V2 developer migration", .serialized)+struct V1ToV2MigrationTests {+ @Test("Representative V1 graph reopens as logically equal V2 with segment mapping")+ func representativeGraph() async throws {+ let directory = try MigrationTemporaryDirectory()+ let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ try createRepresentativeV1Store(at: configuration.legacyStoreURL)+ let sourceBefore = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)++ try await V1ToV2Migrator.migrate(+ configuration: configuration,+ processChecker: AlwaysClosedProcessChecker(),+ verifier: LogicalMigrationVerifier()+ )++ #expect(FileManager.default.fileExists(atPath: configuration.storeURL.path))+ #expect(!FileManager.default.fileExists(atPath: configuration.markerURL.path))+ let reopened = try V2MigrationStore.readSnapshot(+ from: configuration.storeURL,+ capabilities: .m2_0+ )+ #expect(reopened == sourceBefore)+ #expect(reopened.titlePatterns.count == 1)+ guard case .segment(let work, let ignored) = reopened.titlePatterns[0].definition else {+ Issue.record("Expected migrated V1 pattern to use the V2 segment arm")+ return+ }+ #expect(work == (try SegmentRangeSpec(origin: .start, offset: 1, length: 1)))+ #expect(ignored == [try SegmentPositionSpec(origin: .end, offset: 0)])++ let sourceAfter = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)+ #expect(sourceAfter == sourceBefore)+ }++ @Test("Existing V2 destination is refused without changing either store")+ func existingDestinationRefused() async throws {+ let directory = try MigrationTemporaryDirectory()+ let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ try createRepresentativeV1Store(at: configuration.legacyStoreURL)+ try FileManager.default.createDirectory(+ at: configuration.storeURL.deletingLastPathComponent(),+ withIntermediateDirectories: true+ )+ let evidence = Data("existing V2 evidence".utf8)+ try evidence.write(to: configuration.storeURL)+ let sourceBefore = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)++ await #expect(throws: V1ToV2MigrationError.self) {+ try await V1ToV2Migrator.migrate(+ configuration: configuration,+ processChecker: AlwaysClosedProcessChecker(),+ verifier: LogicalMigrationVerifier()+ )+ }++ #expect(try Data(contentsOf: configuration.storeURL) == evidence)+ #expect(try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL) == sourceBefore)+ }++ @Test("Failed verification removes every partial V2 artifact and preserves V1")+ func failedVerificationCleanup() async throws {+ let directory = try MigrationTemporaryDirectory()+ let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ try createRepresentativeV1Store(at: configuration.legacyStoreURL)+ let sourceBefore = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)++ await #expect(throws: V1ToV2MigrationError.self) {+ try await V1ToV2Migrator.migrate(+ configuration: configuration,+ processChecker: AlwaysClosedProcessChecker(),+ verifier: AlwaysFailingMigrationVerifier()+ )+ }++ for url in V2MigrationStore.artifactURLs(for: configuration.storeURL) {+ #expect(!FileManager.default.fileExists(atPath: url.path))+ }+ #expect(!FileManager.default.fileExists(atPath: configuration.markerURL.path))+ #expect(try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL) == sourceBefore)+ }++ @Test("Open runtime process blocks migration before either store is touched")+ func runtimeProcessBlocksMigration() async throws {+ let directory = try MigrationTemporaryDirectory()+ let configuration = LibraryConfiguration(rootDirectory: directory.url, environment: .development)+ try createRepresentativeV1Store(at: configuration.legacyStoreURL)+ let sourceBefore = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)++ await #expect(throws: V1ToV2MigrationError.self) {+ try await V1ToV2Migrator.migrate(+ configuration: configuration,+ processChecker: AlwaysOpenProcessChecker(),+ verifier: LogicalMigrationVerifier()+ )+ }++ #expect(!FileManager.default.fileExists(atPath: configuration.storeURL.path))+ #expect(try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL) == sourceBefore)+ }+}++private struct AlwaysClosedProcessChecker: MigrationRuntimeProcessChecking {+ func requireRuntimeProcessesClosed() throws {}+}++private struct AlwaysOpenProcessChecker: MigrationRuntimeProcessChecking {+ func requireRuntimeProcessesClosed() throws {+ throw V1ToV2MigrationError.runtimeProcessesOpen+ }+}++private struct AlwaysFailingMigrationVerifier: MigrationVerifying {+ func verify(source: LibraryBackupSnapshot, destination: LibraryBackupSnapshot) throws {+ throw V1ToV2MigrationError.verificationFailed(reason: "injected verification failure")+ }+}++private func createRepresentativeV1Store(at url: URL) throws {+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)+ let schema = Schema(versionedSchema: AsterismV1MigrationSupport.AsterismSchemaV1.self)+ let configuration = ModelConfiguration("AsterismV1", schema: schema, url: url, cloudKitDatabase: .none)+ let container = try ModelContainer(+ for: schema,+ migrationPlan: AsterismV1MigrationSupport.AsterismV1MigrationPlan.self,+ configurations: [configuration]+ )+ let context = ModelContext(container)+ let timestamp = Date(timeIntervalSince1970: 1_721_000_000.123)+ let site = AsterismV1MigrationSupport.Site(hostname: "example.com")+ site.modeRaw = SiteMode.taught.rawValue+ let pattern = AsterismV1MigrationSupport.TitlePattern(+ id: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!,+ version: 3,+ isActive: true,+ createdAt: timestamp,+ workAnchor: try SegmentRangeSpec(origin: .start, offset: 1, length: 1),+ junkAnchors: [try SegmentPositionSpec(origin: .end, offset: 0)],+ site: site+ )+ let work = AsterismV1MigrationSupport.Work(+ id: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!,+ displayTitle: "A Work",+ siteHostname: site.hostname,+ timestamp: timestamp+ )+ work.lastParsedTitle = "A Work"+ work.titleProvenanceRaw = TitleProvenance.parsed.rawValue+ let entry = AsterismV1MigrationSupport.Entry(+ id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!,+ captureTitle: "Chapter 1 - A Work | Example",+ captureTitleSourceRaw: CaptureTitleSource.host.rawValue,+ rawURLString: "https://example.com/1",+ hostname: site.hostname,+ entryIdentityKey: "https://example.com/1",+ timestamp: timestamp,+ work: work+ )+ entry.chapterTitle = "Chapter 1"+ entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue+ entry.chapterPatternID = pattern.id+ entry.chapterPatternVersion = pattern.version+ entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue+ entry.workPatternID = pattern.id+ entry.workPatternVersion = pattern.version++ context.insert(site)+ context.insert(pattern)+ context.insert(work)+ context.insert(entry)+ try context.save()+}++private final class MigrationTemporaryDirectory {+ let url: URL++ init() throws {+ url = FileManager.default.temporaryDirectory.appending(+ path: "AsterismMigrationTests-\(UUID())",+ directoryHint: .isDirectory+ )+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+ }++ deinit { try? FileManager.default.removeItem(at: url) }+}
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdnew file mode 100644index 0000000..0ea73bc--- /dev/null+++ b/specs/OVERVIEW.md@@ -0,0 +1,28 @@+# Specs Overview++| Name | Creation Date | Status | Summary |+|------|---------------|--------|---------|+| [Immutable Capture Safety Net](#immutable-capture-safety-net) | 2026-07-17 | Done | Establishes durable local capture, curation, and self-validating backup foundations. |+| [Title Teaching Retroactive Parsing](#title-teaching-retroactive-parsing) | Uncommitted | Done | Adds reader-taught title parsing, retroactive organization, provenance, and actionable capture workflows. |++---++## Immutable Capture Safety Net++Establishes durable local capture, curation, and self-validating backup foundations.++- [decision_log.md](immutable-capture-safety-net/decision_log.md)+- [design.md](immutable-capture-safety-net/design.md)+- [implementation.md](immutable-capture-safety-net/implementation.md)+- [prerequisites.md](immutable-capture-safety-net/prerequisites.md)+- [requirements.md](immutable-capture-safety-net/requirements.md)+- [tasks.md](immutable-capture-safety-net/tasks.md)++## Title Teaching Retroactive Parsing++Adds reader-taught title parsing, retroactive organization, provenance, and actionable capture workflows.++- [decision_log.md](title-teaching-retroactive-parsing/decision_log.md)+- [design.md](title-teaching-retroactive-parsing/design.md)+- [requirements.md](title-teaching-retroactive-parsing/requirements.md)+- [tasks.md](title-teaching-retroactive-parsing/tasks.md)
diff --git a/specs/title-teaching-retroactive-parsing/decision_log.md b/specs/title-teaching-retroactive-parsing/decision_log.mdnew file mode 100644index 0000000..448a8e7--- /dev/null+++ b/specs/title-teaching-retroactive-parsing/decision_log.md@@ -0,0 +1,317 @@+# Decision Log: Title Teaching & Retroactive Parsing++## Decision 1: Use Two Exact Reader-Taught Pattern Forms++**Date**: 2026-07-19+**Status**: accepted++### Context++Sites encode titles either as delimiter-separated segments or as prose around two fields. A built-in Site catalog would not cover reader-specific or changing formats.++### Decision++Support segment patterns and exact phrase templates taught from an immutable capture title. Segment delimiters are the spaced tokens `–`, `—`, `|`, `-`, `·`, `::`, and `:`; phrase templates store exact prefix, separator, suffix, and field order. Matching uses exact Unicode scalars without normalization, while selection and disclosure use the minimum supported OS's locale-independent Unicode properties.++### Rationale++Two small deterministic forms cover the known examples without introducing a general parser language, remote catalog, or bundled Unicode engine.++### Alternatives Considered++- **Site-specific catalog**: Ship known parsing rules — Rejected because the reader must teach unknown and changing Sites.+- **Regular expressions**: Expose a general parser — Rejected because authoring and explaining them is unsuitable for the product.+- **Pinned custom Unicode tables**: Freeze one Unicode release — Rejected because platform grapheme/property APIs are sufficient for this pre-alpha feature.++### Consequences++**Positive:**+- One parser implementation is reused by preview, commit, capture, history, and Re-parse.+- Exact matching remains explainable.++**Negative:**+- Formats outside the two forms fail rather than being guessed.+- Grapheme/property behavior follows the minimum supported OS.++---++## Decision 2: Approve Structural Projections Before One Save++**Date**: 2026-07-19+**Status**: accepted++### Context++Teaching can update a Site, pattern history, many Entries, and Works. The library may change after a preview is displayed.++### Decision++Preview from an immutable structural basis. On confirmation, acquire exclusive access, refetch and compare the basis, rebuild and compare the outcome, then allocate IDs/time and save once. A mismatch returns a refreshed projection and writes nothing.++### Rationale++This directly implements preview fidelity and atomicity without serialized digests, phase journals, or optimistic partial updates.++### Alternatives Considered++- **Trust the displayed preview**: Apply it later without refetching — Rejected because it can overwrite intervening changes.+- **Persist preview tokens or digests**: Validate an encoded approval artifact — Rejected because process-local structural values are sufficient.+- **Multiple saves**: Persist in phases — Rejected because partial teaching state would become observable.++### Consequences++**Positive:**+- Stale confirmations and save failures leave the library unchanged.+- The same commit path serves teaching, articles, Re-parse, and capture.++**Negative:**+- Commit repeats planning under the exclusive lock.+- Large Site projections require bounded, cancellable preview work.++---++## Decision 3: Protect Fields Independently and Retain Provenance++**Date**: 2026-07-19+**Status**: accepted++### Context++A reader may curate chapter and Work assignment independently. Re-teaching must correct derived values without erasing manual choices, and unresolved historical results must remain explainable.++### Decision++Store chapter and assignment provenance separately. Manual provenance protects only its field; intentional unattachment protects assignment only. Retain immutable historical patterns while any Entry cites them, and replay that exact pattern when presenting unresolved provenance.++### Rationale++Field-level protection matches the user's actual choices and makes every derived or unresolved value reproducible from immutable evidence.++### Alternatives Considered++- **Protect the whole Entry after any manual edit**: Simpler flag — Rejected because it blocks valid updates to the other field.+- **Keep only the active pattern**: Delete history — Rejected because historical provenance and unresolved titles would become uninterpretable.+- **Automatically assign when Works later change**: Resolve in the background — Rejected because assignment changes require an explicit user-triggered operation.++### Consequences++**Positive:**+- Manual chapter and assignment choices survive all automatic parsing.+- Re-teaching and Re-parse can target fields precisely.++**Negative:**+- Entry state includes two provenance references and validation rules.+- Referenced patterns cannot be deleted.++---++## Decision 4: Use Exact Work Matching Without Guessing++**Date**: 2026-07-19+**Status**: accepted++### Context++Parsed Work titles may match no Work, one Work, or several same-Site Works. Choosing arbitrarily would silently misassign Entries.++### Decision++Match exact scalar titles against same-Site `lastParsedTitle`, falling back to `displayTitle`. Reuse one match, create one Work per exact title when none exists and an eligible assignment consumes it, and leave assignment unresolved with provenance when multiple matches exist. Derive actionability from field settlement, not parser success.++### Rationale++Exact matching is deterministic and preserves ambiguity for explicit resolution rather than hiding it behind heuristics.++### Alternatives Considered++- **Case-insensitive or normalized matching**: Merge similar titles — Rejected because distinct reader titles could collapse.+- **Choose the first match**: Resolve ambiguity automatically — Rejected because ordering is not semantic evidence.+- **Re-evaluate actionability from the active parser**: Treat parseability as settled — Rejected because settlement depends on stored field state and protection.++### Consequences++**Positive:**+- Preview and commit agree on reuse, creation, and ambiguity.+- Ambiguous assignments remain visible and reproducible.++**Negative:**+- Readers must resolve genuine duplicate-title ambiguity manually.+- Exact matching does not absorb cosmetic title variation.++---++## Decision 5: Scope Re-Teaching by Entry Boundary++**Date**: 2026-07-19+**Status**: accepted++### Context++A replacement rule may represent a new Site format or correction of an old mistake. Applying it blindly to every historical Entry would rewrite unrelated derived history.++### Decision++Replacement targets the triggering Entry and later Entries, plus unsettled earlier fields. The reader may additionally select one historical pattern for correction; both independent nonmanual fields citing that exact UUID/version are then eligible. Articles mode remains one-way in M2.++### Rationale++One boundary handles format changes, while explicit historical correction handles bad prior teaching without a general Sites management UI.++### Alternatives Considered++- **Always reparse all Entries**: Simplest scope — Rejected because format changes should not rewrite valid older history.+- **Arbitrary Entry checklist**: Maximum control — Rejected because it creates a large, error-prone editor.+- **Full Sites settings screen**: Manage every transition — Rejected as later-milestone scope.++### Consequences++**Positive:**+- Replacement scope is predictable and reviewable.+- Historical correction remains explicit.++**Negative:**+- More complex repairs may wait for the later Sites milestone.+- Articles mode cannot be reversed in M2.++---++## Decision 6: Project Future Capture Before Saving++**Date**: 2026-07-19+**Status**: accepted++### Context++The extension must show derived metadata before save, but Site patterns and Works can change while the capture sheet is open.++### Decision++Treat the pending capture as the sole projection target and use the exact displayed title as immutable evidence input. On Save, refetch and rebuild the projection; if it differs, save nothing, display the new projection, and require another Save.++### Rationale++The reader approves exactly what will be stored without adding teaching controls or long-lived model objects to the extension.++### Alternatives Considered++- **Parse only after save**: Hide derived results — Rejected because the reader cannot review them.+- **Save the earlier projection**: Ignore intervening changes — Rejected because it can create inconsistent assignment.+- **Offer teaching in the extension**: Resolve formats during capture — Rejected because extension scope and time are constrained.++### Consequences++**Positive:**+- Capture evidence and derived fields are committed together.+- Changed state cannot silently alter displayed metadata.++**Negative:**+- A changed projection requires a second Save action.+- Capture preparation reads same-Site Work matching data.++---++## Decision 7: Keep Runtime V2-Only with One Developer Migration++**Date**: 2026-07-19+**Status**: accepted++### Context++Asterism is pre-alpha, but the current small V1 library should be retained. Production startup migration, raw SQLite classification, recovery copies, cold restore, and lifecycle diagnostics would dominate implementation without serving a released compatibility promise.++### Decision++The app and extension support only V2. Provide `make migrate-m1-to-m2`, backed by a separate executable and V1 support target not linked into runtime. The command refuses an existing V2 destination, copies the V1 logical graph into fresh V2, maps V1 patterns to the segment form, verifies logical equality, and leaves V1 untouched on failure.++### Rationale++An explicit developer operation preserves current data while keeping all V1 compatibility and recovery code out of product startup.++### Alternatives Considered++- **Automatic startup migration with recovery**: Preserve released-user continuity — Rejected because the product is pre-alpha.+- **Discard V1 data**: Require a clean install — Rejected because the current library is worth retaining.+- **Backup V1 export/import workflow**: Carry data through a file — Rejected because direct logical copying is simpler for the current local store.++### Consequences++**Positive:**+- Runtime lifecycle is current-schema only.+- Current records, relationships, IDs, timestamps, and pattern history are retained.+- Failure cannot consume the only V1 source.++**Negative:**+- Migration is a developer command, not an end-user flow.+- The app and extension must be closed while it runs.++---++## Decision 8: Support Current Backup V2 Only++**Date**: 2026-07-19+**Status**: accepted++### Context++M2 adds tagged patterns and provenance state that Backup V1 cannot represent. Pre-alpha compatibility does not justify maintaining two backup decoders or per-export self-validation machinery.++### Decision++Export and strictly decode Backup V2 only. Encode one coherent V2 snapshot; reject malformed keys, references, forms, and state tuples. Validate correctness in codec tests rather than decoding every export before offering it.++### Rationale++One current format protects data structure without turning an unreleased wire format into a permanent compatibility burden.++### Alternatives Considered++- **Retain Backup V1 decode**: Support old files — Rejected because V1 carry-forward is handled by the developer command.+- **Decode every export before sharing**: Self-audit each file — Rejected because deterministic codec tests provide the needed assurance with less runtime work.+- **Use a permissive decoder**: Ignore unknown data — Rejected because silent loss or reinterpretation is worse than rejection.++### Consequences++**Positive:**+- Backup code has one schema and one validation path.+- Tagged pattern state remains explicit.++**Negative:**+- Backup V1 files are unsupported.+- Backup V2 changes before release may require another explicit format decision.++---++## Decision 9: Deliver Through Four Vertical Gates++**Date**: 2026-07-19+**Status**: accepted++### Context++Schema, segment teaching, articles mode, and phrase teaching can be integrated and tested incrementally. A horizontal rollout would leave partially connected model, repository, and UI layers.++### Decision++Deliver M2.0 as V2 schema/current backup and migration tooling, M2.1 as segment teaching end-to-end, M2.2 as articles mode, and M2.3 as phrase teaching plus cross-form replacement. Each gate passes its applicable behavior, accessibility, and performance requirements.++### Rationale++Vertical gates keep every merged stage operable and prevent later forms from complicating the first complete teaching path.++### Alternatives Considered++- **One large milestone merge**: Implement everything together — Rejected because integration failures become harder to isolate.+- **Layer-by-layer rollout**: Finish all storage before UI — Rejected because no intermediate stage proves the user journey.+- **Phrase before articles**: Prioritize parser breadth — Rejected because articles mode is independent and simpler to complete after segment teaching.++### Consequences++**Positive:**+- Each gate has a complete observable capability.+- Segment behavior becomes the parity baseline for phrase support.++**Negative:**+- Gate flags and tests must keep later features absent until enabled.+- Some shared abstractions are introduced before both forms use them.++---
diff --git a/specs/title-teaching-retroactive-parsing/design.md b/specs/title-teaching-retroactive-parsing/design.mdnew file mode 100644index 0000000..88e5898--- /dev/null+++ b/specs/title-teaching-retroactive-parsing/design.md@@ -0,0 +1,214 @@+# Design: Title Teaching & Retroactive Parsing++## 1. Overview++M2 adds Site-level segment and phrase patterns, previewed retroactive parsing, re-teaching, articles mode, actionable Recent presentation, provenance, and single-Entry Re-parse. Runtime supports only Schema V2; current pre-alpha V1 data is carried forward by one explicit developer command rather than app lifecycle migration.++## 2. Invariants++- `LibraryRepository` is the only persistence writer and never exposes SwiftData models.+- Capture evidence (`captureTitle`, title source, URLs, identity, UUID, and capture times) is immutable.+- Chapter and Work assignment are planned and protected independently. Manual provenance protects only its field; intentional unattachment protects assignment only.+- Preview is pure. Commit refetches the same basis under exclusive access, rebuilds the outcome, and saves once or not at all.+- Text matching is exact Unicode-scalar equality without normalization, trimming, case folding, or locale-sensitive comparison.+- Pattern UUIDs, new Work UUIDs, pending Entry UUIDs, and the operation timestamp are allocated only after preview equality succeeds.+- Runtime app and extension targets contain no V1 reader, classifier, migration, repair, restore, or Backup V1 path.++## 3. Integration points++| Existing location | Change |+|---|---|+| `Models.swift`, `DomainEnums.swift`, `ValueObjects.swift` | Introduce Schema V2 persisted models and the tagged segment/phrase definition fields. Keep V1 declarations only in migration-tool support. |+| `LibraryRepository.swift` | Add teaching snapshots, projections, commit operations, Re-parse, actionable presentation, and V2-only app/extension opening. |+| `LibraryProviding.swift`, `Snapshots.swift`, `RepositoryDrafts.swift` | Add immutable teaching, provenance, capture-projection, and Recent DTOs. |+| `ShareTransport.swift`, `CaptureViewModel.swift` | Project metadata from the effective displayed title and require another Save when the projection changes. |+| `AppLibraryModel.swift`, `RecentView.swift`, `EntryDetailModel/View.swift` | Add actionable filtering, teaching entry points, provenance disclosure, and Re-parse. |+| `CaptureView.swift`, `ShareViewController.swift` | Show projected metadata; keep teaching out of the extension. |+| `BackupExporter.swift`, new `BackupV2*` files | Export and strictly decode current Backup V2 only. |+| New `AsterismV1MigrationSupport` and `AsterismMigrationTool` package targets | Implement `make migrate-m1-to-m2`; neither target is linked into the app or extension. |++## 4. Architecture++```text+App / Extension view models+ │ immutable requests and snapshots+ ▼+ LibraryRepository actor ── CrossProcessLibraryLock ── Schema V2+ │+ ├── TitleParser (pure)+ └── ProjectionPlanner (pure)++make migrate-m1-to-m2+ V1 read model ── logical snapshot ── V2 writer ── equality check+```++`TitleParser` and `ProjectionPlanner` have no storage dependency. `LibraryRepository` converts persisted models to immutable aggregates, invokes the planner, validates current V2 tuples, applies an approved plan, and saves. View models own UI generations and drafts but do not derive parser, provenance, Work matching, or actionability decisions.++## 5. Schema V2++### 5.1 Pattern representation++`TitlePattern` retains UUID, Site-local positive version, active state, creation time, and Site relationship. Its immutable definition is one tagged arm:++```swift+enum PatternDefinition: Sendable, Equatable {+ case segment(work: SegmentRangeSpec, ignored: [SegmentPositionSpec])+ case phrase(prefix: String, separator: String, suffix: String, order: FieldOrder)+}+```++Persisted V2 fields include a form tag and nullable arm-specific values. Validation accepts exactly one complete segment arm through M2.2 and exactly one complete segment or phrase arm beginning with M2.3; segment allows zero ignored anchors. Site mode and pattern relationships follow Requirement 6.13. Chapter and assignment values/provenance follow Requirements 3.14 and 3.15. Detection of any invalid live tuple aborts the current operation and reports the current library unavailable; no repair is attempted.++One injected `M2Capabilities` value defines the current gate: M2.0 enables V2/Backup only, M2.1 adds segment teaching, M2.2 adds articles, and M2.3 adds phrase teaching/cross-form replacement. Repository APIs, live validation, Backup V2 decoding, and UI action registration consult the same value. Development and Personal use the same gate value for a given milestone; tests instantiate all four and prove later forms/actions reject or remain absent.++### 5.2 Immutable DTOs++Teaching bases contain the Site, retained patterns, all same-Site Entries, and same-Site Works used by matching or display. Strings that participate in parsing, matching, or stale detection compare by ordered scalar values. Collections are sorted by stable IDs before structural comparison; replacement targeting separately uses `firstCapturedAt`, then UUID, and Recent retains its existing `lastSharedAt` order.++## 6. Text algorithms++### 6.1 Segment tokenizer++`DelimiterTokenizer` recognizes these spaced tokens, matching the longest token first:++`–`, `—`, `|`, `-`, `·`, `::`, `:`++A token is a delimiter only when the complete token has at least one `M2 White_Space` scalar on each side. Its span includes maximal adjacent whitespace. Discovery precedes splitting; spans sharing whitespace reject as `unsupportedAdjacentDelimiters`. Ordered nonoverlapping spans produce exact intervening source substrings, and an empty or blank segment rejects. Thus `A :: B` uses one double-colon delimiter, while unspaced punctuation remains text.++The editor assigns Chapter, Work, or Ignore. Confirmation requires one contiguous nonblank Chapter range and one disjoint contiguous nonblank Work range; all others are Ignore. Work and ignored positions anchor to the nearer edge using zero-based offsets, with ties choosing the end. Application resolves all anchors before extracting and fails for missing or duplicate positions, overlap, noncontiguous chapter remainder, or blank output. Extracted ranges preserve their exact internal delimiters.++### 6.2 Phrase parser++Phrase authoring stores exact prefix, separator, suffix, and field order from two disjoint selections. Selection boundaries use the minimum supported OS's locale-independent extended-grapheme boundaries; matching itself uses scalar offsets and exact scalar equality.++Application consumes prefix only at the start and suffix only at the end, rejects overlap, counts every separator start in the bounded interior including overlaps, and requires exactly one. Candidate confirmation runs this same parser against the teaching example and must reproduce both selected scalar sequences. The same parser serves preview, commit reconstruction, capture, re-teaching, historical replay, and Re-parse.++Accessible summaries escape `M2 White_Space`, platform `Cc`/`Cf`, and platform default-ignorable scalars as uppercase `\u{HEX}`, escape backslash as `\\`, and render other scalars literally.++### 6.3 Article cleaning++A junk rule stores one or more contiguous end-segment positions from the segment tokenizer. Candidate validation disables confirmation when applying the selection to the immutable teaching example would leave no nonblank prefix. For other projected or future titles, cleaning removes the selected ending and connecting delimiter only when a nonblank prefix remains; otherwise it returns `captureTitle`. Cleaning is presentation-only and never feeds another parse or changes capture evidence.++## 7. Projection and commit++### 7.1 Projection contract++```swift+struct ProjectionContract<Basis: Sendable & Equatable,+ Request: Sendable & Equatable,+ Outcome: Sendable & Equatable>: Sendable {+ let basis: Basis+ let request: Request+ let outcome: Outcome+}+```++Requests carry the exact candidate definition and operation scope. Outcomes contain complete per-Entry before/after chapter, assignment, provenance, protection reason, candidate parse result/failure, Work reuse/ambiguity/create intent, display result, and actionability. Re-teaching outcomes additionally retain separate active-pattern and candidate-pattern parse results/failures for every frozen Entry. An ambiguous outcome carries the complete matching Work snapshots ordered by UUID, not only an ambiguity flag. Prospective Works are keyed by exact parsed title, not a future UUID. Initial teaching previews pattern version 1; replacement previews checked `max(retained version) + 1`.++Each accepted UI edit publishes its generation acknowledgement immediately. Preview work may be cancelled, but only the latest generation may publish a failure or complete projection.++### 7.2 One commit path++Teaching, re-teaching, articles mode, Re-parse, and projected capture use this repository sequence:++1. Acquire the exclusive cross-process lock and create a fresh context.+2. Refetch the current basis and compare it with the approved basis.+3. Rebuild the outcome from the current basis and request; compare it with the approved outcome.+4. On either mismatch, save nothing and return a fresh projection requiring confirmation.+5. Validate the planned V2 graph.+6. Allocate required UUIDs and one operation timestamp, apply the plan, and call the injected save strategy exactly once.+7. Discard the context on failure and report that nothing was saved.++No serialized projection digest, migration manifest, phase journal, or recovery protocol is introduced.++### 7.3 Shared planning rules++- Initial teaching applies to every Site Entry but never overwrites manual fields or intentionally unattached assignments. A parse failure preserves existing derived fields.+- Replacement uses the trigger-and-later boundary plus unsettled earlier fields. Optional historical correction targets both independent nonmanual fields that cite the selected exact pattern UUID/version.+- Targeted parse failure clears only old pattern-derived values and provenance. Manual values remain.+- Work matching compares exact parsed title with same-Site nonblank `lastParsedTitle`, otherwise `displayTitle`. One match reuses, multiple matches remain unresolved with pattern provenance, and zero matches create one Work per exact title only when an eligible assignment consumes it. Every reused/created Work outcome includes its before/after metadata: set `lastParsedTitle` to the exact parsed title, update `displayTitle` only when its title provenance is parsed, and preserve a manual display title. These fields are part of the basis and stale comparison.+- Pattern definitions and identity remain immutable; replacement deactivates the old active pattern and creates max+1. Historical patterns referenced by provenance remain.+- Articles transition deactivates the active pattern, preserves manual chapter and assignment fields, clears pattern-derived fields and provenance, marks every otherwise-unassigned Entry intentionally unattached, and makes all Site Entries non-actionable. Future article captures store chapter absent/none, Work absent/none, and intentionally-unattached true while applying only presentation cleaning.+- Actionability is derived from valid field settlement under Requirement 5.1, not current parser success.++## 8. User flows++### 8.1 Teaching and re-teaching++`TeachingViewModel` holds editor state, generation, latest projection, stale refresh, commit state, and errors. Segment and phrase editors feed the same projection API. Re-teaching is available from taught Entry detail and actionable Recent rows; articles mode is an alternative and remains one-way in M2.++Preview rows show both fields independently, including protected, cleared, unresolved, reused, or created outcomes. Re-teaching rows show active and candidate parse results side by side across every frozen old/new-format title; ambiguity lists every matching Work from the ordered outcome. Cancel, stale confirmation, overflow, invalid plans, and save failure make no writes.++### 8.2 Recent, provenance, and Re-parse++The repository publishes Recent rows and actionable count from one coherent read. The actionable filter preserves existing ordering/grouping and clears when its count reaches zero. Unresolved assignment presentation replays the exact retained pattern; replay failure is a library-integrity failure rather than fabricated output.++Entry detail adds a collapsed source/provenance disclosure and mode-appropriate Teach, Re-teach, or Re-parse actions. Re-parse uses the same projection contract, targets one Entry, and shows every affected Work before confirmation.++### 8.3 Future capture++Capture preparation retains existing title-source precedence. Projection uses the exact currently displayed title and treats the pending Entry as the sole target. A taught-Site parse failure projects and saves chapter absent/none, Work absent/none, intentionally-unattached false, and an actionable Entry. Ambiguous Work matching keeps the parsed chapter/provenance, stores Work absent with pattern assignment provenance, and remains actionable. Articles uses the future-capture state in §7.3.++Save refetches Site/pattern/Work state and rebuilds the complete Entry and affected-Work outcome; if it changed, no write occurs and another Save is required. Success stores immutable evidence and derived fields together in one repository save. The extension never offers teaching.++## 9. V2 runtime opening and V1 carry-forward++### 9.1 Runtime++The containing app owns V2 creation and writes a readiness marker only after opening the current schema successfully. If V2 is absent but the legacy V1 path exists, the app reports the current library unavailable rather than creating an empty V2 over the migration opportunity. The extension opens only an existing ready V2 store; otherwise it offers `Open Asterism`. Neither runtime target links `AsterismV1MigrationSupport`.++### 9.2 Developer migration command++`make migrate-m1-to-m2` invokes `AsterismMigrationTool`. The tool requires the app and extension to be closed, refuses an existing V2 destination, acquires the library lock, opens V1 through the frozen V1 model, and materializes one logical snapshot. It creates a separate V2 destination, maps every V1 pattern to the segment arm with phrase fields absent, and preserves all other stored values, relationships, UUIDs, and timestamps.++The tool closes and reopens V2 and compares counts, IDs, fields, relationships, and pattern mapping with the source snapshot. On success it exits without writing runtime readiness; the containing app owns the first validated V2 open and readiness marker. On any failure the tool exits nonzero, removes the incomplete V2 destination, and never saves to or deletes V1. This command is development tooling, not app startup behavior.++## 10. Backup V2++Backup export takes one coherent repository snapshot and encodes all V2 records and relationships. The strict V2 decoder rejects unknown, missing, or duplicate keys, invalid references, unknown enums/forms, forms unavailable under the injected `M2Capabilities`, and invalid closed tuples. Export does not decode its own output or maintain Backup V1 compatibility; correctness is covered by codec round-trip and malformed-input tests.++## 11. UI and accessibility++New screens reuse existing navigation, Form, typography, amber teaching treatment, appearance handling, and minimum-hit-target constants. Color is supplemental to text/symbol state.++Phrase selection has a non-drag path: separate Chapter and Work controls choose and adjust start/end by one platform extended grapheme cluster, with focusable controls and accessibility actions announcing role, boundary, and selected text. VoiceOver, Switch Control, Full Keyboard Access, Dynamic Type, light/dark appearance, and Reduce Transparency are covered by UI journeys.++## 12. Errors++| Failure | Outcome |+|---|---|+| Per-title parse failure | Show the row reason; continue the complete preview. |+| Invalid candidate | Disable confirmation. |+| Stale projection or changed capture projection | Write nothing; show refreshed outcome and require confirmation. |+| Save failure | Discard the fresh context and retain the draft/projection. |+| Invalid live V2 tuple or open failure | Abort the operation, write nothing, report current library unavailable. |+| Extension not ready | Do not open or write; offer `Open Asterism`. |+| Invalid Backup V2 | Reject it. |+| Migration failure | Remove incomplete V2, leave V1 unchanged, exit nonzero. |++## 13. Testing strategy++- **Parser properties:** accepted tokenization reconstructs the source; all seven tokens obey spacing; `::` wins over `:`; overlapping spans reject; phrase output is exact nonblank source content; separator overlaps count.+- **Planner properties:** identical basis/request gives identical outcome; protected fields never change; one exact Work group is created per title; stale contracts and injected save failures write nothing and make at most one save attempt.+- **Repository tests:** initial teaching, both replacement directions, articles, Re-parse, actionable presentation, historical replay, projected capture, and changed-projection Save.+- **Migration tests:** representative V1 graph including segment history and every provenance tuple; existing-destination refusal; complete logical equality; failure leaves V1 and any existing destination unchanged; runtime binaries do not link V1 migration support.+- **Backup tests:** V2 round-trip plus unknown/duplicate/missing key, invalid reference, invalid form, and invalid tuple cases.+- **UI tests:** full segment/phrase/articles journeys, stale reconfirmation, provenance, actionable filtering, accessibility technologies, appearances, Dynamic Type, and 44-point targets.+- **Performance:** generate repeatable 20,000-Entry/2,000-Work data with one 5,000-Entry Site. Use Requirement 10's physical-device protocol and signposts for 100 ms acknowledgement, 1 s final projection, and 2 s Recent p95 targets; no checked-in multi-megabyte golden package is required.++## 14. Requirement traceability (104/104)++| Requirements | Design |+|---|---|+| 1.1–1.9 | §§6.1, 8.1, 13 segment teaching/tokenization |+| 1.10–1.18 | §§6.2, 11, 13 phrase form, exact matching, platform graphemes, summaries |+| 2.1–2.8 | §7 projection generations, complete outcomes, stale confirmation |+| 3.1–3.15 | §§5, 7.2–7.3 initial teaching, matching, atomicity, closed field tuples |+| 4.1–4.8 | §8.3 projected future capture and second Save |+| 5.1–5.9 | §§7.3, 8.2 actionability, Recent, unresolved replay |+| 6.1–6.13 | §§5, 7.3, 8.1 replacement scope/history/forms |+| 7.1–7.10 | §§6.3, 8.1 articles projection, cleaning, future capture |+| 8.1–8.6 | §8.2 provenance disclosure and Re-parse |+| 9.1–9.4 | §§9–10 developer migration, V2-only runtime/backup |+| 9.5–9.13 | §§5, 9, 11–13 accessibility, readiness, forms, gate coverage |+| 10.1–10.4 | §13 repeatable scale data, signposts, and thresholds |
diff --git a/specs/title-teaching-retroactive-parsing/requirements.md b/specs/title-teaching-retroactive-parsing/requirements.mdnew file mode 100644index 0000000..d2154ea--- /dev/null+++ b/specs/title-teaching-retroactive-parsing/requirements.md@@ -0,0 +1,193 @@+# Requirements: Title Teaching & Retroactive Parsing++## Introduction++Milestone 2 teaches Asterism how each Site encodes Work and chapter titles, then applies that knowledge to past and future captures. Readers can preview a rule before committing it, correct a rule without overwriting manual curation, and identify captures that still need attention. Immutable capture evidence remains the source from which every derived value can be reproduced.++## Out of Scope++- URL-identity teaching, identity-key backfill, re-share editing, and confirmed Work URLs remain M3 scope.+- Work Merge and title-collision reconciliation remain M3 scope.+- CloudKit synchronization and duplicate handling remain M4 scope.+- The general Sites settings screen, switching an articles-mode Site back to taught mode, search, Markdown export, and final visual polish remain M5 scope.+- Multiple active patterns, automatic pattern recognition, and path-scoped article rules remain post-v1 scope.+- A shipped or remotely maintained catalog of Site-specific phrases is not included; phrase templates are taught by the reader.+- Automatic startup migration, in-app V1 recovery or repair, Backup V1 decoding, and production compatibility with pre-M2 stores are not included; existing development data is carried forward only by one explicit developer migration command.+- Backup restore/import UI is not included; Backup V2 decoding is used for current-format validation.+- Per-entry editing of immutable capture titles is not included.++## Requirements++### 1. Teaching Example and Candidate Rule++**User Story:** As a reader, I want to identify the Work, chapter, and irrelevant parts of a captured title, so that Asterism can learn the Site’s title format.++**Acceptance Criteria:**++1. <a name="1.1"></a>WHEN teaching begins from an Entry, the system SHALL use that Entry’s immutable capture title as the example and SHALL limit the candidate rule to the Entry’s Site. +2. <a name="1.2"></a>The system SHALL recognize a delimiter candidate only at en dash, em dash, vertical bar, hyphen-minus, middle dot, colon, or double-colon (`::`) punctuation having at least one `M2 White_Space` scalar immediately on both sides, and SHALL define its span as the punctuation plus the maximal contiguous `M2 White_Space` run on each side. Ordered nonoverlapping spans SHALL split the title into the exact intervening source substrings; tokenization SHALL fail if it produces an empty or `blank` leading, trailing, or interior segment. +3. <a name="1.3"></a>The teaching surface SHALL present every segment as a selectable chip whose role can be changed among chapter, Work, and ignore, with a visible legend and a plain-language description of the candidate rule. +4. <a name="1.4"></a>The system SHALL allow confirmation only when the example contains exactly one contiguous chapter range and one disjoint contiguous Work range that each contain a Unicode scalar outside `M2 White_Space`, with every remaining segment marked ignore. +5. <a name="1.5"></a>WHEN deriving a candidate rule, the system SHALL count the segments before and after the selected Work range or ignored segment, describe its position from the start only when fewer segments precede it than follow it, otherwise describe it from the end, use a zero-based offset, preserve the Work-range length, and disclose the chosen edge and position in the plain-language rule; equal distances SHALL use the end edge. +6. <a name="1.6"></a>WHEN applying a candidate rule, the system SHALL report a parse failure if an anchored position is absent, two distinct ignored anchors resolve to the same segment, Work and ignored positions overlap, the remaining chapter segments are not one contiguous range, or the resulting Work or chapter title contains no Unicode scalar outside `M2 White_Space`. +7. <a name="1.7"></a>WHEN producing a Work or chapter title from adjacent segments, the system SHALL preserve their text and original intervening delimiters while excluding delimiters outside the selected range. +8. <a name="1.8"></a>Given `Chapter Name - Fiction Name | Royal Road` with chapter, Work, and ignore roles respectively, the candidate SHALL produce chapter `Chapter Name` and Work `Fiction Name`; the spaced en-dash title `Chapter Name – Fiction Name | Royal Road` SHALL produce the same fields, while the unspaced en dash in `2020–2021 - Fiction Name | Royal Road` SHALL remain part of chapter `2020–2021`; given `Part One — Chapter 12 - Fiction Name | Royal Road`, that rule SHALL produce chapter `Part One — Chapter 12` and Work `Fiction Name`; given `Fiction Name | Royal Road`, it SHALL fail because no chapter remains. +9. <a name="1.9"></a>IF two delimiter-candidate spans share any `M2 White_Space` scalar, THEN the system SHALL reject that title as having unsupported adjacent delimiters rather than choosing a tokenization order; therefore `A - | B` SHALL be rejected. +10. <a name="1.10"></a>The teaching surface SHALL offer segment-pattern teaching and learned phrase-template teaching without relying on a built-in Site-specific phrase catalog. +11. <a name="1.11"></a>In phrase-template teaching, the reader SHALL select exactly one contiguous chapter substring and one disjoint contiguous Work substring in source-title order, each selection SHALL begin and end at an `M2 Extended Grapheme Cluster` boundary, and each SHALL contain a Unicode scalar outside `M2 White_Space`. +12. <a name="1.12"></a>The system SHALL derive a phrase template containing the exact literal prefix before the first selected field, the exact literal separator between the selected fields, the exact literal suffix after the second field, and the chapter-or-Work role and order of both fields; the separator SHALL contain at least one Unicode scalar outside `M2 White_Space`. +13. <a name="1.13"></a>WHEN applying a phrase template, the system SHALL compare exact Unicode-scalar sequences without normalization, consume the literal prefix only at the title start and literal suffix only at the title end, count every separator starting offset in the bounded interior including overlapping occurrences, require exactly one such occurrence, and extract the two fields around it in source-title and taught-role order. +14. <a name="1.14"></a>A phrase-template parse SHALL fail when a required literal does not match, the separator has other than exactly one starting offset in the bounded interior, the consumed literal regions overlap, or either extracted field contains no Unicode scalar outside `M2 White_Space`; the same matcher SHALL govern preview, bulk parsing, capture, re-teaching, unresolved replay, and single-entry Re-parse. +15. <a name="1.15"></a>Given `Check out Episode 10 from The Regressor Creates Everything on Tapas.` with chapter `Episode 10` and Work `The Regressor Creates Everything` selected, the template SHALL learn prefix `Check out `, separator ` from `, suffix ` on Tapas.`, chapter-before-Work order, and SHALL parse the corresponding Episode 11 title without a hard-coded Tapas rule. +16. <a name="1.16"></a>The system SHALL prevent phrase-template confirmation unless applying the candidate to its immutable teaching example reproduces the exact selected chapter and Work Unicode-scalar sequences. +17. <a name="1.17"></a>Before confirmation, the teaching surface SHALL disclose the candidate pattern form, field order, and exact prefix, separator, and suffix literals in an accessible summary: every scalar in `M2 White_Space`, General Category `Cc`, General Category `Cf`, or `M2 Default_Ignorable` SHALL render once as uppercase unpadded `\u{HEX}`, literal backslash SHALL render as `\\`, and every other scalar SHALL render literally; overlapping membership SHALL produce one escape. +18. <a name="1.18"></a>For every M2 criterion, `M2 White_Space` SHALL mean exactly U+0009–U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000–U+200A, U+2028, U+2029, U+202F, U+205F, and U+3000. General Categories `Cc` and `Cf`, default-ignorable membership, and extended-grapheme-cluster boundaries SHALL use the minimum supported OS’s locale-independent Unicode scalar and grapheme properties without normalization; `blank` SHALL mean containing no scalar outside `M2 White_Space`, and `nonblank` SHALL mean containing at least one scalar outside it. ++### 2. Live Preview and Confirmation++**User Story:** As a reader, I want to preview a candidate rule against my existing captures, so that I can see mistakes before changing my library.++**Acceptance Criteria:**++1. <a name="2.1"></a>WHEN an enabled teaching form’s segment roles, phrase selections, or pattern form change, the system SHALL update that candidate’s result for every stored Entry from the same Site without modifying the library. +2. <a name="2.2"></a>For each Site Entry, the preview SHALL identify whether it is targeted and show the final projected chapter value and provenance, Work assignment and provenance, and Work reuse or creation, or identify why the title cannot be parsed. +3. <a name="2.3"></a>The preview SHALL distinguish fields that will change, fields protected by manual provenance or intentional unattachment, fields that will be cleared, and assignments that will remain unresolved. +4. <a name="2.4"></a>WHEN multiple existing Works match a candidate Work title, the preview SHALL identify every matching Work and SHALL NOT choose one automatically. +5. <a name="2.5"></a>WHEN articles mode is previewed, the preview SHALL show the exact final assignment, chapter, provenance, intentional-unattachment, and display-title result for every Site Entry, including preserved manual fields. +6. <a name="2.6"></a>WHEN the reader cancels teaching, the system SHALL leave the Site, its pattern history, Entries, and Works unchanged. +7. <a name="2.7"></a>For every teaching form, articles-mode change, and single-entry Re-parse enabled under Requirement 9.13, IF any value affecting displayed outcomes or commit preconditions differs between the frozen projection state and one coherent current library state, THEN the system SHALL write nothing, recalculate every outcome, and require confirmation again. +8. <a name="2.8"></a>The system SHALL require explicit confirmation of a complete current projection before committing any teaching rule, articles-mode change, or single-entry Re-parse enabled under Requirement 9.13. ++### 3. Initial Teaching and Retroactive Parsing++**User Story:** As a reader, I want one confirmed rule applied to earlier captures, so that my existing notes become organized without recapturing them.++**Acceptance Criteria:**++1. <a name="3.1"></a>WHEN the first rule for an untaught Site is confirmed, the system SHALL mark the Site taught, retain the confirmed rule as its sole active pattern with site-local pattern version 1, and apply it to every Entry from that Site. +2. <a name="3.2"></a>WHEN an Entry parses successfully and its chapter provenance is not manual, the system SHALL replace its chapter title and record the confirmed pattern identity and site-local pattern version as chapter provenance. +3. <a name="3.3"></a>An Entry’s Work assignment SHALL be eligible for automatic change only when its assignment provenance is not manual and it is not intentionally unattached. +4. <a name="3.4"></a>For Work matching, the system SHALL compare the parsed Work title with each same-Site Work’s nonblank last-parsed title when present and otherwise its display title, using exact case- and whitespace-sensitive equality without normalization. +5. <a name="3.5"></a>WHEN exactly one Work matches and the Entry’s assignment is eligible, the system SHALL assign that Work and record the confirmed pattern identity and site-local pattern version as assignment provenance. +6. <a name="3.6"></a>WHEN no Work matches and at least one targeted Entry producing that parsed Work title has an eligible assignment, the system SHALL create one parsed Work and assign every eligible targeted Entry producing that title to it. +7. <a name="3.7"></a>WHEN multiple Works match and the Entry’s assignment is eligible, the system SHALL leave its Work unset, record the producing pattern identity and site-local pattern version as assignment provenance, and keep it actionable; after reload, the system SHALL reproduce the candidate Work title from the immutable capture title and that retained historical pattern. +8. <a name="3.8"></a>WHEN a parsed Work is created or reused, the system SHALL set its last-parsed title to the parsed Work title, update its display title only when its title provenance is parsed, and preserve a manually overridden display title. +9. <a name="3.9"></a>WHEN an Entry does not satisfy the candidate rule during initial teaching, the system SHALL preserve its existing derived fields and SHALL recompute whether it remains actionable under Requirement 5.1. +10. <a name="3.10"></a>Automatic parsing SHALL leave a manual chapter field unchanged, leave a manual Work assignment unchanged, and SHALL NOT create a Work solely for Entries whose assignments are all protected. +11. <a name="3.11"></a>WHEN teaching succeeds, the system SHALL preserve every Entry’s capture title and source, URLs, note, rating, first-captured time, last-shared time, identity fields, UUID, and manually curated field values. +12. <a name="3.12"></a>IF any part of pattern activation or retroactive parsing fails, THEN the system SHALL persist none of the Site, pattern, Entry, or Work changes and SHALL explain that the library was unchanged. +13. <a name="3.13"></a>After a title pattern is created, the system SHALL keep its UUID, site-local version, form-specific definition, Site relationship, and creation time immutable, SHALL allow only its active state to change, and SHALL retain every historical pattern referenced by Entry provenance. +14. <a name="3.14"></a>A live schema-version-2 or valid Backup V2 chapter state SHALL be exactly one of: absent value with provenance none and no pattern references; nonblank value with manual provenance and no pattern references; or, only outside articles mode, nonblank value with pattern provenance resolving to one exact retained pattern UUID and site-local version belonging to the Entry’s Site. Every other chapter value, provenance, companion-reference, Site, or mode tuple SHALL be a library-integrity failure. +15. <a name="3.15"></a>A live schema-version-2 or valid Backup V2 Work-assignment state SHALL be exactly one of: outside articles mode, Work absent, provenance none, no pattern references, and intentionally-unattached false; Work absent, provenance manual, no pattern references, and intentionally-unattached true; same-Site Work present, provenance manual, no pattern references, and intentionally-unattached false; outside articles mode, same-Site Work present, pattern provenance resolving to one exact retained pattern UUID and site-local version belonging to the Entry’s Site, and intentionally-unattached false; outside articles mode, Work absent, that same-Site valid pattern provenance, and intentionally-unattached false; or, only for an articles-mode Site, Work absent, provenance none, no pattern references, and intentionally-unattached true. Every other Work relationship, provenance, companion-reference, Site, mode, or intentional-unattachment tuple SHALL be a library-integrity failure. ++### 4. Parsing Future Captures++**User Story:** As a reader, I want captures from a taught Site parsed when I save them, so that new notes arrive organized without another teaching step.++**Acceptance Criteria:**++1. <a name="4.1"></a>WHEN capture preparation finds a taught Site with an active pattern and the title parses successfully, the extension SHALL show the projected Work and chapter metadata before saving. +2. <a name="4.2"></a>WHEN a future capture parses successfully, the system SHALL treat that pending capture as the sole target, apply the exact matching, reuse, creation, ambiguity, and Work-metadata outcomes in Requirements 3.4–3.8, and atomically save the displayed immutable evidence, chapter, Work outcome, and per-field provenance. +3. <a name="4.3"></a>WHEN a taught Site’s active pattern cannot parse a capture title, the extension SHALL display and save the raw title with chapter absent and chapter provenance none, Work absent and assignment provenance none, intentionally-unattached false, and SHALL make the saved Entry actionable in the app. +4. <a name="4.4"></a>WHEN Work matching for a parsed capture is ambiguous, the system SHALL save its derived chapter and chapter provenance, leave its Work unset, record the active pattern identity and site-local pattern version as assignment provenance, and make the Entry actionable in the app. +5. <a name="4.5"></a>The extension SHALL NOT offer title teaching or re-teaching during capture. +6. <a name="4.6"></a>IF saving a parsed capture fails, THEN the extension SHALL retain any manually entered title, note, and rating, explain that nothing was saved, and allow another save attempt. +7. <a name="4.7"></a>WHEN Save is selected, the extension SHALL re-evaluate the effective pending title, Site mode, active pattern, and every same-Site Work field used for matching; IF the projected metadata differs from what is displayed, THEN it SHALL refresh the projection and require another Save action, otherwise it SHALL commit exactly the displayed projection. +8. <a name="4.8"></a>The extension SHALL define the effective pending title as the exact title currently displayed to the reader, SHALL recompute projection whenever that title changes, and SHALL save the identical string as immutable capture title when the projection is committed. ++### 5. Actionable Inbox and Recent Presentation++**User Story:** As a reader, I want captures needing title attention surfaced within Recent, so that I can teach formats without maintaining a separate inbox.++**Acceptance Criteria:**++1. <a name="5.1"></a>An Entry outside articles mode SHALL be actionable exactly when either field is unsettled: its chapter is unsettled only in the valid absent-value, provenance-none state from Requirement 3.14; its assignment is unsettled when Work is nil, assignment provenance is not manual, and it is not intentionally unattached. Manual provenance SHALL settle only its corresponding field, and the active pattern’s ability to parse the immutable title SHALL NOT independently change actionability. +2. <a name="5.2"></a>WHEN one or more actionable Entries exist, Recent SHALL show a banner with their count; selecting the banner SHALL filter the existing Recent list to those Entries. +3. <a name="5.3"></a>WHEN the final actionable Entry is resolved, Recent SHALL remove the banner and clear the actionable-only filter. +4. <a name="5.4"></a>Recent SHALL distinguish actionable rows using the style guide’s amber teaching treatment and an inline Teach action while retaining the existing row interaction. +5. <a name="5.5"></a>WHEN an Entry has a Work assignment and chapter title, its Recent row SHALL show that Work’s display title and the chapter title. +6. <a name="5.6"></a>WHEN an actionable Entry has no Work and valid pattern assignment provenance, its Recent row SHALL reproduce the candidate Work title from the immutable capture title and that exact retained pattern, show it with the chapter title when available, and identify the assignment as unresolved; IF the retained pattern cannot reproduce the title, THEN the system SHALL report a library-integrity failure without fabricating or displaying a candidate result. +7. <a name="5.7"></a>WHEN Teach is selected for an actionable row, the system SHALL open teaching with that Entry as the example. +8. <a name="5.8"></a>The actionable filter SHALL preserve Recent’s existing last-shared ordering and calendar grouping, and a manual Work assignment SHALL resolve an assignment-only actionable state once the Entry has a valid chapter title. +9. <a name="5.9"></a>An unresolved assignment SHALL remain reproducible across reloads and SHALL be resolved only by a successful Re-parse, successful targeted re-teaching, a manual Work assignment, deletion of the Entry, or switching its Site to articles mode; changes to matching Works alone SHALL NOT assign it automatically. ++### 6. Re-Teaching and Pattern History++**User Story:** As a reader, I want to replace a Site rule with a controlled scope, so that format changes and earlier teaching mistakes have different consequences.++**Acceptance Criteria:**++1. <a name="6.1"></a>For a taught non-articles Site, the system SHALL allow re-teaching from Entry detail and from actionable Recent entries without requiring the general Sites settings screen. +2. <a name="6.2"></a>WHEN re-teaching begins, the system SHALL freeze one preview snapshot of the Site, its Entries, matching Works, and active pattern, and the preview SHALL show active- and candidate-pattern results across old- and new-format titles from that snapshot. +3. <a name="6.3"></a>WHEN a replacement rule is confirmed, the system SHALL retain every prior pattern, deactivate the prior active pattern, assign the replacement a site-local pattern version one greater than the greatest retained version, and leave exactly one active pattern; IF no greater positive version is representable, THEN it SHALL write nothing and explain that the rule cannot be replaced. +4. <a name="6.4"></a>WHEN the reader chooses “Apply to this and later, plus unresolved earlier entries,” the system SHALL total-order snapshot Entries by first-captured time ascending and then UUID ascending, target eligible fields of the triggering Entry and every later Entry, and additionally target only unsettled nonmanual fields of earlier Entries that are actionable in the frozen pre-transaction state. +5. <a name="6.5"></a>WHEN the reader additionally selects an older pattern for correction, the system SHALL union that correction with the boundary scope and independently target each nonmanual field whose provenance references the exact pattern UUID and site-local pattern version. +6. <a name="6.6"></a>WHEN a targeted nonmanual chapter field parses successfully, the system SHALL replace it with candidate output and candidate provenance; WHEN it cannot parse, the system SHALL clear its old pattern-derived chapter value and set chapter provenance to none. +7. <a name="6.7"></a>WHEN a targeted eligible assignment parses successfully, the system SHALL apply the reuse, create, or ambiguity behavior in Requirements 3.4–3.8 with candidate provenance; WHEN it cannot parse, the system SHALL remove its old pattern-derived assignment and set assignment provenance to none. +8. <a name="6.8"></a>Re-teaching SHALL leave each manual field unchanged and SHALL NOT automatically assign any Entry marked intentionally unattached, including an Entry whose chapter remains eligible for re-parsing. +9. <a name="6.9"></a>WHEN re-teaching moves Entries away from a parsed Work, the system SHALL retain the now-empty Work and its metadata. +10. <a name="6.10"></a>IF replacement activation or scoped re-parsing fails, THEN the system SHALL keep the prior active pattern and all prior Entry and Work values unchanged. +11. <a name="6.11"></a>WHEN re-teaching is confirmed, the system SHALL commit exactly the target set and projected outcomes displayed from the frozen preview snapshot or invalidate that preview under Requirement 2.7. +12. <a name="6.12"></a>Re-teaching SHALL allow a Site to replace a segment pattern with a phrase template or a phrase template with a segment pattern while retaining the superseded pattern and its provenance references. +13. <a name="6.13"></a>A live schema-version-2 or valid Backup V2 Site/pattern state SHALL be exactly one of: untaught with no patterns and no junk-suffix rule; taught with one or more retained patterns, exactly one active pattern, and no junk-suffix rule; or articles mode with zero active patterns, zero or more retained historical patterns, and zero or one valid junk-suffix rule. Every retained pattern SHALL belong to exactly one Site, have a positive Site-unique version and globally unique UUID, and satisfy the applicable immutable form in Requirements 3.13 and 9.10; every other mode, membership, active-count, version, or junk-rule tuple SHALL be a library-integrity failure rather than a repairable reader state. ++### 7. Articles Mode and Junk Stripping++**User Story:** As a reader, I want to classify a Site as articles and optionally remove a repeated title suffix, so that independent pages are not forced into Works.++**Acceptance Criteria:**++1. <a name="7.1"></a>The teaching and re-teaching surfaces SHALL offer articles mode as an alternative to confirming a Work-and-chapter pattern. +2. <a name="7.2"></a>WHEN articles mode is previewed, the system SHALL show manual Work assignments and chapter fields preserved, pattern-derived assignments and chapter fields cleared, every otherwise-unassigned Entry marked intentionally unattached, and every Site Entry excluded from the actionable count. +3. <a name="7.3"></a>Using the segments produced under Requirement 1.2, the system SHALL allow an optional junk suffix containing one or more contiguous end segments and SHALL prevent confirmation when stripping it would leave the example title blank. +4. <a name="7.4"></a>WHEN previewing a junk suffix, the system SHALL strip the same end-relative segment positions from each Site title having enough segments to retain a nonblank result, preserve original text and intervening delimiters in the remainder, and leave other titles unchanged. +5. <a name="7.5"></a>WHEN articles mode is confirmed, the system SHALL atomically set the Site to articles mode, deactivate any active title pattern, persist exactly the previewed junk-suffix rule or no rule when none was selected, preserve manual Work assignments, remove pattern-derived Work assignments and their provenance, and mark every otherwise-unassigned Site Entry intentionally unattached. +6. <a name="7.6"></a>WHEN articles mode is confirmed, the system SHALL preserve manual chapter values and SHALL clear pattern-derived chapter values and their provenance. +7. <a name="7.7"></a>WHEN an article title satisfies the configured end-relative suffix rule, Recent and Entry detail SHALL show the cleaned title without changing the capture title; otherwise they SHALL show the immutable capture title. +8. <a name="7.8"></a>WHEN a new Entry is saved for an articles-mode Site, the system SHALL save chapter title absent with chapter provenance none, Work absent with assignment provenance none, intentionally-unattached true, apply the configured cleaned-title presentation, and exclude it from the actionable inbox. +9. <a name="7.9"></a>M2 SHALL NOT provide an action that switches an articles-mode Site back to taught mode; that transition remains deferred to the general Sites management milestone. +10. <a name="7.10"></a>IF switching to articles mode fails, THEN the system SHALL retain the prior Site mode, active pattern, junk rule, Entries, and Works unchanged. ++### 8. Provenance Disclosure and Single-Entry Re-Parse++**User Story:** As a reader investigating an incorrect result, I want to see its source and re-run parsing for one Entry, so that I can understand and recover without changing unrelated notes.++**Acceptance Criteria:**++1. <a name="8.1"></a>Entry detail SHALL provide a collapsed disclosure containing the capture title verbatim, capture-title source, raw URL, canonical URL when present, and independent chapter and Work-assignment provenance. +2. <a name="8.2"></a>For each chapter and assignment field, Entry detail SHALL show a plain-language pattern-form and rule summary when provenance references a pattern, whether that pattern is active or historical, why the field is settled, unsettled, or protected, and the available recovery action; pattern UUID and site-local version SHALL remain secondary diagnostics, and manual or absent provenance SHALL be identified as such. +3. <a name="8.3"></a>Entry detail SHALL provide a Re-parse action for a taught Site, a Teach action for an untaught Site, and neither action for an articles-mode Site. +4. <a name="8.4"></a>Before single-entry Re-parse confirmation, the system SHALL show a complete exact before-and-after projection for that Entry, including cleared fields, manual and intentional-unattachment protections, Work reuse, creation, ambiguity or unassignment, resulting provenance, and every affected Work; WHEN confirmed, it SHALL apply the Site’s active pattern only to that Entry using the displayed field outcomes in Requirements 6.6–6.8. +5. <a name="8.5"></a>WHEN single-entry parsing produces a Work title, the system SHALL apply the same exact matching, reuse, creation, ambiguity, and manual-protection behavior as bulk parsing. +6. <a name="8.6"></a>IF a single-entry Re-parse projection is stale or invalid, THEN the system SHALL write nothing, show the refreshed current projection or reason, and require explicit confirmation again; IF persistence fails, THEN it SHALL leave the Entry and every Work unchanged and explain that no change was saved. ++### 9. Pre-Alpha Data Carry-Forward and Accessibility++**User Story:** As a pre-alpha tester, I want to carry my current data into M2 and use its new controls accessibly, without requiring production-grade compatibility machinery.++**Acceptance Criteria:**++1. <a name="9.1"></a>The project SHALL provide an explicit `make migrate-m1-to-m2` developer command that reads the current V1 library and creates a fresh V2 destination preserving every existing Site, Entry, Work, field value, relationship, UUID, and timestamp. Each existing V1 TitlePattern SHALL become a V2 segment form with phrase fields absent while preserving its identity, version, active state, creation time, Work anchor, ignored anchors, Site, and provenance references. +2. <a name="9.2"></a>The migration command SHALL refuse to run when a V2 destination already exists and SHALL make a newly created V2 result available only after verifying record counts, UUIDs, field values, relationships, and the mapping in Requirement 9.1 against the V1 source; IF migration or verification fails, THEN it SHALL exit unsuccessfully, leave the V1 source and any pre-existing destination unchanged, and SHALL NOT leave a partial V2 result as usable. +3. <a name="9.3"></a>The M2 app and extension SHALL support only the current V2 store and SHALL have no V1 reader, classifier, migrator, restore, repair, or deletion path; only the developer migration command may read V1. An unsupported or unavailable runtime store SHALL produce only the current-library-unavailable behavior in Requirement 9.9. +4. <a name="9.4"></a>M2 SHALL export and decode Backup V2 only; the backup SHALL represent every feature enabled at the current gate, while Backup V1 compatibility is out of scope. +5. <a name="9.5"></a>Teaching, preview, inbox, provenance, and Re-parse controls SHALL expose descriptive accessibility labels and a touch target of at least 44 by 44 points. +6. <a name="9.6"></a>WHEN segment or phrase teaching is enabled under Requirement 9.13, roles, selections, and preview outcomes SHALL use accessible labels and non-color indicators in addition to the style guide’s role colors. +7. <a name="9.7"></a>At every supported Dynamic Type size, teaching and re-teaching SHALL keep the example, role controls, preview results, confirmation action, and errors reachable, with scrolling permitted. +8. <a name="9.8"></a>The new M2 surfaces SHALL preserve the existing light, dark, and Reduce Transparency behavior established by the M1 application surfaces. +9. <a name="9.9"></a>The extension SHALL access the shared V2 library only after the containing app has initialized it successfully; otherwise it SHALL leave the library unchanged and offer only `Open Asterism`. IF the current V2 store cannot open or a live state violates Requirements 3.14, 3.15, or 6.13, THEN the current operation SHALL write nothing, the app SHALL report the library unavailable, and neither app nor extension SHALL automatically repair, delete, or replace it; an invalid Backup V2 SHALL be rejected. +10. <a name="9.10"></a>From M2.0 through M2.2, every enabled live V2 and Backup V2 TitlePattern SHALL declare one valid segment form containing one Work anchor, zero or more ignored anchors, and phrase fields absent; beginning with M2.3, each pattern SHALL declare exactly one valid segment or phrase form, and the system SHALL reject both forms, neither form, duplicate roles, a blank phrase separator, or invalid form-specific values. +11. <a name="9.11"></a>Backup V2 decoding SHALL reject unknown, missing, and duplicate keys, invalid references, and invalid state tuples; export SHALL encode every current record and relationship from one coherent source snapshot. +12. <a name="9.12"></a>Phrase-template selection SHALL provide a non-drag interaction operable with VoiceOver, Switch Control, and Full Keyboard Access for setting and adjusting each field boundary by `M2 Extended Grapheme Cluster`, and SHALL announce each role, boundary, and selected text. +13. <a name="9.13"></a>Each obligation SHALL apply beginning with the first M2 gate that enables its feature and at every later gate: M2.0 SHALL enable the V2 schema and Backup V2 with title teaching disabled; M2.1 SHALL add segment teaching; M2.2 SHALL add articles mode; and M2.3 SHALL add phrase templates and cross-form replacement. Development and Personal configurations SHALL pass every applicable behavior, backup, accessibility, and Section 10 requirement at each gate, while later-gate features remain absent. ++### 10. Supported Scale and Responsiveness++**User Story:** As a reader with years of captures, I want teaching and Recent to remain responsive, so that accumulated history does not make title recovery impractical.++**Acceptance Criteria:**++1. <a name="10.1"></a>Performance tests SHALL use repeatable data containing exactly 20,000 Entries and 2,000 Works across multiple Sites, with exactly 5,000 Entries for one Site, representative manual and parsed provenance, pattern history, actionable states, and ten scripted edits for each enabled teaching form. +2. <a name="10.2"></a>Measurements SHALL run in a Release build on the slowest supported physical iPhone under nominal thermal conditions with Low Power Mode disabled, record the device model and OS version, reset to a consistent fixture state, perform one warm-up, and collect 20 runs. Acknowledgement SHALL be measured from edit delivery to publication of that generation in view state; final preview from final-edit delivery to complete final projection publication; and Recent from repository request to complete ordered-result/actionable-count publication and UI-observed first-screen interaction. Nearest-rank p95 SHALL be the 19th sorted value of 20. +3. <a name="10.3"></a>WHEN the ten scripted edits are delivered 50 milliseconds apart, obsolete preview generations SHALL NOT replace newer state; every accepted edit SHALL be acknowledged at p95 within 100 milliseconds, and the complete final projection SHALL publish at p95 within 1 second. +4. <a name="10.4"></a>After the repository is open, Recent SHALL publish its complete ordered result and actionable count and present an interactive first screen at p95 within 2 seconds across 20 measured runs; all 20,000 results SHALL remain reachable without requiring eager rendering of every row.
diff --git a/specs/title-teaching-retroactive-parsing/tasks.md b/specs/title-teaching-retroactive-parsing/tasks.mdnew file mode 100644index 0000000..0e457e1--- /dev/null+++ b/specs/title-teaching-retroactive-parsing/tasks.md@@ -0,0 +1,142 @@+---+references:+ - specs/title-teaching-retroactive-parsing/requirements.md+ - specs/title-teaching-retroactive-parsing/design.md+ - specs/title-teaching-retroactive-parsing/decision_log.md+---+# Title Teaching & Retroactive Parsing Implementation++## M2.0 — V2 foundation++- [x] 1. Write failing V2 schema, capability, and runtime-opening tests <!-- id:17t9z7o -->+ - Cover per-gate tagged forms, app-owned V2 creation/readiness, extension existing-ready-only opening, V1-path refusal, unavailable behavior, and runtime non-linkage of V1 support.+ - Stream: 1+ - Requirements: [9.3](requirements.md#9.3), [9.9](requirements.md#9.9), [9.10](requirements.md#9.10), [9.13](requirements.md#9.13)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/SchemaV1Tests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryBootstrapTests.swift, Asterism/AsterismTests/AppLibraryModelTests.swift++- [x] 2. Implement V2 schema, capabilities, and runtime opening <!-- id:17t9z7p -->+ - Add schema-scoped V2 models and one M2Capabilities value shared by repository APIs, validators, Backup, and UI; split app V2 creation from extension noncreating ready-V2 open.+ - Blocked-by: 17t9z7o (Write failing V2 schema, capability, and runtime-opening tests)+ - Stream: 1+ - Requirements: [9.3](requirements.md#9.3), [9.9](requirements.md#9.9), [9.10](requirements.md#9.10), [9.13](requirements.md#9.13)+ - References: Packages/AsterismCore/Sources/AsterismCore/Models.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift, Asterism/Asterism/ViewModels/AppLibraryModel.swift++- [x] 3. Write failing V1-to-V2 developer migration tests <!-- id:17t9z7q -->+ - Use a representative V1 graph; cover existing-destination refusal, reopened logical equality, V1-pattern segment mapping, failed verification cleanup, and unchanged V1 source.+ - Blocked-by: 17t9z7p (Implement V2 schema, capabilities, and runtime opening)+ - Stream: 1+ - Requirements: [9.1](requirements.md#9.1), [9.2](requirements.md#9.2), [9.3](requirements.md#9.3)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests, Packages/AsterismCore/Package.swift, Makefile++- [x] 4. Implement the V1 migration tool and Makefile command <!-- id:17t9z7r -->+ - Create unlinked V1-support and migration-tool targets plus make migrate-m1-to-m2; require closed runtime processes, refuse existing V2, copy/reopen/verify the graph, and leave readiness to the app.+ - Blocked-by: 17t9z7q (Write failing V1-to-V2 developer migration tests)+ - Stream: 1+ - Requirements: [9.1](requirements.md#9.1), [9.2](requirements.md#9.2), [9.3](requirements.md#9.3)+ - References: Packages/AsterismCore/Package.swift, Packages/AsterismCore/Sources/AsterismMigrationTool, Packages/AsterismCore/Sources/AsterismV1MigrationSupport, Makefile++- [x] 5. Write failing current Backup V2 tests <!-- id:17t9z7s -->+ - Cover coherent V2 round trips, gate validation, and malformed key/reference/form/tuple rejection; assert no Backup V1 dispatch or export self-decode.+ - Blocked-by: 17t9z7p (Implement V2 schema, capabilities, and runtime opening)+ - Stream: 1+ - Requirements: [9.4](requirements.md#9.4), [9.10](requirements.md#9.10), [9.11](requirements.md#9.11), [9.13](requirements.md#9.13)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/BackupV1CodecTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/BackupExporterTests.swift, Asterism/AsterismTests/SettingsBackupModelTests.swift++- [x] 6. Implement current-only Backup V2 <!-- id:17t9z7t -->+ - Replace runtime Backup V1 wiring with strict Backup V2 records/decoding from one coherent snapshot, enforcing capabilities and live tuple rules while preserving share cleanup UX.+ - Blocked-by: 17t9z7s (Write failing current Backup V2 tests)+ - Stream: 1+ - Requirements: [9.4](requirements.md#9.4), [9.10](requirements.md#9.10), [9.11](requirements.md#9.11), [9.13](requirements.md#9.13)+ - References: Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift, Packages/AsterismCore/Sources/AsterismCore/BackupV2Types.swift, Asterism/Asterism/ViewModels/SettingsBackupModel.swift++## M2.1 — Segment teaching++- [x] 7. Write failing exact-parser and projection-planner tests <!-- id:17t9z7u -->+ - Add example and seeded properties for seven spaced delimiters (longest :: first), source reconstruction, anchors, exact scalar matching, Work outcomes, protections, ambiguity, versions, generations, stale contracts, and one-save plans.+ - Blocked-by: 17t9z7r (Implement the V1 migration tool and Makefile command), 17t9z7t (Implement current-only Backup V2)+ - Stream: 1+ - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [1.9](requirements.md#1.9), [1.18](requirements.md#1.18), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8), [3.9](requirements.md#3.9), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [3.12](requirements.md#3.12), [3.13](requirements.md#3.13), [3.14](requirements.md#3.14), [3.15](requirements.md#3.15), [5.1](requirements.md#5.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6), [6.7](requirements.md#6.7), [6.8](requirements.md#6.8), [6.9](requirements.md#6.9), [6.10](requirements.md#6.10), [6.11](requirements.md#6.11), [9.10](requirements.md#9.10)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests, specs/title-teaching-retroactive-parsing/design.md++- [x] 8. Implement the parser and projection core <!-- id:17t9z7v -->+ - Add pure segment parsing/derivation, PatternDefinition dispatch, projection DTOs, generation ordering, Work planner, independent field targeting, actionability, and structural stale comparison; add no digests or manifests.+ - Blocked-by: 17t9z7u (Write failing exact-parser and projection-planner tests)+ - Stream: 1+ - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [1.9](requirements.md#1.9), [1.18](requirements.md#1.18), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8), [3.9](requirements.md#3.9), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [3.12](requirements.md#3.12), [3.13](requirements.md#3.13), [3.14](requirements.md#3.14), [3.15](requirements.md#3.15), [5.1](requirements.md#5.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6), [6.7](requirements.md#6.7), [6.8](requirements.md#6.8), [6.9](requirements.md#6.9), [6.10](requirements.md#6.10), [6.11](requirements.md#6.11), [9.10](requirements.md#9.10)+ - References: Packages/AsterismCore/Sources/AsterismCore/TitleParsing.swift, Packages/AsterismCore/Sources/AsterismCore/TeachingTypes.swift, Packages/AsterismCore/Sources/AsterismCore/TitleProjectionPlanner.swift++- [x] 9. Write failing repository teaching, Re-parse, Recent, and capture tests <!-- id:17t9z7w -->+ - Cover initial/replacement commits, historical correction, Work metadata and ambiguity, Re-parse, provenance replay, actionable Recent, taught capture outcomes, second Save, and injected rollback.+ - Blocked-by: 17t9z7v (Implement the parser and projection core)+ - Stream: 1+ - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8), [3.9](requirements.md#3.9), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [3.12](requirements.md#3.12), [3.13](requirements.md#3.13), [3.14](requirements.md#3.14), [3.15](requirements.md#3.15), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5), [4.6](requirements.md#4.6), [4.7](requirements.md#4.7), [4.8](requirements.md#4.8), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [5.8](requirements.md#5.8), [5.9](requirements.md#5.9), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6), [6.7](requirements.md#6.7), [6.8](requirements.md#6.8), [6.9](requirements.md#6.9), [6.10](requirements.md#6.10), [6.11](requirements.md#6.11), [6.13](requirements.md#6.13), [8.1](requirements.md#8.1), [8.2](requirements.md#8.2), [8.3](requirements.md#8.3), [8.4](requirements.md#8.4), [8.5](requirements.md#8.5), [8.6](requirements.md#8.6), [9.9](requirements.md#9.9), [9.10](requirements.md#9.10)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryWorksTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift++- [x] 10. Implement repository transactions and projected capture <!-- id:17t9z7x -->+ - Add coherent teaching/detail/Recent/capture APIs; refetch and rebuild under the exclusive lock, allocate IDs/time late, save one fresh context once, or return a refreshed zero-write projection.+ - Blocked-by: 17t9z7w (Write failing repository teaching, Re-parse, Recent, and capture tests)+ - Stream: 1+ - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8), [3.9](requirements.md#3.9), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [3.12](requirements.md#3.12), [3.13](requirements.md#3.13), [3.14](requirements.md#3.14), [3.15](requirements.md#3.15), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5), [4.6](requirements.md#4.6), [4.7](requirements.md#4.7), [4.8](requirements.md#4.8), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [5.8](requirements.md#5.8), [5.9](requirements.md#5.9), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5), [6.6](requirements.md#6.6), [6.7](requirements.md#6.7), [6.8](requirements.md#6.8), [6.9](requirements.md#6.9), [6.10](requirements.md#6.10), [6.11](requirements.md#6.11), [6.13](requirements.md#6.13), [8.1](requirements.md#8.1), [8.2](requirements.md#8.2), [8.3](requirements.md#8.3), [8.4](requirements.md#8.4), [8.5](requirements.md#8.5), [8.6](requirements.md#8.6), [9.9](requirements.md#9.9), [9.10](requirements.md#9.10)+ - References: Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift, Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift, Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift++- [x] 11. Write failing segment-teaching, Recent/detail, and extension UI tests <!-- id:17t9z7y -->+ - Automate segment authoring, previews, stale reconfirmation, actionable filtering, provenance/Re-parse, and projected extension capture; assert accessibility and absence of later-gate actions.+ - Blocked-by: 17t9z7x (Implement repository transactions and projected capture)+ - Stream: 1+ - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5), [4.6](requirements.md#4.6), [4.7](requirements.md#4.7), [4.8](requirements.md#4.8), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [5.8](requirements.md#5.8), [5.9](requirements.md#5.9), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [8.1](requirements.md#8.1), [8.2](requirements.md#8.2), [8.3](requirements.md#8.3), [8.4](requirements.md#8.4), [8.5](requirements.md#8.5), [8.6](requirements.md#8.6), [9.5](requirements.md#9.5), [9.6](requirements.md#9.6), [9.7](requirements.md#9.7), [9.8](requirements.md#9.8), [9.9](requirements.md#9.9), [9.13](requirements.md#9.13)+ - References: Asterism/AsterismTests, Asterism/AsterismUITests, Asterism/AsterismShareExtension/CaptureView.swift++- [x] 12. Implement the M2.1 segment-teaching surfaces <!-- id:17t9z7z -->+ - Add teaching state/UI, segment chips, complete previews, Recent actionability, Entry-detail provenance/Re-parse, and extension projected metadata with second-Save behavior using existing styles.+ - Blocked-by: 17t9z7y (Write failing segment-teaching, Recent/detail, and extension UI tests)+ - Stream: 1+ - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5), [4.6](requirements.md#4.6), [4.7](requirements.md#4.7), [4.8](requirements.md#4.8), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [5.6](requirements.md#5.6), [5.7](requirements.md#5.7), [5.8](requirements.md#5.8), [5.9](requirements.md#5.9), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [8.1](requirements.md#8.1), [8.2](requirements.md#8.2), [8.3](requirements.md#8.3), [8.4](requirements.md#8.4), [8.5](requirements.md#8.5), [8.6](requirements.md#8.6), [9.5](requirements.md#9.5), [9.6](requirements.md#9.6), [9.7](requirements.md#9.7), [9.8](requirements.md#9.8), [9.9](requirements.md#9.9), [9.13](requirements.md#9.13)+ - References: Asterism/Asterism/ViewModels, Asterism/Asterism/Views, Asterism/AsterismShareExtension/CaptureView.swift, Asterism/Asterism/ContentView.swift++## M2.2 — Articles mode++- [x] 13. Write failing articles-mode planner, repository, and UI tests <!-- id:17t9z80 -->+ - Cover suffix authoring, blank-example rejection, fallback, manual preservation, derived clearing, intentional unattachment, deactivation, zero actionability, one-way mode, future capture, staleness, and accessibility.+ - Blocked-by: 17t9z7z (Implement the M2.1 segment-teaching surfaces)+ - Stream: 1+ - Requirements: [2.5](requirements.md#2.5), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4), [7.5](requirements.md#7.5), [7.6](requirements.md#7.6), [7.7](requirements.md#7.7), [7.8](requirements.md#7.8), [7.9](requirements.md#7.9), [7.10](requirements.md#7.10), [9.5](requirements.md#9.5), [9.6](requirements.md#9.6), [9.7](requirements.md#9.7), [9.8](requirements.md#9.8), [9.9](requirements.md#9.9), [9.13](requirements.md#9.13)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests, Asterism/AsterismTests, Asterism/AsterismUITests++- [x] 14. Implement M2.2 articles mode <!-- id:17t9z81 -->+ - Extend the shared planner, repository, and teaching UI with article projection, suffix cleaning, atomic transition, presentation, and future capture while keeping capture evidence immutable.+ - Blocked-by: 17t9z80 (Write failing articles-mode planner, repository, and UI tests)+ - Stream: 1+ - Requirements: [2.5](requirements.md#2.5), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4), [7.5](requirements.md#7.5), [7.6](requirements.md#7.6), [7.7](requirements.md#7.7), [7.8](requirements.md#7.8), [7.9](requirements.md#7.9), [7.10](requirements.md#7.10), [9.5](requirements.md#9.5), [9.6](requirements.md#9.6), [9.7](requirements.md#9.7), [9.8](requirements.md#9.8), [9.9](requirements.md#9.9), [9.13](requirements.md#9.13)+ - References: Packages/AsterismCore/Sources/AsterismCore/TitleProjectionPlanner.swift, Asterism/Asterism/Views/TeachingView.swift, Asterism/Asterism/Views/RecentView.swift++## M2.3 — Phrase teaching++- [x] 15. Write failing phrase parsing, replacement, and accessibility tests <!-- id:17t9z82 -->+ - Cover grapheme-aligned selection, exact literals/order, overlapping separator starts, summaries, history/replay/capture, both cross-form directions, gate validation, and non-drag assistive paths.+ - Blocked-by: 17t9z81 (Implement M2.2 articles mode)+ - Stream: 1+ - Requirements: [1.10](requirements.md#1.10), [1.11](requirements.md#1.11), [1.12](requirements.md#1.12), [1.13](requirements.md#1.13), [1.14](requirements.md#1.14), [1.15](requirements.md#1.15), [1.16](requirements.md#1.16), [1.17](requirements.md#1.17), [1.18](requirements.md#1.18), [6.12](requirements.md#6.12), [9.5](requirements.md#9.5), [9.6](requirements.md#9.6), [9.7](requirements.md#9.7), [9.8](requirements.md#9.8), [9.9](requirements.md#9.9), [9.10](requirements.md#9.10), [9.11](requirements.md#9.11), [9.12](requirements.md#9.12), [9.13](requirements.md#9.13)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests, Asterism/AsterismTests, Asterism/AsterismUITests++- [x] 16. Implement M2.3 phrase teaching and cross-form replacement <!-- id:17t9z83 -->+ - Add phrase derivation/parsing and platform-grapheme controls, routing preview, commit, history, capture, Re-parse, Backup, and segment↔phrase replacement through shared contracts.+ - Blocked-by: 17t9z82 (Write failing phrase parsing, replacement, and accessibility tests)+ - Stream: 1+ - Requirements: [1.10](requirements.md#1.10), [1.11](requirements.md#1.11), [1.12](requirements.md#1.12), [1.13](requirements.md#1.13), [1.14](requirements.md#1.14), [1.15](requirements.md#1.15), [1.16](requirements.md#1.16), [1.17](requirements.md#1.17), [1.18](requirements.md#1.18), [6.12](requirements.md#6.12), [9.5](requirements.md#9.5), [9.6](requirements.md#9.6), [9.7](requirements.md#9.7), [9.8](requirements.md#9.8), [9.9](requirements.md#9.9), [9.10](requirements.md#9.10), [9.11](requirements.md#9.11), [9.12](requirements.md#9.12), [9.13](requirements.md#9.13)+ - References: Packages/AsterismCore/Sources/AsterismCore/TitleParsing.swift, Packages/AsterismCore/Sources/AsterismCore/TitleProjectionPlanner.swift, Asterism/Asterism/Views/TeachingView.swift++## Integration and validation++- [x] 17. Write failing cross-gate integration and scale-harness tests <!-- id:17t9z84 -->+ - Instantiate all gates in Development and Personal; add generated 20,000-Entry/2,000-Work data with one 5,000-Entry Site, timing signposts, stale-generation assertions, and physical-device performance hooks.+ - Blocked-by: 17t9z7r (Implement the V1 migration tool and Makefile command), 17t9z7t (Implement current-only Backup V2), 17t9z7z (Implement the M2.1 segment-teaching surfaces), 17t9z81 (Implement M2.2 articles mode), 17t9z83 (Implement M2.3 phrase teaching and cross-form replacement)+ - Stream: 1+ - Requirements: [9.13](requirements.md#9.13), [10.1](requirements.md#10.1), [10.2](requirements.md#10.2), [10.3](requirements.md#10.3), [10.4](requirements.md#10.4)+ - References: Asterism/AsterismTests/IntegrationSafetyNetTests.swift, Asterism/AsterismUITests, Packages/AsterismCore/Tests/AsterismCoreTests, Makefile++- [x] 18. Complete cross-target wiring and pass every gate suite <!-- id:17t9z85 -->+ - Finish target membership and injection for migration isolation, readiness, capabilities, navigation, Backup, and capture; run focused Makefile tests, then make test, make build, make build-release, and git diff --check.+ - Blocked-by: 17t9z84 (Write failing cross-gate integration and scale-harness tests)+ - Stream: 1+ - Requirements: [9.13](requirements.md#9.13), [10.1](requirements.md#10.1), [10.2](requirements.md#10.2), [10.3](requirements.md#10.3), [10.4](requirements.md#10.4)+ - References: Asterism/Asterism.xcodeproj/project.pbxproj, Packages/AsterismCore/Package.swift, Asterism/Asterism/ContentView.swift, Makefile
diff --git a/specs/title-teaching-retroactive-parsing/implementation.md b/specs/title-teaching-retroactive-parsing/implementation.mdnew file mode 100644index 0000000..1c626af--- /dev/null+++ b/specs/title-teaching-retroactive-parsing/implementation.md@@ -0,0 +1,100 @@+# Implementation: Title Teaching & Retroactive Parsing++## Beginner Level++### What Changed++Asterism can now learn how each website formats page titles. A reader teaches one example, previews the result, and confirms a rule that organizes past captures from that Site. Future captures use the same rule automatically. Articles mode handles Sites whose pages are independent rather than chapters of a Work, while Recent highlights captures that still need attention.++### Why It Matters++One teaching action can organize an existing library without recapturing pages or overwriting manual curation. Immutable capture titles remain the source of truth, and every derived chapter or Work assignment remains explainable.++### Key Concepts++- **Segment pattern:** assigns chapter, Work, and ignored roles to delimiter-separated title segments.+- **Phrase pattern:** extracts chapter and Work from exact prefix, separator, and suffix literals.+- **Projection contract:** freezes the data and proposed outcome so confirmation commits exactly what was previewed or writes nothing.+- **Actionable entry:** a capture with an unsettled chapter or Work assignment.+- **Articles mode:** a one-way M2 Site mode that avoids Work assignment and can remove a repeated display suffix.++## Intermediate Level++### Changes Overview++- **M2.0 foundation:** Schema V2, capability gates, strict Backup V2, V2-only runtime opening, and explicit V1-to-V2 developer migration.+- **M2.1 segment teaching:** exact delimiter parsing, projection planning, repository transactions, actionable Recent presentation, provenance, Re-parse, and projected extension capture.+- **M2.2 articles mode:** optional junk-suffix cleaning, atomic mode transition, and future article capture behavior.+- **M2.3 phrase teaching:** grapheme-aligned authoring, exact scalar matching, accessible summaries, and cross-form replacement.+- **Integration:** generated scale fixtures, performance signposts, physical-device hooks, cross-gate tests, and Development/Personal build validation.++### Implementation Approach++Pure title parsers and `TitleProjectionPlanner` contain no persistence logic. The `LibraryRepository` actor is the only writer and performs every mutation under `CrossProcessLibraryLock`. Teaching, articles mode, Re-parse, and capture all use the same sequence: read a coherent basis, compute a complete outcome, preview it, refetch under exclusive access, rebuild and compare, then save once or return a refreshed projection.++Pattern, Entry, and Work DTOs preserve exact Unicode-scalar semantics. Manual chapter and assignment provenance protect those fields independently, while retained historical patterns make unresolved assignments reproducible.++### Trade-offs++- Two deterministic pattern forms are easier to teach and explain than regular expressions, but unsupported formats fail rather than being guessed.+- Exact matching avoids accidental merges but does not absorb cosmetic case or whitespace differences.+- Rebuilding projections during commit costs extra work but prevents stale previews from writing.+- Full live-store validation at open is linear in library size, but Requirement 9.9 requires invalid V2 state to become unavailable immediately.+- Articles mode is intentionally one-way until the later Sites-management milestone.++## Expert Level++### Technical Deep Dive++`DelimiterTokenizer` discovers all spaced delimiter spans before splitting, rejects overlapping whitespace, and preserves exact source delimiters inside selected ranges. Phrase templates use grapheme boundaries for authoring and scalar offsets for exact matching, including overlapping separator occurrences. `PatternSummaryFormatter` provides the canonical accessible description for both forms.++`ProjectionContract<Basis, Request, Outcome>` provides structural stale detection. IDs and operation timestamps are allocated only after the approved basis and rebuilt outcome compare equal. Commit paths use Site-scoped SwiftData predicates and checked UUID indexes, avoiding full-table materialization and quadratic model lookup while reporting duplicate identifiers as library corruption rather than trapping.++Recent validates each Site once per coherent publication, replays unresolved provenance through the exact retained pattern, and derives actionability from stored field settlement. The actionable-only filter resets when the final actionable entry is resolved, satisfying Requirement 5.3.++### Architecture Impact++- Runtime app and extension targets contain no V1 migration support.+- Backup and live-state validation share the same V2 closed-tuple validator.+- UI models consume immutable repository projections rather than reimplementing parsing or provenance rules.+- Internal projection application now accepts `[UUID: Work]`, keeping bulk and single-entry commits constant-time per lookup without changing public APIs.++### Potential Issues++- Physical-device p95 thresholds depend on device, OS, power, and thermal conditions; the opt-in harness must be run on the slowest supported iPhone.+- Full opening validation is intentionally O(N), so cold-open cost should be monitored as libraries grow.+- Exact scalar semantics mean visually similar normalized and decomposed titles remain distinct by design.++## Completeness Assessment++### Fully Implemented++- Requirements 1–8: segment and phrase teaching, previews, retroactive/future parsing, actionable Recent, replacement history, articles mode, provenance, and Re-parse.+- Requirements 9.1–9.13: explicit V1 migration tooling, V2-only runtime and backup, gate validation, accessibility paths, readiness behavior, and current-library integrity checks.+- Requirement 10.1 and the test infrastructure for 10.2–10.4: deterministic 20,000-Entry/2,000-Work fixtures, signposts, warm-up/run protocol, stale-generation assertions, and physical-device test hooks.+- Automated validation: `make test-core`, `make test`, `make build`, and `make build-release` pass.++### Partially Implemented++- Requirements 10.2–10.4 require measured p95 results on a qualifying physical iPhone. The harness is implemented, but this review did not produce or verify a physical-device measurement run.++### Missing++No implementation gap was identified outside the pending physical-device measurements.++## Important Changes++1. **Exact pattern engine** — `TitleParsing.swift`, `TitleProjectionPlanner.swift`: one deterministic implementation serves preview, commit, capture, history, and Re-parse. Takeaway: exact source-preserving parsers make preview fidelity testable. Rationale: Decision 1.+2. **Structural one-save transactions** — `LibraryRepository+Contracts.swift`, `LibraryRepository+ReparseCapture.swift`: stale confirmation writes nothing, while approved changes save once. Takeaway: compare both basis and outcome, then allocate identities late. Rationale: Decision 2.+3. **V2-only data boundary** — `AsterismSchemaV2.swift`, `V2LibraryValidator.swift`, `V1ToV2Migrator.swift`: runtime stays current-schema-only while explicit tooling carries pre-alpha data forward. Takeaway: isolate migration dependencies from product targets. Rationale: Decisions 7 and 8.+4. **Actionable Recent and provenance replay** — `LibraryRepository+RecentPresentation.swift`, `RecentView.swift`: unresolved results remain visible and reproducible, with filter state cleared after final resolution. Takeaway: derive attention state from persisted settlement, not current parseability. Rationale: Decisions 3 and 4.+5. **Scale-safe commit lookups** — `LibraryRepository+Contracts.swift`, `LibraryRepository.swift`: Site predicates and checked UUID indexes replace broad fetch/filter and repeated linear scans. Takeaway: keep exclusive-lock work proportional to the target Site. Rationale: inferred from Requirement 10 and the scale fixture.++## Decisions++The implementation follows all nine accepted decisions in `decision_log.md`: two exact pattern forms; structural projections and one save; independent field protection; exact Work matching; boundary-scoped replacement; projected future capture; V2-only runtime with explicit migration; current Backup V2 only; and four vertical delivery gates.++## Open Questions++- When should the first physical-device baseline for the 100 ms acknowledgement, 1 second projection, and 2 second Recent thresholds be recorded?+- Should cold-open validation receive its own explicit performance budget while retaining Requirement 9.9’s full integrity guarantee?
Run make test-performance on the slowest supported physical iPhone under the specified thermal and power conditions before release sign-off.
Full O(N) graph validation is required for integrity; monitor cold-open behavior as the library grows.