Skip to content
Merged
110 changes: 110 additions & 0 deletions App/Features/Documents/DocumentTemplatePickerView.swift
Original file line number Diff line number Diff line change
@@ -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.
}
}
25 changes: 25 additions & 0 deletions App/Features/Documents/DocumentsListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions App/Features/Documents/DocumentsRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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: {
Expand Down
64 changes: 63 additions & 1 deletion App/Features/Exports/ExportView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
}
}

Expand All @@ -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)
}
Expand Down Expand Up @@ -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
}
}
90 changes: 89 additions & 1 deletion App/Features/Exports/ExportViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
Loading
Loading