diff --git a/apps/desktop/src/store/tinybase/persister/organization/collect.ts b/apps/desktop/src/store/tinybase/persister/organization/collect.ts new file mode 100644 index 0000000000..a93389e835 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/organization/collect.ts @@ -0,0 +1,55 @@ +import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas"; + +import type { FrontmatterInput, JsonValue } from "@hypr/plugin-export"; +import type { OrganizationStorage } from "@hypr/store"; + +import type { CollectorResult, TablesContent } from "../utils"; +import { getOrganizationDir, getOrganizationFilePath } from "./utils"; + +export interface OrganizationCollectorResult extends CollectorResult { + validOrgIds: Set; +} + +type OrganizationsTable = Record; + +export function collectOrganizationWriteOps( + _store: MergeableStore, + tables: TablesContent, + dataDir: string, +): OrganizationCollectorResult { + const dirs = new Set(); + const operations: CollectorResult["operations"] = []; + const validOrgIds = new Set(); + + const organizationsDir = getOrganizationDir(dataDir); + dirs.add(organizationsDir); + + const organizations = + (tables as { organizations?: OrganizationsTable }).organizations ?? {}; + + const frontmatterItems: [FrontmatterInput, string][] = []; + + for (const [orgId, org] of Object.entries(organizations)) { + validOrgIds.add(orgId); + + const frontmatter: Record = { + created_at: org.created_at ?? "", + name: org.name ?? "", + user_id: org.user_id ?? "", + }; + + const body = ""; + const filePath = getOrganizationFilePath(dataDir, orgId); + + frontmatterItems.push([{ frontmatter, content: body }, filePath]); + } + + if (frontmatterItems.length > 0) { + operations.push({ + type: "frontmatter-batch", + items: frontmatterItems, + }); + } + + return { dirs, operations, validOrgIds }; +} diff --git a/apps/desktop/src/store/tinybase/persister/organization/load.ts b/apps/desktop/src/store/tinybase/persister/organization/load.ts new file mode 100644 index 0000000000..f304fe6e64 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/organization/load.ts @@ -0,0 +1,99 @@ +import { readDir, readTextFile, remove } from "@tauri-apps/plugin-fs"; + +import type { OrganizationStorage } from "@hypr/store"; + +import { isFileNotFoundError, isUUID } from "../utils"; +import { + getOrganizationDir, + getOrganizationFilePath, + parseMarkdownWithFrontmatter, +} from "./utils"; + +export async function loadAllOrganizations( + dataDir: string, +): Promise> { + const result: Record = {}; + const organizationsDir = getOrganizationDir(dataDir); + + let entries: { name: string; isDirectory: boolean }[]; + try { + entries = await readDir(organizationsDir); + } catch (error) { + if (isFileNotFoundError(error)) { + return result; + } + throw error; + } + + for (const entry of entries) { + if (entry.isDirectory) continue; + if (!entry.name.endsWith(".md")) continue; + + const orgId = entry.name.replace(/\.md$/, ""); + if (!isUUID(orgId)) { + console.warn( + `[OrganizationPersister] Skipping non-UUID file: ${entry.name}`, + ); + continue; + } + + try { + const filePath = getOrganizationFilePath(dataDir, orgId); + const content = await readTextFile(filePath); + const { frontmatter } = await parseMarkdownWithFrontmatter(content); + + result[orgId] = { + user_id: String(frontmatter.user_id ?? ""), + created_at: String(frontmatter.created_at ?? ""), + name: String(frontmatter.name ?? ""), + }; + } catch (error) { + console.error( + `[OrganizationPersister] Failed to load organization ${orgId}:`, + error, + ); + continue; + } + } + + return result; +} + +export async function cleanupOrphanOrganizationFiles( + dataDir: string, + validOrgIds: Set, +): Promise { + const organizationsDir = getOrganizationDir(dataDir); + + let entries: { name: string; isDirectory: boolean }[]; + try { + entries = await readDir(organizationsDir); + } catch (error) { + if (isFileNotFoundError(error)) { + return; + } + throw error; + } + + for (const entry of entries) { + if (entry.isDirectory) continue; + if (!entry.name.endsWith(".md")) continue; + + const orgId = entry.name.replace(/\.md$/, ""); + if (!isUUID(orgId)) continue; + + if (!validOrgIds.has(orgId)) { + try { + const filePath = getOrganizationFilePath(dataDir, orgId); + await remove(filePath); + } catch (error) { + if (!isFileNotFoundError(error)) { + console.error( + `[OrganizationPersister] Failed to remove orphan file ${entry.name}:`, + error, + ); + } + } + } + } +} diff --git a/apps/desktop/src/store/tinybase/persister/organization/migrate.ts b/apps/desktop/src/store/tinybase/persister/organization/migrate.ts new file mode 100644 index 0000000000..8e5f33ab2a --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/organization/migrate.ts @@ -0,0 +1,77 @@ +import { sep } from "@tauri-apps/api/path"; +import { exists, mkdir, readTextFile, remove } from "@tauri-apps/plugin-fs"; + +import { + commands as exportCommands, + type FrontmatterInput, + type JsonValue, +} from "@hypr/plugin-export"; +import type { OrganizationStorage } from "@hypr/store"; + +import { isFileNotFoundError } from "../utils"; +import { getOrganizationDir, getOrganizationFilePath } from "./utils"; + +export async function migrateOrganizationsJsonIfNeeded( + dataDir: string, +): Promise { + const organizationsJsonPath = [dataDir, "organizations.json"].join(sep()); + const organizationsDir = getOrganizationDir(dataDir); + + const jsonExists = await exists(organizationsJsonPath); + if (!jsonExists) { + return; + } + + const dirExists = await exists(organizationsDir); + if (dirExists) { + return; + } + + console.log( + "[OrganizationPersister] Migrating from organizations.json to organizations/*.md", + ); + + try { + const content = await readTextFile(organizationsJsonPath); + const organizations = JSON.parse(content) as Record< + string, + OrganizationStorage + >; + + await mkdir(organizationsDir, { recursive: true }); + + const batchItems: [FrontmatterInput, string][] = []; + + for (const [orgId, org] of Object.entries(organizations)) { + const frontmatter: Record = { + created_at: org.created_at ?? "", + name: org.name ?? "", + user_id: org.user_id ?? "", + }; + + const body = ""; + const filePath = getOrganizationFilePath(dataDir, orgId); + + batchItems.push([{ frontmatter, content: body }, filePath]); + } + + if (batchItems.length > 0) { + const result = await exportCommands.exportFrontmatterBatch(batchItems); + if (result.status === "error") { + throw new Error( + `Failed to export migrated organizations: ${result.error}`, + ); + } + } + + await remove(organizationsJsonPath); + + console.log( + `[OrganizationPersister] Migration complete: ${Object.keys(organizations).length} organizations migrated`, + ); + } catch (error) { + if (!isFileNotFoundError(error)) { + console.error("[OrganizationPersister] Migration failed:", error); + } + } +} diff --git a/apps/desktop/src/store/tinybase/persister/organization/persister.test.ts b/apps/desktop/src/store/tinybase/persister/organization/persister.test.ts index c1f69b5a5b..5b6fd11f50 100644 --- a/apps/desktop/src/store/tinybase/persister/organization/persister.test.ts +++ b/apps/desktop/src/store/tinybase/persister/organization/persister.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { SCHEMA, type Schemas } from "@hypr/store"; import { createOrganizationPersister } from "./persister"; +import { parseMarkdownWithFrontmatter } from "./utils"; vi.mock("@hypr/plugin-path2", () => ({ commands: { @@ -11,18 +12,41 @@ vi.mock("@hypr/plugin-path2", () => ({ }, })); +vi.mock("@hypr/plugin-export", () => ({ + commands: { + parseFrontmatter: vi.fn(), + exportFrontmatterBatch: vi + .fn() + .mockResolvedValue({ status: "ok", data: null }), + }, +})); + vi.mock("@tauri-apps/plugin-fs", () => ({ mkdir: vi.fn().mockResolvedValue(undefined), + readDir: vi.fn(), readTextFile: vi.fn(), writeTextFile: vi.fn().mockResolvedValue(undefined), + exists: vi.fn(), + remove: vi.fn().mockResolvedValue(undefined), })); +function serializeFrontmatterSync( + frontmatter: Record, + body: string, +): string { + const lines = Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`); + return `---\n${lines.join("\n")}\n---\n\n${body}`; +} + function createTestStore() { return createMergeableStore() .setTablesSchema(SCHEMA.table) .setValuesSchema(SCHEMA.value); } +const ORG_UUID_1 = "550e8400-e29b-41d4-a716-446655440000"; +const ORG_UUID_2 = "550e8400-e29b-41d4-a716-446655440001"; + describe("createOrganizationPersister", () => { let store: ReturnType; @@ -41,31 +65,62 @@ describe("createOrganizationPersister", () => { }); describe("load", () => { - test("loads organizations from json file", async () => { - const { readTextFile } = await import("@tauri-apps/plugin-fs"); + test("loads organizations from markdown files", async () => { + const { readDir, readTextFile, exists } = + await import("@tauri-apps/plugin-fs"); + const { commands: exportCommands } = await import("@hypr/plugin-export"); + + vi.mocked(exists).mockResolvedValue(false); + vi.mocked(readDir).mockResolvedValue([ + { + name: `${ORG_UUID_1}.md`, + isDirectory: false, + isFile: true, + isSymlink: false, + }, + ]); - const mockData = { - "org-1": { - user_id: "user-1", + const mockMdContent = serializeFrontmatterSync( + { created_at: "2024-01-01T00:00:00Z", name: "Acme Corp", + user_id: "user-1", }, - }; - vi.mocked(readTextFile).mockResolvedValue(JSON.stringify(mockData)); + "", + ); + vi.mocked(readTextFile).mockResolvedValue(mockMdContent); + vi.mocked(exportCommands.parseFrontmatter).mockResolvedValue({ + status: "ok", + data: { + frontmatter: { + created_at: "2024-01-01T00:00:00Z", + name: "Acme Corp", + user_id: "user-1", + }, + content: "", + }, + }); const persister = createOrganizationPersister(store); await persister.load(); - expect(readTextFile).toHaveBeenCalledWith( - "/mock/data/dir/hyprnote/organizations.json", + expect(readDir).toHaveBeenCalledWith( + "/mock/data/dir/hyprnote/organizations", ); - expect(store.getTable("organizations")).toEqual(mockData); + + const organizations = store.getTable("organizations"); + expect(organizations[ORG_UUID_1]).toEqual({ + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + name: "Acme Corp", + }); }); - test("returns empty organizations when file does not exist", async () => { - const { readTextFile } = await import("@tauri-apps/plugin-fs"); + test("returns empty organizations when directory does not exist", async () => { + const { readDir, exists } = await import("@tauri-apps/plugin-fs"); - vi.mocked(readTextFile).mockRejectedValue( + vi.mocked(exists).mockResolvedValue(false); + vi.mocked(readDir).mockRejectedValue( new Error("No such file or directory"), ); @@ -74,13 +129,64 @@ describe("createOrganizationPersister", () => { expect(store.getTable("organizations")).toEqual({}); }); + + test("skips non-UUID files", async () => { + const { readDir, readTextFile, exists } = + await import("@tauri-apps/plugin-fs"); + const { commands: exportCommands } = await import("@hypr/plugin-export"); + + vi.mocked(exists).mockResolvedValue(false); + vi.mocked(readDir).mockResolvedValue([ + { + name: "not-a-uuid.md", + isDirectory: false, + isFile: true, + isSymlink: false, + }, + { + name: `${ORG_UUID_1}.md`, + isDirectory: false, + isFile: true, + isSymlink: false, + }, + ]); + + const mockMdContent = serializeFrontmatterSync( + { + created_at: "2024-01-01T00:00:00Z", + name: "Acme Corp", + user_id: "user-1", + }, + "", + ); + vi.mocked(readTextFile).mockResolvedValue(mockMdContent); + vi.mocked(exportCommands.parseFrontmatter).mockResolvedValue({ + status: "ok", + data: { + frontmatter: { + created_at: "2024-01-01T00:00:00Z", + name: "Acme Corp", + user_id: "user-1", + }, + content: "", + }, + }); + + const persister = createOrganizationPersister(store); + await persister.load(); + + const organizations = store.getTable("organizations"); + expect(Object.keys(organizations)).toHaveLength(1); + expect(organizations[ORG_UUID_1]).toBeDefined(); + }); }); describe("save", () => { - test("saves organizations to json file", async () => { - const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + test("saves organizations to markdown files via export plugin", async () => { + const { mkdir } = await import("@tauri-apps/plugin-fs"); + const { commands: exportCommands } = await import("@hypr/plugin-export"); - store.setRow("organizations", "org-1", { + store.setRow("organizations", ORG_UUID_1, { user_id: "user-1", created_at: "2024-01-01T00:00:00Z", name: "Acme Corp", @@ -89,45 +195,47 @@ describe("createOrganizationPersister", () => { const persister = createOrganizationPersister(store); await persister.save(); - expect(writeTextFile).toHaveBeenCalledWith( - "/mock/data/dir/hyprnote/organizations.json", - expect.any(String), + expect(mkdir).toHaveBeenCalledWith( + "/mock/data/dir/hyprnote/organizations", + { + recursive: true, + }, ); - const writtenContent = vi.mocked(writeTextFile).mock.calls[0][1]; - const parsed = JSON.parse(writtenContent); - - expect(parsed).toEqual({ - "org-1": { - user_id: "user-1", - created_at: "2024-01-01T00:00:00Z", - name: "Acme Corp", - }, - }); + expect(exportCommands.exportFrontmatterBatch).toHaveBeenCalledWith([ + [ + { + frontmatter: { + created_at: "2024-01-01T00:00:00Z", + name: "Acme Corp", + user_id: "user-1", + }, + content: "", + }, + `/mock/data/dir/hyprnote/organizations/${ORG_UUID_1}.md`, + ], + ]); }); - test("writes empty object when no organizations exist", async () => { - const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + test("does not write when no organizations exist", async () => { + const { commands: exportCommands } = await import("@hypr/plugin-export"); const persister = createOrganizationPersister(store); await persister.save(); - expect(writeTextFile).toHaveBeenCalledWith( - "/mock/data/dir/hyprnote/organizations.json", - "{}", - ); + expect(exportCommands.exportFrontmatterBatch).not.toHaveBeenCalled(); }); - test("saves multiple organizations", async () => { - const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + test("saves multiple organizations in single batch call", async () => { + const { commands: exportCommands } = await import("@hypr/plugin-export"); - store.setRow("organizations", "org-1", { + store.setRow("organizations", ORG_UUID_1, { user_id: "user-1", created_at: "2024-01-01T00:00:00Z", name: "Acme Corp", }); - store.setRow("organizations", "org-2", { + store.setRow("organizations", ORG_UUID_2, { user_id: "user-1", created_at: "2024-01-02T00:00:00Z", name: "Beta Inc", @@ -136,12 +244,119 @@ describe("createOrganizationPersister", () => { const persister = createOrganizationPersister(store); await persister.save(); - const writtenContent = vi.mocked(writeTextFile).mock.calls[0][1]; - const parsed = JSON.parse(writtenContent); + expect(exportCommands.exportFrontmatterBatch).toHaveBeenCalledTimes(1); + + const batchItems = vi.mocked(exportCommands.exportFrontmatterBatch).mock + .calls[0][0]; + expect(batchItems).toHaveLength(2); + + const paths = batchItems.map((item: [unknown, string]) => item[1]); + expect(paths).toContain( + `/mock/data/dir/hyprnote/organizations/${ORG_UUID_1}.md`, + ); + expect(paths).toContain( + `/mock/data/dir/hyprnote/organizations/${ORG_UUID_2}.md`, + ); + }); + }); + + describe("migration", () => { + test("migrates from organizations.json when it exists and organizations dir does not", async () => { + const { exists, readTextFile, mkdir, remove } = + await import("@tauri-apps/plugin-fs"); + const { commands: exportCommands } = await import("@hypr/plugin-export"); + + vi.mocked(exists).mockImplementation(async (path: string | URL) => { + const p = typeof path === "string" ? path : path.toString(); + if (p.endsWith("organizations.json")) return true; + if (p.endsWith("organizations")) return false; + return false; + }); + + const mockJsonData = { + [ORG_UUID_1]: { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + name: "Acme Corp", + }, + }; + vi.mocked(readTextFile).mockResolvedValue(JSON.stringify(mockJsonData)); + + const persister = createOrganizationPersister(store); + await persister.load(); + + expect(mkdir).toHaveBeenCalledWith( + "/mock/data/dir/hyprnote/organizations", + { + recursive: true, + }, + ); + expect(exportCommands.exportFrontmatterBatch).toHaveBeenCalledWith([ + [ + { + frontmatter: { + created_at: "2024-01-01T00:00:00Z", + name: "Acme Corp", + user_id: "user-1", + }, + content: "", + }, + `/mock/data/dir/hyprnote/organizations/${ORG_UUID_1}.md`, + ], + ]); + expect(remove).toHaveBeenCalledWith( + "/mock/data/dir/hyprnote/organizations.json", + ); + }); + }); +}); + +describe("utils", () => { + describe("parseMarkdownWithFrontmatter", () => { + test("parses markdown with frontmatter via plugin", async () => { + const { commands: exportCommands } = await import("@hypr/plugin-export"); + + vi.mocked(exportCommands.parseFrontmatter).mockResolvedValue({ + status: "ok", + data: { + frontmatter: { + name: "Acme Corp", + user_id: "user-1", + }, + content: "", + }, + }); + + const content = `--- +name: Acme Corp +user_id: user-1 +--- + +`; + + const { frontmatter, body } = await parseMarkdownWithFrontmatter(content); + + expect(frontmatter).toEqual({ + name: "Acme Corp", + user_id: "user-1", + }); + expect(body).toBe(""); + }); + + test("returns empty frontmatter when plugin returns error", async () => { + const { commands: exportCommands } = await import("@hypr/plugin-export"); + + vi.mocked(exportCommands.parseFrontmatter).mockResolvedValue({ + status: "error", + error: "Parse error", + }); + + const content = "Just some text without frontmatter"; + + const { frontmatter, body } = await parseMarkdownWithFrontmatter(content); - expect(Object.keys(parsed)).toHaveLength(2); - expect(parsed["org-1"].name).toBe("Acme Corp"); - expect(parsed["org-2"].name).toBe("Beta Inc"); + expect(frontmatter).toEqual({}); + expect(body).toBe("Just some text without frontmatter"); }); }); }); diff --git a/apps/desktop/src/store/tinybase/persister/organization/persister.ts b/apps/desktop/src/store/tinybase/persister/organization/persister.ts index 6de6c9f2e0..acabb988a8 100644 --- a/apps/desktop/src/store/tinybase/persister/organization/persister.ts +++ b/apps/desktop/src/store/tinybase/persister/organization/persister.ts @@ -1,13 +1,35 @@ -import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas"; +import type { + Content, + MergeableStore, + OptionalSchemas, +} from "tinybase/with-schemas"; -import { createSingleTablePersister } from "../utils"; +import { createSessionDirPersister, getDataDir } from "../utils"; +import { + collectOrganizationWriteOps, + type OrganizationCollectorResult, +} from "./collect"; +import { cleanupOrphanOrganizationFiles, loadAllOrganizations } from "./load"; +import { migrateOrganizationsJsonIfNeeded } from "./migrate"; export function createOrganizationPersister( store: MergeableStore, ) { - return createSingleTablePersister(store, { - tableName: "organizations", - filename: "organizations.json", + return createSessionDirPersister(store, { label: "OrganizationPersister", + collect: collectOrganizationWriteOps, + load: async (): Promise | undefined> => { + const dataDir = await getDataDir(); + await migrateOrganizationsJsonIfNeeded(dataDir); + const organizations = await loadAllOrganizations(dataDir); + if (Object.keys(organizations).length === 0) { + return undefined; + } + return [{ organizations }, {}] as unknown as Content; + }, + postSave: async (dataDir, result) => { + const { validOrgIds } = result as OrganizationCollectorResult; + await cleanupOrphanOrganizationFiles(dataDir, validOrgIds); + }, }); } diff --git a/apps/desktop/src/store/tinybase/persister/organization/utils.ts b/apps/desktop/src/store/tinybase/persister/organization/utils.ts new file mode 100644 index 0000000000..1e84ab1d4a --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/organization/utils.ts @@ -0,0 +1,36 @@ +import { sep } from "@tauri-apps/api/path"; + +import { commands } from "@hypr/plugin-export"; + +export interface ParsedMarkdown { + frontmatter: Record; + body: string; +} + +export async function parseMarkdownWithFrontmatter( + content: string, +): Promise { + const result = await commands.parseFrontmatter(content); + if (result.status === "error") { + console.error( + "[OrganizationPersister] Failed to parse frontmatter:", + result.error, + ); + return { frontmatter: {}, body: content }; + } + return { + frontmatter: result.data.frontmatter as Record, + body: result.data.content.trim(), + }; +} + +export function getOrganizationDir(dataDir: string): string { + return [dataDir, "organizations"].join(sep()); +} + +export function getOrganizationFilePath( + dataDir: string, + orgId: string, +): string { + return [dataDir, "organizations", `${orgId}.md`].join(sep()); +}