mirror of
https://github.com/vesan/obsidian-table-beautifier.git
synced 2026-07-22 06:53:19 +00:00
fix: merge multiline cells and strip leftover indentation
Group physical data lines into logical rows by border lines so word-wrapped cells merge instead of producing empty rows. Expand the selection to whole lines before replacing so the converted table is inserted flush-left.
This commit is contained in:
parent
65f7894fab
commit
a4370e95d1
6 changed files with 235 additions and 17 deletions
85
src/__tests__/convert.test.ts
Normal file
85
src/__tests__/convert.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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 │
|
||||
|
|
|
|||
24
src/convert.ts
Normal file
24
src/convert.ts
Normal file
|
|
@ -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));
|
||||
}
|
||||
13
src/main.ts
13
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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue