diff --git a/internal/cli/sync.go b/internal/cli/sync.go new file mode 100644 index 0000000..8bea52f --- /dev/null +++ b/internal/cli/sync.go @@ -0,0 +1,168 @@ +package cli + +import ( + "fmt" + + "github.com/paradise-runner/cider/internal/convert" + "github.com/paradise-runner/cider/internal/frontmatter" + "github.com/paradise-runner/cider/internal/markdown" + "github.com/paradise-runner/cider/internal/notes" + syncpkg "github.com/paradise-runner/cider/internal/sync" + "github.com/spf13/cobra" +) + +var syncStrategy string + +var syncCmd = &cobra.Command{ + Use: "sync ", + Short: "Sync Markdown file(s) with their linked Apple Notes", + Long: `Sync Markdown file(s) with their linked Apple Notes by performing a +block-level merge. Blocks are structural markdown elements such as headings, +paragraphs, lists, code blocks, and blockquotes. + +The default strategy is "union", which keeps blocks from both sides. Use +--strategy to change the merge behavior: + + union Include blocks from both local and remote (default) + prefer-local Keep local blocks, discard remote-only blocks + prefer-remote Keep remote blocks, discard local-only blocks + +The file(s) must have an apple_notes_id in their frontmatter. +If a directory is provided, all Markdown files (.md, .markdown) will be +processed recursively.`, + Args: cobra.ExactArgs(1), + RunE: runSync, +} + +func init() { + syncCmd.Flags().StringVar(&syncStrategy, "strategy", "union", + `merge strategy: "union", "prefer-local", or "prefer-remote"`) + rootCmd.AddCommand(syncCmd) +} + +func runSync(cmd *cobra.Command, args []string) error { + path := args[0] + + strategy, err := parseStrategy(syncStrategy) + if err != nil { + return err + } + + // Resolve path to list of markdown files + files, err := resolvePaths(path) + if err != nil { + return fmt.Errorf("Error: %v", err) + } + + if len(files) == 0 { + return fmt.Errorf("Error: No markdown files found in %s", path) + } + + // Initialize notes client + notesClient := notes.NewClient() + + // Process each file + for _, filePath := range files { + if err := syncSingleFile(filePath, notesClient, strategy); err != nil { + fmt.Printf("Error processing %s: %v\n", filePath, err) + // Continue processing other files + } + } + + return nil +} + +func syncSingleFile(filePath string, notesClient *notes.Client, strategy syncpkg.MergeStrategy) error { + // Read markdown file + fmt.Printf("Reading file: %s\n", filePath) + markdownContent, err := markdown.ReadFile(filePath) + if err != nil { + return fmt.Errorf("file not found: %s", filePath) + } + + // Extract ID from frontmatter (required for sync) + noteID, err := frontmatter.GetAppleNotesID(markdownContent) + if err != nil { + return fmt.Errorf("no apple_notes_id found in frontmatter") + } + + // Find note in Apple Notes + fmt.Println("Searching for note...") + found, err := notesClient.Find(noteID) + if err != nil { + return fmt.Errorf("failed to search for note: %v", err) + } + if !found { + return fmt.Errorf("note not found in Apple Notes") + } + + // Read note content + fmt.Println("Reading note content...") + htmlContent, err := notesClient.Read(noteID) + if err != nil { + return fmt.Errorf("failed to read note content: %v", err) + } + + // Convert remote HTML to Markdown + remoteMarkdown, err := convert.HTMLToMarkdown(htmlContent) + if err != nil { + return fmt.Errorf("failed to convert HTML to markdown: %v", err) + } + + // Strip frontmatter from local content for comparison + localBody := frontmatter.Strip(markdownContent) + + // Run block-level sync + result := syncpkg.Sync(localBody, remoteMarkdown, strategy) + + if !syncpkg.HasChanges(result.Changes) { + fmt.Println("Already in sync.") + return nil + } + + fmt.Printf("Changes: %s\n", result.Summary) + + // Rebuild local file: existing frontmatter + merged body + existingFrontmatter := frontmatter.Extract(markdownContent) + var updatedContent string + if existingFrontmatter != "" { + updatedContent = existingFrontmatter + "\n" + result.Merged + } else { + updatedContent = result.Merged + } + + // Write merged content back to local file + err = markdown.WriteFile(filePath, updatedContent) + if err != nil { + return fmt.Errorf("failed to write to file: %v", err) + } + fmt.Printf("File updated: %s\n", filePath) + + // Push merged content to Apple Notes + fmt.Println("Updating Apple Note...") + mergedHTML, err := convert.MarkdownToHTML(result.Merged) + if err != nil { + return fmt.Errorf("failed to convert merged markdown to HTML: %v", err) + } + + err = notesClient.Update(noteID, mergedHTML) + if err != nil { + return fmt.Errorf("failed to update note: %v", err) + } + + fmt.Printf("Note updated: %s\n", noteID) + return nil +} + +func parseStrategy(s string) (syncpkg.MergeStrategy, error) { + switch s { + case "union": + return syncpkg.MergeUnion, nil + case "prefer-local": + return syncpkg.MergePreferLocal, nil + case "prefer-remote": + return syncpkg.MergePreferRemote, nil + default: + return 0, fmt.Errorf("unknown strategy %q: use \"union\", \"prefer-local\", or \"prefer-remote\"", s) + } +} diff --git a/internal/sync/block.go b/internal/sync/block.go new file mode 100644 index 0000000..eb813ad --- /dev/null +++ b/internal/sync/block.go @@ -0,0 +1,260 @@ +package sync + +import ( + "regexp" + "strings" +) + +// BlockType identifies the kind of markdown structural element +type BlockType int + +const ( + BlockParagraph BlockType = iota // Default text block + BlockHeading // ATX heading (# through ######) + BlockFencedCode // Fenced code block (``` or ~~~) + BlockBlockquote // Blockquote (> prefix) + BlockList // Ordered or unordered list + BlockHorizontalRule // Thematic break (---, ***, ___) +) + +// Block represents a single structural element of a markdown document +type Block struct { + Type BlockType + Content string // raw markdown text of the block (without surrounding blank lines) +} + +var ( + headingRegex = regexp.MustCompile(`^#{1,6}(\s|$)`) + hrDashRegex = regexp.MustCompile(`^\s{0,3}(-\s*){3,}$`) + hrStarRegex = regexp.MustCompile(`^\s{0,3}(\*\s*){3,}$`) + hrUnderRegex = regexp.MustCompile(`^\s{0,3}(_\s*){3,}$`) + ulRegex = regexp.MustCompile(`^\s{0,3}[-*+]\s`) + olRegex = regexp.MustCompile(`^\s{0,3}\d{1,9}[.)]\s`) + fenceRegex = regexp.MustCompile("^\\s{0,3}(`{3,}|~{3,})") +) + +// Parse splits markdown content into a sequence of blocks. +// It recognises headings, fenced code blocks, blockquotes, lists, +// horizontal rules, and paragraphs. Blank lines between blocks are +// consumed as separators and not represented in the output. +func Parse(content string) []Block { + if strings.TrimSpace(content) == "" { + return nil + } + + lines := strings.Split(content, "\n") + var blocks []Block + i := 0 + + for i < len(lines) { + // Skip blank lines (separators between blocks) + if isBlankLine(lines[i]) { + i++ + continue + } + + // Fenced code block + if m := fenceRegex.FindString(lines[i]); m != "" { + block, next := parseFencedCode(lines, i, strings.TrimSpace(m)) + blocks = append(blocks, block) + i = next + continue + } + + // ATX heading + if headingRegex.MatchString(lines[i]) { + blocks = append(blocks, Block{Type: BlockHeading, Content: lines[i]}) + i++ + continue + } + + // Horizontal rule (must be checked before list, since --- could be either) + if isHorizontalRule(lines[i]) { + blocks = append(blocks, Block{Type: BlockHorizontalRule, Content: lines[i]}) + i++ + continue + } + + // Blockquote + if strings.HasPrefix(strings.TrimLeft(lines[i], " "), ">") { + block, next := parseBlockquote(lines, i) + blocks = append(blocks, block) + i = next + continue + } + + // List + if isListItem(lines[i]) { + block, next := parseList(lines, i) + blocks = append(blocks, block) + i = next + continue + } + + // Default: paragraph + block, next := parseParagraph(lines, i) + blocks = append(blocks, block) + i = next + } + + return blocks +} + +// Render reassembles a sequence of blocks into markdown text, +// separating each block with a blank line. +func Render(blocks []Block) string { + if len(blocks) == 0 { + return "" + } + parts := make([]string, len(blocks)) + for i, b := range blocks { + parts[i] = b.Content + } + return strings.Join(parts, "\n\n") + "\n" +} + +// --- helpers ----------------------------------------------------------------- + +func isBlankLine(line string) bool { + return strings.TrimSpace(line) == "" +} + +func isHorizontalRule(line string) bool { + return hrDashRegex.MatchString(line) || hrStarRegex.MatchString(line) || hrUnderRegex.MatchString(line) +} + +func isListItem(line string) bool { + return ulRegex.MatchString(line) || olRegex.MatchString(line) +} + +func parseFencedCode(lines []string, start int, fence string) (Block, int) { + // fence is the trimmed opening marker (e.g. "```" or "~~~") + fenceChar := fence[0] + fenceLen := len(fence) + var collected []string + collected = append(collected, lines[start]) + + i := start + 1 + for i < len(lines) { + collected = append(collected, lines[i]) + trimmed := strings.TrimSpace(lines[i]) + // Closing fence: same character, at least as many, and nothing else + if len(trimmed) >= fenceLen && trimmed == strings.Repeat(string(fenceChar), len(trimmed)) { + i++ + break + } + i++ + } + + return Block{ + Type: BlockFencedCode, + Content: strings.Join(collected, "\n"), + }, i +} + +func parseBlockquote(lines []string, start int) (Block, int) { + var collected []string + i := start + for i < len(lines) { + if strings.HasPrefix(strings.TrimLeft(lines[i], " "), ">") { + collected = append(collected, lines[i]) + i++ + } else if isBlankLine(lines[i]) { + // A blank line ends the blockquote + break + } else { + // Lazy continuation: a non-blank, non-quote line continues the + // blockquote only if the previous line was a quote line. This is + // a simplification; CommonMark has more nuanced rules. + break + } + } + return Block{ + Type: BlockBlockquote, + Content: strings.Join(collected, "\n"), + }, i +} + +func parseList(lines []string, start int) (Block, int) { + var collected []string + i := start + + for i < len(lines) { + if isListItem(lines[i]) { + collected = append(collected, lines[i]) + i++ + continue + } + + // Indented continuation line (part of the current list item) + if !isBlankLine(lines[i]) && (strings.HasPrefix(lines[i], " ") || strings.HasPrefix(lines[i], "\t")) { + collected = append(collected, lines[i]) + i++ + continue + } + + // Blank line: could be inside a loose list or the end of the list + if isBlankLine(lines[i]) { + // Peek ahead: if the next non-blank line is a list item or + // indented continuation, the blank line is part of the list. + j := i + for j < len(lines) && isBlankLine(lines[j]) { + j++ + } + if j < len(lines) && (isListItem(lines[j]) || strings.HasPrefix(lines[j], " ") || strings.HasPrefix(lines[j], "\t")) { + // Include the blank line(s) as part of the list + for i < j { + collected = append(collected, lines[i]) + i++ + } + continue + } + // End of list + break + } + + // Non-list, non-indented, non-blank → end of list + break + } + + return Block{ + Type: BlockList, + Content: strings.Join(collected, "\n"), + }, i +} + +func parseParagraph(lines []string, start int) (Block, int) { + var collected []string + i := start + + for i < len(lines) { + if isBlankLine(lines[i]) { + break + } + // If the line starts a different block type, stop the paragraph + if i > start { + if headingRegex.MatchString(lines[i]) { + break + } + if fenceRegex.MatchString(lines[i]) { + break + } + if isHorizontalRule(lines[i]) { + break + } + if strings.HasPrefix(strings.TrimLeft(lines[i], " "), ">") { + break + } + if isListItem(lines[i]) { + break + } + } + collected = append(collected, lines[i]) + i++ + } + + return Block{ + Type: BlockParagraph, + Content: strings.Join(collected, "\n"), + }, i +} diff --git a/internal/sync/block_test.go b/internal/sync/block_test.go new file mode 100644 index 0000000..2c3566f --- /dev/null +++ b/internal/sync/block_test.go @@ -0,0 +1,303 @@ +package sync + +import ( + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + input string + want []Block + }{ + { + name: "empty string", + input: "", + want: nil, + }, + { + name: "whitespace only", + input: " \n\n \n", + want: nil, + }, + { + name: "single heading", + input: "# Hello", + want: []Block{ + {Type: BlockHeading, Content: "# Hello"}, + }, + }, + { + name: "heading levels", + input: "# H1\n\n## H2\n\n### H3\n\n#### H4\n\n##### H5\n\n###### H6", + want: []Block{ + {Type: BlockHeading, Content: "# H1"}, + {Type: BlockHeading, Content: "## H2"}, + {Type: BlockHeading, Content: "### H3"}, + {Type: BlockHeading, Content: "#### H4"}, + {Type: BlockHeading, Content: "##### H5"}, + {Type: BlockHeading, Content: "###### H6"}, + }, + }, + { + name: "single paragraph", + input: "This is a paragraph.", + want: []Block{ + {Type: BlockParagraph, Content: "This is a paragraph."}, + }, + }, + { + name: "multi-line paragraph", + input: "Line one\nLine two\nLine three", + want: []Block{ + {Type: BlockParagraph, Content: "Line one\nLine two\nLine three"}, + }, + }, + { + name: "two paragraphs", + input: "First paragraph.\n\nSecond paragraph.", + want: []Block{ + {Type: BlockParagraph, Content: "First paragraph."}, + {Type: BlockParagraph, Content: "Second paragraph."}, + }, + }, + { + name: "heading and paragraph", + input: "# Title\n\nSome text here.", + want: []Block{ + {Type: BlockHeading, Content: "# Title"}, + {Type: BlockParagraph, Content: "Some text here."}, + }, + }, + { + name: "fenced code block with backticks", + input: "```go\nfmt.Println(\"hello\")\n```", + want: []Block{ + {Type: BlockFencedCode, Content: "```go\nfmt.Println(\"hello\")\n```"}, + }, + }, + { + name: "fenced code block with tildes", + input: "~~~\nsome code\n~~~", + want: []Block{ + {Type: BlockFencedCode, Content: "~~~\nsome code\n~~~"}, + }, + }, + { + name: "fenced code with blank lines inside", + input: "```\nline 1\n\nline 3\n```", + want: []Block{ + {Type: BlockFencedCode, Content: "```\nline 1\n\nline 3\n```"}, + }, + }, + { + name: "unordered list with dashes", + input: "- Item 1\n- Item 2\n- Item 3", + want: []Block{ + {Type: BlockList, Content: "- Item 1\n- Item 2\n- Item 3"}, + }, + }, + { + name: "unordered list with asterisks", + input: "* Item 1\n* Item 2", + want: []Block{ + {Type: BlockList, Content: "* Item 1\n* Item 2"}, + }, + }, + { + name: "ordered list", + input: "1. First\n2. Second\n3. Third", + want: []Block{ + {Type: BlockList, Content: "1. First\n2. Second\n3. Third"}, + }, + }, + { + name: "list with continuation", + input: "- Item 1\n continued\n- Item 2", + want: []Block{ + {Type: BlockList, Content: "- Item 1\n continued\n- Item 2"}, + }, + }, + { + name: "blockquote single line", + input: "> A quote", + want: []Block{ + {Type: BlockBlockquote, Content: "> A quote"}, + }, + }, + { + name: "blockquote multiple lines", + input: "> Line 1\n> Line 2\n> Line 3", + want: []Block{ + {Type: BlockBlockquote, Content: "> Line 1\n> Line 2\n> Line 3"}, + }, + }, + { + name: "horizontal rule dashes", + input: "---", + want: []Block{ + {Type: BlockHorizontalRule, Content: "---"}, + }, + }, + { + name: "horizontal rule asterisks", + input: "***", + want: []Block{ + {Type: BlockHorizontalRule, Content: "***"}, + }, + }, + { + name: "horizontal rule underscores", + input: "___", + want: []Block{ + {Type: BlockHorizontalRule, Content: "___"}, + }, + }, + { + name: "full document", + input: `# My Document + +This is the introduction paragraph. + +## Section One + +Some content here. + +- Item A +- Item B +- Item C + +## Section Two + +> A wise quote + +Final thoughts.`, + want: []Block{ + {Type: BlockHeading, Content: "# My Document"}, + {Type: BlockParagraph, Content: "This is the introduction paragraph."}, + {Type: BlockHeading, Content: "## Section One"}, + {Type: BlockParagraph, Content: "Some content here."}, + {Type: BlockList, Content: "- Item A\n- Item B\n- Item C"}, + {Type: BlockHeading, Content: "## Section Two"}, + {Type: BlockBlockquote, Content: "> A wise quote"}, + {Type: BlockParagraph, Content: "Final thoughts."}, + }, + }, + { + name: "code block between paragraphs", + input: "Before code.\n\n```\ncode here\n```\n\nAfter code.", + want: []Block{ + {Type: BlockParagraph, Content: "Before code."}, + {Type: BlockFencedCode, Content: "```\ncode here\n```"}, + {Type: BlockParagraph, Content: "After code."}, + }, + }, + { + name: "loose list with blank lines", + input: "- Item 1\n\n- Item 2\n\n- Item 3", + want: []Block{ + {Type: BlockList, Content: "- Item 1\n\n- Item 2\n\n- Item 3"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Parse(tt.input) + + if len(got) != len(tt.want) { + t.Fatalf("Parse() returned %d blocks, want %d\nGot: %v", len(got), len(tt.want), got) + } + + for i := range got { + if got[i].Type != tt.want[i].Type { + t.Errorf("block[%d].Type = %d, want %d", i, got[i].Type, tt.want[i].Type) + } + if got[i].Content != tt.want[i].Content { + t.Errorf("block[%d].Content = %q, want %q", i, got[i].Content, tt.want[i].Content) + } + } + }) + } +} + +func TestRender(t *testing.T) { + tests := []struct { + name string + blocks []Block + want string + }{ + { + name: "empty", + blocks: nil, + want: "", + }, + { + name: "single block", + blocks: []Block{ + {Type: BlockHeading, Content: "# Title"}, + }, + want: "# Title\n", + }, + { + name: "multiple blocks", + blocks: []Block{ + {Type: BlockHeading, Content: "# Title"}, + {Type: BlockParagraph, Content: "A paragraph."}, + {Type: BlockList, Content: "- Item 1\n- Item 2"}, + }, + want: "# Title\n\nA paragraph.\n\n- Item 1\n- Item 2\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Render(tt.blocks) + if got != tt.want { + t.Errorf("Render() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseRenderRoundTrip(t *testing.T) { + // A well-formed document should survive parse→render without meaningful + // content loss (though exact whitespace may differ). + input := `# Title + +First paragraph with some text. + +## Subsection + +- Item 1 +- Item 2 +- Item 3 + +> A blockquote line +> continues here + +` + "```" + `python +def hello(): + print("world") +` + "```" + ` + +Final paragraph. +` + + blocks := Parse(input) + output := Render(blocks) + reparsed := Parse(output) + + if len(blocks) != len(reparsed) { + t.Fatalf("round-trip block count: got %d, original %d", len(reparsed), len(blocks)) + } + + for i := range blocks { + if blocks[i].Type != reparsed[i].Type { + t.Errorf("round-trip block[%d].Type = %d, want %d", i, reparsed[i].Type, blocks[i].Type) + } + if blocks[i].Content != reparsed[i].Content { + t.Errorf("round-trip block[%d].Content = %q, want %q", i, reparsed[i].Content, blocks[i].Content) + } + } +} diff --git a/internal/sync/diff.go b/internal/sync/diff.go new file mode 100644 index 0000000..9647dd2 --- /dev/null +++ b/internal/sync/diff.go @@ -0,0 +1,120 @@ +package sync + +// DiffOpType describes what happened to a block between two document versions +type DiffOpType int + +const ( + // OpEqual means the block exists in both versions with identical content + OpEqual DiffOpType = iota + // OpLocalOnly means the block exists only in the local version + OpLocalOnly + // OpRemoteOnly means the block exists only in the remote version + OpRemoteOnly +) + +// DiffOp represents a single operation in a block-level diff +type DiffOp struct { + Type DiffOpType + Local *Block // set for OpEqual and OpLocalOnly + Remote *Block // set for OpEqual and OpRemoteOnly +} + +// Diff computes a block-level diff between local and remote block sequences. +// It uses the Longest Common Subsequence (LCS) algorithm to align matching +// blocks, then produces a sequence of DiffOp values describing which blocks +// are shared and which are unique to each side. +func Diff(local, remote []Block) []DiffOp { + lcs := computeLCS(local, remote) + return buildDiffOps(local, remote, lcs) +} + +// computeLCS returns the longest common subsequence of two block slices. +// Blocks are compared by content equality. +func computeLCS(a, b []Block) []Block { + m, n := len(a), len(b) + // Build DP table + dp := make([][]int, m+1) + for i := range dp { + dp[i] = make([]int, n+1) + } + for i := 1; i <= m; i++ { + for j := 1; j <= n; j++ { + if a[i-1].Content == b[j-1].Content { + dp[i][j] = dp[i-1][j-1] + 1 + } else if dp[i-1][j] >= dp[i][j-1] { + dp[i][j] = dp[i-1][j] + } else { + dp[i][j] = dp[i][j-1] + } + } + } + + // Backtrack to find the actual subsequence + length := dp[m][n] + result := make([]Block, length) + i, j := m, n + k := length - 1 + for k >= 0 { + if a[i-1].Content == b[j-1].Content { + result[k] = a[i-1] + i-- + j-- + k-- + } else if dp[i-1][j] >= dp[i][j-1] { + i-- + } else { + j-- + } + } + + return result +} + +// buildDiffOps walks local and remote block sequences aligned by their LCS +// and emits DiffOp values in document order. +func buildDiffOps(local, remote []Block, lcs []Block) []DiffOp { + var ops []DiffOp + li, ri, lci := 0, 0, 0 + + for lci < len(lcs) { + target := lcs[lci].Content + + // Emit local-only blocks before the next LCS match + for li < len(local) && local[li].Content != target { + b := local[li] + ops = append(ops, DiffOp{Type: OpLocalOnly, Local: &b}) + li++ + } + + // Emit remote-only blocks before the next LCS match + for ri < len(remote) && remote[ri].Content != target { + b := remote[ri] + ops = append(ops, DiffOp{Type: OpRemoteOnly, Remote: &b}) + ri++ + } + + // Emit the matched block + lb := local[li] + rb := remote[ri] + ops = append(ops, DiffOp{Type: OpEqual, Local: &lb, Remote: &rb}) + li++ + ri++ + lci++ + } + + // Remaining local-only blocks after last LCS element + for li < len(local) { + b := local[li] + ops = append(ops, DiffOp{Type: OpLocalOnly, Local: &b}) + li++ + } + + // Remaining remote-only blocks after last LCS element + for ri < len(remote) { + b := remote[ri] + ops = append(ops, DiffOp{Type: OpRemoteOnly, Remote: &b}) + ri++ + } + + return ops +} diff --git a/internal/sync/diff_test.go b/internal/sync/diff_test.go new file mode 100644 index 0000000..ec453fe --- /dev/null +++ b/internal/sync/diff_test.go @@ -0,0 +1,230 @@ +package sync + +import ( + "testing" +) + +func TestDiffIdentical(t *testing.T) { + blocks := []Block{ + {Type: BlockHeading, Content: "# Title"}, + {Type: BlockParagraph, Content: "Paragraph one."}, + {Type: BlockParagraph, Content: "Paragraph two."}, + } + + ops := Diff(blocks, blocks) + + if len(ops) != 3 { + t.Fatalf("Diff() returned %d ops, want 3", len(ops)) + } + for i, op := range ops { + if op.Type != OpEqual { + t.Errorf("ops[%d].Type = %d, want OpEqual", i, op.Type) + } + } +} + +func TestDiffLocalAddition(t *testing.T) { + local := []Block{ + {Type: BlockHeading, Content: "# Title"}, + {Type: BlockParagraph, Content: "New paragraph."}, + {Type: BlockParagraph, Content: "Shared paragraph."}, + } + remote := []Block{ + {Type: BlockHeading, Content: "# Title"}, + {Type: BlockParagraph, Content: "Shared paragraph."}, + } + + ops := Diff(local, remote) + + expected := []DiffOpType{OpEqual, OpLocalOnly, OpEqual} + if len(ops) != len(expected) { + t.Fatalf("Diff() returned %d ops, want %d", len(ops), len(expected)) + } + for i, op := range ops { + if op.Type != expected[i] { + t.Errorf("ops[%d].Type = %d, want %d", i, op.Type, expected[i]) + } + } + if ops[1].Local.Content != "New paragraph." { + t.Errorf("ops[1].Local.Content = %q, want %q", ops[1].Local.Content, "New paragraph.") + } +} + +func TestDiffRemoteAddition(t *testing.T) { + local := []Block{ + {Type: BlockHeading, Content: "# Title"}, + {Type: BlockParagraph, Content: "Shared paragraph."}, + } + remote := []Block{ + {Type: BlockHeading, Content: "# Title"}, + {Type: BlockParagraph, Content: "Remote paragraph."}, + {Type: BlockParagraph, Content: "Shared paragraph."}, + } + + ops := Diff(local, remote) + + expected := []DiffOpType{OpEqual, OpRemoteOnly, OpEqual} + if len(ops) != len(expected) { + t.Fatalf("Diff() returned %d ops, want %d", len(ops), len(expected)) + } + for i, op := range ops { + if op.Type != expected[i] { + t.Errorf("ops[%d].Type = %d, want %d", i, op.Type, expected[i]) + } + } + if ops[1].Remote.Content != "Remote paragraph." { + t.Errorf("ops[1].Remote.Content = %q, want %q", ops[1].Remote.Content, "Remote paragraph.") + } +} + +func TestDiffDeletion(t *testing.T) { + local := []Block{ + {Type: BlockHeading, Content: "# Title"}, + {Type: BlockParagraph, Content: "Remaining."}, + } + remote := []Block{ + {Type: BlockHeading, Content: "# Title"}, + {Type: BlockParagraph, Content: "Deleted paragraph."}, + {Type: BlockParagraph, Content: "Remaining."}, + } + + ops := Diff(local, remote) + + // The "Deleted paragraph." is remote-only + expected := []DiffOpType{OpEqual, OpRemoteOnly, OpEqual} + if len(ops) != len(expected) { + t.Fatalf("Diff() returned %d ops, want %d", len(ops), len(expected)) + } + for i, op := range ops { + if op.Type != expected[i] { + t.Errorf("ops[%d].Type = %d, want %d", i, op.Type, expected[i]) + } + } +} + +func TestDiffModification(t *testing.T) { + local := []Block{ + {Type: BlockHeading, Content: "# Title"}, + {Type: BlockParagraph, Content: "Updated version of paragraph."}, + {Type: BlockParagraph, Content: "Shared end."}, + } + remote := []Block{ + {Type: BlockHeading, Content: "# Title"}, + {Type: BlockParagraph, Content: "Original version of paragraph."}, + {Type: BlockParagraph, Content: "Shared end."}, + } + + ops := Diff(local, remote) + + // The modified paragraph appears as local-only + remote-only + expected := []DiffOpType{OpEqual, OpLocalOnly, OpRemoteOnly, OpEqual} + if len(ops) != len(expected) { + t.Fatalf("Diff() returned %d ops, want %d", len(ops), len(expected)) + } + for i, op := range ops { + if op.Type != expected[i] { + t.Errorf("ops[%d].Type = %d, want %d", i, op.Type, expected[i]) + } + } +} + +func TestDiffBothEmpty(t *testing.T) { + ops := Diff(nil, nil) + if len(ops) != 0 { + t.Errorf("Diff(nil, nil) returned %d ops, want 0", len(ops)) + } +} + +func TestDiffOneEmpty(t *testing.T) { + blocks := []Block{ + {Type: BlockHeading, Content: "# Title"}, + {Type: BlockParagraph, Content: "Content."}, + } + + t.Run("local empty", func(t *testing.T) { + ops := Diff(nil, blocks) + if len(ops) != 2 { + t.Fatalf("Diff() returned %d ops, want 2", len(ops)) + } + for _, op := range ops { + if op.Type != OpRemoteOnly { + t.Errorf("expected OpRemoteOnly, got %d", op.Type) + } + } + }) + + t.Run("remote empty", func(t *testing.T) { + ops := Diff(blocks, nil) + if len(ops) != 2 { + t.Fatalf("Diff() returned %d ops, want 2", len(ops)) + } + for _, op := range ops { + if op.Type != OpLocalOnly { + t.Errorf("expected OpLocalOnly, got %d", op.Type) + } + } + }) +} + +func TestDiffCompletelyDifferent(t *testing.T) { + local := []Block{ + {Type: BlockParagraph, Content: "Local only A."}, + {Type: BlockParagraph, Content: "Local only B."}, + } + remote := []Block{ + {Type: BlockParagraph, Content: "Remote only X."}, + {Type: BlockParagraph, Content: "Remote only Y."}, + } + + ops := Diff(local, remote) + + // All blocks are unique to their side + if len(ops) != 4 { + t.Fatalf("Diff() returned %d ops, want 4", len(ops)) + } + + localCount, remoteCount := 0, 0 + for _, op := range ops { + switch op.Type { + case OpLocalOnly: + localCount++ + case OpRemoteOnly: + remoteCount++ + case OpEqual: + t.Error("unexpected OpEqual in completely different documents") + } + } + if localCount != 2 { + t.Errorf("got %d local-only ops, want 2", localCount) + } + if remoteCount != 2 { + t.Errorf("got %d remote-only ops, want 2", remoteCount) + } +} + +func TestDiffReorderedBlocks(t *testing.T) { + local := []Block{ + {Type: BlockParagraph, Content: "A"}, + {Type: BlockParagraph, Content: "B"}, + {Type: BlockParagraph, Content: "C"}, + } + remote := []Block{ + {Type: BlockParagraph, Content: "C"}, + {Type: BlockParagraph, Content: "A"}, + {Type: BlockParagraph, Content: "B"}, + } + + ops := Diff(local, remote) + + // LCS is either {A, B} or {C} depending on algorithm. + // With our LCS, A and B form a longer subsequence. + equalCount := 0 + for _, op := range ops { + if op.Type == OpEqual { + equalCount++ + } + } + if equalCount < 2 { + t.Errorf("expected at least 2 equal ops for reordered blocks, got %d", equalCount) + } +} diff --git a/internal/sync/merge.go b/internal/sync/merge.go new file mode 100644 index 0000000..1df0c4e --- /dev/null +++ b/internal/sync/merge.go @@ -0,0 +1,121 @@ +package sync + +import ( + "fmt" + "strings" +) + +// MergeStrategy controls how non-equal blocks are handled during merge +type MergeStrategy int + +const ( + // MergeUnion includes blocks from both sides (additions from either + // side are kept, modifications result in both versions appearing) + MergeUnion MergeStrategy = iota + // MergePreferLocal keeps local blocks and discards remote-only blocks + MergePreferLocal + // MergePreferRemote keeps remote blocks and discards local-only blocks + MergePreferRemote +) + +// SyncResult holds the outcome of a sync operation +type SyncResult struct { + Merged string // the merged markdown body + Changes []DiffOp // the block-level diff operations + Summary string // human-readable change summary +} + +// Sync compares two markdown document bodies at the block level, merges +// them according to the given strategy, and returns the result. +func Sync(local, remote string, strategy MergeStrategy) SyncResult { + localBlocks := Parse(local) + remoteBlocks := Parse(remote) + ops := Diff(localBlocks, remoteBlocks) + merged := Merge(ops, strategy) + + return SyncResult{ + Merged: Render(merged), + Changes: ops, + Summary: summarize(ops), + } +} + +// Merge applies a merge strategy to a set of diff operations and returns +// the resulting block sequence. +func Merge(ops []DiffOp, strategy MergeStrategy) []Block { + var result []Block + + for _, op := range ops { + switch op.Type { + case OpEqual: + result = append(result, *op.Local) + + case OpLocalOnly: + switch strategy { + case MergeUnion, MergePreferLocal: + result = append(result, *op.Local) + case MergePreferRemote: + // discard local-only blocks + } + + case OpRemoteOnly: + switch strategy { + case MergeUnion, MergePreferRemote: + result = append(result, *op.Remote) + case MergePreferLocal: + // discard remote-only blocks + } + } + } + + return result +} + +// HasChanges returns true if the diff contains any non-equal operations +func HasChanges(ops []DiffOp) bool { + for _, op := range ops { + if op.Type != OpEqual { + return true + } + } + return false +} + +// summarize produces a human-readable summary of the diff operations +func summarize(ops []DiffOp) string { + var localOnly, remoteOnly, equal int + for _, op := range ops { + switch op.Type { + case OpEqual: + equal++ + case OpLocalOnly: + localOnly++ + case OpRemoteOnly: + remoteOnly++ + } + } + + if localOnly == 0 && remoteOnly == 0 { + return "no changes detected" + } + + var parts []string + if equal > 0 { + parts = append(parts, fmt.Sprintf("%d %s unchanged", equal, pluralBlock(equal))) + } + if localOnly > 0 { + parts = append(parts, fmt.Sprintf("%d %s only in local", localOnly, pluralBlock(localOnly))) + } + if remoteOnly > 0 { + parts = append(parts, fmt.Sprintf("%d %s only in remote", remoteOnly, pluralBlock(remoteOnly))) + } + + return strings.Join(parts, ", ") +} + +func pluralBlock(n int) string { + if n == 1 { + return "block" + } + return "blocks" +} diff --git a/internal/sync/merge_test.go b/internal/sync/merge_test.go new file mode 100644 index 0000000..70bee1d --- /dev/null +++ b/internal/sync/merge_test.go @@ -0,0 +1,248 @@ +package sync + +import ( + "strings" + "testing" +) + +func TestMergeUnionKeepsBothSides(t *testing.T) { + ops := []DiffOp{ + {Type: OpEqual, Local: blockPtr(BlockHeading, "# Title"), Remote: blockPtr(BlockHeading, "# Title")}, + {Type: OpLocalOnly, Local: blockPtr(BlockParagraph, "Local addition.")}, + {Type: OpRemoteOnly, Remote: blockPtr(BlockParagraph, "Remote addition.")}, + {Type: OpEqual, Local: blockPtr(BlockParagraph, "Shared."), Remote: blockPtr(BlockParagraph, "Shared.")}, + } + + result := Merge(ops, MergeUnion) + + if len(result) != 4 { + t.Fatalf("Merge(Union) returned %d blocks, want 4", len(result)) + } + if result[0].Content != "# Title" { + t.Errorf("block[0] = %q, want %q", result[0].Content, "# Title") + } + if result[1].Content != "Local addition." { + t.Errorf("block[1] = %q, want %q", result[1].Content, "Local addition.") + } + if result[2].Content != "Remote addition." { + t.Errorf("block[2] = %q, want %q", result[2].Content, "Remote addition.") + } + if result[3].Content != "Shared." { + t.Errorf("block[3] = %q, want %q", result[3].Content, "Shared.") + } +} + +func TestMergePreferLocalDropsRemoteOnly(t *testing.T) { + ops := []DiffOp{ + {Type: OpEqual, Local: blockPtr(BlockHeading, "# Title"), Remote: blockPtr(BlockHeading, "# Title")}, + {Type: OpLocalOnly, Local: blockPtr(BlockParagraph, "Local.")}, + {Type: OpRemoteOnly, Remote: blockPtr(BlockParagraph, "Remote.")}, + {Type: OpEqual, Local: blockPtr(BlockParagraph, "End."), Remote: blockPtr(BlockParagraph, "End.")}, + } + + result := Merge(ops, MergePreferLocal) + + if len(result) != 3 { + t.Fatalf("Merge(PreferLocal) returned %d blocks, want 3", len(result)) + } + for _, b := range result { + if b.Content == "Remote." { + t.Error("PreferLocal should not include remote-only blocks") + } + } +} + +func TestMergePreferRemoteDropsLocalOnly(t *testing.T) { + ops := []DiffOp{ + {Type: OpEqual, Local: blockPtr(BlockHeading, "# Title"), Remote: blockPtr(BlockHeading, "# Title")}, + {Type: OpLocalOnly, Local: blockPtr(BlockParagraph, "Local.")}, + {Type: OpRemoteOnly, Remote: blockPtr(BlockParagraph, "Remote.")}, + {Type: OpEqual, Local: blockPtr(BlockParagraph, "End."), Remote: blockPtr(BlockParagraph, "End.")}, + } + + result := Merge(ops, MergePreferRemote) + + if len(result) != 3 { + t.Fatalf("Merge(PreferRemote) returned %d blocks, want 3", len(result)) + } + for _, b := range result { + if b.Content == "Local." { + t.Error("PreferRemote should not include local-only blocks") + } + } +} + +func TestMergeAllEqual(t *testing.T) { + ops := []DiffOp{ + {Type: OpEqual, Local: blockPtr(BlockHeading, "# Title"), Remote: blockPtr(BlockHeading, "# Title")}, + {Type: OpEqual, Local: blockPtr(BlockParagraph, "Same."), Remote: blockPtr(BlockParagraph, "Same.")}, + } + + for _, strategy := range []MergeStrategy{MergeUnion, MergePreferLocal, MergePreferRemote} { + result := Merge(ops, strategy) + if len(result) != 2 { + t.Errorf("strategy %d: got %d blocks, want 2", strategy, len(result)) + } + } +} + +func TestMergeEmpty(t *testing.T) { + result := Merge(nil, MergeUnion) + if len(result) != 0 { + t.Errorf("Merge(nil) returned %d blocks, want 0", len(result)) + } +} + +func TestHasChanges(t *testing.T) { + t.Run("no changes", func(t *testing.T) { + ops := []DiffOp{ + {Type: OpEqual, Local: blockPtr(BlockParagraph, "Same.")}, + } + if HasChanges(ops) { + t.Error("HasChanges() = true, want false") + } + }) + + t.Run("with changes", func(t *testing.T) { + ops := []DiffOp{ + {Type: OpEqual, Local: blockPtr(BlockParagraph, "Same.")}, + {Type: OpLocalOnly, Local: blockPtr(BlockParagraph, "New.")}, + } + if !HasChanges(ops) { + t.Error("HasChanges() = false, want true") + } + }) + + t.Run("empty", func(t *testing.T) { + if HasChanges(nil) { + t.Error("HasChanges(nil) = true, want false") + } + }) +} + +func TestSyncEndToEnd(t *testing.T) { + local := "# My Note\n\nLocal paragraph.\n\nShared paragraph.\n" + remote := "# My Note\n\nShared paragraph.\n\nRemote paragraph.\n" + + result := Sync(local, remote, MergeUnion) + + if !strings.Contains(result.Merged, "# My Note") { + t.Error("merged should contain heading") + } + if !strings.Contains(result.Merged, "Local paragraph.") { + t.Error("merged should contain local paragraph") + } + if !strings.Contains(result.Merged, "Shared paragraph.") { + t.Error("merged should contain shared paragraph") + } + if !strings.Contains(result.Merged, "Remote paragraph.") { + t.Error("merged should contain remote paragraph") + } + if !HasChanges(result.Changes) { + t.Error("should report changes") + } + if result.Summary == "" { + t.Error("summary should not be empty") + } +} + +func TestSyncIdentical(t *testing.T) { + doc := "# Title\n\nSame content.\n" + + result := Sync(doc, doc, MergeUnion) + + if HasChanges(result.Changes) { + t.Error("identical documents should have no changes") + } + if result.Summary != "no changes detected" { + t.Errorf("summary = %q, want %q", result.Summary, "no changes detected") + } +} + +func TestSyncPreferLocal(t *testing.T) { + local := "# Title\n\nLocal version.\n\nShared.\n" + remote := "# Title\n\nRemote version.\n\nShared.\n" + + result := Sync(local, remote, MergePreferLocal) + + if !strings.Contains(result.Merged, "Local version.") { + t.Error("prefer-local should include local blocks") + } + if strings.Contains(result.Merged, "Remote version.") { + t.Error("prefer-local should not include remote-only blocks") + } +} + +func TestSyncPreferRemote(t *testing.T) { + local := "# Title\n\nLocal version.\n\nShared.\n" + remote := "# Title\n\nRemote version.\n\nShared.\n" + + result := Sync(local, remote, MergePreferRemote) + + if !strings.Contains(result.Merged, "Remote version.") { + t.Error("prefer-remote should include remote blocks") + } + if strings.Contains(result.Merged, "Local version.") { + t.Error("prefer-remote should not include local-only blocks") + } +} + +func TestSyncStructuralDifferences(t *testing.T) { + local := `# My Document + +Introduction paragraph. + +## Section One + +- Item A +- Item B + +## Section Two + +Conclusion. +` + remote := `# My Document + +Introduction paragraph. + +## Section One + +- Item A +- Item B +- Item C + +## New Section + +New content here. + +## Section Two + +Conclusion. +` + + result := Sync(local, remote, MergeUnion) + + // Should include the expanded list from remote + if !strings.Contains(result.Merged, "Item C") { + t.Error("merged should include Item C from remote") + } + // Should include the new section from remote + if !strings.Contains(result.Merged, "## New Section") { + t.Error("merged should include new section from remote") + } + if !strings.Contains(result.Merged, "New content here.") { + t.Error("merged should include new content from remote") + } + // Should keep original structure + if !strings.Contains(result.Merged, "## Section One") { + t.Error("merged should keep original sections") + } + if !strings.Contains(result.Merged, "Conclusion.") { + t.Error("merged should keep conclusion") + } +} + +// blockPtr is a test helper that creates a pointer to a Block +func blockPtr(typ BlockType, content string) *Block { + return &Block{Type: typ, Content: content} +}