diff --git a/README.md b/README.md index 63a93ff..04d3e04 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,20 @@ swift build swift run BugbookCLI agent --help ``` +### 4. Install `bugbook` For Local Development + +```bash +swift run BugbookCLI install --force +``` + +Or use the helper script: + +```bash +./scripts/install-bugbook-cli.sh +``` + +By default this installs a symlink at `~/.local/bin/bugbook`. If that directory is not on your `PATH`, the command prints the shell snippet needed to add it. + ## Usage ### CLI (agent workflow) @@ -71,8 +85,32 @@ Read and update notes: ```bash swift run BugbookCLI page list --workspace "~/Library/Application Support/Bugbook" swift run BugbookCLI page get "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" +swift run BugbookCLI page get "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --raw +swift run BugbookCLI page get "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --raw --include-internal-comments +swift run BugbookCLI page get "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --blocks +swift run BugbookCLI page get "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --block-id path:3 --raw +swift run BugbookCLI page headings "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" +swift run BugbookCLI page format "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --style commonmark --dry-run --output summary +swift run BugbookCLI page compact "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --output summary +swift run BugbookCLI page ensure-block-ids "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --blocks +swift run BugbookCLI page strip-block-ids "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" +swift run BugbookCLI page get "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --section-line 110 cat updated-note.md | swift run BugbookCLI page update "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --content-file - +cat updated-note.md | swift run BugbookCLI page update "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --content-file - --output summary +cat roadmap.md | swift run BugbookCLI page update "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --section "Roadmap" --content-file - +cat roadmap.md | swift run BugbookCLI page update "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --section "Roadmap" --create-section --section-level 2 --content-file - +cat roadmap.md | swift run BugbookCLI page update "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --section "Roadmap" --create-section --section-level 2 --content-file - --dry-run +cat block.md | swift run BugbookCLI page update "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --block-id path:3 --content-file - +cat text.txt | swift run BugbookCLI page update "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --block-id path:3 --text-file - +cat sibling.md | swift run BugbookCLI page update "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --block-id path:3 --append-file - --dry-run cat snippet.md | swift run BugbookCLI page update "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" --append-file - +swift run BugbookCLI block list "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" +swift run BugbookCLI block get "Bugbook Strategy" path:3 --workspace "~/Library/Application Support/Bugbook" --raw +cat block.md | swift run BugbookCLI block replace "Bugbook Strategy" path:3 --workspace "~/Library/Application Support/Bugbook" --content-file - +cat text.txt | swift run BugbookCLI block update-text "Bugbook Strategy" path:3 --workspace "~/Library/Application Support/Bugbook" --text-file - +cat sibling.md | swift run BugbookCLI block insert "Bugbook Strategy" path:3 --workspace "~/Library/Application Support/Bugbook" --after --content-file - --dry-run +swift run BugbookCLI block move "Bugbook Strategy" path:3 path:7 --workspace "~/Library/Application Support/Bugbook" --before --dry-run +swift run BugbookCLI block delete "Bugbook Strategy" path:3 --workspace "~/Library/Application Support/Bugbook" --dry-run swift run BugbookCLI backlinks "Bugbook Strategy" --workspace "~/Library/Application Support/Bugbook" swift run BugbookCLI page embed-database "Bugbook Strategy" "Bugbook Strategy Board" --workspace "~/Library/Application Support/Bugbook" swift run BugbookCLI board create "Bugbook Strategy Board" --workspace "~/Library/Application Support/Bugbook" --group-name "Phase" --column "Now" --column "Next" --column "Later" --view list --view calendar --embed-in "Bugbook Strategy" @@ -87,6 +125,29 @@ swift run BugbookCLI skill list --workspace "~/Library/Application Support/Bugbo swift run BugbookCLI skill get "research-summarizer" --workspace "~/Library/Application Support/Bugbook" ``` +Notes: +- `page get --raw` prints clean markdown by default; add `--include-internal-comments` for the literal stored file. +- `page get --blocks` returns parsed markdown blocks plus document metadata. +- `page get --block-id ` narrows reads to one markdown block by stable UUID or `path:0/1`. +- `page headings` returns heading titles, levels, and line numbers for section targeting. +- `page format --style bugbook|commonmark` rewrites a page using either Bugbook's dense block format or a CommonMark-style layout with structural blank lines. +- `page format --style commonmark` now strips persisted block IDs and converts Bugbook-only block syntax into portable approximations: toggles become `
`, columns are flattened sequentially with thematic breaks, database embeds become labeled text, and page-link blocks become relative markdown links when they resolve uniquely in the workspace or plain text when they do not. +- `page compact` rewrites a page through Bugbook's block serializer and removes empty paragraph gaps, which is useful when a note has accumulated extra blank lines. +- `page compact` is the shortcut for `page format --style bugbook`. +- `page ensure-block-ids` persists unique stable block IDs and repairs duplicate persisted IDs when needed; add `--blocks` if you want the parsed block list in the response. +- `page strip-block-ids` removes persisted block ID comments from a page and restores clean markdown storage. +- `page get --section ""` or `--section-line N` narrows reads to one heading section and fails if the selector does not match. +- `page update --section ""` or `--section-line N` scopes replace/prepend/append operations to a heading body. +- `page update --block-id ` scopes replace/prepend/append operations to one block without polluting a clean note with persisted block IDs. +- `page update --block-id --text-file` updates only the selected block's text and preserves its markdown type. +- `page update --section "" --create-section` appends the section if it is missing. +- `page update --dry-run` previews the post-edit page plus structured line changes without writing anything. +- `page create` and `page update` accept `--output summary` when you want a compact write result instead of the full page payload. +- `block list`, `block get`, `block replace`, `block update-text`, `block insert`, `block move`, and `block delete` provide a dedicated block-level command surface on top of the same selectors used by `page get --block-id`. +- Row `get` now matches `query` by returning friendly property names and display values by default; add `--fields` to narrow the payload and `--raw-properties` to include schema IDs and stored option IDs. +- `query --fields` returns friendly property names and display values by default; add `--raw-properties` when you also need schema IDs and stored option IDs. +- Row `create`, `update`, `query --filter`, `query --sort`, and `query --fields` accept friendly property names in addition to schema IDs. + Initialize workspace files: ```bash diff --git a/Sources/Bugbook/Lib/MarkdownBlockParser.swift b/Sources/Bugbook/Lib/MarkdownBlockParser.swift index 519c637..ead6b9e 100644 --- a/Sources/Bugbook/Lib/MarkdownBlockParser.swift +++ b/Sources/Bugbook/Lib/MarkdownBlockParser.swift @@ -85,12 +85,71 @@ enum MarkdownBlockParser { return [Block(type: .paragraph)] } - let lines = markdown.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var lines = markdown.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + if lines.count > 1, lines.last == "" { + lines.removeLast() + } var blocks: [Block] = [] var i = 0 + var pendingBlockID: UUID? + var pendingColors: (BlockColor, BlockColor)? + + func makeBlock( + type: BlockType = .paragraph, + text: String = "", + headingLevel: Int = 1, + listDepth: Int = 0, + isChecked: Bool = false, + language: String = "", + imageSource: String = "", + imageAlt: String = "", + imageWidth: Int? = nil, + databasePath: String = "", + pageLinkName: String = "", + children: [Block] = [], + columnIndex: Int = 0, + isExpanded: Bool = true + ) -> Block { + let colors = pendingColors ?? (.default, .default) + let block = Block( + id: pendingBlockID ?? UUID(), + type: type, + text: text, + headingLevel: headingLevel, + listDepth: listDepth, + isChecked: isChecked, + language: language, + imageSource: imageSource, + imageAlt: imageAlt, + imageWidth: imageWidth, + databasePath: databasePath, + pageLinkName: pageLinkName, + textColor: colors.0, + backgroundColor: colors.1, + children: children, + columnIndex: columnIndex, + isExpanded: isExpanded + ) + pendingBlockID = nil + pendingColors = nil + return block + } while i < lines.count { let line = lines[i] + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if let blockID = parseBlockIDComment(line) { + pendingBlockID = blockID + i += 1 + continue + } + + if let colors = parseColorComment(line) { + pendingColors = colors + i += 1 + continue + } // Code fence if line.hasPrefix("```") { @@ -105,41 +164,41 @@ enum MarkdownBlockParser { codeLines.append(lines[i]) i += 1 } - blocks.append(Block(type: .codeBlock, text: codeLines.joined(separator: "\n"), language: language)) + blocks.append(makeBlock(type: .codeBlock, text: codeLines.joined(separator: "\n"), language: language)) continue } // Horizontal rule if isHorizontalRule(line) { - blocks.append(Block(type: .horizontalRule, text: line)) + blocks.append(makeBlock(type: .horizontalRule, text: line)) i += 1 continue } // Heading if let (level, text) = parseHeading(line) { - blocks.append(Block(type: .heading, text: text, headingLevel: level)) + blocks.append(makeBlock(type: .heading, text: text, headingLevel: level)) i += 1 continue } // Task item (must come before bullet) if let (depth, checked, text) = parseTaskItem(line) { - blocks.append(Block(type: .taskItem, text: text, listDepth: depth, isChecked: checked)) + blocks.append(makeBlock(type: .taskItem, text: text, listDepth: depth, isChecked: checked)) i += 1 continue } // Bullet list item if let (depth, text) = parseBulletItem(line) { - blocks.append(Block(type: .bulletListItem, text: text, listDepth: depth)) + blocks.append(makeBlock(type: .bulletListItem, text: text, listDepth: depth)) i += 1 continue } // Numbered list item if let (depth, text) = parseNumberedItem(line) { - blocks.append(Block(type: .numberedListItem, text: text, listDepth: depth)) + blocks.append(makeBlock(type: .numberedListItem, text: text, listDepth: depth)) i += 1 continue } @@ -152,21 +211,21 @@ enum MarkdownBlockParser { } else { text = String(line.dropFirst(1)) } - blocks.append(Block(type: .blockquote, text: text)) + blocks.append(makeBlock(type: .blockquote, text: text)) i += 1 continue } // Image if let (alt, src, width) = parseImage(line) { - blocks.append(Block(type: .image, imageSource: src, imageAlt: alt, imageWidth: width)) + blocks.append(makeBlock(type: .image, imageSource: src, imageAlt: alt, imageWidth: width)) i += 1 continue } // Database embed if let path = parseDatabaseEmbed(line) { - blocks.append(Block(type: .databaseEmbed, databasePath: path)) + blocks.append(makeBlock(type: .databaseEmbed, databasePath: path)) i += 1 continue } @@ -174,17 +233,17 @@ enum MarkdownBlockParser { // Wiki link (page link) — line is exactly [[Page Name]] if let name = parseWikiLink(line) { if let dbPath = parseDatabaseSchemePath(name) { - blocks.append(Block(type: .databaseEmbed, databasePath: dbPath)) + blocks.append(makeBlock(type: .databaseEmbed, databasePath: dbPath)) } else { - blocks.append(Block(type: .pageLink, pageLinkName: name)) + blocks.append(makeBlock(type: .pageLink, pageLinkName: name)) } i += 1 continue } // Toggle block - if line.trimmingCharacters(in: .whitespaces) == "" || line.trimmingCharacters(in: .whitespaces) == "" { - let collapsed = line.trimmingCharacters(in: .whitespaces).contains("collapsed") + if trimmed == "" || trimmed == "" { + let collapsed = trimmed.contains("collapsed") i += 1 // First line is the toggle title let title = i < lines.count ? lines[i] : "" @@ -200,12 +259,12 @@ enum MarkdownBlockParser { i += 1 } let children = childLines.isEmpty ? [] : parse(childLines.joined(separator: "\n")) - blocks.append(Block(type: .toggle, text: title, children: children, isExpanded: !collapsed)) + blocks.append(makeBlock(type: .toggle, text: title, children: children, isExpanded: !collapsed)) continue } // Column block - if line.trimmingCharacters(in: .whitespaces) == "" { + if trimmed == "" { var allChildren: [Block] = [] var currentColumnIndex = 0 var currentColumnLines: [String] = [] @@ -243,29 +302,12 @@ enum MarkdownBlockParser { } allChildren.append(contentsOf: columnBlocks) } - blocks.append(Block(type: .column, children: allChildren)) - continue - } - - // Color comment (applies to next block) - if let (textColor, bgColor) = parseColorComment(line) { - i += 1 - if i < lines.count { - // Parse the next line as a block and apply colors - let nextLine = lines[i] - var nextBlocks = parse(nextLine) - if !nextBlocks.isEmpty { - nextBlocks[0].textColor = textColor - nextBlocks[0].backgroundColor = bgColor - blocks.append(contentsOf: nextBlocks) - } - i += 1 - } + blocks.append(makeBlock(type: .column, children: allChildren)) continue } // Paragraph (including empty lines) - blocks.append(Block(type: .paragraph, text: line)) + blocks.append(makeBlock(type: .paragraph, text: unescapeParagraphText(line))) i += 1 } @@ -278,10 +320,14 @@ enum MarkdownBlockParser { // MARK: - Serialize - static func serialize(_ blocks: [Block]) -> String { + static func serialize(_ blocks: [Block], includeBlockIDComments: Bool = false) -> String { var lines: [String] = [] for (i, block) in blocks.enumerated() { + if includeBlockIDComments { + lines.append("") + } + // Emit color comment before blocks that have non-default colors let hasColor = block.textColor != .default || block.backgroundColor != .default if hasColor, block.type != .column, block.type != .toggle { @@ -297,7 +343,7 @@ enum MarkdownBlockParser { switch block.type { case .paragraph: - lines.append(block.text) + lines.append(escapedParagraphText(block.text)) case .heading: let hashes = String(repeating: "#", count: max(1, min(6, block.headingLevel))) @@ -345,7 +391,7 @@ enum MarkdownBlockParser { lines.append(block.isExpanded ? "" : "") lines.append(block.text) if !block.children.isEmpty { - lines.append(serialize(block.children)) + lines.append(serialize(block.children, includeBlockIDComments: includeBlockIDComments)) } lines.append("") @@ -358,7 +404,7 @@ enum MarkdownBlockParser { } let colBlocks = block.children.filter { $0.columnIndex == colIdx } if !colBlocks.isEmpty { - lines.append(serialize(colBlocks)) + lines.append(serialize(colBlocks, includeBlockIDComments: includeBlockIDComments)) } } lines.append("") @@ -370,6 +416,64 @@ enum MarkdownBlockParser { // MARK: - Line Parsers + private static func escapedParagraphText(_ text: String) -> String { + let (leadingSpaces, remainder) = splitLeadingSpaces(text) + let slashCount = leadingBackslashCount(in: remainder) + let tail = String(remainder.dropFirst(slashCount)) + guard paragraphNeedsProtection(leadingSpaces + tail) else { + return text + } + return leadingSpaces + String(repeating: "\\", count: slashCount + 1) + tail + } + + private static func unescapeParagraphText(_ line: String) -> String { + let (leadingSpaces, remainder) = splitLeadingSpaces(line) + let slashCount = leadingBackslashCount(in: remainder) + guard slashCount > 0 else { + return line + } + let tail = String(remainder.dropFirst(slashCount)) + guard paragraphNeedsProtection(leadingSpaces + tail) else { + return line + } + return leadingSpaces + String(repeating: "\\", count: slashCount - 1) + tail + } + + private static func splitLeadingSpaces(_ line: String) -> (String, String) { + let prefix = line.prefix(while: { $0 == " " }) + return (String(prefix), String(line.dropFirst(prefix.count))) + } + + private static func leadingBackslashCount(in text: String) -> Int { + text.prefix(while: { $0 == "\\" }).count + } + + private static func paragraphNeedsProtection(_ line: String) -> Bool { + guard !line.isEmpty else { + return false + } + + let trimmed = line.trimmingCharacters(in: .whitespaces) + if parseBlockIDComment(line) != nil || parseColorComment(line) != nil { + return true + } + if line.hasPrefix("```") || isHorizontalRule(line) || parseHeading(line) != nil { + return true + } + if parseTaskItem(line) != nil || parseBulletItem(line) != nil || parseNumberedItem(line) != nil { + return true + } + if line.hasPrefix(">") || parseImage(line) != nil || parseDatabaseEmbed(line) != nil || parseWikiLink(line) != nil { + return true + } + return trimmed == "" + || trimmed == "" + || trimmed == "" + || trimmed == "" + || trimmed == "" + || trimmed == "" + } + private static func isHorizontalRule(_ line: String) -> Bool { let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.count >= 3 else { return false } @@ -523,6 +627,15 @@ enum MarkdownBlockParser { return name.isEmpty ? nil : name } + private static func parseBlockIDComment(_ line: String) -> UUID? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("") else { return nil } + let inner = trimmed.dropFirst(4).dropLast(3).trimmingCharacters(in: .whitespaces) + guard inner.lowercased().hasPrefix("block-id:") else { return nil } + let raw = String(inner.dropFirst("block-id:".count)).trimmingCharacters(in: .whitespacesAndNewlines) + return UUID(uuidString: raw) + } + private static func parseColorComment(_ line: String) -> (BlockColor, BlockColor)? { let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.hasPrefix("") else { return nil } diff --git a/Sources/Bugbook/Models/BlockDocument.swift b/Sources/Bugbook/Models/BlockDocument.swift index d245157..e40c0a0 100644 --- a/Sources/Bugbook/Models/BlockDocument.swift +++ b/Sources/Bugbook/Models/BlockDocument.swift @@ -36,6 +36,7 @@ class BlockDocument: ObservableObject { private var undoStack: [[Block]] = [] private var redoStack: [[Block]] = [] + private var persistsBlockIDs: Bool = false var markdown: String { let metadata = MarkdownBlockParser.Metadata( @@ -45,7 +46,7 @@ class BlockDocument: ObservableObject { fullWidth: fullWidth ) let metaStr = MarkdownBlockParser.serializeMetadata(metadata) - let blockStr = MarkdownBlockParser.serialize(blocks) + let blockStr = MarkdownBlockParser.serialize(blocks, includeBlockIDComments: persistsBlockIDs) if metaStr.isEmpty { return blockStr } @@ -54,6 +55,7 @@ class BlockDocument: ObservableObject { init(markdown: String) { let (metadata, content) = MarkdownBlockParser.parseMetadata(markdown) + self.persistsBlockIDs = content.contains("") else { return false } + let inner = trimmed.dropFirst(4).dropLast(3).trimmingCharacters(in: .whitespaces) + guard inner.lowercased().hasPrefix("block-id:") else { return false } + let raw = String(inner.dropFirst("block-id:".count)).trimmingCharacters(in: .whitespacesAndNewlines) + return UUID(uuidString: raw) != nil +} + +func structuredLineChanges(from before: String, to after: String) -> [[String: Any]] { + let beforeLines = before.components(separatedBy: .newlines) + let afterLines = after.components(separatedBy: .newlines) + return afterLines.difference(from: beforeLines).map { change in + switch change { + case .remove(let offset, let element, _): + return [ + "op": "remove", + "line": offset + 1, + "text": element, + ] + case .insert(let offset, let element, _): + return [ + "op": "insert", + "line": offset + 1, + "text": element, + ] + } + } +} + +private func workspacePageSectionRecord( + from section: MarkdownHeadingSection, + bodyLines: [String], + prefixLineCount: Int +) -> WorkspacePageSectionRecord { + let content = Array(bodyLines[section.headingIndex.. WorkspacePageRecord { + let (frontmatter, body) = parsePageFrontmatter(content) + let title = pageTitle(body: body, fallback: existing.name, frontmatter: frontmatter) + let tags = pageTags(frontmatter: frontmatter, body: body) + let wikilinks = extractWikiLinks(from: body) + + return WorkspacePageRecord( + path: existing.path, + relativePath: existing.relativePath, + name: existing.name, + title: title, + content: content, + body: body, + frontmatter: frontmatter, + tags: tags, + wikilinks: wikilinks, + modifiedAt: existing.modifiedAt + ) +} + private func normalizePageDestination(_ rawPath: String, workspace: String) throws -> String { let normalizedWorkspace = normalizePath(workspace) let expanded = (rawPath as NSString).expandingTildeInPath @@ -475,6 +1059,28 @@ func normalizePath(_ path: String) -> String { (path as NSString).standardizingPath } +func relativePath(fromDirectory sourceDirectory: String, to targetPath: String) -> String { + let sourceComponents = sourceDirectory + .replacingOccurrences(of: "\\", with: "/") + .split(separator: "/") + .map(String.init) + let targetComponents = targetPath + .replacingOccurrences(of: "\\", with: "/") + .split(separator: "/") + .map(String.init) + + var commonLength = 0 + while commonLength < sourceComponents.count, + commonLength < targetComponents.count, + sourceComponents[commonLength] == targetComponents[commonLength] { + commonLength += 1 + } + + var parts = Array(repeating: "..", count: sourceComponents.count - commonLength) + parts.append(contentsOf: targetComponents[commonLength...]) + return parts.isEmpty ? "." : parts.joined(separator: "/") +} + private func sanitizePageFileName(_ value: String) -> String { let sanitized = value.replacingOccurrences( of: "[/\\\\?%*:|\"<>]", diff --git a/Sources/BugbookCLI/PageBlockHelpers.swift b/Sources/BugbookCLI/PageBlockHelpers.swift new file mode 100644 index 0000000..c46ede8 --- /dev/null +++ b/Sources/BugbookCLI/PageBlockHelpers.swift @@ -0,0 +1,1948 @@ +import Foundation + +struct WorkspacePageBlockRecord { + let id: String + let stableID: Bool + let persistedID: String? + let path: [Int] + let type: String + let content: String + let json: [String: Any] + + func toJSON(includeContent: Bool = true) -> [String: Any] { + var output = json + if includeContent { + output["content"] = content + } + return output + } +} + +struct WorkspacePageBlockUpdatePreview { + let original: WorkspacePageRecord + let updated: WorkspacePageRecord + let changed: Bool + let lineChanges: [[String: Any]] + let selectedBlockBefore: WorkspacePageBlockRecord + let selectedBlocksAfter: [WorkspacePageBlockRecord] + + func toJSON() -> [String: Any] { + var json = updated.toDetailJSON() + json["dry_run"] = true + json["changed"] = changed + json["line_changes"] = lineChanges + json["selected_block_before"] = selectedBlockBefore.toJSON() + json["selected_blocks_after"] = selectedBlocksAfter.map { $0.toJSON() } + return json + } +} + +func blockUpdateSummaryJSON( + _ preview: WorkspacePageBlockUpdatePreview, + operation: String, + dryRun: Bool +) -> [String: Any] { + blockUpdateSummaryJSON(preview.updated, preview: preview, operation: operation, dryRun: dryRun) +} + +func blockUpdateSummaryJSON( + _ record: WorkspacePageRecord, + preview: WorkspacePageBlockUpdatePreview, + operation: String, + dryRun: Bool +) -> [String: Any] { + var json = pageWriteSummaryJSON( + record, + operation: operation, + changed: preview.changed + ) + json["selected_block_before"] = preview.selectedBlockBefore.toJSON(includeContent: false) + json["selected_blocks_after"] = preview.selectedBlocksAfter.map { + $0.toJSON(includeContent: false) + } + if dryRun { + json["dry_run"] = true + json["line_changes"] = preview.lineChanges + } + return json +} + +private struct PageFrontmatterSplit { + let prefix: String + let body: String +} + +private struct ParsedPageDocument { + var metadata: ParsedPageDocumentMetadata + var blocks: [ParsedPageBlock] +} + +private enum ParsedPageBlockType: String { + case paragraph + case heading + case bulletListItem = "bullet_list_item" + case numberedListItem = "numbered_list_item" + case taskItem = "task_item" + case codeBlock = "code_block" + case blockquote + case horizontalRule = "horizontal_rule" + case image + case databaseEmbed = "database_embed" + case pageLink = "page_link" + case column + case toggle +} + +private struct ParsedPageBlock { + var id: String + var stableID: Bool + var type: ParsedPageBlockType + var text: String = "" + var headingLevel: Int = 1 + var listDepth: Int = 0 + var isChecked: Bool = false + var language: String = "" + var imageSource: String = "" + var imageAlt: String = "" + var imageWidth: Int? + var databasePath: String = "" + var pageLinkName: String = "" + var commonmarkLinkDestination: String? + var textColor: String? + var backgroundColor: String? + var children: [ParsedPageBlock] = [] + var columnIndex: Int = 0 + var isExpanded: Bool = true +} + +private struct ParsedPageDocumentMetadata { + var icon: String? + var coverURL: String? + var coverPosition: Double = 50 + var fullWidth: Bool = false + + func toJSON() -> [String: Any] { + var json: [String: Any] = [ + "cover_position": coverPosition, + "full_width": fullWidth, + ] + if let icon, !icon.isEmpty { + json["icon"] = icon + } + if let coverURL, !coverURL.isEmpty { + json["cover_url"] = coverURL + } + return json + } +} + +private struct ParsedPageBlockLookup { + let block: ParsedPageBlock + let path: [Int] +} + +private struct ParsedPageBlockMutationResult { + let before: ParsedPageBlockLookup + let afterPaths: [[Int]] +} + +private struct CommonMarkPageLinkResolver { + private let directMatchesByToken: [String: [WorkspacePageRecord]] + private let titleMatchesByToken: [String: [WorkspacePageRecord]] + private let sourceDirectory: String + + init(sourcePage: WorkspacePageRecord, workspace: String) throws { + let pages = try listWorkspacePages(in: workspace) + var directLookup: [String: [WorkspacePageRecord]] = [:] + var titleLookup: [String: [WorkspacePageRecord]] = [:] + + func insert(_ record: WorkspacePageRecord, for token: String, into lookup: inout [String: [WorkspacePageRecord]]) { + guard !token.isEmpty else { + return + } + if lookup[token]?.contains(where: { $0.relativePath == record.relativePath }) == true { + return + } + lookup[token, default: []].append(record) + } + + for page in pages { + insert(page, for: normalizePageLookup(page.relativePath), into: &directLookup) + insert(page, for: normalizePageLookup((page.relativePath as NSString).deletingPathExtension), into: &directLookup) + insert(page, for: normalizePageLookup(page.name), into: &directLookup) + insert(page, for: normalizePageLookup(page.title), into: &titleLookup) + } + + directMatchesByToken = directLookup + titleMatchesByToken = titleLookup + sourceDirectory = (sourcePage.relativePath as NSString).deletingLastPathComponent + } + + func destination(for pageName: String) -> String? { + let token = normalizePageLookup(pageName) + guard !token.isEmpty else { + return nil + } + let matches = (directMatchesByToken[token]?.isEmpty == false) + ? directMatchesByToken[token] + : titleMatchesByToken[token] + guard let matches, matches.count == 1, let target = matches.first?.relativePath else { + return nil + } + return relativePath(fromDirectory: sourceDirectory, to: target) + } +} + +private enum PageBlockParser { + static func parseDocument(_ markdown: String) -> ParsedPageDocument { + let (metadata, content) = parseMetadata(markdown) + return ParsedPageDocument(metadata: metadata, blocks: parseBlocks(content)) + } + + static func parseBlocks(_ markdown: String) -> [ParsedPageBlock] { + guard !markdown.isEmpty else { + return [ParsedPageBlock(id: newBlockID(), stableID: false, type: .paragraph)] + } + + var lines = markdown.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + if lines.count > 1, lines.last == "" { + lines.removeLast() + } + var blocks: [ParsedPageBlock] = [] + var index = 0 + var pendingBlockID: String? + var pendingStableID = false + var pendingTextColor: String? + var pendingBackgroundColor: String? + + func makeBlock( + type: ParsedPageBlockType = .paragraph, + text: String = "", + headingLevel: Int = 1, + listDepth: Int = 0, + isChecked: Bool = false, + language: String = "", + imageSource: String = "", + imageAlt: String = "", + imageWidth: Int? = nil, + databasePath: String = "", + pageLinkName: String = "", + children: [ParsedPageBlock] = [], + columnIndex: Int = 0, + isExpanded: Bool = true + ) -> ParsedPageBlock { + let block = ParsedPageBlock( + id: pendingBlockID ?? newBlockID(), + stableID: pendingStableID, + type: type, + text: text, + headingLevel: headingLevel, + listDepth: listDepth, + isChecked: isChecked, + language: language, + imageSource: imageSource, + imageAlt: imageAlt, + imageWidth: imageWidth, + databasePath: databasePath, + pageLinkName: pageLinkName, + textColor: pendingTextColor, + backgroundColor: pendingBackgroundColor, + children: children, + columnIndex: columnIndex, + isExpanded: isExpanded + ) + pendingBlockID = nil + pendingStableID = false + pendingTextColor = nil + pendingBackgroundColor = nil + return block + } + + while index < lines.count { + let line = lines[index] + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if let blockID = parseBlockIDComment(line) { + pendingBlockID = blockID + pendingStableID = true + index += 1 + continue + } + + if let (textColor, backgroundColor) = parseColorComment(line) { + pendingTextColor = textColor + pendingBackgroundColor = backgroundColor + index += 1 + continue + } + + if line.hasPrefix("```") { + let language = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces) + var codeLines: [String] = [] + index += 1 + while index < lines.count { + if lines[index].hasPrefix("```") { + index += 1 + break + } + codeLines.append(lines[index]) + index += 1 + } + blocks.append(makeBlock(type: .codeBlock, text: codeLines.joined(separator: "\n"), language: language)) + continue + } + + if isHorizontalRule(line) { + blocks.append(makeBlock(type: .horizontalRule, text: line)) + index += 1 + continue + } + + if let (level, text) = parseHeading(line) { + blocks.append(makeBlock(type: .heading, text: text, headingLevel: level)) + index += 1 + continue + } + + if let (depth, checked, text) = parseTaskItem(line) { + blocks.append(makeBlock(type: .taskItem, text: text, listDepth: depth, isChecked: checked)) + index += 1 + continue + } + + if let (depth, text) = parseBulletItem(line) { + blocks.append(makeBlock(type: .bulletListItem, text: text, listDepth: depth)) + index += 1 + continue + } + + if let (depth, text) = parseNumberedItem(line) { + blocks.append(makeBlock(type: .numberedListItem, text: text, listDepth: depth)) + index += 1 + continue + } + + if line.hasPrefix(">") { + let text: String + if line.count > 1, line[line.index(after: line.startIndex)] == " " { + text = String(line.dropFirst(2)) + } else { + text = String(line.dropFirst(1)) + } + blocks.append(makeBlock(type: .blockquote, text: text)) + index += 1 + continue + } + + if let (alt, src, width) = parseImage(line) { + blocks.append(makeBlock(type: .image, imageSource: src, imageAlt: alt, imageWidth: width)) + index += 1 + continue + } + + if let path = parseDatabaseEmbed(line) { + blocks.append(makeBlock(type: .databaseEmbed, databasePath: path)) + index += 1 + continue + } + + if let name = parseWikiLink(line) { + if let dbPath = parseDatabaseSchemePath(name) { + blocks.append(makeBlock(type: .databaseEmbed, databasePath: dbPath)) + } else { + blocks.append(makeBlock(type: .pageLink, pageLinkName: name)) + } + index += 1 + continue + } + + if trimmed == "" || trimmed == "" { + let collapsed = trimmed.contains("collapsed") + index += 1 + let title = index < lines.count ? lines[index] : "" + index += 1 + + var childLines: [String] = [] + while index < lines.count { + if lines[index].trimmingCharacters(in: .whitespaces) == "" { + index += 1 + break + } + childLines.append(lines[index]) + index += 1 + } + + blocks.append( + makeBlock( + type: .toggle, + text: title, + children: childLines.isEmpty ? [] : parseBlocks(childLines.joined(separator: "\n")), + isExpanded: !collapsed + ) + ) + continue + } + + if trimmed == "" { + var allChildren: [ParsedPageBlock] = [] + var currentColumnIndex = 0 + var currentColumnLines: [String] = [] + index += 1 + + while index < lines.count { + let columnLine = lines[index] + let columnTrimmed = columnLine.trimmingCharacters(in: .whitespaces) + if columnTrimmed == "" { + index += 1 + break + } + if columnTrimmed == "" { + if !currentColumnLines.isEmpty { + var columnBlocks = parseBlocks(currentColumnLines.joined(separator: "\n")) + for childIndex in columnBlocks.indices { + columnBlocks[childIndex].columnIndex = currentColumnIndex + } + allChildren.append(contentsOf: columnBlocks) + } + currentColumnLines = [] + currentColumnIndex += 1 + index += 1 + continue + } + currentColumnLines.append(columnLine) + index += 1 + } + + if !currentColumnLines.isEmpty { + var columnBlocks = parseBlocks(currentColumnLines.joined(separator: "\n")) + for childIndex in columnBlocks.indices { + columnBlocks[childIndex].columnIndex = currentColumnIndex + } + allChildren.append(contentsOf: columnBlocks) + } + + blocks.append(makeBlock(type: .column, children: allChildren)) + continue + } + + blocks.append(makeBlock(type: .paragraph, text: unescapeParagraphText(line))) + index += 1 + } + + if blocks.isEmpty { + return [ParsedPageBlock(id: newBlockID(), stableID: false, type: .paragraph)] + } + + return blocks + } + + static func serializeDocument( + _ document: ParsedPageDocument, + includeBlockIDComments: Bool = true, + style: PageMarkdownFormatStyle = .bugbook + ) -> String { + let metadata = serializeMetadata(document.metadata) + let body = serialize( + document.blocks, + includeBlockIDComments: includeBlockIDComments, + style: style + ) + if metadata.isEmpty { + return body + } + if body.isEmpty { + return metadata + } + let separator = style == .commonmark ? "\n\n" : "\n" + return metadata + separator + body + } + + static func serializeBlocks( + _ blocks: [ParsedPageBlock], + includeBlockIDComments: Bool = false, + style: PageMarkdownFormatStyle = .bugbook + ) -> String { + serialize(blocks, includeBlockIDComments: includeBlockIDComments, style: style) + } + + private static func parseMetadata(_ markdown: String) -> (ParsedPageDocumentMetadata, String) { + var metadata = ParsedPageDocumentMetadata() + let lines = markdown.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var contentStartIndex = 0 + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("") { + let inner = trimmed.dropFirst(10).dropLast(3).trimmingCharacters(in: .whitespaces) + metadata.icon = inner.isEmpty ? nil : inner + contentStartIndex += 1 + continue + } + + if trimmed.hasPrefix("") { + let inner = trimmed.dropFirst(11).dropLast(3).trimmingCharacters(in: .whitespaces) + if let atRange = inner.range(of: "@", options: .backwards) { + metadata.coverURL = String(inner[.." { + metadata.fullWidth = true + contentStartIndex += 1 + continue + } + + break + } + + let remaining = Array(lines.dropFirst(contentStartIndex)).joined(separator: "\n") + return (metadata, remaining) + } + + private static func serializeMetadata(_ metadata: ParsedPageDocumentMetadata) -> String { + var lines: [String] = [] + if let icon = metadata.icon, !icon.isEmpty { + lines.append("") + } + if let cover = metadata.coverURL, !cover.isEmpty { + lines.append("") + } + if metadata.fullWidth { + lines.append("") + } + return lines.joined(separator: "\n") + } + + private static func serialize( + _ blocks: [ParsedPageBlock], + includeBlockIDComments: Bool, + style: PageMarkdownFormatStyle + ) -> String { + serializeLines( + blocks, + includeBlockIDComments: includeBlockIDComments, + style: style + ).joined(separator: "\n") + } + + private static func serializeLines( + _ blocks: [ParsedPageBlock], + includeBlockIDComments: Bool, + style: PageMarkdownFormatStyle + ) -> [String] { + let chunks = blocks.enumerated().map { index, block in + serializeChunk( + block, + at: index, + in: blocks, + includeBlockIDComments: includeBlockIDComments, + style: style + ) + } + + switch style { + case .bugbook: + return chunks.flatMap { $0 } + case .commonmark: + var lines: [String] = [] + for index in chunks.indices { + if index > 0, + shouldInsertCommonMarkBlankLine(between: blocks[index - 1], and: blocks[index]), + !lines.isEmpty, + lines.last != "" { + lines.append("") + } + lines.append(contentsOf: chunks[index]) + } + return lines + } + } + + private static func serializeChunk( + _ block: ParsedPageBlock, + at index: Int, + in siblings: [ParsedPageBlock], + includeBlockIDComments: Bool, + style: PageMarkdownFormatStyle + ) -> [String] { + var lines: [String] = [] + + if includeBlockIDComments, style == .bugbook { + lines.append("") + } + + let hasColor = block.textColor != nil || block.backgroundColor != nil + if hasColor, style == .bugbook, block.type != .column, block.type != .toggle { + var parts: [String] = [] + if let textColor = block.textColor, !textColor.isEmpty { + parts.append("color:\(textColor)") + } + if let backgroundColor = block.backgroundColor, !backgroundColor.isEmpty { + parts.append("bg:\(backgroundColor)") + } + if !parts.isEmpty { + lines.append("") + } + } + + switch block.type { + case .paragraph: + lines.append(escapedParagraphText(block.text)) + + case .heading: + let hashes = String(repeating: "#", count: max(1, min(6, block.headingLevel))) + lines.append("\(hashes) \(block.text)") + + case .bulletListItem: + let indent = String(repeating: " ", count: block.listDepth) + lines.append("\(indent)- \(block.text)") + + case .numberedListItem: + let indent = String(repeating: " ", count: block.listDepth) + let number = computeNumberedPosition(at: index, depth: block.listDepth, in: siblings) + lines.append("\(indent)\(number). \(block.text)") + + case .taskItem: + let indent = String(repeating: " ", count: block.listDepth) + let check = block.isChecked ? "x" : " " + lines.append("\(indent)- [\(check)] \(block.text)") + + case .blockquote: + lines.append("> \(block.text)") + + case .codeBlock: + lines.append("```\(block.language)") + lines.append(block.text) + lines.append("```") + + case .horizontalRule: + lines.append("---") + + case .image: + var line = "![\(block.imageAlt)](\(block.imageSource))" + if let imageWidth = block.imageWidth { + line += "{width=\(imageWidth)}" + } + lines.append(line) + + case .databaseEmbed: + switch style { + case .bugbook: + lines.append("") + case .commonmark: + lines.append("**Bugbook database:** \(pageDisplayName(fromPath: block.databasePath))") + } + + case .pageLink: + switch style { + case .bugbook: + lines.append("[[\(block.pageLinkName)]]") + case .commonmark: + if let destination = block.commonmarkLinkDestination, !destination.isEmpty { + lines.append("[\(escapeMarkdownLinkText(block.pageLinkName))](<\(destination)>)") + } else { + lines.append(escapedParagraphText(block.pageLinkName)) + } + } + + case .toggle: + switch style { + case .bugbook: + lines.append(block.isExpanded ? "" : "") + lines.append(block.text) + if !block.children.isEmpty { + lines.append(contentsOf: serializeLines( + block.children, + includeBlockIDComments: includeBlockIDComments, + style: style + )) + } + lines.append("") + case .commonmark: + lines.append(block.isExpanded ? "
" : "
") + lines.append("\(escapeHTML(block.text))") + if !block.children.isEmpty { + lines.append("") + lines.append(contentsOf: serializeLines( + block.children, + includeBlockIDComments: false, + style: style + )) + if lines.last != "" { + lines.append("") + } + } + lines.append("
") + } + + case .column: + let maxColumn = block.children.map(\.columnIndex).max() ?? 0 + switch style { + case .bugbook: + lines.append("") + for columnIndex in 0...maxColumn { + if columnIndex > 0 { + lines.append("") + } + let columnBlocks = block.children.filter { $0.columnIndex == columnIndex } + if !columnBlocks.isEmpty { + lines.append(contentsOf: serializeLines( + columnBlocks, + includeBlockIDComments: includeBlockIDComments, + style: style + )) + } + } + lines.append("") + case .commonmark: + var emittedColumn = false + for columnIndex in 0...maxColumn { + let columnBlocks = block.children.filter { $0.columnIndex == columnIndex } + guard !columnBlocks.isEmpty else { continue } + if emittedColumn { + if !lines.isEmpty, lines.last != "" { + lines.append("") + } + lines.append("---") + lines.append("") + } + lines.append(contentsOf: serializeLines( + columnBlocks, + includeBlockIDComments: false, + style: style + )) + emittedColumn = true + } + } + } + + return lines + } + + private static func shouldInsertCommonMarkBlankLine( + between previous: ParsedPageBlock, + and next: ParsedPageBlock + ) -> Bool { + if isListItem(previous), isListItem(next) { + return false + } + if previous.type == .blockquote, next.type == .blockquote { + return false + } + return true + } + + private static func isListItem(_ block: ParsedPageBlock) -> Bool { + switch block.type { + case .bulletListItem, .numberedListItem, .taskItem: + return true + default: + return false + } + } + + private static func escapeHTML(_ text: String) -> String { + text + .replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + } + + private static func escapeMarkdownLinkText(_ text: String) -> String { + text + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "[", with: "\\[") + .replacingOccurrences(of: "]", with: "\\]") + } + + private static func isHorizontalRule(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.count >= 3 else { return false } + let characters = Set(trimmed.filter { $0 != " " }) + return characters.count == 1 && (characters.contains("-") || characters.contains("*") || characters.contains("_")) + } + + private static func escapedParagraphText(_ text: String) -> String { + let (leadingSpaces, remainder) = splitLeadingSpaces(text) + let slashCount = leadingBackslashCount(in: remainder) + let tail = String(remainder.dropFirst(slashCount)) + guard paragraphNeedsProtection(leadingSpaces + tail) else { + return text + } + return leadingSpaces + String(repeating: "\\", count: slashCount + 1) + tail + } + + private static func unescapeParagraphText(_ line: String) -> String { + let (leadingSpaces, remainder) = splitLeadingSpaces(line) + let slashCount = leadingBackslashCount(in: remainder) + guard slashCount > 0 else { + return line + } + let tail = String(remainder.dropFirst(slashCount)) + guard paragraphNeedsProtection(leadingSpaces + tail) else { + return line + } + return leadingSpaces + String(repeating: "\\", count: slashCount - 1) + tail + } + + private static func splitLeadingSpaces(_ line: String) -> (String, String) { + let prefix = line.prefix(while: { $0 == " " }) + return (String(prefix), String(line.dropFirst(prefix.count))) + } + + private static func leadingBackslashCount(in text: String) -> Int { + text.prefix(while: { $0 == "\\" }).count + } + + private static func paragraphNeedsProtection(_ line: String) -> Bool { + guard !line.isEmpty else { + return false + } + + let trimmed = line.trimmingCharacters(in: .whitespaces) + if parseBlockIDComment(line) != nil || parseColorComment(line) != nil { + return true + } + if line.hasPrefix("```") || isHorizontalRule(line) || parseHeading(line) != nil { + return true + } + if parseTaskItem(line) != nil || parseBulletItem(line) != nil || parseNumberedItem(line) != nil { + return true + } + if line.hasPrefix(">") || parseImage(line) != nil || parseDatabaseEmbed(line) != nil || parseWikiLink(line) != nil { + return true + } + return trimmed == "" + || trimmed == "" + || trimmed == "" + || trimmed == "" + || trimmed == "" + || trimmed == "" + } + + private static func parseHeading(_ line: String) -> (Int, String)? { + guard line.hasPrefix("#") else { return nil } + var level = 0 + for character in line { + if character == "#" { + level += 1 + } else { + break + } + } + guard level >= 1, level <= 6, line.count > level else { return nil } + let index = line.index(line.startIndex, offsetBy: level) + guard line[index] == " " else { return nil } + return (level, String(line[line.index(after: index)...])) + } + + private static func parseTaskItem(_ line: String) -> (Int, Bool, String)? { + let stripped = line.drop(while: { $0 == " " }) + let depth = (line.count - stripped.count) / 2 + guard stripped.count >= 6 else { return nil } + guard let marker = stripped.first, marker == "-" || marker == "*" || marker == "+" else { return nil } + let rest = stripped.dropFirst() + guard rest.hasPrefix(" [") else { return nil } + let afterBracket = rest.dropFirst(2) + guard let check = afterBracket.first, check == " " || check == "x" || check == "X" else { return nil } + let afterCheck = afterBracket.dropFirst() + guard afterCheck.hasPrefix("] ") else { return nil } + return (depth, check != " ", String(afterCheck.dropFirst(2))) + } + + private static func parseBulletItem(_ line: String) -> (Int, String)? { + let stripped = line.drop(while: { $0 == " " }) + let depth = (line.count - stripped.count) / 2 + guard stripped.count >= 2 else { return nil } + guard let marker = stripped.first, marker == "-" || marker == "*" || marker == "+" else { return nil } + let rest = stripped.dropFirst() + guard rest.hasPrefix(" ") else { return nil } + return (depth, String(rest.dropFirst())) + } + + private static func parseNumberedItem(_ line: String) -> (Int, String)? { + let stripped = line.drop(while: { $0 == " " }) + let depth = (line.count - stripped.count) / 2 + + var digitEnd = stripped.startIndex + while digitEnd < stripped.endIndex, stripped[digitEnd].isNumber { + digitEnd = stripped.index(after: digitEnd) + } + guard digitEnd > stripped.startIndex else { return nil } + guard digitEnd < stripped.endIndex, stripped[digitEnd] == "." else { return nil } + let afterDot = stripped.index(after: digitEnd) + guard afterDot < stripped.endIndex, stripped[afterDot] == " " else { return nil } + return (depth, String(stripped[stripped.index(after: afterDot)...])) + } + + private static func parseImage(_ line: String) -> (String, String, Int?)? { + guard line.hasPrefix("![") else { return nil } + guard let altEnd = line.range(of: "](") else { return nil } + let altStart = line.index(line.startIndex, offsetBy: 2) + let alt = String(line[altStart.. String? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix(""), + let marker = trimmed.range(of: "database:") { + let pathStart = marker.upperBound + let pathEnd = trimmed.index(trimmed.endIndex, offsetBy: -3) + guard pathStart < pathEnd else { return nil } + let path = String(trimmed[pathStart.. String? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("["), + trimmed.hasSuffix(")"), + let split = trimmed.range(of: "](") else { return nil } + let urlPart = String(trimmed[split.upperBound.. String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("database:") else { return nil } + + var target = String(trimmed.dropFirst("database:".count)).trimmingCharacters(in: .whitespacesAndNewlines) + if target.isEmpty { return nil } + + if target.hasPrefix("///") { + target = "/" + String(target.dropFirst(3)) + } else if target.hasPrefix("//") { + target = "/" + String(target.dropFirst(2)) + } + + if target.hasPrefix("~") { + target = (target as NSString).expandingTildeInPath + } + if target.contains("%"), let decoded = target.removingPercentEncoding { + target = decoded + } + if target.hasSuffix("/_schema.json") { + target = (target as NSString).deletingLastPathComponent + } + + guard target.hasPrefix("/") else { return nil } + return target + } + + private static func parseWikiLink(_ line: String) -> String? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("[["), trimmed.hasSuffix("]]") else { return nil } + let name = String(trimmed.dropFirst(2).dropLast(2)) + return name.isEmpty ? nil : name + } + + private static func parseBlockIDComment(_ line: String) -> String? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("") else { return nil } + let inner = trimmed.dropFirst(4).dropLast(3).trimmingCharacters(in: .whitespaces) + guard inner.lowercased().hasPrefix("block-id:") else { return nil } + let raw = String(inner.dropFirst("block-id:".count)).trimmingCharacters(in: .whitespacesAndNewlines) + return UUID(uuidString: raw)?.uuidString.lowercased() + } + + private static func parseColorComment(_ line: String) -> (String?, String?)? { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard trimmed.hasPrefix("") else { return nil } + let inner = trimmed.dropFirst(4).dropLast(3).trimmingCharacters(in: .whitespaces) + guard inner.contains("color:") || inner.contains("bg:") else { return nil } + guard !inner.contains("database:") else { return nil } + + var textColor: String? + var backgroundColor: String? + for part in inner.split(separator: " ") { + if part.hasPrefix("color:") { + let value = String(part.dropFirst(6)).trimmingCharacters(in: .whitespacesAndNewlines) + textColor = value.isEmpty ? nil : value + } else if part.hasPrefix("bg:") { + let value = String(part.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines) + backgroundColor = value.isEmpty ? nil : value + } + } + + if textColor == nil, backgroundColor == nil { + return nil + } + return (textColor, backgroundColor) + } + + private static func computeNumberedPosition( + at index: Int, + depth: Int, + in blocks: [ParsedPageBlock] + ) -> Int { + var number = 1 + guard index > 0 else { return number } + for previousIndex in stride(from: index - 1, through: 0, by: -1) { + let previous = blocks[previousIndex] + guard previous.type == .numberedListItem else { + if previous.type != .paragraph || !previous.text.isEmpty { + break + } + continue + } + if previous.listDepth < depth { + break + } + if previous.listDepth == depth { + number += 1 + } + } + return number + } +} + +func parsedPageDocumentJSON(from markdown: String) -> [String: Any] { + let document = PageBlockParser.parseDocument(markdown) + return [ + "document_metadata": document.metadata.toJSON(), + "blocks": parsedPageBlocksJSON(document.blocks), + ] +} + +func previewWorkspacePageFormat( + query: String, + workspace: String, + style: PageMarkdownFormatStyle +) throws -> WorkspacePageUpdatePreview { + let existing = try resolveWorkspacePage(query, workspace: workspace) + let (split, document) = parseWorkspacePageDocument(from: existing.content) + let persistBlockIDComments = style == .bugbook && documentHasPersistedBlockIDs(document.blocks) + + var formattedBlocks = document.blocks + _ = compactEmptyParagraphBlocks(in: &formattedBlocks) + if style == .commonmark, parsedPageBlocksContainPageLinks(formattedBlocks) { + let resolver = try CommonMarkPageLinkResolver(sourcePage: existing, workspace: workspace) + formattedBlocks = resolveCommonMarkPageLinks(in: formattedBlocks, using: resolver) + } + let formattedDocument = ParsedPageDocument(metadata: document.metadata, blocks: formattedBlocks) + let nextContent = split.prefix + PageBlockParser.serializeDocument( + formattedDocument, + includeBlockIDComments: persistBlockIDComments, + style: style + ) + let updated = workspacePageRecord(from: existing, content: nextContent) + + return WorkspacePageUpdatePreview( + original: existing, + updated: updated, + changed: existing.content != nextContent, + lineChanges: structuredLineChanges(from: existing.content, to: nextContent), + selectedSectionBefore: nil, + selectedSectionAfter: nil + ) +} + +func previewWorkspacePageCompact( + query: String, + workspace: String +) throws -> WorkspacePageUpdatePreview { + try previewWorkspacePageFormat( + query: query, + workspace: workspace, + style: .bugbook + ) +} + +func ensureWorkspacePageBlockIDs(query: String, workspace: String) throws -> [String: Any] { + let existing = try resolveWorkspacePage(query, workspace: workspace) + let (split, document) = parseWorkspacePageDocument(from: existing.content) + var normalizedBlocks = document.blocks + let changed = normalizeStableBlockIDs(in: &normalizedBlocks) + let normalizedDocument = ParsedPageDocument(metadata: document.metadata, blocks: normalizedBlocks) + + let finalRecord: WorkspacePageRecord + let finalDocument: ParsedPageDocument + if changed { + let nextContent = split.prefix + PageBlockParser.serializeDocument(normalizedDocument, includeBlockIDComments: true) + try nextContent.write(toFile: existing.path, atomically: true, encoding: .utf8) + finalRecord = try loadWorkspacePage(at: existing.path, relativeTo: workspace) + finalDocument = parseWorkspacePageDocument(from: finalRecord.content).document + } else { + finalRecord = existing + finalDocument = normalizedDocument + } + + return [ + "page": finalRecord.relativePath, + "title": finalRecord.title, + "changed": changed, + "stable_block_ids": true, + "block_count": parsedPageBlockCount(finalDocument.blocks), + "blocks": parsedPageBlocksJSON(finalDocument.blocks), + ] +} + +func stripWorkspacePageBlockIDs(query: String, workspace: String) throws -> [String: Any] { + let existing = try resolveWorkspacePage(query, workspace: workspace) + let (split, document) = parseWorkspacePageDocument(from: existing.content) + let changed = documentHasPersistedBlockIDs(document.blocks) + + let finalRecord: WorkspacePageRecord + if changed { + let nextContent = split.prefix + PageBlockParser.serializeDocument(document, includeBlockIDComments: false) + try nextContent.write(toFile: existing.path, atomically: true, encoding: .utf8) + finalRecord = try loadWorkspacePage(at: existing.path, relativeTo: workspace) + } else { + finalRecord = existing + } + + return [ + "page": finalRecord.relativePath, + "title": finalRecord.title, + "changed": changed, + "stable_block_ids": false, + "block_count": parsedPageBlockCount(document.blocks), + ] +} + +func resolveWorkspacePageBlock(_ page: WorkspacePageRecord, selector: String) throws -> WorkspacePageBlockRecord { + let document = parseWorkspacePageDocument(from: page.content).document + return try lookupBlock(in: document.blocks, selector: selector) +} + +private func lookupBlock(in blocks: [ParsedPageBlock], selector: String) throws -> WorkspacePageBlockRecord { + let matches = findParsedPageBlocks(in: blocks, selector: selector) + guard let lookup = matches.first else { + throw CLIError.invalidInput("Block not found: \(selector)") + } + if parsePathSelector(selector) == nil, matches.count > 1 { + throw CLIError.invalidInput( + "Block selector is ambiguous because multiple blocks share persisted ID \(selector). Run `page ensure-block-ids` or use a path: selector." + ) + } + return workspacePageBlockRecord(from: lookup) +} + +func previewWorkspacePageBlockUpdate( + query: String, + workspace: String, + blockSelector: String, + replacementContent: String? = nil, + prependContent: String? = nil, + appendContent: String? = nil +) throws -> WorkspacePageBlockUpdatePreview { + if replacementContent != nil && (prependContent != nil || appendContent != nil) { + throw CLIError.invalidInput("Use --content-file by itself, or use --prepend-file/--append-file without --content-file") + } + + let existing = try resolveWorkspacePage(query, workspace: workspace) + let (split, document) = parseWorkspacePageDocument(from: existing.content) + let beforeBlock = try lookupBlock(in: document.blocks, selector: blockSelector) + let persistBlockIDComments = documentHasPersistedBlockIDs(document.blocks) + + var updatedBlocks = document.blocks + if persistBlockIDComments { + _ = normalizeStableBlockIDs(in: &updatedBlocks) + } + var usedIDs = collectBlockIDs(in: updatedBlocks) + + let replacementBlocks = replacementContent.map(parseReplacementBlocks) + let prependBlocks = prependContent.map(parseInsertedBlocks) ?? [] + let appendBlocks = appendContent.map(parseInsertedBlocks) ?? [] + + guard let mutation = applyBlockMutation( + in: &updatedBlocks, + selector: blockSelector, + replacementBlocks: replacementBlocks, + prependBlocks: prependBlocks, + appendBlocks: appendBlocks, + usedIDs: &usedIDs + ) else { + throw CLIError.invalidInput("Block not found: \(blockSelector)") + } + + if persistBlockIDComments { + _ = normalizeStableBlockIDs(in: &updatedBlocks) + } + + let updatedDocument = ParsedPageDocument(metadata: document.metadata, blocks: updatedBlocks) + let nextContent = split.prefix + PageBlockParser.serializeDocument( + updatedDocument, + includeBlockIDComments: persistBlockIDComments + ) + let updatedRecord = workspacePageRecord(from: existing, content: nextContent) + let selectedBlocksAfter = try mutation.afterPaths.map { path in + try lookupBlock(in: updatedBlocks, selector: pathSelector(path)) + } + + return WorkspacePageBlockUpdatePreview( + original: existing, + updated: updatedRecord, + changed: existing.content != nextContent, + lineChanges: structuredLineChanges(from: existing.content, to: nextContent), + selectedBlockBefore: beforeBlock, + selectedBlocksAfter: selectedBlocksAfter + ) +} + +func previewWorkspacePageBlockTextUpdate( + query: String, + workspace: String, + blockSelector: String, + textContent: String +) throws -> WorkspacePageBlockUpdatePreview { + let existing = try resolveWorkspacePage(query, workspace: workspace) + let (split, document) = parseWorkspacePageDocument(from: existing.content) + let beforeBlock = try lookupBlock(in: document.blocks, selector: blockSelector) + let persistBlockIDComments = documentHasPersistedBlockIDs(document.blocks) + + var updatedBlocks = document.blocks + if persistBlockIDComments { + _ = normalizeStableBlockIDs(in: &updatedBlocks) + } + + guard let afterPath = try updateParsedPageBlockText( + in: &updatedBlocks, + selector: blockSelector, + newText: textContent + ) else { + throw CLIError.invalidInput("Block not found: \(blockSelector)") + } + + if persistBlockIDComments { + _ = normalizeStableBlockIDs(in: &updatedBlocks) + } + + let updatedDocument = ParsedPageDocument(metadata: document.metadata, blocks: updatedBlocks) + let nextContent = split.prefix + PageBlockParser.serializeDocument( + updatedDocument, + includeBlockIDComments: persistBlockIDComments + ) + let updatedRecord = workspacePageRecord(from: existing, content: nextContent) + let selectedBlockAfter = try lookupBlock(in: updatedBlocks, selector: pathSelector(afterPath)) + + return WorkspacePageBlockUpdatePreview( + original: existing, + updated: updatedRecord, + changed: existing.content != nextContent, + lineChanges: structuredLineChanges(from: existing.content, to: nextContent), + selectedBlockBefore: beforeBlock, + selectedBlocksAfter: [selectedBlockAfter] + ) +} + +func previewWorkspacePageBlockMove( + query: String, + workspace: String, + blockSelector: String, + destinationSelector: String, + placeBefore: Bool +) throws -> WorkspacePageBlockUpdatePreview { + let existing = try resolveWorkspacePage(query, workspace: workspace) + let (split, document) = parseWorkspacePageDocument(from: existing.content) + let beforeBlock = try lookupBlock(in: document.blocks, selector: blockSelector) + _ = try lookupBlock(in: document.blocks, selector: destinationSelector) + let persistBlockIDComments = documentHasPersistedBlockIDs(document.blocks) + + var updatedBlocks = document.blocks + if persistBlockIDComments { + _ = normalizeStableBlockIDs(in: &updatedBlocks) + } + + guard let afterPath = try moveParsedPageBlock( + in: &updatedBlocks, + sourceSelector: blockSelector, + destinationSelector: destinationSelector, + placeBefore: placeBefore + ) else { + throw CLIError.invalidInput("Block not found: \(blockSelector)") + } + + if persistBlockIDComments { + _ = normalizeStableBlockIDs(in: &updatedBlocks) + } + + let updatedDocument = ParsedPageDocument(metadata: document.metadata, blocks: updatedBlocks) + let nextContent = split.prefix + PageBlockParser.serializeDocument( + updatedDocument, + includeBlockIDComments: persistBlockIDComments + ) + let updatedRecord = workspacePageRecord(from: existing, content: nextContent) + let selectedBlockAfter = try lookupBlock(in: updatedBlocks, selector: pathSelector(afterPath)) + + return WorkspacePageBlockUpdatePreview( + original: existing, + updated: updatedRecord, + changed: existing.content != nextContent, + lineChanges: structuredLineChanges(from: existing.content, to: nextContent), + selectedBlockBefore: beforeBlock, + selectedBlocksAfter: [selectedBlockAfter] + ) +} + +func updateWorkspacePageBlock( + query: String, + workspace: String, + blockSelector: String, + replacementContent: String? = nil, + prependContent: String? = nil, + appendContent: String? = nil +) throws -> WorkspacePageRecord { + let preview = try previewWorkspacePageBlockUpdate( + query: query, + workspace: workspace, + blockSelector: blockSelector, + replacementContent: replacementContent, + prependContent: prependContent, + appendContent: appendContent + ) + + try preview.updated.content.write(toFile: preview.original.path, atomically: true, encoding: .utf8) + return try loadWorkspacePage(at: preview.original.path, relativeTo: workspace) +} + +func updateWorkspacePageBlockText( + query: String, + workspace: String, + blockSelector: String, + textContent: String +) throws -> WorkspacePageRecord { + let preview = try previewWorkspacePageBlockTextUpdate( + query: query, + workspace: workspace, + blockSelector: blockSelector, + textContent: textContent + ) + + try preview.updated.content.write(toFile: preview.original.path, atomically: true, encoding: .utf8) + return try loadWorkspacePage(at: preview.original.path, relativeTo: workspace) +} + +func updateWorkspacePageBlockMove( + query: String, + workspace: String, + blockSelector: String, + destinationSelector: String, + placeBefore: Bool +) throws -> WorkspacePageRecord { + let preview = try previewWorkspacePageBlockMove( + query: query, + workspace: workspace, + blockSelector: blockSelector, + destinationSelector: destinationSelector, + placeBefore: placeBefore + ) + + try preview.updated.content.write(toFile: preview.original.path, atomically: true, encoding: .utf8) + return try loadWorkspacePage(at: preview.original.path, relativeTo: workspace) +} + +private func parseWorkspacePageDocument(from content: String) -> (split: PageFrontmatterSplit, document: ParsedPageDocument) { + let split = splitPageFrontmatter(from: content) + return (split, PageBlockParser.parseDocument(split.body)) +} + +private func parsedPageBlockCount(_ blocks: [ParsedPageBlock]) -> Int { + blocks.reduce(0) { partialResult, block in + partialResult + 1 + parsedPageBlockCount(block.children) + } +} + +private func parsedPageBlocksContainPageLinks(_ blocks: [ParsedPageBlock]) -> Bool { + for block in blocks { + if block.type == .pageLink || parsedPageBlocksContainPageLinks(block.children) { + return true + } + } + return false +} + +private func documentHasPersistedBlockIDs(_ blocks: [ParsedPageBlock]) -> Bool { + for block in blocks { + if block.stableID { + return true + } + if documentHasPersistedBlockIDs(block.children) { + return true + } + } + return false +} + +private func splitPageFrontmatter(from content: String) -> PageFrontmatterSplit { + guard content.hasPrefix("---") else { + return PageFrontmatterSplit(prefix: "", body: content) + } + + let lines = content.components(separatedBy: .newlines) + guard lines.first == "---" else { + return PageFrontmatterSplit(prefix: "", body: content) + } + + for index in 1.. Set { + var ids = Set() + for block in blocks { + ids.insert(block.id) + ids.formUnion(collectBlockIDs(in: block.children)) + } + return ids +} + +private func resolveCommonMarkPageLinks( + in blocks: [ParsedPageBlock], + using resolver: CommonMarkPageLinkResolver +) -> [ParsedPageBlock] { + blocks.map { block in + var resolved = block + if block.type == .pageLink { + resolved.commonmarkLinkDestination = resolver.destination(for: block.pageLinkName) + } + if !block.children.isEmpty { + resolved.children = resolveCommonMarkPageLinks(in: block.children, using: resolver) + } + return resolved + } +} + +private func findParsedPageBlocks( + in blocks: [ParsedPageBlock], + selector: String, + pathPrefix: [Int] = [] +) -> [ParsedPageBlockLookup] { + let selectorPath = parsePathSelector(selector) + var matches: [ParsedPageBlockLookup] = [] + + for (offset, block) in blocks.enumerated() { + let path = pathPrefix + [offset] + if let selectorPath { + if selectorPath == path { + matches.append(ParsedPageBlockLookup(block: block, path: path)) + } + } else if block.stableID && block.id == selector { + matches.append(ParsedPageBlockLookup(block: block, path: path)) + } + + let childMatches = findParsedPageBlocks(in: block.children, selector: selector, pathPrefix: path) + if !childMatches.isEmpty { + matches.append(contentsOf: childMatches) + } + } + + return matches +} + +private func lookupParsedPageBlock( + in blocks: [ParsedPageBlock], + selector: String +) throws -> ParsedPageBlockLookup? { + let matches = findParsedPageBlocks(in: blocks, selector: selector) + guard let match = matches.first else { + return nil + } + if parsePathSelector(selector) == nil, matches.count > 1 { + throw CLIError.invalidInput( + "Block selector is ambiguous because multiple blocks share persisted ID \(selector). Run `page ensure-block-ids` or use a path: selector." + ) + } + return match +} + +private func parsePathSelector(_ selector: String) -> [Int]? { + guard selector.hasPrefix("path:") else { return nil } + let raw = String(selector.dropFirst("path:".count)) + guard !raw.isEmpty else { return nil } + let components = raw.split(separator: "/") + guard !components.isEmpty else { return nil } + + var path: [Int] = [] + for component in components { + guard let value = Int(component) else { return nil } + path.append(value) + } + return path +} + +private func isStrictPathPrefix(_ prefix: [Int], of path: [Int]) -> Bool { + guard prefix.count < path.count else { + return false + } + return Array(path.prefix(prefix.count)) == prefix +} + +private func adjustedPathAfterRemoving(_ path: [Int], removedPath: [Int]) -> [Int]? { + if path == removedPath || isStrictPathPrefix(removedPath, of: path) { + return nil + } + + var adjusted = path + let sharedDepth = zip(path, removedPath).prefix { $0 == $1 }.count + if sharedDepth < path.count, + sharedDepth < removedPath.count, + path[sharedDepth] > removedPath[sharedDepth] { + adjusted[sharedDepth] -= 1 + } + return adjusted +} + +private func parsedPageBlock( + at path: [Int], + in blocks: [ParsedPageBlock] +) -> ParsedPageBlock? { + guard let index = path.first, blocks.indices.contains(index) else { + return nil + } + + let block = blocks[index] + if path.count == 1 { + return block + } + + return parsedPageBlock(at: Array(path.dropFirst()), in: block.children) +} + +private func parentType( + for path: [Int], + in blocks: [ParsedPageBlock] +) -> ParsedPageBlockType? { + guard !path.isEmpty else { + return nil + } + return parsedPageBlock(at: path, in: blocks)?.type +} + +private func removeParsedPageBlock( + at path: [Int], + from blocks: inout [ParsedPageBlock] +) -> ParsedPageBlock? { + guard let index = path.first else { + return nil + } + + if path.count == 1 { + guard blocks.indices.contains(index) else { + return nil + } + return blocks.remove(at: index) + } + + guard blocks.indices.contains(index) else { + return nil + } + return removeParsedPageBlock(at: Array(path.dropFirst()), from: &blocks[index].children) +} + +private func insertParsedPageBlock( + _ block: ParsedPageBlock, + at path: [Int], + in blocks: inout [ParsedPageBlock] +) -> Bool { + guard let index = path.first else { + return false + } + + if path.count == 1 { + guard index >= 0, index <= blocks.count else { + return false + } + blocks.insert(block, at: index) + return true + } + + guard blocks.indices.contains(index) else { + return false + } + return insertParsedPageBlock(block, at: Array(path.dropFirst()), in: &blocks[index].children) +} + +private func moveParsedPageBlock( + in blocks: inout [ParsedPageBlock], + sourceSelector: String, + destinationSelector: String, + placeBefore: Bool +) throws -> [Int]? { + guard let source = try lookupParsedPageBlock(in: blocks, selector: sourceSelector) else { + return nil + } + guard let destination = try lookupParsedPageBlock(in: blocks, selector: destinationSelector) else { + throw CLIError.invalidInput("Block not found: \(destinationSelector)") + } + + if source.path == destination.path { + throw CLIError.invalidInput("Source and destination blocks must be different") + } + if isStrictPathPrefix(source.path, of: destination.path) { + throw CLIError.invalidInput("Cannot move a block relative to one of its descendants") + } + + guard var movedBlock = removeParsedPageBlock(at: source.path, from: &blocks) else { + return nil + } + guard let adjustedDestinationPath = adjustedPathAfterRemoving(destination.path, removedPath: source.path), + let adjustedDestination = parsedPageBlock(at: adjustedDestinationPath, in: blocks) else { + return nil + } + + let destinationParentPath = Array(adjustedDestinationPath.dropLast()) + let destinationParentType = parentType(for: destinationParentPath, in: blocks) + movedBlock.columnIndex = destinationParentType == .column ? adjustedDestination.columnIndex : 0 + + let insertionIndex = adjustedDestinationPath.last! + (placeBefore ? 0 : 1) + let insertionPath = destinationParentPath + [insertionIndex] + guard insertParsedPageBlock(movedBlock, at: insertionPath, in: &blocks) else { + return nil + } + + return insertionPath +} + +private func applyBlockMutation( + in blocks: inout [ParsedPageBlock], + selector: String, + replacementBlocks: [ParsedPageBlock]?, + prependBlocks: [ParsedPageBlock], + appendBlocks: [ParsedPageBlock], + usedIDs: inout Set, + pathPrefix: [Int] = [], + parentType: ParsedPageBlockType? = nil +) -> ParsedPageBlockMutationResult? { + for index in blocks.indices { + let path = pathPrefix + [index] + let target = blocks[index] + let matches: Bool + if let selectorPath = parsePathSelector(selector) { + matches = selectorPath == path + } else { + matches = target.stableID && target.id == selector + } + + if matches { + let before = ParsedPageBlockLookup(block: target, path: path) + + var prefix = prependBlocks + var suffix = appendBlocks + if parentType == .column { + applyColumnIndex(to: &prefix, columnIndex: target.columnIndex) + applyColumnIndex(to: &suffix, columnIndex: target.columnIndex) + } + ensureUniqueBlockIDs(in: &prefix, usedIDs: &usedIDs) + ensureUniqueBlockIDs(in: &suffix, usedIDs: &usedIDs) + + if let replacementBlocks { + for removedID in collectBlockIDs(in: [target]) { + usedIDs.remove(removedID) + } + + var replacement = replacementBlocks + if parentType == .column { + applyColumnIndex(to: &replacement, columnIndex: target.columnIndex) + } + ensureUniqueBlockIDs(in: &replacement, usedIDs: &usedIDs) + + let replacementStartIndex = index + prefix.count + let afterPaths = replacement.indices.map { replacementOffset in + pathPrefix + [replacementStartIndex + replacementOffset] + } + blocks.replaceSubrange(index...index, with: prefix + replacement + suffix) + return ParsedPageBlockMutationResult(before: before, afterPaths: afterPaths) + } + + blocks.replaceSubrange(index...index, with: prefix + [target] + suffix) + return ParsedPageBlockMutationResult( + before: before, + afterPaths: [pathPrefix + [index + prefix.count]] + ) + } + + if !blocks[index].children.isEmpty, + let result = applyBlockMutation( + in: &blocks[index].children, + selector: selector, + replacementBlocks: replacementBlocks, + prependBlocks: prependBlocks, + appendBlocks: appendBlocks, + usedIDs: &usedIDs, + pathPrefix: path, + parentType: blocks[index].type + ) { + return result + } + } + + return nil +} + +private func updateParsedPageBlockText( + in blocks: inout [ParsedPageBlock], + selector: String, + newText: String, + pathPrefix: [Int] = [] +) throws -> [Int]? { + for index in blocks.indices { + let path = pathPrefix + [index] + let matches: Bool + if let selectorPath = parsePathSelector(selector) { + matches = selectorPath == path + } else { + matches = blocks[index].stableID && blocks[index].id == selector + } + + if matches { + guard parsedPageBlockSupportsTextMutation(blocks[index]) else { + throw CLIError.invalidInput("Block type does not support text-only updates: \(blocks[index].type.rawValue)") + } + blocks[index].text = newText + return path + } + + if !blocks[index].children.isEmpty, + let updatedPath = try updateParsedPageBlockText( + in: &blocks[index].children, + selector: selector, + newText: newText, + pathPrefix: path + ) { + return updatedPath + } + } + + return nil +} + +private func parsedPageBlockSupportsTextMutation(_ block: ParsedPageBlock) -> Bool { + switch block.type { + case .paragraph, .heading, .bulletListItem, .numberedListItem, .taskItem, .codeBlock, .blockquote, .toggle: + return true + case .horizontalRule, .image, .databaseEmbed, .pageLink, .column: + return false + } +} + +private func applyColumnIndex(to blocks: inout [ParsedPageBlock], columnIndex: Int) { + for index in blocks.indices { + blocks[index].columnIndex = columnIndex + } +} + +private func ensureUniqueBlockIDs(in blocks: inout [ParsedPageBlock], usedIDs: inout Set) { + for index in blocks.indices { + if usedIDs.contains(blocks[index].id) { + blocks[index].id = newBlockID() + blocks[index].stableID = false + } + usedIDs.insert(blocks[index].id) + if !blocks[index].children.isEmpty { + ensureUniqueBlockIDs(in: &blocks[index].children, usedIDs: &usedIDs) + } + } +} + +private func normalizeStableBlockIDs(in blocks: inout [ParsedPageBlock]) -> Bool { + var changed = false + var usedIDs = Set() + normalizeStableBlockIDs(in: &blocks, usedIDs: &usedIDs, changed: &changed) + return changed +} + +private func compactEmptyParagraphBlocks(in blocks: inout [ParsedPageBlock]) -> Bool { + var changed = false + var compacted: [ParsedPageBlock] = [] + compacted.reserveCapacity(blocks.count) + + for var block in blocks { + if !block.children.isEmpty, compactEmptyParagraphBlocks(in: &block.children) { + changed = true + } + if block.type == .paragraph, + block.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + changed = true + continue + } + compacted.append(block) + } + + if compacted.count != blocks.count { + changed = true + } + blocks = compacted + return changed +} + +private func normalizeStableBlockIDs( + in blocks: inout [ParsedPageBlock], + usedIDs: inout Set, + changed: inout Bool +) { + for index in blocks.indices { + if !blocks[index].stableID || blocks[index].id.isEmpty || usedIDs.contains(blocks[index].id) { + blocks[index].id = newBlockID() + blocks[index].stableID = true + changed = true + } else { + blocks[index].stableID = true + } + + usedIDs.insert(blocks[index].id) + if !blocks[index].children.isEmpty { + normalizeStableBlockIDs(in: &blocks[index].children, usedIDs: &usedIDs, changed: &changed) + } + } +} + +private func parseInsertedBlocks(_ markdown: String) -> [ParsedPageBlock] { + if markdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return [] + } + return PageBlockParser.parseBlocks(markdown) +} + +private func parseReplacementBlocks(_ markdown: String) -> [ParsedPageBlock] { + if markdown.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return [] + } + return PageBlockParser.parseBlocks(markdown) +} + +private func workspacePageBlockRecord(from lookup: ParsedPageBlockLookup) -> WorkspacePageBlockRecord { + let selectorID = blockSelectorID(for: lookup.block, path: lookup.path) + let json = parsedPageBlockJSON(lookup.block, path: lookup.path) + let content = PageBlockParser.serializeBlocks([lookup.block], includeBlockIDComments: false) + return WorkspacePageBlockRecord( + id: selectorID, + stableID: lookup.block.stableID, + persistedID: lookup.block.stableID ? lookup.block.id : nil, + path: lookup.path, + type: lookup.block.type.rawValue, + content: content, + json: json + ) +} + +private func pathSelector(_ path: [Int]) -> String { + "path:\(path.map(String.init).joined(separator: "/"))" +} + +private func blockSelectorID(for block: ParsedPageBlock, path: [Int]) -> String { + if block.stableID { + return block.id + } + return "path:\(path.map(String.init).joined(separator: "/"))" +} + +private func parsedPageBlocksJSON( + _ blocks: [ParsedPageBlock], + pathPrefix: [Int] = [], + includeColumnIndex: Bool = false +) -> [[String: Any]] { + blocks.enumerated().map { offset, block in + parsedPageBlockJSON( + block, + path: pathPrefix + [offset], + includeColumnIndex: includeColumnIndex + ) + } +} + +private func parsedPageBlockJSON( + _ block: ParsedPageBlock, + path: [Int], + includeColumnIndex: Bool = false +) -> [String: Any] { + var json: [String: Any] = [ + "id": blockSelectorID(for: block, path: path), + "stable_id": block.stableID, + "path": path, + "type": block.type.rawValue, + ] + + if block.stableID { + json["persisted_id"] = block.id + } + if !block.text.isEmpty || block.type == .paragraph || block.type == .heading || block.type == .blockquote || block.type == .toggle { + json["text"] = block.text + } + if block.headingLevel != 1 || block.type == .heading { + json["heading_level"] = block.headingLevel + } + if block.listDepth > 0 || block.type == .bulletListItem || block.type == .numberedListItem || block.type == .taskItem { + json["list_depth"] = block.listDepth + } + if block.type == .taskItem { + json["checked"] = block.isChecked + } + if !block.language.isEmpty { + json["language"] = block.language + } + if !block.imageSource.isEmpty { + json["image_source"] = block.imageSource + } + if !block.imageAlt.isEmpty { + json["image_alt"] = block.imageAlt + } + if let imageWidth = block.imageWidth { + json["image_width"] = imageWidth + } + if !block.databasePath.isEmpty { + json["database_path"] = block.databasePath + } + if !block.pageLinkName.isEmpty { + json["page_name"] = block.pageLinkName + } + if let textColor = block.textColor { + json["text_color"] = textColor + } + if let backgroundColor = block.backgroundColor { + json["background_color"] = backgroundColor + } + if includeColumnIndex { + json["column_index"] = block.columnIndex + } + if block.type == .toggle { + json["expanded"] = block.isExpanded + } + if !block.children.isEmpty { + json["children"] = block.children.enumerated().map { offset, child in + parsedPageBlockJSON( + child, + path: path + [offset], + includeColumnIndex: block.type == .column + ) + } + } + + return json +} + +private func newBlockID() -> String { + UUID().uuidString.lowercased() +} diff --git a/Sources/BugbookCore/Workspace/AgentWorkspaceTemplate.swift b/Sources/BugbookCore/Workspace/AgentWorkspaceTemplate.swift index ac4b816..8e6567f 100644 --- a/Sources/BugbookCore/Workspace/AgentWorkspaceTemplate.swift +++ b/Sources/BugbookCore/Workspace/AgentWorkspaceTemplate.swift @@ -31,14 +31,39 @@ public enum AgentWorkspaceTemplate { ```bash bugbook page list bugbook page get "Bugbook Strategy" +bugbook page get "Bugbook Strategy" --raw +bugbook page get "Bugbook Strategy" --raw --include-internal-comments +bugbook page get "Bugbook Strategy" --blocks +bugbook page get "Bugbook Strategy" --block-id path:3 --raw +bugbook page headings "Bugbook Strategy" +bugbook page format "Bugbook Strategy" --style commonmark --dry-run --output summary +bugbook page compact "Bugbook Strategy" --output summary +bugbook page ensure-block-ids "Bugbook Strategy" --blocks +bugbook page strip-block-ids "Bugbook Strategy" +bugbook page get "Bugbook Strategy" --section-line 110 bugbook page create "Notes/Research Summary" --title "Research Summary" cat replacement.md | bugbook page update "Bugbook Strategy" --content-file - +cat replacement.md | bugbook page update "Bugbook Strategy" --content-file - --output summary +cat roadmap.md | bugbook page update "Bugbook Strategy" --section "Roadmap" --content-file - +cat roadmap.md | bugbook page update "Bugbook Strategy" --section "Roadmap" --create-section --section-level 2 --content-file - +cat roadmap.md | bugbook page update "Bugbook Strategy" --section "Roadmap" --create-section --section-level 2 --content-file - --dry-run +cat block.md | bugbook page update "Bugbook Strategy" --block-id path:3 --content-file - +cat text.txt | bugbook page update "Bugbook Strategy" --block-id path:3 --text-file - +cat sibling.md | bugbook page update "Bugbook Strategy" --block-id path:3 --append-file - --dry-run +bugbook block list "Bugbook Strategy" +bugbook block get "Bugbook Strategy" path:3 --raw +cat block.md | bugbook block replace "Bugbook Strategy" path:3 --content-file - +cat text.txt | bugbook block update-text "Bugbook Strategy" path:3 --text-file - +cat sibling.md | bugbook block insert "Bugbook Strategy" path:3 --after --content-file - --dry-run +bugbook block move "Bugbook Strategy" path:3 path:7 --before --dry-run +bugbook block delete "Bugbook Strategy" path:3 --dry-run cat snippet.md | bugbook page update "Bugbook Strategy" --append-file - +bugbook get "Bugbook Strategy Board" row_1234abcd --fields "Title,Phase" --raw-properties bugbook backlinks "Bugbook Strategy" bugbook search "local-first agent notes" ``` -`bugbook page update` supports either a full replacement or prepend/append edits per command. +`bugbook page get --raw` prints clean markdown by default; add `--include-internal-comments` for the literal stored file. `bugbook page get --blocks` returns parsed markdown blocks plus document metadata. `bugbook page get --block-id` narrows reads to one block by stable UUID or `path:` selector. `bugbook page headings` lists headings with levels and line numbers. `bugbook page format --style bugbook|commonmark` rewrites a page using either Bugbook's dense block format or a CommonMark-style layout with structural blank lines. `bugbook page format --style commonmark` strips persisted block IDs and converts Bugbook-only block syntax into portable approximations: toggles become `
`, columns are flattened sequentially with thematic breaks, database embeds become labeled text, and page-link blocks become relative markdown links when they resolve uniquely in the workspace or plain text when they do not. `bugbook page compact` is the shortcut for `bugbook page format --style bugbook` and removes empty paragraph gaps. `bugbook page ensure-block-ids` persists unique stable block IDs and repairs duplicate persisted IDs, while `bugbook page strip-block-ids` removes those internal comments again. `bugbook page get --section` or `--section-line` narrows reads to a single heading section. `bugbook page update` supports either a full replacement or prepend/append edits per command, `--section` or `--section-line` scopes those edits to a heading body, `--block-id` scopes them to one block without polluting a clean note, `--text-file` preserves the selected block's markdown type, `--create-section` appends a missing section safely, `--dry-run` previews the resulting page plus structured line changes before writing, and `--output summary` returns a compact mutation payload. `bugbook block list`, `block get`, `block replace`, `block update-text`, `block insert`, `block move`, and `block delete` provide a dedicated block-level surface. `bugbook get` and `bugbook query --fields` return friendly property names and display values by default; add `--raw-properties` when you also need schema IDs and stored option IDs. ## Boards And Databases ```bash @@ -53,7 +78,7 @@ bugbook db view add "Bugbook Strategy Board" --type calendar --name "Calendar" - bugbook db view set-default "Bugbook Strategy Board" "Calendar" ``` -Inspect the schema before writing rows directly so you use the real property IDs and option IDs. +You can write rows using either schema IDs or friendly property/option names, but inspect the schema first when you need exact field coverage. ## Skills ```bash diff --git a/Tests/BugbookCLITests/BugbookCLITests.swift b/Tests/BugbookCLITests/BugbookCLITests.swift index ac61c7e..e5825db 100644 --- a/Tests/BugbookCLITests/BugbookCLITests.swift +++ b/Tests/BugbookCLITests/BugbookCLITests.swift @@ -95,6 +95,1434 @@ final class BugbookCLITests: XCTestCase { } } + func testPageGetSupportsRawMarkdownAndBlocks() throws { + let workspace = try makeWorkspace() + let pageContent = """ + --- + title: Structured Note + --- + + + + # Structured Note + + - First bullet + - [x] Done item + [[Another Page]] + """ + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Notes/Structured Note", + "--content-file", try writeTempFile(in: workspace, name: "structured.md", contents: pageContent) + ]) + ) + + let raw = try captureStandardOutput { + var command = try Page.Get.parseAsRoot([ + "--workspace", workspace, + "Structured Note", + "--raw" + ]) + try command.run() + } + XCTAssertEqual(raw, pageContent) + + let blocks = try runJSON( + Page.Get.parseAsRoot([ + "--workspace", workspace, + "Structured Note", + "--blocks" + ]) + ) + + let metadata = try XCTUnwrap(blocks["document_metadata"] as? [String: Any]) + XCTAssertEqual(metadata["icon"] as? String, "bolt.fill") + XCTAssertEqual(metadata["full_width"] as? Bool, true) + + let items = try XCTUnwrap(blocks["blocks"] as? [[String: Any]]) + let semanticItems = items.filter { item in + let type = item["type"] as? String + let text = item["text"] as? String + return type != "paragraph" || !(text ?? "").isEmpty + } + + XCTAssertEqual(semanticItems[0]["type"] as? String, "heading") + XCTAssertEqual(semanticItems[0]["text"] as? String, "Structured Note") + XCTAssertEqual(semanticItems[1]["type"] as? String, "bullet_list_item") + XCTAssertEqual(semanticItems[2]["type"] as? String, "task_item") + XCTAssertEqual(semanticItems[2]["checked"] as? Bool, true) + XCTAssertEqual(semanticItems[3]["type"] as? String, "page_link") + XCTAssertEqual(semanticItems[3]["page_name"] as? String, "Another Page") + } + + func testPageGetRawStripsInternalBlockIDsUnlessRequested() throws { + let workspace = try makeWorkspace() + let blockID = "11111111-1111-1111-1111-111111111111" + let content = """ + + # Raw Note + + + Body + """ + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Raw Note", + "--content-file", try writeTempFile(in: workspace, name: "raw-note.md", contents: content) + ]) + ) + + let defaultRaw = try captureStandardOutput { + var command = try Page.Get.parseAsRoot([ + "--workspace", workspace, + "Raw Note", + "--raw" + ]) + try command.run() + } + XCTAssertFalse(defaultRaw.contains("")) + } + + func testPageUpdateCanTargetMarkdownSection() throws { + let workspace = try makeWorkspace() + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Plan", + "--content-file", try writeTempFile(in: workspace, name: "plan.md", contents: """ + # Plan + + ## Roadmap + Keep this scoped. + + ## Notes + Leave this alone. + """) + ]) + ) + + let appended = try runJSON( + Page.Update.parseAsRoot([ + "--workspace", workspace, + "Plan", + "--section", "Roadmap", + "--append-file", try writeTempFile(in: workspace, name: "roadmap-append.md", contents: """ + - Add raw markdown mode + - Add block JSON mode + """) + ]) + ) + + let appendedBody = try XCTUnwrap(appended["body"] as? String) + XCTAssertTrue(appendedBody.contains("## Roadmap\nKeep this scoped.\n\n- Add raw markdown mode\n- Add block JSON mode")) + XCTAssertTrue(appendedBody.contains("## Notes\nLeave this alone.")) + + let replaced = try runJSON( + Page.Update.parseAsRoot([ + "--workspace", workspace, + "Plan", + "--section", "Notes", + "--content-file", try writeTempFile(in: workspace, name: "notes-replacement.md", contents: """ + Fresh notes only. + """) + ]) + ) + + let replacedBody = try XCTUnwrap(replaced["body"] as? String) + XCTAssertTrue(replacedBody.contains("## Notes\nFresh notes only.")) + XCTAssertFalse(replacedBody.contains("Leave this alone.")) + XCTAssertTrue(replacedBody.contains("- Add raw markdown mode")) + } + + func testPageHeadingsAndSectionCreation() throws { + let workspace = try makeWorkspace() + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Notes/Heading Note", + "--content-file", try writeTempFile(in: workspace, name: "headings.md", contents: """ + --- + title: Heading Note + --- + + # Heading Note + + ## Existing + Already here. + """) + ]) + ) + + let headings = try runJSON( + Page.Headings.parseAsRoot([ + "--workspace", workspace, + "Heading Note" + ]) + ) + let headingItems = try XCTUnwrap(headings["headings"] as? [[String: Any]]) + XCTAssertEqual(headingItems.count, 2) + XCTAssertEqual(headingItems[0]["title"] as? String, "Heading Note") + XCTAssertEqual(headingItems[0]["line"] as? Int, 5) + XCTAssertEqual(headingItems[1]["title"] as? String, "Existing") + XCTAssertEqual(headingItems[1]["level"] as? Int, 2) + + let updated = try runJSON( + Page.Update.parseAsRoot([ + "--workspace", workspace, + "Heading Note", + "--section", "Roadmap", + "--create-section", + "--section-level", "2", + "--content-file", try writeTempFile(in: workspace, name: "roadmap.md", contents: """ + Add heading discovery. + """) + ]) + ) + + let body = try XCTUnwrap(updated["body"] as? String) + XCTAssertTrue(body.contains("## Roadmap\n\nAdd heading discovery.")) + } + + func testPageGetAndUpdateCanTargetSectionLine() throws { + let workspace = try makeWorkspace() + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Notes/Repeated Headings", + "--content-file", try writeTempFile(in: workspace, name: "repeated-headings.md", contents: """ + # Repeated Headings + + ## Notes + First section. + + ## Notes + Second section. + """) + ]) + ) + + let fetched = try runJSON( + Page.Get.parseAsRoot([ + "--workspace", workspace, + "Repeated Headings", + "--section-line", "6" + ]) + ) + + let selectedSection = try XCTUnwrap(fetched["selected_section"] as? [String: Any]) + XCTAssertEqual(selectedSection["title"] as? String, "Notes") + XCTAssertEqual(selectedSection["heading_line"] as? Int, 6) + XCTAssertEqual(selectedSection["body"] as? String, "Second section.") + + let updated = try runJSON( + Page.Update.parseAsRoot([ + "--workspace", workspace, + "Repeated Headings", + "--section-line", "6", + "--content-file", try writeTempFile(in: workspace, name: "notes-update.md", contents: """ + Updated second section. + """) + ]) + ) + + let body = try XCTUnwrap(updated["body"] as? String) + XCTAssertTrue(body.contains("## Notes\nFirst section.")) + XCTAssertTrue(body.contains("## Notes\nUpdated second section.")) + XCTAssertFalse(body.contains("Second section.")) + } + + func testPageGetRespectsExplicitHeadingLevelSelectors() throws { + let workspace = try makeWorkspace() + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Notes/Level Selectors", + "--content-file", try writeTempFile(in: workspace, name: "level-selectors.md", contents: """ + # Level Selectors + + ## Roadmap + Parent section. + + ### Roadmap + Nested section. + """) + ]) + ) + + let fetched = try runJSON( + Page.Get.parseAsRoot([ + "--workspace", workspace, + "Level Selectors", + "--section", "### Roadmap" + ]) + ) + + let selectedSection = try XCTUnwrap(fetched["selected_section"] as? [String: Any]) + XCTAssertEqual(selectedSection["level"] as? Int, 3) + XCTAssertEqual(selectedSection["heading_line"] as? Int, 6) + XCTAssertEqual(selectedSection["body"] as? String, "Nested section.") + } + + func testPageGetSectionSelectorsRejectMissingMatches() throws { + let workspace = try makeWorkspace() + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Missing Selector", + "--content-file", try writeTempFile(in: workspace, name: "missing-selector.md", contents: """ + # Missing Selector + + ## Present + Exists. + """) + ]) + ) + + var missingHeading = try Page.Get.parseAsRoot([ + "--workspace", workspace, + "Missing Selector", + "--section", "Absent" + ]) + + XCTAssertThrowsError(try missingHeading.run()) { error in + guard case CLIError.invalidInput(let message) = error else { + return XCTFail("Unexpected error: \(error)") + } + XCTAssertTrue(message.contains("Heading not found")) + } + + var missingLine = try Page.Get.parseAsRoot([ + "--workspace", workspace, + "Missing Selector", + "--section-line", "99" + ]) + + XCTAssertThrowsError(try missingLine.run()) { error in + guard case CLIError.invalidInput(let message) = error else { + return XCTFail("Unexpected error: \(error)") + } + XCTAssertTrue(message.contains("Heading not found")) + } + } + + func testPageUpdateDryRunPreviewsSectionCreationWithoutWriting() throws { + let workspace = try makeWorkspace() + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Preview Note", + "--content-file", try writeTempFile(in: workspace, name: "preview-note.md", contents: """ + # Preview Note + + Existing content. + """) + ]) + ) + + let preview = try runJSON( + Page.Update.parseAsRoot([ + "--workspace", workspace, + "Preview Note", + "--section", "Roadmap", + "--create-section", + "--section-level", "2", + "--content-file", try writeTempFile(in: workspace, name: "preview-roadmap.md", contents: """ + Planned change. + """), + "--dry-run" + ]) + ) + + XCTAssertEqual(preview["dry_run"] as? Bool, true) + XCTAssertEqual(preview["changed"] as? Bool, true) + XCTAssertNil(preview["selected_section_before"]) + + let selectedSection = try XCTUnwrap(preview["selected_section"] as? [String: Any]) + XCTAssertEqual(selectedSection["title"] as? String, "Roadmap") + XCTAssertEqual(selectedSection["level"] as? Int, 2) + + let previewBody = try XCTUnwrap(preview["body"] as? String) + XCTAssertTrue(previewBody.contains("## Roadmap\n\nPlanned change.")) + + let lineChanges = try XCTUnwrap(preview["line_changes"] as? [[String: Any]]) + XCTAssertTrue(lineChanges.contains { change in + change["op"] as? String == "insert" && change["text"] as? String == "## Roadmap" + }) + + let fetched = try runJSON( + Page.Get.parseAsRoot([ + "--workspace", workspace, + "Preview Note" + ]) + ) + XCTAssertFalse((fetched["body"] as? String)?.contains("## Roadmap") == true) + } + + func testPageUpdateSummaryOutputAvoidsFullPagePayloads() throws { + let workspace = try makeWorkspace() + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Summary Mode", + "--content-file", try writeTempFile(in: workspace, name: "summary-mode.md", contents: """ + # Summary Mode + + ## Roadmap + Ship the basics. + """) + ]) + ) + + let preview = try runJSON( + Page.Update.parseAsRoot([ + "--workspace", workspace, + "Summary Mode", + "--section", "Roadmap", + "--append-file", try writeTempFile(in: workspace, name: "summary-append.md", contents: """ + - Add quieter writes + """), + "--dry-run", + "--output", "summary" + ]) + ) + + XCTAssertEqual(preview["dry_run"] as? Bool, true) + XCTAssertEqual(preview["updated"] as? Bool, true) + XCTAssertEqual(preview["changed"] as? Bool, true) + XCTAssertNil(preview["content"]) + XCTAssertNil(preview["body"]) + let selectedSection = try XCTUnwrap(preview["selected_section"] as? [String: Any]) + XCTAssertEqual(selectedSection["title"] as? String, "Roadmap") + XCTAssertNil(selectedSection["content"]) + + let updated = try runJSON( + Page.Update.parseAsRoot([ + "--workspace", workspace, + "Summary Mode", + "--section", "Roadmap", + "--append-file", try writeTempFile(in: workspace, name: "summary-apply.md", contents: """ + - Add quieter writes + """), + "--output", "summary" + ]) + ) + + XCTAssertEqual(updated["updated"] as? Bool, true) + XCTAssertEqual(updated["changed"] as? Bool, true) + XCTAssertNil(updated["content"]) + XCTAssertNil(updated["body"]) + } + + func testPageCompactRemovesEmptyParagraphGaps() throws { + let workspace = try makeWorkspace() + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Compact Me", + "--content-file", try writeTempFile(in: workspace, name: "compact-me.md", contents: """ + # Compact Me + + Updated March 7, 2026 + + --- + + ## Roadmap + + First paragraph. + + Second paragraph. + """) + ]) + ) + + let preview = try runJSON( + Page.Compact.parseAsRoot([ + "--workspace", workspace, + "Compact Me", + "--dry-run", + "--output", "summary" + ]) + ) + XCTAssertEqual(preview["dry_run"] as? Bool, true) + XCTAssertEqual(preview["operation"] as? String, "compact") + XCTAssertEqual(preview["changed"] as? Bool, true) + XCTAssertNil(preview["content"]) + XCTAssertNil(preview["body"]) + + let updated = try runJSON( + Page.Compact.parseAsRoot([ + "--workspace", workspace, + "Compact Me" + ]) + ) + + let body = try XCTUnwrap(updated["body"] as? String) + XCTAssertEqual(body, """ + # Compact Me + Updated March 7, 2026 + --- + ## Roadmap + First paragraph. + Second paragraph. + """) + + let blocks = try runJSON( + Page.Get.parseAsRoot([ + "--workspace", workspace, + "Compact Me", + "--blocks" + ]) + ) + let blockItems = try XCTUnwrap(blocks["blocks"] as? [[String: Any]]) + XCTAssertFalse(blockItems.contains { block in + (block["type"] as? String) == "paragraph" && ((block["text"] as? String) ?? "").isEmpty + }) + } + + func testPageCompactPreservesPersistedBlockIDs() throws { + let workspace = try makeWorkspace() + let headingID = "11111111-1111-1111-1111-111111111111" + let bodyID = "22222222-2222-2222-2222-222222222222" + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Compact With IDs", + "--content-file", try writeTempFile(in: workspace, name: "compact-with-ids.md", contents: """ + + # Compact With IDs + + + Paragraph body. + """) + ]) + ) + + _ = try runJSON( + Page.Compact.parseAsRoot([ + "--workspace", workspace, + "Compact With IDs" + ]) + ) + + let raw = try captureStandardOutput { + var command = try Page.Get.parseAsRoot([ + "--workspace", workspace, + "Compact With IDs", + "--raw", + "--include-internal-comments" + ]) + try command.run() + } + + XCTAssertTrue(raw.contains("")) + XCTAssertTrue(raw.contains("")) + XCTAssertFalse(raw.contains("\n\n")) + } + + func testPageCompactSummaryUsesPostWriteModifiedAt() throws { + let workspace = try makeWorkspace() + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Compact Summary", + "--content-file", try writeTempFile(in: workspace, name: "compact-summary.md", contents: """ + # Compact Summary + + Paragraph + """) + ]) + ) + + usleep(1_100_000) + + let summary = try runJSON( + Page.Compact.parseAsRoot([ + "--workspace", workspace, + "Compact Summary", + "--output", "summary" + ]) + ) + + let fetched = try runJSON( + Page.Get.parseAsRoot([ + "--workspace", workspace, + "Compact Summary" + ]) + ) + + XCTAssertEqual(summary["modified_at"] as? String, fetched["modified_at"] as? String) + } + + func testPageFormatCommonMarkAddsStructuralBlankLines() throws { + let workspace = try makeWorkspace() + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Format CommonMark", + "--content-file", try writeTempFile(in: workspace, name: "format-commonmark.md", contents: """ + # Format CommonMark + First paragraph + Second paragraph + - First bullet + - Second bullet + ## Next + Tail paragraph + """) + ]) + ) + + let preview = try runJSON( + Page.Format.parseAsRoot([ + "--workspace", workspace, + "Format CommonMark", + "--style", "commonmark", + "--dry-run", + "--output", "summary" + ]) + ) + + XCTAssertEqual(preview["operation"] as? String, "format") + XCTAssertEqual(preview["format_style"] as? String, "commonmark") + XCTAssertEqual(preview["changed"] as? Bool, true) + + let updated = try runJSON( + Page.Format.parseAsRoot([ + "--workspace", workspace, + "Format CommonMark", + "--style", "commonmark" + ]) + ) + + XCTAssertEqual(updated["format_style"] as? String, "commonmark") + let body = try XCTUnwrap(updated["body"] as? String) + XCTAssertEqual(body, """ + # Format CommonMark + + First paragraph + + Second paragraph + + - First bullet + - Second bullet + + ## Next + + Tail paragraph + """) + } + + func testPageFormatCommonMarkRemovesBugbookCommentSyntax() throws { + let workspace = try makeWorkspace() + let blockID = "11111111-1111-1111-1111-111111111111" + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Format Portable", + "--content-file", try writeTempFile(in: workspace, name: "format-portable.md", contents: """ + + # Format Portable + + Toggle Title + Toggle body + + + Left column + + Right column + + + """) + ]) + ) + + _ = try runJSON( + Page.Format.parseAsRoot([ + "--workspace", workspace, + "Format Portable", + "--style", "commonmark" + ]) + ) + + let raw = try captureStandardOutput { + var command = try Page.Get.parseAsRoot([ + "--workspace", workspace, + "Format Portable", + "--raw", + "--include-internal-comments" + ]) + try command.run() + } + + XCTAssertFalse(raw.contains("")) + XCTAssertFalse(raw.contains(" + # Duplicate Block IDs + + First paragraph + """) + ]) + ) + + var ambiguousGet = try Page.Get.parseAsRoot([ + "--workspace", workspace, + "Duplicate Block IDs", + "--block-id", duplicateID + ]) + + XCTAssertThrowsError(try ambiguousGet.run()) { error in + guard case CLIError.invalidInput(let message) = error else { + return XCTFail("Unexpected error: \(error)") + } + XCTAssertTrue(message.contains("ambiguous")) + } + + let ensured = try runJSON( + Page.EnsureBlockIDs.parseAsRoot([ + "--workspace", workspace, + "Duplicate Block IDs", + "--blocks" + ]) + ) + + XCTAssertEqual(ensured["changed"] as? Bool, true) + let blocks = try XCTUnwrap(ensured["blocks"] as? [[String: Any]]) + let ids = try blocks.map { block -> String in + try XCTUnwrap(block["id"] as? String) + } + XCTAssertEqual(Set(ids).count, ids.count) + XCTAssertTrue(blocks.allSatisfy { ($0["stable_id"] as? Bool) == true }) + } + + func testPageGetAndUpdateCanTargetBlockSelectors() throws { + let workspace = try makeWorkspace() + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Block Target", + "--content-file", try writeTempFile(in: workspace, name: "block-target.md", contents: """ + # Block Target + First paragraph + Second paragraph + """) + ]) + ) + + let initialBlocks = try runJSON( + Page.Get.parseAsRoot([ + "--workspace", workspace, + "Block Target", + "--blocks" + ]) + ) + let initialItems = try XCTUnwrap(initialBlocks["blocks"] as? [[String: Any]]) + XCTAssertEqual(initialItems.count, 3) + XCTAssertEqual(initialItems[1]["id"] as? String, "path:1") + XCTAssertEqual(initialItems[1]["stable_id"] as? Bool, false) + + let rawBlock = try captureStandardOutput { + var command = try Page.Get.parseAsRoot([ + "--workspace", workspace, + "Block Target", + "--block-id", "path:1", + "--raw" + ]) + try command.run() + } + XCTAssertEqual(rawBlock, "First paragraph") + + let preview = try runJSON( + Page.Update.parseAsRoot([ + "--workspace", workspace, + "Block Target", + "--block-id", "path:1", + "--content-file", try writeTempFile(in: workspace, name: "block-replacement.md", contents: """ + Updated paragraph + """), + "--dry-run" + ]) + ) + XCTAssertEqual(preview["dry_run"] as? Bool, true) + let selectedBlockBefore = try XCTUnwrap(preview["selected_block_before"] as? [String: Any]) + XCTAssertEqual(selectedBlockBefore["id"] as? String, "path:1") + let selectedBlocksAfter = try XCTUnwrap(preview["selected_blocks_after"] as? [[String: Any]]) + XCTAssertEqual(selectedBlocksAfter.count, 1) + XCTAssertEqual(selectedBlocksAfter[0]["stable_id"] as? Bool, false) + + let updated = try runJSON( + Page.Update.parseAsRoot([ + "--workspace", workspace, + "Block Target", + "--block-id", "path:1", + "--content-file", try writeTempFile(in: workspace, name: "block-apply.md", contents: """ + Updated paragraph + """) + ]) + ) + let body = try XCTUnwrap(updated["body"] as? String) + XCTAssertTrue(body.contains("Updated paragraph")) + + let blocksAfter = try runJSON( + Page.Get.parseAsRoot([ + "--workspace", workspace, + "Block Target", + "--blocks" + ]) + ) + let updatedItems = try XCTUnwrap(blocksAfter["blocks"] as? [[String: Any]]) + XCTAssertTrue(updatedItems.allSatisfy { ($0["stable_id"] as? Bool) == false }) + XCTAssertEqual(updatedItems[1]["text"] as? String, "Updated paragraph") + } + + func testPageUpdateBlockTextPreservesMarkdownType() throws { + let workspace = try makeWorkspace() + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Typed Block", + "--content-file", try writeTempFile(in: workspace, name: "typed-block.md", contents: """ + # Typed Block + - Original bullet + """) + ]) + ) + + let preview = try runJSON( + Page.Update.parseAsRoot([ + "--workspace", workspace, + "Typed Block", + "--block-id", "path:1", + "--text-file", try writeTempFile(in: workspace, name: "typed-block-preview.md", contents: "Updated bullet"), + "--dry-run" + ]) + ) + let selectedBlocksAfter = try XCTUnwrap(preview["selected_blocks_after"] as? [[String: Any]]) + XCTAssertEqual(selectedBlocksAfter.first?["type"] as? String, "bullet_list_item") + XCTAssertEqual(selectedBlocksAfter.first?["text"] as? String, "Updated bullet") + + let updated = try runJSON( + Page.Update.parseAsRoot([ + "--workspace", workspace, + "Typed Block", + "--block-id", "path:1", + "--text-file", try writeTempFile(in: workspace, name: "typed-block-apply.md", contents: "Updated bullet") + ]) + ) + + let body = try XCTUnwrap(updated["body"] as? String) + XCTAssertTrue(body.contains("- Updated bullet")) + XCTAssertFalse(body.contains(" + Parent + Child + + Sibling + """) + ]) + ) + + var invalidMove = try Block.Move.parseAsRoot([ + "--workspace", workspace, + "Nested Move", + "path:0", + "path:0/0", + "--after" + ]) + + XCTAssertThrowsError(try invalidMove.run()) { error in + guard case CLIError.invalidInput(let message) = error else { + return XCTFail("Unexpected error: \(error)") + } + XCTAssertTrue(message.contains("descendants")) + } + } + + func testPageStripBlockIDsRemovesPersistedComments() throws { + let workspace = try makeWorkspace() + let blockID = "11111111-1111-1111-1111-111111111111" + + _ = try runJSON( + Page.Create.parseAsRoot([ + "--workspace", workspace, + "Strip Block IDs", + "--content-file", try writeTempFile(in: workspace, name: "strip-block-ids.md", contents: """ + + # Strip Block IDs + """) + ]) + ) + + let stripped = try runJSON( + Page.StripBlockIDs.parseAsRoot([ + "--workspace", workspace, + "Strip Block IDs" + ]) + ) + XCTAssertEqual(stripped["changed"] as? Bool, true) + XCTAssertEqual(stripped["stable_block_ids"] as? Bool, false) + + let raw = try captureStandardOutput { + var command = try Page.Get.parseAsRoot([ + "--workspace", workspace, + "Strip Block IDs", + "--raw", + "--include-internal-comments" + ]) + try command.run() + } + XCTAssertFalse(raw.contains(" + # Title + + Body + """ + + let doc = BlockDocument(markdown: markdown) + XCTAssertEqual(doc.blocks[0].id.uuidString.lowercased(), titleID) + XCTAssertEqual(doc.blocks[1].id.uuidString.lowercased(), bodyID) + + let output = doc.markdown + XCTAssertTrue(output.contains("")) + XCTAssertTrue(output.contains("")) + } + + func testMarkdownIgnoresSingleTrailingNewlineAsExtraBlock() { + let doc = BlockDocument(markdown: "# Title\nBody\n") + XCTAssertEqual(doc.blocks.count, 2) + XCTAssertEqual(doc.blocks[0].text, "Title") + XCTAssertEqual(doc.blocks[1].text, "Body") + } + + func testMarkdownEscapesParagraphSyntaxToPreserveParagraphBlocks() { + let doc = BlockDocument(markdown: "Paragraph\n") + doc.blocks[0].text = "- not a list" + + let output = doc.markdown + XCTAssertEqual(output, "\\- not a list") + + let reparsed = BlockDocument(markdown: output) + XCTAssertEqual(reparsed.blocks.count, 1) + XCTAssertEqual(reparsed.blocks[0].type, .paragraph) + XCTAssertEqual(reparsed.blocks[0].text, "- not a list") + } } // MARK: - AppState Tests diff --git a/scripts/install-bugbook-cli.sh b/scripts/install-bugbook-cli.sh new file mode 100755 index 0000000..73fe304 --- /dev/null +++ b/scripts/install-bugbook-cli.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +swift run BugbookCLI install --force "$@"