@cueloop/integration-obsidian 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +28 -0
- package/src/detect.test.ts +51 -0
- package/src/detect.ts +35 -0
- package/src/export.test.ts +129 -0
- package/src/export.ts +59 -0
- package/src/extension.ts +14 -0
- package/src/filename.test.ts +63 -0
- package/src/filename.ts +51 -0
- package/src/frontmatter.ts +14 -0
- package/src/index.ts +5 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cueloop/integration-obsidian",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@cueloop/extension-api": "workspace:*",
|
|
11
|
+
"@cueloop/schema": "workspace:*"
|
|
12
|
+
},
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"src",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"bun": ">=1.3.0"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
26
|
+
"directory": "packages/integrations/obsidian"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { detectVaults, obsidianConfigPath } from "./detect";
|
|
6
|
+
|
|
7
|
+
let dir: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
dir = mkdtempSync(join(tmpdir(), "cueloop-obsidian-detect-"));
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("detectVaults", () => {
|
|
17
|
+
test("returns registered vault paths that exist on disk", () => {
|
|
18
|
+
const alive = join(dir, "notes");
|
|
19
|
+
mkdirSync(alive);
|
|
20
|
+
const gone = join(dir, "deleted-vault");
|
|
21
|
+
const configPath = join(dir, "obsidian.json");
|
|
22
|
+
writeFileSync(
|
|
23
|
+
configPath,
|
|
24
|
+
JSON.stringify({
|
|
25
|
+
vaults: {
|
|
26
|
+
abc123: { path: alive, ts: 1700000000000, open: true },
|
|
27
|
+
def456: { path: gone, ts: 1700000000001 },
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
expect(detectVaults(configPath)).toEqual([alive]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("missing or malformed config yields no vaults", () => {
|
|
35
|
+
expect(detectVaults(join(dir, "nope.json"))).toEqual([]);
|
|
36
|
+
const broken = join(dir, "broken.json");
|
|
37
|
+
writeFileSync(broken, "not json");
|
|
38
|
+
expect(detectVaults(broken)).toEqual([]);
|
|
39
|
+
const empty = join(dir, "empty.json");
|
|
40
|
+
writeFileSync(empty, "{}");
|
|
41
|
+
expect(detectVaults(empty)).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("obsidianConfigPath", () => {
|
|
46
|
+
test("resolves per platform", () => {
|
|
47
|
+
expect(obsidianConfigPath("darwin")).toContain(join("Library", "Application Support", "obsidian", "obsidian.json"));
|
|
48
|
+
expect(obsidianConfigPath("linux")).toContain(join("obsidian", "obsidian.json"));
|
|
49
|
+
expect(obsidianConfigPath("win32")).toContain(join("obsidian", "obsidian.json"));
|
|
50
|
+
});
|
|
51
|
+
});
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault auto-detection: Obsidian keeps a registry of known vaults in its own
|
|
3
|
+
* config file. We read it and return the vault paths that still exist on
|
|
4
|
+
* disk. The config path is overridable for tests.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** Platform-specific location of Obsidian's own obsidian.json. */
|
|
12
|
+
export function obsidianConfigPath(platform: NodeJS.Platform = process.platform): string {
|
|
13
|
+
if (platform === "darwin") {
|
|
14
|
+
return join(homedir(), "Library", "Application Support", "obsidian", "obsidian.json");
|
|
15
|
+
}
|
|
16
|
+
if (platform === "win32") {
|
|
17
|
+
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "obsidian", "obsidian.json");
|
|
18
|
+
}
|
|
19
|
+
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "obsidian", "obsidian.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Vault paths registered with Obsidian that exist on disk. */
|
|
23
|
+
export function detectVaults(configPath: string = obsidianConfigPath()): string[] {
|
|
24
|
+
try {
|
|
25
|
+
const raw = JSON.parse(readFileSync(configPath, "utf8")) as {
|
|
26
|
+
vaults?: Record<string, { path?: string }>;
|
|
27
|
+
};
|
|
28
|
+
return Object.values(raw.vaults ?? {})
|
|
29
|
+
.map((v) => v.path)
|
|
30
|
+
.filter((p): p is string => typeof p === "string" && existsSync(p));
|
|
31
|
+
} catch {
|
|
32
|
+
// no Obsidian install, or an unreadable registry: no vaults
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
6
|
+
import { Registry } from "@cueloop/extension-api";
|
|
7
|
+
import { OBSIDIAN_DEFAULTS, exportSession, shouldExport, type ObsidianConfig } from "./export";
|
|
8
|
+
import { createObsidianExtension } from "./extension";
|
|
9
|
+
import { frontmatter } from "./frontmatter";
|
|
10
|
+
|
|
11
|
+
const NOW = new Date("2026-08-07T12:34:56.000Z");
|
|
12
|
+
|
|
13
|
+
function resolvedSession(overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
14
|
+
return {
|
|
15
|
+
schemaVersion: "1",
|
|
16
|
+
id: "s_test1",
|
|
17
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
18
|
+
artifact: {
|
|
19
|
+
type: "plan",
|
|
20
|
+
content: "# Migration Plan\n\nMove the store.\n",
|
|
21
|
+
meta: { title: "Migration Plan" },
|
|
22
|
+
},
|
|
23
|
+
revisions: [{ revision: 1, content: "# Migration Plan\n\nMove the store.\n", submittedAt: NOW.toISOString() }],
|
|
24
|
+
annotations: [],
|
|
25
|
+
verdict: { kind: "approve", summary: "ship it", feedback: "", resolvedAt: NOW.toISOString() },
|
|
26
|
+
status: "resolved",
|
|
27
|
+
createdAt: NOW.toISOString(),
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let vault: string;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
vault = mkdtempSync(join(tmpdir(), "cueloop-obsidian-vault-"));
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
rmSync(vault, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function config(overrides: Partial<ObsidianConfig> = {}): ObsidianConfig {
|
|
42
|
+
return { ...OBSIDIAN_DEFAULTS, vault, ...overrides };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("exportSession", () => {
|
|
46
|
+
test("writes the final plan with frontmatter into vault/folder", () => {
|
|
47
|
+
const session = resolvedSession();
|
|
48
|
+
const result = exportSession(session, config(), NOW);
|
|
49
|
+
expect(result.success).toBe(true);
|
|
50
|
+
expect(result.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan.md"));
|
|
51
|
+
const written = readFileSync(result.path!, "utf8");
|
|
52
|
+
expect(written).toBe(`${frontmatter(session, NOW)}\n\n# Migration Plan\n\nMove the store.\n`);
|
|
53
|
+
expect(written).toContain("created: 2026-08-07T12:34:56.000Z");
|
|
54
|
+
expect(written).toContain("source: cueloop");
|
|
55
|
+
expect(written).toContain("session: s_test1");
|
|
56
|
+
expect(written).toContain("verdict: approve");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the working copy wins over the submitted content", () => {
|
|
60
|
+
const session = resolvedSession({ workingCopy: "# Migration Plan\n\nMove the store carefully.\n" });
|
|
61
|
+
const result = exportSession(session, config(), NOW);
|
|
62
|
+
expect(readFileSync(result.path!, "utf8")).toContain("Move the store carefully.");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("never overwrites: collisions get counting suffixes", () => {
|
|
66
|
+
const session = resolvedSession();
|
|
67
|
+
const first = exportSession(session, config(), NOW);
|
|
68
|
+
const second = exportSession(session, config(), NOW);
|
|
69
|
+
const third = exportSession(session, config(), NOW);
|
|
70
|
+
expect(second.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan 2.md"));
|
|
71
|
+
expect(third.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan 3.md"));
|
|
72
|
+
expect(existsSync(first.path!)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("no vault configured and none detected fails without writing", () => {
|
|
76
|
+
const emptyConfigPath = join(vault, "obsidian.json");
|
|
77
|
+
const result = exportSession(resolvedSession(), config({ vault: undefined, obsidianConfigPath: emptyConfigPath }), NOW);
|
|
78
|
+
expect(result.success).toBe(false);
|
|
79
|
+
expect(result.error).toContain("no Obsidian vault");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("a configured vault path that does not exist fails", () => {
|
|
83
|
+
const result = exportSession(resolvedSession(), config({ vault: join(vault, "missing") }), NOW);
|
|
84
|
+
expect(result.success).toBe(false);
|
|
85
|
+
expect(result.error).toContain("vault not found");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("falls back to the first auto-detected vault", () => {
|
|
89
|
+
const detected = join(vault, "detected-vault");
|
|
90
|
+
mkdirSync(detected);
|
|
91
|
+
const obsidianJson = join(vault, "obsidian.json");
|
|
92
|
+
writeFileSync(obsidianJson, JSON.stringify({ vaults: { v1: { path: detected } } }));
|
|
93
|
+
const result = exportSession(resolvedSession(), config({ vault: undefined, obsidianConfigPath: obsidianJson }), NOW);
|
|
94
|
+
expect(result.success).toBe(true);
|
|
95
|
+
expect(result.path).toBe(join(detected, "cueloop", "2026-08-07 - Migration Plan.md"));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("obsidian extension", () => {
|
|
100
|
+
test("registers the exporter through the extension API and exports a resolved session", async () => {
|
|
101
|
+
const registry = new Registry();
|
|
102
|
+
const record = await registry.load("obsidian", createObsidianExtension(config()));
|
|
103
|
+
expect(record.errors).toEqual([]);
|
|
104
|
+
const exporter = record.exporters.get("obsidian");
|
|
105
|
+
expect(exporter).toBeDefined();
|
|
106
|
+
const result = await exporter!(resolvedSession());
|
|
107
|
+
expect(result.success).toBe(true);
|
|
108
|
+
expect(existsSync(result.path!)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("shouldExport", () => {
|
|
113
|
+
test("approve exports only approvals", () => {
|
|
114
|
+
expect(shouldExport("approve", "approve")).toBe(true);
|
|
115
|
+
expect(shouldExport("approve", "request_changes")).toBe(false);
|
|
116
|
+
expect(shouldExport("approve", "comment")).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("resolve exports any verdict", () => {
|
|
120
|
+
expect(shouldExport("resolve", "approve")).toBe(true);
|
|
121
|
+
expect(shouldExport("resolve", "request_changes")).toBe(true);
|
|
122
|
+
expect(shouldExport("resolve", "comment")).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("manual never auto-exports", () => {
|
|
126
|
+
expect(shouldExport("manual", "approve")).toBe(false);
|
|
127
|
+
expect(shouldExport("manual", "request_changes")).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
});
|
package/src/export.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export a session's final plan (working copy when the reviewer edited,
|
|
3
|
+
* else the submitted content) into an Obsidian vault as a markdown note
|
|
4
|
+
* with provenance frontmatter. Pure filesystem writes; never overwrites.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { ReviewSession, VerdictKind } from "@cueloop/schema";
|
|
10
|
+
import { detectVaults } from "./detect";
|
|
11
|
+
import { formatFilename, titleFrom, uniquePath, type Separator } from "./filename";
|
|
12
|
+
import { frontmatter } from "./frontmatter";
|
|
13
|
+
|
|
14
|
+
export type ExportOn = "approve" | "resolve" | "manual";
|
|
15
|
+
|
|
16
|
+
export interface ObsidianConfig {
|
|
17
|
+
/** Vault path; when unset, the first auto-detected vault is used. */
|
|
18
|
+
vault?: string;
|
|
19
|
+
folder: string;
|
|
20
|
+
filenameFormat: string;
|
|
21
|
+
separator: Separator;
|
|
22
|
+
exportOn: ExportOn;
|
|
23
|
+
/** Override for Obsidian's own config file location (tests). */
|
|
24
|
+
obsidianConfigPath?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const OBSIDIAN_DEFAULTS: ObsidianConfig = {
|
|
28
|
+
folder: "cueloop",
|
|
29
|
+
filenameFormat: "{YYYY}-{MM}-{DD} - {title}",
|
|
30
|
+
separator: "space",
|
|
31
|
+
exportOn: "manual",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** approve exports only approvals; resolve exports any verdict; manual never auto-exports. */
|
|
35
|
+
export function shouldExport(exportOn: ExportOn, verdict: VerdictKind): boolean {
|
|
36
|
+
if (exportOn === "approve") return verdict === "approve";
|
|
37
|
+
return exportOn === "resolve";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ExportResult {
|
|
41
|
+
success: boolean;
|
|
42
|
+
path?: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function exportSession(session: ReviewSession, config: ObsidianConfig, now: Date = new Date()): ExportResult {
|
|
47
|
+
const vault = config.vault ?? detectVaults(config.obsidianConfigPath)[0];
|
|
48
|
+
if (!vault) return { success: false, error: "no Obsidian vault configured or detected" };
|
|
49
|
+
if (!existsSync(vault)) return { success: false, error: `vault not found: ${vault}` };
|
|
50
|
+
|
|
51
|
+
const content = session.workingCopy ?? session.artifact.content;
|
|
52
|
+
const title = titleFrom(content, session.artifact.meta.title);
|
|
53
|
+
const dir = join(vault, config.folder);
|
|
54
|
+
mkdirSync(dir, { recursive: true });
|
|
55
|
+
const base = formatFilename(config.filenameFormat, title, now, config.separator);
|
|
56
|
+
const path = uniquePath(dir, base);
|
|
57
|
+
writeFileSync(path, `${frontmatter(session, now)}\n\n${content}`);
|
|
58
|
+
return { success: true, path };
|
|
59
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bundled Obsidian extension: a plain factory over the pure core,
|
|
3
|
+
* registering through the public extension API like any third-party
|
|
4
|
+
* extension would (no privileged built-ins).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionFactory } from "@cueloop/extension-api";
|
|
8
|
+
import { exportSession, type ObsidianConfig } from "./export";
|
|
9
|
+
|
|
10
|
+
export function createObsidianExtension(config: ObsidianConfig): ExtensionFactory {
|
|
11
|
+
return (cueloop) => {
|
|
12
|
+
cueloop.registerExporter("obsidian", async (session) => exportSession(session, config));
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { formatFilename, sanitizeTitle, titleFrom, uniquePath } from "./filename";
|
|
6
|
+
|
|
7
|
+
const DATE = new Date("2026-08-07T12:00:00Z");
|
|
8
|
+
|
|
9
|
+
describe("titleFrom", () => {
|
|
10
|
+
test("uses the first H1", () => {
|
|
11
|
+
expect(titleFrom("intro\n\n# Migration Plan\n\n## Steps\n")).toBe("Migration Plan");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("falls back to the provided title, then to untitled", () => {
|
|
15
|
+
expect(titleFrom("no headings here", "Session Title")).toBe("Session Title");
|
|
16
|
+
expect(titleFrom("no headings here")).toBe("untitled");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("strips forbidden characters and collapses whitespace", () => {
|
|
20
|
+
expect(titleFrom('# Fix <the> "store": a/b\\c | d?* (v2) {x} [y] #tag ~ `code`')).toBe(
|
|
21
|
+
"Fix the store abc d v2 x y tag code",
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("caps the title at 50 characters", () => {
|
|
26
|
+
const long = "# " + "word ".repeat(20);
|
|
27
|
+
expect(titleFrom(long).length).toBeLessThanOrEqual(50);
|
|
28
|
+
expect(titleFrom(long).endsWith(" ")).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a title of only forbidden characters becomes untitled", () => {
|
|
32
|
+
expect(sanitizeTitle("###")).toBe("untitled");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("formatFilename", () => {
|
|
37
|
+
test("renders date tokens and the title with the space separator", () => {
|
|
38
|
+
expect(formatFilename("{YYYY}-{MM}-{DD} - {title}", "My Plan", DATE, "space")).toBe("2026-08-07 - My Plan");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("dash and underscore separators replace whitespace runs", () => {
|
|
42
|
+
expect(formatFilename("{YYYY}-{MM}-{DD} - {title}", "My Plan", DATE, "dash")).toBe("2026-08-07---My-Plan");
|
|
43
|
+
expect(formatFilename("{title}", "My Plan", DATE, "underscore")).toBe("My_Plan");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("uniquePath", () => {
|
|
48
|
+
let dir: string;
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
dir = mkdtempSync(join(tmpdir(), "cueloop-obsidian-name-"));
|
|
51
|
+
});
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("appends counting suffixes instead of overwriting", () => {
|
|
57
|
+
expect(uniquePath(dir, "plan")).toBe(join(dir, "plan.md"));
|
|
58
|
+
writeFileSync(join(dir, "plan.md"), "");
|
|
59
|
+
expect(uniquePath(dir, "plan")).toBe(join(dir, "plan 2.md"));
|
|
60
|
+
writeFileSync(join(dir, "plan 2.md"), "");
|
|
61
|
+
expect(uniquePath(dir, "plan")).toBe(join(dir, "plan 3.md"));
|
|
62
|
+
});
|
|
63
|
+
});
|
package/src/filename.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Note naming: title from the plan's first H1, sanitized for filesystems,
|
|
3
|
+
* formatted through the configured pattern, never overwriting an existing
|
|
4
|
+
* note (collision suffixes " 2", " 3", ...).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export type Separator = "space" | "dash" | "underscore";
|
|
11
|
+
|
|
12
|
+
const SEPARATOR_CHAR: Record<Separator, string> = { space: " ", dash: "-", underscore: "_" };
|
|
13
|
+
|
|
14
|
+
/** Characters that break filenames or Obsidian links. */
|
|
15
|
+
const FORBIDDEN = /[<>:"/\\|?*(){}[\]#~`]/g;
|
|
16
|
+
|
|
17
|
+
/** Sanitize a raw title for use as a filesystem name; never empty. */
|
|
18
|
+
export function sanitizeTitle(raw: string): string {
|
|
19
|
+
const clean = raw.replace(FORBIDDEN, "").replace(/\s+/g, " ").trim();
|
|
20
|
+
const capped = clean.slice(0, 50).trim();
|
|
21
|
+
return capped || "untitled";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Title = first H1 of the content, else the fallback, sanitized. */
|
|
25
|
+
export function titleFrom(content: string, fallback?: string): string {
|
|
26
|
+
const h1 = content.match(/^#[ \t]+(.+)$/m)?.[1];
|
|
27
|
+
return sanitizeTitle(h1 ?? fallback ?? "untitled");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render the filename pattern ({YYYY}, {MM}, {DD}, {title}); each whitespace
|
|
32
|
+
* run in the result becomes the configured separator.
|
|
33
|
+
*/
|
|
34
|
+
export function formatFilename(format: string, title: string, date: Date, separator: Separator = "space"): string {
|
|
35
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
36
|
+
const name = format
|
|
37
|
+
.replaceAll("{YYYY}", String(date.getFullYear()))
|
|
38
|
+
.replaceAll("{MM}", pad(date.getMonth() + 1))
|
|
39
|
+
.replaceAll("{DD}", pad(date.getDate()))
|
|
40
|
+
.replaceAll("{title}", title);
|
|
41
|
+
return name.replace(/\s+/g, SEPARATOR_CHAR[separator]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** First free path for base.md in dir: base.md, base 2.md, base 3.md, ... */
|
|
45
|
+
export function uniquePath(dir: string, base: string): string {
|
|
46
|
+
let candidate = join(dir, `${base}.md`);
|
|
47
|
+
for (let n = 2; existsSync(candidate); n++) {
|
|
48
|
+
candidate = join(dir, `${base} ${n}.md`);
|
|
49
|
+
}
|
|
50
|
+
return candidate;
|
|
51
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Frontmatter for exported notes: provenance the vault can query. */
|
|
2
|
+
|
|
3
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
4
|
+
|
|
5
|
+
export function frontmatter(session: ReviewSession, created: Date): string {
|
|
6
|
+
return [
|
|
7
|
+
"---",
|
|
8
|
+
`created: ${created.toISOString()}`,
|
|
9
|
+
"source: cueloop",
|
|
10
|
+
`session: ${session.id}`,
|
|
11
|
+
`verdict: ${session.verdict?.kind ?? "pending"}`,
|
|
12
|
+
"---",
|
|
13
|
+
].join("\n");
|
|
14
|
+
}
|