@cueloop/integration-obsidian 0.1.0-alpha.7 → 0.1.0-alpha.70
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 +63 -6
- package/src/export.ts +11 -2
- package/src/filename.test.ts +19 -2
- package/src/filename.ts +12 -2
- package/src/frontmatter.ts +2 -2
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.70",
|
|
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.7",
|
|
11
|
-
"@cueloop/schema": "0.1.0-alpha.7"
|
|
12
|
-
},
|
|
13
23
|
"publishConfig": {
|
|
14
24
|
"access": "public"
|
|
15
25
|
},
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@cueloop/extension-api": "0.1.0-alpha.70",
|
|
28
|
+
"@cueloop/schema": "0.1.0-alpha.70",
|
|
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,7 +20,13 @@ 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
|
verdict: { kind: "approve", summary: "ship it", feedback: "", resolvedAt: NOW.toISOString() },
|
|
26
32
|
status: "resolved",
|
|
@@ -44,11 +50,17 @@ function config(overrides: Partial<ObsidianConfig> = {}): ObsidianConfig {
|
|
|
44
50
|
|
|
45
51
|
describe("exportSession", () => {
|
|
46
52
|
test("writes the final plan with frontmatter into vault/folder", () => {
|
|
53
|
+
// Arrange
|
|
47
54
|
const session = resolvedSession();
|
|
55
|
+
|
|
56
|
+
// Act
|
|
48
57
|
const result = exportSession(session, config(), NOW);
|
|
58
|
+
|
|
59
|
+
// Assert
|
|
49
60
|
expect(result.success).toBe(true);
|
|
50
61
|
expect(result.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan.md"));
|
|
51
62
|
const written = readFileSync(result.path!, "utf8");
|
|
63
|
+
|
|
52
64
|
expect(written).toBe(`${frontmatter(session, NOW)}\n\n# Migration Plan\n\nMove the store.\n`);
|
|
53
65
|
expect(written).toContain("created: 2026-08-07T12:34:56.000Z");
|
|
54
66
|
expect(written).toContain("source: cueloop");
|
|
@@ -57,40 +69,75 @@ describe("exportSession", () => {
|
|
|
57
69
|
});
|
|
58
70
|
|
|
59
71
|
test("the working copy wins over the submitted content", () => {
|
|
60
|
-
|
|
72
|
+
// Arrange
|
|
73
|
+
const session = resolvedSession({
|
|
74
|
+
workingCopy: "# Migration Plan\n\nMove the store carefully.\n",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Act
|
|
61
78
|
const result = exportSession(session, config(), NOW);
|
|
79
|
+
|
|
80
|
+
// Assert
|
|
62
81
|
expect(readFileSync(result.path!, "utf8")).toContain("Move the store carefully.");
|
|
63
82
|
});
|
|
64
83
|
|
|
65
84
|
test("never overwrites: collisions get counting suffixes", () => {
|
|
85
|
+
// Arrange
|
|
66
86
|
const session = resolvedSession();
|
|
87
|
+
|
|
88
|
+
// Act
|
|
67
89
|
const first = exportSession(session, config(), NOW);
|
|
68
90
|
const second = exportSession(session, config(), NOW);
|
|
69
91
|
const third = exportSession(session, config(), NOW);
|
|
92
|
+
|
|
93
|
+
// Assert
|
|
70
94
|
expect(second.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan 2.md"));
|
|
71
95
|
expect(third.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan 3.md"));
|
|
72
96
|
expect(existsSync(first.path!)).toBe(true);
|
|
73
97
|
});
|
|
74
98
|
|
|
75
99
|
test("no vault configured and none detected fails without writing", () => {
|
|
100
|
+
// Arrange
|
|
76
101
|
const emptyConfigPath = join(vault, "obsidian.json");
|
|
77
|
-
|
|
102
|
+
|
|
103
|
+
// Act
|
|
104
|
+
const result = exportSession(
|
|
105
|
+
resolvedSession(),
|
|
106
|
+
config({ vault: undefined, obsidianConfigPath: emptyConfigPath }),
|
|
107
|
+
NOW,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Assert
|
|
78
111
|
expect(result.success).toBe(false);
|
|
79
112
|
expect(result.error).toContain("no Obsidian vault");
|
|
80
113
|
});
|
|
81
114
|
|
|
82
115
|
test("a configured vault path that does not exist fails", () => {
|
|
116
|
+
// Act
|
|
83
117
|
const result = exportSession(resolvedSession(), config({ vault: join(vault, "missing") }), NOW);
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
84
120
|
expect(result.success).toBe(false);
|
|
85
121
|
expect(result.error).toContain("vault not found");
|
|
86
122
|
});
|
|
87
123
|
|
|
88
124
|
test("falls back to the first auto-detected vault", () => {
|
|
125
|
+
// Arrange
|
|
89
126
|
const detected = join(vault, "detected-vault");
|
|
127
|
+
|
|
90
128
|
mkdirSync(detected);
|
|
91
129
|
const obsidianJson = join(vault, "obsidian.json");
|
|
130
|
+
|
|
92
131
|
writeFileSync(obsidianJson, JSON.stringify({ vaults: { v1: { path: detected } } }));
|
|
93
|
-
|
|
132
|
+
|
|
133
|
+
// Act
|
|
134
|
+
const result = exportSession(
|
|
135
|
+
resolvedSession(),
|
|
136
|
+
config({ vault: undefined, obsidianConfigPath: obsidianJson }),
|
|
137
|
+
NOW,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Assert
|
|
94
141
|
expect(result.success).toBe(true);
|
|
95
142
|
expect(result.path).toBe(join(detected, "cueloop", "2026-08-07 - Migration Plan.md"));
|
|
96
143
|
});
|
|
@@ -98,12 +145,22 @@ describe("exportSession", () => {
|
|
|
98
145
|
|
|
99
146
|
describe("obsidian extension", () => {
|
|
100
147
|
test("registers the exporter through the extension API and exports a resolved session", async () => {
|
|
148
|
+
// Arrange
|
|
101
149
|
const registry = new Registry();
|
|
150
|
+
|
|
151
|
+
// Act
|
|
102
152
|
const record = await registry.load("obsidian", createObsidianExtension(config()));
|
|
153
|
+
|
|
154
|
+
// Assert
|
|
103
155
|
expect(record.errors).toEqual([]);
|
|
104
156
|
const exporter = record.exporters.get("obsidian");
|
|
157
|
+
|
|
105
158
|
expect(exporter).toBeDefined();
|
|
159
|
+
|
|
160
|
+
// Act
|
|
106
161
|
const result = await exporter!(resolvedSession());
|
|
162
|
+
|
|
163
|
+
// Assert
|
|
107
164
|
expect(result.success).toBe(true);
|
|
108
165
|
expect(existsSync(result.path!)).toBe(true);
|
|
109
166
|
});
|
package/src/export.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
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, VerdictKind } 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";
|
|
@@ -34,6 +34,7 @@ export const OBSIDIAN_DEFAULTS: ObsidianConfig = {
|
|
|
34
34
|
/** approve exports only approvals; resolve exports any verdict; manual never auto-exports. */
|
|
35
35
|
export function shouldExport(exportOn: ExportOn, verdict: VerdictKind): boolean {
|
|
36
36
|
if (exportOn === "approve") return verdict === "approve";
|
|
37
|
+
|
|
37
38
|
return exportOn === "resolve";
|
|
38
39
|
}
|
|
39
40
|
|
|
@@ -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,8 +1,8 @@
|
|
|
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()}`,
|