Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 91 additions & 10 deletions src/utils/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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"),
});
},
},
{
Expand Down
122 changes: 122 additions & 0 deletions tests/clipboardSplit.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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"]);
});
Loading