@cueloop/integration-obsidian 0.1.0-alpha.4 → 0.1.0-alpha.40
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 +19 -14
- package/src/detect.test.ts +6 -1
- package/src/detect.ts +12 -4
- package/src/export.test.ts +57 -4
- package/src/export.ts +5 -1
- package/src/filename.test.ts +18 -2
- package/src/filename.ts +7 -2
package/package.json
CHANGED
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/integration-obsidian",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.40",
|
|
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.4",
|
|
11
|
-
"@cueloop/schema": "0.1.0-alpha.4"
|
|
12
|
-
},
|
|
13
23
|
"publishConfig": {
|
|
14
24
|
"access": "public"
|
|
15
25
|
},
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@cueloop/extension-api": "0.1.0-alpha.40",
|
|
28
|
+
"@cueloop/schema": "0.1.0-alpha.40"
|
|
29
|
+
},
|
|
20
30
|
"engines": {
|
|
21
31
|
"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
32
|
}
|
|
28
33
|
}
|
package/src/detect.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ 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");
|
|
19
20
|
mkdirSync(alive);
|
|
20
21
|
const gone = join(dir, "deleted-vault");
|
|
@@ -28,6 +29,8 @@ describe("detectVaults", () => {
|
|
|
28
29
|
},
|
|
29
30
|
}),
|
|
30
31
|
);
|
|
32
|
+
|
|
33
|
+
// Assert
|
|
31
34
|
expect(detectVaults(configPath)).toEqual([alive]);
|
|
32
35
|
});
|
|
33
36
|
|
|
@@ -44,7 +47,9 @@ describe("detectVaults", () => {
|
|
|
44
47
|
|
|
45
48
|
describe("obsidianConfigPath", () => {
|
|
46
49
|
test("resolves per platform", () => {
|
|
47
|
-
expect(obsidianConfigPath("darwin")).toContain(
|
|
50
|
+
expect(obsidianConfigPath("darwin")).toContain(
|
|
51
|
+
join("Library", "Application Support", "obsidian", "obsidian.json"),
|
|
52
|
+
);
|
|
48
53
|
expect(obsidianConfigPath("linux")).toContain(join("obsidian", "obsidian.json"));
|
|
49
54
|
expect(obsidianConfigPath("win32")).toContain(join("obsidian", "obsidian.json"));
|
|
50
55
|
});
|
package/src/detect.ts
CHANGED
|
@@ -14,9 +14,17 @@ export function obsidianConfigPath(platform: NodeJS.Platform = process.platform)
|
|
|
14
14
|
return join(homedir(), "Library", "Application Support", "obsidian", "obsidian.json");
|
|
15
15
|
}
|
|
16
16
|
if (platform === "win32") {
|
|
17
|
-
return join(
|
|
17
|
+
return join(
|
|
18
|
+
process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
|
|
19
|
+
"obsidian",
|
|
20
|
+
"obsidian.json",
|
|
21
|
+
);
|
|
18
22
|
}
|
|
19
|
-
return join(
|
|
23
|
+
return join(
|
|
24
|
+
process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
|
|
25
|
+
"obsidian",
|
|
26
|
+
"obsidian.json",
|
|
27
|
+
);
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
/** Vault paths registered with Obsidian that exist on disk. */
|
|
@@ -26,8 +34,8 @@ export function detectVaults(configPath: string = obsidianConfigPath()): string[
|
|
|
26
34
|
vaults?: Record<string, { path?: string }>;
|
|
27
35
|
};
|
|
28
36
|
return Object.values(raw.vaults ?? {})
|
|
29
|
-
.map((
|
|
30
|
-
.filter((
|
|
37
|
+
.map((vault) => vault.path)
|
|
38
|
+
.filter((path): path is string => typeof path === "string" && existsSync(path));
|
|
31
39
|
} catch {
|
|
32
40
|
// no Obsidian install, or an unreadable registry: no vaults
|
|
33
41
|
return [];
|
package/src/export.test.ts
CHANGED
|
@@ -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,8 +50,13 @@ 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");
|
|
@@ -57,40 +68,73 @@ describe("exportSession", () => {
|
|
|
57
68
|
});
|
|
58
69
|
|
|
59
70
|
test("the working copy wins over the submitted content", () => {
|
|
60
|
-
|
|
71
|
+
// Arrange
|
|
72
|
+
const session = resolvedSession({
|
|
73
|
+
workingCopy: "# Migration Plan\n\nMove the store carefully.\n",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Act
|
|
61
77
|
const result = exportSession(session, config(), NOW);
|
|
78
|
+
|
|
79
|
+
// Assert
|
|
62
80
|
expect(readFileSync(result.path!, "utf8")).toContain("Move the store carefully.");
|
|
63
81
|
});
|
|
64
82
|
|
|
65
83
|
test("never overwrites: collisions get counting suffixes", () => {
|
|
84
|
+
// Arrange
|
|
66
85
|
const session = resolvedSession();
|
|
86
|
+
|
|
87
|
+
// Act
|
|
67
88
|
const first = exportSession(session, config(), NOW);
|
|
68
89
|
const second = exportSession(session, config(), NOW);
|
|
69
90
|
const third = exportSession(session, config(), NOW);
|
|
91
|
+
|
|
92
|
+
// Assert
|
|
70
93
|
expect(second.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan 2.md"));
|
|
71
94
|
expect(third.path).toBe(join(vault, "cueloop", "2026-08-07 - Migration Plan 3.md"));
|
|
72
95
|
expect(existsSync(first.path!)).toBe(true);
|
|
73
96
|
});
|
|
74
97
|
|
|
75
98
|
test("no vault configured and none detected fails without writing", () => {
|
|
99
|
+
// Arrange
|
|
76
100
|
const emptyConfigPath = join(vault, "obsidian.json");
|
|
77
|
-
|
|
101
|
+
|
|
102
|
+
// Act
|
|
103
|
+
const result = exportSession(
|
|
104
|
+
resolvedSession(),
|
|
105
|
+
config({ vault: undefined, obsidianConfigPath: emptyConfigPath }),
|
|
106
|
+
NOW,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Assert
|
|
78
110
|
expect(result.success).toBe(false);
|
|
79
111
|
expect(result.error).toContain("no Obsidian vault");
|
|
80
112
|
});
|
|
81
113
|
|
|
82
114
|
test("a configured vault path that does not exist fails", () => {
|
|
115
|
+
// Act
|
|
83
116
|
const result = exportSession(resolvedSession(), config({ vault: join(vault, "missing") }), NOW);
|
|
117
|
+
|
|
118
|
+
// Assert
|
|
84
119
|
expect(result.success).toBe(false);
|
|
85
120
|
expect(result.error).toContain("vault not found");
|
|
86
121
|
});
|
|
87
122
|
|
|
88
123
|
test("falls back to the first auto-detected vault", () => {
|
|
124
|
+
// Arrange
|
|
89
125
|
const detected = join(vault, "detected-vault");
|
|
90
126
|
mkdirSync(detected);
|
|
91
127
|
const obsidianJson = join(vault, "obsidian.json");
|
|
92
128
|
writeFileSync(obsidianJson, JSON.stringify({ vaults: { v1: { path: detected } } }));
|
|
93
|
-
|
|
129
|
+
|
|
130
|
+
// Act
|
|
131
|
+
const result = exportSession(
|
|
132
|
+
resolvedSession(),
|
|
133
|
+
config({ vault: undefined, obsidianConfigPath: obsidianJson }),
|
|
134
|
+
NOW,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Assert
|
|
94
138
|
expect(result.success).toBe(true);
|
|
95
139
|
expect(result.path).toBe(join(detected, "cueloop", "2026-08-07 - Migration Plan.md"));
|
|
96
140
|
});
|
|
@@ -98,12 +142,21 @@ describe("exportSession", () => {
|
|
|
98
142
|
|
|
99
143
|
describe("obsidian extension", () => {
|
|
100
144
|
test("registers the exporter through the extension API and exports a resolved session", async () => {
|
|
145
|
+
// Arrange
|
|
101
146
|
const registry = new Registry();
|
|
147
|
+
|
|
148
|
+
// Act
|
|
102
149
|
const record = await registry.load("obsidian", createObsidianExtension(config()));
|
|
150
|
+
|
|
151
|
+
// Assert
|
|
103
152
|
expect(record.errors).toEqual([]);
|
|
104
153
|
const exporter = record.exporters.get("obsidian");
|
|
105
154
|
expect(exporter).toBeDefined();
|
|
155
|
+
|
|
156
|
+
// Act
|
|
106
157
|
const result = await exporter!(resolvedSession());
|
|
158
|
+
|
|
159
|
+
// Assert
|
|
107
160
|
expect(result.success).toBe(true);
|
|
108
161
|
expect(existsSync(result.path!)).toBe(true);
|
|
109
162
|
});
|
package/src/export.ts
CHANGED
|
@@ -43,7 +43,11 @@ export interface ExportResult {
|
|
|
43
43
|
error?: string;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export function exportSession(
|
|
46
|
+
export function exportSession(
|
|
47
|
+
session: ReviewSession,
|
|
48
|
+
config: ObsidianConfig,
|
|
49
|
+
now: Date = new Date(),
|
|
50
|
+
): ExportResult {
|
|
47
51
|
const vault = config.vault ?? detectVaults(config.obsidianConfigPath)[0];
|
|
48
52
|
if (!vault) return { success: false, error: "no Obsidian vault configured or detected" };
|
|
49
53
|
if (!existsSync(vault)) return { success: false, error: `vault not found: ${vault}` };
|
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,11 +38,15 @@ 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
|
});
|
|
@@ -54,10 +61,19 @@ describe("uniquePath", () => {
|
|
|
54
61
|
});
|
|
55
62
|
|
|
56
63
|
test("appends counting suffixes instead of overwriting", () => {
|
|
64
|
+
// Assert
|
|
57
65
|
expect(uniquePath(dir, "plan")).toBe(join(dir, "plan.md"));
|
|
66
|
+
|
|
67
|
+
// Act
|
|
58
68
|
writeFileSync(join(dir, "plan.md"), "");
|
|
69
|
+
|
|
70
|
+
// Assert
|
|
59
71
|
expect(uniquePath(dir, "plan")).toBe(join(dir, "plan 2.md"));
|
|
72
|
+
|
|
73
|
+
// Act
|
|
60
74
|
writeFileSync(join(dir, "plan 2.md"), "");
|
|
75
|
+
|
|
76
|
+
// Assert
|
|
61
77
|
expect(uniquePath(dir, "plan")).toBe(join(dir, "plan 3.md"));
|
|
62
78
|
});
|
|
63
79
|
});
|
package/src/filename.ts
CHANGED
|
@@ -31,8 +31,13 @@ export function titleFrom(content: string, fallback?: string): string {
|
|
|
31
31
|
* Render the filename pattern ({YYYY}, {MM}, {DD}, {title}); each whitespace
|
|
32
32
|
* run in the result becomes the configured separator.
|
|
33
33
|
*/
|
|
34
|
-
export function formatFilename(
|
|
35
|
-
|
|
34
|
+
export function formatFilename(
|
|
35
|
+
format: string,
|
|
36
|
+
title: string,
|
|
37
|
+
date: Date,
|
|
38
|
+
separator: Separator = "space",
|
|
39
|
+
): string {
|
|
40
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
36
41
|
const name = format
|
|
37
42
|
.replaceAll("{YYYY}", String(date.getFullYear()))
|
|
38
43
|
.replaceAll("{MM}", pad(date.getMonth() + 1))
|