From 822f7a51e592ce3b9b21f9491214e1548516f087 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:12:22 -0800 Subject: [PATCH] fix: preserve child hierarchy in CLIPBOARDPASTETEXT split mode (#126) --- src/utils/core.ts | 101 ++++++++++++++++++++++++++--- tests/clipboardSplit.test.ts | 122 +++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 10 deletions(-) create mode 100644 tests/clipboardSplit.test.ts diff --git a/src/utils/core.ts b/src/utils/core.ts index e399e5d..066cc97 100644 --- a/src/utils/core.ts +++ b/src/utils/core.ts @@ -186,6 +186,83 @@ const getLevelsBelowParentUid = ( return levels <= 0 || !levels ? tree : getTreeUptoLevel(tree, levels); }; +const getClipboardIndentUnit = (lines: string[]) => { + const spaceIndents = lines + .map((line) => line.match(/^[\t ]*/)?.[0] || "") + .filter((indent) => !indent.includes("\t")) + .map((indent) => indent.length) + .filter((length) => length > 0); + return spaceIndents.length ? Math.min(...spaceIndents) : 2; +}; + +const getClipboardIndentLevel = ({ + line, + indentUnit, +}: { + line: string; + indentUnit: number; +}) => { + const indent = line.match(/^[\t ]*/)?.[0] || ""; + const tabs = (indent.match(/\t/g) || []).length; + const spaces = indent.replace(/\t/g, "").length; + return tabs + (indentUnit > 0 ? Math.floor(spaces / indentUnit) : 0); +}; + +const normalizeClipboardNode = (node: InputTextNode): InputTextNode => + node.children && node.children.length + ? { ...node, children: node.children.map(normalizeClipboardNode) } + : { ...node, children: undefined }; + +const parseClipboardSplitText = ({ + text, + noHyphens, + noExtraSpaces, +}: { + text: string; + noHyphens: boolean; + noExtraSpaces: boolean; +}): InputTextNode[] => { + const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + const meaningful = lines.filter((line) => line.trim().length); + const indentUnit = getClipboardIndentUnit(meaningful); + const roots: InputTextNode[] = []; + const stack: InputTextNode[] = []; + + lines.forEach((line) => { + if (!line.trim().length) { + roots.push({ text: "" }); + stack.length = 0; + return; + } + + const level = getClipboardIndentLevel({ line, indentUnit }); + const cappedLevel = Math.min(Math.max(level, 0), stack.length); + let lineText = line.trimStart(); + if (noHyphens) { + lineText = lineText.replace(/^[-*•]\s+/, ""); + } + if (noExtraSpaces) { + lineText = lineText.replace(/\s\s+/g, " "); + } + + const node: InputTextNode = { text: lineText, children: [] }; + + while (stack.length > cappedLevel) { + stack.pop(); + } + if (!stack.length) { + roots.push(node); + } else { + const parent = stack[stack.length - 1]; + parent.children = parent.children || []; + parent.children.push(node); + } + stack.push(node); + }); + + return roots.map(normalizeClipboardNode); +}; + addNlpDateParser({ pattern: () => /D[B,E]O(N)?[M,Y]/i, extract: (_, match) => { @@ -1796,16 +1873,20 @@ export const COMMANDS: { const postCarriage = settings.has("returnasspace") ? postCarriageOne.replace(/(\r)?\n(\r)?/g, " ") : postTrim; - const postHyphens = settings.has("nohyphens") - ? postCarriage.replace(/- /g, "") - : postCarriage; - const postSpaces = settings.has("noextraspaces") - ? postHyphens.replace(/\s\s+/g, " ") - : postHyphens; - const postSplit = settings.has("split") - ? postSpaces.split(/(?:\r)?\n/) - : [postSpaces]; - return postSplit; + if (!settings.has("split")) { + const postHyphens = settings.has("nohyphens") + ? postCarriage.replace(/- /g, "") + : postCarriage; + const postSpaces = settings.has("noextraspaces") + ? postHyphens.replace(/\s\s+/g, " ") + : postHyphens; + return [postSpaces]; + } + return parseClipboardSplitText({ + text: postCarriage, + noHyphens: settings.has("nohyphens"), + noExtraSpaces: settings.has("noextraspaces"), + }); }, }, { diff --git a/tests/clipboardSplit.test.ts b/tests/clipboardSplit.test.ts new file mode 100644 index 0000000..3991df3 --- /dev/null +++ b/tests/clipboardSplit.test.ts @@ -0,0 +1,122 @@ +import { expect, test } from "@playwright/test"; + +if (!globalThis.window) { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: {}, + }); +} +const windowObject = globalThis.window as { roamAlphaAPI?: { graph?: { name: string } } }; +windowObject.roamAlphaAPI = windowObject.roamAlphaAPI || { graph: { name: "test-graph" } }; +windowObject.roamAlphaAPI.graph = windowObject.roamAlphaAPI.graph || { + name: "test-graph", +}; + +if (!globalThis.localStorage) { + const store = new Map(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => store.get(key) || null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + }, + }); +} + +const { COMMANDS } = require("../src/utils/core") as typeof import("../src/utils/core"); +const clipboardCommand = COMMANDS.find((c) => c.text === "CLIPBOARDPASTETEXT"); +if (!clipboardCommand) { + throw new Error("CLIPBOARDPASTETEXT command is missing"); +} + +const setClipboardText = (text: string) => { + if (!globalThis.navigator) { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: {}, + }); + } + Object.defineProperty(globalThis.navigator as object, "clipboard", { + configurable: true, + value: { + readText: async () => text, + }, + }); +}; + +const runClipboardCommand = async (text: string, ...args: string[]) => { + setClipboardText(text); + return clipboardCommand.handler(...args); +}; + +const normalize = (value: unknown) => JSON.parse(JSON.stringify(value)); + +test("CLIPBOARDPASTETEXT split preserves hierarchy with mixed tab and space indentation", async () => { + const result = normalize( + await runClipboardCommand( + [ + "Root", + "\tTab child", + "\t\tTab grandchild", + " Space child", + " Space grandchild", + ].join("\n"), + "split" + ) + ); + + expect(result).toEqual([ + { + text: "Root", + children: [ + { text: "Tab child", children: [{ text: "Tab grandchild" }] }, + { text: "Space child", children: [{ text: "Space grandchild" }] }, + ], + }, + ]); +}); + +test("CLIPBOARDPASTETEXT split resets hierarchy after blank lines", async () => { + const result = normalize( + await runClipboardCommand( + ["Parent", " Child", "", "After blank", " New child"].join("\n"), + "split" + ) + ); + + expect(result).toEqual([ + { text: "Parent", children: [{ text: "Child" }] }, + { text: "" }, + { text: "After blank", children: [{ text: "New child" }] }, + ]); +}); + +test("CLIPBOARDPASTETEXT split applies nohyphens and noextraspaces per line", async () => { + const result = normalize( + await runClipboardCommand( + ["- Parent item", " * Child item"].join("\n"), + "split", + "nohyphens", + "noextraspaces" + ) + ); + + expect(result).toEqual([ + { + text: "Parent item", + children: [{ text: "Child item" }], + }, + ]); +}); + +test("CLIPBOARDPASTETEXT non-split behavior remains unchanged", async () => { + const result = await runClipboardCommand( + "- Foo bar", + "nohyphens", + "noextraspaces" + ); + + expect(result).toEqual(["Foo bar"]); +});