Skip to content

Commit ad094c9

Browse files
feat(desktop): store humans as markdown files with YAML frontmatter (#2791)
1 parent 12c195c commit ad094c9

File tree

13 files changed

+744
-61
lines changed

13 files changed

+744
-61
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,19 +29,20 @@
2929
"@hypr/api-client": "workspace:*",
3030
"@hypr/codemirror": "workspace:^",
3131
"@hypr/plugin-analytics": "workspace:*",
32-
"@hypr/plugin-audio-priority": "workspace:*",
3332
"@hypr/plugin-apple-calendar": "workspace:*",
33+
"@hypr/plugin-audio-priority": "workspace:*",
3434
"@hypr/plugin-auth": "workspace:*",
3535
"@hypr/plugin-cli2": "workspace:*",
3636
"@hypr/plugin-db2": "workspace:*",
3737
"@hypr/plugin-deeplink2": "workspace:*",
3838
"@hypr/plugin-detect": "workspace:*",
3939
"@hypr/plugin-export": "workspace:*",
40-
"@hypr/plugin-folder": "workspace:*",
4140
"@hypr/plugin-extensions": "workspace:*",
41+
"@hypr/plugin-folder": "workspace:*",
4242
"@hypr/plugin-hooks": "workspace:*",
4343
"@hypr/plugin-icon": "workspace:*",
4444
"@hypr/plugin-importer": "workspace:*",
45+
"@hypr/plugin-js": "workspace:*",
4546
"@hypr/plugin-listener": "workspace:*",
4647
"@hypr/plugin-listener2": "workspace:*",
4748
"@hypr/plugin-local-stt": "workspace:*",
@@ -51,15 +52,14 @@
5152
"@hypr/plugin-notify": "workspace:*",
5253
"@hypr/plugin-overlay": "workspace:*",
5354
"@hypr/plugin-path2": "workspace:*",
54-
"@hypr/plugin-tantivy": "workspace:*",
5555
"@hypr/plugin-pdf": "workspace:*",
5656
"@hypr/plugin-permissions": "workspace:*",
5757
"@hypr/plugin-settings": "workspace:*",
5858
"@hypr/plugin-sfx": "workspace:*",
59+
"@hypr/plugin-tantivy": "workspace:*",
5960
"@hypr/plugin-template": "workspace:*",
6061
"@hypr/plugin-updater2": "workspace:*",
6162
"@hypr/plugin-windows": "workspace:*",
62-
"@hypr/plugin-js": "workspace:*",
6363
"@hypr/store": "workspace:*",
6464
"@hypr/tiptap": "workspace:^",
6565
"@hypr/ui": "workspace:^",
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas";
2+
3+
import type { FrontmatterInput, JsonValue } from "@hypr/plugin-export";
4+
import type { HumanStorage } from "@hypr/store";
5+
6+
import type { CollectorResult, TablesContent } from "../utils";
7+
import { getHumanDir, getHumanFilePath } from "./utils";
8+
9+
export interface HumanCollectorResult extends CollectorResult {
10+
validHumanIds: Set<string>;
11+
}
12+
13+
type HumansTable = Record<string, HumanStorage>;
14+
15+
export function collectHumanWriteOps<Schemas extends OptionalSchemas>(
16+
_store: MergeableStore<Schemas>,
17+
tables: TablesContent,
18+
dataDir: string,
19+
): HumanCollectorResult {
20+
const dirs = new Set<string>();
21+
const operations: CollectorResult["operations"] = [];
22+
const validHumanIds = new Set<string>();
23+
24+
const humansDir = getHumanDir(dataDir);
25+
dirs.add(humansDir);
26+
27+
const humans = (tables as { humans?: HumansTable }).humans ?? {};
28+
29+
const frontmatterItems: [FrontmatterInput, string][] = [];
30+
31+
for (const [humanId, human] of Object.entries(humans)) {
32+
validHumanIds.add(humanId);
33+
34+
const { memo, ...frontmatterFields } = human;
35+
36+
const frontmatter: Record<string, JsonValue> = {
37+
user_id: frontmatterFields.user_id ?? "",
38+
created_at: frontmatterFields.created_at ?? "",
39+
name: frontmatterFields.name ?? "",
40+
email: frontmatterFields.email ?? "",
41+
org_id: frontmatterFields.org_id ?? "",
42+
job_title: frontmatterFields.job_title ?? "",
43+
linkedin_username: frontmatterFields.linkedin_username ?? "",
44+
};
45+
46+
const body = memo ?? "";
47+
const filePath = getHumanFilePath(dataDir, humanId);
48+
49+
frontmatterItems.push([{ frontmatter, content: body }, filePath]);
50+
}
51+
52+
if (frontmatterItems.length > 0) {
53+
operations.push({
54+
type: "frontmatter-batch",
55+
items: frontmatterItems,
56+
});
57+
}
58+
59+
return { dirs, operations, validHumanIds };
60+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { readDir, readTextFile, remove } from "@tauri-apps/plugin-fs";
2+
3+
import type { HumanStorage } from "@hypr/store";
4+
5+
import { isFileNotFoundError, isUUID } from "../utils";
6+
import {
7+
getHumanDir,
8+
getHumanFilePath,
9+
parseMarkdownWithFrontmatter,
10+
} from "./utils";
11+
12+
export async function loadAllHumans(
13+
dataDir: string,
14+
): Promise<Record<string, HumanStorage>> {
15+
const result: Record<string, HumanStorage> = {};
16+
const humansDir = getHumanDir(dataDir);
17+
18+
let entries: { name: string; isDirectory: boolean }[];
19+
try {
20+
entries = await readDir(humansDir);
21+
} catch (error) {
22+
if (isFileNotFoundError(error)) {
23+
return result;
24+
}
25+
throw error;
26+
}
27+
28+
for (const entry of entries) {
29+
if (entry.isDirectory) continue;
30+
if (!entry.name.endsWith(".md")) continue;
31+
32+
const humanId = entry.name.replace(/\.md$/, "");
33+
if (!isUUID(humanId)) {
34+
console.warn(`[HumanPersister] Skipping non-UUID file: ${entry.name}`);
35+
continue;
36+
}
37+
38+
try {
39+
const filePath = getHumanFilePath(dataDir, humanId);
40+
const content = await readTextFile(filePath);
41+
const { frontmatter, body } = await parseMarkdownWithFrontmatter(content);
42+
43+
result[humanId] = {
44+
user_id: String(frontmatter.user_id ?? ""),
45+
created_at: String(frontmatter.created_at ?? ""),
46+
name: String(frontmatter.name ?? ""),
47+
email: String(frontmatter.email ?? ""),
48+
org_id: String(frontmatter.org_id ?? ""),
49+
job_title: String(frontmatter.job_title ?? ""),
50+
linkedin_username: String(frontmatter.linkedin_username ?? ""),
51+
memo: body,
52+
};
53+
} catch (error) {
54+
console.error(`[HumanPersister] Failed to load human ${humanId}:`, error);
55+
continue;
56+
}
57+
}
58+
59+
return result;
60+
}
61+
62+
export async function cleanupOrphanHumanFiles(
63+
dataDir: string,
64+
validHumanIds: Set<string>,
65+
): Promise<void> {
66+
const humansDir = getHumanDir(dataDir);
67+
68+
let entries: { name: string; isDirectory: boolean }[];
69+
try {
70+
entries = await readDir(humansDir);
71+
} catch (error) {
72+
if (isFileNotFoundError(error)) {
73+
return;
74+
}
75+
throw error;
76+
}
77+
78+
for (const entry of entries) {
79+
if (entry.isDirectory) continue;
80+
if (!entry.name.endsWith(".md")) continue;
81+
82+
const humanId = entry.name.replace(/\.md$/, "");
83+
if (!isUUID(humanId)) continue;
84+
85+
if (!validHumanIds.has(humanId)) {
86+
try {
87+
const filePath = getHumanFilePath(dataDir, humanId);
88+
await remove(filePath);
89+
} catch (error) {
90+
if (!isFileNotFoundError(error)) {
91+
console.error(
92+
`[HumanPersister] Failed to remove orphan file ${entry.name}:`,
93+
error,
94+
);
95+
}
96+
}
97+
}
98+
}
99+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { sep } from "@tauri-apps/api/path";
2+
import { exists, mkdir, readTextFile, remove } from "@tauri-apps/plugin-fs";
3+
4+
import {
5+
commands as exportCommands,
6+
type FrontmatterInput,
7+
type JsonValue,
8+
} from "@hypr/plugin-export";
9+
import type { HumanStorage } from "@hypr/store";
10+
11+
import { isFileNotFoundError } from "../utils";
12+
import { getHumanDir, getHumanFilePath } from "./utils";
13+
14+
export async function migrateHumansJsonIfNeeded(
15+
dataDir: string,
16+
): Promise<void> {
17+
const humansJsonPath = [dataDir, "humans.json"].join(sep());
18+
const humansDir = getHumanDir(dataDir);
19+
20+
const jsonExists = await exists(humansJsonPath);
21+
if (!jsonExists) {
22+
return;
23+
}
24+
25+
const dirExists = await exists(humansDir);
26+
if (dirExists) {
27+
return;
28+
}
29+
30+
console.log("[HumanPersister] Migrating from humans.json to humans/*.md");
31+
32+
try {
33+
const content = await readTextFile(humansJsonPath);
34+
const humans = JSON.parse(content) as Record<string, HumanStorage>;
35+
36+
await mkdir(humansDir, { recursive: true });
37+
38+
const batchItems: [FrontmatterInput, string][] = [];
39+
40+
for (const [humanId, human] of Object.entries(humans)) {
41+
const { memo, ...frontmatterFields } = human;
42+
43+
const frontmatter: Record<string, JsonValue> = {
44+
user_id: frontmatterFields.user_id ?? "",
45+
created_at: frontmatterFields.created_at ?? "",
46+
name: frontmatterFields.name ?? "",
47+
email: frontmatterFields.email ?? "",
48+
org_id: frontmatterFields.org_id ?? "",
49+
job_title: frontmatterFields.job_title ?? "",
50+
linkedin_username: frontmatterFields.linkedin_username ?? "",
51+
};
52+
53+
const body = memo ?? "";
54+
const filePath = getHumanFilePath(dataDir, humanId);
55+
56+
batchItems.push([{ frontmatter, content: body }, filePath]);
57+
}
58+
59+
if (batchItems.length > 0) {
60+
const result = await exportCommands.exportFrontmatterBatch(batchItems);
61+
if (result.status === "error") {
62+
throw new Error(`Failed to export migrated humans: ${result.error}`);
63+
}
64+
}
65+
66+
await remove(humansJsonPath);
67+
68+
console.log(
69+
`[HumanPersister] Migration complete: ${Object.keys(humans).length} humans migrated`,
70+
);
71+
} catch (error) {
72+
if (!isFileNotFoundError(error)) {
73+
console.error("[HumanPersister] Migration failed:", error);
74+
}
75+
}
76+
}

0 commit comments

Comments
 (0)