@cueloop/integration-obsidian 0.1.0-alpha.7 → 0.1.0-alpha.71

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 CHANGED
@@ -1,33 +1,34 @@
1
1
  {
2
2
  "name": "@cueloop/integration-obsidian",
3
- "version": "0.1.0-alpha.7",
3
+ "version": "0.1.0-alpha.71",
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
- "files": [
17
- "src",
18
- "README.md"
19
- ],
26
+ "dependencies": {
27
+ "@cueloop/extension-api": "0.1.0-alpha.71",
28
+ "@cueloop/schema": "0.1.0-alpha.71",
29
+ "valibot": "^1.4.2"
30
+ },
20
31
  "engines": {
21
32
  "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
- "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
+ }
@@ -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(join("Library", "Application Support", "obsidian", "obsidian.json"));
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(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "obsidian", "obsidian.json");
22
+ return join(
23
+ process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
24
+ "obsidian",
25
+ "obsidian.json",
26
+ );
18
27
  }
19
- return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "obsidian", "obsidian.json");
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")) as {
26
- vaults?: Record<string, { path?: string }>;
27
- };
39
+ const raw = v.parse(ObsidianRegistrySchema, JSON.parse(readFileSync(configPath, "utf8")));
40
+
28
41
  return Object.values(raw.vaults ?? {})
29
- .map((v) => v.path)
30
- .filter((p): p is string => typeof p === "string" && existsSync(p));
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 [];
@@ -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 { ReviewSession } from "@cueloop/schema";
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<ReviewSession> = {}): ReviewSession {
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: [{ revision: 1, content: "# Migration Plan\n\nMove the store.\n", submittedAt: NOW.toISOString() }],
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
- const session = resolvedSession({ workingCopy: "# Migration Plan\n\nMove the store carefully.\n" });
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
- const result = exportSession(resolvedSession(), config({ vault: undefined, obsidianConfigPath: emptyConfigPath }), NOW);
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
- const result = exportSession(resolvedSession(), config({ vault: undefined, obsidianConfigPath: obsidianJson }), NOW);
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 { ReviewSession, VerdictKind } from "@cueloop/schema";
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(session: ReviewSession, config: ObsidianConfig, now: Date = new Date()): ExportResult {
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
  }
@@ -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("2026-08-07 - My Plan");
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("2026-08-07---My-Plan");
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(format: string, title: string, date: Date, separator: Separator = "space"): string {
35
- const pad = (n: number) => String(n).padStart(2, "0");
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
  }
@@ -1,8 +1,8 @@
1
1
  /** Frontmatter for exported notes: provenance the vault can query. */
2
2
 
3
- import type { ReviewSession } from "@cueloop/schema";
3
+ import type { Thread } from "@cueloop/schema";
4
4
 
5
- export function frontmatter(session: ReviewSession, created: Date): string {
5
+ export function frontmatter(session: Thread, created: Date): string {
6
6
  return [
7
7
  "---",
8
8
  `created: ${created.toISOString()}`,