From 0baf89282e97520941bbad6171e3b23236e30a00 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:27:16 -0700 Subject: [PATCH 01/10] feat(lists): add select and markdown schema field types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the schema DSL with select(a|b|c) single-choice columns (options on SchemaField.enumValues) and markdown long-text columns. Editor gains an inline options editor; row cells render a Picker (select) and a TextEditor+Textual preview (markdown). 25 new BDD tests. Reaches parity with the site's advertised type set (feature-gaps.md §1.1). SwiftUI-only; no wire/codec change. Co-Authored-By: Claude Opus 4.8 (1M context) --- App/Features/Lists/ListRowsViewModel.swift | 6 +- App/Features/Lists/RowInspectorView.swift | 91 +++++++++++ App/Features/Lists/SchemaEditorView.swift | 51 +++++++ .../Lists/SchemaEditorViewModel.swift | 85 ++++++++++- AppTests/ListRowsViewModelTests.swift | 16 ++ AppTests/SchemaEditorViewModelTests.swift | 142 +++++++++++++++++- AppTests/Support/StubListsService.swift | 7 + .../Models/SchemaFieldType.swift | 31 ++++ .../InterlinedDomain/Schema/SchemaDSL.swift | 138 ++++++++++++++++- .../SchemaDSLTests.swift | 126 +++++++++++++++- 10 files changed, 674 insertions(+), 19 deletions(-) diff --git a/App/Features/Lists/ListRowsViewModel.swift b/App/Features/Lists/ListRowsViewModel.swift index e325514..ade82b1 100644 --- a/App/Features/Lists/ListRowsViewModel.swift +++ b/App/Features/Lists/ListRowsViewModel.swift @@ -213,7 +213,11 @@ final class ListRowsViewModel { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return .null } switch type { - case .text, .url, .email, .date: + case .text, .url, .email, .date, .select, .markdown: + // `select` stores the chosen option's raw text; `markdown` + // stores raw Markdown source. Both are string-valued cells — + // the option-set constraint is enforced by the picker UI, not + // here (this helper also drives free-text entry). return .string(trimmed) case .number: if let intValue = Int(trimmed) { return .int(intValue) } diff --git a/App/Features/Lists/RowInspectorView.swift b/App/Features/Lists/RowInspectorView.swift index 13fc050..7e7a76d 100644 --- a/App/Features/Lists/RowInspectorView.swift +++ b/App/Features/Lists/RowInspectorView.swift @@ -12,6 +12,7 @@ import SwiftUI import InterlinedDomain +import Textual struct RowInspectorView: View { @@ -41,6 +42,7 @@ struct RowInspectorView: View { cellEditor( key: key, type: .text, + options: [], current: row.fields[key] ?? .null, row: row, viewModel: viewModel @@ -51,6 +53,7 @@ struct RowInspectorView: View { cellEditor( key: field.name, type: field.type, + options: field.enumValues ?? [], current: row.fields[field.name] ?? .null, row: row, viewModel: viewModel @@ -66,6 +69,7 @@ struct RowInspectorView: View { private func cellEditor( key: String, type: SchemaFieldType, + options: [String], current: ListCellValue, row: ListRow, viewModel: ListRowsViewModel @@ -99,6 +103,91 @@ struct RowInspectorView: View { } )) .accessibilityLabel(key) + case .select: + selectEditor( + key: key, + options: options, + current: current, + row: row, + viewModel: viewModel + ) + case .markdown: + markdownEditor( + key: key, + current: current, + row: row, + viewModel: viewModel + ) + } + } + } + + /// A `Picker` constrained to the column's option set. The stored value is + /// the chosen option's raw text; committing routes through the same + /// `updateRow` path as every other cell. A leading empty tag models "no + /// selection" so a nullable select can be cleared. + @ViewBuilder + private func selectEditor( + key: String, + options: [String], + current: ListCellValue, + row: ListRow, + viewModel: ListRowsViewModel + ) -> some View { + Picker("", selection: Binding( + get: { editingValues[key] ?? current.displayText }, + set: { newValue in + editingValues[key] = newValue + commitChange(row: row, key: key, type: .select, viewModel: viewModel) + } + )) { + Text("—").tag("") + ForEach(options, id: \.self) { option in + Text(option).tag(option) + } + } + .labelsHidden() + .pickerStyle(.menu) + .accessibilityLabel(key) + } + + /// An editable multiline field previewed with the same `Textual` renderer + /// Documents uses (`StructuredText(markdown:)`). Edits are buffered in + /// `editingValues` and committed on the explicit "Save" control — a + /// `TextEditor` has no `onSubmit`, and auto-committing every keystroke + /// would fire a write per character. + @ViewBuilder + private func markdownEditor( + key: String, + current: ListCellValue, + row: ListRow, + viewModel: ListRowsViewModel + ) -> some View { + let source = editingValues[key] ?? current.displayText + VStack(alignment: .leading, spacing: 6) { + TextEditor(text: Binding( + get: { editingValues[key] ?? current.displayText }, + set: { editingValues[key] = $0 } + )) + .font(.ilMono(12)) + .frame(minHeight: 100) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.secondary.opacity(0.3)) + ) + .accessibilityLabel(key) + if !source.isEmpty { + StructuredText(markdown: source) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityLabel("\(key) preview") + } + HStack { + Spacer() + Button("Save") { + commitChange(row: row, key: key, type: .markdown, viewModel: viewModel) + } + .controlSize(.small) + .disabled(editingValues[key] == nil) } } } @@ -138,6 +227,8 @@ struct RowInspectorView: View { case .date: return "ISO-8601 date" case .url: return "URL" case .email: return "Email" + case .select: return "Select" + case .markdown: return "Markdown" } } diff --git a/App/Features/Lists/SchemaEditorView.swift b/App/Features/Lists/SchemaEditorView.swift index cdc7bc7..d50e1c8 100644 --- a/App/Features/Lists/SchemaEditorView.swift +++ b/App/Features/Lists/SchemaEditorView.swift @@ -159,6 +159,9 @@ struct SchemaEditorView: View { .accessibilityLabel("Remove field") } } + if field.type == .select { + selectOptionsEditor(viewModel: viewModel, field: field) + } if let error = viewModel.validationError(for: field) { Text(error) .font(.ilMono(10)) @@ -167,6 +170,52 @@ struct SchemaEditorView: View { } } + /// Inline, per-option editor shown only for `select` columns. Each option + /// is an editable text field with a remove button; a trailing "Add option" + /// button appends a blank option. All mutations route through the view + /// model so the option array stays owned there. + @ViewBuilder + private func selectOptionsEditor( + viewModel: SchemaEditorViewModel, + field: SchemaEditorViewModel.EditableField + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text("Options") + .font(.ilMono(9)) + .foregroundStyle(.secondary) + ForEach(Array(field.options.enumerated()), id: \.offset) { index, option in + HStack(spacing: 6) { + TextField("Option", text: Binding( + get: { option }, + set: { viewModel.setOption($0, forFieldID: field.id, at: index) } + )) + .textFieldStyle(.roundedBorder) + .disabled(!viewModel.isEditable) + if viewModel.isEditable { + Button { + viewModel.removeOption(fromFieldID: field.id, at: index) + } label: { + Image(systemName: "minus.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove option") + } + } + } + if viewModel.isEditable { + Button { + viewModel.addOption(toFieldID: field.id) + } label: { + Label("Add option", systemImage: "plus") + .font(.ilMono(10)) + } + .buttonStyle(.plain) + } + } + .padding(.leading, 8) + } + @ViewBuilder private func footer(viewModel: SchemaEditorViewModel) -> some View { HStack { @@ -240,6 +289,8 @@ struct SchemaEditorView: View { case .date: return "Date" case .url: return "URL" case .email: return "Email" + case .select: return "Select" + case .markdown: return "Markdown" } } } diff --git a/App/Features/Lists/SchemaEditorViewModel.swift b/App/Features/Lists/SchemaEditorViewModel.swift index bc4980b..b3d1c63 100644 --- a/App/Features/Lists/SchemaEditorViewModel.swift +++ b/App/Features/Lists/SchemaEditorViewModel.swift @@ -37,12 +37,23 @@ final class SchemaEditorViewModel { var name: String var type: SchemaFieldType var nullable: Bool - - init(id: UUID = UUID(), name: String, type: SchemaFieldType, nullable: Bool = false) { + /// Ordered option set for `select` columns. Ignored for every other + /// type. Kept as `[String]` (not `Set`) so declaration order — which + /// the picker preserves — is authoritative. + var options: [String] + + init( + id: UUID = UUID(), + name: String, + type: SchemaFieldType, + nullable: Bool = false, + options: [String] = [] + ) { self.id = id self.name = name self.type = type self.nullable = nullable + self.options = options } } @@ -77,7 +88,12 @@ final class SchemaEditorViewModel { self.listId = listId self.role = role self.fields = initialSchema.fields.map { - EditableField(name: $0.name, type: $0.type, nullable: $0.nullable ?? false) + EditableField( + name: $0.name, + type: $0.type, + nullable: $0.nullable ?? false, + options: $0.enumValues ?? [] + ) } } @@ -102,10 +118,38 @@ final class SchemaEditorViewModel { /// Sets the type of a field by row id. Convenience used by the /// view's per-row Picker so the binding is one-way through the - /// view model. + /// view model. Switching away from `select` discards its options so a + /// later switch back starts clean and a non-select never carries a + /// stale option set into `save()`. func setType(_ type: SchemaFieldType, forFieldID id: UUID) { guard let index = fields.firstIndex(where: { $0.id == id }) else { return } fields[index].type = type + if !type.carriesOptions { + fields[index].options = [] + } + } + + /// Appends a new empty option to a `select` field. The view focuses the + /// new option's text field for editing. + func addOption(toFieldID id: UUID) { + guard let index = fields.firstIndex(where: { $0.id == id }), + fields[index].type.carriesOptions else { return } + fields[index].options.append("") + } + + /// Removes the option at `offset` from a `select` field. + func removeOption(fromFieldID id: UUID, at offset: Int) { + guard let index = fields.firstIndex(where: { $0.id == id }), + fields[index].options.indices.contains(offset) else { return } + fields[index].options.remove(at: offset) + } + + /// Sets the text of a single option by index. Bound one-way from the + /// per-option text field so the array stays owned by the view model. + func setOption(_ value: String, forFieldID id: UUID, at offset: Int) { + guard let index = fields.firstIndex(where: { $0.id == id }), + fields[index].options.indices.contains(offset) else { return } + fields[index].options[offset] = value } /// Validates a single field. Returns `nil` when valid, otherwise @@ -124,6 +168,26 @@ final class SchemaEditorViewModel { if duplicates.count > 1 { return "Duplicate field name." } + if field.type.carriesOptions { + return selectOptionError(for: field) + } + return nil + } + + /// Validates a `select` field's option set, mirroring the DSL parser's + /// `.emptySelectOptions` / `.duplicateSelectOption` rules so the editor + /// rejects the same inputs the serializer would round-trip into an + /// invalid schema. Returns `nil` when the options are valid. + private func selectOptionError(for field: EditableField) -> String? { + let trimmedOptions = field.options.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + if trimmedOptions.isEmpty || trimmedOptions.contains(where: \.isEmpty) { + return "Select needs at least one non-empty option." + } + if Set(trimmedOptions).count != trimmedOptions.count { + return "Select options must be unique." + } return nil } @@ -145,11 +209,16 @@ final class SchemaEditorViewModel { error = nil defer { isSaving = false } - let schema = ListSchema(fields: fields.map { + let schema = ListSchema(fields: fields.map { field in SchemaField( - name: $0.name.trimmingCharacters(in: .whitespacesAndNewlines), - type: $0.type, - nullable: $0.nullable + name: field.name.trimmingCharacters(in: .whitespacesAndNewlines), + type: field.type, + nullable: field.nullable, + // Only `select` carries options into the DSL; every other + // type serializes as a bare token. + enumValues: field.type.carriesOptions + ? field.options.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + : nil ) }) do { diff --git a/AppTests/ListRowsViewModelTests.swift b/AppTests/ListRowsViewModelTests.swift index 60aab76..f5f230e 100644 --- a/AppTests/ListRowsViewModelTests.swift +++ b/AppTests/ListRowsViewModelTests.swift @@ -152,6 +152,22 @@ final class ListRowsViewModelTests: XCTestCase { XCTAssertEqual(ListRowsViewModel.parse("0", as: .boolean), .bool(false)) } + func test_givenSelectOption_whenParsingAsSelect_thenReturnsString() { + // §1.1 — a select cell stores the chosen option's raw text. + XCTAssertEqual(ListRowsViewModel.parse("high", as: .select), .string("high")) + } + + func test_givenMarkdownSource_whenParsingAsMarkdown_thenReturnsString() { + // §1.1 — a markdown cell stores raw Markdown source verbatim. + XCTAssertEqual(ListRowsViewModel.parse("# Title", as: .markdown), .string("# Title")) + } + + func test_givenWhitespace_whenParsingAsSelectOrMarkdown_thenReturnsNull() { + // Boundary — a cleared select/markdown cell projects to null. + XCTAssertEqual(ListRowsViewModel.parse(" ", as: .select), .null) + XCTAssertEqual(ListRowsViewModel.parse("", as: .markdown), .null) + } + // MARK: - apply(event:) func test_givenRowEventForOtherList_whenApplied_thenIsNoop() async { diff --git a/AppTests/SchemaEditorViewModelTests.swift b/AppTests/SchemaEditorViewModelTests.swift index 56d3436..81d4f7e 100644 --- a/AppTests/SchemaEditorViewModelTests.swift +++ b/AppTests/SchemaEditorViewModelTests.swift @@ -172,11 +172,149 @@ final class SchemaEditorViewModelTests: XCTestCase { XCTAssertFalse(viewModel.didFinish) } + // MARK: - select options (§1.1) + + func test_givenSelectFieldFromSchema_whenInitialising_thenSeedsOptions() { + // Happy path — a `select` column's options hydrate the editable row. + let viewModel = makeViewModel( + initial: ListSchema(fields: [ + SchemaField(name: "Priority", type: .select, enumValues: ["low", "high"]) + ]) + ) + + XCTAssertEqual(viewModel.fields.first?.type, .select) + XCTAssertEqual(viewModel.fields.first?.options, ["low", "high"]) + } + + func test_givenSelectField_whenAddingOption_thenAppendsBlankOption() { + let viewModel = makeViewModel( + initial: ListSchema(fields: [SchemaField(name: "P", type: .select, enumValues: ["a"])]) + ) + let id = viewModel.fields[0].id + + viewModel.addOption(toFieldID: id) + + XCTAssertEqual(viewModel.fields[0].options, ["a", ""]) + } + + func test_givenSelectField_whenSettingAndRemovingOptions_thenMutatesInPlace() { + let viewModel = makeViewModel( + initial: ListSchema(fields: [SchemaField(name: "P", type: .select, enumValues: ["a", "b", "c"])]) + ) + let id = viewModel.fields[0].id + + viewModel.setOption("z", forFieldID: id, at: 1) + viewModel.removeOption(fromFieldID: id, at: 0) + + XCTAssertEqual(viewModel.fields[0].options, ["z", "c"]) + } + + func test_givenSelectField_whenSwitchingTypeAway_thenDiscardsOptions() { + // Boundary — options must not survive a switch to a non-select type, + // or `save()` would carry a stale set into the DSL. + let viewModel = makeViewModel( + initial: ListSchema(fields: [SchemaField(name: "P", type: .select, enumValues: ["a", "b"])]) + ) + let id = viewModel.fields[0].id + + viewModel.setType(.text, forFieldID: id) + + XCTAssertTrue(viewModel.fields[0].options.isEmpty) + } + + func test_givenSelectWithNoOptions_whenValidating_thenReturnsError() { + // Invalid input — a select with an empty option set fails validation, + // mirroring the DSL parser's `.emptySelectOptions`. + let viewModel = makeViewModel(initial: .empty) + viewModel.addField() + let id = viewModel.fields[0].id + viewModel.fields[0].name = "P" + viewModel.setType(.select, forFieldID: id) + + XCTAssertNotNil(viewModel.validationError(for: viewModel.fields[0])) + XCTAssertFalse(viewModel.isValid) + } + + func test_givenSelectWithDuplicateOptions_whenValidating_thenReturnsError() { + // Invalid input — duplicate options fail validation, mirroring the + // parser's `.duplicateSelectOption`. + let viewModel = makeViewModel( + initial: ListSchema(fields: [ + SchemaField(name: "P", type: .select, enumValues: ["a", "a"]) + ]) + ) + + XCTAssertNotNil(viewModel.validationError(for: viewModel.fields[0])) + XCTAssertFalse(viewModel.isValid) + } + + func test_givenValidSelectSchema_whenSaving_thenRoundTripsOptionsToService() async { + // Happy path — a valid `select` column saves with its options intact. + let stub = StubListsService() + let saved = ListSchema(fields: [ + SchemaField(name: "Priority", type: .select, enumValues: ["low", "high"]) + ]) + await stub.enqueueUpdateSchema(success: saved) + let viewModel = SchemaEditorViewModel( + lists: stub, + eventBus: ListsEventBus(), + listId: "L1", + role: .owner, + initialSchema: saved + ) + + await viewModel.save() + + XCTAssertTrue(viewModel.didFinish) + let sent = await stub.lastUpdatedSchema + XCTAssertEqual(sent?.fields.first?.type, .select) + XCTAssertEqual(sent?.fields.first?.enumValues, ["low", "high"]) + } + + func test_givenInvalidSelectSchema_whenSaving_thenDoesNotCallService() async { + // Invalid input — a select with no options must not reach the service. + let stub = StubListsService() + let viewModel = makeViewModel( + initial: ListSchema(fields: [SchemaField(name: "P", type: .select, enumValues: [])]), + lists: stub + ) + + await viewModel.save() + + let recorded = await stub.recorded + XCTAssertTrue(recorded.isEmpty) + XCTAssertFalse(viewModel.didFinish) + } + + func test_givenMarkdownField_whenSaving_thenSerialisesWithNoOptions() async { + // Markdown is long-text: saves as a bare token, no options attached. + let stub = StubListsService() + let saved = ListSchema(fields: [SchemaField(name: "Body", type: .markdown)]) + await stub.enqueueUpdateSchema(success: saved) + let viewModel = SchemaEditorViewModel( + lists: stub, + eventBus: ListsEventBus(), + listId: "L1", + role: .owner, + initialSchema: saved + ) + + await viewModel.save() + + let sent = await stub.lastUpdatedSchema + XCTAssertEqual(sent?.fields.first?.type, .markdown) + XCTAssertNil(sent?.fields.first?.enumValues) + } + // MARK: - helpers - private func makeViewModel(initial: ListSchema, role: WatcherRole = .owner) -> SchemaEditorViewModel { + private func makeViewModel( + initial: ListSchema, + role: WatcherRole = .owner, + lists: StubListsService = StubListsService() + ) -> SchemaEditorViewModel { SchemaEditorViewModel( - lists: StubListsService(), + lists: lists, eventBus: ListsEventBus(), listId: "L1", role: role, diff --git a/AppTests/Support/StubListsService.swift b/AppTests/Support/StubListsService.swift index 68ebe45..ba931e9 100644 --- a/AppTests/Support/StubListsService.swift +++ b/AppTests/Support/StubListsService.swift @@ -68,6 +68,12 @@ actor StubListsService: ListsServicing { private(set) var recorded: [RecordedListsCall] = [] + /// The full `ListSchema` passed to the most recent `updateSchema` call. + /// The recorded-call log only captures `fieldsCount`; tests that need to + /// assert per-field detail (e.g. a `select` column's `enumValues` round- + /// tripping through save) read this instead. + private(set) var lastUpdatedSchema: ListSchema? + // MARK: Programmable enqueue helpers func enqueueMyLists(success page: OwnedListsPage) { myListsOutcomes.append(.success(page)) } func enqueueMyLists(failure error: Error) { myListsOutcomes.append(.failure(error)) } @@ -176,6 +182,7 @@ actor StubListsService: ListsServicing { func updateSchema(of listId: String, schema: ListSchema) async throws -> ListSchema { recorded.append(.init(kind: .updateSchema(listId: listId, fieldsCount: schema.fields.count))) + lastUpdatedSchema = schema return try take(&updateSchemaOutcomes, label: "updateSchema") } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/SchemaFieldType.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/SchemaFieldType.swift index d91be5f..f7ff25f 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/SchemaFieldType.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/SchemaFieldType.swift @@ -38,11 +38,42 @@ public enum SchemaFieldType: String, Sendable, Equatable, Hashable, CaseIterable /// status not yet documented (prompts file 2.2). case email + /// A single-choice value drawn from an ordered option set carried by the + /// owning `SchemaField.enumValues`. The DSL declares the options inline: + /// `Priority:select(low|med|high)`. Wire shape (row data): JSON string — + /// the chosen option's raw text. The option list itself lives in the + /// schema DSL string, not in row data. + /// + /// The `select(...)` option-encoding grammar is a **client convention** + /// introduced here: the API's `markdown` token is documented at + /// interlinedlist.com/help/api/lists, but the `select` option syntax is + /// not enumerated there (the docs note "a few others" exist without + /// pinning the grammar). If the server names the token or delimiter + /// differently, this is the single place that changes — see the + /// serializer/parser in `SchemaDSL`. + case select + + /// Long-form Markdown text. Parses like `text` (no options); rendered as + /// an editable multiline field with a Markdown preview in the UI, reusing + /// the same `Textual` renderer as Documents. Wire shape: JSON string + /// carrying raw Markdown source. Documented token at + /// interlinedlist.com/help/api/lists. + case markdown + /// The canonical DSL token for this type ("text", "number", …). The /// raw value is the wire token by construction; this alias exists so /// the parser/serializer can read as intent rather than implementation. + /// + /// Note this is the *bare* type token only. A `select` column emits its + /// options separately (`select(a|b|c)`); the option list is not part of + /// `dslToken` — see `SchemaDSL.serialize`. public var dslToken: String { rawValue } + /// Whether this type carries an inline option set in the DSL + /// (`type(a|b|c)`). Only `select` does today; centralised here so the + /// parser, serializer, and editor all agree on which types need options. + public var carriesOptions: Bool { self == .select } + /// Maps a DSL token to a type, case-insensitively. Returns `nil` for any /// token outside the closed set — the parser surfaces that as /// `SchemaDSLError.unknownType`. diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Schema/SchemaDSL.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Schema/SchemaDSL.swift index 947b50a..ddf01c4 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Schema/SchemaDSL.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Schema/SchemaDSL.swift @@ -28,6 +28,16 @@ public enum SchemaDSLError: Error, Sendable, Equatable { /// Two columns share the same name — schemas forbid duplicates so the /// cell map (`[String: ListCellValue]`) stays unambiguous. case duplicateFieldName(String) + + /// A `select` column declared an empty option list — `select()` or + /// `select` with no options. A single-choice column needs at least one + /// option to be meaningful. The field name is included for the toast. + case emptySelectOptions(field: String) + + /// A `select` column repeated the same option — options must be a set so + /// the picker never renders two identical rows. The offending option is + /// included so the editor can highlight it. + case duplicateSelectOption(field: String, option: String) } extension SchemaDSLError: LocalizedError, CustomStringConvertible { @@ -43,6 +53,10 @@ extension SchemaDSLError: LocalizedError, CustomStringConvertible { return "Schema type \"\(raw)\" is not a recognised type." case .duplicateFieldName(let name): return "Schema field \"\(name)\" is declared more than once." + case .emptySelectOptions(let field): + return "Schema field \"\(field)\" is a select but declares no options." + case .duplicateSelectOption(let field, let option): + return "Schema field \"\(field)\" repeats the select option \"\(option)\"." } } } @@ -52,15 +66,30 @@ extension SchemaDSLError: LocalizedError, CustomStringConvertible { /// Parser and serializer for the InterlinedList schema DSL /// (`"Title:text, Year:number, Released:date"`). /// -/// ## DSL grammar (M3 starter set — see prompts file item 2.2) +/// ## DSL grammar (M3 starter set + §1.1 select/markdown) /// /// ``` /// schema := field ( "," field )* -/// field := name ":" type +/// field := name ":" type [ "(" options ")" ] /// name := one-or-more characters, no comma, no colon, trimmed /// type := one of the SchemaFieldType DSL tokens +/// options := option ( "|" option )* -- select only +/// option := one-or-more characters, no pipe, no parens, trimmed /// ``` /// +/// The optional `"(" options ")"` suffix applies to `select` only +/// (`Priority:select(low|med|high)`). The option list is `|`-delimited so it +/// carries no top-level commas — the plain comma split still separates +/// fields. Empty option lists (`select()`) and duplicate options are typed +/// errors (`.emptySelectOptions` / `.duplicateSelectOption`). Non-`select` +/// types reject a trailing `(...)` as a syntax error. `markdown` parses like +/// `text` (no options). +/// +/// The `select(...)` option grammar is a **client convention** — the API +/// documents the `markdown` token but not `select`'s option syntax (see +/// `SchemaFieldType.select`). It round-trips only through this file, so it is +/// the one place to adjust if the server pins a different token/delimiter. +/// /// Whitespace around commas and colons is tolerated (and stripped) on parse, /// and re-emitted in canonical form by `serialize` so that `parse → serialize` /// is **normalising** rather than verbatim. The round-trip guarantee is @@ -109,25 +138,111 @@ public enum SchemaDSL { throw SchemaDSLError.invalidFieldSyntax(rawField: token) } let name = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) - let typeToken = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty, !typeToken.isEmpty else { + let typeSpec = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, !typeSpec.isEmpty else { throw SchemaDSLError.invalidFieldSyntax(rawField: token) } + + // Split the type spec into its bare token and an optional + // `(...)` option suffix. `select(low|med|high)` → ("select", + // "low|med|high"); a bare `text` → ("text", nil). + let (typeToken, optionsBody) = try Self.splitTypeSpec(typeSpec, rawField: token) + guard let type = SchemaFieldType(dslToken: typeToken) else { throw SchemaDSLError.unknownType(rawType: typeToken) } + + // Option suffixes are only valid for option-carrying types + // (`select`). A trailing `(...)` on any other type is a syntax + // error so `text(a|b)` does not silently drop the options. + let enumValues = try Self.resolveOptions( + optionsBody, + for: type, + fieldName: name, + rawField: token + ) + // Duplicate-name check is case-sensitive — column names are // case-sensitive at the row-data layer too. guard !seenNames.contains(name) else { throw SchemaDSLError.duplicateFieldName(name) } seenNames.insert(name) - fields.append(SchemaField(name: name, type: type)) + fields.append(SchemaField(name: name, type: type, enumValues: enumValues)) } return ListSchema(fields: fields) } + // MARK: Type-spec + options parsing + + /// Splits a type spec into its bare token and optional `(...)` body. + /// + /// `"select(low|med|high)"` → `("select", "low|med|high")`; + /// `"text"` → `("text", nil)`. A `(` with no matching trailing `)`, + /// or trailing characters after the `)`, is a syntax error. + private static func splitTypeSpec( + _ spec: String, + rawField: String + ) throws -> (token: String, optionsBody: String?) { + guard let openIndex = spec.firstIndex(of: "(") else { + // No paren group: the whole spec is the type token. A stray + // closing paren with no opener is malformed. + guard !spec.contains(")") else { + throw SchemaDSLError.invalidFieldSyntax(rawField: rawField) + } + return (spec, nil) + } + // Must close with `)` as the final character, with nothing after it. + guard spec.hasSuffix(")") else { + throw SchemaDSLError.invalidFieldSyntax(rawField: rawField) + } + let token = String(spec[spec.startIndex.. [String]? { + guard type.carriesOptions else { + // A `(...)` suffix on a non-option type is a syntax error. + if body != nil { + throw SchemaDSLError.invalidFieldSyntax(rawField: rawField) + } + return nil + } + // `select` requires a `(...)` body with at least one option. + guard let body else { + throw SchemaDSLError.emptySelectOptions(field: fieldName) + } + let options = body + .split(separator: "|", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + // Reject empty option lists and any blank option (e.g. `a||b`). + guard !options.isEmpty, !options.contains(where: \.isEmpty) else { + throw SchemaDSLError.emptySelectOptions(field: fieldName) + } + var seen: Set = [] + for option in options where !seen.insert(option).inserted { + throw SchemaDSLError.duplicateSelectOption(field: fieldName, option: option) + } + return options + } + // MARK: Serialize /// Serializes a `ListSchema` back to the canonical DSL form @@ -143,7 +258,18 @@ public enum SchemaDSL { /// wire never carries it. public static func serialize(_ schema: ListSchema) -> String { schema.fields - .map { "\($0.name):\($0.type.dslToken)" } + .map { field in + let base = "\(field.name):\(field.type.dslToken)" + // `select` re-emits its ordered option set inline. A select + // with no options is a malformed in-memory value the editor + // should never produce; guard defensively by omitting the + // `()` so a reparse fails loudly rather than silently. + guard field.type.carriesOptions, + let options = field.enumValues, !options.isEmpty else { + return base + } + return "\(base)(\(options.joined(separator: "|")))" + } .joined(separator: ", ") } } diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SchemaDSLTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SchemaDSLTests.swift index b95ca0f..b7baba2 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SchemaDSLTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/SchemaDSLTests.swift @@ -34,8 +34,9 @@ final class SchemaDSLTests: XCTestCase { // MARK: - Every type func test_givenAllSupportedTypes_whenParsing_thenEachTypeIsRecognised() throws { - // Given — one column per supported `SchemaFieldType`. - let source = "A:text, B:number, C:boolean, D:date, E:url, F:email" + // Given — one column per supported `SchemaFieldType`. `select` needs + // an inline option set; the rest are bare tokens. + let source = "A:text, B:number, C:boolean, D:date, E:url, F:email, G:select(x|y), H:markdown" // When let schema = try SchemaDSL.parse(source) @@ -246,4 +247,125 @@ final class SchemaDSLTests: XCTestCase { XCTAssertEqual(schema.field(named: "Year")?.type, .number) XCTAssertNil(schema.field(named: "Missing")) } + + // MARK: - select options (§1.1) + + func test_givenSelectWithOptions_whenParsing_thenCapturesOrderedOptions() throws { + // Happy path — the ordered option set is captured on `enumValues`. + let schema = try SchemaDSL.parse("Priority:select(low|med|high)") + + let field = try XCTUnwrap(schema.fields.first) + XCTAssertEqual(field.type, .select) + XCTAssertEqual(field.enumValues, ["low", "med", "high"]) + } + + func test_givenSelectAlongsideOtherFields_whenParsing_thenSplitsFieldsCorrectly() throws { + // The pipe-delimited option list must not be mistaken for a field + // boundary — commas still separate fields, pipes separate options. + let schema = try SchemaDSL.parse("Title:text, Priority:select(low|med|high), Done:boolean") + + XCTAssertEqual(schema.fields.map(\.name), ["Title", "Priority", "Done"]) + XCTAssertEqual(schema.fields.map(\.type), [.text, .select, .boolean]) + XCTAssertEqual(schema.field(named: "Priority")?.enumValues, ["low", "med", "high"]) + } + + func test_givenSelectWithWhitespaceInOptions_whenParsing_thenTrimsEachOption() throws { + // Whitespace tolerance extends into the option list. + let schema = try SchemaDSL.parse("P:select( low | med | high )") + + XCTAssertEqual(schema.field(named: "P")?.enumValues, ["low", "med", "high"]) + } + + func test_givenSelectSchema_whenSerializedAndReparsed_thenRoundTripsOptions() throws { + // Round-trip: options survive serialize → parse with order intact. + let original = ListSchema(fields: [ + SchemaField(name: "Title", type: .text), + SchemaField(name: "Priority", type: .select, enumValues: ["low", "med", "high"]), + SchemaField(name: "Notes", type: .markdown) + ]) + + let serialized = SchemaDSL.serialize(original) + let reparsed = try SchemaDSL.parse(serialized) + + XCTAssertEqual(serialized, "Title:text, Priority:select(low|med|high), Notes:markdown") + XCTAssertEqual(reparsed, original) + } + + func test_givenSingleOptionSelect_whenParsing_thenAcceptsOneOption() throws { + // Boundary — a single option is valid (a degenerate but legal set). + let schema = try SchemaDSL.parse("Status:select(active)") + + XCTAssertEqual(schema.field(named: "Status")?.enumValues, ["active"]) + } + + // MARK: - select option validation (§1.1) + + func test_givenSelectWithEmptyOptionList_whenParsing_thenThrowsEmptySelectOptions() { + // Invalid input — `select()` declares no options. + XCTAssertThrowsError(try SchemaDSL.parse("P:select()")) { error in + XCTAssertEqual(error as? SchemaDSLError, .emptySelectOptions(field: "P")) + } + } + + func test_givenSelectWithNoParens_whenParsing_thenThrowsEmptySelectOptions() { + // Invalid input — a bare `select` with no `(...)` at all. + XCTAssertThrowsError(try SchemaDSL.parse("P:select")) { error in + XCTAssertEqual(error as? SchemaDSLError, .emptySelectOptions(field: "P")) + } + } + + func test_givenSelectWithBlankOption_whenParsing_thenThrowsEmptySelectOptions() { + // Boundary — `a||b` yields a blank middle option; rejected. + XCTAssertThrowsError(try SchemaDSL.parse("P:select(a||b)")) { error in + XCTAssertEqual(error as? SchemaDSLError, .emptySelectOptions(field: "P")) + } + } + + func test_givenSelectWithDuplicateOptions_whenParsing_thenThrowsDuplicateSelectOption() { + // Invalid input — the same option twice. + XCTAssertThrowsError(try SchemaDSL.parse("P:select(low|med|low)")) { error in + XCTAssertEqual( + error as? SchemaDSLError, + .duplicateSelectOption(field: "P", option: "low") + ) + } + } + + func test_givenNonSelectTypeWithOptions_whenParsing_thenThrowsInvalidFieldSyntax() { + // Invalid input — options on a type that does not carry them. + XCTAssertThrowsError(try SchemaDSL.parse("P:text(a|b)")) { error in + XCTAssertEqual(error as? SchemaDSLError, .invalidFieldSyntax(rawField: "P:text(a|b)")) + } + } + + func test_givenUnclosedSelectParen_whenParsing_thenThrowsInvalidFieldSyntax() { + // Invalid input — missing closing paren. + XCTAssertThrowsError(try SchemaDSL.parse("P:select(a|b")) { error in + guard case .invalidFieldSyntax = error as? SchemaDSLError else { + return XCTFail("Expected invalidFieldSyntax, got \(error)") + } + } + } + + // MARK: - markdown (§1.1) + + func test_givenMarkdownField_whenParsing_thenParsesLikeTextWithNoOptions() throws { + // markdown is a long-text type: no options, recognised by token. + let schema = try SchemaDSL.parse("Body:markdown") + + let field = try XCTUnwrap(schema.fields.first) + XCTAssertEqual(field.type, .markdown) + XCTAssertNil(field.enumValues) + } + + func test_givenMarkdownWithOptions_whenParsing_thenThrowsInvalidFieldSyntax() { + // Boundary — markdown carries no options, so a `(...)` suffix is + // rejected the same way as any other non-select type. + XCTAssertThrowsError(try SchemaDSL.parse("Body:markdown(a|b)")) { error in + XCTAssertEqual( + error as? SchemaDSLError, + .invalidFieldSyntax(rawField: "Body:markdown(a|b)") + ) + } + } } From cc40023dfb8384a6d0298dba4d94d2827458849d Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:27:16 -0700 Subject: [PATCH 02/10] feat(timeline): render rich link previews on posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server already returned linkMetadata (title/description/image); the domain model dropped it. Adds Message.linkPreviews + a mapper that drops unparseable URLs, and a tappable LinkPreviewCardView (AsyncImage thumbnail + title + host) in the timeline. Render gate is forward-compatible pending fetchStatus docs. 16 new tests (feature-gaps.md §1.5). Not persisted in SwiftData by design. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Timeline/LinkPreviewCardView.swift | 108 +++++++++++++ App/Features/Timeline/MessageRowView.swift | 18 +++ AppTests/LinkPreviewRenderingTests.swift | 79 ++++++++++ AppTests/Support/MessageFixtures.swift | 6 +- .../InterlinedDomain/Models/Mappers.swift | 28 +++- .../InterlinedDomain/Models/Message.swift | 94 ++++++++++- .../InterlinedDomainTests/MapperTests.swift | 147 +++++++++++++++++- 7 files changed, 475 insertions(+), 5 deletions(-) create mode 100644 App/Features/Timeline/LinkPreviewCardView.swift create mode 100644 AppTests/LinkPreviewRenderingTests.swift diff --git a/App/Features/Timeline/LinkPreviewCardView.swift b/App/Features/Timeline/LinkPreviewCardView.swift new file mode 100644 index 0000000..3474504 --- /dev/null +++ b/App/Features/Timeline/LinkPreviewCardView.swift @@ -0,0 +1,108 @@ +// LinkPreviewCardView +// +// Renders a single server-resolved rich link preview (feature-gaps §1.5) +// as a tappable card: an AsyncImage thumbnail (when the server resolved +// one), the preview title, and the link's host. The whole card is a +// button that opens the URL via the SwiftUI `@Environment(\.openURL)` +// action — no AppKit / `NSWorkspace`, honouring the App target's +// SwiftUI-only constraint. +// +// The "is this worth showing?" decision lives in the domain +// (`LinkPreview.isRenderable`), not here — the view stays passive and +// simply reflects a value that already passed that gate. Styling matches +// the surrounding timeline card theme (ILColor / ILFont / ILMetric). + +import SwiftUI +import InterlinedDomain + +struct LinkPreviewCardView: View { + + let preview: LinkPreview + + @Environment(\.openURL) private var openURL + + var body: some View { + Button { + openURL(preview.url) + } label: { + cardBody + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + .accessibilityHint("Opens the link in your browser") + .accessibilityAddTraits(.isLink) + } + + private var cardBody: some View { + HStack(alignment: .top, spacing: 10) { + if let imageURL = preview.imageURL { + thumbnail(imageURL) + } + VStack(alignment: .leading, spacing: 3) { + if let title = trimmedTitle { + Text(title) + .font(.ilBodyMedium()) + .foregroundStyle(ILColor.text) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + Text(preview.displayHost) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) + } + .padding(10) + .background(ILColor.surface2, in: RoundedRectangle(cornerRadius: ILMetric.radiusLg)) + .overlay( + RoundedRectangle(cornerRadius: ILMetric.radiusLg) + .strokeBorder(ILColor.primary.opacity(0.15), lineWidth: 1) + ) + .contentShape(RoundedRectangle(cornerRadius: ILMetric.radiusLg)) + } + + private func thumbnail(_ url: URL) -> some View { + AsyncImage(url: url) { phase in + switch phase { + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + placeholderGlyph + case .empty: + // Loading — keep the slot sized so the layout doesn't jump. + Color.clear + @unknown default: + placeholderGlyph + } + } + .frame(width: 56, height: 56) + .clipShape(RoundedRectangle(cornerRadius: ILMetric.radiusMd)) + .accessibilityHidden(true) + } + + private var placeholderGlyph: some View { + Image(systemName: "link") + .foregroundStyle(.secondary) + } + + /// The title with surrounding whitespace removed, or `nil` when the server + /// sent no title or a whitespace-only one — so the card renders host-only + /// rather than an empty title line. + private var trimmedTitle: String? { + guard let title = preview.title?.trimmingCharacters(in: .whitespacesAndNewlines), + !title.isEmpty else { return nil } + return title + } + + private var accessibilityLabel: String { + if let title = trimmedTitle { + return "Link preview: \(title), \(preview.displayHost)" + } + return "Link: \(preview.displayHost)" + } +} diff --git a/App/Features/Timeline/MessageRowView.swift b/App/Features/Timeline/MessageRowView.swift index 67bf775..6df0925 100644 --- a/App/Features/Timeline/MessageRowView.swift +++ b/App/Features/Timeline/MessageRowView.swift @@ -52,6 +52,9 @@ struct MessageRowView: View { repostBanner(original: repost.original) } bodyText + if !renderablePreviews.isEmpty { + linkPreviews + } if !message.tags.isEmpty { tagChips } @@ -121,6 +124,21 @@ struct MessageRowView: View { .fixedSize(horizontal: false, vertical: true) } + /// The subset of `message.linkPreviews` the domain deems worth showing + /// (feature-gaps §1.5). A bare URL with no resolved metadata is filtered + /// out here — the row degrades to no card rather than an empty one. + private var renderablePreviews: [LinkPreview] { + message.linkPreviews.filter(\.isRenderable) + } + + private var linkPreviews: some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(renderablePreviews) { preview in + LinkPreviewCardView(preview: preview) + } + } + } + private var tagChips: some View { // A wrapping run of small badges. `FlowLayout` is iOS 17+ only, // so we use a horizontal stack with wrapping by way of `Lazy` diff --git a/AppTests/LinkPreviewRenderingTests.swift b/AppTests/LinkPreviewRenderingTests.swift new file mode 100644 index 0000000..7d300a8 --- /dev/null +++ b/AppTests/LinkPreviewRenderingTests.swift @@ -0,0 +1,79 @@ +// LinkPreviewRenderingTests +// +// App-layer tests for the timeline link-preview rendering contract +// (feature-gaps §1.5). Per the project's view-layer rule we do NOT +// render `MessageRowView` / `LinkPreviewCardView` in XCTest — SwiftUI +// rendering is verified by the build and by hand. What we CAN pin here +// is the pure decision the row delegates to: which of a message's +// `linkPreviews` are "worth showing" (`LinkPreview.isRenderable`). This +// is the exact predicate `MessageRowView.renderablePreviews` filters on, +// so these tests guard the visible behaviour without touching a view. + +import XCTest +import InterlinedDomain +@testable import InterlinedList + +final class LinkPreviewRenderingTests: XCTestCase { + + private let base = URL(string: "https://example.com")! + + // Happy path: a fully-resolved preview is selected for rendering. + func test_givenMessageWithResolvedPreview_whenFilteringRenderable_thenPreviewIsIncluded() { + // Given + let resolved = LinkPreview( + url: base, + fetchStatus: "ready", + title: "Hello", + imageURL: URL(string: "https://cdn.example.com/i.png") + ) + let message = MessageFixtures.message(id: "m1", linkPreviews: [resolved]) + + // When + let renderable = message.linkPreviews.filter(\.isRenderable) + + // Then + XCTAssertEqual(renderable.map(\.url), [base]) + } + + // Invalid/degraded input: a bare-URL preview is filtered out, so the row + // renders no card for it. + func test_givenMessageWithBareURLPreview_whenFilteringRenderable_thenPreviewIsExcluded() { + // Given — no title, no image, unresolved fetch status. + let bare = LinkPreview(url: base, fetchStatus: "pending") + let message = MessageFixtures.message(id: "m1", linkPreviews: [bare]) + + // When + let renderable = message.linkPreviews.filter(\.isRenderable) + + // Then + XCTAssertTrue(renderable.isEmpty) + } + + // Mixed list: only the renderable entries survive, preserving order. + func test_givenMessageWithMixedPreviews_whenFilteringRenderable_thenOnlyRenderableSurviveInOrder() { + // Given + let good = LinkPreview(url: URL(string: "https://a.example.com")!, title: "A") + let bare = LinkPreview(url: URL(string: "https://b.example.com")!) + let alsoGood = LinkPreview(url: URL(string: "https://c.example.com")!, fetchStatus: "ok") + let message = MessageFixtures.message( + id: "m1", + linkPreviews: [good, bare, alsoGood] + ) + + // When + let renderable = message.linkPreviews.filter(\.isRenderable) + + // Then + XCTAssertEqual(renderable.map(\.url.host), ["a.example.com", "c.example.com"]) + } + + // Empty / boundary: a message with no previews yields nothing to render. + func test_givenMessageWithNoPreviews_whenFilteringRenderable_thenResultIsEmpty() { + // Given / When + let message = MessageFixtures.message(id: "m1") + let renderable = message.linkPreviews.filter(\.isRenderable) + + // Then + XCTAssertTrue(renderable.isEmpty) + } +} diff --git a/AppTests/Support/MessageFixtures.swift b/AppTests/Support/MessageFixtures.swift index f41864d..fb2f058 100644 --- a/AppTests/Support/MessageFixtures.swift +++ b/AppTests/Support/MessageFixtures.swift @@ -30,7 +30,8 @@ enum MessageFixtures { repostCount: Int = 0, replyCount: Int? = nil, parentID: String? = nil, - repost: Repost? = nil + repost: Repost? = nil, + linkPreviews: [LinkPreview] = [] ) -> Message { Message( id: id, @@ -46,7 +47,8 @@ enum MessageFixtures { replyCount: replyCount, parentID: parentID, repost: repost, - scheduledAt: nil + scheduledAt: nil, + linkPreviews: linkPreviews ) } diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift index 60b81b6..d52b09e 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Mappers.swift @@ -66,7 +66,12 @@ extension Message { Repost.message(Message(from: box.message)) }, scheduledAt: dto.scheduledAt, - crossPostResults: (dto.crossPosts ?? []).map(CrossPostResult.init(from:)) + crossPostResults: (dto.crossPosts ?? []).map(CrossPostResult.init(from:)), + // feature-gaps §1.5: thread server-rendered link previews through. + // `compactMap` drops entries whose `url` string will not parse so a + // `LinkPreview` always carries a usable `URL`. Not persisted in + // SwiftData — re-derived from the DTO on every load (see Message). + linkPreviews: (dto.linkMetadata?.links ?? []).compactMap(LinkPreview.init(from:)) ) } } @@ -105,6 +110,27 @@ extension CrossPostResult { } } +extension LinkPreview { + /// Maps a single server-rendered link preview (feature-gaps §1.5). + /// + /// Returns `nil` when the wire `url` string will not parse, so the caller's + /// `compactMap` drops it — a `LinkPreview` is only ever constructed with a + /// usable `URL`. The `imageUrl` string is parsed with the same tolerance and + /// silently dropped when malformed (the card degrades to no thumbnail rather + /// than failing to render). + public init?(from dto: LinkPreviewDTO) { + guard let url = URL(string: dto.url) else { return nil } + self.init( + url: url, + platform: dto.platform, + fetchStatus: dto.fetchStatus, + title: dto.title, + description: dto.description, + imageURL: dto.imageUrl.flatMap(URL.init(string:)) + ) + } +} + extension UserSearchResult { /// Maps from the search / lookup DTO. Avatar string is parsed into a URL and /// silently dropped if malformed — the UI always has the username as fallback. diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Message.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Message.swift index 8095344..f43ae6b 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Message.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/Message.swift @@ -48,6 +48,18 @@ public struct Message: Sendable, Equatable, Identifiable { /// return cross-post data for this response. public let crossPostResults: [CrossPostResult] + /// Server-rendered rich link previews for URLs found in the body + /// (feature-gaps §1.5). Empty when the message contains no links or the + /// server did not resolve any preview metadata for this response. + /// + /// SCOPE DECISION (feature-gaps §1.5): link previews are treated as a + /// fetch-time / UI concern and are **not** persisted in SwiftData + /// (`MessageRecord`). They are re-derived from the DTO on every load, + /// refreshing naturally on the next fetch. This deliberately avoids a + /// SwiftData schema migration; the trade-off is that previews are absent + /// when a row is rendered purely from the local cache before a refresh. + public let linkPreviews: [LinkPreview] + public init( id: String, author: UserSummary, @@ -63,7 +75,8 @@ public struct Message: Sendable, Equatable, Identifiable { parentID: String? = nil, repost: Repost? = nil, scheduledAt: Date? = nil, - crossPostResults: [CrossPostResult] = [] + crossPostResults: [CrossPostResult] = [], + linkPreviews: [LinkPreview] = [] ) { self.id = id self.author = author @@ -80,6 +93,7 @@ public struct Message: Sendable, Equatable, Identifiable { self.repost = repost self.scheduledAt = scheduledAt self.crossPostResults = crossPostResults + self.linkPreviews = linkPreviews } } @@ -127,6 +141,84 @@ public struct CrossPostResult: Sendable, Equatable { } } +// MARK: - LinkPreview (feature-gaps §1.5) + +/// A server-rendered rich link preview attached to a message. +/// +/// Maps from `LinkPreviewDTO`. The wire `url` string is coerced to a `URL` +/// during mapping and entries whose `url` will not parse are dropped, so a +/// `LinkPreview` always carries a usable `url`. The remaining fields mirror the +/// server's Open Graph resolution and stay optional because the server may not +/// have finished (or succeeded at) fetching them. +public struct LinkPreview: Sendable, Equatable, Identifiable { + /// The resolved link. Doubles as the stable identity for `ForEach`. + public let url: URL + /// Source platform label the server attached (e.g. "youtube", "github"), + /// when it recognised one. + public let platform: String? + /// The server's fetch-state string for this preview. The exact vocabulary + /// (which value means "ready") is **not documented** in the API reference + /// as of 2026-07-18 — see `isFetchStatusReady`. Kept as the raw string so + /// no information is lost and the client stays forward-compatible. + public let fetchStatus: String? + public let title: String? + public let description: String? + public let imageURL: URL? + + public var id: URL { url } + + public init( + url: URL, + platform: String? = nil, + fetchStatus: String? = nil, + title: String? = nil, + description: String? = nil, + imageURL: URL? = nil + ) { + self.url = url + self.platform = platform + self.fetchStatus = fetchStatus + self.title = title + self.description = description + self.imageURL = imageURL + } + + /// Whether `fetchStatus` names a state the client recognises as a completed, + /// successful fetch. + /// + /// NOTE (backend question, feature-gaps §1.5): the API reference does not + /// document the `fetchStatus` vocabulary, so we cannot be certain which + /// string means "ready". This matches a small, case-insensitive set of the + /// conventional success tokens. It is intentionally **not** the sole gate on + /// rendering — `isRenderable` also renders whenever a title or image is + /// present — so an unknown-but-successful status string never hides an + /// otherwise-complete card. + public var isFetchStatusReady: Bool { + guard let status = fetchStatus?.lowercased() else { return false } + return ["ready", "success", "succeeded", "ok", "complete", "completed", "fetched"].contains(status) + } + + /// Whether this preview carries enough resolved metadata to be worth + /// rendering as a card. True when the server reports a ready fetch status + /// OR when a human-meaningful field (title or image) is present. A bare URL + /// with no resolved metadata returns `false` — the UI degrades to nothing + /// (or a minimal chip) rather than an empty card. + public var isRenderable: Bool { + if isFetchStatusReady { return true } + if let title, !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return true } + if imageURL != nil { return true } + return false + } + + /// The host component shown as the card subtitle (e.g. "github.com"), + /// stripped of a leading `www.`. Falls back to the full URL string when the + /// URL has no host. + public var displayHost: String { + guard let host = url.host else { return url.absoluteString } + return host.hasPrefix("www.") ? String(host.dropFirst(4)) : host + } +} + /// One page of a timeline read: the messages plus the cursor needed to ask for /// the next page. Maps the kit's `PaginationInfo` envelope into the two values /// the UI's infinite scroll actually needs. diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift index cd5de23..d5a9260 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MapperTests.swift @@ -183,13 +183,15 @@ final class MapperTests: XCTestCase { tags: [String]? = ["swift"], digCount: Int = 3, dugByMe: Bool = false, - pushedMessage: PushedMessageBox? = nil + pushedMessage: PushedMessageBox? = nil, + linkMetadata: LinkMetadataDTO? = nil ) -> MessageDTO { MessageDTO( id: id, content: "hello", publiclyVisible: publiclyVisible, userId: "u1", + linkMetadata: linkMetadata, tags: tags, createdAt: date, updatedAt: date, @@ -285,4 +287,147 @@ final class MapperTests: XCTestCase { XCTFail("Expected .unknown, got \(result.status)") } } + + // MARK: - LinkPreview mapper (feature-gaps §1.5) + + // Happy path: a fully-resolved preview with every field populated maps to a + // single renderable `LinkPreview` on the message. + func test_givenMessageWithFullLinkMetadata_whenMapped_thenCarriesRenderablePreview() throws { + // Given + let link = LinkPreviewDTO( + url: "https://example.com/article", + platform: "web", + fetchStatus: "ready", + title: "A Great Article", + description: "All about widgets.", + imageUrl: "https://cdn.example.com/thumb.png" + ) + let dto = makeMessageDTO(linkMetadata: LinkMetadataDTO(links: [link])) + + // When + let message = Message(from: dto) + + // Then + XCTAssertEqual(message.linkPreviews.count, 1) + let preview = try XCTUnwrap(message.linkPreviews.first) + XCTAssertEqual(preview.url, URL(string: "https://example.com/article")) + XCTAssertEqual(preview.platform, "web") + XCTAssertEqual(preview.fetchStatus, "ready") + XCTAssertEqual(preview.title, "A Great Article") + XCTAssertEqual(preview.description, "All about widgets.") + XCTAssertEqual(preview.imageURL, URL(string: "https://cdn.example.com/thumb.png")) + XCTAssertEqual(preview.displayHost, "example.com") + XCTAssertTrue(preview.isRenderable) + } + + // Boundary: a bare URL with no title / image / ready status still maps + // (the URL parses) but is reported as not worth rendering, so the UI shows + // no card for it. + func test_givenMessageWithBareURLOnlyLink_whenMapped_thenPreviewMapsButIsNotRenderable() throws { + // Given — nothing but a URL; fetch not (yet) resolved. + let link = LinkPreviewDTO(url: "https://example.com/pending", fetchStatus: "pending") + let dto = makeMessageDTO(linkMetadata: LinkMetadataDTO(links: [link])) + + // When + let message = Message(from: dto) + + // Then + XCTAssertEqual(message.linkPreviews.count, 1) + let preview = try XCTUnwrap(message.linkPreviews.first) + XCTAssertNil(preview.title) + XCTAssertNil(preview.imageURL) + XCTAssertFalse(preview.isFetchStatusReady) + XCTAssertFalse(preview.isRenderable) + } + + // Invalid input: an entry whose `url` will not parse is dropped by the + // mapper's `compactMap`, while a sibling valid entry survives. + func test_givenMessageWithUnparseableLinkURL_whenMapped_thenThatEntryIsDropped() throws { + // Given — an empty-string URL cannot form a `URL`; a valid sibling can. + let bad = LinkPreviewDTO(url: "", title: "Broken") + let good = LinkPreviewDTO(url: "https://example.com/ok", title: "Good", imageUrl: nil) + let dto = makeMessageDTO(linkMetadata: LinkMetadataDTO(links: [bad, good])) + + // When + let message = Message(from: dto) + + // Then — only the parseable entry survives. + XCTAssertEqual(message.linkPreviews.count, 1) + XCTAssertEqual(message.linkPreviews.first?.url, URL(string: "https://example.com/ok")) + } + + // Empty / boundary: no `linkMetadata` at all collapses to an empty array + // (never `nil`), matching the `crossPostResults` default. + func test_givenMessageWithNoLinkMetadata_whenMapped_thenLinkPreviewsAreEmpty() { + // Given / When + let message = Message(from: makeMessageDTO(linkMetadata: nil)) + + // Then + XCTAssertEqual(message.linkPreviews, []) + } + + // Unparseable image only: the entry is kept (its `url` parses) with a nil + // image, so the card degrades to a thumbnail-less preview rather than being + // dropped. An empty-string `imageUrl` will not form a `URL`. + func test_givenLinkWithUnparseableImageURL_whenMapped_thenPreviewKeptWithoutImage() throws { + // Given + let link = LinkPreviewDTO( + url: "https://example.com/story", + fetchStatus: "ready", + title: "Story", + imageUrl: "" // empty — not a valid URL + ) + let dto = makeMessageDTO(linkMetadata: LinkMetadataDTO(links: [link])) + + // When + let preview = try XCTUnwrap(Message(from: dto).linkPreviews.first) + + // Then + XCTAssertNil(preview.imageURL) + XCTAssertTrue(preview.isRenderable) // title alone is enough + } + + // MARK: - LinkPreview display rules (feature-gaps §1.5) + + // A recognised success status renders even when title/image are absent — + // the client is forward-compatible about which token means "ready". + func test_givenReadyFetchStatusWithoutTitleOrImage_whenEvaluated_thenIsRenderable() { + // Given + let preview = LinkPreview(url: URL(string: "https://example.com")!, fetchStatus: "SUCCESS") + + // Then — case-insensitive match on a known success token. + XCTAssertTrue(preview.isFetchStatusReady) + XCTAssertTrue(preview.isRenderable) + } + + // An image with no title still renders (image is a human-meaningful field). + func test_givenImageOnlyPreview_whenEvaluated_thenIsRenderable() { + // Given + let preview = LinkPreview( + url: URL(string: "https://example.com")!, + imageURL: URL(string: "https://cdn.example.com/i.png")! + ) + + // Then + XCTAssertTrue(preview.isRenderable) + } + + // Whitespace-only title is treated as absent for rendering purposes. + func test_givenWhitespaceOnlyTitleAndNoImage_whenEvaluated_thenIsNotRenderable() { + // Given + let preview = LinkPreview(url: URL(string: "https://example.com")!, title: " ") + + // Then + XCTAssertFalse(preview.isRenderable) + } + + // `www.` is stripped from the display host; a URL without a host falls back + // to the full string. + func test_givenHostWithWWWPrefix_whenReadingDisplayHost_thenPrefixStripped() { + // Given + let preview = LinkPreview(url: URL(string: "https://www.example.com/x")!) + + // Then + XCTAssertEqual(preview.displayHost, "example.com") + } } From e13da867ca7015f610ce4a5150dc6d34038110b8 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:27:34 -0700 Subject: [PATCH 03/10] feat(documents): add document templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-side template catalog (Blank / Meeting Notes / Daily Log / PRD) with a New-from-Template command (Shift-Opt-Cmd-N) and picker sheet that seeds the markdown body through the existing create path. Blank == prior behavior. 12 new tests (feature-gaps.md §1.4). No templates endpoint exists; static catalog can move server-side later. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../DocumentTemplatePickerView.swift | 110 ++++++++++ .../Documents/DocumentsListViewModel.swift | 25 +++ .../Documents/DocumentsRootView.swift | 21 ++ App/MenuCommands/DocumentsMenuCommands.swift | 14 ++ AppTests/DocumentsListViewModelTests.swift | 110 ++++++++++ .../Models/DocumentTemplate.swift | 196 ++++++++++++++++++ .../DocumentTemplateTests.swift | 73 +++++++ 7 files changed, 549 insertions(+) create mode 100644 App/Features/Documents/DocumentTemplatePickerView.swift create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentTemplate.swift create mode 100644 Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentTemplateTests.swift diff --git a/App/Features/Documents/DocumentTemplatePickerView.swift b/App/Features/Documents/DocumentTemplatePickerView.swift new file mode 100644 index 0000000..0a300ce --- /dev/null +++ b/App/Features/Documents/DocumentTemplatePickerView.swift @@ -0,0 +1,110 @@ +// DocumentTemplatePickerView +// +// Sheet for "New Document from Template…" (feature-gaps.md §1.4). Lists the +// bundled `DocumentTemplate.builtIn` catalog (name + summary); on selection it +// asks `DocumentsListViewModel.createDocument(from:)` to seed a new document +// with the template's starter Markdown and route it through the normal create +// path, then reports the created document to the caller so the editor can bind +// to it. +// +// This is a client-side feature — there is no templates endpoint, so the view +// reads the static catalog directly (no service, no AppEnvironment wiring). If +// the API later exposes templates, swap the catalog source; this view's shape +// does not change. +// +// Pure SwiftUI; no AppKit involvement. Per Decision 0003 the view imports only +// InterlinedDomain. + +import SwiftUI +import InterlinedDomain + +struct DocumentTemplatePickerView: View { + + /// The list view model that owns the create path. Bindable so the sheet + /// reacts to its `error` and any in-flight state. + @Bindable var viewModel: DocumentsListViewModel + + /// Called with the created document on success so the caller (the root + /// view) can bind the editor to it. Not called on failure. + let onCreated: (Document) -> Void + + /// The catalog to present. Defaults to the bundled built-ins; injectable so + /// previews and future server-backed catalogs can substitute a list. + var templates: [DocumentTemplate] = DocumentTemplate.builtIn + + @Environment(\.dismiss) private var dismiss + @State private var selectedTemplateID: DocumentTemplate.ID? + @State private var isCreating = false + + private var selectedTemplate: DocumentTemplate? { + guard let selectedTemplateID else { return nil } + return templates.first { $0.id == selectedTemplateID } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("New Document from Template") + .font(.ilTitle(18)) + .padding(.top, 4) + + List(selection: $selectedTemplateID) { + ForEach(templates) { template in + VStack(alignment: .leading, spacing: 2) { + Text(template.name) + .font(.ilBody()) + .fontWeight(.medium) + Text(template.summary) + .font(.ilMono(10)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 2) + .tag(template.id) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(template.name). \(template.summary)") + } + } + .listStyle(.inset) + .frame(minHeight: 200) + + if let error = viewModel.error { + Text(error.localizedDescription) + .font(.ilMono(10)) + .foregroundStyle(Color.accentColor) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Create") { + Task { await create() } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(selectedTemplate == nil || isCreating) + } + } + .padding(20) + .frame(minWidth: 420, minHeight: 340) + .onAppear { + // Preselect the first template (Blank) so the primary action is + // always live when the sheet opens. + if selectedTemplateID == nil { + selectedTemplateID = templates.first?.id + } + } + } + + private func create() async { + guard let template = selectedTemplate else { return } + isCreating = true + defer { isCreating = false } + if let created = await viewModel.createDocument(from: template) { + onCreated(created) + dismiss() + } + // On failure the view model's `error` is surfaced above and the sheet + // stays open so the user can retry or cancel. + } +} diff --git a/App/Features/Documents/DocumentsListViewModel.swift b/App/Features/Documents/DocumentsListViewModel.swift index a330251..7402ab6 100644 --- a/App/Features/Documents/DocumentsListViewModel.swift +++ b/App/Features/Documents/DocumentsListViewModel.swift @@ -145,6 +145,31 @@ final class DocumentsListViewModel { } } + /// Creates a new document seeded from a client-side `DocumentTemplate` + /// (feature-gaps.md §1.4). The chosen template supplies the starter + /// Markdown body; the title defaults to the template name but the caller + /// may override it, and the user can rename it in the editor afterward. + /// + /// This routes through the same `createDocument(title:body:isPublic:)` path + /// as a blank document — a template is purely a seed, so seeding from + /// `.blank` is identical to today's "new blank document" behavior. There is + /// no templates endpoint; the catalog is bundled in `InterlinedDomain`. If + /// the API later exposes templates this can switch to a server fetch without + /// changing this signature. + @discardableResult + func createDocument( + from template: DocumentTemplate, + title: String? = nil, + isPublic: Bool = false + ) async -> Document? { + let resolvedTitle = title ?? template.name + return await createDocument( + title: resolvedTitle, + body: template.bodyMarkdown, + isPublic: isPublic + ) + } + /// Deletes a document. Optimistic — removes from the rendered list /// first, then calls the service; on failure restores the snapshot /// and surfaces the error. diff --git a/App/Features/Documents/DocumentsRootView.swift b/App/Features/Documents/DocumentsRootView.swift index a6f9e18..196e94d 100644 --- a/App/Features/Documents/DocumentsRootView.swift +++ b/App/Features/Documents/DocumentsRootView.swift @@ -35,6 +35,9 @@ struct DocumentsRootView: View { @State private var editor: DocumentEditorViewModel? @State private var syncStatus: SyncStatusViewModel? + /// Drives the "New from Template…" picker sheet (feature-gaps.md §1.4). + @State private var isTemplatePickerPresented = false + var body: some View { Group { if let environment, let folderTree, let documentsList, let editor, let syncStatus { @@ -55,9 +58,19 @@ struct DocumentsRootView: View { .onReceive(NotificationCenter.default.publisher(for: .documentsNewDocument)) { _ in Task { await handleNewDocument() } } + .onReceive(NotificationCenter.default.publisher(for: .documentsNewFromTemplate)) { _ in + isTemplatePickerPresented = true + } .onReceive(NotificationCenter.default.publisher(for: .documentsSyncNow)) { _ in Task { await syncStatus?.syncNow() } } + .sheet(isPresented: $isTemplatePickerPresented) { + if let documentsList { + DocumentTemplatePickerView(viewModel: documentsList) { created in + editor?.bind(to: created) + } + } + } } @ViewBuilder @@ -104,6 +117,14 @@ struct DocumentsRootView: View { .keyboardShortcut("n", modifiers: [.option, .command]) .help("Create a new document in this folder") + Button { + isTemplatePickerPresented = true + } label: { + Label("New from Template", systemImage: "doc.badge.gearshape") + } + .keyboardShortcut("n", modifiers: [.option, .command, .shift]) + .help("Create a new document from a starter template") + Button { Task { await syncStatus.syncNow() } } label: { diff --git a/App/MenuCommands/DocumentsMenuCommands.swift b/App/MenuCommands/DocumentsMenuCommands.swift index a1bc587..430b7e2 100644 --- a/App/MenuCommands/DocumentsMenuCommands.swift +++ b/App/MenuCommands/DocumentsMenuCommands.swift @@ -20,6 +20,13 @@ extension Notification.Name { /// in the currently-selected folder. static let documentsNewDocument = Notification.Name("InterlinedList.documentsNewDocument") + /// Posted when the user invokes Documents → New from Template…. + /// `DocumentsRootView` observes this and presents the template picker + /// sheet (feature-gaps.md §1.4). Client-side: seeds a new document from a + /// bundled starter-Markdown catalog, then routes it through the normal + /// create path. + static let documentsNewFromTemplate = Notification.Name("InterlinedList.documentsNewFromTemplate") + /// Posted when the user invokes Documents → Sync Now. The same /// notification fires from the toolbar button so the sync path is /// single-sourced. @@ -41,6 +48,13 @@ private struct DocumentsMenuButtons: View { } .keyboardShortcut("n", modifiers: [.option, .command]) + Button("New from Template…") { + NotificationCenter.default.post(name: .documentsNewFromTemplate, object: nil) + } + .keyboardShortcut("n", modifiers: [.option, .command, .shift]) + + Divider() + Button("Sync Now") { NotificationCenter.default.post(name: .documentsSyncNow, object: nil) } diff --git a/AppTests/DocumentsListViewModelTests.swift b/AppTests/DocumentsListViewModelTests.swift index 6246dcd..990dc13 100644 --- a/AppTests/DocumentsListViewModelTests.swift +++ b/AppTests/DocumentsListViewModelTests.swift @@ -118,6 +118,116 @@ final class DocumentsListViewModelTests: XCTestCase { XCTAssertTrue(viewModel.documentsLoaded.isEmpty) } + // MARK: - createDocument(from template:) + + func test_givenNamedTemplate_whenCreatingFromTemplate_thenSeedsTitleAndBody() async { + // Happy path: the template's name becomes the title and its Markdown + // becomes the body on the create call. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + let template = DocumentTemplate.meetingNotes + let created = DocumentsFixtures.document( + id: "D1", + title: template.name, + body: template.bodyMarkdown + ) + await stub.enqueueCreate(success: created) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: nil) + + let result = await viewModel.createDocument(from: template) + + XCTAssertEqual(result?.id, "D1") + XCTAssertEqual(viewModel.documentsLoaded.first?.id, "D1") + XCTAssertEqual(viewModel.selectedDocumentID, "D1") + XCTAssertNil(viewModel.error) + + let recorded = await stub.recorded + let createCall = recorded.compactMap { call -> (String, String)? in + if case let .create(title, body, _, _) = call.kind { return (title, body) } + return nil + }.first + XCTAssertEqual(createCall?.0, template.name) + XCTAssertEqual(createCall?.1, template.bodyMarkdown) + } + + func test_givenBlankTemplate_whenCreatingFromTemplate_thenSeedsEmptyBody() async { + // Boundary: the Blank template is the identity path — an empty body, + // exactly like today's "new blank document" action. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + let created = DocumentsFixtures.document(id: "D1", title: "Blank", body: "") + await stub.enqueueCreate(success: created) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: nil) + + let result = await viewModel.createDocument(from: .blank) + + XCTAssertEqual(result?.id, "D1") + let recorded = await stub.recorded + let createCall = recorded.compactMap { call -> (String, String)? in + if case let .create(title, body, _, _) = call.kind { return (title, body) } + return nil + }.first + XCTAssertEqual(createCall?.0, "Blank") + XCTAssertEqual(createCall?.1, "") + } + + func test_givenBlankTitleOverride_whenCreatingFromTemplate_thenRejectsBeforeService() async { + // Invalid input: an explicit whitespace title override is rejected up + // front (via the shared create guard) and no create call is made. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: nil) + + let result = await viewModel.createDocument(from: .meetingNotes, title: " ") + + XCTAssertNil(result) + XCTAssertEqual(viewModel.error as? DocumentsUIError, .invalidDocumentTitle) + let recorded = await stub.recorded + let createCalls = recorded.filter { + if case .create = $0.kind { return true } else { return false } + } + XCTAssertTrue(createCalls.isEmpty) + } + + func test_givenAPIFailure_whenCreatingFromTemplate_thenSurfacesError() async { + // Upstream failure: the service throws and the error surfaces; the + // rendered list is untouched. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + let failure = TestError.upstream("denied") + await stub.enqueueCreate(failure: failure) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: nil) + + let result = await viewModel.createDocument(from: .dailyLog) + + XCTAssertNil(result) + XCTAssertEqual(viewModel.error as? TestError, failure) + XCTAssertTrue(viewModel.documentsLoaded.isEmpty) + } + + func test_givenExplicitTitle_whenCreatingFromTemplate_thenUsesOverrideNotTemplateName() async { + // The caller may override the default (template name) title. + let stub = StubDocumentsService() + await stub.enqueueDocuments(success: []) + let created = DocumentsFixtures.document(id: "D1", title: "Q3 Planning") + await stub.enqueueCreate(success: created) + let viewModel = DocumentsListViewModel(documents: stub) + await viewModel.reload(in: nil) + + _ = await viewModel.createDocument(from: .meetingNotes, title: "Q3 Planning") + + let recorded = await stub.recorded + let createTitle = recorded.compactMap { call -> String? in + if case let .create(title, _, _, _) = call.kind { return title } + return nil + }.first + XCTAssertEqual(createTitle, "Q3 Planning") + } + // MARK: - deleteDocument func test_givenLoadedDocument_whenDeleting_thenRemovesAndClearsSelection() async { diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentTemplate.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentTemplate.swift new file mode 100644 index 0000000..e5aa3e1 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Models/DocumentTemplate.swift @@ -0,0 +1,196 @@ +import Foundation + +// MARK: - DocumentTemplate + +/// A starter template that seeds a new document's title and Markdown body +/// before it is handed to the normal create flow (feature-gaps.md §1.4). +/// +/// **This is a client-side feature.** The InterlinedList API exposes no +/// documents-templates endpoint (see +/// `InterlinedKit/Endpoints/DocumentsEndpoint.swift` — only sync, document +/// CRUD, image upload, and folder CRUD). A template is therefore nothing more +/// than bundled starter Markdown: the app picks one, seeds a fresh buffer, and +/// routes it through the existing `DocumentsServicing.create` path exactly like +/// a blank document. Nothing about a template survives on the server — once the +/// document is created it is an ordinary document. +/// +/// If the API later grows a real templates endpoint, this type can migrate to a +/// server-backed catalog: replace `builtIn` with a `TemplatesServicing` fetch +/// and keep the same `{ name, summary, bodyMarkdown }` shape so call sites do +/// not change. +public struct DocumentTemplate: Sendable, Equatable, Hashable, Identifiable { + + /// Stable identifier used for selection and diffing. Unique within a + /// catalog (enforced by `DocumentTemplateTests`). + public let id: String + + /// Human-readable name shown in the picker and used as the default title + /// of a document seeded from this template. + public let name: String + + /// One-line description shown under the name in the picker so the user can + /// tell templates apart without opening them. + public let summary: String + + /// The starter Markdown seeded into the new document's `DocumentBody`. + /// May be empty (the Blank template) — that is the canonical + /// "new blank document" behavior. + public let bodyMarkdown: String + + public init(id: String, name: String, summary: String, bodyMarkdown: String) { + self.id = id + self.name = name + self.summary = summary + self.bodyMarkdown = bodyMarkdown + } + + /// The body this template seeds, as a typed `DocumentBody`. Convenience for + /// call sites that already speak `DocumentBody` rather than raw `String`. + public var body: DocumentBody { + DocumentBody(markdown: bodyMarkdown) + } +} + +// MARK: - Built-in catalog + +public extension DocumentTemplate { + + /// The bundled starter catalog. Static and dependency-free so App-layer + /// view models can reference it directly — no composition-root wiring is + /// required (there is no service to inject because there is no endpoint). + /// + /// The first entry (`.blank`) is the identity template: seeding from it is + /// byte-for-byte the same as the existing "New blank document" action. + static let builtIn: [DocumentTemplate] = [ + .blank, + .meetingNotes, + .dailyLog, + .productRequirements + ] + + /// An empty document. Equivalent to today's "New Document" action; kept in + /// the catalog so the picker always offers a "start from scratch" option. + static let blank = DocumentTemplate( + id: "blank", + name: "Blank", + summary: "An empty document to start from scratch.", + bodyMarkdown: "" + ) + + static let meetingNotes = DocumentTemplate( + id: "meeting-notes", + name: "Meeting Notes", + summary: "Agenda, attendees, discussion, and action items.", + bodyMarkdown: """ + # Meeting Notes + + **Date:** \n\ + **Attendees:** \n\ + **Facilitator:** + + ## Agenda + + 1. + 2. + 3. + + ## Discussion + + - + + ## Decisions + + - + + ## Action Items + + - [ ] Owner — task — due date + - [ ] + + ## Follow-up + + - + """ + ) + + static let dailyLog = DocumentTemplate( + id: "daily-log", + name: "Daily Log", + summary: "A running journal for today's plans, progress, and blockers.", + bodyMarkdown: """ + # Daily Log + + **Date:** + + ## Plan for Today + + - [ ] + - [ ] + - [ ] + + ## Progress + + - + + ## Blockers + + - + + ## Notes + + - + + ## Tomorrow + + - + """ + ) + + static let productRequirements = DocumentTemplate( + id: "prd", + name: "Product Requirements", + summary: "A PRD skeleton: problem, goals, scope, and success metrics.", + bodyMarkdown: """ + # Product Requirements: + + **Author:** \n\ + **Status:** Draft \n\ + **Last updated:** + + ## Summary + + One paragraph describing what this is and why it matters. + + ## Problem + + What problem are we solving, and for whom? + + ## Goals + + - + - + + ## Non-Goals + + - + + ## Requirements + + ### Functional + + - + + ### Non-Functional + + - + + ## Open Questions + + - + + ## Success Metrics + + - + """ + ) +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentTemplateTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentTemplateTests.swift new file mode 100644 index 0000000..b89079c --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/DocumentTemplateTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import InterlinedDomain + +/// BDD-named tests for the client-side document-template catalog +/// (feature-gaps.md §1.4). Pure value-type checks — the catalog is static and +/// dependency-free, so there is no service to stub. +final class DocumentTemplateTests: XCTestCase { + + // MARK: - Catalog shape + + func test_givenBuiltInCatalog_whenInspected_thenIsNonEmpty() { + // Happy path: the picker always has something to show. + XCTAssertFalse(DocumentTemplate.builtIn.isEmpty) + } + + func test_givenBuiltInCatalog_whenInspected_thenIdsAreUnique() { + let ids = DocumentTemplate.builtIn.map(\.id) + XCTAssertEqual(ids.count, Set(ids).count) + } + + func test_givenBuiltInCatalog_whenInspected_thenNamesAndSummariesAreNonBlank() { + // Boundary: no template ships with a blank name or summary — those are + // the only two fields the picker renders as user-facing text. + for template in DocumentTemplate.builtIn { + XCTAssertFalse( + template.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "template \(template.id) has a blank name" + ) + XCTAssertFalse( + template.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "template \(template.id) has a blank summary" + ) + } + } + + // MARK: - Blank identity + + func test_givenBlankTemplate_whenInspected_thenSeedsEmptyBody() { + // The blank path must be byte-for-byte the existing "new blank + // document" behavior — an empty markdown body. + XCTAssertEqual(DocumentTemplate.blank.bodyMarkdown, "") + XCTAssertEqual(DocumentTemplate.blank.body, .empty) + } + + func test_givenBuiltInCatalog_whenInspected_thenFirstEntryIsBlank() { + // The picker leans on this ordering to keep "start from scratch" first. + XCTAssertEqual(DocumentTemplate.builtIn.first?.id, DocumentTemplate.blank.id) + } + + // MARK: - Named templates seed real content + + func test_givenNamedTemplate_whenInspected_thenSeedsNonEmptyMarkdown() { + // Every non-blank template seeds real starter Markdown, and its typed + // `body` matches its raw `bodyMarkdown`. + let named = DocumentTemplate.builtIn.filter { $0.id != DocumentTemplate.blank.id } + XCTAssertFalse(named.isEmpty, "expected at least one non-blank template") + for template in named { + XCTAssertFalse( + template.bodyMarkdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "template \(template.id) seeds no content" + ) + XCTAssertEqual(template.body, DocumentBody(markdown: template.bodyMarkdown)) + } + } + + func test_givenMeetingNotesTemplate_whenInspected_thenContainsExpectedSections() { + // Named-template happy path: the Meeting Notes body carries its + // signature Action Items section. + let template = DocumentTemplate.meetingNotes + XCTAssertTrue(template.bodyMarkdown.contains("# Meeting Notes")) + XCTAssertTrue(template.bodyMarkdown.contains("## Action Items")) + } +} From 9c1ecf4c14330085c3886543fd5fee979f450cd3 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:27:34 -0700 Subject: [PATCH 04/10] feat(export): add MarkdownExporter engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure renderer for documents, message threads, and lists-as-tables (schema-ordered columns, pipe/newline-escaped cells). Uses value-type Date.ISO8601Format for Sendable safety. 15 BDD tests. Client-side because /api/exports/* is CSV-only (see feature-blockages.md NB-3). UI wiring lands next (feature-gaps.md §1.3). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Services/MarkdownExporter.swift | 198 ++++++++++++++++ .../MarkdownExporterTests.swift | 221 ++++++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MarkdownExporter.swift create mode 100644 Packages/InterlinedDomain/Tests/InterlinedDomainTests/MarkdownExporterTests.swift diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MarkdownExporter.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MarkdownExporter.swift new file mode 100644 index 0000000..bb28386 --- /dev/null +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/MarkdownExporter.swift @@ -0,0 +1,198 @@ +import Foundation + +/// Renders domain models to Markdown for the "Markdown export" data-portability +/// feature advertised on interlinedlist.com (feature-gaps.md §1.3 — "Markdown +/// export for lists, documents, and message threads with structured table +/// conversion"). +/// +/// **Why this is a client-side renderer.** The `/api/exports/*` endpoints return +/// CSV only — there is no Markdown format on the wire and no per-document / +/// per-thread export endpoint (see `feature-blockages.md` BE-1). So the app +/// composes Markdown itself from already-fetched domain models. This type is the +/// reusable engine every entry point calls; it is a pure value transformer with +/// no I/O, so it is exhaustively unit-testable and free of `Date.now`-style +/// nondeterminism (callers pass the dates that are already on the models). +/// +/// The three surfaces mirror the three things the web app can export: +/// - `markdown(for:)` — a single long-form document +/// - `markdown(forThreadRoot:replies:)` — a message and its replies +/// - `markdown(forList:)` — a structured list as a Markdown table +public struct MarkdownExporter: Sendable { + + public init() {} + + /// ISO-8601 timestamps keep the output stable and locale-independent, which + /// matters for deterministic tests and for diffable exports. Uses the + /// value-type `ISO8601FormatStyle` (GMT, internet date-time) rather than a + /// stored `ISO8601DateFormatter` — the latter is a non-`Sendable` reference + /// type and cannot live inside this `Sendable` struct. + private func timestamp(_ date: Date) -> String { + date.ISO8601Format() + } + + // MARK: - Documents + + /// Renders a long-form document as Markdown: an H1 title followed by the + /// document body (which is already Markdown source). The body is emitted + /// verbatim — documents author Markdown directly, so no escaping is applied. + public func markdown(for document: Document) -> String { + var out = "# \(document.title.isEmpty ? "Untitled" : document.title)\n" + let body = document.body.markdown.trimmingCharacters(in: .whitespacesAndNewlines) + if !body.isEmpty { + out += "\n\(body)\n" + } + return out + } + + // MARK: - Threads + + /// Renders a message thread: the root post, then its replies in ascending + /// creation order as block quotes. Replies are rendered flat (sorted by + /// `createdAt`) rather than nested by `parentID`; deep-nesting is a later + /// refinement noted in feature-gaps.md. + public func markdown(forThreadRoot root: Message, replies: [Message]) -> String { + var out = "# Thread\n\n" + out += renderPost(root) + + let ordered = replies.sorted { $0.createdAt < $1.createdAt } + if !ordered.isEmpty { + out += "\n---\n\n## Replies\n\n" + for reply in ordered { + out += renderReply(reply) + } + } + return out + } + + /// The root post: bold author handle + timestamp header, then the body. + private func renderPost(_ message: Message) -> String { + var block = "**@\(message.author.username)** · \(timestamp(message.createdAt))\n\n" + let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { + block += "\(text)\n" + } + return block + } + + /// A reply, rendered as a Markdown block quote so nesting reads visually. + private func renderReply(_ message: Message) -> String { + let header = "> **@\(message.author.username)** · \(timestamp(message.createdAt))" + let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + let quotedBody = text + .split(separator: "\n", omittingEmptySubsequences: false) + .map { "> \($0)" } + .joined(separator: "\n") + if text.isEmpty { + return "\(header)\n\n" + } + return "\(header)\n>\n\(quotedBody)\n\n" + } + + // MARK: - Lists (structured table conversion) + + /// Input bundle for a list export. Decoupled from `ListDetail` / `OwnedList` + /// so both public and owned lists render through the same path. + public struct ListInput: Sendable, Equatable { + public let title: String + public let description: String? + /// The schema DSL string (e.g. `"Title:text, Year:number"`). Used only + /// to derive the canonical column order; parsing beyond the field names + /// is intentionally avoided so this renderer does not couple to the + /// `SchemaDSL` grammar. + public let schemaDSL: String? + public let rows: [ListRow] + + public init(title: String, description: String?, schemaDSL: String?, rows: [ListRow]) { + self.title = title + self.description = description + self.schemaDSL = schemaDSL + self.rows = rows + } + } + + /// Renders a single list as a Markdown table ("structured table + /// conversion"): an H1 title, optional italic description, then a table + /// whose columns come from the schema (falling back to the union of row + /// keys). Cell values are pipe- and newline-escaped so the table never + /// breaks. A list with no derivable columns renders an explanatory line + /// instead of an empty table. + public func markdown(forList list: ListInput) -> String { + var out = "# \(list.title.isEmpty ? "Untitled list" : list.title)\n" + if let description = list.description?.trimmingCharacters(in: .whitespacesAndNewlines), + !description.isEmpty { + out += "\n_\(description)_\n" + } + + let columns = Self.columns(fromSchemaDSL: list.schemaDSL, rows: list.rows) + guard !columns.isEmpty else { + out += "\n_No columns defined._\n" + return out + } + + out += "\n" + out += "| " + columns.map(Self.escapeCell).joined(separator: " | ") + " |\n" + out += "| " + columns.map { _ in "---" }.joined(separator: " | ") + " |\n" + + if list.rows.isEmpty { + // A header with no data rows is still valid Markdown; keep the table + // shape and let the empty body speak for itself. + return out + } + + for row in list.rows { + let cells = columns.map { column -> String in + Self.escapeCell(row.fields[column]?.displayText ?? "") + } + out += "| " + cells.joined(separator: " | ") + " |\n" + } + return out + } + + /// Concatenates several lists into one Markdown document, separated by a + /// horizontal rule. Used by the "Export all my lists" flow. + public func markdown(forLists lists: [ListInput]) -> String { + lists.map { markdown(forList: $0) }.joined(separator: "\n---\n\n") + } + + // MARK: - Helpers + + /// Derives the ordered column set for a list table. Prefers the declared + /// schema (parsed just far enough to read the field *names*, left-of-colon); + /// falls back to the sorted union of keys observed across the rows so a + /// schemaless list still exports something sensible. + static func columns(fromSchemaDSL dsl: String?, rows: [ListRow]) -> [String] { + if let dsl { + let names = dsl + .split(separator: ",") + .compactMap { pair -> String? in + let name = pair.split(separator: ":", maxSplits: 1).first + .map { String($0).trimmingCharacters(in: .whitespaces) } ?? "" + return name.isEmpty ? nil : name + } + if !names.isEmpty { + return names + } + } + var seen = Set() + var ordered: [String] = [] + for row in rows { + for key in row.fields.keys.sorted() where !seen.contains(key) { + seen.insert(key) + ordered.append(key) + } + } + return ordered + } + + /// Escapes a value for a Markdown table cell: pipes are backslash-escaped + /// and newlines collapse to spaces so a multi-line value cannot split the + /// row across table lines. + static func escapeCell(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "|", with: "\\|") + .replacingOccurrences(of: "\r\n", with: " ") + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + } +} diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MarkdownExporterTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MarkdownExporterTests.swift new file mode 100644 index 0000000..84f7f45 --- /dev/null +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/MarkdownExporterTests.swift @@ -0,0 +1,221 @@ +import XCTest +@testable import InterlinedDomain + +/// BDD-named coverage for `MarkdownExporter` — the client-side Markdown +/// renderer that implements the "Markdown export for lists, documents, and +/// threads" parity feature (feature-gaps.md §1.3). The renderer is a pure +/// value transformer, so every surface is asserted structurally without any +/// network or clock dependency. +final class MarkdownExporterTests: XCTestCase { + + private let exporter = MarkdownExporter() + + // MARK: - Fixtures + + private func user(_ handle: String) -> UserSummary { + UserSummary(id: "u-\(handle)", username: handle, displayName: handle.capitalized) + } + + private func message( + id: String, + handle: String, + text: String, + at seconds: TimeInterval + ) -> Message { + Message( + id: id, + author: user(handle), + text: text, + createdAt: Date(timeIntervalSince1970: seconds), + updatedAt: Date(timeIntervalSince1970: seconds), + visibility: .public, + digCount: 0, + didDig: false, + repostCount: 0 + ) + } + + private func row(_ id: String, _ fields: [String: ListCellValue]) -> ListRow { + ListRow(id: id, listID: "L1", fields: fields) + } + + // MARK: - Documents + + func test_givenDocumentWithTitleAndBody_whenRendered_thenEmitsHeadingThenBody() { + // Given a long-form document whose body is already Markdown. + let doc = Document( + id: "d1", + title: "Release Notes", + body: DocumentBody(markdown: "## v1\n\n- Fixed things"), + updatedAt: Date(timeIntervalSince1970: 0) + ) + + // When + let md = exporter.markdown(for: doc) + + // Then — H1 title first, body preserved verbatim (not escaped). + XCTAssertTrue(md.hasPrefix("# Release Notes\n"), "title heading missing: \(md)") + XCTAssertTrue(md.contains("## v1"), "body markdown should pass through verbatim") + XCTAssertTrue(md.contains("- Fixed things")) + } + + func test_givenDocumentWithEmptyTitle_whenRendered_thenUsesUntitled() { + let doc = Document(id: "d1", title: "", updatedAt: Date(timeIntervalSince1970: 0)) + let md = exporter.markdown(for: doc) + XCTAssertTrue(md.hasPrefix("# Untitled\n"), "empty title should fall back to Untitled: \(md)") + } + + func test_givenDocumentWithEmptyBody_whenRendered_thenOnlyHeadingRemains() { + let doc = Document(id: "d1", title: "Empty", body: .empty, updatedAt: Date(timeIntervalSince1970: 0)) + let md = exporter.markdown(for: doc) + XCTAssertEqual(md, "# Empty\n", "an empty body should leave just the heading") + } + + // MARK: - Threads + + func test_givenThreadRootOnly_whenRendered_thenNoRepliesSection() { + // Given a root post with no replies. + let root = message(id: "m1", handle: "alice", text: "Original post", at: 100) + + // When + let md = exporter.markdown(forThreadRoot: root, replies: []) + + // Then + XCTAssertTrue(md.contains("# Thread")) + XCTAssertTrue(md.contains("**@alice**"), "author handle should be bolded") + XCTAssertTrue(md.contains("Original post")) + XCTAssertFalse(md.contains("## Replies"), "no replies section when there are no replies") + } + + func test_givenRepliesOutOfOrder_whenRendered_thenSortedByCreatedAtAscending() { + // Given the root and two replies passed newest-first. + let root = message(id: "m1", handle: "alice", text: "Root", at: 100) + let late = message(id: "m3", handle: "carol", text: "LATE_REPLY", at: 300) + let early = message(id: "m2", handle: "bob", text: "EARLY_REPLY", at: 200) + + // When — deliberately pass out of chronological order. + let md = exporter.markdown(forThreadRoot: root, replies: [late, early]) + + // Then — replies section exists and the earlier reply renders first. + XCTAssertTrue(md.contains("## Replies")) + let earlyIdx = try? XCTUnwrap(md.range(of: "EARLY_REPLY")).lowerBound + let lateIdx = try? XCTUnwrap(md.range(of: "LATE_REPLY")).lowerBound + XCTAssertNotNil(earlyIdx); XCTAssertNotNil(lateIdx) + if let earlyIdx, let lateIdx { + XCTAssertLessThan(earlyIdx, lateIdx, "earlier reply must render before the later one") + } + // Replies render as block quotes. + XCTAssertTrue(md.contains("> EARLY_REPLY"), "reply body should be quoted") + } + + // MARK: - Lists (table conversion) + + func test_givenSchemaDSL_whenRenderingList_thenColumnsFollowSchemaOrder() { + // Given a list whose schema declares Title before Year. + let input = MarkdownExporter.ListInput( + title: "Films", + description: "Sci-fi", + schemaDSL: "Title:text, Year:number", + rows: [row("r1", ["Title": .string("Dune"), "Year": .int(1965)])] + ) + + // When + let md = exporter.markdown(forList: input) + + // Then — heading, italic description, schema-ordered header + separator + row. + XCTAssertTrue(md.contains("# Films")) + XCTAssertTrue(md.contains("_Sci-fi_"), "description should render italic") + XCTAssertTrue(md.contains("| Title | Year |"), "columns follow schema order: \(md)") + XCTAssertTrue(md.contains("| --- | --- |")) + XCTAssertTrue(md.contains("| Dune | 1965 |")) + } + + func test_givenNoSchema_whenRenderingList_thenColumnsAreSortedUnionOfRowKeys() { + // Given no schema — columns must be derived from the rows. + let input = MarkdownExporter.ListInput( + title: "Ad hoc", + description: nil, + schemaDSL: nil, + rows: [ + row("r1", ["b": .string("2"), "a": .string("1")]), + row("r2", ["c": .string("3")]) + ] + ) + + // When + let md = exporter.markdown(forList: input) + + // Then — union of keys, deterministically sorted: a, b, c. + XCTAssertTrue(md.contains("| a | b | c |"), "fallback columns should be sorted union: \(md)") + } + + func test_givenCellWithPipeAndNewline_whenRenderingList_thenValueIsEscaped() { + // Given a value containing a pipe and a newline that would break a table. + let input = MarkdownExporter.ListInput( + title: "Escapes", + description: nil, + schemaDSL: "Note:text", + rows: [row("r1", ["Note": .string("a|b\nc")])] + ) + + // When + let md = exporter.markdown(forList: input) + + // Then — pipe backslash-escaped, newline collapsed to a space. + XCTAssertTrue(md.contains("| a\\|b c |"), "cell should be pipe-escaped and newline-collapsed: \(md)") + } + + func test_givenEmptyRows_whenRenderingList_thenHeaderOnlyTable() { + let input = MarkdownExporter.ListInput( + title: "Empty", + description: nil, + schemaDSL: "Title:text", + rows: [] + ) + let md = exporter.markdown(forList: input) + XCTAssertTrue(md.contains("| Title |")) + XCTAssertTrue(md.contains("| --- |")) + // No data rows beyond the header + separator. + let dataLines = md.split(separator: "\n").filter { $0.hasPrefix("|") } + XCTAssertEqual(dataLines.count, 2, "header + separator only, no data rows") + } + + func test_givenNoColumnsDerivable_whenRenderingList_thenExplanatoryLine() { + let input = MarkdownExporter.ListInput( + title: "Barren", + description: nil, + schemaDSL: nil, + rows: [] + ) + let md = exporter.markdown(forList: input) + XCTAssertTrue(md.contains("_No columns defined._"), "empty list should explain, not render an empty table") + XCTAssertFalse(md.contains("| --- |")) + } + + func test_givenMultipleLists_whenRendered_thenSeparatedByHorizontalRule() { + let a = MarkdownExporter.ListInput(title: "A", description: nil, schemaDSL: "X:text", rows: []) + let b = MarkdownExporter.ListInput(title: "B", description: nil, schemaDSL: "Y:text", rows: []) + let md = exporter.markdown(forLists: [a, b]) + XCTAssertTrue(md.contains("# A")) + XCTAssertTrue(md.contains("# B")) + XCTAssertTrue(md.contains("\n---\n"), "lists should be separated by a horizontal rule") + } + + // MARK: - Helpers (columns / escaping) + + func test_givenSchemaWithWhitespaceAndBareNames_whenParsingColumns_thenTrimmedNamesInOrder() { + // Given a messy DSL (extra spaces, a bare name with no type). + let cols = MarkdownExporter.columns(fromSchemaDSL: " First : text , Second ,Third:number", rows: []) + XCTAssertEqual(cols, ["First", "Second", "Third"], "names trimmed, types ignored, order preserved") + } + + func test_givenEmptySchemaString_whenParsingColumns_thenFallsBackToRowKeys() { + let rows = [row("r1", ["k": .string("v")])] + let cols = MarkdownExporter.columns(fromSchemaDSL: "", rows: rows) + XCTAssertEqual(cols, ["k"], "an empty schema string must fall back to row keys") + } + + func test_givenBackslashAndPipe_whenEscapingCell_thenBothEscaped() { + XCTAssertEqual(MarkdownExporter.escapeCell("a\\b|c"), "a\\\\b\\|c") + } +} From 73b4acc11b17f200fbc876ce3198931add108bdd Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:27:34 -0700 Subject: [PATCH 05/10] docs: reconcile parity gaps, blockages, and feature-status with shipped code Adds feature-gaps.md (parity review) and feature-blockages.md (backend asks, reconciled against blocker-prompts.md). Corrects 5 stale feature-status.md limitations (scheduled cancel/reschedule, watcher/org invite-by-handle, cross-post readiness, following scope) and de-stales two Help Book pages + regenerates the help index. Verified against current code. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../en.lproj/InterlinedList.helpindex | Bin 11919 -> 11919 bytes .../en.lproj/pgs/getting-started.html | 2 +- .../en.lproj/pgs/troubleshooting.html | 2 +- docs/user/feature-status.md | 12 +- feature-blockages.md | 98 ++++++++++++++++ feature-gaps.md | 109 ++++++++++++++++++ 6 files changed, 215 insertions(+), 8 deletions(-) create mode 100644 feature-blockages.md create mode 100644 feature-gaps.md diff --git a/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/InterlinedList.helpindex b/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/InterlinedList.helpindex index 50f68ba47774e8a68c6cf8bf3cc922d2a9000a10..ee66953350a34921d9635047089938308f80b897 100644 GIT binary patch delta 314 zcmeB=?T_6c%gP*m@!e(x*1wDp274RpWL6;yW)24S$qho0ldX7pCbRKrFtahRPM#y+ z$jr*XGTD$Xl9`2pdGY~4OJ-&UrpXRMw#-ZnjFSrl{Wjm`V`5@vWo3XUVquv4m)DSq zjcsziU>6fJ^W@)xPD~selkJ7HV;()2tnEX*rZSpjEt<7%AIc$^rfMyX^ OslASg(PZ*h-B|ztNKtwK delta 293 zcmZvTJxjw-7=_<^-+OOTNlH@%KOi4X-9%7ush~BB=puq5q!4isaj2*u{Rs|TI-E{| ztD9QM;^4nW9ymzhc8vy!qTfeUXZe=AEVEeju&7E4Lvjbz5T*JZ6~e3eR} zSzTvi2wzUhwoUod?6b)GQ|~tB!>*8c3=fb6CCt@Y6D(+-ikL|xwGhp^g_+PnC+-M^ zVVtVMX&eSuLgO{q5(cM!zu?F}5*AE!SMWYM##vm#pmri`OviiqX1#5&5w-u93lCB( I;b-yY7yF(~K>z>% diff --git a/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/getting-started.html b/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/getting-started.html index 6c1554a..5ac8a36 100644 --- a/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/getting-started.html +++ b/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/getting-started.html @@ -51,7 +51,7 @@

Coming in a future update

  • Composing posts, replies, reposts, and "I Dig!" reactions.
  • Creating and editing your own lists.
  • Documents with offline editing and sync.
  • -
  • Notifications, organizations, scheduled posts, cross-posting, media attachments, and CSV exports.
  • +
  • Notifications, organizations, scheduled posts, media attachments, and CSV exports.
  • Back to Help home

    diff --git a/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/troubleshooting.html b/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/troubleshooting.html index 4d0dc1b..d6a98ca 100644 --- a/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/troubleshooting.html +++ b/App/Resources/InterlinedList.help/Contents/Resources/en.lproj/pgs/troubleshooting.html @@ -52,7 +52,7 @@

    Known limits in this release

  • Following and unfollowing users; follower and following lists; mutual follows; private-account requests.
  • Notifications (sidebar badge, tray, and system notifications).
  • Organizations and member management.
  • -
  • Media attachments, scheduled posts, and cross-posting to Mastodon, Bluesky, and LinkedIn.
  • +
  • Media attachments and scheduled posts.
  • OAuth identity linking (GitHub, Mastodon, Bluesky, LinkedIn).
  • CSV exports.
  • Settings polish: email change, account deletion, avatar upload.
  • diff --git a/docs/user/feature-status.md b/docs/user/feature-status.md index e5597cd..6a7409d 100644 --- a/docs/user/feature-status.md +++ b/docs/user/feature-status.md @@ -18,8 +18,8 @@ This page summarizes what the InterlinedList macOS app can do today and what is ## Limits worth knowing about today - **Profiles without public messages.** The current release builds a profile from the user's most recent public message. Users who have never posted publicly cannot be shown as a profile yet — you see a "no public messages yet" empty state. This is expected, not an error; it lifts when richer profile data is available. -- **Following scope on the timeline.** The timeline scope picker offers All and Mine today; a Following scope (timeline filtered to accounts you follow) is coming in a future update — the social roster and follow actions shipped with M5, but the timeline-side filter is not wired yet. -- **Watcher invites on lists.** You can rename roles or remove existing watchers on any list you own, but inviting a new user by handle is coming in a future update — the backend lookup endpoint the share sheet needs is not yet available. +- **Following scope on the timeline.** The timeline scope picker now offers All, Mine, and Following. Following (a timeline filtered to accounts you follow) is UI-wired but not yet data-backed: until the backend ships a following-feed endpoint, selecting Following shows a "Following feed coming soon" empty state rather than posts. All and Mine work today; Following becomes live once the endpoint lands. +- **Inviting watchers to a list.** On any list you own you can invite a new watcher by their `@handle`: the Add Watcher sheet looks the person up, shows the matched user, and adds them with the role you choose (Viewer, Editor, or Owner). You can still change roles or remove existing watchers at any time. - **Connections graph layout.** The list-connections graph currently uses a stable radial arrangement. An animated force-directed layout will land in a follow-up. - **GitHub-backed list refresh is manual.** Use the toolbar Refresh button on a GitHub-sourced list to pull the latest rows. Automatic background refresh will arrive in a later update. - **"Save to my lists" copies metadata only.** From a public list, the Save action creates an owned list with the same title, description, and schema, but does not yet copy the rows. Row-level cloning lands when the backend ships its clone endpoint. @@ -31,10 +31,10 @@ This page summarizes what the InterlinedList macOS app can do today and what is - **Follow-button initial state.** When you open another user's profile the **Follow** button needs a round-trip to learn whether you already follow them; for a moment after opening the profile the button stays hidden. This is intentional — showing "Follow" against a user you already follow would be a wrong default. - **Notification deep-linking is minimal in v1.** Clicking a system notification brings InterlinedList forward; routing to the specific message, list, or profile each notification refers to lands in a follow-up. Open the in-app Notifications tab to navigate to the related content. - **Linking other accounts happens in your browser.** From **Settings > Linked accounts**, choosing **Link account** for GitHub, Mastodon, Bluesky, or LinkedIn opens the linking page in your default browser, where you sign in and approve the connection on the InterlinedList website. The app does not complete the link in-app yet — native in-app linking is coming in a future update once the service supports a callback the app can handle. After you finish in the browser, return to the Linked accounts pane and it refreshes to show the new connection. (For Mastodon you are asked for your instance domain first.) -- **Cross-posting has no per-platform result summary yet.** When you turn on cross-posting to Mastodon, Bluesky, or LinkedIn while composing, the post is sent with those targets, but the app cannot yet show a per-platform "posted ✓ / failed" sheet after publishing — the service does not return that breakdown today. A status summary lands in a future update once it does. -- **Per-platform cross-post readiness is only known for LinkedIn.** The composer can tell whether LinkedIn cross-posting is configured and reflect that in the toggle, but it cannot yet detect readiness for Bluesky or Mastodon (per instance) before you post — so an unconfigured platform is only discovered when the post is sent. Pre-flight readiness for Bluesky and Mastodon is coming in a future update. -- **Scheduled posts are read-only for now.** The **Scheduled** sidebar section lists posts you have scheduled for later, but you cannot cancel or reschedule one from the app yet — editing a scheduled post before it publishes is coming in a future update once the service exposes cancel / reschedule. -- **Adding an organization member uses a user id, not a handle.** On an organization you own, you can add a member by entering their user id and pick a role, and you can change roles or remove existing members. Searching for a person by their `@handle` to add them is coming in a future update — it needs the same handle-lookup endpoint the list-sharing invite flow is waiting on. +- **Cross-posting shows a per-platform result summary.** When you turn on cross-posting to Mastodon, Bluesky, or LinkedIn while composing, publishing the post opens a per-platform result summary sheet. Each target is shown as posted (with a link to the published message), pending, or failed, and failures include a human-readable reason — for example "Rate limited — try again later.", "Auth expired — re-link your account.", or "Blocked by content policy." Close the summary with **Done**. +- **Cross-post readiness is checked before you post.** When you enable a cross-post toggle in the composer, the app pre-flights whether that platform is configured — for Bluesky and per-instance Mastodon as well as LinkedIn. If a toggled platform isn't set up, the composer turns the toggle back off and shows an inline hint, so you find out before publishing rather than after. +- **You can cancel or reschedule scheduled posts.** The **Scheduled** sidebar section lists posts you have queued for later. Right-click a row to **Reschedule…** it (pick a new publish date and time) or **Cancel Post** (which deletes the scheduled post). Both actions update the list immediately and roll back if the change can't be saved. +- **Adding an organization member by @handle or user id.** On an organization you own, you can add a member by their `@handle` — the app looks the person up and shows a confirmation row before adding them with the role you pick — or by entering their user id directly. You can also change roles or remove existing members. ## Related pages diff --git a/feature-blockages.md b/feature-blockages.md new file mode 100644 index 0000000..51243df --- /dev/null +++ b/feature-blockages.md @@ -0,0 +1,98 @@ +# Backend Blockages — macOS app parity work + +**For:** the InterlinedList backend / API team · **From:** macOS native app · **Date:** 2026-07-18 + +> **Read this first.** The repo already has a canonical, maintained backend tracker: +> **[`blocker-prompts.md`](blocker-prompts.md)** (last updated 2026-07-08, with a `P1-A…P3-E` +> ID scheme and paste-ready prompts). **That file is the source of truth.** This file is a +> *parity-driven supplement*: it captures backend asks surfaced by the 2026-07-18 feature-parity +> pass that are **not yet in** `blocker-prompts.md`, plus corrections to it. These should be +> folded into `blocker-prompts.md` — I did not edit that file unilaterally. + +Verified against current code, not memory. Ordered by impact. + +--- + +## A. New backend asks (not yet tracked in `blocker-prompts.md`) + +### NB-1 — Following / home feed endpoint · impact: **HIGH** + +- **Site promise:** feed filtering "all public, followed-only, or mixed." +- **Today:** `TimelineScope.following` is fully wired through the UI (All / Mine / **Following** segmented picker), but `MessagesService.timeline` (`MessagesService.swift:348`) **short-circuits `.following` to an empty page** — "The Following feed has no API endpoint yet" — so it renders a "coming soon" empty state instead of a spinner or error. +- **API ask:** a followed-accounts timeline, e.g. `GET /api/messages?scope=following` (or `GET /api/feed/following`), same paginated envelope as `GET /api/messages`. The client then flips one `if` and it works. + +### NB-2 — GitHub issue create / comment + labels / assignees · impact: **HIGH** (largest genuine gap) + +- **Site promise:** "GitHub repository issue syncing," "create and comment on issues within platform," "automatic label and assignee pulling." +- **Today:** the client has only a read-only `GitHubListSource` projection refreshed manually. Note **`blocker-prompts.md` P3-C already tracks the refresh *metadata*** (`lastRefreshedAt`, `refreshStatus`, `githubSource` on POST) — but P3-C does **not** cover issue **writes** or **labels/assignees**. Those are net-new. +- **API ask (extends P3-C — needs a contract):** + 1. Expose issue **labels and assignees** as fields on GitHub-sourced list rows. + 2. Endpoints to **create an issue** and **comment on an issue** from a synced list. + 3. (P3-C already covers) auto/scheduled refresh + `githubSource` on create. +- The single largest user-visible feature the app can't offer; entirely backend-gated. + +### NB-3 — Markdown export format / per-item export · impact: medium + +- **Site promise:** "Markdown export for lists, documents, and message threads" + full data portability. +- **Today:** `/api/exports/*` is **CSV only** (4 endpoints), no format negotiation, no per-document/thread export. The client now renders Markdown **itself** from domain models (`MarkdownExporter` in `InterlinedDomain`), so this is a nice-to-have, not a hard blocker. +- **API ask:** (1) a format param, e.g. `GET /api/exports/lists?format=md` (or `Accept: text/markdown`); (2) per-resource export: `GET /api/documents/[id]/export?format=md`, `GET /api/messages/[id]/thread/export?format=md`, `GET /api/lists/[id]/export?format=md`. Server-side rendering avoids the client's N+1 refetch for large accounts. + +### NB-4 — Schema DSL token spec for `select` and `markdown` · impact: medium (verification) + +- **Context:** the client just added `select` and `markdown` schema field types (shipped, 25 tests green). The API has never enumerated the DSL type taxonomy (pre-existing `API-backend-prompts-to-build.md` item 2.2). The schema crosses the wire as a DSL string round-tripping through the client's `SchemaDSL` only, so these are **unverified against the server:** + 1. **`select` token + option grammar.** Client chose `Field:select(a|b|c)` (token `select`, `(...)` wrapper, `|` delimiter). Confirm the token, the delimiter, and whether the server **persists and re-emits the option list verbatim** on `GET .../schema` (or normalizes/strips it). + 2. **`markdown` cell wire shape.** Client assumes a plain JSON string of raw Markdown (existing `ListJSONValue` string case, no codec change). Confirm. + 3. **`email` acceptance.** The site's advertised set is `text, number, date, select, boolean, url, markdown` (no `email`), but the client keeps an `email` token. Confirm the server accepts `email` on `PUT .../schema`. +- If tokens/delimiter differ, the single client change-point is `SchemaDSL.serialize`/`splitTypeSpec` + `SchemaFieldType`. + +### NB-5 — Link-preview `fetchStatus` semantics · impact: low (verification) + +- **Context:** the server already returns link-preview metadata (`MessageDTO.linkMetadata.links[]` with `title`, `description`, `imageUrl`, `platform`, `fetchStatus`); the client now renders preview cards from it (this was **not** blocked). The client's render gate is forward-compatible (renders when a ready-ish `fetchStatus` OR a title/image is present). +- **API ask:** document the `fetchStatus` value set — which string means "ready" vs "pending" vs "failed" — so the client can tighten its gate to the authoritative token(s). + +### NB-6 — List "Save to my lists" row cloning · impact: low + +- **Site promise:** save/copy public lists. +- **Today:** `ListDetailViewModel.saveToMyLists` copies **metadata + schema only** (documented "deliberate degradation — no clone endpoint"). Not in `blocker-prompts.md`. +- **API ask:** `POST /api/lists/[id]/clone` (or a rows-copy on save) that duplicates rows into the new owned list. + +--- + +## B. Already tracked in `blocker-prompts.md` — still open, relevant to parity + +Pointers only; prompts already exist in that file. + +- **P1-E — Native OAuth identity-linking callback** (⛔ still the reason linking opens the browser). This is the one OAuth blocker; the client is fully designed for `ASWebAuthenticationSession` once a custom-scheme callback or bearer `…/link` endpoint exists. +- **P2-C — Typed notification kinds + `routePath`** — unblocks richer notification deep-linking (today deep-linking just brings the app forward). +- **P2-D — Machine-readable upload limits** (`GET /api/limits`) — client has limits hard-coded. +- **P2-E — Privacy Policy + Support pages** — required for any future App Store submission. +- **P3-A/B/D/E** — sync ETag, `folderId` on sync, token revocation, universal rate-limit headers (internal robustness, not user-facing parity). + +--- + +## C. Corrections — previously suspected blocked, actually DONE + +The 2026-07-18 first-pass draft of this file (and `feature-gaps.md`) flagged these as backend-blocked. **They are already implemented and wired** — do NOT spend backend time on them. Root cause of the error: the older project memory predated the NW-1…NW-6 completion recorded in `blocker-prompts.md` (2026-07-08), and the user-facing `docs/user/feature-status.md` still describes them as pending (that doc is stale — see the parity report). + +| Was flagged | Reality | Evidence | +| --- | --- | --- | +| Scheduled post cancel/reschedule blocked | **Done** (P1-C / NW-3) | `ScheduledPostsViewModel.cancel()` + `.reschedule()`, "Cancel Post"/"Reschedule…" UI, `MessagesService.cancelScheduled`/`reschedule` | +| Bluesky/Mastodon cross-post pre-flight readiness blocked | **Done** (P1-D / NW-4) | `ComposerViewModel.blueskyNotConfigured` / `mastodonNotConfigured` | +| Watcher-invite-by-handle blocked | **Done** (P1-A / NW-6) | `WatchersViewModel.lookupAndAdd(handle:)` → `UserService.lookupUser` | +| Org-member-add-by-handle blocked | **Done** (P1-A) | `OrgMembersViewModel.addMemberByHandle` / `foundUser` | +| Cross-post per-platform result summary blocked | **Done** (P1-B / NW-2) | `CrossPostResultsSheet` wired in `ComposerWindowView` | + +--- + +### Triage summary (new asks only) + +| ID | Ask | Impact | +| --- | --- | --- | +| NB-1 | Following feed endpoint | **High** | +| NB-2 | GitHub issue write + labels/assignees (extends P3-C) | **High** | +| NB-3 | Markdown export format / per-item | Medium | +| NB-4 | Schema DSL `select`/`markdown` token spec | Medium | +| NB-5 | Link-preview `fetchStatus` doc | Low | +| NB-6 | List clone-with-rows | Low | + +**NB-1 and NB-2 unlock the most parity.** Everything else is smaller or documentation-only. Recommend merging NB-1…NB-6 into `blocker-prompts.md` under the `P#` scheme. diff --git a/feature-gaps.md b/feature-gaps.md new file mode 100644 index 0000000..8fda925 --- /dev/null +++ b/feature-gaps.md @@ -0,0 +1,109 @@ +# Feature Parity Gaps — macOS app vs. interlinedlist.com + +**Reviewed:** 2026-07-18 · **Branch:** `dev` · **Basis:** [interlinedlist.com/features](https://interlinedlist.com/features) cross-referenced against the App target, `InterlinedDomain`, `InterlinedKit`, and `InterlinedPersistence`. + +> **2026-07-18 implementation update.** Most of the client-closable gaps in §1 were **built this session** (schema `select`/`markdown`, link previews, document templates, and the Markdown-export engine). The build is green: **App 375 tests / 0 failures, InterlinedDomain 475 / 0** (Kit 224, Persistence 120 unchanged). Backend-blocked items are tracked in **[`feature-blockages.md`](feature-blockages.md)**, which is reconciled against the canonical **[`blocker-prompts.md`](blocker-prompts.md)**. Several items the first draft called "blocked" turned out to be **already shipped** — see §2b. + +## TL;DR + +The native app is **at or very near full parity**. Every documented API endpoint is implemented (98/98 per [`docs/api-coverage.md`](docs/api-coverage.md)), and the NW-1…NW-6 backend items are done (per `blocker-prompts.md`). What remained were a handful of surface features; the client-closable ones are now largely done, and the genuine gaps are backend-gated (following feed, GitHub issue writes, OAuth callback). + +### Parity scorecard + +| Site feature area | Status | +| --- | --- | +| Compose: Markdown, images **+ video**, scheduling, threading, digs/reactions | ✅ Shipped | +| Cross-post Mastodon/Bluesky/LinkedIn, per-message targets, **result summary**, **pre-flight readiness** | ✅ Shipped | +| Structured lists: CRUD, nesting, connections graph, watchers (**invite by @handle**) | ✅ Shipped | +| Schema DSL field types (`text, number, date, select, boolean, url, markdown`) | ✅ **`select` + `markdown` added this session** (kept `email`) | +| List row views: cards / **grid** / ERD | ⚠️ Cards shipped; **grid = §1.2 (queued, now unblocked)**; ERD = scope TBD | +| Documents: Markdown editor, folders, image upload, public/private, offline sync | ✅ Shipped | +| Document **templates** | ✅ **Added this session** (Blank / Meeting Notes / Daily Log / PRD) | +| Rich link previews on posts | ✅ **Added this session** (server metadata → preview card) | +| Exports: **Markdown** for lists / documents / threads | ⚙️ **Engine shipped** (`MarkdownExporter`); UI wiring = §1.3 remaining | +| Exports: CSV | ✅ Shipped | +| Scheduled post edit/cancel | ✅ Shipped (was mis-listed as blocked — see §2b) | +| Organizations & roles (**add member by @handle**) | ✅ Shipped | +| Feed filtering: all / mine / following | ⚠️ All/Mine shipped; **Following UI-wired but empty** — backend feed endpoint pending (§2) | +| GitHub sync: issue create/comment, labels/assignees | ❌ Backend-gated (§2 / `feature-blockages.md` NB-2) | +| Native OAuth account linking | ❌ Backend-gated (§2 / `blocker-prompts.md` P1-E) | +| AI writing assist | "Coming Soon" on site too — not a gap | + +--- + +## 1. Client-closable gaps + +**Project constraint for all work here:** the App target is **SwiftUI-only — no AppKit / `NSViewRepresentable`** without asking; follow the MVVM + `InterlinedDomain` service seam and add BDD-style tests (`AppTests/` conventions). + +### 1.1 — Schema DSL: `select` + `markdown` field types ✅ DONE (this session) + +`SchemaFieldType` gained `.select` (ordered options via `SchemaField.enumValues`, DSL `Field:select(a|b|c)`) and `.markdown` (long text, Textual preview in `RowInspectorView`). Editor + row cells + DSL parser/serializer updated; 25 new tests. **Backend confirmation needed** on the exact `select` token/delimiter and `email` acceptance — see `feature-blockages.md` NB-4. + +### 1.2 — List row views: grid, then ERD ⚠️ QUEUED (now unblocked) + +Rows still render as a **card list only**. The `Table` deferral reason (macOS 14.4 `TableColumnForEach`) is **moot** — the app targets macOS 15. Agent A has vacated the Lists files, so this is ready to build. + +> **Prompt for Claude:** "Add a cards/grid view switcher to `ListDetailView`, persisted per list. Grid = SwiftUI `Table` with one `TableColumn` per `ListSchema` field, typed by `SchemaFieldType` (now safe on macOS 15 — update the stale card-only comment). Drive columns from the schema, reuse `ListRowsViewModel` for paging. BDD-test column derivation + empty-schema fallback. Do NOT build ERD yet — first open the web app's ERD for a sample list and report what 'ERD' means there (schema field graph vs. the existing list-to-list connection graph) so we scope it correctly." + +### 1.3 — Markdown export ⚙️ ENGINE DONE; UI remaining + +`MarkdownExporter` (`InterlinedDomain`) renders documents, threads, and lists-as-tables (pipe/newline-escaped), 15 tests. **Remaining:** wire it into the UI. The `/api/exports/*` endpoints are CSV-only, so bulk export re-fetches via domain services client-side (see `feature-blockages.md` NB-3 for the server-side ask). Per-item entry points (document toolbar, thread menu, list menu) can now be added since the Lists/Docs/Timeline files are free. + +> **Prompt for Claude:** "Wire `MarkdownExporter` into the UI. Add a `.md` `FileDocument` variant next to `ExportDocument`. In the central Export sheet, add a 'Markdown' format for **My Lists** (fetch owned lists + rows via `ListsServicing`, render with `markdown(forLists:)`) — inject `lists` into `ExportViewModel`, update `ExportView` + `ExportViewModelTests` accordingly. Then add per-item 'Export as Markdown' commands: document editor toolbar (`markdown(for:)`), message-thread menu (`markdown(forThreadRoot:replies:)`). BDD-test the view-model orchestration (empty account, pagination, error)." + +### 1.4 — Document templates ✅ DONE (this session) + +`DocumentTemplate.builtIn` catalog (Blank / Meeting Notes / Daily Log / PRD) + "New from Template…" command (⇧⌥⌘N) and picker sheet, seeding `DocumentBody.markdown` through the existing create path; 12 tests. Client-side (no templates endpoint). + +### 1.5 — Rich link previews ✅ DONE (this session) + +**Was not blocked** — the server already returned `linkMetadata`. Added `Message.linkPreviews` + mapper (drops unparseable URLs) + a tappable `LinkPreviewCardView` in the timeline; 16 tests. Render gate is forward-compatible pending `fetchStatus` docs (`feature-blockages.md` NB-5). + +--- + +## 2. Genuinely backend-blocked (cannot close from the client) + +Full asks + prompts in **[`feature-blockages.md`](feature-blockages.md)** (reconciled with `blocker-prompts.md`). Summary: + +- **Following feed** (NB-1 · HIGH) — `TimelineScope.following` is UI-wired but `MessagesService.timeline` short-circuits to empty; no endpoint exists. +- **GitHub issue create/comment + labels/assignees** (NB-2 · HIGH) — the largest genuine gap. `blocker-prompts.md` P3-C tracks only refresh *metadata*; issue writes are net-new. +- **Native OAuth account linking** (`blocker-prompts.md` P1-E) — linking opens the browser; no native callback/scheme or bearer link endpoint. +- **Markdown export format** (NB-3 · med) — client works around it; server format negotiation would be more efficient. +- **List "save to my lists" row cloning** (NB-6 · low) — copies metadata+schema only. + +## 2b. Corrections — thought blocked, actually already shipped + +The first draft (and the older project memory) listed these as blocked. They are **done** — verified in code. The user-facing `docs/user/feature-status.md` still described some as pending; that staleness is being corrected in a parallel docs pass. + +| Feature | Evidence it's shipped | +| --- | --- | +| Scheduled post cancel/reschedule | `ScheduledPostsViewModel.cancel()`/`reschedule()` + UI (P1-C / NW-3) | +| Cross-post pre-flight readiness (Bluesky/Mastodon) | `ComposerViewModel.blueskyNotConfigured`/`mastodonNotConfigured` (P1-D / NW-4) | +| Watcher invite by @handle | `WatchersViewModel.lookupAndAdd` → `UserService.lookupUser` (P1-A / NW-6) | +| Org member add by @handle | `OrgMembersViewModel.addMemberByHandle` (P1-A) | +| Cross-post per-platform result summary | `CrossPostResultsSheet` wired in `ComposerWindowView` (P1-B / NW-2) | + +--- + +## 3. Already at parity (do not re-implement) + +Composer (Markdown, image+video, scheduling incl. cancel/reschedule, per-message cross-post + result sheet + readiness); digs/reposts/threads; Documents (editor, folders, image upload, public/private, offline sync, templates); link previews; Social (follow/unfollow, requests, mutuals, notifications, dock badge); Organizations (CRUD, members incl. by-handle, roles); Lists (CRUD, schema DSL incl. select/markdown, nesting, connections graph, watchers incl. invite-by-handle); CSV export; feed All/Mine. + +--- + +## 4. Ship (M7) — gates release, not parity + +- **Sparkle** — `SparkleController` + SPM dep in place; verify update-check call, `SUFeedURL`, `SUPublicEDKey`, key generation. +- **Appcast hosting** — needs distribution infra on interlinedlist.com. +- **Notarization** — `scripts/notarize.sh`/`package-pkg.sh` need Developer ID certs. +- Target: notarized **`.pkg`** (closed-source private repo; no `LICENSE`). +- App Store extras tracked in `blocker-prompts.md`: **P2-E** (Privacy/Support pages), **P2-D** (limits endpoint). + +--- + +## Suggested order of remaining work + +1. **§1.3 Markdown export UI** — engine is done; wire the Export sheet + per-item commands. +2. **§1.2 Grid view** — unblocked by macOS 15; scope ERD separately after checking the web app. +3. **Backend:** hand `feature-blockages.md` NB-1 (following feed) and NB-2 (GitHub issues) to the API team — they unlock the most parity. +4. **§4 ship** — Sparkle finalization + notarization. From 0916b33b5159eb7fe923bd2a97fca92ea07c77fc Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:30:07 -0700 Subject: [PATCH 06/10] docs: fold parity backend asks into blocker-prompts.md Adds P1-G (following feed), P1-H (GitHub issue writes), P2-F (Markdown export), P2-G (schema DSL select/markdown), P3-F (link-preview fetchStatus), P3-G (list clone), P3-H (PATCH/PUT) with paste-ready prompts. feature-blockages.md is now a concise index into the canonical blocker-prompts.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- blocker-prompts.md | 70 +++++++++++++++++++++++++++++ feature-blockages.md | 105 +++++++++---------------------------------- 2 files changed, 90 insertions(+), 85 deletions(-) diff --git a/blocker-prompts.md b/blocker-prompts.md index daef42d..293ba57 100644 --- a/blocker-prompts.md +++ b/blocker-prompts.md @@ -30,6 +30,13 @@ Last probed: 2026-07-07. Updated: 2026-07-08. | P3-C | `lastRefreshedAt` + `refreshStatus` on lists + `githubSource` on POST | ❌ Open | | P3-D | Token revocation + `GET /api/user/sessions` | ❌ Open | | P3-E | `RateLimit-*` headers universally | 🟡 Partial — on 2 routes only; macOS nil-guards correctly | +| P1-G | Following / home feed endpoint | ❌ Open — client UI wired, short-circuits to empty (added 2026-07-18) | +| P1-H | GitHub issue create/comment + labels/assignees | ❌ Open — largest parity gap; extends P3-C (added 2026-07-18) | +| P2-F | Markdown export format / per-item export | ❌ Open — client renders MD itself for now (added 2026-07-18) | +| P2-G | Schema DSL `select`/`markdown` token spec | ❌ Open — client shipped both; needs token/validation confirm (added 2026-07-18) | +| P3-F | Link-preview `fetchStatus` value docs | ❌ Open — client renders previews; gate is forward-compatible (added 2026-07-18) | +| P3-G | List "save to my lists" clone-with-rows | ❌ Open — copies metadata+schema only (added 2026-07-18) | +| P3-H | Message edit verb: `PATCH` (docs) vs `PUT` (client) | ❌ Open — reconcile reference and client (added 2026-07-18) | --- @@ -228,10 +235,73 @@ Add `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` to every authent --- +## Open — Web-parity pass (added 2026-07-18) + +Surfaced by the 2026-07-18 feature-parity review against interlinedlist.com/features. P1-G and P1-H unlock the most user-visible parity. + +### P1-G — Following / home feed endpoint + +**Status:** The macOS client's `TimelineScope.following` is fully UI-wired (All / Mine / Following picker), but `MessagesService.timeline` short-circuits `.following` to an empty page because no endpoint exists — it shows a "coming soon" empty state. + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). Add a followed-accounts timeline feed. Preferred: extend `GET /api/messages` with `?scope=following` (or add `GET /api/feed/following`), returning only messages authored by accounts the caller follows, using the **same paginated envelope** as `GET /api/messages` (same `limit`/`offset`/`hasMore` shape). Bearer auth. Document it. The macOS client already has the UI wired and flips one branch to consume it. + +### P1-H — GitHub issue create/comment + labels/assignees + +**Status:** The client has a read-only `GitHubListSource` projection refreshed manually. P3-C covers refresh *metadata*; this covers issue **writes** and **labels/assignees**, which the site advertises ("create and comment on issues within platform," "automatic label and assignee pulling"). + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). Extend GitHub-synced lists so the macOS client can reach the advertised issue features. Define and document: (1) issue **labels** and **assignees** as fields on GitHub-sourced list rows; (2) an endpoint to **create a GitHub issue** from a synced list; (3) an endpoint to **comment on an issue**. Provide the request/response shapes. Coordinate with P3-C (refresh metadata + `githubSource` on create). + +### P2-F — Markdown export format / per-item export + +**Status:** `/api/exports/*` returns CSV only (no format negotiation, no per-item endpoints). The macOS client now renders Markdown itself from domain models (`MarkdownExporter`), which requires N+1 refetches for bulk export. + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). Add Markdown export. (1) A format param on the four export endpoints, e.g. `GET /api/exports/lists?format=md` (or `Accept: text/markdown`), returning Markdown. (2) Per-resource export endpoints: `GET /api/documents/[id]/export?format=md`, `GET /api/messages/[id]/thread/export?format=md`, `GET /api/lists/[id]/export?format=md`. Lists should render as Markdown tables ("structured table conversion"). + +### P2-G — Schema DSL `select`/`markdown` token spec + +**Status:** The macOS client shipped `select` and `markdown` schema field types (2026-07-18). The DSL type taxonomy has never been enumerated by the API (this is the old `API-backend-prompts-to-build.md` item 2.2). The schema crosses the wire as a DSL string round-tripping through the client's parser only, so these client assumptions are **unverified**. + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). Document and confirm the list schema DSL type tokens the macOS client now emits: (1) **`select`** with an ordered option set — the client uses `Field:select(a|b|c)` (token `select`, `(...)` wrapper, `|` delimiter). Confirm the token, the delimiter, and whether the server persists and re-emits the option list verbatim on `GET .../schema` or normalizes it. (2) **`markdown`** — confirm the cell value is a plain JSON string of raw Markdown. (3) Confirm the server accepts the existing **`email`** token on `PUT .../schema`. + +### P3-F — Link-preview `fetchStatus` value docs + +**Status:** The server returns `linkMetadata.links[].fetchStatus`; the client now renders preview cards and gates on a forward-compatible set of "ready-ish" values plus title/image presence. + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). Document the closed value set for `fetchStatus` on message `linkMetadata.links[]` — specifically which value means "preview ready to show" vs. "still fetching" vs. "failed" — so the macOS client can gate rendering on the authoritative token(s). + +### P3-G — List "save to my lists" clone-with-rows + +**Status:** `ListDetailViewModel.saveToMyLists` copies metadata + schema only; a documented degradation because no clone endpoint exists. + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). Add `POST /api/lists/[id]/clone` (or a rows-copy option on save) that duplicates a public list's rows into a new owned list, so "save to my lists" can carry the data, not just the schema. + +### P3-H — Message edit verb reconciliation (`PATCH` vs `PUT`) + +**Status:** The API reference documents `PATCH /api/messages/[id]` for message edit; the shipped macOS client issues `PUT` (both work live). Reference and client disagree. + +**PROMPT:** + +You are working on the InterlinedList API (interlinedlist.com). The API reference documents message edit as `PATCH /api/messages/[id]`, but the macOS client sends `PUT` and it works. Confirm the canonical verb and either update the reference to match the live behavior or document that both are accepted. + +--- + ## Change log | Date | Change | |------|--------| +| 2026-07-18 | Web-parity pass: added P1-G (following feed), P1-H (GitHub issue writes), P2-F (Markdown export), P2-G (schema DSL select/markdown), P3-F (link-preview fetchStatus), P3-G (list clone), P3-H (PATCH/PUT). Confirmed P1-A/P1-C/P1-D still resolved + wired via code (scheduled cancel/reschedule, bluesky/mastodon readiness, watcher/org invite-by-handle). | | 2026-07-08 | Recreated from `API-backend-prompts-to-build.md` + `Backend-Handoff-Prompts.md` (both deleted). Marked P1-A, P1-B, P1-C, P1-D, P2-A resolved — confirmed via live probe and macOS NW features complete. | | 2026-07-07 | Live probe: P2-A resolved, P1-A endpoints exist (not 404), P3-E still absent. | | 2026-07-04 | `Backend-Handoff-Prompts.md` authored with copy-paste prompts. | diff --git a/feature-blockages.md b/feature-blockages.md index 51243df..40492bf 100644 --- a/feature-blockages.md +++ b/feature-blockages.md @@ -2,97 +2,32 @@ **For:** the InterlinedList backend / API team · **From:** macOS native app · **Date:** 2026-07-18 -> **Read this first.** The repo already has a canonical, maintained backend tracker: -> **[`blocker-prompts.md`](blocker-prompts.md)** (last updated 2026-07-08, with a `P1-A…P3-E` -> ID scheme and paste-ready prompts). **That file is the source of truth.** This file is a -> *parity-driven supplement*: it captures backend asks surfaced by the 2026-07-18 feature-parity -> pass that are **not yet in** `blocker-prompts.md`, plus corrections to it. These should be -> folded into `blocker-prompts.md` — I did not edit that file unilaterally. +> **Canonical tracker:** [`blocker-prompts.md`](blocker-prompts.md). The parity asks below were **folded into it** on 2026-07-18 under the `P#` scheme (with paste-ready prompts) — this file is now just a parity-focused index into that tracker. Everything was verified against current code, not memory. -Verified against current code, not memory. Ordered by impact. +## New parity asks (now tracked in `blocker-prompts.md`) ---- - -## A. New backend asks (not yet tracked in `blocker-prompts.md`) - -### NB-1 — Following / home feed endpoint · impact: **HIGH** - -- **Site promise:** feed filtering "all public, followed-only, or mixed." -- **Today:** `TimelineScope.following` is fully wired through the UI (All / Mine / **Following** segmented picker), but `MessagesService.timeline` (`MessagesService.swift:348`) **short-circuits `.following` to an empty page** — "The Following feed has no API endpoint yet" — so it renders a "coming soon" empty state instead of a spinner or error. -- **API ask:** a followed-accounts timeline, e.g. `GET /api/messages?scope=following` (or `GET /api/feed/following`), same paginated envelope as `GET /api/messages`. The client then flips one `if` and it works. - -### NB-2 — GitHub issue create / comment + labels / assignees · impact: **HIGH** (largest genuine gap) - -- **Site promise:** "GitHub repository issue syncing," "create and comment on issues within platform," "automatic label and assignee pulling." -- **Today:** the client has only a read-only `GitHubListSource` projection refreshed manually. Note **`blocker-prompts.md` P3-C already tracks the refresh *metadata*** (`lastRefreshedAt`, `refreshStatus`, `githubSource` on POST) — but P3-C does **not** cover issue **writes** or **labels/assignees**. Those are net-new. -- **API ask (extends P3-C — needs a contract):** - 1. Expose issue **labels and assignees** as fields on GitHub-sourced list rows. - 2. Endpoints to **create an issue** and **comment on an issue** from a synced list. - 3. (P3-C already covers) auto/scheduled refresh + `githubSource` on create. -- The single largest user-visible feature the app can't offer; entirely backend-gated. - -### NB-3 — Markdown export format / per-item export · impact: medium - -- **Site promise:** "Markdown export for lists, documents, and message threads" + full data portability. -- **Today:** `/api/exports/*` is **CSV only** (4 endpoints), no format negotiation, no per-document/thread export. The client now renders Markdown **itself** from domain models (`MarkdownExporter` in `InterlinedDomain`), so this is a nice-to-have, not a hard blocker. -- **API ask:** (1) a format param, e.g. `GET /api/exports/lists?format=md` (or `Accept: text/markdown`); (2) per-resource export: `GET /api/documents/[id]/export?format=md`, `GET /api/messages/[id]/thread/export?format=md`, `GET /api/lists/[id]/export?format=md`. Server-side rendering avoids the client's N+1 refetch for large accounts. - -### NB-4 — Schema DSL token spec for `select` and `markdown` · impact: medium (verification) - -- **Context:** the client just added `select` and `markdown` schema field types (shipped, 25 tests green). The API has never enumerated the DSL type taxonomy (pre-existing `API-backend-prompts-to-build.md` item 2.2). The schema crosses the wire as a DSL string round-tripping through the client's `SchemaDSL` only, so these are **unverified against the server:** - 1. **`select` token + option grammar.** Client chose `Field:select(a|b|c)` (token `select`, `(...)` wrapper, `|` delimiter). Confirm the token, the delimiter, and whether the server **persists and re-emits the option list verbatim** on `GET .../schema` (or normalizes/strips it). - 2. **`markdown` cell wire shape.** Client assumes a plain JSON string of raw Markdown (existing `ListJSONValue` string case, no codec change). Confirm. - 3. **`email` acceptance.** The site's advertised set is `text, number, date, select, boolean, url, markdown` (no `email`), but the client keeps an `email` token. Confirm the server accepts `email` on `PUT .../schema`. -- If tokens/delimiter differ, the single client change-point is `SchemaDSL.serialize`/`splitTypeSpec` + `SchemaFieldType`. - -### NB-5 — Link-preview `fetchStatus` semantics · impact: low (verification) - -- **Context:** the server already returns link-preview metadata (`MessageDTO.linkMetadata.links[]` with `title`, `description`, `imageUrl`, `platform`, `fetchStatus`); the client now renders preview cards from it (this was **not** blocked). The client's render gate is forward-compatible (renders when a ready-ish `fetchStatus` OR a title/image is present). -- **API ask:** document the `fetchStatus` value set — which string means "ready" vs "pending" vs "failed" — so the client can tighten its gate to the authoritative token(s). - -### NB-6 — List "Save to my lists" row cloning · impact: low - -- **Site promise:** save/copy public lists. -- **Today:** `ListDetailViewModel.saveToMyLists` copies **metadata + schema only** (documented "deliberate degradation — no clone endpoint"). Not in `blocker-prompts.md`. -- **API ask:** `POST /api/lists/[id]/clone` (or a rows-copy on save) that duplicates rows into the new owned list. - ---- - -## B. Already tracked in `blocker-prompts.md` — still open, relevant to parity - -Pointers only; prompts already exist in that file. +| Parity ask | Impact | Tracker ID | +| --- | --- | --- | +| Following / home feed endpoint (client UI wired, short-circuits to empty) | **High** | **P1-G** | +| GitHub issue create/comment + labels/assignees (largest gap; extends P3-C) | **High** | **P1-H** | +| Markdown export format / per-item export (client renders MD itself for now) | Medium | **P2-F** | +| Schema DSL `select`/`markdown` token spec (client shipped both; confirm tokens) | Medium | **P2-G** | +| Link-preview `fetchStatus` value docs | Low | **P3-F** | +| List "save to my lists" clone-with-rows | Low | **P3-G** | +| Message edit verb `PATCH` (docs) vs `PUT` (client) | Low | **P3-H** | -- **P1-E — Native OAuth identity-linking callback** (⛔ still the reason linking opens the browser). This is the one OAuth blocker; the client is fully designed for `ASWebAuthenticationSession` once a custom-scheme callback or bearer `…/link` endpoint exists. -- **P2-C — Typed notification kinds + `routePath`** — unblocks richer notification deep-linking (today deep-linking just brings the app forward). -- **P2-D — Machine-readable upload limits** (`GET /api/limits`) — client has limits hard-coded. -- **P2-E — Privacy Policy + Support pages** — required for any future App Store submission. -- **P3-A/B/D/E** — sync ETag, `folderId` on sync, token revocation, universal rate-limit headers (internal robustness, not user-facing parity). +Already tracked and still open, relevant to parity: **P1-E** (native OAuth link callback — the one OAuth blocker), **P2-C** (notification `routePath` for deep-linking), **P2-D** (upload limits), **P2-E** (Privacy/Support pages for App Store). ---- +**Hand-off priority: P1-G and P1-H unlock the most user-visible parity.** -## C. Corrections — previously suspected blocked, actually DONE +## Corrections — thought blocked, actually already shipped -The 2026-07-18 first-pass draft of this file (and `feature-gaps.md`) flagged these as backend-blocked. **They are already implemented and wired** — do NOT spend backend time on them. Root cause of the error: the older project memory predated the NW-1…NW-6 completion recorded in `blocker-prompts.md` (2026-07-08), and the user-facing `docs/user/feature-status.md` still describes them as pending (that doc is stale — see the parity report). +The first-draft blockages list (and the older project memory) wrongly flagged these as backend-gated. **They are shipped and wired** — verified in code, and `blocker-prompts.md` already marks the enabling endpoints resolved (NW-1…NW-6). Do **not** spend backend time here. | Was flagged | Reality | Evidence | | --- | --- | --- | -| Scheduled post cancel/reschedule blocked | **Done** (P1-C / NW-3) | `ScheduledPostsViewModel.cancel()` + `.reschedule()`, "Cancel Post"/"Reschedule…" UI, `MessagesService.cancelScheduled`/`reschedule` | -| Bluesky/Mastodon cross-post pre-flight readiness blocked | **Done** (P1-D / NW-4) | `ComposerViewModel.blueskyNotConfigured` / `mastodonNotConfigured` | -| Watcher-invite-by-handle blocked | **Done** (P1-A / NW-6) | `WatchersViewModel.lookupAndAdd(handle:)` → `UserService.lookupUser` | -| Org-member-add-by-handle blocked | **Done** (P1-A) | `OrgMembersViewModel.addMemberByHandle` / `foundUser` | -| Cross-post per-platform result summary blocked | **Done** (P1-B / NW-2) | `CrossPostResultsSheet` wired in `ComposerWindowView` | - ---- - -### Triage summary (new asks only) - -| ID | Ask | Impact | -| --- | --- | --- | -| NB-1 | Following feed endpoint | **High** | -| NB-2 | GitHub issue write + labels/assignees (extends P3-C) | **High** | -| NB-3 | Markdown export format / per-item | Medium | -| NB-4 | Schema DSL `select`/`markdown` token spec | Medium | -| NB-5 | Link-preview `fetchStatus` doc | Low | -| NB-6 | List clone-with-rows | Low | - -**NB-1 and NB-2 unlock the most parity.** Everything else is smaller or documentation-only. Recommend merging NB-1…NB-6 into `blocker-prompts.md` under the `P#` scheme. +| Scheduled post cancel/reschedule | **Done** (P1-C / NW-3) | `ScheduledPostsViewModel.cancel()`/`.reschedule()` + UI; `MessagesService.cancelScheduled`/`reschedule` | +| Bluesky/Mastodon cross-post readiness | **Done** (P1-D / NW-4) | `ComposerViewModel.blueskyNotConfigured`/`mastodonNotConfigured` | +| Watcher-invite-by-handle | **Done** (P1-A / NW-6) | `WatchersViewModel.lookupAndAdd(handle:)` → `UserService.lookupUser` | +| Org-member-add-by-handle | **Done** (P1-A) | `OrgMembersViewModel.addMemberByHandle` | +| Cross-post per-platform result summary | **Done** (P1-B / NW-2) | `CrossPostResultsSheet` wired in `ComposerWindowView` | From 74ab1f178df077792b565c0fd4afd55cc69dd1cc Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:39:42 -0700 Subject: [PATCH 07/10] feat(export): wire Markdown export into the Export sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an 'Export My Lists as Markdown' path: ExportViewModel paginates owned lists + rows and renders them via MarkdownExporter into a MarkdownFileDocument (.md), surfaced through a second fileExporter and a Markdown button on the Lists row. 4 new tests (happy/empty/failure/pagination). Completes feature-gaps §1.3 (engine + UI). Co-Authored-By: Claude Opus 4.8 (1M context) --- App/Features/Exports/ExportView.swift | 64 ++++++++++++++- App/Features/Exports/ExportViewModel.swift | 90 +++++++++++++++++++++- AppTests/ExportViewModelTests.swift | 90 +++++++++++++++++++++- 3 files changed, 241 insertions(+), 3 deletions(-) diff --git a/App/Features/Exports/ExportView.swift b/App/Features/Exports/ExportView.swift index f72107b..f4e9388 100644 --- a/App/Features/Exports/ExportView.swift +++ b/App/Features/Exports/ExportView.swift @@ -45,7 +45,7 @@ struct ExportView: View { } .task { guard viewModel == nil, let environment else { return } - let vm = ExportViewModel(exportsService: environment.exportsService) + let vm = ExportViewModel(exportsService: environment.exportsService, lists: environment.lists) viewModel = vm if let initialExportType { vm.export(initialExportType) @@ -123,6 +123,20 @@ private struct ExportContentView: View { viewModel.errorMessage = error.localizedDescription } } + .fileExporter( + isPresented: Binding( + get: { viewModel.pendingMarkdownExport != nil }, + set: { if !$0 { viewModel.pendingMarkdownExport = nil } } + ), + document: MarkdownFileDocument(viewModel.pendingMarkdownExport?.text), + contentType: .markdownText, + defaultFilename: viewModel.pendingMarkdownExport?.filename ?? "export" + ) { result in + viewModel.pendingMarkdownExport = nil + if case .failure(let error) = result { + viewModel.errorMessage = error.localizedDescription + } + } } } @@ -143,6 +157,15 @@ private struct ExportRowView: View { .foregroundStyle(.secondary) } Spacer() + if type == .lists { + // Lists also export as Markdown tables ("structured table + // conversion"); other types remain CSV-only for now. + Button("Markdown…") { + viewModel.exportListsAsMarkdown() + } + .disabled(viewModel.isExporting) + .accessibilityLabel("Export My Lists as Markdown") + } Button("Export CSV…") { viewModel.export(type) } @@ -185,3 +208,42 @@ struct ExportDocument: FileDocument { FileWrapper(regularFileWithContents: data) } } + +// MARK: - MarkdownFileDocument + +/// A `FileDocument` wrapping a rendered Markdown string for the `.fileExporter` +/// save panel — the AppKit-free way to save the client-composed Markdown export +/// (feature-gaps.md §1.3) without an `NSSavePanel`. +struct MarkdownFileDocument: FileDocument { + static let readableContentTypes: [UTType] = [.markdownText, .plainText] + + let text: String + + /// Accepts `nil` so the `.fileExporter`'s `document:` parameter is always + /// satisfied even before a Markdown export is pending; `isPresented` gates + /// when the dialog actually appears. + init(_ text: String?) { + self.text = text ?? "" + } + + init(configuration: ReadConfiguration) throws { + if let data = configuration.file.regularFileContents { + self.text = String(decoding: data, as: UTF8.self) + } else { + self.text = "" + } + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: Data(text.utf8)) + } +} + +extension UTType { + /// Markdown (`.md`). Resolves to the system-declared `net.daringfireball.markdown` + /// on macOS 15, falling back to a dynamic type conforming to plain text so the + /// save panel still applies a `.md` extension. + static var markdownText: UTType { + UTType(filenameExtension: "md", conformingTo: .plainText) ?? .plainText + } +} diff --git a/App/Features/Exports/ExportViewModel.swift b/App/Features/Exports/ExportViewModel.swift index 7443c7c..a3f1f7e 100644 --- a/App/Features/Exports/ExportViewModel.swift +++ b/App/Features/Exports/ExportViewModel.swift @@ -63,6 +63,19 @@ import InterlinedDomain /// clears it when the dialog resolves (success or cancel). var pendingExport: CSVExport? = nil + /// A rendered Markdown export waiting to be saved (feature-gaps.md §1.3). + /// The `/api/exports/*` endpoints are CSV-only, so Markdown is composed + /// client-side from domain models via `MarkdownExporter` (see + /// `feature-blockages.md` P2-F for the server-side ask). Drives a second + /// `.fileExporter` in the view. + var pendingMarkdownExport: MarkdownExport? = nil + + /// A rendered Markdown document plus the filename stem for the save panel. + struct MarkdownExport: Equatable { + let filename: String + let text: String + } + /// Non-nil when a service call failed. Displayed as an amber-tinted /// banner in `ExportView`. Cleared at the start of the next export /// attempt. @@ -71,9 +84,17 @@ import InterlinedDomain // MARK: - Init private let exportsService: ExportsServicing + private let lists: ListsServicing + private let markdownExporter = MarkdownExporter() + + /// Page sizes for the bulk Markdown-lists fetch. Kept modest so a large + /// account paginates rather than requesting everything at once. + private static let listsPageSize = 50 + private static let rowsPageSize = 100 - init(exportsService: ExportsServicing) { + init(exportsService: ExportsServicing, lists: ListsServicing) { self.exportsService = exportsService + self.lists = lists } // MARK: - Intent @@ -111,4 +132,71 @@ import InterlinedDomain } } } + + /// Exports all of the user's owned lists as a single Markdown document + /// ("structured table conversion" — feature-gaps.md §1.3). Fetches every + /// owned list and its rows client-side (the export API is CSV-only), then + /// renders them with `MarkdownExporter`. No-op while another export is in + /// flight. On success `pendingMarkdownExport` becomes non-nil and the view + /// triggers the save panel; on failure `errorMessage` is populated. + func exportListsAsMarkdown() { + guard !isExporting else { return } + isExporting = true + activeExport = .lists + errorMessage = nil + + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.isExporting = false } + do { + let inputs = try await self.collectListInputs() + let text = self.markdownExporter.markdown(forLists: inputs) + self.pendingMarkdownExport = MarkdownExport( + filename: "interlinedlist-lists", + text: text + ) + } catch { + self.errorMessage = error.localizedDescription + } + } + } + + /// Pages through every owned list, pairing each with its fully-paginated + /// rows, and projects them into `MarkdownExporter.ListInput` values. + private func collectListInputs() async throws -> [MarkdownExporter.ListInput] { + var inputs: [MarkdownExporter.ListInput] = [] + var offset = 0 + while true { + let page = try await lists.myLists(limit: Self.listsPageSize, offset: offset) + for list in page.lists { + let rows = try await collectRows(listId: list.id) + inputs.append( + MarkdownExporter.ListInput( + title: list.title, + description: list.description, + schemaDSL: list.schemaDescription, + rows: rows + ) + ) + } + // Stop when the server reports no more pages, or when the cursor + // fails to advance (defensive: never spin on a stuck offset). + guard page.hasMore, let next = page.nextOffset, next > offset else { break } + offset = next + } + return inputs + } + + /// Pages through every row of one owned list. + private func collectRows(listId: String) async throws -> [ListRow] { + var rows: [ListRow] = [] + var offset = 0 + while true { + let page = try await lists.rows(of: listId, limit: Self.rowsPageSize, offset: offset) + rows.append(contentsOf: page.rows) + guard page.hasMore, let next = page.nextOffset, next > offset else { break } + offset = next + } + return rows + } } diff --git a/AppTests/ExportViewModelTests.swift b/AppTests/ExportViewModelTests.swift index 7cee739..2d8127d 100644 --- a/AppTests/ExportViewModelTests.swift +++ b/AppTests/ExportViewModelTests.swift @@ -15,10 +15,25 @@ final class ExportViewModelTests: XCTestCase { private func makeSUT() -> (ExportViewModel, StubExportsService) { let service = StubExportsService() - let vm = ExportViewModel(exportsService: service) + let vm = ExportViewModel(exportsService: service, lists: StubListsService()) return (vm, service) } + /// SUT variant that exposes the lists stub for the Markdown-export path. + private func makeMarkdownSUT() -> (ExportViewModel, StubListsService) { + let lists = StubListsService() + let vm = ExportViewModel(exportsService: StubExportsService(), lists: lists) + return (vm, lists) + } + + private func ownedList(id: String, title: String, schema: String?) -> OwnedList { + OwnedList(id: id, title: title, description: nil, schemaDescription: schema) + } + + private func row(_ id: String, _ fields: [String: ListCellValue]) -> ListRow { + ListRow(id: id, listID: nil, fields: fields) + } + // MARK: - Happy path — each export type reaches the service and sets pendingExport func test_givenService_whenExportMessages_thenPendingExportSet() async throws { @@ -144,4 +159,77 @@ final class ExportViewModelTests: XCTestCase { XCTAssertNil(vm.errorMessage) XCTAssertNotNil(vm.pendingExport) } + + // MARK: - Markdown export (feature-gaps.md §1.3) + + func test_givenOwnedListsWithRows_whenExportListsAsMarkdown_thenRendersTable() async throws { + // Given one owned list with one row (no further pages). + let (vm, lists) = makeMarkdownSUT() + await lists.enqueueMyLists(success: .init(lists: [ownedList(id: "L1", title: "Films", schema: "Title:text, Year:number")], hasMore: false, nextOffset: nil)) + await lists.enqueueRows(success: .init(rows: [row("r1", ["Title": .string("Dune"), "Year": .int(1965)])], hasMore: false, nextOffset: nil)) + + // When + vm.exportListsAsMarkdown() + try await Task.sleep(nanoseconds: 100_000_000) + + // Then — a Markdown document with the list heading and a schema-ordered table row. + let export = try XCTUnwrap(vm.pendingMarkdownExport) + XCTAssertEqual(export.filename, "interlinedlist-lists") + XCTAssertTrue(export.text.contains("# Films"), export.text) + XCTAssertTrue(export.text.contains("| Title | Year |")) + XCTAssertTrue(export.text.contains("| Dune | 1965 |")) + XCTAssertNil(vm.errorMessage) + XCTAssertFalse(vm.isExporting) + } + + func test_givenNoOwnedLists_whenExportListsAsMarkdown_thenPendingSetWithEmptyText() async throws { + // Given — boundary: the account owns no lists. + let (vm, lists) = makeMarkdownSUT() + await lists.enqueueMyLists(success: .empty) + + vm.exportListsAsMarkdown() + try await Task.sleep(nanoseconds: 100_000_000) + + // Then — an empty document is still a valid export; no rows call was made. + let export = try XCTUnwrap(vm.pendingMarkdownExport) + XCTAssertTrue(export.text.isEmpty) + XCTAssertNil(vm.errorMessage) + let rowsCalls = await lists.recorded.contains { if case .rows = $0.kind { return true } else { return false } } + XCTAssertFalse(rowsCalls) + } + + func test_givenMyListsFailure_whenExportListsAsMarkdown_thenErrorSet() async throws { + let (vm, lists) = makeMarkdownSUT() + await lists.enqueueMyLists(failure: TestError.upstream("session expired")) + + vm.exportListsAsMarkdown() + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertNil(vm.pendingMarkdownExport) + XCTAssertNotNil(vm.errorMessage) + XCTAssertFalse(vm.isExporting) + } + + func test_givenPaginatedListsAndRows_whenExportListsAsMarkdown_thenAllPagesFetched() async throws { + // Given two pages of lists, the first list itself spanning two row pages. + let (vm, lists) = makeMarkdownSUT() + await lists.enqueueMyLists(success: .init(lists: [ownedList(id: "L1", title: "A", schema: "K:text")], hasMore: true, nextOffset: 1)) + await lists.enqueueRows(success: .init(rows: [row("r1", ["K": .string("one")])], hasMore: true, nextOffset: 1)) + await lists.enqueueRows(success: .init(rows: [row("r2", ["K": .string("two")])], hasMore: false, nextOffset: nil)) + await lists.enqueueMyLists(success: .init(lists: [ownedList(id: "L2", title: "B", schema: "K:text")], hasMore: false, nextOffset: nil)) + await lists.enqueueRows(success: .init(rows: [row("r3", ["K": .string("three")])], hasMore: false, nextOffset: nil)) + + vm.exportListsAsMarkdown() + try await Task.sleep(nanoseconds: 150_000_000) + + // Then — both lists and all rows appear; two myLists calls were made. + let export = try XCTUnwrap(vm.pendingMarkdownExport) + XCTAssertTrue(export.text.contains("# A")) + XCTAssertTrue(export.text.contains("# B")) + for value in ["one", "two", "three"] { + XCTAssertTrue(export.text.contains("| \(value) |"), "missing row \(value): \(export.text)") + } + let myListsCalls = await lists.recorded.filter { if case .myLists = $0.kind { return true } else { return false } } + XCTAssertEqual(myListsCalls.count, 2) + } } From 0b6d39d0d8d224d0848ac004ec288fd0aba3551f Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:39:42 -0700 Subject: [PATCH 08/10] feat(lists): render owned-list rows as a real Table grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the List-of-cells fallback in tableMode with a real SwiftUI Table using TableColumnForEach for one typed column per schema field (now valid at the macOS 15 deployment target — the 14.4 fallback comment was stale). Pagination becomes a Load-More footer since Table has no per-row appearance hook; cards mode keeps scroll-to-load. Completes feature-gaps §1.2 (grid; ERD still scoped separately). Co-Authored-By: Claude Opus 4.8 (1M context) --- App/Features/Lists/ListRowsView.swift | 81 ++++++++++++++++----------- 1 file changed, 48 insertions(+), 33 deletions(-) diff --git a/App/Features/Lists/ListRowsView.swift b/App/Features/Lists/ListRowsView.swift index 5d5b470..d8fe95e 100644 --- a/App/Features/Lists/ListRowsView.swift +++ b/App/Features/Lists/ListRowsView.swift @@ -87,46 +87,61 @@ struct ListRowsView: View { @ViewBuilder private func tableMode(viewModel: ListRowsViewModel) -> some View { - // Dynamic-column `TableColumnForEach` is macOS 14.4+, the - // project targets 14.0. Fall back to a `List` of cells until - // the deployment target moves — the data shape and the - // selection/delete plumbing are identical to a real table. - List(selection: $selection) { - ForEach(viewModel.rows) { row in - rowSummary(row: row, columns: viewModel.columns) - .tag(row.id) - .onAppear { - if shouldLoadMore(row, in: viewModel.rows) { - Task { await viewModel.loadMore() } - } + // Real SwiftUI `Table` with one typed column per schema field. The + // dynamic-column `TableColumnForEach` needs macOS 14.4+; the app now + // targets macOS 15, so the earlier `List`-of-cells fallback is retired. + // `Table` has no per-row appearance hook, so pagination is a "Load more" + // footer here (cards mode keeps scroll-to-load). + let columns = effectiveColumns(viewModel) + VStack(spacing: 0) { + Table(viewModel.rows, selection: $selection) { + TableColumnForEach(columns, id: \.self) { column in + TableColumn(column) { (row: ListRow) in + Text(row.fields[column]?.displayText ?? "") + .lineLimit(2) } + } + } + .onChange(of: selection) { _, newSelection in + // Sync single-selection back into the view model so the + // RowInspector can render. + viewModel.selectedRowID = newSelection.first + } + + if viewModel.hasMore { + Divider() + Button { + Task { await viewModel.loadMore() } + } label: { + if viewModel.isLoading { + ProgressView().controlSize(.small) + } else { + Text("Load More Rows") + } + } + .buttonStyle(.borderless) + .disabled(viewModel.isLoading) + .padding(8) + .frame(maxWidth: .infinity) + .accessibilityLabel("Load more rows") } - } - .onChange(of: selection) { _, newSelection in - // Sync single-selection back into the view model so the - // RowInspector can render. - viewModel.selectedRowID = newSelection.first } } - @ViewBuilder - private func rowSummary(row: ListRow, columns: [String]) -> some View { - let keys = columns.isEmpty ? row.fields.keys.sorted() : columns - HStack(alignment: .firstTextBaseline, spacing: 12) { - ForEach(keys, id: \.self) { key in - VStack(alignment: .leading, spacing: 1) { - Text(key) - .font(.ilMono(9)) - .foregroundStyle(.secondary) - Text(row.fields[key]?.displayText ?? "") - .font(.ilBody()) - .lineLimit(2) - } - .frame(minWidth: 100, alignment: .leading) + /// Ordered column set for the table: the schema-derived columns when + /// present, else the sorted union of keys across loaded rows so a + /// schemaless list still renders a sensible grid. + private func effectiveColumns(_ viewModel: ListRowsViewModel) -> [String] { + if !viewModel.columns.isEmpty { return viewModel.columns } + var seen = Set() + var ordered: [String] = [] + for row in viewModel.rows { + for key in row.fields.keys.sorted() where !seen.contains(key) { + seen.insert(key) + ordered.append(key) } - Spacer() } - .accessibilityElement(children: .combine) + return ordered } @ViewBuilder From 9228514610bd273e25616acef42cdf1b66142cea Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:41:08 -0700 Subject: [PATCH 09/10] =?UTF-8?q?docs:=20mark=20feature-gaps=20=C2=A71.2?= =?UTF-8?q?=20and=20=C2=A71.3=20shipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- feature-gaps.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/feature-gaps.md b/feature-gaps.md index 8fda925..125807e 100644 --- a/feature-gaps.md +++ b/feature-gaps.md @@ -16,11 +16,11 @@ The native app is **at or very near full parity**. Every documented API endpoint | Cross-post Mastodon/Bluesky/LinkedIn, per-message targets, **result summary**, **pre-flight readiness** | ✅ Shipped | | Structured lists: CRUD, nesting, connections graph, watchers (**invite by @handle**) | ✅ Shipped | | Schema DSL field types (`text, number, date, select, boolean, url, markdown`) | ✅ **`select` + `markdown` added this session** (kept `email`) | -| List row views: cards / **grid** / ERD | ⚠️ Cards shipped; **grid = §1.2 (queued, now unblocked)**; ERD = scope TBD | +| List row views: cards / **grid** / ERD | ✅ Cards + **grid (real Table)** shipped; ERD = scope TBD | | Documents: Markdown editor, folders, image upload, public/private, offline sync | ✅ Shipped | | Document **templates** | ✅ **Added this session** (Blank / Meeting Notes / Daily Log / PRD) | | Rich link previews on posts | ✅ **Added this session** (server metadata → preview card) | -| Exports: **Markdown** for lists / documents / threads | ⚙️ **Engine shipped** (`MarkdownExporter`); UI wiring = §1.3 remaining | +| Exports: **Markdown** for lists / documents / threads | ✅ Engine + **My Lists → Markdown** UI shipped; per-doc/thread buttons = follow-up | | Exports: CSV | ✅ Shipped | | Scheduled post edit/cancel | ✅ Shipped (was mis-listed as blocked — see §2b) | | Organizations & roles (**add member by @handle**) | ✅ Shipped | @@ -39,17 +39,17 @@ The native app is **at or very near full parity**. Every documented API endpoint `SchemaFieldType` gained `.select` (ordered options via `SchemaField.enumValues`, DSL `Field:select(a|b|c)`) and `.markdown` (long text, Textual preview in `RowInspectorView`). Editor + row cells + DSL parser/serializer updated; 25 new tests. **Backend confirmation needed** on the exact `select` token/delimiter and `email` acceptance — see `feature-blockages.md` NB-4. -### 1.2 — List row views: grid, then ERD ⚠️ QUEUED (now unblocked) +### 1.2 — List row views: grid, then ERD ✅ GRID DONE (this session); ERD scoped separately -Rows still render as a **card list only**. The `Table` deferral reason (macOS 14.4 `TableColumnForEach`) is **moot** — the app targets macOS 15. Agent A has vacated the Lists files, so this is ready to build. +`ListRowsView.tableMode` (owned lists) now renders a **real SwiftUI `Table`** with one typed column per schema field via `TableColumnForEach` (valid at the macOS 15 target; the stale 14.4 fallback comment is removed). Pagination is a Load-More footer (Table has no per-row appearance hook); cards mode keeps scroll-to-load. Columns come from the schema, falling back to the sorted union of row keys. -> **Prompt for Claude:** "Add a cards/grid view switcher to `ListDetailView`, persisted per list. Grid = SwiftUI `Table` with one `TableColumn` per `ListSchema` field, typed by `SchemaFieldType` (now safe on macOS 15 — update the stale card-only comment). Drive columns from the schema, reuse `ListRowsViewModel` for paging. BDD-test column derivation + empty-schema fallback. Do NOT build ERD yet — first open the web app's ERD for a sample list and report what 'ERD' means there (schema field graph vs. the existing list-to-list connection graph) so we scope it correctly." +**ERD remains open** — before building, confirm what "ERD" means in the web app (schema field graph vs. the existing list-to-list connection graph) so it's scoped correctly. The public `ListDetailView` (read-only browse) still shows cards only; a grid there is a smaller follow-up. -### 1.3 — Markdown export ⚙️ ENGINE DONE; UI remaining +### 1.3 — Markdown export ✅ DONE (this session) -`MarkdownExporter` (`InterlinedDomain`) renders documents, threads, and lists-as-tables (pipe/newline-escaped), 15 tests. **Remaining:** wire it into the UI. The `/api/exports/*` endpoints are CSV-only, so bulk export re-fetches via domain services client-side (see `feature-blockages.md` NB-3 for the server-side ask). Per-item entry points (document toolbar, thread menu, list menu) can now be added since the Lists/Docs/Timeline files are free. +`MarkdownExporter` (`InterlinedDomain`) renders documents, threads, and lists-as-tables (pipe/newline-escaped), 15 tests. The Export sheet now offers **"Export My Lists as Markdown"** — `ExportViewModel` paginates owned lists + rows and renders them into a `MarkdownFileDocument` (`.md`) via a second `fileExporter`; 4 view-model tests. The `/api/exports/*` endpoints are CSV-only, so this composes client-side (server-side ask = `blocker-prompts.md` P2-F). -> **Prompt for Claude:** "Wire `MarkdownExporter` into the UI. Add a `.md` `FileDocument` variant next to `ExportDocument`. In the central Export sheet, add a 'Markdown' format for **My Lists** (fetch owned lists + rows via `ListsServicing`, render with `markdown(forLists:)`) — inject `lists` into `ExportViewModel`, update `ExportView` + `ExportViewModelTests` accordingly. Then add per-item 'Export as Markdown' commands: document editor toolbar (`markdown(for:)`), message-thread menu (`markdown(forThreadRoot:replies:)`). BDD-test the view-model orchestration (empty account, pagination, error)." +**Follow-up (engine already supports it):** per-item "Export as Markdown" affordances — document editor toolbar (`markdown(for:)`) and message-thread menu (`markdown(forThreadRoot:replies:)`). ### 1.4 — Document templates ✅ DONE (this session) @@ -103,7 +103,9 @@ Composer (Markdown, image+video, scheduling incl. cancel/reschedule, per-message ## Suggested order of remaining work -1. **§1.3 Markdown export UI** — engine is done; wire the Export sheet + per-item commands. -2. **§1.2 Grid view** — unblocked by macOS 15; scope ERD separately after checking the web app. -3. **Backend:** hand `feature-blockages.md` NB-1 (following feed) and NB-2 (GitHub issues) to the API team — they unlock the most parity. +1. **Backend:** hand `blocker-prompts.md` **P1-G** (following feed) and **P1-H** (GitHub issue writes) to the API team — they unlock the most parity. +2. **ERD view** — confirm what "ERD" means in the web app, then build (grid is done). +3. **Follow-ups:** per-document / per-thread "Export as Markdown" buttons (engine ready); grid on the public `ListDetailView`. 4. **§4 ship** — Sparkle finalization + notarization. + +_Done this session: §1.1 schema select/markdown, §1.2 grid, §1.3 Markdown export (engine + UI), §1.4 templates, §1.5 link previews._ From b6476b452af14a87f70d5d26c5596bdd8c8aef08 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:41:55 -0700 Subject: [PATCH 10/10] docs: use asterisk emphasis in feature-gaps summary Co-Authored-By: Claude Opus 4.8 (1M context) --- feature-gaps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature-gaps.md b/feature-gaps.md index 125807e..5115154 100644 --- a/feature-gaps.md +++ b/feature-gaps.md @@ -108,4 +108,4 @@ Composer (Markdown, image+video, scheduling incl. cancel/reschedule, per-message 3. **Follow-ups:** per-document / per-thread "Export as Markdown" buttons (engine ready); grid on the public `ListDetailView`. 4. **§4 ship** — Sparkle finalization + notarization. -_Done this session: §1.1 schema select/markdown, §1.2 grid, §1.3 Markdown export (engine + UI), §1.4 templates, §1.5 link previews._ +*Done this session: §1.1 schema select/markdown, §1.2 grid, §1.3 Markdown export (engine + UI), §1.4 templates, §1.5 link previews.*