diff --git a/src/__tests__/convert.test.ts b/src/__tests__/convert.test.ts new file mode 100644 index 0000000..24134af --- /dev/null +++ b/src/__tests__/convert.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import type { Editor, EditorPosition } from "obsidian"; +import { convertSelection } from "../convert"; + +// Minimal in-memory Editor standing in for Obsidian's Editor. It implements +// only the methods convertSelection touches, backed by an array of lines. +class FakeEditor { + private lines: string[]; + private from: EditorPosition; + private to: EditorPosition; + + constructor(text: string, from: EditorPosition, to: EditorPosition) { + this.lines = text.split("\n"); + this.from = from; + this.to = to; + } + + getValue(): string { + return this.lines.join("\n"); + } + + getLine(line: number): string { + return this.lines[line]; + } + + getCursor(which: "from" | "to"): EditorPosition { + return which === "from" ? this.from : this.to; + } + + setSelection(from: EditorPosition, to: EditorPosition): void { + this.from = from; + this.to = to; + } + + getSelection(): string { + const { from, to } = this; + if (from.line === to.line) { + return this.lines[from.line].slice(from.ch, to.ch); + } + const first = this.lines[from.line].slice(from.ch); + const middle = this.lines.slice(from.line + 1, to.line); + const last = this.lines[to.line].slice(0, to.ch); + return [first, ...middle, last].join("\n"); + } + + replaceSelection(text: string): void { + const { from, to } = this; + const before = this.lines[from.line].slice(0, from.ch); + const after = this.lines[to.line].slice(to.ch); + const replaced = (before + text + after).split("\n"); + this.lines = [ + ...this.lines.slice(0, from.line), + ...replaced, + ...this.lines.slice(to.line + 1), + ]; + } +} + +describe("convertSelection", () => { + it("inserts the Markdown table flush-left despite leftover indentation", () => { + const doc = + " ┌──────┬──────┐\n" + + " │ A │ B │\n" + + " ├──────┼──────┤\n" + + " │ 1 │ 2 │\n" + + " └──────┴──────┘"; + // Selection starts at the box character, leaving the 2-space indent behind. + const editor = new FakeEditor(doc, { line: 0, ch: 2 }, { line: 4, ch: 17 }); + + convertSelection(editor as unknown as Editor); + + expect(editor.getValue()).toBe( + "| A | B |\n" + "| - | - |\n" + "| 1 | 2 |" + ); + }); + + it("leaves the document untouched when the selection is not a table", () => { + const doc = "just regular text\nover two lines"; + const editor = new FakeEditor(doc, { line: 0, ch: 0 }, { line: 1, ch: 14 }); + + convertSelection(editor as unknown as Editor); + + expect(editor.getValue()).toBe(doc); + }); +}); diff --git a/src/__tests__/formatter.test.ts b/src/__tests__/formatter.test.ts index 91b85d7..bba4743 100644 --- a/src/__tests__/formatter.test.ts +++ b/src/__tests__/formatter.test.ts @@ -87,6 +87,18 @@ describe("formatMarkdownTable", () => { expect(lines[3]).toContain("x "); }); + it("does not backtick a merged multiline cell containing spaces", () => { + const table: ParsedTable = { + headers: ["Surface", "Net change"], + rows: [["Per-template list", "fixed via ShowableScope"]], + }; + + const result = formatMarkdownTable(table); + + expect(result).not.toContain("`fixed via ShowableScope`"); + expect(result).toContain("fixed via ShowableScope"); + }); + it("handles empty cells", () => { const table: ParsedTable = { headers: ["A", "B"], diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index 2907be9..13f9c07 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -89,6 +89,48 @@ describe("parseTable", () => { expect(result).toBeNull(); }); + it("merges multiline cells into one logical row per separator", () => { + const input = `┌────────────┬─────────────────────────┐ +│ Surface │ Before (Apr 15 – this │ +│ │ PR) │ +├────────────┼─────────────────────────┤ +│ Main list │ ✅ Yes │ +├────────────┼─────────────────────────┤ +│ Per-tmpl │ ✅ Yes (the visible │ +│ │ bug) │ +└────────────┴─────────────────────────┘`; + + const result = parseTable(input); + + expect(result).not.toBeNull(); + expect(result!.headers).toEqual(["Surface", "Before (Apr 15 – this PR)"]); + expect(result!.rows).toEqual([ + ["Main list", "✅ Yes"], + ["Per-tmpl", "✅ Yes (the visible bug)"], + ]); + }); + + it("merges a row whose first physical line is empty in some columns", () => { + const input = `┌────────────┬─────────────┐ +│ Surface │ Net change │ +├────────────┼─────────────┤ +│ Main list │ unchanged │ +├────────────┼─────────────┤ +│ │ fixed via │ +│ Muokkaa │ privileges │ +│ │ demotion │ +└────────────┴─────────────┘`; + + const result = parseTable(input); + + expect(result).not.toBeNull(); + expect(result!.headers).toEqual(["Surface", "Net change"]); + expect(result!.rows).toEqual([ + ["Main list", "unchanged"], + ["Muokkaa", "fixed via privileges demotion"], + ]); + }); + it("handles a three-column table", () => { const input = `┌──────┬──────┬──────┐ │ A │ B │ C │ diff --git a/src/convert.ts b/src/convert.ts new file mode 100644 index 0000000..30ff7db --- /dev/null +++ b/src/convert.ts @@ -0,0 +1,24 @@ +import type { Editor } from "obsidian"; +import { parseTable } from "./parser"; +import { formatMarkdownTable } from "./formatter"; + +export function convertSelection(editor: Editor): void { + const selection = editor.getSelection(); + if (!selection) return; + + const table = parseTable(selection); + if (!table) return; + + // Expand the selection to whole lines so any leftover indentation — before + // the table on the first line, or on a partially selected last line — is + // replaced too. Otherwise the Markdown table is inserted indented. + const from = editor.getCursor("from"); + const to = editor.getCursor("to"); + const lastLine = to.ch === 0 && to.line > from.line ? to.line - 1 : to.line; + editor.setSelection( + { line: from.line, ch: 0 }, + { line: lastLine, ch: editor.getLine(lastLine).length } + ); + + editor.replaceSelection(formatMarkdownTable(table)); +} diff --git a/src/main.ts b/src/main.ts index c48618a..cb47aff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,5 @@ import { Editor, Menu, Plugin } from "obsidian"; -import { parseTable } from "./parser"; -import { formatMarkdownTable } from "./formatter"; +import { convertSelection } from "./convert"; const TABLE_HINT = /[│┌┬┐├┼┤└┴┘─]/; @@ -8,16 +7,6 @@ function looksLikeTable(text: string): boolean { return TABLE_HINT.test(text) || /\+[-=]+\+/.test(text); } -function convertSelection(editor: Editor): void { - const selection = editor.getSelection(); - if (!selection) return; - - const table = parseTable(selection); - if (!table) return; - - editor.replaceSelection(formatMarkdownTable(table)); -} - export default class TableBeautifierPlugin extends Plugin { onload() { this.addCommand({ diff --git a/src/parser.ts b/src/parser.ts index b9a6dfe..95c0c9c 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -20,6 +20,11 @@ function isDataRow(line: string): boolean { return true; } +function isBorderLine(line: string): boolean { + const trimmed = line.trim(); + return trimmed.length > 0 && BORDER_LINE.test(trimmed); +} + function splitCells(line: string): string[] { const trimmed = line.trim(); @@ -34,20 +39,81 @@ function splitCells(line: string): string[] { return cells.map((cell) => cell.trim()); } +// A logical row may be word-wrapped across several physical lines. Merge them +// column by column, joining the non-empty fragments with a single space. +function mergeGroup(group: string[]): string[] { + const lineCells = group.map(splitCells); + const columnCount = Math.max(...lineCells.map((cells) => cells.length)); + + const merged: string[] = []; + for (let col = 0; col < columnCount; col++) { + const fragments = lineCells + .map((cells) => cells[col] ?? "") + .filter((fragment) => fragment.length > 0); + merged.push(fragments.join(" ")); + } + return merged; +} + +// Split physical data lines into groups separated by border lines. Each group +// is the physical lines belonging to one logical row. +function groupDataLines(lines: string[]): { groups: string[][]; hasBorder: boolean } { + const groups: string[][] = []; + let current: string[] = []; + let hasBorder = false; + + const flush = () => { + if (current.length > 0) { + groups.push(current); + current = []; + } + }; + + for (const line of lines) { + if (isDataRow(line)) { + current.push(line); + } else if (isBorderLine(line)) { + hasBorder = true; + flush(); + } else { + flush(); + } + } + flush(); + + return { groups, hasBorder }; +} + export function parseTable(text: string): ParsedTable | null { const lines = text.split("\n"); + const { groups, hasBorder } = groupDataLines(lines); - const dataRows = lines.filter(isDataRow); + // Turn groups into logical rows (one string[] per row). + let logicalRows: string[][]; - if (dataRows.length < 2) return null; + if (!hasBorder) { + // No borders to delimit rows — each physical data line is its own row. + const dataLines = groups.flat(); + logicalRows = dataLines.map((line) => splitCells(line)); + } else { + let rowGroups = groups; - const allCells = dataRows.map(splitCells); + // Borders present but only one data group (header group + one data group): + // the table has no per-row separators, so each physical line is a row. + if (rowGroups.length === 2 && rowGroups[1].length > 1) { + rowGroups = [rowGroups[0], ...rowGroups[1].map((line) => [line])]; + } - const columnCount = allCells[0].length; + logicalRows = rowGroups.map(mergeGroup); + } + + if (logicalRows.length < 2) return null; + + const columnCount = logicalRows[0].length; if (columnCount === 0) return null; // Validate consistent column count - const consistentRows = allCells.filter((row) => row.length === columnCount); + const consistentRows = logicalRows.filter((row) => row.length === columnCount); if (consistentRows.length < 2) return null; return {