@cueloop/integration-obsidian 0.1.0-alpha.8 → 0.1.0-alpha.80
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 +22 -21
- package/src/detect.test.ts +10 -1
- package/src/detect.ts +20 -7
- package/src/export.test.ts +78 -17
- package/src/export.ts +16 -7
- package/src/filename.test.ts +19 -2
- package/src/filename.ts +12 -2
- package/src/frontmatter.ts +3 -3
package/package.json
CHANGED
|
@@ -1,33 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/integration-obsidian",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.80",
|
|
4
|
+
"description": "Export reviewed cueloop plans into an Obsidian vault",
|
|
5
|
+
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
8
|
+
},
|
|
4
9
|
"license": "Apache-2.0",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
13
|
+
"directory": "packages/integrations/obsidian"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
5
19
|
"type": "module",
|
|
6
20
|
"exports": {
|
|
7
21
|
".": "./src/index.ts"
|
|
8
22
|
},
|
|
9
|
-
"dependencies": {
|
|
10
|
-
"@cueloop/extension-api": "0.1.0-alpha.8",
|
|
11
|
-
"@cueloop/schema": "0.1.0-alpha.8"
|
|
12
|
-
},
|
|
13
23
|
"publishConfig": {
|
|
14
24
|
"access": "public"
|
|
15
25
|
},
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@cueloop/extension-api": "0.1.0-alpha.80",
|
|
28
|
+
"@cueloop/schema": "0.1.0-alpha.80",
|
|
29
|
+
"valibot": "^1.4.2"
|
|
30
|
+
},
|
|
20
31
|
"engines": {
|
|
21
32
|
"bun": ">=1.3.0"
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
"type": "git",
|
|
25
|
-
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
26
|
-
"directory": "packages/integrations/obsidian"
|
|
27
|
-
},
|
|
28
|
-
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
29
|
-
"bugs": {
|
|
30
|
-
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
31
|
-
},
|
|
32
|
-
"description": "Export reviewed cueloop plans into an Obsidian vault"
|
|
33
|
-
}
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/detect.test.ts
CHANGED
|
@@ -15,10 +15,13 @@ afterEach(() => {
|
|
|
15
15
|
|
|
16
16
|
describe("detectVaults", () => {
|
|
17
17
|
test("returns registered vault paths that exist on disk", () => {
|
|
18
|
+
// Arrange
|
|
18
19
|
const alive = join(dir, "notes");
|
|
20
|
+
|
|
19
21
|
mkdirSync(alive);
|
|
20
22
|
const gone = join(dir, "deleted-vault");
|
|
21
23
|
const configPath = join(dir, "obsidian.json");
|
|
24
|
+
|
|
22
25
|
writeFileSync(
|
|
23
26
|
configPath,
|
|
24
27
|
JSON.stringify({
|
|
@@ -28,15 +31,19 @@ describe("detectVaults", () => {
|
|
|
28
31
|
},
|
|
29
32
|
}),
|
|
30
33
|
);
|
|
34
|
+
|
|
35
|
+
// Assert
|
|
31
36
|
expect(detectVaults(configPath)).toEqual([alive]);
|
|
32
37
|
});
|
|
33
38
|
|
|
34
39
|
test("missing or malformed config yields no vaults", () => {
|
|
35
40
|
expect(detectVaults(join(dir, "nope.json"))).toEqual([]);
|
|
36
41
|
const broken = join(dir, "broken.json");
|
|
42
|
+
|
|
37
43
|
writeFileSync(broken, "not json");
|
|
38
44
|
expect(detectVaults(broken)).toEqual([]);
|
|
39
45
|
const empty = join(dir, "empty.json");
|
|
46
|
+
|
|
40
47
|
writeFileSync(empty, "{}");
|
|
41
48
|
expect(detectVaults(empty)).toEqual([]);
|
|
42
49
|
});
|
|
@@ -44,7 +51,9 @@ describe("detectVaults", () => {
|
|
|
44
51
|
|
|
45
52
|
describe("obsidianConfigPath", () => {
|
|
46
53
|
test("resolves per platform", () => {
|
|
47
|
-
expect(obsidianConfigPath("darwin")).toContain(
|
|
54
|
+
expect(obsidianConfigPath("darwin")).toContain(
|
|
55
|
+
join("Library", "Application Support", "obsidian", "obsidian.json"),
|
|
56
|
+
);
|
|
48
57
|
expect(obsidianConfigPath("linux")).toContain(join("obsidian", "obsidian.json"));
|
|
49
58
|
expect(obsidianConfigPath("win32")).toContain(join("obsidian", "obsidian.json"));
|
|
50
59
|
});
|
package/src/detect.ts
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
+
import * as v from "valibot";
|
|
11
|
+
|
|
12
|
+
const ObsidianRegistrySchema = v.object({
|
|
13
|
+
vaults: v.optional(v.record(v.string(), v.object({ path: v.optional(v.string()) }))),
|
|
14
|
+
});
|
|
10
15
|
|
|
11
16
|
/** Platform-specific location of Obsidian's own obsidian.json. */
|
|
12
17
|
export function obsidianConfigPath(platform: NodeJS.Platform = process.platform): string {
|
|
@@ -14,20 +19,28 @@ export function obsidianConfigPath(platform: NodeJS.Platform = process.platform)
|
|
|
14
19
|
return join(homedir(), "Library", "Application Support", "obsidian", "obsidian.json");
|
|
15
20
|
}
|
|
16
21
|
if (platform === "win32") {
|
|
17
|
-
return join(
|
|
22
|
+
return join(
|
|
23
|
+
process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
|
|
24
|
+
"obsidian",
|
|
25
|
+
"obsidian.json",
|
|
26
|
+
);
|
|
18
27
|
}
|
|
19
|
-
|
|
28
|
+
|
|
29
|
+
return join(
|
|
30
|
+
process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
|
|
31
|
+
"obsidian",
|
|
32
|
+
"obsidian.json",
|
|
33
|
+
);
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
/** Vault paths registered with Obsidian that exist on disk. */
|
|
23
37
|
export function detectVaults(configPath: string = obsidianConfigPath()): string[] {
|
|
24
38
|
try {
|
|
25
|
-
const raw = JSON.parse(readFileSync(configPath, "utf8"))
|
|
26
|
-
|
|
27
|
-
};
|
|
39
|
+
const raw = v.parse(ObsidianRegistrySchema, JSON.parse(readFileSync(configPath, "utf8")));
|
|
40
|
+
|
|
28
41
|
return Object.values(raw.vaults ?? {})
|
|
29
|
-
.map((
|
|
30
|
-
.filter((
|
|
42
|
+
.map((vault) => vault.path)
|
|
43
|
+
.filter((path): path is string => typeof path === "string" && existsSync(path));
|
|
31
44
|
} catch {
|
|
32
45
|
// no Obsidian install, or an unreadable registry: no vaults
|
|
33
46
|
return [];
|
package/src/export.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import type {
|
|
5
|
+
import type { Thread } from "@cueloop/schema";
|
|
6
6
|
import { Registry } from "@cueloop/extension-api";
|
|
7
7
|
import { OBSIDIAN_DEFAULTS, exportSession, shouldExport, type ObsidianConfig } from "./export";
|
|
8
8
|
import { createObsidianExtension } from "./extension";
|
|
@@ -10,7 +10,7 @@ import { frontmatter } from "./frontmatter";
|
|
|
10
10
|
|
|
11
11
|
const NOW = new Date("2026-08-07T12:34:56.000Z");
|
|
12
12
|
|
|
13
|
-
function resolvedSession(overrides: Partial<
|
|
13
|
+
function resolvedSession(overrides: Partial<Thread> = {}): Thread {
|
|
14
14
|
return {
|
|
15
15
|
schemaVersion: "1",
|
|
16
16
|
id: "s_test1",
|
|
@@ -20,9 +20,21 @@ function resolvedSession(overrides: Partial<ReviewSession> = {}): ReviewSession
|
|
|
20
20
|
content: "# Migration Plan\n\nMove the store.\n",
|
|
21
21
|
meta: { title: "Migration Plan" },
|
|
22
22
|
},
|
|
23
|
-
revisions: [
|
|
23
|
+
revisions: [
|
|
24
|
+
{
|
|
25
|
+
revision: 1,
|
|
26
|
+
content: "# Migration Plan\n\nMove the store.\n",
|
|
27
|
+
submittedAt: NOW.toISOString(),
|
|
28
|
+
},
|
|
29
|
+
],
|
|
24
30
|
annotations: [],
|
|
25
|
-
|
|
31
|
+
message: {
|
|
32
|
+
id: "msg_1",
|
|
33
|
+
outcome: "approved",
|
|
34
|
+
summary: "ship it",
|
|
35
|
+
body: "",
|
|
36
|
+
sentAt: NOW.toISOString(),
|
|
37
|
+
},
|
|
26
38
|
status: "resolved",
|
|
27
39
|
createdAt: NOW.toISOString(),
|
|
28
40
|
...overrides,
|
|
@@ -44,53 +56,94 @@ function config(overrides: Partial<ObsidianConfig> = {}): ObsidianConfig {
|
|
|
44
56
|
|
|
45
57
|
describe("exportSession", () => {
|
|
46
58
|
test("writes the final plan with frontmatter into vault/folder", () => {
|
|
59
|
+
// Arrange
|
|
47
60
|
const session = resolvedSession();
|
|
61
|
+
|
|
62
|
+
// Act
|
|
48
63
|
const result = exportSession(session, config(), NOW);
|
|
64
|
+
|
|
65
|
+
// Assert
|
|
49
66
|
expect(result.success).toBe(true);
|
|
50
67
|
expect(result.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan.md"));
|
|
51
68
|
const written = readFileSync(result.path!, "utf8");
|
|
69
|
+
|
|
52
70
|
expect(written).toBe(`${frontmatter(session, NOW)}\n\n# Migration Plan\n\nMove the store.\n`);
|
|
53
71
|
expect(written).toContain("created: 2026-08-07T12:34:56.000Z");
|
|
54
72
|
expect(written).toContain("source: cueloop");
|
|
55
73
|
expect(written).toContain("session: s_test1");
|
|
56
|
-
expect(written).toContain("
|
|
74
|
+
expect(written).toContain("message: approved");
|
|
57
75
|
});
|
|
58
76
|
|
|
59
77
|
test("the working copy wins over the submitted content", () => {
|
|
60
|
-
|
|
78
|
+
// Arrange
|
|
79
|
+
const session = resolvedSession({
|
|
80
|
+
workingCopy: "# Migration Plan\n\nMove the store carefully.\n",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Act
|
|
61
84
|
const result = exportSession(session, config(), NOW);
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
62
87
|
expect(readFileSync(result.path!, "utf8")).toContain("Move the store carefully.");
|
|
63
88
|
});
|
|
64
89
|
|
|
65
90
|
test("never overwrites: collisions get counting suffixes", () => {
|
|
91
|
+
// Arrange
|
|
66
92
|
const session = resolvedSession();
|
|
93
|
+
|
|
94
|
+
// Act
|
|
67
95
|
const first = exportSession(session, config(), NOW);
|
|
68
96
|
const second = exportSession(session, config(), NOW);
|
|
69
97
|
const third = exportSession(session, config(), NOW);
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
70
100
|
expect(second.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan 2.md"));
|
|
71
101
|
expect(third.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan 3.md"));
|
|
72
102
|
expect(existsSync(first.path!)).toBe(true);
|
|
73
103
|
});
|
|
74
104
|
|
|
75
105
|
test("no vault configured and none detected fails without writing", () => {
|
|
106
|
+
// Arrange
|
|
76
107
|
const emptyConfigPath = join(vault, "obsidian.json");
|
|
77
|
-
|
|
108
|
+
|
|
109
|
+
// Act
|
|
110
|
+
const result = exportSession(
|
|
111
|
+
resolvedSession(),
|
|
112
|
+
config({ vault: undefined, obsidianConfigPath: emptyConfigPath }),
|
|
113
|
+
NOW,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
// Assert
|
|
78
117
|
expect(result.success).toBe(false);
|
|
79
118
|
expect(result.error).toContain("no Obsidian vault");
|
|
80
119
|
});
|
|
81
120
|
|
|
82
121
|
test("a configured vault path that does not exist fails", () => {
|
|
122
|
+
// Act
|
|
83
123
|
const result = exportSession(resolvedSession(), config({ vault: join(vault, "missing") }), NOW);
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
84
126
|
expect(result.success).toBe(false);
|
|
85
127
|
expect(result.error).toContain("vault not found");
|
|
86
128
|
});
|
|
87
129
|
|
|
88
130
|
test("falls back to the first auto-detected vault", () => {
|
|
131
|
+
// Arrange
|
|
89
132
|
const detected = join(vault, "detected-vault");
|
|
133
|
+
|
|
90
134
|
mkdirSync(detected);
|
|
91
135
|
const obsidianJson = join(vault, "obsidian.json");
|
|
136
|
+
|
|
92
137
|
writeFileSync(obsidianJson, JSON.stringify({ vaults: { v1: { path: detected } } }));
|
|
93
|
-
|
|
138
|
+
|
|
139
|
+
// Act
|
|
140
|
+
const result = exportSession(
|
|
141
|
+
resolvedSession(),
|
|
142
|
+
config({ vault: undefined, obsidianConfigPath: obsidianJson }),
|
|
143
|
+
NOW,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Assert
|
|
94
147
|
expect(result.success).toBe(true);
|
|
95
148
|
expect(result.path).toBe(join(detected, "cueloop", "2026-08-07 - Migration Plan.md"));
|
|
96
149
|
});
|
|
@@ -98,12 +151,22 @@ describe("exportSession", () => {
|
|
|
98
151
|
|
|
99
152
|
describe("obsidian extension", () => {
|
|
100
153
|
test("registers the exporter through the extension API and exports a resolved session", async () => {
|
|
154
|
+
// Arrange
|
|
101
155
|
const registry = new Registry();
|
|
156
|
+
|
|
157
|
+
// Act
|
|
102
158
|
const record = await registry.load("obsidian", createObsidianExtension(config()));
|
|
159
|
+
|
|
160
|
+
// Assert
|
|
103
161
|
expect(record.errors).toEqual([]);
|
|
104
162
|
const exporter = record.exporters.get("obsidian");
|
|
163
|
+
|
|
105
164
|
expect(exporter).toBeDefined();
|
|
165
|
+
|
|
166
|
+
// Act
|
|
106
167
|
const result = await exporter!(resolvedSession());
|
|
168
|
+
|
|
169
|
+
// Assert
|
|
107
170
|
expect(result.success).toBe(true);
|
|
108
171
|
expect(existsSync(result.path!)).toBe(true);
|
|
109
172
|
});
|
|
@@ -111,19 +174,17 @@ describe("obsidian extension", () => {
|
|
|
111
174
|
|
|
112
175
|
describe("shouldExport", () => {
|
|
113
176
|
test("approve exports only approvals", () => {
|
|
114
|
-
expect(shouldExport("
|
|
115
|
-
expect(shouldExport("
|
|
116
|
-
expect(shouldExport("approve", "comment")).toBe(false);
|
|
177
|
+
expect(shouldExport("approved", "approved")).toBe(true);
|
|
178
|
+
expect(shouldExport("approved", "changes_requested")).toBe(false);
|
|
117
179
|
});
|
|
118
180
|
|
|
119
|
-
test("
|
|
120
|
-
expect(shouldExport("
|
|
121
|
-
expect(shouldExport("
|
|
122
|
-
expect(shouldExport("resolve", "comment")).toBe(true);
|
|
181
|
+
test("message exports any outcome", () => {
|
|
182
|
+
expect(shouldExport("message", "approved")).toBe(true);
|
|
183
|
+
expect(shouldExport("message", "changes_requested")).toBe(true);
|
|
123
184
|
});
|
|
124
185
|
|
|
125
186
|
test("manual never auto-exports", () => {
|
|
126
|
-
expect(shouldExport("manual", "
|
|
127
|
-
expect(shouldExport("manual", "
|
|
187
|
+
expect(shouldExport("manual", "approved")).toBe(false);
|
|
188
|
+
expect(shouldExport("manual", "changes_requested")).toBe(false);
|
|
128
189
|
});
|
|
129
190
|
});
|
package/src/export.ts
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import type {
|
|
9
|
+
import type { Thread, MessageOutcome } from "@cueloop/schema";
|
|
10
10
|
import { detectVaults } from "./detect";
|
|
11
11
|
import { formatFilename, titleFrom, uniquePath, type Separator } from "./filename";
|
|
12
12
|
import { frontmatter } from "./frontmatter";
|
|
13
13
|
|
|
14
|
-
export type ExportOn = "
|
|
14
|
+
export type ExportOn = "approved" | "message" | "manual";
|
|
15
15
|
|
|
16
16
|
export interface ObsidianConfig {
|
|
17
17
|
/** Vault path; when unset, the first auto-detected vault is used. */
|
|
@@ -31,10 +31,11 @@ export const OBSIDIAN_DEFAULTS: ObsidianConfig = {
|
|
|
31
31
|
exportOn: "manual",
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
/**
|
|
35
|
-
export function shouldExport(exportOn: ExportOn,
|
|
36
|
-
if (exportOn === "
|
|
37
|
-
|
|
34
|
+
/** approved exports only approvals; message exports any Message; manual never auto-exports. */
|
|
35
|
+
export function shouldExport(exportOn: ExportOn, message: MessageOutcome): boolean {
|
|
36
|
+
if (exportOn === "approved") return message === "approved";
|
|
37
|
+
|
|
38
|
+
return exportOn === "message";
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
export interface ExportResult {
|
|
@@ -43,17 +44,25 @@ export interface ExportResult {
|
|
|
43
44
|
error?: string;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
export function exportSession(
|
|
47
|
+
export function exportSession(
|
|
48
|
+
session: Thread,
|
|
49
|
+
config: ObsidianConfig,
|
|
50
|
+
now: Date = new Date(),
|
|
51
|
+
): ExportResult {
|
|
47
52
|
const vault = config.vault ?? detectVaults(config.obsidianConfigPath)[0];
|
|
53
|
+
|
|
48
54
|
if (!vault) return { success: false, error: "no Obsidian vault configured or detected" };
|
|
49
55
|
if (!existsSync(vault)) return { success: false, error: `vault not found: ${vault}` };
|
|
50
56
|
|
|
51
57
|
const content = session.workingCopy ?? session.artifact.content;
|
|
52
58
|
const title = titleFrom(content, session.artifact.meta.title);
|
|
53
59
|
const dir = join(vault, config.folder);
|
|
60
|
+
|
|
54
61
|
mkdirSync(dir, { recursive: true });
|
|
55
62
|
const base = formatFilename(config.filenameFormat, title, now, config.separator);
|
|
56
63
|
const path = uniquePath(dir, base);
|
|
64
|
+
|
|
57
65
|
writeFileSync(path, `${frontmatter(session, now)}\n\n${content}`);
|
|
66
|
+
|
|
58
67
|
return { success: true, path };
|
|
59
68
|
}
|
package/src/filename.test.ts
CHANGED
|
@@ -23,7 +23,10 @@ describe("titleFrom", () => {
|
|
|
23
23
|
});
|
|
24
24
|
|
|
25
25
|
test("caps the title at 50 characters", () => {
|
|
26
|
+
// Arrange
|
|
26
27
|
const long = "# " + "word ".repeat(20);
|
|
28
|
+
|
|
29
|
+
// Assert
|
|
27
30
|
expect(titleFrom(long).length).toBeLessThanOrEqual(50);
|
|
28
31
|
expect(titleFrom(long).endsWith(" ")).toBe(false);
|
|
29
32
|
});
|
|
@@ -35,17 +38,22 @@ describe("titleFrom", () => {
|
|
|
35
38
|
|
|
36
39
|
describe("formatFilename", () => {
|
|
37
40
|
test("renders date tokens and the title with the space separator", () => {
|
|
38
|
-
expect(formatFilename("{YYYY}-{MM}-{DD} - {title}", "My Plan", DATE, "space")).toBe(
|
|
41
|
+
expect(formatFilename("{YYYY}-{MM}-{DD} - {title}", "My Plan", DATE, "space")).toBe(
|
|
42
|
+
"2026-08-07 - My Plan",
|
|
43
|
+
);
|
|
39
44
|
});
|
|
40
45
|
|
|
41
46
|
test("dash and underscore separators replace whitespace runs", () => {
|
|
42
|
-
expect(formatFilename("{YYYY}-{MM}-{DD} - {title}", "My Plan", DATE, "dash")).toBe(
|
|
47
|
+
expect(formatFilename("{YYYY}-{MM}-{DD} - {title}", "My Plan", DATE, "dash")).toBe(
|
|
48
|
+
"2026-08-07---My-Plan",
|
|
49
|
+
);
|
|
43
50
|
expect(formatFilename("{title}", "My Plan", DATE, "underscore")).toBe("My_Plan");
|
|
44
51
|
});
|
|
45
52
|
});
|
|
46
53
|
|
|
47
54
|
describe("uniquePath", () => {
|
|
48
55
|
let dir: string;
|
|
56
|
+
|
|
49
57
|
beforeEach(() => {
|
|
50
58
|
dir = mkdtempSync(join(tmpdir(), "cueloop-obsidian-name-"));
|
|
51
59
|
});
|
|
@@ -54,10 +62,19 @@ describe("uniquePath", () => {
|
|
|
54
62
|
});
|
|
55
63
|
|
|
56
64
|
test("appends counting suffixes instead of overwriting", () => {
|
|
65
|
+
// Assert
|
|
57
66
|
expect(uniquePath(dir, "plan")).toBe(join(dir, "plan.md"));
|
|
67
|
+
|
|
68
|
+
// Act
|
|
58
69
|
writeFileSync(join(dir, "plan.md"), "");
|
|
70
|
+
|
|
71
|
+
// Assert
|
|
59
72
|
expect(uniquePath(dir, "plan")).toBe(join(dir, "plan 2.md"));
|
|
73
|
+
|
|
74
|
+
// Act
|
|
60
75
|
writeFileSync(join(dir, "plan 2.md"), "");
|
|
76
|
+
|
|
77
|
+
// Assert
|
|
61
78
|
expect(uniquePath(dir, "plan")).toBe(join(dir, "plan 3.md"));
|
|
62
79
|
});
|
|
63
80
|
});
|
package/src/filename.ts
CHANGED
|
@@ -18,12 +18,14 @@ const FORBIDDEN = /[<>:"/\\|?*(){}[\]#~`]/g;
|
|
|
18
18
|
export function sanitizeTitle(raw: string): string {
|
|
19
19
|
const clean = raw.replace(FORBIDDEN, "").replace(/\s+/g, " ").trim();
|
|
20
20
|
const capped = clean.slice(0, 50).trim();
|
|
21
|
+
|
|
21
22
|
return capped || "untitled";
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
/** Title = first H1 of the content, else the fallback, sanitized. */
|
|
25
26
|
export function titleFrom(content: string, fallback?: string): string {
|
|
26
27
|
const h1 = content.match(/^#[ \t]+(.+)$/m)?.[1];
|
|
28
|
+
|
|
27
29
|
return sanitizeTitle(h1 ?? fallback ?? "untitled");
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -31,21 +33,29 @@ export function titleFrom(content: string, fallback?: string): string {
|
|
|
31
33
|
* Render the filename pattern ({YYYY}, {MM}, {DD}, {title}); each whitespace
|
|
32
34
|
* run in the result becomes the configured separator.
|
|
33
35
|
*/
|
|
34
|
-
export function formatFilename(
|
|
35
|
-
|
|
36
|
+
export function formatFilename(
|
|
37
|
+
format: string,
|
|
38
|
+
title: string,
|
|
39
|
+
date: Date,
|
|
40
|
+
separator: Separator = "space",
|
|
41
|
+
): string {
|
|
42
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
36
43
|
const name = format
|
|
37
44
|
.replaceAll("{YYYY}", String(date.getFullYear()))
|
|
38
45
|
.replaceAll("{MM}", pad(date.getMonth() + 1))
|
|
39
46
|
.replaceAll("{DD}", pad(date.getDate()))
|
|
40
47
|
.replaceAll("{title}", title);
|
|
48
|
+
|
|
41
49
|
return name.replace(/\s+/g, SEPARATOR_CHAR[separator]);
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
/** First free path for base.md in dir: base.md, base 2.md, base 3.md, ... */
|
|
45
53
|
export function uniquePath(dir: string, base: string): string {
|
|
46
54
|
let candidate = join(dir, `${base}.md`);
|
|
55
|
+
|
|
47
56
|
for (let n = 2; existsSync(candidate); n++) {
|
|
48
57
|
candidate = join(dir, `${base} ${n}.md`);
|
|
49
58
|
}
|
|
59
|
+
|
|
50
60
|
return candidate;
|
|
51
61
|
}
|
package/src/frontmatter.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/** Frontmatter for exported notes: provenance the vault can query. */
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { Thread } from "@cueloop/schema";
|
|
4
4
|
|
|
5
|
-
export function frontmatter(session:
|
|
5
|
+
export function frontmatter(session: Thread, created: Date): string {
|
|
6
6
|
return [
|
|
7
7
|
"---",
|
|
8
8
|
`created: ${created.toISOString()}`,
|
|
9
9
|
"source: cueloop",
|
|
10
10
|
`session: ${session.id}`,
|
|
11
|
-
`
|
|
11
|
+
`message: ${session.message?.outcome ?? "pending"}`,
|
|
12
12
|
"---",
|
|
13
13
|
].join("\n");
|
|
14
14
|
}
|