A read-only Catch up section on both share-extension capture sheets: the work's own notes plus the nearest noted chapter before the one being shared. Rides the T-1916 characters read, widened into one ShareWorkContext read per (work, chapter placement).
ChapterPlacement scale.ChapterPlacement); WorkDetailModel delegates to it.#if DEBUG previews and the author's on-device runs; the extension has no XCUITest coverage by design.Ready to push
Implementation matches the amended spec on every MUST; all four review agents' actionable findings were fixed in e854848/8f8de2c with zero test-file changes, and the suites are green with no new compiler warnings. The one mid-flight behaviour change (Decision 4, nearest-preceding chapter) came from the author's own on-device test and is fully recorded in the decision log.
5964851 T-1917: share-sheet-last-note smolspec e808de4 T-1917: widen the share-sheet characters read into one work-context read d418b94 T-1917: changelog for phase 1 (Core read), review decisions Q10–Q13 d792d67 T-1917: draw the catch-up section under both capture sheets' note editors 18c5417 T-1917: keep the catch-up previews out of the extension binary 5d3d400 T-1917: changelog for phase 2 (Extension), Q14, spec Done 925522a T-1917: catch up with the nearest chapter before, not the newest note 3b40d57 T-1917: changelog reflects Decision 4's selection rule 2e9dc9c T-1917: record review outcomes of 925522a (Q15–Q17) 1ab6d70 T-1917: key the work-context read on the chapter's placement 8f8de2c T-1917: pre-push review doc fixes e854848 T-1917: pre-push review fixes — share context reads, shared date and glyph 632479c T-1917: Q15 records the dead title arm as removed 8bbcccb T-1917: implementation explanation (pre-push review phase 6) Sharing a chapter to Asterism now shows a read-only “Catch up” section under the note editor: the story's own notes, and the note you wrote on the chapter just before the one you're sharing — its title, date, rating arrow and full text.
A reader sharing the next chapter had to remember where the story left off unaided. The sheet now answers “what just happened” with the reader's own words, and never with a note from a later chapter — re-reading works correctly.
ShareWorkContext.swift (renamed from ShareCharacters.swift): ShareLastNote, ShareWorkContext, heading/accessibility producers, and the widened anchored read.ChapterPlacement in ChapterKeyDerivation.swift: the spine's comparator extracted; WorkDetailModel delegates.ShareCatchUpSection.swift: header + one card after the note editor on both sheets; previews under #if DEBUG.ShareDateText.medium formatter cache; one rating glyph/accent mapping in ConstellationKit.One read per (work, anchor): characters as T-1916 read them, then in its own do/catch the carrier's genericNotes and the selected note — bucket rows by id, drop all-blank buckets, group, place each carrier, keep placements strictly before the anchor, take the nearest (ties: newest lastSharedAt, then UUID), snapshot only that group. Strings are produced in core; the view formats nothing.
The walk stays pure over entryGroups and snapshots exactly one group, because snapshot(EntryGroup) validates raw enums and can throw — the notes half's failure domain is the row it will show. The bucket pre-filter is a superset of the candidate set (the carrier is one of the bucket's rows), cutting group construction ~10× for sparse noters on the re-share sheet's blocking first render. ChapterPlacement is a total order (position < key; ChapterKey tie-breaks on label), so max(by:) is deterministic; the UUID leg inverts deliberately to yield the lowest id. Equal placement is not “before”, which also closes the duplicate-of-the-shared-chapter case. The Req 8.12 third title arm is provably unreachable under this rule and was removed (Q15); split groups keep snapshot's carrier/representative field sourcing (Q16).
One comparator now orders the spine, the citation gutter and the sheet. CaptureCoordinating widened with an .empty-returning default — a conformer missing the new signature degrades to an empty section rather than a compile error (recorded trade-off). A new package-side logger category ShareWorkContext (not under the me.nore.ig.Asterism subsystem CLAUDE.md's model filter names — Q13).
lastSharedAt; within one placement the tie-break prefers it..stale keeps the pre-refresh context by design.Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift
Why it matters. The feature's core: one shared-lock read returning characters, work note and the nearest preceding noted chapter; owns the failure split and every selection edge case.
What to look at. shareWorkContext(forWorkID:excludingEntryID:currentChapterSequence:currentChapterTitle:context:), selection at the placement filter + max(by:)
Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swift
Why it matters. New public ordering type; the spine, gutter and sheet must agree forever after.
What to look at. ChapterPlacement enum, Comparable; of(chapterSequence:chapterTitle:) single-parse
Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift
Why it matters. Controls how often the extension takes the library lock while the user types.
What to look at. WorkContextKey (workID + ChapterPlacement?), needsWorkContextLoad / loadWorkContextIfNeeded / displayedWorkContext
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift
Why it matters. The re-share sheet's spinner blocks on this read; it must stay one lock acquisition and exclude the edited entry.
What to look at. includeWorkContext block: excludingEntryID + carrier chapterSequence/chapterTitle anchor
Asterism/AsterismShareExtension/ShareCatchUpSection.swift
Why it matters. The only user-visible new surface; all text, no control, below the editor by decision.
What to look at. whole file; identifiers on the section header (Q14), previews under #if DEBUG
Packages/AsterismCore/Sources/AsterismCore/ReSharePresentation.swift
Why it matters. Review majors/minors: per-entry group construction on a blocking path, two DateFormatter constructions per keystroke, the rating mapping spelled three times.
What to look at. e854848: ShareDateText.medium cache; bucket pre-filter; ConstellationKit rating glyph accessor; dead third title arm removed
Decision 3's newest-lastSharedAt rule put chapter 55's note on chapter 53's sheet in the author's on-device test. Selection is now placement-anchored: strictly-before candidates only, nearest wins, unplaceable anchor drops the block but keeps the work notes. The author chose both halves explicitly.
Writing is the sheet's purpose; below the editor, scrolling to catch up is deliberate and nothing the reader is typing into moves when the section arrives one refresh later.
A second read would duplicate the guard machinery and take the lock twice. The notes half runs in its own do/catch; its failure leaves the characters intact and logs at debug level (Q13).
Title trimmed like the note (Q10); catchUp identifiers on the section header because container identifiers shadow children (Q14); the provably dead third title arm removed (Q15); the read guard keyed on placement, not raw strings (Q17).
The app has no URL scheme and the extension cannot launch its container (docs/asterism-design.md §3.1); the notes are inline or nothing (Q1).
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | ShareWorkContext read (efficiency) | The groups walk built full EntryGroup machinery for every entry row of the work on the re-share sheet's blocking first render (~1,000 faults for a 1,000-chapter serial to select one note). | Rows bucketed by id first; all-blank and excluded buckets dropped before entryGroups — a provable superset of the candidate set. |
| major | docs/asterism-design.md | §3.4/§3.5 still described the capture sheet as metadata + note + rating + save, 'nothing else'. | Catch up added as layout step 5 and to the Editing state row. |
| major | specs/OVERVIEW.md | Table row and detail bullets described the superseded newest-lastSharedAt rule with no Decision 4. | Restated as nearest-preceding with a Decision 4 bullet. |
| minor | Date formatting (reuse + efficiency) | Medium-date formatter built identically in two files and constructed twice per SwiftUI body evaluation. | One ShareDateText.medium with a locked cache keyed on (locale, calendar, timeZone); golden tests unchanged. |
| minor | Rating glyph mapping (reuse) | up→cyan / down→violet + SF Symbol names spelled in three call sites plus the toggle style. | One public symbol+accent accessor on ConstellationKit's rating direction; all sites read it. |
| minor | Dead title arm (quality) | Req 8.12's third arm (site-cleaned capture title) was unreachable under Decision 4 but still paid a SiteLookupCache and a try per read. | Arm and cache removed; Q15 updated to record removal; docs corrected. |
| minor | ChapterPlacement.of (efficiency) | The chapter sequence was parsed twice (once via position, once via key). | Single parse of the first number group, branch on isChapterNumber. |
| minor | Type directory fetch (efficiency) | Whole-table WorkTypeEntity fetch ran even for single-row works that need no carrier pick. | Directory fetched only when the work has duplicate rows. |
| minor | CHANGELOG.md | Two '### Changed' headings under one [Unreleased]; one contradictory clause; a stale signature name. | Merged, clause dropped, signature corrected. |
| minor | tasks.md accuracy | Task 5 claimed a simulator look that cannot exist; tasks 2/4 described pre-Decision-4 signatures. | Detail lines updated to what actually shipped and how it was verified. |
| minor | Signature sprawl (quality) | The read spans four signatures (static/public/protocol/forwarder) and derives the placement twice per call path; a query object would collapse them. | Skipped: the signatures are the smolspec's own stated shape, the double derivation is single-parse cheap after the .of fix, and reshaping the protocol pre-push buys little. |
| nit | Comparator readability | The max(by:) UUID leg flips comparison direction mid-chain. | Intent comment added (max yields nearest, newest, lowest id). |
| nit | CaptureView duplication | ShareCatchUpSection call repeated in ready and saving branches, matching the existing noteSection pattern. | Skipped: consistent with the file's existing structure; folding one section alone helps nothing. |
| nit | Test style | Some CaptureStateTests assertions read internal state (contextKey) rather than behaviour. | Skipped: inherited T-1916 pattern, each paired with a call-count assertion. |
Click to expand.
diff --git a/Asterism/Asterism/ViewModels/WorkDetailModel.swift b/Asterism/Asterism/ViewModels/WorkDetailModel.swiftindex 9ef98e5..d74685e 100644--- a/Asterism/Asterism/ViewModels/WorkDetailModel.swift+++ b/Asterism/Asterism/ViewModels/WorkDetailModel.swift@@ -84,21 +84,21 @@ public final class WorkDetailModel { ) -> Bool { let lhs = left.element let rhs = right.element- // The URL's position where both have one; a note with one before a- // note without; otherwise the chapter key on the same terms.- switch (lhs.sequencePosition, rhs.sequencePosition) {- case (.some(let leftPosition), .some(let rightPosition)):- if leftPosition != rightPosition { return leftPosition < rightPosition }+ // The placement where both have one; a note with one before a note+ // without. `ChapterPlacement` is the scale — one derivation shared with+ // the share sheet's catch-up selection (share-sheet-last-note Decision+ // 4), so the spine and the sheet cannot come apart about order. The+ // `lastSharedAt` fallback below stays here: it is what an *unplaced*+ // row falls back to on this page, and the sheet has no such fallback.+ switch (+ ChapterPlacement(sequencePosition: lhs.sequencePosition, chapterKey: lhs.chapterKey),+ ChapterPlacement(sequencePosition: rhs.sequencePosition, chapterKey: rhs.chapterKey)+ ) {+ case (.some(let leftPlacement), .some(let rightPlacement)):+ if leftPlacement != rightPlacement { return leftPlacement < rightPlacement } case (.some, .none): return true case (.none, .some): return false- case (.none, .none):- switch (lhs.chapterKey, rhs.chapterKey) {- case (.some(let leftKey), .some(let rightKey)):- if leftKey != rightKey { return leftKey < rightKey }- case (.some, .none): return true- case (.none, .some): return false- case (.none, .none): break- }+ case (.none, .none): break } if lhs.lastSharedAt != rhs.lastSharedAt { return lhs.lastSharedAt < rhs.lastSharedAt
diff --git a/Asterism/AsterismShareExtension/CaptureView.swift b/Asterism/AsterismShareExtension/CaptureView.swiftindex 92f6c16..abc2467 100644--- a/Asterism/AsterismShareExtension/CaptureView.swift+++ b/Asterism/AsterismShareExtension/CaptureView.swift@@ -131,6 +131,11 @@ struct CaptureView: View { } ratingSection noteSection+ // Under the editor, never above it (Decision 1). Nothing renders for a+ // work with neither notes, for a projection that names no existing+ // work, or for a failed read.+ ShareCatchUpSection(+ context: viewModel.workContext, identifierPrefix: "capture") } // MARK: - Saving@@ -157,6 +162,8 @@ struct CaptureView: View { } ratingSection noteSection+ ShareCatchUpSection(+ context: viewModel.workContext, identifierPrefix: "capture") } // MARK: - Saved@@ -305,7 +312,7 @@ struct CaptureView: View { // a work without characters, for a projection that names no // existing work, or for a failed read — the row text is nil for an // empty list in all three cases.- if let charactersText = ShareCharacterRow.text(for: viewModel.characters) {+ if let charactersText = ShareCharacterRow.text(for: viewModel.workContext.characters) { ShareCharactersRow( text: charactersText, accessibilityIdentifier: "capture.characters") }@@ -402,23 +409,24 @@ struct CaptureView: View { private var ratingSection: some View { HStack(spacing: 16) {- ratingToggle(rating: .up, symbol: "arrowtriangle.up.fill", label: "Rate up")- ratingToggle(rating: .down, symbol: "arrowtriangle.down.fill", label: "Rate down")+ ratingToggle(rating: .up, label: "Rate up")+ ratingToggle(rating: .down, label: "Rate down") Spacer() } } - private func ratingToggle(rating: Rating, symbol: String, label: String) -> some View {+ private func ratingToggle(rating: Rating, label: String) -> some View {+ // Glyph and style both off the one direction, so the toggle and every+ // read-only echo of a rating cannot drift apart.+ let direction: ConstellationRatingToggleStyle.Direction = rating == .up ? .up : .down let isSelected = viewModel.currentRating == rating return Button { viewModel.toggleRating(rating) } label: {- Image(systemName: symbol)+ Image(systemName: direction.symbolName) .font(.title3) }- .buttonStyle(- .constellationRatingToggle(rating == .up ? .up : .down, isActive: isSelected)- )+ .buttonStyle(.constellationRatingToggle(direction, isActive: isSelected)) .disabled(viewModel.isSaving) .accessibilityLabel(label) .accessibilityIdentifier("capture.rating.\(rating.rawValue)")@@ -483,8 +491,9 @@ final class ObservableCaptureViewModel: ObservableObject { private var suppressDidSet = false @Published var state: CaptureViewState- /// The projected work's cast, filled in after the sheet renders (T-1916).- @Published var characters: [ShareCharacter] = []+ /// The projected work's cast and notes, filled in after the sheet renders+ /// (T-1916, T-1917).+ @Published var workContext: ShareWorkContext = .empty @Published var manualTitle: String = "" { didSet { guard !suppressDidSet else { return }@@ -562,33 +571,34 @@ final class ObservableCaptureViewModel: ObservableObject { func refreshState() { suppressDidSet = true state = viewModel.state- characters = viewModel.displayedCharacters+ workContext = viewModel.displayedWorkContext // 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- scheduleCharacterLoad()+ scheduleWorkContextLoad() } - /// Fills the characters row in from the projected work id (T-1916, Q13).+ /// Fills the characters row and the catch-up section in from the projected+ /// work id (T-1916 Q13, T-1917). /// /// Every awaited view-model call refreshes through `refreshState()` — the /// initial load from `ShareCaptureRootView`, each setter, the post-save /// re-projection — so triggering the read here needs no caller list. The /// read is spawned, never awaited: the first render must not wait on it. ///- /// The "needs a read" rule lives in `CaptureViewModel.needsCharacterLoad`,- /// which `loadCharactersIfNeeded` itself returns early on (Q23) — the bridge+ /// The "needs a read" rule lives in `CaptureViewModel.needsWorkContextLoad`,+ /// which `loadWorkContextIfNeeded` itself returns early on (Q23) — the bridge /// keeps no copy of it. It refreshes only when the read published something, /// so a no-op read cannot start another refresh and the spin Q21 guarded /// against is gone even without the check; the check remains so a refresh /// per keystroke does not spawn a task per keystroke to find that out.- private func scheduleCharacterLoad() {- guard let coordinator, viewModel.needsCharacterLoad else { return }+ private func scheduleWorkContextLoad() {+ guard let coordinator, viewModel.needsWorkContextLoad else { return } Task { [weak self] in guard let self else { return }- if await self.viewModel.loadCharactersIfNeeded(coordinator: coordinator) {+ if await self.viewModel.loadWorkContextIfNeeded(coordinator: coordinator) { self.refreshState() } }
diff --git a/Asterism/AsterismShareExtension/ReShareCaptureView.swift b/Asterism/AsterismShareExtension/ReShareCaptureView.swiftindex 585e65f..d68a46e 100644--- a/Asterism/AsterismShareExtension/ReShareCaptureView.swift+++ b/Asterism/AsterismShareExtension/ReShareCaptureView.swift@@ -130,7 +130,7 @@ struct ReShareCaptureView: View { // The work's cast (T-1916, Q6). One wrapping text row; nothing renders // for a work without characters, which is also what a failed read // leaves behind — the row text is nil for an empty list.- if let charactersText = ShareCharacterRow.text(for: state.characters) {+ if let charactersText = ShareCharacterRow.text(for: state.workContext.characters) { ShareCharactersRow( text: charactersText, accessibilityIdentifier: "reshare.characters") .frame(maxWidth: .infinity, alignment: .leading)@@ -146,6 +146,12 @@ struct ReShareCaptureView: View { // Note section (prefilled, cursor at end) noteSection++ // The work's notes and the chapter note before this one, under the+ // editor (Decision 1). The entry being edited is excluded by the read,+ // so this never repeats the editor's own content back.+ ShareCatchUpSection(+ context: state.workContext, identifierPrefix: "reshare") } private func editBanner(firstCapturedAt: Date) -> some View {@@ -234,23 +240,24 @@ struct ReShareCaptureView: View { private var ratingSection: some View { HStack(spacing: 16) {- ratingToggle(rating: .up, symbol: "arrowtriangle.up.fill", label: "Rate up")- ratingToggle(rating: .down, symbol: "arrowtriangle.down.fill", label: "Rate down")+ ratingToggle(rating: .up, label: "Rate up")+ ratingToggle(rating: .down, label: "Rate down") Spacer() } } - private func ratingToggle(rating: Rating, symbol: String, label: String) -> some View {+ private func ratingToggle(rating: Rating, label: String) -> some View {+ // Glyph and style both off the one direction, so the toggle and every+ // read-only echo of a rating cannot drift apart.+ let direction: ConstellationRatingToggleStyle.Direction = rating == .up ? .up : .down let isSelected = viewModel.draftRating == rating return Button { viewModel.toggleRating(rating) } label: {- Image(systemName: symbol)+ Image(systemName: direction.symbolName) .font(.title3) }- .buttonStyle(- .constellationRatingToggle(rating == .up ? .up : .down, isActive: isSelected)- )+ .buttonStyle(.constellationRatingToggle(direction, isActive: isSelected)) .disabled(viewModel.isSaving) .accessibilityLabel(label) .accessibilityIdentifier("reshare.rating.\(rating.rawValue)")
diff --git a/Asterism/AsterismShareExtension/ShareCatchUpSection.swift b/Asterism/AsterismShareExtension/ShareCatchUpSection.swiftnew file mode 100644index 0000000..0c9f87d--- /dev/null+++ b/Asterism/AsterismShareExtension/ShareCatchUpSection.swift@@ -0,0 +1,162 @@+import AsterismCore+import ConstellationKit+import SwiftUI++/// "Catch up" — the read-only notes both capture sheets show under the note+/// editor for a work they already know (T-1917): the work's own notes, then its+/// most recent chapter note in full.+///+/// Below the editor by Decision 1, so the thing the reader came to do keeps its+/// place; text only, no focusable control, no clamp and no expand affordance, as+/// the work page renders the same notes.+///+/// The view decides nothing. Whether there is anything to draw is+/// `ShareWorkContext.hasCatchUp` (Q11), the heading line and the block's+/// accessibility label are `ShareLastNoteRow`'s (Q12), and the strings arrive+/// trimmed from the read — so this file lays out text and formats no date.+struct ShareCatchUpSection: View {+ /// The whole context rather than its two note fields: the render test lives+ /// on it, and the caller passing one value cannot pass a work note from one+ /// work with a last note from another.+ let context: ShareWorkContext+ /// `"capture"` or `"reshare"` — the sheet this section belongs to, which+ /// prefixes every identifier below.+ let identifierPrefix: String++ var body: some View {+ if context.hasCatchUp {+ VStack(alignment: .leading, spacing: 4) {+ // The default cyan accent, as `noteSection` above it uses it.+ // The section's identifier rides the header rather than the+ // card: a container's identifier is inherited by every+ // descendant and would shadow the two below it+ // (`docs/agent-notes/testing.md`), and the header is already the+ // static-text element sections are addressed by.+ ConstellationSectionHeader("Catch up")+ .accessibilityIdentifier("\(identifierPrefix).catchUp")+ VStack(alignment: .leading, spacing: 12) {+ if !context.workNote.isEmpty {+ workNotes(context.workNote)+ }+ if let lastNote = context.lastNote {+ lastNoteBlock(lastNote)+ }+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding()+ .constellationField()+ }+ }+ }++ /// The work's own notes as a plain paragraph — no heading of its own, as the+ /// work page reads them as the lede of the page.+ private func workNotes(_ text: String) -> some View {+ Text(text)+ .font(.subheadline)+ .foregroundStyle(AsterismColors.noteText)+ .lineSpacing(4)+ .fixedSize(horizontal: false, vertical: true)+ .frame(maxWidth: .infinity, alignment: .leading)+ .accessibilityLabel("Work notes")+ .accessibilityValue(text)+ .accessibilityIdentifier("\(identifierPrefix).workNotes")+ }++ /// The heading line, its rating glyph, and the note underneath in full.+ private func lastNoteBlock(_ note: ShareLastNote) -> some View {+ VStack(alignment: .leading, spacing: 4) {+ // `.firstTextBaseline`, so the glyph stays beside the heading's+ // first line when a long chapter title wraps — where+ // `ShareCharactersRow` puts its own.+ HStack(alignment: .firstTextBaseline, spacing: 6) {+ if let direction = ratingDirection(note.rating) {+ Image(systemName: direction.symbolName)+ .font(.caption)+ .foregroundStyle(direction.accent)+ .accessibilityHidden(true)+ }+ Text(ShareLastNoteRow.heading(for: note))+ .font(.footnote.weight(.semibold))+ .fixedSize(horizontal: false, vertical: true)+ }+ .frame(maxWidth: .infinity, alignment: .leading)++ Text(note.note)+ .font(.subheadline)+ .foregroundStyle(AsterismColors.noteText)+ .lineSpacing(4)+ .fixedSize(horizontal: false, vertical: true)+ .frame(maxWidth: .infinity, alignment: .leading)+ }+ .accessibilityElement(children: .combine)+ .accessibilityLabel(ShareLastNoteRow.accessibilityLabel(for: note))+ .accessibilityValue(note.note)+ .accessibilityIdentifier("\(identifierPrefix).lastNote")+ }++ /// The rating toggles' own direction, which carries the glyph and its+ /// accent — read off the one place that spells them, so a rated chapter+ /// reads the same here as it does on the sheet above.+ private func ratingDirection(_ rating: Rating?) -> ConstellationRatingToggleStyle.Direction? {+ switch rating {+ case .up: .up+ case .down: .down+ case nil: nil+ }+ }+}++// The share extension cannot be driven from an XCUITest (see+// `docs/agent-notes/composed-teaching-ui.md`), so these previews are how the+// layout gets looked at: a long note under a wrapping title, at the default+// text size and at the largest accessibility one.++#if DEBUG+private let catchUpSample = ShareWorkContext(+ characters: [],+ workNote: "A slow-burn serial about a courier who keeps taking the long way "+ + "round. Reread the first arc before starting book two.",+ lastNote: ShareLastNote(+ title: "Chapter Forty-One — The Long Way Round, and What Waited at the End of It",+ note: """+ Kest finally admits she has been carrying the second letter the whole \+ time, which recontextualises the argument at the bridge two chapters \+ back. The courier's guild is not the villain here — the tolls are — and \+ the narrator keeps saying so in ways nobody in the story hears.++ Pick up from the harbour scene: everyone who mattered is in one room \+ and nobody has said the quiet part yet.+ """,+ rating: .up,+ lastSharedAt: Date(timeIntervalSince1970: 1_773_446_400)))++#Preview("Catch up") {+ ScrollView {+ ShareCatchUpSection(context: catchUpSample, identifierPrefix: "capture")+ .padding()+ }+}++#Preview("Catch up — accessibility text") {+ ScrollView {+ ShareCatchUpSection(context: catchUpSample, identifierPrefix: "capture")+ .padding()+ }+ .environment(\.dynamicTypeSize, .accessibility5)+}++#Preview("Catch up — last note only, rated down") {+ ScrollView {+ ShareCatchUpSection(+ context: ShareWorkContext(+ lastNote: ShareLastNote(+ title: "",+ note: "Dropped it. The second POV never earns its chapters.",+ rating: .down,+ lastSharedAt: Date(timeIntervalSince1970: 1_773_446_400))),+ identifierPrefix: "reshare")+ .padding()+ }+}+#endif
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 27a9481..d29d183 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,48 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Share sheet last note — the Catch up section (share-sheet-last-note,+ phase 2: Extension, T-1917).** Both capture sheets show a read-only "Catch+ up" section under the note editor whenever the share resolves to an existing+ work and the context has something to show: the work's own notes as a+ paragraph, then the last chapter note as `{title} · {date}` with the rating+ glyph beside the heading's first line and the note text in full — no clamp,+ no expand control, no focusable control (Decision 1). The section renders+ nothing at all, header included, when the work has neither. Heading and+ accessibility label ("Last note on {title}, {date}", ", rated up/down")+ are produced in core (`ShareLastNoteRow.heading(for:)` /+ `accessibilityLabel(for:)`, Q12), so the extension formats no dates; the+ render test is `ShareWorkContext.hasCatchUp` (Q11). The `catchUp`+ identifiers sit on the section header so the paragraph and block stay+ queryable (Q14). A notes-half read failure now logs at debug level under+ `category:ShareWorkContext` (Q13). Previews of the section live under+ `#if DEBUG`. The layout was checked through SwiftUI previews at the default+ and largest accessibility text sizes, not on a simulator — the extension+ cannot be driven there without a host app.++- **Share sheet last note — core read (share-sheet-last-note, phase 1: Core+ read, T-1917).** The T-1916 characters read is widened into one+ `ShareWorkContext` read: the work's characters as before, then — in its own+ catch, so a corrupt row elsewhere in the work empties the notes without+ taking the characters down (Decision 2) — the carrier row's `genericNotes`+ and the last chapter note: the nearest noted chapter *before* the one being+ shared, placed on the Chapter view's own scale (`ChapterPlacement`: URL+ position, else chapter key), ties at one placement by newest `lastSharedAt`+ then UUID, the edited entry excluded on re-share (Decision 4, which+ superseded Decision 3's newest-note rule after on-device testing showed a+ later chapter's note surfacing on an earlier chapter). An unplaceable+ current chapter shows the work notes alone; the new-capture read re-keys on+ work id plus chapter anchor. Duplicate, split and torn+ rows present as the work page presents them — carrier or leading variant, no+ marker (Q8). `ShareLastNoteRow.heading(for:)` produces `{title} · {date}`+ (date alone for an empty title; the title trimmed, Q10) in core. The re-share+ lookup carries the context inside its existing locked read and keeps it+ across a `.stale` commit; the pending-capture drain never reads it; the+ new-capture sheet reads under the unchanged token guard. `ShareCharacters.swift` becomes `ShareWorkContext.swift`;+ `ReShareEditBasis.workContext`, `CaptureCoordinating.shareWorkContext(workID:currentChapterSequence:currentChapterTitle:)`+ and the `CaptureViewModel` renames replace the characters-only names. No+ schema change; the extension's Catch up section itself is phase 2.+ - **Site-wide chapter ids order the spine but never label it (work-detail-reading-redesign, Q25–Q26).** On Royal Road the URL rule's chapter sequence is the site-wide chapter id, so the spine showed seven-digit "chapter
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift b/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swiftindex be555c2..be04217 100644--- a/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift@@ -31,6 +31,39 @@ public enum CaptureViewModelError: Error, Sendable, Equatable { case invalidState(String) } +/// What one work-context read is keyed on: the work the sheet projects and+/// *where in it* the chapter it is about to add sits.+///+/// T-1916 keyed the read on the work id alone. Decision 4 of+/// `share-sheet-last-note` made the catch-up selection depend on the reader's+/// position in the work, so a draft edit that moves the projected chapter+/// within one work — a manual title, a different URL — has to read again.+///+/// The key is the *derived* placement, not the raw chapter pair the projection+/// carries. The read itself derives the same placement (`ChapterPlacement.of`)+/// and selects against nothing else, so two raw pairs that place the same+/// chapter ask the same question and deserve one answer. Keying on the raw+/// strings instead made every keystroke of a manual title its own key: typing+/// "Chapter 6" issued a locked read per character and collapsed the catch-up+/// section between each one. An edit that genuinely *moves* the placement+/// ("Chapter 5" → "Chapter 55") still reads again, which is the point.+internal struct WorkContextKey: Sendable, Equatable {+ let workID: UUID+ /// Nil where the chapter has no placement at all — the read shows the work+ /// notes alone then, and every unplaceable draft is that one state.+ let placement: ChapterPlacement?++ /// The key a projection amounts to, or nil where it names no existing work+ /// — a create, an ambiguity, a failed projection, a blank title.+ init?(_ outcome: CaptureOutcome?) {+ guard let outcome, let workID = outcome.projectedWorkID else { return nil }+ self.workID = workID+ self.placement = ChapterPlacement.of(+ chapterSequence: outcome.composedChapterSequence,+ chapterTitle: outcome.projectedChapter)+ }+}+ /// 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.@@ -51,38 +84,46 @@ public final class CaptureViewModel { /// The generation at which the current projection was produced. private var projectionGeneration: Int = -1 - /// The cast of the projected work, for the characters row (T-1916).- /// Empty until a read returns, and after a read that failed — a work without- /// characters and a failed read are one state on the sheet.- public private(set) var characters: [ShareCharacter] = []-- /// The work id `characters` describes, and the one a read is out for.- /// Both key on the work rather than on `generation`, which advances on every- /// keystroke and would discard a read the reader started typing during (Q12).- internal private(set) var charactersWorkID: UUID?- internal private(set) var inFlightWorkID: UUID?-- /// Identifies the read that owns `inFlightWorkID`, so a read only releases- /// its own marker. The work id cannot do that job: two reads for the same- /// work carry the same id.- private var characterReadToken = 0-- /// The cast the sheet may draw now: `characters` only while it describes the- /// work the sheet projects (Q22). A projection that moves to another work —- /// or to one that does not exist yet — shows nothing until its own read- /// lands, rather than the previous work's cast under the new work's title.- public var displayedCharacters: [ShareCharacter] {- charactersWorkID == currentOutcome?.projectedWorkID ? characters : []+ /// The projected work's cast and notes, for the characters row (T-1916) and+ /// the catch-up section (T-1917). `.empty` until a read returns, and after a+ /// read that failed — a work with neither and a failed read are one state on+ /// the sheet.+ public private(set) var workContext: ShareWorkContext = .empty++ /// What `workContext` describes, and what a read is out for. Both key on the+ /// projection rather than on `generation`, which advances on every keystroke+ /// and would discard a read the reader started typing during (Q12).+ internal private(set) var contextKey: WorkContextKey?+ internal private(set) var inFlightKey: WorkContextKey?++ /// Identifies the read that owns `inFlightKey`, so a read only releases its+ /// own marker. The key cannot do that job: two reads for the same work and+ /// chapter carry the same key.+ private var contextReadToken = 0++ /// The key the sheet's current projection asks for, or nil where it names no+ /// existing work.+ private var currentContextKey: WorkContextKey? { WorkContextKey(currentOutcome) }++ /// The context the sheet may draw now: `workContext` only while it describes+ /// the projection the sheet holds (Q22). A projection that moves to another+ /// work — or to one that does not exist yet — shows nothing until its own+ /// read lands, rather than the previous work's cast and notes under the new+ /// work's title. A move within one work is the same rule for the same+ /// reason: the last note the previous read chose was chosen against another+ /// chapter, and may now be one from ahead of it (Decision 4).+ public var displayedWorkContext: ShareWorkContext {+ contextKey == currentContextKey ? workContext : .empty } - /// Whether the projected work still needs a read (Q23). The one predicate:- /// `loadCharactersIfNeeded` returns early on it, and the bridge consults it+ /// Whether the projection still needs a read (Q23). The one predicate:+ /// `loadWorkContextIfNeeded` returns early on it, and the bridge consults it /// before spawning a task rather than keeping a copy of the rule.- public var needsCharacterLoad: Bool {- guard let target = currentOutcome?.projectedWorkID else {- return charactersWorkID != nil || inFlightWorkID != nil+ public var needsWorkContextLoad: Bool {+ guard let target = currentContextKey else {+ return contextKey != nil || inFlightKey != nil }- return target != charactersWorkID && target != inFlightWorkID+ return target != contextKey && target != inFlightKey } /// Whether save is currently possible (ready or failed states, with valid title and projection).@@ -194,56 +235,64 @@ public final class CaptureViewModel { await reprojectIfNeeded(coordinator: coordinator) } - // MARK: - Characters (T-1916)+ // MARK: - Work context (T-1916, T-1917) - /// Read the projected work's cast, at most once per work id.+ /// Read the projected work's cast and notes, at most once per projection key. /// /// Called after every published state change. A projection that names no /// existing work — `.create`, `.ambiguous`, a failed projection, a blank- /// title — clears the row; a projection that names the same work as the last- /// read, which is every keystroke of a note edit, reads nothing.+ /// title — clears them; a projection naming the same work *and* placing its+ /// chapter where the last read placed it — every keystroke of a note edit,+ /// and every keystroke of a title edit that does not move the chapter —+ /// reads nothing. ///- /// Never throws: a failed read leaves the sheet in the state a work without- /// characters produces.+ /// Never throws: a failed read leaves the sheet in the state a work with+ /// neither characters nor notes produces. ///- /// - Returns: Whether the published cast changed, so the caller knows+ /// - Returns: Whether the published context changed, so the caller knows /// whether anything is worth redrawing (Q23). A read the projection /// superseded, and a call with nothing to do, both answer `false`. @discardableResult- public func loadCharactersIfNeeded(coordinator: any CaptureCoordinating) async -> Bool {- guard needsCharacterLoad else { return false }- guard let target = currentOutcome?.projectedWorkID else {- let wasPublished = charactersWorkID != nil || !characters.isEmpty- characters = []- charactersWorkID = nil- inFlightWorkID = nil+ public func loadWorkContextIfNeeded(coordinator: any CaptureCoordinating) async -> Bool {+ guard needsWorkContextLoad else { return false }+ guard let outcome = currentOutcome, let target = WorkContextKey(outcome) else {+ let wasPublished = contextKey != nil || workContext != .empty+ workContext = .empty+ contextKey = nil+ inFlightKey = nil return wasPublished } - characterReadToken += 1- let token = characterReadToken- inFlightWorkID = target- let fetched: [ShareCharacter]+ contextReadToken += 1+ let token = contextReadToken+ inFlightKey = target+ let fetched: ShareWorkContext do {- fetched = try await coordinator.shareCharacters(workID: target)+ // The anchor is the projection's raw pair, not the key's placement:+ // the read derives the placement itself, from the same fields.+ fetched = try await coordinator.shareWorkContext(+ workID: target.workID,+ currentChapterSequence: outcome.composedChapterSequence,+ currentChapterTitle: outcome.projectedChapter) } catch {- // The row is the reader's convenience, not their capture: the cause- // reaches diagnostics, the sheet draws a characterless work (Q15).+ // The row and the section are the reader's convenience, not their+ // capture: the cause reaches diagnostics, the sheet draws a+ // characterless, noteless work (Q15). captureViewModelLogger.debug(- "Capture characters: read failed — \(String(describing: error), privacy: .public)")- fetched = []+ "Capture work context: read failed — \(String(describing: error), privacy: .public)")+ fetched = .empty } // Release the marker whenever it is still this read's, so a read // superseded before it returned does not block a later one for the same- // work. The token, not the work id: a later read for the *same* work- // sets the same id, and must keep its own marker.- if characterReadToken == token { inFlightWorkID = nil }-- // Publish only while the sheet still projects the work that was read.- guard currentOutcome?.projectedWorkID == target else { return false }- let changed = charactersWorkID != target || characters != fetched- charactersWorkID = target- characters = fetched+ // work. The token, not the key: a later read for the *same* projection+ // sets the same key, and must keep its own marker.+ if contextReadToken == token { inFlightKey = nil }++ // Publish only while the sheet still projects what was read.+ guard currentContextKey == target else { return false }+ let changed = contextKey != target || workContext != fetched+ contextKey = target+ workContext = fetched return changed }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swift b/Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swiftindex 7b96d9e..81745c7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ChapterKeyDerivation.swift@@ -39,6 +39,77 @@ public struct ChapterKey: Sendable, Hashable, Comparable { } } +/// Where a chapter sits in its work, on the one scale that orders the work+/// page's Chapter view, the citation gutter and the share sheet's catch-up+/// selection (Decision 4 of `share-sheet-last-note`).+///+/// Two scales that cannot interleave: a site-wide id from the URL rule's+/// sequence (`position`) and a chapter number (`key`) say nothing about each+/// other, so every positioned chapter sorts before every keyed one rather than+/// guessing a place among them (Q26). That is `WorkDetailModel`'s+/// `precedesInChapterOrder` rule, and this is the type it compares with — the+/// spine and the sheet cannot drift into two answers about order.+///+/// A chapter with neither has no placement at all: it is `nil` here, never a+/// third case, because "unplaced" is not a position on the scale. The spine+/// falls back to `lastSharedAt` for such a row; the catch-up selection drops it.+public enum ChapterPlacement: Sendable, Hashable, Comparable {+ /// The URL rule's sequence, where that sequence is a site-wide identifier.+ case position(ChapterKey)+ /// The chapter number, from the sequence or from a prefixed title.+ case key(ChapterKey)++ /// The placement a chapter row's two derived fields amount to, or nil where+ /// the row carries neither. The position wins where both are present, as it+ /// does in the spine's order.+ public init?(sequencePosition: ChapterKey?, chapterKey: ChapterKey?) {+ if let sequencePosition {+ self = .position(sequencePosition)+ } else if let chapterKey {+ self = .key(chapterKey)+ } else {+ return nil+ }+ }++ /// The same, derived from the raw fields — the read's path, where the+ /// derived pair is not already in hand.+ ///+ /// The sequence's first number group is parsed once and branched on, rather+ /// than through `position` and `key` in turn: those two answer opposite+ /// halves of the same `isChapterNumber` question about the same group, and+ /// asking both parses every sequence twice. The share sheet's catch-up+ /// selection calls this once per entry of a work.+ static func of(chapterSequence: String?, chapterTitle: String?) -> ChapterPlacement? {+ if let sequence = chapterSequence,+ let group = ChapterKeyDerivation.firstNumberGroup(in: Array(sequence))+ {+ // A number the site could plausibly be counting chapters with is the+ // key; anything larger is a site-wide id, which positions (Q25, Q26).+ return ChapterKeyDerivation.isChapterNumber(group.key)+ ? .key(group.key) : .position(group.key)+ }+ // No usable sequence, so the title rule gets its turn — and a position+ // could only have come from the sequence.+ guard let key = ChapterKeyDerivation.key(+ chapterSequence: nil, chapterTitle: chapterTitle)+ else { return nil }+ return .key(key)+ }++ /// Position before position by key, every position before every key, key+ /// before key by key. Total and consistent with `==`, so `max` over a set of+ /// placements is the nearest one to the end of the story.+ public static func < (lhs: ChapterPlacement, rhs: ChapterPlacement) -> Bool {+ switch (lhs, rhs) {+ case (.position(let left), .position(let right)): return left < right+ case (.position, .key): return true+ case (.key, .position): return false+ case (.key(let left), .key(let right)): return left < right+ }+ }+}+ /// Derives a chapter's key and the title the spine row shows beside it. nonisolated enum ChapterKeyDerivation { @@ -95,7 +166,10 @@ nonisolated enum ChapterKeyDerivation { /// Q25: every part at or under the limit. A group with a part past it is /// an identifier as a whole; no part of it is drawn.- private static func isChapterNumber(_ key: ChapterKey) -> Bool {+ ///+ /// `fileprivate` for `ChapterPlacement.of`, which branches on this rather+ /// than asking `key` and `position` the same question twice.+ fileprivate static func isChapterNumber(_ key: ChapterKey) -> Bool { key.components.allSatisfy { $0 <= largestChapterNumber } } @@ -162,9 +236,11 @@ nonisolated enum ChapterKeyDerivation { } /// The first number group anywhere in the characters — only the first; a- /// later, smaller one is never tried in its place.- private static func firstNumberGroup(in characters: [Character]) -> (key: ChapterKey, end: Int)?- {+ /// later, smaller one is never tried in its place. `fileprivate` for+ /// `ChapterPlacement.of`, which parses the sequence once and branches.+ fileprivate static func firstNumberGroup(in characters: [Character]) -> (+ key: ChapterKey, end: Int+ )? { guard let start = characters.firstIndex(where: isASCIIDigit) else { return nil } return numberGroup(characters, from: start) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swiftindex 40ab915..5f27f54 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift@@ -55,13 +55,14 @@ public struct ReShareEditBasis: Sendable, Equatable { public let persistedModifiedAt: Date /// Immutable first-capture time for banner formatting (Req 4.2). public let firstCapturedAt: Date- /// The cast of the work this entry belongs to, for the sheet's characters- /// row (T-1916, Q8). Display-only, like `title` and `firstCapturedAt`: the- /// staleness comparison on commit does not look at it. Empty unless the- /// lookup was asked for it, and empty whenever the read failed (Q15).+ /// The work this entry belongs to, as the sheet shows it: the cast for the+ /// characters row (T-1916, Q8) and the notes for the catch-up section+ /// (T-1917). Display-only, like `title` and `firstCapturedAt`: the+ /// staleness comparison on commit does not look at it. `.empty` unless the+ /// lookup was asked for it, and `.empty` whenever the read failed (Q15). /// Only a lookup-produced basis can carry one: the commit path's bases —- /// the pre-insert race guard's and `.stale`'s — walk no characters (Q11).- public let characters: [ShareCharacter]+ /// the pre-insert race guard's and `.stale`'s — read no work context (Q11).+ public let workContext: ShareWorkContext public init( entryID: UUID,@@ -72,7 +73,7 @@ public struct ReShareEditBasis: Sendable, Equatable { persistedRating: Rating?, persistedModifiedAt: Date, firstCapturedAt: Date,- characters: [ShareCharacter] = []+ workContext: ShareWorkContext = .empty ) { self.entryID = entryID self.hostname = hostname@@ -82,14 +83,14 @@ public struct ReShareEditBasis: Sendable, Equatable { self.persistedRating = persistedRating self.persistedModifiedAt = persistedModifiedAt self.firstCapturedAt = firstCapturedAt- self.characters = characters+ self.workContext = workContext } - /// The same basis carrying a cast. The lookup builds the basis before it- /// knows whether a character read is wanted, so the cast is attached rather- /// than threaded through `reShareBasis(hostname:)`, which the commit path- /// also calls and must keep answering without one.- public func with(characters: [ShareCharacter]) -> ReShareEditBasis {+ /// The same basis carrying a work context. The lookup builds the basis+ /// before it knows whether the read is wanted, so the context is attached+ /// rather than threaded through `reShareBasis(hostname:)`, which the commit+ /// path also calls and must keep answering without one.+ public func with(workContext: ShareWorkContext) -> ReShareEditBasis { ReShareEditBasis( entryID: entryID, hostname: hostname,@@ -99,7 +100,7 @@ public struct ReShareEditBasis: Sendable, Equatable { persistedRating: persistedRating, persistedModifiedAt: persistedModifiedAt, firstCapturedAt: firstCapturedAt,- characters: characters)+ workContext: workContext) } } @@ -163,27 +164,28 @@ extension LibraryRepository { try await captureLookup(rawURL: rawURL, captureTitle: nil) } - /// Protocol-facing lookup without a character read. Kept as its own method- /// rather than a default argument because a protocol witness has to spell- /// the requirement's full name.+ /// Protocol-facing lookup without a work-context read. Kept as its own+ /// method rather than a default argument because a protocol witness has to+ /// spell the requirement's full name. public func captureLookup( rawURL: String, captureTitle: String? ) async throws -> CaptureLookupDisposition { try await captureLookup(- rawURL: rawURL, captureTitle: captureTitle, includeCharacters: false)+ rawURL: rawURL, captureTitle: captureTitle, includeWorkContext: false) } - /// - Parameter includeCharacters: Whether an `.edit` basis carries the- /// matched work's cast (T-1916). Opt-in because only the extension's- /// re-share sheet displays it: the pending-capture drain runs the same- /// lookup for every spooled record and reads nothing but the entry id- /// and the identity key from it, and the character walk canonicalises- /// each row's facts through JSON on the way (Q10).+ /// - Parameter includeWorkContext: Whether an `.edit` basis carries the+ /// matched work's cast and notes (T-1916, T-1917). Opt-in because only the+ /// extension's re-share sheet displays them: the pending-capture drain+ /// runs the same lookup for every spooled record and reads nothing but the+ /// entry id and the identity key from it, while the character walk+ /// canonicalises each row's facts through JSON on the way and the notes+ /// half fetches the work type directory (Q10, Decision 2). public func captureLookup( rawURL: String, captureTitle: String?,- includeCharacters: Bool+ includeWorkContext: Bool ) async throws -> CaptureLookupDisposition { let hostname: String do {@@ -256,24 +258,34 @@ extension LibraryRepository { let disposition = Self.lookupDisposition( fromGroups: groups, hostname: hostname, identityKey: rawURL) - // Still inside the same shared read: the cast the sheet shows comes+ // Still inside the same shared read: what the sheet shows comes // from the state the disposition was decided on, over the one // `entryGroups` pass the disposition was decided from (Q24). // `.values.first` is the group the basis describes: `.edit` is // returned only where `groups.count == 1`.- guard includeCharacters, case .edit(let basis) = disposition,- let workID = groups.values.first?.carrier.work?.id+ guard includeWorkContext, case .edit(let basis) = disposition,+ let matched = groups.values.first,+ let workID = matched.carrier.work?.id else { return disposition } do {- return .edit(basis.with(- characters: try Self.shareCharacters(forWorkID: workID, context: context)))+ // The edited entry is excluded: its note is already the+ // editor's content, so the block shows the one before it. Which+ // notes are "before" is decided by the matched entry's own+ // chapter — the carrier's, the row whose content this sheet is+ // editing (Decision 4).+ return .edit(basis.with(workContext: try Self.shareWorkContext(+ forWorkID: workID, excludingEntryID: basis.entryID,+ currentChapterSequence: matched.carrier.chapterSequence,+ currentChapterTitle: matched.carrier.chapterTitle,+ context: context))) } catch {- // The row is the reader's convenience, not their capture: a- // failed read leaves the sheet exactly as a characterless work- // leaves it, and says so in diagnostics only (Q15).+ // The row and the section are the reader's convenience, not+ // their capture: a failed read leaves the sheet exactly as a+ // characterless, noteless work leaves it, and says so in+ // diagnostics only (Q15). captureLogger.debug(- "Capture lookup: character read failed — \(String(describing: error), privacy: .public)")+ "Capture lookup: work context read failed — \(String(describing: error), privacy: .public)") return disposition } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift b/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swiftindex 5a06e94..bcb35b4 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift@@ -26,10 +26,11 @@ public struct ReShareEditState: Sendable, Equatable { public let persistedNote: String public let persistedRating: Rating? public let firstCapturedAt: Date- /// The cast of the work this entry belongs to (T-1916). Empty where the work- /// has none, where the read failed, or where the lookup was never asked —- /// all three draw the same sheet.- public let characters: [ShareCharacter]+ /// The work this entry belongs to, as the sheet shows it: its cast+ /// (T-1916) and its notes (T-1917). `.empty` where the work has neither,+ /// where the read failed, or where the lookup was never asked — all three+ /// draw the same sheet.+ public let workContext: ShareWorkContext public var draftNote: String public var draftRating: Rating? public var cursorAtEnd: Bool@@ -45,7 +46,7 @@ public struct ReShareEditState: Sendable, Equatable { draftRating: Rating?, cursorAtEnd: Bool = true, errorMessage: String? = nil,- characters: [ShareCharacter] = []+ workContext: ShareWorkContext = .empty ) { self.entryID = entryID self.title = title@@ -56,7 +57,7 @@ public struct ReShareEditState: Sendable, Equatable { self.draftRating = draftRating self.cursorAtEnd = cursorAtEnd self.errorMessage = errorMessage- self.characters = characters+ self.workContext = workContext } } @@ -177,7 +178,7 @@ public final class LookupCaptureViewModel { draftRating: basis.persistedRating, cursorAtEnd: true, errorMessage: nil,- characters: basis.characters+ workContext: basis.workContext )) case .new(let basis):@@ -258,10 +259,10 @@ public final class LookupCaptureViewModel { draftRating: draftRating, cursorAtEnd: false, errorMessage: nil,- // The refreshed basis comes from the commit path, which walks no- // characters (Q11): the cast the reader is looking at carries+ // The refreshed basis comes from the commit path, which reads+ // no work context (Q11): what the reader is looking at carries // forward rather than blinking out between two taps of Update.- characters: editState.characters+ workContext: editState.workContext )) case .invalidated(let reason):
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ReSharePresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/ReSharePresentation.swiftindex 638d997..5b230c2 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ReSharePresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ReSharePresentation.swift@@ -2,6 +2,63 @@ import Foundation // MARK: - Re-share UI presentation helpers (Design §9.4, §9.5) +/// The one medium-style date the share sheets write.+///+/// Both dated lines of the re-share sheet read alike because they read the same+/// function (T-1917 Q9): the banner's "Noted …" and the last note's heading.+/// Cached because the heading is produced inside a SwiftUI body, which+/// re-evaluates on every keystroke in the note editor, and a `DateFormatter` is+/// expensive to build.+public enum ShareDateText {++ /// `.medium` date, no time — `Mar 14, 2026` in `en_US`.+ public static func medium(+ _ date: Date,+ locale: Locale = .current,+ calendar: Calendar = .current,+ timeZone: TimeZone = .current+ ) -> String {+ formatters.string(from: date, locale: locale, calendar: calendar, timeZone: timeZone)+ }++ private static let formatters = MediumDateFormatterCache()+}++/// One `DateFormatter` per (locale, calendar, time zone), formatting *inside*+/// the lock.+///+/// `DateFormatter` is not thread-safe, so the cache hands back strings rather+/// than formatters — a shared formatter handed to two callers would be the bug+/// this cache exists to avoid rather than cause. The key set is bounded by the+/// device's region settings, so it never grows.+private final class MediumDateFormatterCache: @unchecked Sendable {+ private struct Key: Hashable {+ let locale: String+ let calendar: Calendar.Identifier+ let timeZone: String+ }++ private let lock = NSLock()+ private var formatters: [Key: DateFormatter] = [:]++ func string(from date: Date, locale: Locale, calendar: Calendar, timeZone: TimeZone) -> String {+ let key = Key(+ locale: locale.identifier, calendar: calendar.identifier,+ timeZone: timeZone.identifier)+ return lock.withLock {+ if let formatter = formatters[key] { return formatter.string(from: date) }+ let formatter = DateFormatter()+ formatter.dateStyle = .medium+ formatter.timeStyle = .none+ formatter.locale = locale+ formatter.calendar = calendar+ formatter.timeZone = timeZone+ formatters[key] = formatter+ return formatter.string(from: date)+ }+ }+}+ /// Formats the re-share banner text from firstCapturedAt (Req 4.2). /// Format: "Noted <date> — editing existing entry" public enum ReShareBannerFormatter {@@ -11,13 +68,8 @@ public enum ReShareBannerFormatter { calendar: Calendar = .current, timeZone: TimeZone = .current ) -> String {- let dateFormatter = DateFormatter()- dateFormatter.dateStyle = .medium- dateFormatter.timeStyle = .none- dateFormatter.locale = locale- dateFormatter.calendar = calendar- dateFormatter.timeZone = timeZone- let dateString = dateFormatter.string(from: firstCapturedAt)+ let dateString = ShareDateText.medium(+ firstCapturedAt, locale: locale, calendar: calendar, timeZone: timeZone) return "Noted \(dateString) — editing existing entry" } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swiftindex b131b5e..ef39d5c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift@@ -352,15 +352,24 @@ public protocol CaptureCoordinating: Sendable { 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- /// The cast of the work the sheet projects, for the characters row (T-1916).- /// A requirement rather than an extension-only method so a call through- /// `any CaptureCoordinating` reaches `CaptureCoordinator`'s forwarding read;- /// the default below keeps every existing conformer compiling.- func shareCharacters(workID: UUID) async throws -> [ShareCharacter]+ /// The work the sheet projects, for the characters row (T-1916) and the+ /// catch-up section (T-1917). A requirement rather than an extension-only+ /// method so a call through `any CaptureCoordinating` reaches+ /// `CaptureCoordinator`'s forwarding read; the default below keeps every+ /// existing conformer compiling.+ ///+ /// The two chapter fields are the projection's own — `composedChapterSequence`+ /// and `projectedChapter` — which place the chapter being shared, so the+ /// catch-up section can select the note *before* it (Decision 4).+ func shareWorkContext(+ workID: UUID, currentChapterSequence: String?, currentChapterTitle: String?+ ) async throws -> ShareWorkContext } public extension CaptureCoordinating {- /// No cast unless the conformer reads one — a coordinator that never talks to- /// a library shows no characters rather than failing.- func shareCharacters(workID: UUID) async throws -> [ShareCharacter] { [] }+ /// No context unless the conformer reads one — a coordinator that never+ /// talks to a library shows nothing rather than failing.+ func shareWorkContext(+ workID: UUID, currentChapterSequence: String?, currentChapterTitle: String?+ ) async throws -> ShareWorkContext { .empty } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ShareCharacters.swift b/Packages/AsterismCore/Sources/AsterismCore/ShareCharacters.swiftdeleted file mode 100644index d4b87c9..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/ShareCharacters.swift+++ /dev/null@@ -1,81 +0,0 @@-import Foundation-import SwiftData--// The cast of a work, as the share extension's capture sheets show it (T-1916).-//-// Derived here rather than in the extension for the reason sharesheet-polish Q6-// records for every other row of that card: the extension lays text out, it does-// not decide what the text says. It also keeps the order one answer — the share-// row and the work page sort the same groups through `sortedCharacterGroups`.--/// One character of a work on a capture sheet: what to call them, and what else-/// they are called.-///-/// Name and aliases only (Q2). A fact cites an entry anywhere in the work,-/// including chapters ahead of the one being shared, and the sheet has no-/// spoiler boundary to keep them behind.-public struct ShareCharacter: Sendable, Equatable {- public let name: String- /// In the order `CharacterAuthoredContent` holds them, which is sorted at- /// construction — the order the work page shows too.- public let aliases: [String]-- public init(name: String, aliases: [String] = []) {- self.name = name- self.aliases = aliases- }-}--/// The single wrapping text row both capture sheets draw for a cast (Q6).-public enum ShareCharacterRow {-- /// `Characters: Alice (Al, Ally), Bob`, or nil where there are none.- ///- /// Nil rather than an empty string: a work without characters draws no- /// element at all, so the sheet's state for it is the state for a work whose- /// read failed.- public static func text(for characters: [ShareCharacter]) -> String? {- guard !characters.isEmpty else { return nil }- let listed = characters.map { character in- character.aliases.isEmpty- ? character.name- : "\(character.name) (\(character.aliases.joined(separator: ", ")))"- }- return "Characters: " + listed.joined(separator: ", ")- }-}--// MARK: - The read--extension LibraryRepository {-- /// A work's characters for a capture sheet, in the work page's order.- ///- /// By predicate rather than through `fetchWorkGroup(id:)`: an id the sheet- /// projected but the store does not hold is an empty cast rather than an- /// error, and the group read loads the work type directory, which a name and- /// its aliases need nothing from. Same-UUID duplicate rows are covered- /// exactly as the work page covers them — `characterRows(of:)` unions the- /// rows' inverses — and an orphaned character, unreachable through any of- /// them, is invisible here as it is there (Q7).- internal static func shareCharacters(- forWorkID id: UUID, context: ModelContext- ) throws -> [ShareCharacter] {- let works = try context.fetch(FetchDescriptor<Work>(predicate: #Predicate { $0.id == id }))- guard !works.isEmpty else { return [] }- return sortedCharacterGroups(characterGroups(characterRows(of: works)))- .map { group in- let content = group.presentedContent- return ShareCharacter(name: content.name, aliases: content.aliases)- }- }-- /// The same, as one shared-lock read. Never writes; an unknown id and a work- /// without characters both answer `[]`.- public func shareCharacters(workID: UUID) async throws -> [ShareCharacter] {- try await withLockedContext(mode: .shared, operation: "reading share characters") {- context in- try Self.shareCharacters(forWorkID: workID, context: context)- }- }-}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swift b/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swiftindex 0d4790e..ec4f30e 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swift@@ -337,12 +337,17 @@ public actor CaptureCoordinator: CaptureCoordinating, LookupCaptureCoordinating CaptureContract(basis: contract.basis, request: stamped, outcome: contract.outcome)) } - /// The projected work's cast for the capture sheet's characters row (T-1916).- /// A follow-up shared-lock read rather than part of the projection: the- /// projection is recomputed per keystroke and compared at commit, the cast is- /// not (Q5).- public func shareCharacters(workID: UUID) async throws -> [ShareCharacter] {- try await repository.shareCharacters(workID: workID)+ /// The projected work's cast and notes for the capture sheet (T-1916,+ /// T-1917). A follow-up shared-lock read rather than part of the projection:+ /// the projection is recomputed per keystroke and compared at commit, the+ /// context is not (Q5). No exclusion — this arm has no entry id to exclude+ /// (Q7).+ public func shareWorkContext(+ workID: UUID, currentChapterSequence: String?, currentChapterTitle: String?+ ) async throws -> ShareWorkContext {+ try await repository.shareWorkContext(+ workID: workID, currentChapterSequence: currentChapterSequence,+ currentChapterTitle: currentChapterTitle) } // MARK: - LookupCaptureCoordinating (lookup-first re-share, Decision 4)@@ -351,12 +356,12 @@ public actor CaptureCoordinator: CaptureCoordinating, LookupCaptureCoordinating try await repository.captureLookup(rawURL: rawURL) } - /// The sheet this lookup feeds shows the matched work's cast, so it asks for- /// it here — the one read, inside the lookup the sheet already waits on- /// (T-1916, Q5).+ /// The sheet this lookup feeds shows the matched work's cast and notes, so+ /// it asks for them here — the one read, inside the lookup the sheet already+ /// waits on (T-1916, Q5). public func captureLookup(rawURL: String, captureTitle: String?) async throws -> CaptureLookupDisposition { try await repository.captureLookup(- rawURL: rawURL, captureTitle: captureTitle, includeCharacters: true)+ rawURL: rawURL, captureTitle: captureTitle, includeWorkContext: true) } public func commitReShareUpdate(
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift b/Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swiftnew file mode 100644index 0000000..77f5c98--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift@@ -0,0 +1,336 @@+import Foundation+import OSLog+import SwiftData++// What the share extension's capture sheets show about a work they already+// know: its cast (T-1916) and, beside it, the notes the reader wrote about it+// (T-1917).+//+// Derived here rather than in the extension for the reason sharesheet-polish Q6+// records for every other row of that card: the extension lays text out, it does+// not decide what the text says. It also keeps the order one answer — the share+// row and the work page sort the same groups through `sortedCharacterGroups`,+// and the last note is selected the way the work page orders its chapters.++/// `captureLogger` is file-private to `LibraryRepository+Capture.swift`, so the+/// notes half's own failure line gets its own logger of the same shape, as+/// `CaptureViewModel` does for the sheet's failed read (Q13).+private let shareWorkContextLogger = Logger(+ subsystem: "AsterismCore", category: "ShareWorkContext")++/// One character of a work on a capture sheet: what to call them, and what else+/// they are called.+///+/// Name and aliases only (Q2). A fact cites an entry anywhere in the work,+/// including chapters ahead of the one being shared, and the sheet has no+/// spoiler boundary to keep them behind.+public struct ShareCharacter: Sendable, Equatable {+ public let name: String+ /// In the order `CharacterAuthoredContent` holds them, which is sorted at+ /// construction — the order the work page shows too.+ public let aliases: [String]++ public init(name: String, aliases: [String] = []) {+ self.name = name+ self.aliases = aliases+ }+}++/// The single wrapping text row both capture sheets draw for a cast (Q6).+public enum ShareCharacterRow {++ /// `Characters: Alice (Al, Ally), Bob`, or nil where there are none.+ ///+ /// Nil rather than an empty string: a work without characters draws no+ /// element at all, so the sheet's state for it is the state for a work whose+ /// read failed.+ public static func text(for characters: [ShareCharacter]) -> String? {+ guard !characters.isEmpty else { return nil }+ let listed = characters.map { character in+ character.aliases.isEmpty+ ? character.name+ : "\(character.name) (\(character.aliases.joined(separator: ", ")))"+ }+ return "Characters: " + listed.joined(separator: ", ")+ }+}++/// The nearest noted chapter before the one being shared, as the catch-up+/// section shows it (T-1917).+///+/// `note` and `title` arrive trimmed and `note` is never blank: the read selects+/// the nearest preceding entry that has something to say, so a `ShareLastNote`+/// the sheet holds always has text under its heading (Decision 4).+public struct ShareLastNote: Sendable, Equatable {+ /// The entry's Req 8.12 title — chapter title, else chapter sequence. The+ /// requirement's third arm, the site-cleaned capture title, cannot be+ /// reached here: a selected note is placed by one of those two fields+ /// (Decision 4). Empty where the chapter title trims away, which the+ /// heading renders as the date alone.+ public let title: String+ /// The note text, trimmed of surrounding whitespace.+ public let note: String+ /// The entry's rating, for the glyph beside the heading.+ public let rating: Rating?+ /// The entry's last share, which the heading dates the note by (Q9). Not+ /// `firstCapturedAt`, which is what the re-share banner above it shows.+ public let lastSharedAt: Date++ public init(title: String, note: String, rating: Rating? = nil, lastSharedAt: Date) {+ self.title = title+ self.note = note+ self.rating = rating+ self.lastSharedAt = lastSharedAt+ }+}++/// Everything one capture sheet shows about the work it resolved to: the cast+/// row in the metadata card, and the catch-up section below the note editor.+///+/// One value because it is one read (Decision 2): the sheet's in-flight guard+/// and its work-id key are T-1916's, and a second read for the notes would+/// duplicate both.+public struct ShareWorkContext: Sendable, Equatable {+ public let characters: [ShareCharacter]+ /// The work's own notes, trimmed; `""` where the work has none, where the+ /// notes half of the read failed, and where no read has happened yet.+ public let workNote: String+ /// The nearest non-blank chapter note before the chapter being shared, or+ /// nil where the work has none — the ordinary empty case, not a failure.+ /// Also nil where the chapter being shared has no placement of its own+ /// (Decision 4): a note that might be from *ahead* is worse than no note.+ public let lastNote: ShareLastNote?++ public init(+ characters: [ShareCharacter] = [], workNote: String = "",+ lastNote: ShareLastNote? = nil+ ) {+ self.characters = characters+ self.workNote = workNote+ self.lastNote = lastNote+ }++ /// Nothing to show. The state of a sheet before its read returns, of a+ /// projection that names no existing work, and of a read that failed.+ public static let empty = ShareWorkContext()++ /// Whether the catch-up section has anything to draw. Header included: a+ /// section announcing nothing is worse than no section (Req 3).+ public var hasCatchUp: Bool { !workNote.isEmpty || lastNote != nil }+}++/// The two strings of the last-note block — the heading line it draws and the+/// label VoiceOver reads — produced here so the extension only lays them out+/// (sharesheet-polish Q6). The view formats no date of its own (Q12).+public enum ShareLastNoteRow {++ /// `{title} · {date}`, or the date alone for a title-less capture.+ ///+ /// The date is `lastSharedAt` through `ShareDateText`, which+ /// `ReShareBannerFormatter` also writes its date with, so the two dated+ /// lines of the re-share sheet read alike even though they answer different+ /// questions (Q9).+ public static func heading(+ for note: ShareLastNote,+ locale: Locale = .current,+ calendar: Calendar = .current,+ timeZone: TimeZone = .current+ ) -> String {+ let dateString = ShareDateText.medium(+ note.lastSharedAt, locale: locale, calendar: calendar, timeZone: timeZone)+ guard !note.title.isEmpty else { return dateString }+ return "\(note.title) · \(dateString)"+ }++ /// `Last note on {title}, {date}` — `Last note, {date}` for a title-less+ /// capture — with `, rated up` / `, rated down` appended where the entry+ /// carries a rating.+ ///+ /// Spelt out rather than read off the heading: the block's glyph is silent+ /// to VoiceOver and the ` · ` separator does not read as anything, so the+ /// label says what the line means instead of what it looks like.+ public static func accessibilityLabel(+ for note: ShareLastNote,+ locale: Locale = .current,+ calendar: Calendar = .current,+ timeZone: TimeZone = .current+ ) -> String {+ let dateString = ShareDateText.medium(+ note.lastSharedAt, locale: locale, calendar: calendar, timeZone: timeZone)+ let opening = note.title.isEmpty+ ? "Last note, \(dateString)"+ : "Last note on \(note.title), \(dateString)"+ switch note.rating {+ case .up: return opening + ", rated up"+ case .down: return opening + ", rated down"+ case nil: return opening+ }+ }+}++// MARK: - The read++extension LibraryRepository {++ /// A work's cast and notes for a capture sheet, in the work page's order.+ ///+ /// By predicate rather than through `fetchWorkGroup(id:)`: an id the sheet+ /// projected but the store does not hold is an empty context rather than an+ /// error. Same-UUID duplicate rows are covered exactly as the work page+ /// covers them — `characterRows(of:)` unions the rows' inverses, the work+ /// note is the carrier row's, a split entry group contributes its carrier's+ /// note — and an orphaned character or entry, unreachable through any of+ /// them, is invisible here as it is there (share-sheet-characters Q7,+ /// share-sheet-last-note Q8).+ ///+ /// The notes half runs in its own `do/catch` (Decision 2): it fetches the+ /// work type directory and snapshots one entry, both of which can throw over+ /// a row this sheet is not about, and neither is worth taking the cast down+ /// for.+ ///+ /// - Parameters:+ /// - excludingEntryID: The entry the re-share sheet is editing, whose note+ /// is already the editor's content. The new-capture arm passes none (Q7).+ /// - currentChapterSequence: The chapter being shared, as the URL rule+ /// sequenced it — the matched entry's on re-share, the projection's+ /// `composedChapterSequence` on new capture.+ /// - currentChapterTitle: The same chapter's title — the matched entry's+ /// `chapterTitle` on re-share, the projection's `projectedChapter` on+ /// new capture. Together these two place the anchor Decision 4 selects+ /// *before*; a chapter neither of them places has no last note at all.+ internal static func shareWorkContext(+ forWorkID id: UUID, excludingEntryID: UUID? = nil,+ currentChapterSequence: String?, currentChapterTitle: String?,+ context: ModelContext+ ) throws -> ShareWorkContext {+ let works = try context.fetch(FetchDescriptor<Work>(predicate: #Predicate { $0.id == id }))+ guard !works.isEmpty else { return .empty }+ let characters = sortedCharacterGroups(characterGroups(characterRows(of: works)))+ .map { group in+ let content = group.presentedContent+ return ShareCharacter(name: content.name, aliases: content.aliases)+ }++ do {+ // One row is its own carrier, so the group has nothing to decide and+ // the note is on the row. That matters because the directory is a+ // whole-table fetch of every work type, and a work with one row is+ // nearly every work.+ let carriedNotes: String?+ if works.count == 1 {+ carriedNotes = works[0].genericNotes+ } else {+ let types = try workTypeDirectory(context: context)+ carriedNotes = workGroup(id: id, rows: works, types: types)?.carrier.genericNotes+ }+ let workNote = (carriedNotes ?? "")+ .trimmingCharacters(in: .whitespacesAndNewlines)++ // Where the reader is now. A chapter this places nowhere leaves the+ // work notes standing alone: the section cannot tell a note from+ // before the current chapter from one after it, and a note from+ // ahead is worse than no note (Decision 4).+ guard let anchor = ChapterPlacement.of(+ chapterSequence: currentChapterSequence, chapterTitle: currentChapterTitle)+ else {+ return ShareWorkContext(+ characters: characters, workNote: workNote, lastNote: nil)+ }++ // Only the rows that could carry an answer are grouped. A candidate+ // is a group whose *carrier* has a note, and the carrier is one of+ // the group's own rows — so a UUID whose every row is blank can+ // never yield one, and neither can the excluded entry. Dropping+ // those buckets before `entryGroups` is a superset filter: it+ // changes what is walked, never what is selected. The rest of the+ // qualification — the carrier's note, the placement — stays below,+ // where the carrier is known.+ var byID: [UUID: [Entry]] = [:]+ for row in works.flatMap(\.entryValues) where row.id != excludingEntryID {+ byID[row.id, default: []].append(row)+ }+ let noted = byID.values.filter { rows in+ rows.contains { !$0.note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }+ }+ // Pure over the rows already faulted in: `entryGroups` builds no+ // snapshots, so a corrupt row elsewhere in the work cannot fail the+ // walk. Only the selected group is snapshotted, below.+ let groups = Self.entryGroups(noted.flatMap { $0 }, canonicalWorkIDs: [:])+ // Every candidate placed by its carrier — the row whose note this+ // would show — so the note and the position it claims come from one+ // row. An unplaced entry never qualifies; equal placement is not+ // "before", so a duplicate of the chapter being shared cannot show+ // its own note back to the reader.+ let candidates = groups.values.compactMap { group -> (EntryGroup, ChapterPlacement)? in+ guard group.id != excludingEntryID,+ !group.carrier.note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,+ let placement = ChapterPlacement.of(+ chapterSequence: group.carrier.chapterSequence,+ chapterTitle: group.carrier.chapterTitle),+ placement < anchor+ else { return nil }+ return (group, placement)+ }+ // The nearest one to the anchor: distance in story order is the+ // better proxy for "what just happened" than recency is (Decision+ // 4). Two entries at one placement — a re-numbered duplicate, two+ // titles the rules read alike — fall back to the newest note, then+ // to the id, so the answer never moves between reads.+ //+ // The comparator flips to `>` on the last leg deliberately: the+ // maximum over this order is the nearest placement, then the newest+ // date, then the *lowest* uuid.+ let selected = candidates.max { left, right in+ if left.1 != right.1 { return left.1 < right.1 }+ if left.0.lastSharedAt != right.0.lastSharedAt {+ return left.0.lastSharedAt < right.0.lastSharedAt+ }+ return left.0.id.uuidString.lowercased() > right.0.id.uuidString.lowercased()+ }?.0+ guard let selected else {+ return ShareWorkContext(+ characters: characters, workNote: workNote, lastNote: nil)+ }++ let entry = try snapshot(selected)+ // Req 8.12's chapter resolution, minus its third arm: a selected+ // note is placed, a placement comes from one of these two fields,+ // so an entry with neither is never here and its site-cleaned+ // capture title is unreachable as a heading (Decision 4).+ let title = entry.chapterTitle ?? entry.chapterSequence ?? ""+ return ShareWorkContext(+ characters: characters,+ workNote: workNote,+ lastNote: ShareLastNote(+ title: title.trimmingCharacters(in: .whitespacesAndNewlines),+ note: entry.note.trimmingCharacters(in: .whitespacesAndNewlines),+ rating: entry.rating,+ lastSharedAt: selected.lastSharedAt))+ } catch {+ // The notes are the reader's convenience, not their capture: a+ // failure here leaves the cast as read and the section empty+ // (share-sheet-characters Q15). The inner catch hides it from the+ // lookup's own handler, so this is the only trace it leaves (Q13);+ // the reason is readable, no note or title text goes near the log.+ shareWorkContextLogger.debug(+ "Share work context: notes read failed — \(String(describing: error), privacy: .public)"+ )+ return ShareWorkContext(characters: characters)+ }+ }++ /// The same, as one shared-lock read. Never writes; an unknown id and a work+ /// with neither characters nor notes both answer `.empty`.+ public func shareWorkContext(+ workID: UUID, excludingEntryID: UUID? = nil,+ currentChapterSequence: String?, currentChapterTitle: String?+ ) async throws -> ShareWorkContext {+ try await withLockedContext(mode: .shared, operation: "reading share work context") {+ context in+ try Self.shareWorkContext(+ forWorkID: workID, excludingEntryID: excludingEntryID,+ currentChapterSequence: currentChapterSequence,+ currentChapterTitle: currentChapterTitle, context: context)+ }+ }+}
diff --git a/Packages/AsterismCore/Sources/ConstellationKit/ConstellationButtonStyles.swift b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationButtonStyles.swiftindex 79a0173..690e48e 100644--- a/Packages/AsterismCore/Sources/ConstellationKit/ConstellationButtonStyles.swift+++ b/Packages/AsterismCore/Sources/ConstellationKit/ConstellationButtonStyles.swift@@ -116,11 +116,24 @@ extension ButtonStyle where Self == ConstellationTertiaryButtonStyle { /// A `ButtonStyle` rather than a whole view so the action and the accessibility /// identifier stay at the call site, where the existing ones already live. public struct ConstellationRatingToggleStyle: ButtonStyle {+ /// A rating's direction, and the two things every surface draws it with.+ ///+ /// Public because the toggle is not the only place a rating appears: the+ /// share sheet's catch-up block echoes a rated chapter read-only, and a+ /// second spelling of the glyph is how the two drift apart (T-1917). public enum Direction: Sendable { case up case down - var accent: Color {+ /// The SF Symbol every drawing of this direction uses.+ public var symbolName: String {+ switch self {+ case .up: "arrowtriangle.up.fill"+ case .down: "arrowtriangle.down.fill"+ }+ }++ public var accent: Color { switch self { case .up: AsterismColors.cyan case .down: AsterismColors.violet
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swiftindex c965934..82e0408 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift@@ -406,26 +406,39 @@ struct CaptureStateTests { "\(cell.name): raw page title \"\(cell.rawPageTitle)\"") } - // MARK: - Characters row (T-1916)+ // MARK: - Work context: characters row (T-1916) and catch-up section (T-1917) - @Test("A reuse projection reads the projected work's cast once")- @MainActor func reuseProjectionReadsCharacters() async throws {+ @Test("A reuse projection reads the projected work's context once")+ @MainActor func reuseProjectionReadsWorkContext() async throws { let fixture = CaptureStateFixture.taughtSite()- fixture.coordinator.shareCharactersResult = .success([- ShareCharacter(name: "Alice", aliases: ["Al"]),- ShareCharacter(name: "Bob")- ])+ let lastNote = ShareLastNote(+ title: "Chapter Four", note: "Where it left off", rating: .up,+ lastSharedAt: Date(timeIntervalSince1970: 1_800_000_000))+ fixture.coordinator.shareWorkContextResult = .success(ShareWorkContext(+ characters: [+ ShareCharacter(name: "Alice", aliases: ["Al"]),+ ShareCharacter(name: "Bob")+ ],+ workNote: "Reading with Ada.",+ lastNote: lastNote)) await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)- await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)-- #expect(fixture.coordinator.shareCharactersCalls == [fixture.coordinator.projectedWorkID])- #expect(fixture.viewModel.characters.map(\.name) == ["Alice", "Bob"])- #expect(fixture.viewModel.charactersWorkID == fixture.coordinator.projectedWorkID)- #expect(fixture.viewModel.inFlightWorkID == nil)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)++ // One read, carrying the chapter the sheet is about to add — the+ // anchor the catch-up selection needs (Decision 4).+ #expect(fixture.coordinator.shareWorkContextCalls == [RecordedContextRead(+ workID: fixture.coordinator.projectedWorkID, chapterSequence: nil,+ chapterTitle: "5")])+ #expect(fixture.viewModel.workContext.characters.map(\.name) == ["Alice", "Bob"])+ // The notes travel with the cast — one read, one value (Decision 2).+ #expect(fixture.viewModel.workContext.workNote == "Reading with Ada.")+ #expect(fixture.viewModel.workContext.lastNote == lastNote)+ #expect(fixture.viewModel.contextKey?.workID == fixture.coordinator.projectedWorkID)+ #expect(fixture.viewModel.inFlightKey == nil) } - @Test("A claim projection reads the cast too — it names an existing work (Q3)")- @MainActor func claimProjectionReadsCharacters() async throws {+ @Test("A claim projection reads the context too — it names an existing work (Q3)")+ @MainActor func claimProjectionReadsWorkContext() async throws { let fixture = CaptureStateFixture.taughtSite() let claimedID = UUID() // A claim carries the work id with no `workOutcome`, exactly as@@ -436,12 +449,13 @@ struct CaptureStateTests { actionable: false, intentionallyUnattached: false, composedAssignment: .claim(workID: claimedID) )))- fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+ fixture.coordinator.shareWorkContextResult = .success(+ ShareWorkContext(characters: [ShareCharacter(name: "Alice")])) await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)- await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator) - #expect(fixture.coordinator.shareCharactersCalls == [claimedID])- #expect(fixture.viewModel.characters == [ShareCharacter(name: "Alice")])+ #expect(fixture.coordinator.shareWorkContextWorkIDs == [claimedID])+ #expect(fixture.viewModel.workContext.characters == [ShareCharacter(name: "Alice")]) } @Test("A projection that names no existing work reads nothing",@@ -455,28 +469,128 @@ struct CaptureStateTests { CaptureViewModelError.projectionFailed("no projection")) } await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)- await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator) - #expect(fixture.coordinator.shareCharactersCalls.isEmpty, "\(cell.name)")- #expect(fixture.viewModel.characters.isEmpty, "\(cell.name)")- #expect(fixture.viewModel.charactersWorkID == nil, "\(cell.name)")+ #expect(fixture.coordinator.shareWorkContextCalls.isEmpty, "\(cell.name)")+ #expect(fixture.viewModel.workContext.characters.isEmpty, "\(cell.name)")+ #expect(fixture.viewModel.contextKey == nil, "\(cell.name)") } - @Test("A note edit that leaves the work unchanged reads nothing further")+ @Test("A note edit that leaves the work and the chapter unchanged reads nothing further") @MainActor func unchangedWorkDoesNotRefetch() async throws { let fixture = CaptureStateFixture.taughtSite()- fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+ fixture.coordinator.shareWorkContextResult = .success(+ ShareWorkContext(characters: [ShareCharacter(name: "Alice")])) await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)- await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)- #expect(fixture.coordinator.shareCharactersCalls.count == 1)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)+ #expect(fixture.coordinator.shareWorkContextCalls.count == 1) await fixture.viewModel.setNote("A note", coordinator: fixture.coordinator)- await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator) await fixture.viewModel.setNote("A longer note", coordinator: fixture.coordinator)- await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)++ #expect(fixture.coordinator.shareWorkContextCalls.count == 1)+ #expect(fixture.viewModel.workContext.characters == [ShareCharacter(name: "Alice")])+ }++ /// The guard keys on the *derived* placement, not on the raw chapter pair.+ /// On the new-capture sheet every keystroke of a manual title reprojects,+ /// so a raw-string key made "Chapter 6" nine keys: nine locked reads, and+ /// a catch-up section that collapsed and reappeared between characters.+ /// Every title below places the chapter at 6, so the first read answered+ /// for all of them.+ @Test("A title edit that keeps the chapter's placement reads nothing further")+ @MainActor func aTitleEditWithinOnePlacementDoesNotRefetch() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ let workID = fixture.coordinator.projectedWorkID+ let cast = [ShareCharacter(name: "Alice")]+ fixture.coordinator.shareWorkContextResult = .success(ShareWorkContext(characters: cast))+ fixture.coordinator.projectCaptureResult = .success(+ makeContract(outcome: makeReuseOutcome(chapter: "Chapter 6", workID: workID)))+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)+ #expect(fixture.coordinator.shareWorkContextCalls.count == 1)++ for title in ["Chapter 6:", "Chapter 6: The", "Chapter 6: The Gathering"] {+ fixture.coordinator.projectCaptureResult = .success(+ makeContract(outcome: makeReuseOutcome(chapter: title, workID: workID)))+ await fixture.viewModel.setManualTitle(title, coordinator: fixture.coordinator)+ #expect(fixture.viewModel.needsWorkContextLoad == false, "\(title)")+ // The section stays on screen through the whole word.+ #expect(fixture.viewModel.displayedWorkContext.characters == cast, "\(title)")+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)+ #expect(fixture.viewModel.displayedWorkContext.characters == cast, "\(title)")+ } - #expect(fixture.coordinator.shareCharactersCalls.count == 1)- #expect(fixture.viewModel.characters == [ShareCharacter(name: "Alice")])+ #expect(fixture.coordinator.shareWorkContextCalls == [RecordedContextRead(+ workID: workID, chapterSequence: nil, chapterTitle: "Chapter 6")])+ }++ /// The other side of the same key: a keystroke that *moves* the chapter —+ /// 5 becoming 55 as the reader types — asks a different question of the+ /// work, so it reads again. The anchor the read carries is the raw pair,+ /// not the placement: the read derives the placement itself.+ @Test("A title edit that moves the chapter's placement reads again")+ @MainActor func aTitleEditThatMovesThePlacementRefetches() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ let workID = fixture.coordinator.projectedWorkID+ fixture.coordinator.shareWorkContextResult = .success(+ ShareWorkContext(characters: [ShareCharacter(name: "Alice")]))+ fixture.coordinator.projectCaptureResult = .success(+ makeContract(outcome: makeReuseOutcome(chapter: "Chapter 5", workID: workID)))+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)+ #expect(fixture.coordinator.shareWorkContextCalls.count == 1)++ fixture.coordinator.projectCaptureResult = .success(+ makeContract(outcome: makeReuseOutcome(chapter: "Chapter 55", workID: workID)))+ await fixture.viewModel.setManualTitle("Chapter 55", coordinator: fixture.coordinator)+ #expect(fixture.viewModel.needsWorkContextLoad)+ // Nothing is drawn against chapter 55 until its own read lands.+ #expect(fixture.viewModel.displayedWorkContext == .empty)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)++ #expect(fixture.coordinator.shareWorkContextCalls == [+ RecordedContextRead(workID: workID, chapterSequence: nil, chapterTitle: "Chapter 5"),+ RecordedContextRead(workID: workID, chapterSequence: nil, chapterTitle: "Chapter 55"),+ ])+ #expect(fixture.viewModel.displayedWorkContext.characters == [ShareCharacter(name: "Alice")])+ }++ /// Decision 4 widened T-1916's guard from the work to the work *and* the+ /// chapter: the catch-up selection is made against the chapter being+ /// shared, so a draft edit that moves the chapter within one work has to+ /// read again or the block would answer for the wrong position.+ @Test("A chapter change within one work reads again")+ @MainActor func aChapterChangeWithinOneWorkRefetches() async throws {+ let fixture = CaptureStateFixture.taughtSite()+ let workID = fixture.coordinator.projectedWorkID+ fixture.coordinator.shareWorkContextResult = .success(+ ShareWorkContext(characters: [ShareCharacter(name: "Alice")]))+ await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)+ #expect(fixture.coordinator.shareWorkContextCalls.count == 1)++ // The same work, a chapter further along — a re-parsed title, a URL the+ // rule sequenced differently.+ fixture.coordinator.projectCaptureResult = .success(makeContract(outcome: CaptureOutcome(+ projectedChapter: "6", projectedWorkTitle: "Test Work",+ workOutcome: .reuse(workID: workID), projectedWorkID: workID,+ actionable: false, intentionallyUnattached: false,+ composedChapterSequence: "1043128"+ )))+ await fixture.viewModel.setManualTitle("Chapter 6", coordinator: fixture.coordinator)+ // Nothing is drawn against the new chapter until its own read lands.+ #expect(fixture.viewModel.displayedWorkContext == .empty)+ #expect(fixture.viewModel.needsWorkContextLoad)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)++ #expect(fixture.coordinator.shareWorkContextCalls == [+ RecordedContextRead(workID: workID, chapterSequence: nil, chapterTitle: "5"),+ RecordedContextRead(workID: workID, chapterSequence: "1043128", chapterTitle: "6"),+ ])+ #expect(fixture.viewModel.displayedWorkContext.characters == [ShareCharacter(name: "Alice")]) } @Test("A read superseded before it returns is never published")@@ -484,29 +598,30 @@ struct CaptureStateTests { let fixture = CaptureStateFixture.taughtSite() let firstWorkID = fixture.coordinator.projectedWorkID let secondWorkID = UUID()- fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+ fixture.coordinator.shareWorkContextResult = .success(+ ShareWorkContext(characters: [ShareCharacter(name: "Alice")])) await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator) // Reproject onto another work while the read is suspended: the view model // is awaiting the coordinator, so the main actor is free for the edit. let coordinator = fixture.coordinator let viewModel = fixture.viewModel- coordinator.shareCharactersHook = { @Sendable in+ coordinator.shareWorkContextHook = { @Sendable in coordinator.projectedWorkID = secondWorkID await viewModel.setNote("Edited mid-read", coordinator: coordinator) }- await viewModel.loadCharactersIfNeeded(coordinator: coordinator)+ await viewModel.loadWorkContextIfNeeded(coordinator: coordinator) - #expect(coordinator.shareCharactersCalls == [firstWorkID])+ #expect(coordinator.shareWorkContextWorkIDs == [firstWorkID]) #expect(viewModel.currentOutcome?.projectedWorkID == secondWorkID)- #expect(viewModel.characters.isEmpty)- #expect(viewModel.charactersWorkID == nil)+ #expect(viewModel.workContext.characters.isEmpty)+ #expect(viewModel.contextKey == nil) // The marker is released, so the second work can still be read for.- #expect(viewModel.inFlightWorkID == nil)- coordinator.shareCharactersHook = nil- await viewModel.loadCharactersIfNeeded(coordinator: coordinator)- #expect(coordinator.shareCharactersCalls == [firstWorkID, secondWorkID])- #expect(viewModel.charactersWorkID == secondWorkID)+ #expect(viewModel.inFlightKey == nil)+ coordinator.shareWorkContextHook = nil+ await viewModel.loadWorkContextIfNeeded(coordinator: coordinator)+ #expect(coordinator.shareWorkContextWorkIDs == [firstWorkID, secondWorkID])+ #expect(viewModel.contextKey?.workID == secondWorkID) } @Test("An earlier read does not release a later read's marker for the same work")@@ -515,17 +630,18 @@ struct CaptureStateTests { let coordinator = fixture.coordinator let viewModel = fixture.viewModel let workID = coordinator.projectedWorkID- coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+ coordinator.shareWorkContextResult = .success(+ ShareWorkContext(characters: [ShareCharacter(name: "Alice")])) await viewModel.load(payload: fixture.defaultPayload, coordinator: coordinator) // Read 1 for the work, held suspended inside the coordinator. let firstEntered = ReadGate(), firstResumes = ReadGate()- coordinator.shareCharactersHook = { @Sendable in+ coordinator.shareWorkContextHook = { @Sendable in firstEntered.open() await firstResumes.wait() } let firstRead = Task { @MainActor in- await viewModel.loadCharactersIfNeeded(coordinator: coordinator)+ await viewModel.loadWorkContextIfNeeded(coordinator: coordinator) } await firstEntered.wait() @@ -536,19 +652,19 @@ struct CaptureStateTests { actionable: false, intentionallyUnattached: false ))) await viewModel.setNote("Retitled away", coordinator: coordinator)- await viewModel.loadCharactersIfNeeded(coordinator: coordinator)- #expect(viewModel.inFlightWorkID == nil)+ await viewModel.loadWorkContextIfNeeded(coordinator: coordinator)+ #expect(viewModel.inFlightKey == nil) // And returns to it, so read 2 marks the same work id read 1 marked. coordinator.projectCaptureResult = nil await viewModel.setNote("Retitled back", coordinator: coordinator) let secondEntered = ReadGate(), secondResumes = ReadGate()- coordinator.shareCharactersHook = { @Sendable in+ coordinator.shareWorkContextHook = { @Sendable in secondEntered.open() await secondResumes.wait() } let secondRead = Task { @MainActor in- await viewModel.loadCharactersIfNeeded(coordinator: coordinator)+ await viewModel.loadWorkContextIfNeeded(coordinator: coordinator) } await secondEntered.wait() @@ -556,43 +672,45 @@ struct CaptureStateTests { // it read — but the marker it finds is read 2's, and stays. firstResumes.open() _ = await firstRead.value- #expect(viewModel.inFlightWorkID == workID)- #expect(viewModel.displayedCharacters == [ShareCharacter(name: "Alice")])+ #expect(viewModel.inFlightKey?.workID == workID)+ #expect(viewModel.displayedWorkContext.characters == [ShareCharacter(name: "Alice")]) // Read 2 owns the marker, so read 2 releases it. secondResumes.open() _ = await secondRead.value- #expect(viewModel.inFlightWorkID == nil)- #expect(coordinator.shareCharactersCalls == [workID, workID])- #expect(viewModel.displayedCharacters == [ShareCharacter(name: "Alice")])+ #expect(viewModel.inFlightKey == nil)+ #expect(coordinator.shareWorkContextWorkIDs == [workID, workID])+ #expect(viewModel.displayedWorkContext.characters == [ShareCharacter(name: "Alice")]) } - @Test("A failed read leaves the state a castless work produces")+ @Test("A failed read leaves the state an empty work produces") @MainActor func failedReadMatchesEmptyRead() async throws { let failing = CaptureStateFixture.taughtSite()- failing.coordinator.shareCharactersResult = .failure(+ failing.coordinator.shareWorkContextResult = .failure( CaptureViewModelError.invalidState("Simulated read failure")) await failing.viewModel.load(payload: failing.defaultPayload, coordinator: failing.coordinator)- await failing.viewModel.loadCharactersIfNeeded(coordinator: failing.coordinator)-- let castless = CaptureStateFixture.taughtSite()- castless.coordinator.shareCharactersResult = .success([])- await castless.viewModel.load(payload: castless.defaultPayload, coordinator: castless.coordinator)- await castless.viewModel.loadCharactersIfNeeded(coordinator: castless.coordinator)-- #expect(failing.viewModel.characters == castless.viewModel.characters)- #expect(failing.viewModel.charactersWorkID == castless.viewModel.charactersWorkID)- #expect(failing.viewModel.inFlightWorkID == castless.viewModel.inFlightWorkID)- #expect(ShareCharacterRow.text(for: failing.viewModel.characters) == nil)+ await failing.viewModel.loadWorkContextIfNeeded(coordinator: failing.coordinator)++ let empty = CaptureStateFixture.taughtSite()+ empty.coordinator.shareWorkContextResult = .success(.empty)+ await empty.viewModel.load(payload: empty.defaultPayload, coordinator: empty.coordinator)+ await empty.viewModel.loadWorkContextIfNeeded(coordinator: empty.coordinator)++ #expect(failing.viewModel.workContext == .empty)+ #expect(failing.viewModel.workContext == empty.viewModel.workContext)+ #expect(failing.viewModel.contextKey == empty.viewModel.contextKey)+ #expect(failing.viewModel.inFlightKey == empty.viewModel.inFlightKey)+ #expect(ShareCharacterRow.text(for: failing.viewModel.workContext.characters) == nil) } - @Test("Losing the projected work clears the cast")- @MainActor func losingTheWorkClearsCharacters() async throws {+ @Test("Losing the projected work clears the context")+ @MainActor func losingTheWorkClearsTheContext() async throws { let fixture = CaptureStateFixture.taughtSite()- fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+ fixture.coordinator.shareWorkContextResult = .success(+ ShareWorkContext(characters: [ShareCharacter(name: "Alice")])) await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)- await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)- #expect(fixture.viewModel.characters.isEmpty == false)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)+ #expect(fixture.viewModel.workContext.characters.isEmpty == false) // The reader retitles onto a work that does not exist yet. fixture.coordinator.projectCaptureResult = .success(makeContract(outcome: CaptureOutcome(@@ -601,20 +719,21 @@ struct CaptureStateTests { actionable: false, intentionallyUnattached: false ))) await fixture.viewModel.setNote("Retitled", coordinator: fixture.coordinator)- await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator) - #expect(fixture.viewModel.characters.isEmpty)- #expect(fixture.viewModel.charactersWorkID == nil)- #expect(fixture.coordinator.shareCharactersCalls.count == 1)+ #expect(fixture.viewModel.workContext.characters.isEmpty)+ #expect(fixture.viewModel.contextKey == nil)+ #expect(fixture.coordinator.shareWorkContextCalls.count == 1) } @Test("A projection that moves to another work draws nothing until its own read lands")- @MainActor func displayedCharactersDropOnAWorkChange() async throws {+ @MainActor func displayedContextDropsOnAWorkChange() async throws { let fixture = CaptureStateFixture.taughtSite()- fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+ fixture.coordinator.shareWorkContextResult = .success(+ ShareWorkContext(characters: [ShareCharacter(name: "Alice")])) await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)- await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)- #expect(fixture.viewModel.displayedCharacters == [ShareCharacter(name: "Alice")])+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)+ #expect(fixture.viewModel.displayedWorkContext.characters == [ShareCharacter(name: "Alice")]) // The projection moves to another existing work. Nothing has been read // for it yet, so the sheet must not show the first work's cast under the@@ -622,17 +741,18 @@ struct CaptureStateTests { fixture.coordinator.projectedWorkID = UUID() await fixture.viewModel.setNote("Retitled onto another work", coordinator: fixture.coordinator) - #expect(fixture.viewModel.displayedCharacters.isEmpty)+ #expect(fixture.viewModel.displayedWorkContext.characters.isEmpty) // The loaded list is untouched — the rule is about what may be drawn.- #expect(fixture.viewModel.characters == [ShareCharacter(name: "Alice")])+ #expect(fixture.viewModel.workContext.characters == [ShareCharacter(name: "Alice")]) } @Test("A projection that names no existing work draws nothing at once")- @MainActor func displayedCharactersDropOnACreate() async throws {+ @MainActor func displayedContextDropsOnACreate() async throws { let fixture = CaptureStateFixture.taughtSite()- fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+ fixture.coordinator.shareWorkContextResult = .success(+ ShareWorkContext(characters: [ShareCharacter(name: "Alice")])) await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)- await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator) fixture.coordinator.projectCaptureResult = .success(makeContract(outcome: CaptureOutcome( projectedChapter: "5", projectedWorkTitle: "Brand New Work",@@ -642,22 +762,23 @@ struct CaptureStateTests { await fixture.viewModel.setNote("Retitled", coordinator: fixture.coordinator) // Before the clearing read runs, and after it.- #expect(fixture.viewModel.displayedCharacters.isEmpty)- await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)- #expect(fixture.viewModel.displayedCharacters.isEmpty)+ #expect(fixture.viewModel.displayedWorkContext.characters.isEmpty)+ await fixture.viewModel.loadWorkContextIfNeeded(coordinator: fixture.coordinator)+ #expect(fixture.viewModel.displayedWorkContext.characters.isEmpty) } @Test("A read reports whether it published, so the bridge refreshes only then") @MainActor func loadReportsWhetherItPublished() async throws { let fixture = CaptureStateFixture.taughtSite()- fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+ fixture.coordinator.shareWorkContextResult = .success(+ ShareWorkContext(characters: [ShareCharacter(name: "Alice")])) await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator) - let published = await fixture.viewModel.loadCharactersIfNeeded(+ let published = await fixture.viewModel.loadWorkContextIfNeeded( coordinator: fixture.coordinator) #expect(published) // Nothing left to read for the same work.- let again = await fixture.viewModel.loadCharactersIfNeeded(+ let again = await fixture.viewModel.loadWorkContextIfNeeded( coordinator: fixture.coordinator) #expect(again == false) @@ -667,17 +788,17 @@ struct CaptureStateTests { let viewModel = fixture.viewModel coordinator.projectedWorkID = UUID() await viewModel.setNote("Onto another work", coordinator: coordinator)- coordinator.shareCharactersHook = { @Sendable in+ coordinator.shareWorkContextHook = { @Sendable in coordinator.projectedWorkID = UUID() await viewModel.setNote("Edited mid-read", coordinator: coordinator) }- let superseded = await viewModel.loadCharactersIfNeeded(coordinator: coordinator)+ let superseded = await viewModel.loadWorkContextIfNeeded(coordinator: coordinator) #expect(superseded == false) } } /// A contract carrying the outcome under test; only the outcome matters to the-/// characters row.+/// work-context read. private func makeContract(outcome: CaptureOutcome) -> CaptureContract { CaptureContract( basis: CaptureBasis(siteMode: .taught, hostname: "taught.example", activePattern: nil, works: []),@@ -689,6 +810,18 @@ private func makeContract(outcome: CaptureOutcome) -> CaptureContract { outcome: outcome) } +/// A reuse projection of one work at one chapter — the shape the new-capture+/// sheet produces on every keystroke while the reader types a title.+private func makeReuseOutcome(+ chapter: String, workID: UUID, sequence: String? = nil+) -> CaptureOutcome {+ CaptureOutcome(+ projectedChapter: chapter, projectedWorkTitle: "Test Work",+ workOutcome: .reuse(workID: workID), projectedWorkID: workID,+ actionable: false, intentionallyUnattached: false,+ composedChapterSequence: sequence)+}+ /// One projection that names no existing work, so the sheet shows no cast. /// A nil `outcome` means the projection itself failed. private struct NoCastCase: Sendable {@@ -802,6 +935,14 @@ private final class ReadGate: @unchecked Sendable { } } +/// One work-context read the fake was asked for: the work, and the chapter the+/// sheet anchored it on (Decision 4).+struct RecordedContextRead: Equatable, Sendable {+ let workID: UUID+ let chapterSequence: String?+ let chapterTitle: String?+}+ private final class FakeCaptureCoordinating: CaptureCoordinating, @unchecked Sendable { private let lock = NSLock() @@ -836,12 +977,15 @@ private final class FakeCaptureCoordinating: CaptureCoordinating, @unchecked Sen set { lock.withLock { _projectedWorkID = newValue } } } - private var _shareCharactersCalls: [UUID] = []- var shareCharactersCalls: [UUID] { lock.withLock { _shareCharactersCalls } }- var shareCharactersResult: Result<[ShareCharacter], Error> = .success([])- /// Run while a character read is in flight — the hook the superseded case+ private var _shareWorkContextCalls: [RecordedContextRead] = []+ var shareWorkContextCalls: [RecordedContextRead] { lock.withLock { _shareWorkContextCalls } }+ /// The work ids alone, for the cases that are about the work and not about+ /// the chapter anchoring the read (Decision 4).+ var shareWorkContextWorkIDs: [UUID] { shareWorkContextCalls.map(\.workID) }+ var shareWorkContextResult: Result<ShareWorkContext, Error> = .success(.empty)+ /// Run while a work-context read is in flight — the hook the superseded case /// needs, since the read is suspended here and the main actor is free.- var shareCharactersHook: (@Sendable () async -> Void)?+ var shareWorkContextHook: (@Sendable () async -> Void)? /// Default projection outcome private var defaultProjectionOutcome: CaptureOutcome {@@ -895,11 +1039,17 @@ private final class FakeCaptureCoordinating: CaptureCoordinating, @unchecked Sen return CaptureContract(basis: basis, request: request, outcome: defaultProjectionOutcome) } - func shareCharacters(workID: UUID) async throws -> [ShareCharacter] {- lock.withLock { _shareCharactersCalls.append(workID) }- let hook: (@Sendable () async -> Void)? = lock.withLock { shareCharactersHook }+ func shareWorkContext(+ workID: UUID, currentChapterSequence: String?, currentChapterTitle: String?+ ) async throws -> ShareWorkContext {+ lock.withLock {+ _shareWorkContextCalls.append(RecordedContextRead(+ workID: workID, chapterSequence: currentChapterSequence,+ chapterTitle: currentChapterTitle))+ }+ let hook: (@Sendable () async -> Void)? = lock.withLock { shareWorkContextHook } if let hook { await hook() }- return try lock.withLock { shareCharactersResult }.get()+ return try lock.withLock { shareWorkContextResult }.get() } func commitCapture(_ contract: CaptureContract) async throws -> CaptureCommitOutcome {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ChapterKeyDerivationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ChapterKeyDerivationTests.swiftindex 00b955c..d6ee4a6 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ChapterKeyDerivationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ChapterKeyDerivationTests.swift@@ -214,3 +214,95 @@ struct ChapterKeyDerivationTests { == "Interlude \u{2013} Pisces") } }++// The one placement scale the work page's Chapter view and the share sheet's+// catch-up selection both read (share-sheet-last-note Decision 4). Tested+// directly: two derivations of "before" would be two answers about order, which+// is the defect T-1917 was fixed for.++@Suite("Chapter placement")+struct ChapterPlacementTests {++ private static func placement(+ sequence: String? = nil, title: String? = nil+ ) -> ChapterPlacement? {+ ChapterPlacement.of(chapterSequence: sequence, chapterTitle: title)+ }++ private static func key(_ components: [Int], _ label: String) -> ChapterKey {+ ChapterKey(components: components, label: label)+ }++ @Test("A chapter number places by key, whether the sequence or the title carries it")+ func numbersPlaceByKey() {+ #expect(Self.placement(sequence: "341") == .key(Self.key([341], "341")))+ #expect(Self.placement(title: "Chapter 14") == .key(Self.key([14], "14")))+ #expect(Self.placement(sequence: "5-07") == .key(Self.key([5, 7], "5.07")))+ }++ /// Q26: a site-wide id is a useless label and a reliable position, and it+ /// wins over a number in the title — the precedence the spine gives it.+ @Test("A site-wide id places by position, ahead of any number in the title")+ func siteWideIDsPlaceByPosition() {+ #expect(Self.placement(sequence: "1043128") == .position(Self.key([1_043_128], "1043128")))+ #expect(+ Self.placement(sequence: "1043128", title: "Chapter 14")+ == .position(Self.key([1_043_128], "1043128")))+ }++ @Test("A chapter the rules never numbered has no placement at all")+ func unnumberedChaptersHaveNoPlacement() {+ #expect(Self.placement() == nil)+ #expect(Self.placement(title: "An Interlude") == nil)+ #expect(Self.placement(title: "Chapters 14") == nil)+ }++ @Test("Positions order among themselves, keys among themselves")+ func likeScalesCompareByKey() {+ #expect(Self.placement(title: "Chapter 5")! < Self.placement(title: "Chapter 12")!)+ #expect(Self.placement(sequence: "5.07")! < Self.placement(sequence: "5.10")!)+ #expect(Self.placement(sequence: "1043100")! < Self.placement(sequence: "1043200")!)+ }++ /// The two scales cannot interleave, so every positioned chapter precedes+ /// every numbered one — `WorkDetailModel.precedesInChapterOrder`'s rule.+ @Test("Every position precedes every key, whatever the numbers say")+ func positionsPrecedeKeys() {+ let position = Self.placement(sequence: "1043128")!+ let key = Self.placement(title: "Chapter 14")!+ #expect(position < key)+ #expect(!(key < position))+ }++ @Test("Equal placements are neither before nor after each other")+ func equalPlacementsDoNotPrecede() {+ let left = Self.placement(title: "Chapter 14")!+ let right = Self.placement(title: "Chapter 14: The Long Way")!+ #expect(left == right)+ #expect(!(left < right))+ #expect(!(right < left))+ }++ /// The label breaks a tie between two spellings of one number, so the order+ /// is total — `max` over a set of placements has exactly one answer.+ @Test("Two spellings of one number order by the label, and stay ordered")+ func spellingsOrderTotally() {+ let padded = ChapterPlacement.key(Self.key([7], "007"))+ let plain = ChapterPlacement.key(Self.key([7], "7"))+ #expect(padded != plain)+ #expect(padded < plain)+ #expect([plain, padded].max() == plain)+ }++ /// The derived pair a chapter row already carries, which is how+ /// `WorkDetailModel` reaches the same scale without re-deriving anything.+ @Test("A row's derived pair places the same way the raw fields do")+ func derivedPairMatchesTheRawFields() {+ let position = Self.key([1_043_128], "1043128")+ let key = Self.key([14], "14")+ #expect(+ ChapterPlacement(sequencePosition: position, chapterKey: key) == .position(position))+ #expect(ChapterPlacement(sequencePosition: nil, chapterKey: key) == .key(key))+ #expect(ChapterPlacement(sequencePosition: nil, chapterKey: nil) == nil)+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swiftindex 92cfc40..11ecf45 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift@@ -253,16 +253,23 @@ struct ReShareExtensionUITests { #expect(label == "Update existing entry") } - // MARK: - The work's cast on the edit sheet (T-1916)+ // MARK: - The work's cast and notes on the edit sheet (T-1916, T-1917) private static let cast = [ ShareCharacter(name: "Alice", aliases: ["Al", "Ally"]), ShareCharacter(name: "Bob"), ] - @Test("Edit state carries the basis cast after lookup")- @MainActor func editStateCarriesCharacters() async throws {- let fixture = ReShareUIFixture.editExisting(characters: Self.cast)+ private static let lastNote = ShareLastNote(+ title: "Chapter 0", note: "Where the story was", rating: .up,+ lastSharedAt: Date(timeIntervalSince1970: 1_719_000_000))++ private static let context = ShareWorkContext(+ characters: cast, workNote: "Reading with Ada.", lastNote: lastNote)++ @Test("Edit state carries the basis cast and notes after lookup")+ @MainActor func editStateCarriesWorkContext() async throws {+ let fixture = ReShareUIFixture.editExisting(workContext: Self.context) await fixture.viewModel.loadWithLookup( payload: fixture.payload, coordinator: fixture.coordinator@@ -271,13 +278,16 @@ struct ReShareExtensionUITests { Issue.record("Expected readyEdit") return }- #expect(state.characters == Self.cast)- #expect(ShareCharacterRow.text(for: state.characters)+ #expect(state.workContext.characters == Self.cast)+ #expect(ShareCharacterRow.text(for: state.workContext.characters) == "Characters: Alice (Al, Ally), Bob")+ #expect(state.workContext.workNote == "Reading with Ada.")+ #expect(state.workContext.lastNote == Self.lastNote)+ #expect(state.workContext.hasCatchUp) } - @Test("A lookup with no cast leaves the edit state's list empty")- @MainActor func editStateWithoutCharactersIsEmpty() async throws {+ @Test("A lookup with nothing to show leaves the edit state's context empty")+ @MainActor func editStateWithoutAContextIsEmpty() async throws { let fixture = ReShareUIFixture.editExisting() await fixture.viewModel.loadWithLookup( payload: fixture.payload,@@ -287,15 +297,17 @@ struct ReShareExtensionUITests { Issue.record("Expected readyEdit") return }- #expect(state.characters.isEmpty)- #expect(ShareCharacterRow.text(for: state.characters) == nil)+ #expect(state.workContext == .empty)+ #expect(!state.workContext.hasCatchUp)+ #expect(ShareCharacterRow.text(for: state.workContext.characters) == nil) } - /// Q11: the commit path walks no characters, so its refreshed basis carries- /// none. The row must not empty out under a reader who tapped Update twice.- @Test("A stale refresh keeps the cast already shown")- @MainActor func staleRefreshKeepsCharacters() async throws {- let fixture = ReShareUIFixture.editExisting(characters: Self.cast)+ /// Q11: the commit path reads no work context, so its refreshed basis+ /// carries none. The row and the catch-up section must not empty out under a+ /// reader who tapped Update twice.+ @Test("A stale refresh keeps the cast and notes already shown")+ @MainActor func staleRefreshKeepsTheWorkContext() async throws {+ let fixture = ReShareUIFixture.editExisting(workContext: Self.context) await fixture.viewModel.loadWithLookup( payload: fixture.payload, coordinator: fixture.coordinator@@ -316,13 +328,13 @@ struct ReShareExtensionUITests { Issue.record("Expected readyEdit after stale") return }- #expect(state.characters == Self.cast)+ #expect(state.workContext == Self.context) #expect(state.persistedNote == "concurrent note") } - @Test("A failed save keeps the cast on the rebuilt edit state")- @MainActor func saveFailureKeepsCharacters() async throws {- let fixture = ReShareUIFixture.editExisting(characters: Self.cast)+ @Test("A failed save keeps the cast and notes on the rebuilt edit state")+ @MainActor func saveFailureKeepsTheWorkContext() async throws {+ let fixture = ReShareUIFixture.editExisting(workContext: Self.context) await fixture.viewModel.loadWithLookup( payload: fixture.payload, coordinator: fixture.coordinator@@ -334,7 +346,7 @@ struct ReShareExtensionUITests { Issue.record("Expected readyEdit with error") return }- #expect(state.characters == Self.cast)+ #expect(state.workContext == Self.context) } } @@ -354,7 +366,7 @@ private struct ReShareUIFixture { self.entryID = entryID } - static func editExisting(characters: [ShareCharacter] = []) -> ReShareUIFixture {+ static func editExisting(workContext: ShareWorkContext = .empty) -> ReShareUIFixture { let entryID = UUID() let coordinator = FakeReShareUICoordinator() coordinator.lookupResult = .edit(ReShareEditBasis(@@ -366,7 +378,7 @@ private struct ReShareUIFixture { persistedRating: .up, persistedModifiedAt: Date(timeIntervalSince1970: 1_721_000_000), firstCapturedAt: Date(timeIntervalSince1970: 1_720_000_000),- characters: characters+ workContext: workContext )) let payload = SharePayload( providerURL: "https://example.com/chapter-1",
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCharacterProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCharacterProjectionTests.swiftdeleted file mode 100644index 1c1e4b0..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCharacterProjectionTests.swift+++ /dev/null@@ -1,288 +0,0 @@-import Foundation-import Testing--@testable import AsterismCore--// T-1916: the cast a capture sheet shows for a work it already knows.-//-// Two claims. The row text is a pure function of the projection, so the-// extension decides nothing about it (Q6); and the projection answers with the-// work page's characters, in the work page's order, for the same store.--@Suite("Share character row text")-struct ShareCharacterRowTests {-- @Test("No characters means no row at all")- func emptyListHasNoText() {- #expect(ShareCharacterRow.text(for: []) == nil)- }-- @Test("Names are comma-separated, aliases parenthesised in the order given")- func namesAndAliasesRead() {- let text = ShareCharacterRow.text(for: [- ShareCharacter(name: "Alice", aliases: ["Al", "Ally"]),- ShareCharacter(name: "Bob"),- ])- #expect(text == "Characters: Alice (Al, Ally), Bob")- }-- @Test("An alias list is drawn as stored — sorted, not re-ordered here")- func aliasOrderIsTheStoredOrder() {- // `CharacterAuthoredContent` sorts aliases at construction, so the row- // has nothing left to decide; it must not impose a second order.- let content = CharacterAuthoredContent(name: "Hanna", aliases: ["Zephyr", "Ann"])- let text = ShareCharacterRow.text(for: [- ShareCharacter(name: content.name, aliases: content.aliases)- ])- #expect(content.aliases == ["Ann", "Zephyr"])- #expect(text == "Characters: Hanna (Ann, Zephyr)")- }-- @Test("A name containing a comma is joined verbatim (accepted, Q6)")- func commaInANameIsNotEscaped() {- let text = ShareCharacterRow.text(for: [- ShareCharacter(name: "Vance, the Elder"),- ShareCharacter(name: "Bo"),- ])- #expect(text == "Characters: Vance, the Elder, Bo")- }-}--@Suite("Share characters read", .serialized)-struct ShareCharacterReadTests {-- private static let workID = UUID(uuidString: "1F000000-0000-4000-8000-000000000001")!- private static let otherWorkID = UUID(uuidString: "1F000000-0000-4000-8000-000000000002")!- private static let alice = UUID(uuidString: "1F000000-0000-4000-8000-00000000000A")!- private static let bob = UUID(uuidString: "1F000000-0000-4000-8000-00000000000B")!- private static let zedFirst = UUID(uuidString: "1F000000-0000-4000-8000-000000000C01")!- private static let zedSecond = UUID(uuidString: "1F000000-0000-4000-8000-000000000C02")!- private static let orphan = UUID(uuidString: "1F000000-0000-4000-8000-00000000000F")!-- private func seeded(- works: [M5SeedWork]? = nil, characters: [M5SeedCharacter]- ) async throws -> M5Fixture {- let fixture = try await M5Fixture()- try await fixture.repository.seedM5Rows(- sites: [M5SeedSite(hostname: "c.example")],- works: works ?? [- M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example")- ],- characters: characters)- return fixture- }-- @Test("The work's characters come back in normalised name order, UUID as the tie-break")- func charactersAreOrderedLikeTheWorkPage() async throws {- let fixture = try await seeded(characters: [- // Two names whose raw order and normalised order disagree, so the- // assertion cannot pass on a plain string sort.- M5SeedCharacter(id: Self.bob, name: "Bob", workID: Self.workID),- M5SeedCharacter(- id: Self.alice, name: "alice", aliases: ["Ally", "Al"], workID: Self.workID),- // Same normalised name, distinct UUIDs: the tie-break decides.- M5SeedCharacter(id: Self.zedSecond, name: "Zed", workID: Self.workID),- M5SeedCharacter(id: Self.zedFirst, name: "Zed", workID: Self.workID),- ])-- let characters = try await fixture.repository.shareCharacters(workID: Self.workID)- #expect(characters == [- ShareCharacter(name: "alice", aliases: ["Al", "Ally"]),- ShareCharacter(name: "Bob"),- ShareCharacter(name: "Zed"),- ShareCharacter(name: "Zed"),- ])- #expect(- ShareCharacterRow.text(for: characters)- == "Characters: alice (Al, Ally), Bob, Zed, Zed")-- // The claim that matters: one order, shared with the page (Q9).- let detail = try await fixture.repository.workDetail(id: Self.workID)- #expect(detail.characters.map(\.name) == characters.map(\.name))- #expect(detail.characters.map(\.aliases) == characters.map(\.aliases))- withExtendedLifetime(fixture) {}- }-- @Test("A torn character shows the content the work page presents, with no marker")- func tornCharacterUsesPresentedContent() async throws {- let fixture = try await seeded(characters: [- M5SeedCharacter(- id: Self.alice, name: "Alice", aliases: ["Al"], note: "brave",- workID: Self.workID),- M5SeedCharacter(- id: Self.alice, name: "Alice Vance", aliases: ["Al"], note: "wary",- workID: Self.workID),- ])-- let characters = try await fixture.repository.shareCharacters(workID: Self.workID)- let detail = try await fixture.repository.workDetail(id: Self.workID)- let presented = try #require(detail.characters.first)- #expect(presented.isTorn)- // One entry for one character, whatever its rows disagree about, and- // the same text the page shows for it.- #expect(characters == [ShareCharacter(name: presented.name, aliases: presented.aliases)])- withExtendedLifetime(fixture) {}- }-- @Test("An orphaned character is invisible, as it is on the work page")- func orphanIsAbsent() async throws {- let fixture = try await seeded(characters: [- M5SeedCharacter(id: Self.alice, name: "Alice", workID: Self.workID),- M5SeedCharacter(id: Self.orphan, name: "Nobody", workID: nil),- ])-- let characters = try await fixture.repository.shareCharacters(workID: Self.workID)- #expect(characters == [ShareCharacter(name: "Alice")])- withExtendedLifetime(fixture) {}- }-- @Test("Duplicate Work rows sharing an id union their characters, each listed once")- func duplicateWorkRowsUnionWithoutDoubles() async throws {- let fixture = try await seeded(- works: [- M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example"),- M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example"),- ],- characters: [- // One character per row of the same work group…- M5SeedCharacter(id: Self.alice, name: "Alice", workID: Self.workID),- M5SeedCharacter(- id: Self.bob, name: "Bob", workID: Self.workID, workRowIndex: 1),- // …and one character that is itself two rows: still one entry.- M5SeedCharacter(- id: Self.zedFirst, name: "Zed", note: "one", workID: Self.workID),- M5SeedCharacter(- id: Self.zedFirst, name: "Zed", note: "two", workID: Self.workID,- workRowIndex: 1),- ])-- let characters = try await fixture.repository.shareCharacters(workID: Self.workID)- #expect(characters.map(\.name) == ["Alice", "Bob", "Zed"])- withExtendedLifetime(fixture) {}- }-- @Test("A work without characters and an unknown id both answer nothing, neither throws")- func emptyAnswersDoNotThrow() async throws {- let fixture = try await seeded(- works: [- M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example"),- M5SeedWork(id: Self.otherWorkID, displayTitle: "Another", hostname: "c.example"),- ],- characters: [- M5SeedCharacter(id: Self.alice, name: "Alice", workID: Self.workID)- ])-- let characterless = try await fixture.repository.shareCharacters(workID: Self.otherWorkID)- #expect(characterless == [])- // An id the sheet projected but the store does not hold: an empty cast,- // not a `recordNotFound`.- let unknown = try await fixture.repository.shareCharacters(workID: UUID())- #expect(unknown == [])- withExtendedLifetime(fixture) {}- }-}--// T-1916 task 3: the re-share sheet's cast rides along in the lookup it already-// waits on (Q5), and only for the caller that displays it (Q10).--@Suite("Share characters on the capture lookup", .serialized)-struct ShareCharacterLookupTests {-- private static let workID = UUID(uuidString: "1F000000-0000-4000-8000-000000000101")!- private static let castlessWorkID = UUID(uuidString: "1F000000-0000-4000-8000-000000000102")!- private static let entryID = UUID(uuidString: "1F000000-0000-4000-8000-0000000001E1")!- private static let alice = UUID(uuidString: "1F000000-0000-4000-8000-0000000001AA")!- private static let bob = UUID(uuidString: "1F000000-0000-4000-8000-0000000001BB")!-- private static let rawURL = "https://c.example/chapter-1"-- /// One entry on `rawURL`, attached to `workID` unless told otherwise, so the- /// lookup answers `.edit` for a work the store already holds.- private func seeded(- workID: UUID? = ShareCharacterLookupTests.workID, characters: [M5SeedCharacter]- ) async throws -> M5Fixture {- let fixture = try await M5Fixture()- try await fixture.repository.seedM5Rows(- sites: [M5SeedSite(hostname: "c.example")],- works: [- M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example"),- M5SeedWork(- id: Self.castlessWorkID, displayTitle: "Another Serial",- hostname: "c.example"),- ],- entries: [- M5SeedEntry(- id: Self.entryID, captureTitle: "Chapter 1", hostname: "c.example",- path: "chapter-1", chapterTitle: "Chapter 1", workID: workID)- ],- characters: characters)- return fixture- }-- private func editBasis(- _ fixture: M5Fixture, includeCharacters: Bool- ) async throws -> ReShareEditBasis {- let disposition = try await fixture.repository.captureLookup(- rawURL: Self.rawURL, captureTitle: "Chapter 1",- includeCharacters: includeCharacters)- guard case .edit(let basis) = disposition else {- Issue.record("Expected an edit disposition, got \(disposition)")- throw LibraryRepositoryError.recordNotFound(type: "Entry", id: Self.entryID)- }- return basis- }-- private static let cast: [M5SeedCharacter] = [- M5SeedCharacter(id: bob, name: "Bob", workID: workID),- M5SeedCharacter(id: alice, name: "alice", aliases: ["Ally", "Al"], workID: workID),- ]-- @Test("An opted-in lookup carries the matched work's cast in the work page's order")- func lookupCarriesCharactersWhenAsked() async throws {- let fixture = try await seeded(characters: Self.cast)-- let basis = try await editBasis(fixture, includeCharacters: true)- #expect(basis.entryID == Self.entryID)- #expect(basis.characters == [- ShareCharacter(name: "alice", aliases: ["Al", "Ally"]),- ShareCharacter(name: "Bob"),- ])- // The same list the standalone read gives for that work — one order,- // one projection.- let direct = try await fixture.repository.shareCharacters(workID: Self.workID)- #expect(basis.characters == direct)- withExtendedLifetime(fixture) {}- }-- @Test("A lookup that did not ask carries no cast, whatever the work holds")- func lookupWithoutTheFlagCarriesNothing() async throws {- let fixture = try await seeded(characters: Self.cast)-- #expect(try await editBasis(fixture, includeCharacters: false).characters == [])- // The default the drain and every other caller take.- let defaulted = try await fixture.repository.captureLookup(- rawURL: Self.rawURL, captureTitle: "Chapter 1")- guard case .edit(let basis) = defaulted else {- Issue.record("Expected an edit disposition, got \(defaulted)")- return- }- #expect(basis.characters == [])- withExtendedLifetime(fixture) {}- }-- @Test("A matched work without characters carries an empty cast, as does an unattached entry")- func characterlessAndUnattachedBothCarryNothing() async throws {- let castless = try await seeded(- workID: Self.castlessWorkID,- characters: [M5SeedCharacter(id: Self.alice, name: "alice", workID: Self.workID)])- #expect(try await editBasis(castless, includeCharacters: true).characters == [])- withExtendedLifetime(castless) {}-- // No work at all: the same empty row, no failure.- let unattached = try await seeded(- workID: nil,- characters: [M5SeedCharacter(id: Self.alice, name: "alice", workID: Self.workID)])- #expect(try await editBasis(unattached, includeCharacters: true).characters == [])- withExtendedLifetime(unattached) {}- }-}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swiftnew file mode 100644index 0000000..67f7f3b--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift@@ -0,0 +1,943 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// T-1916/T-1917: what a capture sheet shows for a work it already knows — the+// cast, the work's own notes and the last chapter note.+//+// Three claims. The row text and the heading are pure functions of the+// projection, so the extension decides nothing about either (Q6); the+// projection answers with the work page's characters, notes and order, for the+// same store; and the re-share lookup carries the whole context along, only for+// the caller that displays it (Q10).++@Suite("Share character row text")+struct ShareCharacterRowTests {++ @Test("No characters means no row at all")+ func emptyListHasNoText() {+ #expect(ShareCharacterRow.text(for: []) == nil)+ }++ @Test("Names are comma-separated, aliases parenthesised in the order given")+ func namesAndAliasesRead() {+ let text = ShareCharacterRow.text(for: [+ ShareCharacter(name: "Alice", aliases: ["Al", "Ally"]),+ ShareCharacter(name: "Bob"),+ ])+ #expect(text == "Characters: Alice (Al, Ally), Bob")+ }++ @Test("An alias list is drawn as stored — sorted, not re-ordered here")+ func aliasOrderIsTheStoredOrder() {+ // `CharacterAuthoredContent` sorts aliases at construction, so the row+ // has nothing left to decide; it must not impose a second order.+ let content = CharacterAuthoredContent(name: "Hanna", aliases: ["Zephyr", "Ann"])+ let text = ShareCharacterRow.text(for: [+ ShareCharacter(name: content.name, aliases: content.aliases)+ ])+ #expect(content.aliases == ["Ann", "Zephyr"])+ #expect(text == "Characters: Hanna (Ann, Zephyr)")+ }++ @Test("A name containing a comma is joined verbatim (accepted, Q6)")+ func commaInANameIsNotEscaped() {+ let text = ShareCharacterRow.text(for: [+ ShareCharacter(name: "Vance, the Elder"),+ ShareCharacter(name: "Bo"),+ ])+ #expect(text == "Characters: Vance, the Elder, Bo")+ }+}++// T-1917: the heading line of the last-note block, produced in the package so+// the extension only lays it out (sharesheet-polish Q6). Asserted under a fixed+// locale, calendar and time zone, because a golden that moves with the runner's+// region asserts nothing.++@Suite("Share last-note heading")+struct ShareLastNoteHeadingTests {++ private static let locale = Locale(identifier: "en_US")+ private static let calendar = Calendar(identifier: .gregorian)+ private static let timeZone = TimeZone(identifier: "UTC")!++ private static func date(year: Int, month: Int, day: Int) -> Date {+ var calendar = Calendar(identifier: .gregorian)+ calendar.timeZone = timeZone+ return calendar.date(+ from: DateComponents(year: year, month: month, day: day, hour: 12))!+ }++ private static func note(+ title: String, rating: Rating? = nil+ ) -> ShareLastNote {+ ShareLastNote(+ title: title, note: "Where it left off", rating: rating,+ lastSharedAt: date(year: 2026, month: 3, day: 14))+ }++ private static func heading(_ note: ShareLastNote) -> String {+ ShareLastNoteRow.heading(+ for: note, locale: locale, calendar: calendar, timeZone: timeZone)+ }++ @Test("A titled chapter heads with its title and the last-shared date")+ func titleAndDate() {+ #expect(Self.heading(Self.note(title: "The Long Way")) == "The Long Way · Mar 14, 2026")+ }++ @Test("A title-less capture heads with the date alone")+ func dateAloneWithoutATitle() {+ #expect(Self.heading(Self.note(title: "")) == "Mar 14, 2026")+ }++ @Test("The rating is not part of the heading text — the glyph draws it")+ func ratingDoesNotChangeTheText() {+ let plain = Self.heading(Self.note(title: "Chapter Nine"))+ #expect(Self.heading(Self.note(title: "Chapter Nine", rating: .up)) == plain)+ #expect(Self.heading(Self.note(title: "Chapter Nine", rating: .down)) == plain)+ }++ // The block's VoiceOver label, produced beside the heading so the view+ // formats no date itself (Q12). The glyph the sighted reader sees is+ // spoken here, because the block hides it from VoiceOver.++ private static func label(_ note: ShareLastNote) -> String {+ ShareLastNoteRow.accessibilityLabel(+ for: note, locale: locale, calendar: calendar, timeZone: timeZone)+ }++ @Test("A titled chapter reads as the note on that chapter, dated")+ func labelNamesTheChapter() {+ #expect(Self.label(Self.note(title: "The Long Way")) == "Last note on The Long Way, Mar 14, 2026")+ }++ @Test("A title-less capture drops the chapter, keeping the date")+ func labelWithoutATitle() {+ #expect(Self.label(Self.note(title: "")) == "Last note, Mar 14, 2026")+ }++ @Test("A rating the glyph draws is spoken as a suffix")+ func labelSpeaksTheRating() {+ #expect(+ Self.label(Self.note(title: "Chapter Nine", rating: .up))+ == "Last note on Chapter Nine, Mar 14, 2026, rated up")+ #expect(+ Self.label(Self.note(title: "Chapter Nine", rating: .down))+ == "Last note on Chapter Nine, Mar 14, 2026, rated down")+ #expect(+ Self.label(Self.note(title: "", rating: .down))+ == "Last note, Mar 14, 2026, rated down")+ }+}++@Suite("Share work context read", .serialized)+struct ShareWorkContextReadTests {++ private static let workID = UUID(uuidString: "1F000000-0000-4000-8000-000000000001")!+ private static let otherWorkID = UUID(uuidString: "1F000000-0000-4000-8000-000000000002")!+ private static let alice = UUID(uuidString: "1F000000-0000-4000-8000-00000000000A")!+ private static let bob = UUID(uuidString: "1F000000-0000-4000-8000-00000000000B")!+ private static let zedFirst = UUID(uuidString: "1F000000-0000-4000-8000-000000000C01")!+ private static let zedSecond = UUID(uuidString: "1F000000-0000-4000-8000-000000000C02")!+ private static let orphan = UUID(uuidString: "1F000000-0000-4000-8000-00000000000F")!++ private static let oldest = UUID(uuidString: "1F000000-0000-4000-8000-0000000000E1")!+ private static let middle = UUID(uuidString: "1F000000-0000-4000-8000-0000000000E2")!+ private static let newest = UUID(uuidString: "1F000000-0000-4000-8000-0000000000E3")!+ private static let ahead = UUID(uuidString: "1F000000-0000-4000-8000-0000000000E4")!+ private static let unplaced = UUID(uuidString: "1F000000-0000-4000-8000-0000000000E5")!+ /// Two ids sharing a `lastSharedAt`, so only the UUID order can decide.+ private static let tieLow = UUID(uuidString: "1F000000-0000-4000-8000-0000000000A1")!+ private static let tieHigh = UUID(uuidString: "1F000000-0000-4000-8000-0000000000F1")!++ private func seeded(+ works: [M5SeedWork]? = nil, entries: [M5SeedEntry] = [],+ sites: [M5SeedSite] = [M5SeedSite(hostname: "c.example")],+ characters: [M5SeedCharacter] = []+ ) async throws -> M5Fixture {+ let fixture = try await M5Fixture()+ try await fixture.repository.seedM5Rows(+ sites: sites,+ works: works ?? [+ M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example")+ ],+ entries: entries,+ characters: characters)+ return fixture+ }++ /// The read as both sheets make it, anchored on the chapter being shared+ /// (Decision 4). The default anchor sits past every chapter these tests+ /// seed, so a case that is not about placement selects the last noted+ /// chapter of the work, which is what the sheet shows there.+ private func context(+ _ fixture: M5Fixture, workID: UUID = ShareWorkContextReadTests.workID,+ excluding: UUID? = nil,+ anchorSequence: String? = nil, anchorTitle: String? = "Chapter 999"+ ) async throws -> ShareWorkContext {+ try await fixture.repository.shareWorkContext(+ workID: workID, excludingEntryID: excluding,+ currentChapterSequence: anchorSequence, currentChapterTitle: anchorTitle)+ }++ // MARK: - The cast (T-1916)++ @Test("The work's characters come back in normalised name order, UUID as the tie-break")+ func charactersAreOrderedLikeTheWorkPage() async throws {+ let fixture = try await seeded(characters: [+ // Two names whose raw order and normalised order disagree, so the+ // assertion cannot pass on a plain string sort.+ M5SeedCharacter(id: Self.bob, name: "Bob", workID: Self.workID),+ M5SeedCharacter(+ id: Self.alice, name: "alice", aliases: ["Ally", "Al"], workID: Self.workID),+ // Same normalised name, distinct UUIDs: the tie-break decides.+ M5SeedCharacter(id: Self.zedSecond, name: "Zed", workID: Self.workID),+ M5SeedCharacter(id: Self.zedFirst, name: "Zed", workID: Self.workID),+ ])++ let characters = try await context(fixture).characters+ #expect(characters == [+ ShareCharacter(name: "alice", aliases: ["Al", "Ally"]),+ ShareCharacter(name: "Bob"),+ ShareCharacter(name: "Zed"),+ ShareCharacter(name: "Zed"),+ ])+ #expect(+ ShareCharacterRow.text(for: characters)+ == "Characters: alice (Al, Ally), Bob, Zed, Zed")++ // The claim that matters: one order, shared with the page (Q9).+ let detail = try await fixture.repository.workDetail(id: Self.workID)+ #expect(detail.characters.map(\.name) == characters.map(\.name))+ #expect(detail.characters.map(\.aliases) == characters.map(\.aliases))+ withExtendedLifetime(fixture) {}+ }++ @Test("A torn character shows the content the work page presents, with no marker")+ func tornCharacterUsesPresentedContent() async throws {+ let fixture = try await seeded(characters: [+ M5SeedCharacter(+ id: Self.alice, name: "Alice", aliases: ["Al"], note: "brave",+ workID: Self.workID),+ M5SeedCharacter(+ id: Self.alice, name: "Alice Vance", aliases: ["Al"], note: "wary",+ workID: Self.workID),+ ])++ let characters = try await context(fixture).characters+ let detail = try await fixture.repository.workDetail(id: Self.workID)+ let presented = try #require(detail.characters.first)+ #expect(presented.isTorn)+ // One entry for one character, whatever its rows disagree about, and+ // the same text the page shows for it.+ #expect(characters == [ShareCharacter(name: presented.name, aliases: presented.aliases)])+ withExtendedLifetime(fixture) {}+ }++ @Test("An orphaned character is invisible, as it is on the work page")+ func orphanIsAbsent() async throws {+ let fixture = try await seeded(characters: [+ M5SeedCharacter(id: Self.alice, name: "Alice", workID: Self.workID),+ M5SeedCharacter(id: Self.orphan, name: "Nobody", workID: nil),+ ])++ let characters = try await context(fixture).characters+ #expect(characters == [ShareCharacter(name: "Alice")])+ withExtendedLifetime(fixture) {}+ }++ @Test("Duplicate Work rows sharing an id union their characters, each listed once")+ func duplicateWorkRowsUnionWithoutDoubles() async throws {+ let fixture = try await seeded(+ works: [+ M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example"),+ M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example"),+ ],+ characters: [+ // One character per row of the same work group…+ M5SeedCharacter(id: Self.alice, name: "Alice", workID: Self.workID),+ M5SeedCharacter(+ id: Self.bob, name: "Bob", workID: Self.workID, workRowIndex: 1),+ // …and one character that is itself two rows: still one entry.+ M5SeedCharacter(+ id: Self.zedFirst, name: "Zed", note: "one", workID: Self.workID),+ M5SeedCharacter(+ id: Self.zedFirst, name: "Zed", note: "two", workID: Self.workID,+ workRowIndex: 1),+ ])++ let characters = try await context(fixture).characters+ #expect(characters.map(\.name) == ["Alice", "Bob", "Zed"])+ withExtendedLifetime(fixture) {}+ }++ @Test("A work without characters and an unknown id both answer nothing, neither throws")+ func emptyAnswersDoNotThrow() async throws {+ let fixture = try await seeded(+ works: [+ M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example"),+ M5SeedWork(id: Self.otherWorkID, displayTitle: "Another", hostname: "c.example"),+ ],+ characters: [+ M5SeedCharacter(id: Self.alice, name: "Alice", workID: Self.workID)+ ])++ #expect(try await context(fixture, workID: Self.otherWorkID) == .empty)+ // An id the sheet projected but the store does not hold: an empty+ // context, not a `recordNotFound`.+ #expect(try await context(fixture, workID: UUID()) == .empty)+ withExtendedLifetime(fixture) {}+ }++ // MARK: - The notes (T-1917, Decision 4)++ /// The defect that superseded Decision 3, as reported: chapter 55 carries+ /// the work's most recent note, the reader is re-sharing 53, and a note from+ /// *ahead* of them cannot catch anyone up. Chapter 40 is there so "nearest"+ /// is not satisfied by "oldest" either.+ @Test("The nearest chapter before the one being shared wins over a later, newer note")+ func nearestPrecedingChapterWins() async throws {+ let day1 = try m5LocalDate(year: 2026, month: 5, day: 12)+ let day2 = try m5LocalDate(year: 2026, month: 5, day: 13)+ let day3 = try m5LocalDate(year: 2026, month: 5, day: 14)+ let fixture = try await seeded(+ works: [+ M5SeedWork(+ id: Self.workID, displayTitle: "A Serial", hostname: "c.example",+ genericNotes: " Reading with Ada. ")+ ],+ entries: [+ M5SeedEntry(+ id: Self.oldest, captureTitle: "Forty", hostname: "c.example",+ path: "forty", note: "Long ago", chapterTitle: "Chapter 40",+ firstCapturedAt: day2, lastSharedAt: day2, workID: Self.workID),+ M5SeedEntry(+ id: Self.middle, captureTitle: "Fifty-two", hostname: "c.example",+ path: "fifty-two", note: " She reaches the lamp room. ", rating: .up,+ chapterTitle: "Chapter 52", firstCapturedAt: day1, lastSharedAt: day1,+ workID: Self.workID),+ M5SeedEntry(+ id: Self.ahead, captureTitle: "Fifty-five", hostname: "c.example",+ path: "fifty-five", note: "Ahead of where the reader is",+ chapterTitle: "Chapter 55", firstCapturedAt: day3, lastSharedAt: day3,+ workID: Self.workID),+ ])++ let context = try await context(fixture, anchorTitle: "Chapter 53")+ // Trimmed in the read, so the views test only for emptiness (Req 5).+ #expect(context.workNote == "Reading with Ada.")+ #expect(context.lastNote == ShareLastNote(+ title: "Chapter 52", note: "She reaches the lamp room.", rating: .up,+ lastSharedAt: day1))+ #expect(context.hasCatchUp)+ withExtendedLifetime(fixture) {}+ }++ @Test("A preceding chapter whose note is blank is skipped, to the next one before it")+ func blankNotesAreSkippedToTheNextPrecedingChapter() async throws {+ let fixture = try await seeded(entries: [+ M5SeedEntry(+ id: Self.oldest, captureTitle: "Two", hostname: "c.example", path: "two",+ note: "Still here", chapterTitle: "Chapter 2", workID: Self.workID),+ M5SeedEntry(+ id: Self.middle, captureTitle: "Three", hostname: "c.example", path: "three",+ note: " \n ", chapterTitle: "Chapter 3", workID: Self.workID),+ M5SeedEntry(+ id: Self.newest, captureTitle: "Four", hostname: "c.example", path: "four",+ note: "", chapterTitle: "Chapter 4", workID: Self.workID),+ ])++ let context = try await context(fixture, anchorTitle: "Chapter 5")+ #expect(context.lastNote?.title == "Chapter 2")+ #expect(context.lastNote?.note == "Still here")+ withExtendedLifetime(fixture) {}+ }++ /// Strictly before, so a second capture of the chapter being shared cannot+ /// read the reader their own note back — the known issue Q7 accepted on the+ /// ambiguous path, closed as a side effect of Decision 4.+ @Test("An entry at the same placement as the chapter being shared is not 'before' it")+ func equalPlacementIsNotBefore() async throws {+ let fixture = try await seeded(entries: [+ M5SeedEntry(+ id: Self.newest, captureTitle: "Seven again", hostname: "c.example",+ path: "seven-again", note: "The chapter being shared",+ chapterTitle: "Chapter 7", workID: Self.workID),+ M5SeedEntry(+ id: Self.oldest, captureTitle: "Six", hostname: "c.example", path: "six",+ note: "The one before", chapterTitle: "Chapter 6", workID: Self.workID),+ ])+ #expect(+ try await context(fixture, anchorTitle: "Chapter 7").lastNote?.note+ == "The one before")+ withExtendedLifetime(fixture) {}++ // With nothing but the same-placement entry, there is no last note at+ // all rather than that entry.+ let alone = try await seeded(entries: [+ M5SeedEntry(+ id: Self.newest, captureTitle: "Seven again", hostname: "c.example",+ path: "seven-again", note: "The chapter being shared",+ chapterTitle: "Chapter 7", workID: Self.workID)+ ])+ #expect(try await context(alone, anchorTitle: "Chapter 7").lastNote == nil)+ withExtendedLifetime(alone) {}+ }++ /// Decision 4's accepted cost: an entry the rules never numbered — no URL+ /// sequence, no number in its title — sits on neither scale, so nothing can+ /// say whether it is before the chapter being shared. It is never the last+ /// note, however recently it was written. This is also where Req 8.12's+ /// third title arm went: an entry with no chapter fields cannot be selected,+ /// so its site-cleaned capture title is unreachable as a heading.+ @Test("An entry with no placement never qualifies, however noted")+ func unplaceableCandidateNeverQualifies() async throws {+ let day1 = try m5LocalDate(year: 2026, month: 5, day: 12)+ let day2 = try m5LocalDate(year: 2026, month: 5, day: 13)+ let fixture = try await seeded(entries: [+ M5SeedEntry(+ id: Self.unplaced, captureTitle: "An Interlude", hostname: "c.example",+ path: "interlude", note: "Between the chapters",+ chapterTitle: "An Interlude", firstCapturedAt: day2, lastSharedAt: day2,+ workID: Self.workID),+ M5SeedEntry(+ id: Self.oldest, captureTitle: "Two", hostname: "c.example", path: "two",+ note: "Numbered, and older", chapterTitle: "Chapter 2",+ firstCapturedAt: day1, lastSharedAt: day1, workID: Self.workID),+ ])++ #expect(try await context(fixture, anchorTitle: "Chapter 5").lastNote?.title == "Chapter 2")+ withExtendedLifetime(fixture) {}++ // Alone, it leaves the block empty rather than taking the place.+ let alone = try await seeded(entries: [+ M5SeedEntry(+ id: Self.unplaced, captureTitle: "An Interlude", hostname: "c.example",+ path: "interlude", note: "Between the chapters", workID: Self.workID)+ ])+ #expect(try await context(alone, anchorTitle: "Chapter 5").lastNote == nil)+ withExtendedLifetime(alone) {}+ }++ /// The reader's second call: a note that might be from ahead is worse than+ /// no note, so an unplaceable current chapter shows the work notes alone.+ @Test("A chapter with no placement of its own has no last note, and keeps the work notes")+ func unplaceableAnchorKeepsOnlyTheWorkNotes() async throws {+ let fixture = try await seeded(+ works: [+ M5SeedWork(+ id: Self.workID, displayTitle: "A Serial", hostname: "c.example",+ genericNotes: "Reading with Ada.")+ ],+ entries: [+ M5SeedEntry(+ id: Self.oldest, captureTitle: "Two", hostname: "c.example", path: "two",+ note: "Where the story was", chapterTitle: "Chapter 2",+ workID: Self.workID)+ ])++ // A titled chapter the rules never numbered, and a capture with no+ // chapter fields at all: the same answer.+ for anchor in ["An Interlude", nil] {+ let context = try await context(fixture, anchorTitle: anchor)+ #expect(context.lastNote == nil, "anchor \(anchor ?? "nil")")+ #expect(context.workNote == "Reading with Ada.", "anchor \(anchor ?? "nil")")+ #expect(context.hasCatchUp, "anchor \(anchor ?? "nil")")+ }+ withExtendedLifetime(fixture) {}+ }++ @Test("Two entries at one placement break by the newest note, then by UUID ascending")+ func tiesAtOnePlacementBreakByRecencyThenUUID() async throws {+ let day1 = try m5LocalDate(year: 2026, month: 5, day: 12)+ let day2 = try m5LocalDate(year: 2026, month: 5, day: 13)+ // Same chapter number, two entries: the newer note wins even though the+ // UUID order would say otherwise.+ let recency = try await seeded(entries: [+ M5SeedEntry(+ id: Self.tieLow, captureTitle: "Three", hostname: "c.example",+ path: "three-a", note: "Older", chapterTitle: "Chapter 3",+ firstCapturedAt: day1, lastSharedAt: day1, workID: Self.workID),+ M5SeedEntry(+ id: Self.tieHigh, captureTitle: "Three", hostname: "c.example",+ path: "three-b", note: "Newer", chapterTitle: "Chapter 3",+ firstCapturedAt: day2, lastSharedAt: day2, workID: Self.workID),+ ])+ #expect(try await context(recency, anchorTitle: "Chapter 9").lastNote?.note == "Newer")+ withExtendedLifetime(recency) {}++ // Same chapter number and the same date: only the UUID can decide, and+ // it decides the same way on every read.+ let uuids = try await seeded(entries: [+ M5SeedEntry(+ id: Self.tieHigh, captureTitle: "Three", hostname: "c.example",+ path: "three-b", note: "Higher UUID", chapterTitle: "Chapter 3",+ firstCapturedAt: day1, lastSharedAt: day1, workID: Self.workID),+ M5SeedEntry(+ id: Self.tieLow, captureTitle: "Three", hostname: "c.example",+ path: "three-a", note: "Lower UUID", chapterTitle: "Chapter 3",+ firstCapturedAt: day1, lastSharedAt: day1, workID: Self.workID),+ ])+ #expect(try await context(uuids, anchorTitle: "Chapter 9").lastNote?.note == "Lower UUID")+ withExtendedLifetime(uuids) {}+ }++ /// The spine's rule (Q26), which the sheet now shares: a site-wide id and a+ /// chapter number say nothing about each other, so every positioned chapter+ /// sorts before every numbered one — in both directions.+ @Test("Position and key mix as the work page's Chapter view mixes them")+ func positionAndKeyMixAsTheSpineDoes() async throws {+ let fixture = try await seeded(entries: [+ // A site-wide id from the URL rule — a position, not a number.+ M5SeedEntry(+ id: Self.oldest, captureTitle: "Posted early", hostname: "c.example",+ path: "early", note: "Positioned", chapterSequence: "1043100",+ workID: Self.workID),+ M5SeedEntry(+ id: Self.newest, captureTitle: "Five", hostname: "c.example", path: "five",+ note: "Numbered", chapterTitle: "Chapter 5", workID: Self.workID),+ ])++ // A numbered anchor: the numbered chapter is nearer than any position.+ #expect(try await context(fixture, anchorTitle: "Chapter 12").lastNote?.note == "Numbered")+ // A positioned anchor: a numbered chapter is not before it at all, so+ // only the earlier position qualifies.+ #expect(+ try await context(fixture, anchorSequence: "1043200", anchorTitle: nil)+ .lastNote?.note == "Positioned")+ // And an anchor before that position leaves nothing at all.+ #expect(+ try await context(fixture, anchorSequence: "1043000", anchorTitle: nil)+ .lastNote == nil)+ withExtendedLifetime(fixture) {}+ }++ @Test("The re-share sheet's own entry is excluded, so the block shows the note before it")+ func exclusionSkipsTheEditedEntry() async throws {+ let day1 = try m5LocalDate(year: 2026, month: 5, day: 12)+ let day2 = try m5LocalDate(year: 2026, month: 5, day: 13)+ let fixture = try await seeded(entries: [+ M5SeedEntry(+ id: Self.oldest, captureTitle: "One", hostname: "c.example", path: "one",+ note: "Before", chapterTitle: "Chapter 1", firstCapturedAt: day1,+ lastSharedAt: day1, workID: Self.workID),+ M5SeedEntry(+ id: Self.newest, captureTitle: "Two", hostname: "c.example", path: "two",+ note: "The one being edited", chapterTitle: "Chapter 2",+ firstCapturedAt: day2, lastSharedAt: day2, workID: Self.workID),+ ])++ // A later chapter being shared sees chapter 2's note…+ let unexcluded = try await context(fixture, anchorTitle: "Chapter 3")+ #expect(unexcluded.lastNote?.note == "The one being edited")++ // …while re-sharing chapter 2 itself excludes it — by id, and by+ // placement — and reaches the note before it.+ let excluded = try await context(+ fixture, excluding: Self.newest, anchorTitle: "Chapter 2")+ #expect(excluded.lastNote?.note == "Before")+ #expect(excluded.lastNote?.title == "Chapter 1")+ withExtendedLifetime(fixture) {}+ }++ @Test("A work whose entries all carry blank notes has no last note, and says so quietly")+ func noNotedEntryIsAnEmptyCaseNotAFailure() async throws {+ let fixture = try await seeded(+ works: [+ M5SeedWork(+ id: Self.workID, displayTitle: "A Serial", hostname: "c.example",+ genericNotes: "Kept")+ ],+ entries: [+ M5SeedEntry(+ id: Self.oldest, captureTitle: "One", hostname: "c.example", path: "one",+ note: " ", rating: .down, chapterTitle: "Chapter 1", workID: Self.workID)+ ])++ let context = try await context(fixture)+ #expect(context.lastNote == nil)+ #expect(context.workNote == "Kept")+ #expect(context.hasCatchUp)+ withExtendedLifetime(fixture) {}+ }++ @Test("Neither a work note nor a last note means nothing to draw at all")+ func nothingToSayDrawsNoSection() async throws {+ let fixture = try await seeded(+ entries: [+ M5SeedEntry(+ id: Self.oldest, captureTitle: "One", hostname: "c.example", path: "one",+ chapterTitle: "Chapter 1", workID: Self.workID)+ ],+ characters: [M5SeedCharacter(id: Self.alice, name: "Alice", workID: Self.workID)])++ let context = try await context(fixture)+ #expect(!context.hasCatchUp)+ // …while the characters row is unaffected (Req 3).+ #expect(context.characters == [ShareCharacter(name: "Alice")])+ withExtendedLifetime(fixture) {}+ }++ @Test("Same-UUID duplicate Work rows contribute the carrier row's notes")+ func duplicateWorkRowsUseTheCarriersNote() async throws {+ let fixture = try await seeded(+ works: [+ // A bare row — parsed title, no notes — and the row that+ // carries what the reader wrote. The bare row may represent;+ // the carrier is what the page and the sheet present (Q41).+ M5SeedWork(+ id: Self.workID, displayTitle: "A Serial", hostname: "c.example",+ titleProvenance: .parsed, lastParsedTitle: "A Serial"),+ M5SeedWork(+ id: Self.workID, displayTitle: "A Serial", hostname: "c.example",+ genericNotes: "The reader's own notes"),+ ],+ entries: [+ M5SeedEntry(+ id: Self.oldest, captureTitle: "One", hostname: "c.example", path: "one",+ note: "A note", chapterTitle: "Chapter 1", workID: Self.workID)+ ])++ let context = try await context(fixture)+ #expect(context.workNote == "The reader's own notes")+ // The same note the work page presents for the same rows (Req 9).+ let detail = try await fixture.repository.workDetail(id: Self.workID)+ #expect(context.workNote == detail.work.genericNotes)+ withExtendedLifetime(fixture) {}+ }++ @Test("A torn work contributes its leading variant's note, with no marker (Q8)")+ func tornWorkUsesTheLeadingVariantsNote() async throws {+ let earlier = try m5LocalDate(year: 2026, month: 4, day: 1)+ let later = try m5LocalDate(year: 2026, month: 4, day: 2)+ let fixture = try await seeded(+ works: [+ M5SeedWork(+ id: Self.workID, displayTitle: "A Serial", hostname: "c.example",+ genericNotes: "One reading", createdAt: earlier),+ M5SeedWork(+ id: Self.workID, displayTitle: "A Serial", hostname: "c.example",+ genericNotes: "Another reading", createdAt: later),+ ],+ entries: [+ M5SeedEntry(+ id: Self.oldest, captureTitle: "One", hostname: "c.example", path: "one",+ note: "A note", chapterTitle: "Chapter 1", workID: Self.workID)+ ])++ let context = try await context(fixture)+ let detail = try await fixture.repository.workDetail(id: Self.workID)+ // Whatever the rows disagree about, the sheet says what the page says.+ #expect(context.workNote == detail.work.genericNotes)+ #expect(!context.workNote.isEmpty)+ withExtendedLifetime(fixture) {}+ }++ @Test("A split entry group contributes its carrier's note; a torn one its leading variant's")+ func splitAndTornEntryGroupsUseTheCarriersNote() async throws {+ let day1 = try m5LocalDate(year: 2026, month: 5, day: 12)+ let day2 = try m5LocalDate(year: 2026, month: 5, day: 13)+ let fixture = try await seeded(entries: [+ // A split group: one bare row — its assignment derived, so nothing+ // on it is authored — and one carrying the note and the chapter.+ M5SeedEntry(+ id: Self.oldest, captureTitle: "One", hostname: "c.example", path: "one",+ firstCapturedAt: day1, lastSharedAt: day1, workID: Self.workID,+ workAssignmentProvenance: .none),+ M5SeedEntry(+ id: Self.oldest, captureTitle: "One", hostname: "c.example", path: "one",+ note: "The carried note", chapterTitle: "Chapter 1",+ firstCapturedAt: day1, lastSharedAt: day1, workID: Self.workID),+ // A torn group, later in the story, so it is the one selected.+ M5SeedEntry(+ id: Self.newest, captureTitle: "Two", hostname: "c.example", path: "two",+ note: "One version", chapterTitle: "Chapter 2", firstCapturedAt: day2,+ lastSharedAt: day2, workID: Self.workID),+ M5SeedEntry(+ id: Self.newest, captureTitle: "Two", hostname: "c.example", path: "two",+ note: "Another version", chapterTitle: "Chapter 2",+ firstCapturedAt: day2, lastSharedAt: day2, workID: Self.workID),+ ])++ let detail = try await fixture.repository.workDetail(id: Self.workID)+ let torn = try #require(detail.chapterRows.first { $0.id == Self.newest })+ let split = try #require(detail.chapterRows.first { $0.id == Self.oldest })++ // The torn group is the nearest chapter before the anchor; no marker,+ // just the note the page shows for it.+ let nearest = try await context(fixture, anchorTitle: "Chapter 3")+ #expect(nearest.lastNote?.note == torn.note)+ #expect(!(nearest.lastNote?.note.isEmpty ?? true))++ // Excluding it reaches the split group, whose carrier — not its bare+ // row — supplies the note, the chapter title and the placement.+ let excluded = try await context(+ fixture, excluding: Self.newest, anchorTitle: "Chapter 2")+ #expect(excluded.lastNote?.note == split.note)+ #expect(excluded.lastNote?.note == "The carried note")+ #expect(excluded.lastNote?.title == "Chapter 1")+ withExtendedLifetime(fixture) {}+ }++ /// Two of Req 8.12's three arms. The third — the site-cleaned capture title+ /// — is unreachable for a *selected* note under Decision 4: a placement is+ /// derived from the chapter title or the chapter sequence, so an entry+ /// carrying neither is never selected. `unplaceableCandidateNeverQualifies`+ /// is where that case went.+ @Test("The heading title is Req 8.12's resolution: chapter title, else sequence")+ func titleResolvesAsRecentResolvesIt() async throws {+ // A taught whole-title site, so the resolution has a cleaned arm to+ // prefer the chapter fields over.+ let site = M5SeedSite(+ hostname: "c.example", mode: .taught, wholeTitleRule: true, trimPrefix: "Read: ")++ func lastNote(_ entry: M5SeedEntry, anchorTitle: String?) async throws -> ShareLastNote? {+ let fixture = try await seeded(entries: [entry], sites: [site])+ let context = try await context(fixture, anchorTitle: anchorTitle)+ withExtendedLifetime(fixture) {}+ return context.lastNote+ }++ let titled = try await lastNote(+ M5SeedEntry(+ id: Self.oldest, captureTitle: "Read: Chapter One", hostname: "c.example",+ path: "one", note: "A note", chapterTitle: "The Long Way",+ chapterSequence: "7", workID: Self.workID),+ anchorTitle: "Chapter 9")+ #expect(titled?.title == "The Long Way")++ // No chapter title, but the URL rule numbered it — a site-wide id shows+ // as that id, exactly as Recent lists it. That id is a position, which+ // every numbered anchor sorts after (Q26).+ let sequenced = try await lastNote(+ M5SeedEntry(+ id: Self.oldest, captureTitle: "Read: Chapter One", hostname: "c.example",+ path: "one", note: "A note", chapterSequence: "1043128",+ workID: Self.workID),+ anchorTitle: "Chapter 9")+ #expect(sequenced?.title == "1043128")+ }++ @Test("A blank chapter title carries an empty title, which the heading draws as the date alone")+ func blankTitleHeadsWithTheDateAlone() async throws {+ let day = try m5LocalDate(year: 2026, month: 5, day: 12)+ // Placed by its sequence and titled by a string that trims away (Q10):+ // with Decision 4 in force this is the shape that still reaches an+ // empty heading title, a title-less capture having no placement at all.+ let fixture = try await seeded(entries: [+ M5SeedEntry(+ id: Self.oldest, captureTitle: "", hostname: "c.example", path: "one",+ note: "A note", chapterTitle: " ", chapterSequence: "12",+ firstCapturedAt: day, lastSharedAt: day, workID: Self.workID)+ ])++ let note = try #require(try await context(fixture, anchorTitle: "Chapter 20").lastNote)+ #expect(note.title == "")+ let heading = ShareLastNoteRow.heading(+ for: note, locale: Locale(identifier: "en_US"),+ calendar: Calendar(identifier: .gregorian), timeZone: .current)+ #expect(!heading.contains("·"))+ withExtendedLifetime(fixture) {}+ }++ @Test("A failure in the notes half leaves the characters as read (Decision 2)")+ func notesHalfFailureKeepsTheCharacters() async throws {+ let fixture = try await seeded(+ works: [+ M5SeedWork(+ id: Self.workID, displayTitle: "A Serial", hostname: "c.example",+ genericNotes: "Kept")+ ],+ entries: [+ M5SeedEntry(+ id: Self.oldest, captureTitle: "One", hostname: "c.example", path: "one",+ note: "A note", chapterTitle: "Chapter 1", workID: Self.workID)+ ],+ characters: [M5SeedCharacter(id: Self.alice, name: "Alice", workID: Self.workID)])++ // An unrecognised rating raw, which `snapshot(EntryGroup)` refuses. Set+ // in the read's own context and never saved: the store on disk stays as+ // seeded, and the failure is the notes half's alone.+ let context = try await fixture.repository.withLockedContext(+ mode: .exclusive, operation: "corrupting a row for the test"+ ) { context in+ for row in try context.fetch(FetchDescriptor<Entry>()) { row.ratingRaw = "sideways" }+ return try LibraryRepository.shareWorkContext(+ forWorkID: Self.workID, currentChapterSequence: nil,+ currentChapterTitle: "Chapter 9", context: context)+ }++ #expect(context.characters == [ShareCharacter(name: "Alice")])+ #expect(context.workNote == "")+ #expect(context.lastNote == nil)+ withExtendedLifetime(fixture) {}+ }+}++// T-1916 task 3 / T-1917 task 3: the re-share sheet's context rides along in the+// lookup it already waits on (Q5), and only for the caller that displays it+// (Q10).++@Suite("Share work context on the capture lookup", .serialized)+struct ShareWorkContextLookupTests {++ private static let workID = UUID(uuidString: "1F000000-0000-4000-8000-000000000101")!+ private static let castlessWorkID = UUID(uuidString: "1F000000-0000-4000-8000-000000000102")!+ private static let entryID = UUID(uuidString: "1F000000-0000-4000-8000-0000000001E1")!+ private static let earlierEntryID = UUID(uuidString: "1F000000-0000-4000-8000-0000000001E0")!+ private static let laterEntryID = UUID(uuidString: "1F000000-0000-4000-8000-0000000001E2")!+ private static let alice = UUID(uuidString: "1F000000-0000-4000-8000-0000000001AA")!+ private static let bob = UUID(uuidString: "1F000000-0000-4000-8000-0000000001BB")!++ private static let rawURL = "https://c.example/chapter-1"++ /// One entry on `rawURL`, attached to `workID` unless told otherwise, so the+ /// lookup answers `.edit` for a work the store already holds. `extraEntries`+ /// are earlier chapters of the same work.+ private func seeded(+ workID: UUID? = ShareWorkContextLookupTests.workID,+ workNote: String = "",+ note: String = "",+ extraEntries: [M5SeedEntry] = [],+ characters: [M5SeedCharacter]+ ) async throws -> M5Fixture {+ let fixture = try await M5Fixture()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "c.example")],+ works: [+ M5SeedWork(+ id: Self.workID, displayTitle: "A Serial", hostname: "c.example",+ genericNotes: workNote),+ M5SeedWork(+ id: Self.castlessWorkID, displayTitle: "Another Serial",+ hostname: "c.example"),+ ],+ entries: [+ M5SeedEntry(+ id: Self.entryID, captureTitle: "Chapter 1", hostname: "c.example",+ path: "chapter-1", note: note, chapterTitle: "Chapter 1",+ lastSharedAt: M5Fixture.epoch.addingTimeInterval(86_400), workID: workID)+ ] + extraEntries,+ characters: characters)+ return fixture+ }++ private func editBasis(+ _ fixture: M5Fixture, includeWorkContext: Bool+ ) async throws -> ReShareEditBasis {+ let disposition = try await fixture.repository.captureLookup(+ rawURL: Self.rawURL, captureTitle: "Chapter 1",+ includeWorkContext: includeWorkContext)+ guard case .edit(let basis) = disposition else {+ Issue.record("Expected an edit disposition, got \(disposition)")+ throw LibraryRepositoryError.recordNotFound(type: "Entry", id: Self.entryID)+ }+ return basis+ }++ private static let cast: [M5SeedCharacter] = [+ M5SeedCharacter(id: bob, name: "Bob", workID: workID),+ M5SeedCharacter(id: alice, name: "alice", aliases: ["Ally", "Al"], workID: workID),+ ]++ @Test("An opted-in lookup carries the matched work's cast in the work page's order")+ func lookupCarriesCharactersWhenAsked() async throws {+ let fixture = try await seeded(characters: Self.cast)++ let basis = try await editBasis(fixture, includeWorkContext: true)+ #expect(basis.entryID == Self.entryID)+ #expect(basis.workContext.characters == [+ ShareCharacter(name: "alice", aliases: ["Al", "Ally"]),+ ShareCharacter(name: "Bob"),+ ])+ // The same list the standalone read gives for that work — one order,+ // one projection.+ let direct = try await fixture.repository.shareWorkContext(+ workID: Self.workID, currentChapterSequence: nil, currentChapterTitle: "Chapter 1")+ #expect(basis.workContext.characters == direct.characters)+ withExtendedLifetime(fixture) {}+ }++ @Test("An opted-in lookup excludes the entry it matched, and carries the work's notes")+ func lookupExcludesTheMatchedEntry() async throws {+ let fixture = try await seeded(+ workNote: "Reading with Ada.",+ note: "The note already in the editor",+ extraEntries: [+ M5SeedEntry(+ id: Self.earlierEntryID, captureTitle: "Chapter 0", hostname: "c.example",+ path: "chapter-0", note: "Where the story was", chapterTitle: "Chapter 0",+ workID: Self.workID)+ ],+ characters: Self.cast)++ let basis = try await editBasis(fixture, includeWorkContext: true)+ #expect(basis.workContext.workNote == "Reading with Ada.")+ // Not the matched entry's own note, which is what the editor holds.+ #expect(basis.workContext.lastNote?.note == "Where the story was")+ #expect(basis.workContext.lastNote?.title == "Chapter 0")+ withExtendedLifetime(fixture) {}+ }++ /// The device report, end to end: the lookup anchors on the chapter it+ /// matched, so a newer note from a chapter *ahead* of it stays off the+ /// sheet (Decision 4).+ @Test("An opted-in lookup anchors on the matched chapter, not on the newest note")+ func lookupAnchorsOnTheMatchedChapter() async throws {+ let fixture = try await seeded(+ note: "The note already in the editor",+ extraEntries: [+ M5SeedEntry(+ id: Self.earlierEntryID, captureTitle: "Chapter 0", hostname: "c.example",+ path: "chapter-0", note: "Where the story was", chapterTitle: "Chapter 0",+ workID: Self.workID),+ M5SeedEntry(+ id: Self.laterEntryID, captureTitle: "Chapter 2", hostname: "c.example",+ path: "chapter-2", note: "Ahead of the reader", chapterTitle: "Chapter 2",+ lastSharedAt: M5Fixture.epoch.addingTimeInterval(864_000),+ workID: Self.workID),+ ],+ characters: Self.cast)++ let basis = try await editBasis(fixture, includeWorkContext: true)+ #expect(basis.workContext.lastNote?.title == "Chapter 0")+ withExtendedLifetime(fixture) {}+ }++ @Test("A lookup that did not ask carries no context, whatever the work holds")+ func lookupWithoutTheFlagCarriesNothing() async throws {+ let fixture = try await seeded(+ workNote: "Reading with Ada.", characters: Self.cast)++ #expect(try await editBasis(fixture, includeWorkContext: false).workContext == .empty)+ // The default the drain and every other caller take.+ let defaulted = try await fixture.repository.captureLookup(+ rawURL: Self.rawURL, captureTitle: "Chapter 1")+ guard case .edit(let basis) = defaulted else {+ Issue.record("Expected an edit disposition, got \(defaulted)")+ return+ }+ #expect(basis.workContext == .empty)+ withExtendedLifetime(fixture) {}+ }++ @Test("A matched work without a cast or notes carries nothing, as does an unattached entry")+ func emptyWorkAndUnattachedBothCarryNothing() async throws {+ let castless = try await seeded(+ workID: Self.castlessWorkID,+ characters: [M5SeedCharacter(id: Self.alice, name: "alice", workID: Self.workID)])+ #expect(try await editBasis(castless, includeWorkContext: true).workContext == .empty)+ withExtendedLifetime(castless) {}++ // No work at all: the same empty sheet, no failure.+ let unattached = try await seeded(+ workID: nil,+ characters: [M5SeedCharacter(id: Self.alice, name: "alice", workID: Self.workID)])+ #expect(try await editBasis(unattached, includeWorkContext: true).workContext == .empty)+ withExtendedLifetime(unattached) {}+ }+}
diff --git a/docs/asterism-design.md b/docs/asterism-design.mdindex 85cad71..c7723a5 100644--- a/docs/asterism-design.md+++ b/docs/asterism-design.md@@ -190,6 +190,7 @@ Top to bottom: 2. **Note field**, multiline, focused. 3. **Rating**: two toggle buttons (▲ ▼) beside Save. Tap to set, tap again to clear. Absent by default; no rating tap ever required. 4. **Save** button.+5. **Catch up** (read-only, below the editor; only where the capture resolves to a work the library already holds and there is something to show): the work's own notes, then the nearest noted chapter *before* the one being shared as `{title} · {date}` with its full note text. Text only, no control, no clamp (share-sheet-last-note Decisions 1 and 4). Nothing else — curation lives in the app. @@ -198,7 +199,7 @@ Nothing else — curation lives in the app. | State | Trigger | Differences | |---|---|---| | Parsed | Known site, parse succeeds | Baseline as above. |-| Editing | Identity key matches existing entry | "Noted <date> — editing existing entry" banner, the entry's work's cast in the `Characters:` row directly beneath it; note pre-filled, cursor at end; button reads Update. |+| Editing | Identity key matches existing entry | "Noted <date> — editing existing entry" banner, the entry's work's cast in the `Characters:` row directly beneath it; note pre-filled, cursor at end; button reads Update; the Catch up section shows the nearest noted chapter before this one (the edited entry excluded). | | New site | Unknown hostname | Capture title shown raw; "New site — title saved, teach later in app" banner; entry saved flagged unparsed. **No teach mode at capture time.** | | No title | Fetch fallback failed | Title editable inline. |
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 2933023..2fe8133 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -25,6 +25,7 @@ | [Character Extraction](#character-extraction) | 2026-08-19 | Done | v2 plan item 4 (T-2229). The on-device model reads a work's notes and proposes characters — each fact a verbatim quote citing its source — held until the reader accepts them in a per-candidate, per-fact review; accepted characters are fully reader-editable (and combinable) and live in a new `Character` entity. New schema V7, new archive generation 6/7, CloudKit-synced with the full torn/duplicate machinery. Prototype over a real archive gated the design (Decision 3) and set the schema: aliases yes, confidence no. | | [Work Detail Reading Redesign](#work-detail-reading-redesign) | 2026-08-22 | Done | Smolspec. Makes the notes the content of the work detail screen: the header folds into title, site line, a `{n} notes ▲ ▼` meta line and the work's notes as a paragraph; chapter notes become a spine — a rail with a rating dot and a multi-part chapter key per note (`341`, `5.07`) and the full note text, no cards, no clamp — with a Newest/Chapter sort (unnumbered notes last); the expanded character card is rebuilt on the same gutter. Two derived fields on the `WorkChapterRow` projection, no schema change. Supersedes polish-and-export Reqs 5.4 and 9.4 (and 5.1's order, in part) and design-doc §7's "no chapter-number parsing from titles" (Decision 2). | | [Share Sheet Characters](#share-sheet-characters) | 2026-08-23 | Done | Smolspec (T-1916). One read-only `Characters: Alice (Al, Ally), Bob` row on both capture sheets whenever the share resolves to a work that already exists — every character, work-page order, names and aliases only (no facts, so no spoiler question). The re-share arm gets the list inside `captureLookup`'s existing read (opt-in, so the pending-capture drain never pays for it); the new-capture arm reads once per projected work, guarded by work id rather than the per-keystroke `generation`. No schema change. |+| [Share Sheet Last Note](#share-sheet-last-note) | 2026-08-24 | Done | Smolspec (T-1917). A read-only "Catch up" section under the note editor on both capture sheets whenever the share resolves to a work that already exists: the work's own notes, then the nearest noted chapter before the one being shared (placed on the Chapter view's `ChapterPlacement` scale, the edited entry excluded on re-share) as `{title} · {date}` plus the full note text. Rides the T-1916 characters read, widened into one `ShareWorkContext` (Decision 2); no link into the app — the extension cannot launch its container (Q1). No schema change. | --- @@ -426,3 +427,20 @@ Smolspec (T-1916). The capture sheets show one read-only row listing the work's - [decision_log.md](share-sheet-characters/decision_log.md) - [implementation.md](share-sheet-characters/implementation.md) - [tasks.md](share-sheet-characters/tasks.md)++## Share Sheet Last Note++Smolspec (T-1917). Makes the capture sheets a place to catch up: below the note editor, the work's notes paragraph and the last chapter note — heading `{title} · {date}` with a ▲/▼ glyph when rated, full text, no clamp — whenever the share resolves to an existing work. Nothing renders when the work has neither.++**Decisions worth knowing:**++- **Inline, not a link** (Q1): no URL scheme exists and design §3.1 rules out launching the container from the extension.+- **Below the editor** (Decision 1): writing is the sheet's purpose; the reader dismisses the keyboard or scrolls for the notes.+- **One read, notes half fails on its own** (Decision 2): the characters read becomes `shareWorkContext`; a pure `entryGroups` walk snapshots only the selected entry, so a corrupt row elsewhere in the work cannot take the characters row down. Supersedes the "no type directory" half of share-sheet-characters Q4.+- **Nearest chapter before, not newest note** (Decision 4, superseding Decision 3): candidates are placed by `ChapterPlacement` (URL position, else chapter key — the spine's own comparator, now shared); only placements strictly before the shared chapter qualify, the nearest wins, and an unplaceable current chapter shows the work notes alone.+- **"Last" is newest `lastSharedAt` with a non-blank note** (Decision 3): the work page's "Open last noted chapter" ordering, blank notes skipped.++- [smolspec.md](share-sheet-last-note/smolspec.md)+- [decision_log.md](share-sheet-last-note/decision_log.md)+- [tasks.md](share-sheet-last-note/tasks.md)+- [implementation.md](share-sheet-last-note/implementation.md)
diff --git a/specs/share-sheet-characters/decision_log.md b/specs/share-sheet-characters/decision_log.mdindex edf2000..52ae82f 100644--- a/specs/share-sheet-characters/decision_log.md+++ b/specs/share-sheet-characters/decision_log.md@@ -7,7 +7,7 @@ | Q1 | 2026-08-23 | Show every character of the work, in the work page's name order; no cap, no "main character" ranking | The schema has no prominence, pin or role field, and the reader chose the full list over a derived top-N or a reader-pinned flag (the latter would mean schema V8, a new archive generation and a CloudKit publish) | | Q2 | 2026-08-23 | Name and aliases only — no facts, notes or fact counts | Facts cite entries anywhere in the work, including chapters ahead of the one being shared; `docs/asterism-v2-plan.md` parks spoiler boundaries as a product decision and V7 encodes none. Names and aliases carry no spoiler and keep the note editor near the top of the sheet | | Q3 | 2026-08-23 | Shown on the re-share arm and on the new-capture arm whenever `projectedWorkID` is set (`.reuse` and `.claim` assignments); nothing for `.create`, `.ambiguous`, the saved-for-later and message arms | A claim is an existing nil-identity work matched by title (`LibraryRepository+OutcomeComputation.swift`, `captureWorkMatch`) and has characters exactly as a reuse does; `projectedWorkID` is the field the commit path already keys on |-| Q4 | 2026-08-23 | A narrow character read rather than `workDetail(id:)` | `workDetail` builds every chapter row, the work snapshot and the type directory under the lock; the sheet needs name and aliases only |+| Q4 | 2026-08-23 | A narrow character read rather than `workDetail(id:)` | `workDetail` builds every chapter row, the work snapshot and the type directory under the lock; the sheet needs name and aliases only. **Partly superseded** (2026-08-23): share-sheet-last-note Decision 2 widens this read into `shareWorkContext`, which fetches the type directory to pick the carrier work row | | Q5 | 2026-08-23 | Re-share arm: characters ride along in `captureLookup`'s locked read, on the basis. New-capture arm: a follow-up `shareCharacters(workID:)` read keyed by the projected work id, awaited by the bridge after the sheet renders | The lookup already has the carrier's work in hand, so the re-share arm gets one read, no bridge change and no late fill. The projection is recomputed per keystroke and `CaptureOutcome` is compared at commit, so characters cannot live there; a read keyed by work id runs once per work, not per keystroke | | Q6 | 2026-08-23 | One wrapping text row (`person.2` glyph, `Characters: Name (alias, alias), Name`) in the metadata card's existing row style — no pills, no flow layout; the visible text carries the `Characters:` prefix like the sibling `Work:`/`Chapter:` rows | `FlowLayout` lives in the app target (`TeachingComponents.swift`), the sheet's other metadata is prefixed text rows, and a text row is the shortest presentation of a full list | | Q7 | 2026-08-23 | Torn characters show their presented content with no marker; orphans are invisible | The extension can offer no resolution; `characterRows(of:)` cannot reach an orphan, matching the work page |
diff --git a/specs/share-sheet-last-note/decision_log.md b/specs/share-sheet-last-note/decision_log.mdnew file mode 100644index 0000000..549f7b8--- /dev/null+++ b/specs/share-sheet-last-note/decision_log.md@@ -0,0 +1,181 @@+# Decision Log: Share Sheet Last Note++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-08-23 | Show the notes inline on the sheet; no link into the app | The ticket offered either. The app has no URL scheme, `onOpenURL` or routing, and `docs/asterism-design.md` §3.1 holds that the extension cannot launch its container — `extensionContext.open(_:)` is documented for Today widgets only. The inline section needs none of that and follows T-1916 |+| Q2 | 2026-08-23 | The section goes under the note editor | promoted to Decision 1 |+| Q3 | 2026-08-23 | One read carries characters, work note and last note | promoted to Decision 2 |+| Q4 | 2026-08-23 | "Last chapter" is the newest `lastSharedAt` entry with a non-blank note | promoted to Decision 3 |+| Q5 | 2026-08-23 | The work's own notes (`Work.genericNotes`) appear above the chapter note | Reader's choice: the work note is context the reader wrote for exactly this purpose |+| Q6 | 2026-08-23 | Both arms: new capture for an existing work (`projectedWorkID` set) and re-share | Same arms as the characters row (share-sheet-characters Q3); the read is the same one |+| Q7 | 2026-08-23 | The new-capture arm passes no exclusion, even when the lookup reached `.new` through a multi-match or a torn group | The arm has no entry id to exclude and that path is already the ambiguous-capture one; the heading names the chapter the note belongs to, so a note on the chapter being shared is visible as such |+| Q8 | 2026-08-23 | A torn entry or torn work shows its leading variant's note with no marker | `snapshot(EntryGroup)` and `workGroup` already present the leading variant (`LibraryRepository+Groups.swift:212-215`, `:229-232`), which is what the characters row does for a torn character (share-sheet-characters Q7). A marker or resolution affordance belongs to the app, which owns tear resolution |+| Q9 | 2026-08-23 | The heading's date is `lastSharedAt`, while the re-share banner above it shows `firstCapturedAt` | Each date answers its own question — when the entry was first noted, when the last note was last touched — and the banner's wording is url-identity-re-share Req 4.2's. Not to be unified |+| Q10 | 2026-08-24 | The last note's title is trimmed in the read, like its text | Req 8.12's resolution hands the stored string through untrimmed in Recent, where surrounding whitespace is invisible; on the sheet an untrimmed whitespace-only title would render `" · {date}"` against the date-alone rule. Same blank rule as the notes, so the view still tests only for emptiness |+| Q11 | 2026-08-24 | `ShareWorkContext.hasCatchUp` is the section's render test | The "neither work note nor last note" check lives in core, testable, and the view consumes it rather than restating it (sharesheet-polish Q6) |+| Q12 | 2026-08-24 | The block's accessibility label is produced in core as `ShareLastNoteRow.accessibilityLabel(for:locale:calendar:timeZone:)` | It is a second title-plus-date string; producing it beside `heading(for:)` keeps the view from formatting a date itself (sharesheet-polish Q6) |+| Q13 | 2026-08-24 | The notes-half failure logs at debug level under a capture logger of `CaptureViewModel`'s shape | The smolspec allows it; without it Decision 2's new failure domain is the only one in the feature with no trace, since the inner catch hides it from the lookup's handler. Its subsystem is `AsterismCore`, the one `captureLogger` and `CaptureViewModel`'s logger already use — not the `me.nore.ig.Asterism` subsystem CLAUDE.md's model-diagnostics filter names, so filter on `category:ShareWorkContext` |+| Q14 | 2026-08-24 | `capture.catchUp` / `reshare.catchUp` sit on the section's `ConstellationSectionHeader`, not the card container | A container `.accessibilityIdentifier` shadows its children's in XCUITest queries (`docs/agent-notes/testing.md`), which would make `…workNotes` and `…lastNote` unqueryable; `unattached-section-header` and `duplicate-elsewhere-header` already address sections by their header. The alternative — container identifier plus `.accessibilityElement(children: .contain)`, as `ComposedTeachingView` does — was not taken because the extension has no XCUITest coverage to need a container query, and the header is the static text that names the section |+| Q15 | 2026-08-24 | Req 8.12's third title arm (site-cleaned capture title) is unreachable for a selected note under Decision 4 | A placement requires `chapterSequence` or `chapterTitle`, and the resolution's first two arms consume exactly those; the `displayTitle` fallback and its `SiteLookupCache` were removed in pre-push review (the title is `chapterTitle ?? chapterSequence`); the tests record the unreachability |+| Q16 | 2026-08-24 | A split group is placed and anchored by its carrier's `chapterSequence`, though `snapshot` heads it with the representative's | `chapterSequence` is capture evidence, not authored content, so `snapshot(EntryGroup)` takes it from the representative while the note and title come from the carrier. Rows of one split group disagreeing on sequence needs a CloudKit split with divergent URL-rule parses; accepted as too narrow to widen the read for |+| Q17 | 2026-08-24 | The new-capture guard keys on the *placement* of the projected chapter, not the raw title/sequence strings | Typing a title reprojects per keystroke; raw-string keys made every character a fresh locked read and collapsed the section between them. Two projections with one placement are one read; a placement move still re-reads |++## Decision 1: The catch-up section sits below the note editor++**Date**: 2026-08-23+**Status**: accepted++### Context++The sheet opens with the note editor focused (a 0.3 s delayed focus on the new-capture sheet, cursor at end on re-share) and the keyboard up. The catch-up content — a work note and a full chapter note — can be long. Where it sits decides whether it displaces the thing the reader came to do.++### Decision++Render the section after the note editor on both sheets, full note text, no clamp. The reader dismisses the keyboard or scrolls to reach it.++### Rationale++Writing is the sheet's purpose; a block above the editor pushes the editor below the fold for every share, whether or not the reader wants to catch up. Below it, scrolling is a deliberate gesture in a context where it makes sense, and the work page's no-clamp rule (work-detail-reading-redesign) carries over unchanged.++### Alternatives Considered++- **Above the editor, clamped with an expand control**: keeps the editor near the top — Rejected: a clamp hides exactly the text the reader scrolled for, and the expand control is a second interaction for the same outcome.+- **A header above the editor, content below**: announces the content — Rejected: it announces what the reader still has to scroll for, and costs the same vertical space.++### Consequences++**Positive:**+- The editor keeps its place and focus; nothing the reader is typing into moves when the section arrives one refresh later on the new-capture sheet.++**Negative:**+- With the keyboard up the section starts off-screen on most phones; its presence is not visible without scrolling.++---++## Decision 2: One read per work, with the notes half failing on its own++**Date**: 2026-08-23+**Status**: accepted++### Context++T-1916 reads a work's characters as one narrow shared-lock read, opt-in on the re-share lookup and guarded by work id with an in-flight token on the new-capture sheet (share-sheet-characters Q4, Q10, Q12). The last note and the work note need the same trigger and the same guard. The work note on a same-UUID duplicate set is the carrier row's, which `workGroup(id:rows:types:)` picks only with the type directory share-sheet-characters Q4 chose not to fetch.++### Decision++Widen the characters read into one `ShareWorkContext` read: characters as today; then the type directory, `workGroup` for the carrier's `genericNotes`, and a pure `entryGroups` walk that snapshots only the selected entry (originally ordered by `(lastSharedAt, id)`; the ordering and selection are Decision 4's since it superseded Decision 3). The notes half runs in its own `do/catch`: its failure leaves the characters as read and the notes empty.++### Rationale++A second read would duplicate the in-flight/token guard for the same work id. The groups walk selects exactly what `workDetail`'s `entryActivityOrder` selects without snapshotting every entry (each snapshot validates raw enums and can throw). Snapshotting only the chosen entry keeps the notes half from taking the characters row down over one corrupt row elsewhere in the work — a failure domain the characters row did not have before this change.++### Alternatives Considered++- **A second narrow read for the notes**: keeps T-1916 untouched — Rejected: duplicates the guard machinery and takes the lock twice per projected work.+- **`workDetail(id:)` or `snapshot(WorkGroup)`**: the work page's own read — Rejected: snapshots every entry two to three times and builds rows and presentations the sheet never shows, on the re-share sheet's first-render path.+- **A `FetchDescriptor<Entry>` on `work.id` sorted by `lastSharedAt`**: cheapest — Rejected: cannot see a split group's carrier note, a group's `lastSharedAt` (the maximum over its rows), or a whitespace-only note through a predicate.++### Consequences++**Positive:**+- One lock acquisition and one guard per work; selection agrees with the work page on duplicate, split and torn rows.++**Negative:**+- The type directory is fetched on the re-share lookup path; the "no type directory" half of share-sheet-characters Q4 is superseded.+- Two failure modes to test instead of one.++---++## Decision 3: "Last chapter" is the newest `lastSharedAt` entry whose note is not blank++**Date**: 2026-08-23+**Status**: superseded by Decision 4++### Context++Entries carry no reading position; `lastSharedAt` is the only clock, bumped on every share and re-share. The work page's "Open last noted chapter" opens the entry with the newest `lastSharedAt` (polish-and-export Req 5.3), blank note or not.++### Decision++The last note is the work's logical entry with the newest `lastSharedAt` — UUID ascending on a tie — whose trimmed note is non-empty; on the re-share sheet the entry being edited is excluded. No entry qualifies → no last note. The heading shows the entry's Req 8.12 title as Recent shows it, a site-wide id sequence included.++### Rationale++A blank note cannot catch anyone up, and a rated-but-blank entry says nothing about the story either, so the selection skips to the note that does. The edited entry's note on re-share is already the editor's content. The title resolution is Recent's and entry detail's so the sheet and the list agree; the work page's chapter-key gutter is a different presentation of the same entries, not a different title.++### Alternatives Considered++- **Newest by chapter key**: the last chapter *in the story* with a note — Rejected: a reader re-sharing an earlier chapter is re-reading, and the note they wrote most recently still says where they are; keys are also absent for unnumbered chapters.+- **Newest entry regardless of note**: the work page's literal rule — Rejected: shows a heading with nothing under it.++### Consequences++**Positive:**+- Matches the reader's own most recent act of noting.++**Negative:**+- A re-share that only fixes a typo bumps `lastSharedAt` (`commitReShareUpdate`), so that chapter becomes "last" until the next note.+- A Royal Road sequence-only entry heads with its seven-digit site id, as Recent lists it.++---++## Decision 4: "Last chapter" is the nearest noted chapter before the one being shared++**Date**: 2026-08-24+**Status**: accepted++### Context++On-device testing of the Decision 3 rule showed its flaw immediately: re-sharing+chapter 53 of a work whose chapter 55 carried the most recent note put 55's note+under "Catch up" — a note from *ahead* of the reader's position, which cannot+catch anyone up. `lastSharedAt` recency answers "what did I note last", but the+section's question is "what happened before where I am now". The current+chapter's position is derivable on both arms: the re-share basis entry carries+`chapterSequence`/`chapterTitle`, and the new-capture projection carries+`composedChapterSequence`/`projectedChapter` — the same inputs+`ChapterKeyDerivation` places spine rows with.++### Decision++The last note is selected from the work's logical entries whose trimmed note is+non-blank and whose placement — `ChapterKeyDerivation.position(chapterSequence:)`,+else `ChapterKeyDerivation.key(chapterSequence:chapterTitle:)`, the Chapter+view's own order — is strictly before the current chapter's placement, the+edited entry excluded on re-share. Among candidates the nearest to the current+chapter wins; at the same placement the newest `lastSharedAt`, then entry UUID+ascending. An entry with no placement never qualifies. When the *current*+chapter has no placement, the chapter-note block is dropped and the work-notes+paragraph stands alone. Heading, trimming, blank handling and title resolution+are unchanged.++### Rationale++The reader answered the two open calls directly: nearest-preceding in story+order is "what just happened", which is what catching up means; and a note that+might be from a later chapter is worse than no note, so an unplaceable current+chapter shows only the work notes. Reusing the spine's placement scale keeps the+sheet and the work page's Chapter view telling one story about order, site-wide+id sequences included.++### Alternatives Considered++- **Most recently noted among preceding chapters**: recency bounded by position — Rejected by the reader: recency is what put chapter 55's note on chapter 53's sheet; distance to the current chapter is the better proxy for relevance.+- **Fall back to newest-noted when the current chapter is unplaceable**: today's behaviour as a fallback — Rejected by the reader: it reintroduces the wrong-direction note exactly where the section cannot label the mistake.+- **Hide the whole section when unplaceable**: simplest guard — Rejected: the work notes carry no order and are always safe to show.+- **Decision 3's newest-`lastSharedAt` rule**: superseded — correct only when the reader never re-reads and notes strictly in story order.++### Consequences++**Positive:**+- The section can never show a note from at or ahead of the chapter being shared; re-reading works.+- One placement derivation (`ChapterKeyDerivation`) orders the spine, the citation gutter and the catch-up selection.++**Negative:**+- A noted but unnumbered entry (an interlude the URL rule never sequenced and whose title carries no number) can never appear as the last note.+- The new-capture read is keyed on the work id *and* the current chapter's placement anchor, so a URL edit that changes the sequence within the same work re-reads; Decision 2's one-read-per-work-id guard becomes one-read-per-(work, anchor).+- The re-share sheet's known-issue of showing the shared chapter's own note through a duplicate entry at the same placement is closed as a side effect (equal placement is not "before").++---
diff --git a/specs/share-sheet-last-note/implementation.md b/specs/share-sheet-last-note/implementation.mdnew file mode 100644index 0000000..adc900a--- /dev/null+++ b/specs/share-sheet-last-note/implementation.md@@ -0,0 +1,163 @@+# Implementation Explanation: Share Sheet Last Note (T-1917)++Three-level explanation of the branch `T-1917/share-sheet-last-note` against+`main`, written at pre-push review. The spec is `smolspec.md`; the selection+rule changed mid-implementation after on-device testing (Decision 4 superseding+Decision 3 in `decision_log.md`).++## Beginner Level++### What This Does++When you share a chapter of a story to Asterism, the capture sheet now shows a+read-only "Catch up" section under the note editor: the notes you wrote about+the work as a whole, and the note you wrote on the chapter *just before* the+one you are sharing. Re-reading chapter 53? You see your note on chapter 52+(or the closest earlier chapter you noted), with its title and the date you+last touched it, plus a small ▲/▼ marker if you rated it.++If the work has no notes at all, the section simply is not there. If the app+cannot tell where the shared chapter sits in the story (no chapter number+anywhere), it shows only the work notes — never a note that might be from a+later chapter.++### Why It Matters++A reader sharing the next chapter had to remember where the story left off+unaided. The sheet already showed the work's title and cast (T-1916); now it+also answers "what just happened", using notes the reader wrote for exactly+that purpose. Nothing about saving changes — the section is text only, below+the editor, so writing the new note stays the sheet's first job.++### Key Concepts++- **Share extension**: the small piece of Asterism that runs inside the system+ share sheet. It cannot open the main app, so the notes are shown inline.+- **Entry / Work**: an entry is one noted chapter; a work is the story that+ groups them.+- **Placement**: where a chapter sits in the story, worked out from the+ chapter number in its URL or title — the same ordering the work page's+ Chapter view uses.++## Intermediate Level++### Changes Overview++- `Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift` (renamed+ from `ShareCharacters.swift`): `ShareLastNote`, `ShareWorkContext`+ (characters + work note + last note, `.empty`, `hasCatchUp`),+ `ShareLastNoteRow.heading(for:)` / `accessibilityLabel(for:)`, and the+ widened read `shareWorkContext(forWorkID:excludingEntryID:currentChapterSequence:currentChapterTitle:context:)`.+- `ChapterKeyDerivation.swift`: public `ChapterPlacement` (`.position` /+ `.key`, `Comparable`) — the spine's ordering extracted into one comparator;+ `WorkDetailModel.precedesInChapterOrder` now delegates to it.+- `LibraryRepository+Capture.swift`: the re-share lookup's opt-in block+ (`includeWorkContext:`) reads the context inside its existing locked read,+ anchored on the matched entry and excluding it.+- `CaptureViewModel.swift`: the new-capture arm loads once per+ `WorkContextKey` — projected work id **plus** the chapter's derived+ placement — with the T-1916 token/in-flight/publish guards unchanged.+- `Asterism/AsterismShareExtension/ShareCatchUpSection.swift` (new): header,+ work-notes paragraph and last-note block in one card, rendered after the+ note editor on both sheets; `#Preview`s under `#if DEBUG`.+- `ReSharePresentation.swift`: `ShareDateText.medium(...)` — one cached+ medium-date formatter shared by the banner and both last-note producers.+- ConstellationKit: the rating direction now carries its symbol and accent+ publicly; the toggle style, both sheets and the catch-up glyph read one+ mapping.++### Implementation Approach++One read per (work, anchor) delivers everything the sheet shows about a work+(Decision 2): characters exactly as T-1916 read them, then — in its own+`do/catch`, so a corrupt row empties the notes without taking the characters+down — the carrier row's `genericNotes` and the selected last note. Selection+(Decision 4): bucket the work's entry rows by id, drop buckets with no+non-blank note, group the survivors, place each group's carrier via+`ChapterPlacement`, keep placements strictly before the anchor, take the+nearest (ties: newest `lastSharedAt`, then UUID ascending), snapshot only that+group. Heading and accessibility label are produced in core (sharesheet-polish+Q6); the view lays text out and formats nothing.++### Trade-offs++- **Nearest preceding vs newest note**: the branch first shipped Decision 3+ (newest `lastSharedAt`); on-device testing immediately showed a later+ chapter's note surfacing on an earlier chapter, and the reader ruled it+ wrong. Nearest-preceding in story order is what "catch up" means; the+ recency rule survives only as the tie-break within one placement.+- **One widened read vs a second narrow one**: a second read would duplicate+ the in-flight guard and take the lock twice; the cost accepted is the type+ directory fetch on the lookup path (skipped when the work has one row).+- **Guard keyed on placement, not raw strings**: typing a title reprojects per+ keystroke; raw-string keys meant a locked read and a visible flicker per+ character. Two projections with one placement are one read.+- **No link into the app**: the extension cannot launch its container (Q1);+ the notes are inline or nothing.++## Expert Level++### Technical Deep Dive++The selection walk is pure over `entryGroups` — no snapshots until the single+selected group — because `snapshot(EntryGroup)` validates raw enums and can+throw; snapshotting one group bounds the notes half's failure domain to the+row it will actually show. The pre-filter (buckets with any non-blank note)+is a superset of the candidate set since the carrier is always one of the+bucket's rows; on a reader who notes ~10% of chapters it cuts the group+construction an order of magnitude on the re-share sheet's blocking+first-render path. `ChapterPlacement` is a total order (`position < key`+mirrors the spine's "URL position before title-derived key"; `ChapterKey`+tie-breaks on label after components), so `max(by:)` is deterministic; the+UUID leg deliberately inverts to yield the *lowest* id under `max`.++Anchor semantics: equal placement is not "before", which also closes the+duplicate-of-the-shared-chapter case; an unplaceable candidate never+qualifies; an unplaceable anchor drops the block but not the paragraph. The+Req 8.12 title resolution's third arm (site-cleaned capture title) is+provably unreachable under this rule — placement requires the fields the+first two arms consume — and was removed (Q15); split groups keep+`snapshot`'s field sourcing (carrier `chapterTitle`, representative+`chapterSequence`), an asymmetry recorded as Q16.++### Architecture Impact++- The spine, the citation gutter and the share sheet now order chapters+ through one comparator; a future ordering change is one edit.+- `CaptureCoordinating` widened by two anchor parameters with an+ `.empty`-returning default — conformers that miss the new signature get an+ empty section, not a compile error; the sole production conformer forwards.+- The share extension gained its first `#if DEBUG` previews; the release+ binary carries neither the registry nor the sample prose.+- `subsystem: "AsterismCore", category: "ShareWorkContext"` joins the+ package-side logger set (not the `me.nore.ig.Asterism` set CLAUDE.md's+ model-diagnostics filter names — Q13 documents the filter).++### Potential Issues++- A noted but unnumbered interlude can never appear as the last note+ (Decision 4's accepted negative). If readers notice, the fix is a placement+ fallback, not a selection rewrite.+- A re-share that only fixes a typo still bumps `lastSharedAt`; within one+ placement the tie-break prefers it. Harmless today, but the date shown is+ "last touched", not "written".+- The `.stale` carry-forward keeps the pre-refresh context; a note edited on+ another device between lookup and commit shows one refresh stale. Accepted:+ the refreshed basis carries no context.+- View-layer requirements (placement below the editor, glyph colours,+ identifiers) are pinned only by `#Preview`s and the reader's on-device run —+ the extension has no XCUITest coverage by design.++## Completeness Assessment++- **Fully implemented**: every MUST in the amended smolspec — both arms,+ Decision 4 selection with all recorded edge cases, blank handling in the+ read, heading/label produced in core, identifiers and accessibility, the+ one-read guard (placement-keyed, Q17), `.stale` carry-forward, drain+ exclusion, failure isolation with debug logging (Q13). Verified by+ `make test-core`, `make test-quick`, `make verify-identity`, and the+ reader's on-device runs (which drove Decision 4).+- **Partially implemented**: visual requirements verified by previews and one+ manual device pass, not by automated UI tests (out of scope by design).+- **Missing**: nothing known against the amended spec. Divergences from the+ original text are all recorded (Q10, Q14, Q15, Q17, Decision 4).
diff --git a/specs/share-sheet-last-note/smolspec.md b/specs/share-sheet-last-note/smolspec.mdnew file mode 100644index 0000000..61d4b31--- /dev/null+++ b/specs/share-sheet-last-note/smolspec.md@@ -0,0 +1,47 @@+# Share Sheet Last Note++**Ticket:** T-1917++## Overview++A reader sharing the next chapter of a work has to remember where the story left off unaided: the capture sheets show the work's title and cast, but none of the notes they wrote. This change adds a read-only "Catch up" section under the note editor on both capture sheets whenever the share resolves to a work that already exists: the work's own notes, then the most recent chapter note. It rides the read that already fetches the characters row (`specs/share-sheet-characters/`), so nothing about capture, commit or the schema changes. A link into the app is not part of this — the app has no URL scheme and `docs/asterism-design.md` §3.1 holds that the extension cannot launch its container (Q1 in `decision_log.md`).++## Requirements++- The system MUST show the section on the re-share sheet (the `.edit` disposition, when the matched entry has a work) and on the new-capture sheet whenever the projection names an existing work (`CaptureOutcome.projectedWorkID != nil`: the `.reuse` and `.claim` assignments), and MUST NOT show it for a work that would be created, is ambiguous or absent, for an `.edit` of an unattached entry, or when the projection failed.+- The section MUST sit below the note editor on both sheets, so the editor keeps its place and the reader dismisses the keyboard or scrolls for the notes. It MUST contain no focusable control — text only.+- The section MUST consist of, in order: a "Catch up" section header; the work's notes as a plain paragraph when the work has any; the last chapter note as a block with a heading line and the full note text. When the work has neither (`workNote == ""` and `lastNote == nil`), the section — header included — MUST NOT render; the characters row is unaffected.+- The last chapter note MUST be the work's logical entry whose note is not blank (whitespace-only counts as blank) and whose placement — `ChapterKeyDerivation.position(chapterSequence:)`, else `ChapterKeyDerivation.key(chapterSequence:chapterTitle:)`, the Chapter view's order — is strictly before the current chapter's placement (Decision 4, superseding Decision 3): the nearest such entry, ties at one placement broken by newest `lastSharedAt` then entry UUID ascending. The current chapter's placement comes from the edited entry's `chapterSequence`/`chapterTitle` on re-share and from `composedChapterSequence`/`projectedChapter` on new capture; the entry being edited MUST be excluded on re-share, and an entry with no placement never qualifies. When the current chapter has no placement, the last note MUST be absent and the work-notes paragraph stands alone. A work with no qualifying entry has no last note; that is the ordinary empty case, not a failure.+- Blank handling MUST happen in the read: the work note and the last note's text are delivered trimmed of surrounding whitespace, a blank work note as `""`; the views render the strings as delivered and test only for emptiness.+- The heading MUST read `{title} · {date}` — `{date}` alone when the title is empty — where the title is the entry's chapter title, else its chapter sequence, else the site-cleaned capture title (Req 8.12's resolution, what Recent and entry detail show; a site-wide id sequence shows as that id, Decision 3 — under Decision 4 the third arm cannot fire for a selected note and was removed, Q15), and the date is `lastSharedAt` in the medium date style of `ReShareBannerFormatter`. A rated entry MUST show `arrowtriangle.up.fill` in `AsterismColors.cyan` / `arrowtriangle.down.fill` in `AsterismColors.violet` (the rating toggles' colours) beside the heading's first line, staying there when the title wraps.+- The block's accessibility label MUST be "Last note on {title}, {date}" ("Last note, {date}" for an empty title) followed by ", rated up" / ", rated down" where rated; its value is the note text. The paragraph's label is "Work notes", its value the text.+- The note text MUST render in full — no line limit, no expand control — `.subheadline` in `AsterismColors.noteText` with `lineSpacing(4)`, as the work page's spine renders a note.+- Duplicate rows MUST present as the work page presents them: the work note is the carrier row's `genericNotes`, a split entry group contributes its carrier's note. A torn entry or torn work contributes its leading variant's content with no torn marker (Q8). An orphaned entry contributes nothing.+- The read MUST take the shared library lock only, MUST never write, MUST NOT prevent saving or cancelling, and MUST NOT run before the new-capture sheet's first render. A failure in the notes half of the read MUST leave the characters as read and the notes empty; a failure before that leaves the context empty, as today. Either MAY log at debug level under the capture logger.+- The characters row and the catch-up section MUST come from one read per projected work: one lock acquisition, one in-flight guard, keyed on the work id as T-1916 keys it. The new-capture sheet MUST NOT repeat the read while the projected work id and the current chapter's anchor (`composedChapterSequence`/`projectedChapter`) are unchanged; an anchor change within the same work re-reads (Decision 4).+- A re-share commit returning `.stale` MUST keep the section already shown; the refreshed basis carries no context.+- The pending-capture drain MUST NOT read the work context.+- The heading text MUST be produced in `AsterismCore` as a testable value; the extension views MUST only lay it out (sharesheet-polish Q6).+- Identifiers: `capture.catchUp` / `reshare.catchUp` on the section, `capture.workNotes` / `reshare.workNotes` on the paragraph, `capture.lastNote` / `reshare.lastNote` on the block.++## Implementation Approach++- **Values** — in `Packages/AsterismCore/Sources/AsterismCore/ShareCharacters.swift` (rename to `ShareWorkContext.swift`): `ShareLastNote` (`title`, `note`, `rating: Rating?`, `lastSharedAt`; `Sendable, Equatable`), `ShareWorkContext` (`characters: [ShareCharacter]`, `workNote: String`, `lastNote: ShareLastNote?`, a static `empty`), and `ShareLastNoteRow.heading(for note: ShareLastNote, locale:calendar:timeZone:) -> String` in `ReShareBannerFormatter`'s shape (`ReSharePresentation.swift:7`), reading `title` and `lastSharedAt`. `ShareCharacter` and `ShareCharacterRow` are unchanged.+- **Read** — static `LibraryRepository.shareWorkContext(forWorkID:excludingEntryID:currentChapterSequence:currentChapterTitle:context:) throws -> ShareWorkContext` (the two current-chapter fields anchor Decision 4's placement), replacing the static `shareCharacters(forWorkID:context:)`. Fetch `Work` rows by id predicate (an unknown id answers `.empty`; not `fetchWorkGroup`, which throws on it) and compute `characters` exactly as today. Then, in its own `do/catch` (Decision 2): `workGroup(id:rows:types: workTypeDirectory(context:))` (`LibraryRepository+Groups.swift:221`) for the carrier's `genericNotes`; `entryGroups(rows.flatMap(\.entryValues), canonicalWorkIDs: [:])` (`:243`) — pure, no snapshots — each group placed by `ChapterKeyDerivation` from its carrier's `chapterSequence`/`chapterTitle`; among groups whose `id != excludingEntryID`, whose `carrier.note` is non-blank and whose placement is strictly before the anchor's, the nearest to the anchor (ties: newest `lastSharedAt`, then `id.uuidString.lowercased()` ascending), no group qualifying — or the anchor unplaceable — meaning no last note (Decision 4); `snapshot(_:)` (`:282`) of that one group only, its title resolved with `chapterTitle ?? chapterSequence ?? displayTitle(for:hostname:sites:context:)` (`LibraryRepository+Export.swift:154`, a throwaway `SiteLookupCache` because the parameter is `inout`). The public `shareWorkContext(workID:excludingEntryID:currentChapterSequence:currentChapterTitle:) async throws` replaces `shareCharacters(workID:)` (one `.shared` read); callers catch and log as today.+- **Re-share arm** — `captureLookup(rawURL:captureTitle:includeCharacters:)` (`LibraryRepository+Capture.swift:183`) becomes `includeWorkContext:`; its opt-in block (`:258-274`, inside the lookup's locked read, ahead of the re-share sheet's first render as T-1916's is) calls the read with `excludingEntryID: basis.entryID`. `CaptureCoordinator.captureLookup` (`ShareTransport.swift:359`) passes `true`; `PendingCaptureDrain` keeps the default (`false`). `ReShareEditBasis.characters` (`:64`) and `with(characters:)` (`:92`) become `workContext: ShareWorkContext` / `with(workContext:)`, with the init default `workContext: ShareWorkContext = .empty` kept — the app test bundle constructs the basis (`Asterism/AsterismTests/ExtensionLookupWiringTests.swift:69`, `Helpers/MockLibraryProvider.swift:585`). `ReShareEditState.characters` (`LookupCaptureViewModel.swift:32`) becomes `workContext`, copied from the basis as today; the `.stale` carry-forward (`:247-264`) keeps it.+- **New-capture arm** — `CaptureCoordinating.shareCharacters(workID:)` (`RecentPresentation.swift:359`, default `:365`, forwarded at `ShareTransport.swift:344`) becomes `shareWorkContext(workID:) async throws -> ShareWorkContext` with an `.empty` default; this arm passes no exclusion (Q7). `CaptureViewModel` (`CaptureViewModel.swift:57-245`), an exhaustive rename list: `characters` → `workContext`, `displayedCharacters` → `displayedWorkContext`, `charactersWorkID` → `contextWorkID`, `needsCharacterLoad` → `needsWorkContextLoad`, `loadCharactersIfNeeded` → `loadWorkContextIfNeeded`; the token, in-flight marker, catch-and-log (`:227-235`) and publish guard are unchanged. `ObservableCaptureViewModel` (`CaptureView.swift:487`, `refreshState()` at `:562-591`) publishes `workContext`.+- **Views** — a `ShareCatchUpSection` view in `Asterism/AsterismShareExtension/` beside `ShareCharactersRow.swift`, taking `workNote`, `lastNote` and an identifier prefix, calling `ShareLastNoteRow.heading(for:)` itself as `editBanner` calls `ReShareBannerFormatter` (`ReShareCaptureView.swift:152`). Rendered after `noteSection` in `CaptureView.readyContent` / `failedContent` (`CaptureView.swift:122-133`, `:150-159`) and `ReShareCaptureView.editContent` (`ReShareCaptureView.swift:115-150`). Header via `ConstellationSectionHeader` with its default accent, as `noteSection` uses it; paragraph and block together in one `.constellationField()` card; heading `.footnote.weight(.semibold)` with the glyph in an `HStack(alignment: .firstTextBaseline)` as `ShareCharactersRow` places its own. The characters row keeps its place in the metadata card and reads `workContext.characters`.+- **Tests** — `Packages/AsterismCore/Tests/AsterismCoreTests/`: `ShareCharacterProjectionTests.swift` renamed to `ShareWorkContextTests.swift`, keeping its three suites (row text, the read, the lookup opt-in) and adding to the read: newest-with-note selection past a blank and a whitespace-only newest entry, exclusion of the edited entry, no entry with a note, unknown id, carrier `genericNotes` on same-UUID duplicate Work rows, carrier note on a split entry group, leading-variant note on a torn entry and a torn work, the three title resolutions, the empty-title heading, tie-break by UUID, trimming, and a notes-half failure keeping the characters; to the lookup: `includeWorkContext: true` excludes the matched entry, `false` answers `.empty`, an unattached match answers `.empty`; plus the heading formatter under a fixed locale, calendar and time zone. `CaptureStateTests.swift:409-506` ("Characters row (T-1916)") and its `FakeCaptureCoordinating` (`:805`) follow the renames and assert one coordinator call per work id; `ReShareExtensionUITests.swift:265-337` and `ReShareUIFixture.editExisting(characters:)` → `editExisting(workContext:)` carry the context, including the `.stale` case. Seed with `seedM5Rows` (`M5RepositoryTestSupport.swift:255`). Pre-commit bar: `make test-core` and `make test-quick` green, no new warnings, `make verify-identity` passing.+- **Out of scope** — any link, URL scheme, deep link or extension→app hand-off; more than one chapter note; a clamp or expand control; editing the shown notes; a torn marker or resolution affordance; the `.create` / `.ambiguous` / saved-for-later / message arms; the commit race guard's basis; any schema, archive or CloudKit change; XCUITest coverage of the extension (`docs/agent-notes/composed-teaching-ui.md`).++## Risks and Assumptions++- Risk: the re-share sheet's spinner waits on this read, which now faults the work's entries and fetches the type directory. | Mitigation: the walk is pure over the groups and snapshots one entry; it is bounded by one work's entries, reached through the already-materialised inverse relationship; the entry walk is new work on that path, the character fetch is not.+- Risk: on the new-capture sheet the section appears one refresh after the sheet, and can appear or disappear as typing moves the projection between works. | Mitigation: it sits below the editor, so nothing the reader is looking at moves; `displayedWorkContext` never shows one work's context under another's title.+- Risk: when the re-share lookup answers `.new` — more than one matching entry group, or one torn group — the new-capture sheet projects `.reuse` for the same work with no exclusion, and the last note may be the chapter being shared, as one variant's text when torn. | Mitigation: accepted (Q7) — the path is already the ambiguous-capture one, and the heading names the chapter except for a title-less capture.+- Risk: a very long note renders in full inside the extension's memory budget. | Mitigation: one `Text`; the work page renders the same notes unclamped.+- Assumption: beyond the two app-bundle test files named above, `ReShareEditBasis`, `ReShareEditState`, `CaptureViewModel` and the coordinator seam have no consumers outside `AsterismCore`, the extension and their tests.+- Prerequisite: none beyond the current `main` (`share-sheet-characters` merged).++## Escalation Note+This change was scoped as a smolspec. If implementation reveals ambiguity only the user can resolve, an irreversible boundary (public API, persisted schema, auth path), or a contested architectural choice, stop and escalate to the full spec workflow rather than deciding it inline.
diff --git a/specs/share-sheet-last-note/tasks.md b/specs/share-sheet-last-note/tasks.mdnew file mode 100644index 0000000..b7939f8--- /dev/null+++ b/specs/share-sheet-last-note/tasks.md@@ -0,0 +1,44 @@+---+references:+ - specs/share-sheet-last-note/smolspec.md+ - specs/share-sheet-last-note/decision_log.md+---+# Share Sheet Last Note++## Core read++- [x] 1. Share-context values and the last-note heading exist in AsterismCore <!-- id:xyjyy8t -->+ - `ShareCharacters.swift` becomes `ShareWorkContext.swift` holding `ShareLastNote`, `ShareWorkContext` (with `.empty`) and `ShareLastNoteRow.heading(for:locale:calendar:timeZone:)`; `ShareCharacter` and `ShareCharacterRow` unchanged.+ - Verified by heading tests under a fixed locale, calendar and time zone: `{title} · {date}`, date alone for an empty title, rating ignored.+ - References: specs/share-sheet-last-note/smolspec.md++- [x] 2. The work-context read returns the characters, the carrier work note and the selected last note <!-- id:xyjyy8u -->+ - `shareWorkContext(forWorkID:excludingEntryID:context:)` replaces the static characters read: characters as today, then — in its own catch (Decision 2) — the type directory, `workGroup` for the carrier `genericNotes`, the pure `entryGroups` walk (selection since re-anchored by Decision 4: nearest placement strictly before the shared chapter), one `snapshot` of the selected group, Req 8.12 title resolution, trimming. Public `shareWorkContext(workID:excludingEntryID:currentChapterSequence:currentChapterTitle:)` replaces `shareCharacters(workID:)` (anchor params added by Decision 4).+ - Verified in `ShareWorkContextTests` (renamed from `ShareCharacterProjectionTests`, existing suites kept): selection past a blank and a whitespace-only newest entry, exclusion, no noted entry, unknown id, carrier note on same-UUID duplicate Work rows, carrier note on a split entry group, leading-variant note on a torn entry and a torn work, the three title resolutions, tie-break by UUID, a notes-half failure keeping the characters.+ - Blocked-by: xyjyy8t (Share-context values and the last-note heading exist in AsterismCore)+ - References: specs/share-sheet-last-note/smolspec.md++- [x] 3. The re-share lookup carries the work context without the edited entry; the drain never reads it <!-- id:xyjyy8v -->+ - `captureLookup(includeCharacters:)` becomes `includeWorkContext:`, its opt-in block passes `excludingEntryID: basis.entryID`; `ReShareEditBasis.workContext` (init default `.empty` kept for the app test bundle) and `with(workContext:)`; `ReShareEditState.workContext` copied from the basis and kept across `.stale`; `CaptureCoordinator.captureLookup` passes `true`, `PendingCaptureDrain` keeps the default.+ - Verified by the lookup suite in `ShareWorkContextTests` (`true` excludes the matched entry, `false` answers `.empty`, an unattached match answers `.empty`) and `ReShareExtensionUITests` with `ReShareUIFixture.editExisting(workContext:)` including the `.stale` carry-forward.+ - Blocked-by: xyjyy8u (The work-context read returns the characters, the carrier work note and the selected last note)+ - References: specs/share-sheet-last-note/smolspec.md++- [x] 4. The new-capture arm loads the work context once per projected work id <!-- id:xyjyy8w -->+ - `CaptureCoordinating.shareWorkContext(workID:currentChapterSequence:currentChapterTitle:)` with an `.empty` default (anchor added by Decision 4; the guard keys on work id plus placement, Q17) replaces `shareCharacters(workID:)` (forwarded by `CaptureCoordinator`); `CaptureViewModel` renames per the exhaustive list in the smolspec (`workContext`, `displayedWorkContext`, `contextWorkID`, `needsWorkContextLoad`, `loadWorkContextIfNeeded`) with token, in-flight marker, catch-and-log and publish guard unchanged.+ - Verified in `CaptureStateTests` (the T-1916 suite follows the renames): one coordinator call per work id, fetch on `.reuse`/`.claim`, none on `.create`/`.ambiguous`/nil, no refetch on an unchanged id, a superseded id never published, a throwing coordinator yielding `.empty`.+ - Blocked-by: xyjyy8u (The work-context read returns the characters, the carrier work note and the selected last note)+ - References: specs/share-sheet-last-note/smolspec.md++## Extension++- [x] 5. Both capture sheets show the Catch up section under the note editor <!-- id:xyjyy8x -->+ - `ShareCatchUpSection` beside `ShareCharactersRow.swift`, rendered after `noteSection` in `CaptureView.readyContent`/`failedContent` and `ReShareCaptureView.editContent`: header, work-notes paragraph, last-note block with heading, glyph and full note text per the smolspec; nothing rendered when the context has neither; identifiers and accessibility labels as specified. `ObservableCaptureViewModel` publishes `workContext`; the characters row reads `workContext.characters`.+ - Verified by the extension target building, `make test-quick` green, and `#Preview`s of the section with a long note at the default and largest accessibility text sizes (the extension cannot be driven in the simulator without a host-app automation), plus the reader's own on-device run — which surfaced the selection defect Decision 4 fixed.+ - Blocked-by: xyjyy8v (The re-share lookup carries the work context without the edited entry; the drain never reads it), xyjyy8w (The new-capture arm loads the work context once per projected work id)+ - References: specs/share-sheet-last-note/smolspec.md++- [x] 6. The pre-commit bar is green with no new warnings <!-- id:xyjyy8y -->+ - `make test-core`, `make test-quick` and `make verify-identity` pass; the compiler warning count is unchanged against a forced recompile; the app test bundle (`ExtensionLookupWiringTests`, `MockLibraryProvider`) compiles unmodified on the kept basis default.+ - Blocked-by: xyjyy8x (Both capture sheets show the Catch up section under the note editor)+ - References: specs/share-sheet-last-note/smolspec.md
A noted entry with no number in URL or title can never be the last note (Decision 4's accepted negative). If that bites real libraries, revisit with a placement fallback rather than a selection rewrite.
Placement below the editor, glyph colours and identifiers are pinned by #if DEBUG previews and the author's on-device runs only — the extension has no XCUITest harness by design.
A future conformer that misses the widened signature silently shows an empty section instead of failing to compile. Spec-mandated for the test bundle, but worth remembering when adding conformers.