diff --git a/apps/desktop/src/store/tinybase/persister/calendar.test.ts b/apps/desktop/src/store/tinybase/persister/calendar.test.ts new file mode 100644 index 000000000..8c19e7aa4 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/calendar.test.ts @@ -0,0 +1,172 @@ +import { createMergeableStore } from "tinybase/with-schemas"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { SCHEMA, type Schemas } from "@hypr/store"; + +import { createCalendarPersister } from "./calendar"; + +vi.mock("@hypr/plugin-path2", () => ({ + commands: { + base: vi.fn().mockResolvedValue("/mock/data/dir/hyprnote"), + }, +})); + +vi.mock("@tauri-apps/plugin-fs", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + readTextFile: vi.fn(), + writeTextFile: vi.fn().mockResolvedValue(undefined), +})); + +function createTestStore() { + return createMergeableStore() + .setTablesSchema(SCHEMA.table) + .setValuesSchema(SCHEMA.value); +} + +describe("createCalendarPersister", () => { + let store: ReturnType; + + beforeEach(() => { + store = createTestStore(); + vi.clearAllMocks(); + }); + + test("returns a persister object with expected methods", () => { + const persister = createCalendarPersister(store); + + expect(persister).toBeDefined(); + expect(persister.save).toBeTypeOf("function"); + expect(persister.load).toBeTypeOf("function"); + expect(persister.destroy).toBeTypeOf("function"); + }); + + describe("load", () => { + test("loads calendars from json file", async () => { + const { readTextFile } = await import("@tauri-apps/plugin-fs"); + + const mockData = { + "cal-1": { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + tracking_id_calendar: "tracking-1", + name: "Work Calendar", + enabled: true, + provider: "apple", + source: "iCloud", + color: "#FF0000", + }, + }; + vi.mocked(readTextFile).mockResolvedValue(JSON.stringify(mockData)); + + const persister = createCalendarPersister(store); + await persister.load(); + + expect(readTextFile).toHaveBeenCalledWith( + "/mock/data/dir/hyprnote/calendars.json", + ); + expect(store.getTable("calendars")).toEqual(mockData); + }); + + test("returns empty calendars when file does not exist", async () => { + const { readTextFile } = await import("@tauri-apps/plugin-fs"); + + vi.mocked(readTextFile).mockRejectedValue( + new Error("No such file or directory"), + ); + + const persister = createCalendarPersister(store); + await persister.load(); + + expect(store.getTable("calendars")).toEqual({}); + }); + }); + + describe("save", () => { + test("saves calendars to json file", async () => { + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + + store.setRow("calendars", "cal-1", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + tracking_id_calendar: "tracking-1", + name: "Work Calendar", + enabled: true, + provider: "apple", + source: "iCloud", + color: "#FF0000", + }); + + const persister = createCalendarPersister(store); + await persister.save(); + + expect(writeTextFile).toHaveBeenCalledWith( + "/mock/data/dir/hyprnote/calendars.json", + expect.any(String), + ); + + const writtenContent = vi.mocked(writeTextFile).mock.calls[0][1]; + const parsed = JSON.parse(writtenContent); + + expect(parsed).toEqual({ + "cal-1": { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + tracking_id_calendar: "tracking-1", + name: "Work Calendar", + enabled: true, + provider: "apple", + source: "iCloud", + color: "#FF0000", + }, + }); + }); + + test("writes empty object when no calendars exist", async () => { + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + + const persister = createCalendarPersister(store); + await persister.save(); + + expect(writeTextFile).toHaveBeenCalledWith( + "/mock/data/dir/hyprnote/calendars.json", + "{}", + ); + }); + + test("saves multiple calendars", async () => { + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + + store.setRow("calendars", "cal-1", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + tracking_id_calendar: "tracking-1", + name: "Work Calendar", + enabled: true, + provider: "apple", + source: "iCloud", + color: "#FF0000", + }); + + store.setRow("calendars", "cal-2", { + user_id: "user-1", + created_at: "2024-01-02T00:00:00Z", + tracking_id_calendar: "tracking-2", + name: "Personal Calendar", + enabled: false, + provider: "google", + source: "Gmail", + color: "#00FF00", + }); + + const persister = createCalendarPersister(store); + await persister.save(); + + const writtenContent = vi.mocked(writeTextFile).mock.calls[0][1]; + const parsed = JSON.parse(writtenContent); + + expect(Object.keys(parsed)).toHaveLength(2); + expect(parsed["cal-1"].name).toBe("Work Calendar"); + expect(parsed["cal-2"].name).toBe("Personal Calendar"); + }); + }); +}); diff --git a/apps/desktop/src/store/tinybase/persister/calendar.ts b/apps/desktop/src/store/tinybase/persister/calendar.ts new file mode 100644 index 000000000..9057155d1 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/calendar.ts @@ -0,0 +1,15 @@ +import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas"; + +import { createSimpleJsonPersister, type PersisterMode } from "./utils"; + +export function createCalendarPersister( + store: MergeableStore, + config: { mode: PersisterMode } = { mode: "save-only" }, +) { + return createSimpleJsonPersister(store, { + tableName: "calendars", + filename: "calendars.json", + label: "CalendarPersister", + mode: config.mode, + }); +} diff --git a/apps/desktop/src/store/tinybase/persister/chat.ts b/apps/desktop/src/store/tinybase/persister/chat.ts index 96297e59a..9b2fa0cb2 100644 --- a/apps/desktop/src/store/tinybase/persister/chat.ts +++ b/apps/desktop/src/store/tinybase/persister/chat.ts @@ -4,11 +4,13 @@ import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas"; import type { ChatMessageStorage } from "@hypr/store"; +import { StoreOrMergeableStore } from "../store/shared"; import { ensureDirsExist, getChatDir, getDataDir, iterateTableRows, + type PersisterMode, type TablesContent, } from "./utils"; @@ -65,61 +67,70 @@ function collectMessagesByChatGroup( export function createChatPersister( store: MergeableStore, + config: { mode: PersisterMode } = { mode: "save-only" }, ) { + const loadFn = + config.mode === "save-only" ? async () => undefined : async () => undefined; + + const saveFn = + config.mode === "load-only" + ? async () => {} + : async (getContent: () => unknown) => { + const [tables] = getContent() as [TablesContent | undefined, unknown]; + const dataDir = await getDataDir(); + + const messagesByChatGroup = collectMessagesByChatGroup(tables); + if (messagesByChatGroup.size === 0) { + return; + } + + const dirs = new Set(); + const writeOperations: Array<{ path: string; content: string }> = []; + + for (const [ + chatGroupId, + { chatGroup, messages }, + ] of messagesByChatGroup) { + const chatDir = getChatDir(dataDir, chatGroupId); + dirs.add(chatDir); + + const json: ChatJson = { + chat_group: chatGroup, + messages: messages.sort( + (a, b) => + new Date(a.created_at || 0).getTime() - + new Date(b.created_at || 0).getTime(), + ), + }; + writeOperations.push({ + path: `${chatDir}/_messages.json`, + content: JSON.stringify(json, null, 2), + }); + } + + try { + await ensureDirsExist(dirs); + } catch (e) { + console.error("Failed to ensure dirs exist:", e); + return; + } + + for (const op of writeOperations) { + try { + await writeTextFile(op.path, op.content); + } catch (e) { + console.error(`Failed to write ${op.path}:`, e); + } + } + }; + return createCustomPersister( store, - async () => { - return undefined; - }, - async (getContent) => { - const [tables] = getContent() as [TablesContent | undefined, unknown]; - const dataDir = await getDataDir(); - - const messagesByChatGroup = collectMessagesByChatGroup(tables); - if (messagesByChatGroup.size === 0) { - return; - } - - const dirs = new Set(); - const writeOperations: Array<{ path: string; content: string }> = []; - - for (const [ - chatGroupId, - { chatGroup, messages }, - ] of messagesByChatGroup) { - const chatDir = getChatDir(dataDir, chatGroupId); - dirs.add(chatDir); - - const json: ChatJson = { - chat_group: chatGroup, - messages: messages.sort( - (a, b) => - new Date(a.created_at || 0).getTime() - - new Date(b.created_at || 0).getTime(), - ), - }; - writeOperations.push({ - path: `${chatDir}/_messages.json`, - content: JSON.stringify(json, null, 2), - }); - } - - try { - await ensureDirsExist(dirs); - } catch (e) { - console.error("Failed to ensure dirs exist:", e); - return; - } - - for (const op of writeOperations) { - try { - await writeTextFile(op.path, op.content); - } catch (e) { - console.error(`Failed to write ${op.path}:`, e); - } - } - }, + loadFn, + saveFn, (listener) => setInterval(listener, 1000), (interval) => clearInterval(interval), + (error) => console.error("[ChatPersister]:", error), + StoreOrMergeableStore, ); } diff --git a/apps/desktop/src/store/tinybase/persister/events.test.ts b/apps/desktop/src/store/tinybase/persister/events.test.ts new file mode 100644 index 000000000..7c16ff2f1 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/events.test.ts @@ -0,0 +1,181 @@ +import { createMergeableStore } from "tinybase/with-schemas"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { SCHEMA, type Schemas } from "@hypr/store"; + +import { createEventPersister } from "./events"; + +vi.mock("@hypr/plugin-path2", () => ({ + commands: { + base: vi.fn().mockResolvedValue("/mock/data/dir/hyprnote"), + }, +})); + +vi.mock("@tauri-apps/plugin-fs", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + readTextFile: vi.fn(), + writeTextFile: vi.fn().mockResolvedValue(undefined), +})); + +function createTestStore() { + return createMergeableStore() + .setTablesSchema(SCHEMA.table) + .setValuesSchema(SCHEMA.value); +} + +describe("createEventPersister", () => { + let store: ReturnType; + + beforeEach(() => { + store = createTestStore(); + vi.clearAllMocks(); + }); + + test("returns a persister object with expected methods", () => { + const persister = createEventPersister(store); + + expect(persister).toBeDefined(); + expect(persister.save).toBeTypeOf("function"); + expect(persister.load).toBeTypeOf("function"); + expect(persister.destroy).toBeTypeOf("function"); + }); + + describe("load", () => { + test("loads events from json file", async () => { + const { readTextFile } = await import("@tauri-apps/plugin-fs"); + + const mockData = { + "event-1": { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + tracking_id_event: "tracking-1", + calendar_id: "cal-1", + title: "Team Meeting", + started_at: "2024-01-01T10:00:00Z", + ended_at: "2024-01-01T11:00:00Z", + location: "Conference Room A", + meeting_link: "https://meet.example.com/abc", + description: "Weekly team sync", + note: "", + ignored: false, + recurrence_series_id: "", + }, + }; + vi.mocked(readTextFile).mockResolvedValue(JSON.stringify(mockData)); + + const persister = createEventPersister(store); + await persister.load(); + + expect(readTextFile).toHaveBeenCalledWith( + "/mock/data/dir/hyprnote/events.json", + ); + expect(store.getTable("events")).toEqual(mockData); + }); + + test("returns empty events when file does not exist", async () => { + const { readTextFile } = await import("@tauri-apps/plugin-fs"); + + vi.mocked(readTextFile).mockRejectedValue( + new Error("No such file or directory"), + ); + + const persister = createEventPersister(store); + await persister.load(); + + expect(store.getTable("events")).toEqual({}); + }); + }); + + describe("save", () => { + test("saves events to json file", async () => { + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + + store.setRow("events", "event-1", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + tracking_id_event: "tracking-1", + calendar_id: "cal-1", + title: "Team Meeting", + started_at: "2024-01-01T10:00:00Z", + ended_at: "2024-01-01T11:00:00Z", + location: "Conference Room A", + meeting_link: "https://meet.example.com/abc", + description: "Weekly team sync", + note: "", + ignored: false, + recurrence_series_id: "", + }); + + const persister = createEventPersister(store); + await persister.save(); + + expect(writeTextFile).toHaveBeenCalledWith( + "/mock/data/dir/hyprnote/events.json", + expect.any(String), + ); + + const writtenContent = vi.mocked(writeTextFile).mock.calls[0][1]; + const parsed = JSON.parse(writtenContent); + + expect(parsed["event-1"].title).toBe("Team Meeting"); + }); + + test("writes empty object when no events exist", async () => { + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + + const persister = createEventPersister(store); + await persister.save(); + + expect(writeTextFile).toHaveBeenCalledWith( + "/mock/data/dir/hyprnote/events.json", + "{}", + ); + }); + + test("saves multiple events", async () => { + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + + store.setRow("events", "event-1", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + tracking_id_event: "tracking-1", + calendar_id: "cal-1", + title: "Team Meeting", + started_at: "2024-01-01T10:00:00Z", + ended_at: "2024-01-01T11:00:00Z", + location: "", + meeting_link: "", + description: "", + note: "", + ignored: false, + recurrence_series_id: "", + }); + + store.setRow("events", "event-2", { + user_id: "user-1", + created_at: "2024-01-02T00:00:00Z", + tracking_id_event: "tracking-2", + calendar_id: "cal-1", + title: "1:1 with Manager", + started_at: "2024-01-02T14:00:00Z", + ended_at: "2024-01-02T14:30:00Z", + location: "", + meeting_link: "", + description: "", + note: "", + ignored: false, + recurrence_series_id: "", + }); + + const persister = createEventPersister(store); + await persister.save(); + + const writtenContent = vi.mocked(writeTextFile).mock.calls[0][1]; + const parsed = JSON.parse(writtenContent); + + expect(Object.keys(parsed)).toHaveLength(2); + expect(parsed["event-1"].title).toBe("Team Meeting"); + expect(parsed["event-2"].title).toBe("1:1 with Manager"); + }); + }); +}); diff --git a/apps/desktop/src/store/tinybase/persister/events.ts b/apps/desktop/src/store/tinybase/persister/events.ts index 7527c5e63..32d9ed3e7 100644 --- a/apps/desktop/src/store/tinybase/persister/events.ts +++ b/apps/desktop/src/store/tinybase/persister/events.ts @@ -1,86 +1,15 @@ -import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs"; -import { createCustomPersister } from "tinybase/persisters/with-schemas"; -import type { - Content, - MergeableStore, - OptionalSchemas, -} from "tinybase/with-schemas"; +import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas"; -import { commands as path2Commands } from "@hypr/plugin-path2"; -import type { EventStorage } from "@hypr/store"; - -import { StoreOrMergeableStore } from "../store/shared"; -import type { PersisterMode } from "./utils"; - -const FILENAME = "events.json"; - -type EventsJson = Record; - -async function getFilePath(): Promise { - const base = await path2Commands.base(); - return `${base}/${FILENAME}`; -} - -export function jsonToContent( - data: EventsJson, -): Content { - return [{ events: data }, {}] as unknown as Content; -} - -export function storeToJson( - store: MergeableStore, -): EventsJson { - const table = store.getTable("events") ?? {}; - return table as unknown as EventsJson; -} +import { createSimpleJsonPersister, type PersisterMode } from "./utils"; export function createEventPersister( store: MergeableStore, - config: { mode: PersisterMode } = { mode: "load-and-save" }, + config: { mode: PersisterMode } = { mode: "save-only" }, ) { - const loadFn = - config.mode === "save-only" - ? async (): Promise | undefined> => undefined - : async (): Promise | undefined> => { - try { - const filePath = await getFilePath(); - const content = await readTextFile(filePath); - const data = JSON.parse(content) as EventsJson; - return jsonToContent(data); - } catch (error) { - const errorStr = String(error); - if ( - errorStr.includes("No such file or directory") || - errorStr.includes("ENOENT") || - errorStr.includes("not found") - ) { - return jsonToContent({}); - } - console.error("[EventPersister] load error:", error); - return undefined; - } - }; - - const saveFn = - config.mode === "load-only" - ? async () => {} - : async () => { - try { - const data = storeToJson(store); - const filePath = await getFilePath(); - await writeTextFile(filePath, JSON.stringify(data, null, 2)); - } catch (error) { - console.error("[EventPersister] save error:", error); - } - }; - - return createCustomPersister( - store, - loadFn, - saveFn, - (listener) => setInterval(listener, 1000), - (handle) => clearInterval(handle), - (error) => console.error("[EventPersister]:", error), - StoreOrMergeableStore, - ); + return createSimpleJsonPersister(store, { + tableName: "events", + filename: "events.json", + label: "EventPersister", + mode: config.mode, + }); } diff --git a/apps/desktop/src/store/tinybase/persister/human.test.ts b/apps/desktop/src/store/tinybase/persister/human.test.ts index 631c8bf70..150c3f2c3 100644 --- a/apps/desktop/src/store/tinybase/persister/human.test.ts +++ b/apps/desktop/src/store/tinybase/persister/human.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { SCHEMA, type Schemas } from "@hypr/store"; -import { createHumanPersister, jsonToContent, storeToJson } from "./human"; +import { createHumanPersister } from "./human"; vi.mock("@hypr/plugin-path2", () => ({ commands: { @@ -12,6 +12,7 @@ vi.mock("@hypr/plugin-path2", () => ({ })); vi.mock("@tauri-apps/plugin-fs", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), readTextFile: vi.fn(), writeTextFile: vi.fn().mockResolvedValue(undefined), })); @@ -22,84 +23,6 @@ function createTestStore() { .setValuesSchema(SCHEMA.value); } -describe("humanPersister roundtrip", () => { - test("json -> store -> json preserves all data", () => { - const original = { - "human-1": { - user_id: "user-1", - created_at: "2024-01-01T00:00:00Z", - name: "John Doe", - email: "john@example.com", - org_id: "org-1", - job_title: "Engineer", - linkedin_username: "johndoe", - memo: "Some notes", - }, - "human-2": { - user_id: "user-1", - created_at: "2024-01-02T00:00:00Z", - name: "Jane Smith", - email: "jane@example.com", - org_id: "org-2", - job_title: "Manager", - linkedin_username: "janesmith", - memo: "", - }, - }; - - const [tables] = jsonToContent(original); - const store = createMergeableStore(); - store.setTables(tables); - const result = storeToJson(store); - - expect(result).toEqual(original); - }); - - test("store -> json -> store preserves all data", () => { - const store = createMergeableStore(); - - const originalHumans = { - "human-1": { - user_id: "user-1", - created_at: "2024-01-01T00:00:00Z", - name: "John Doe", - email: "john@example.com", - org_id: "org-1", - job_title: "Engineer", - linkedin_username: "johndoe", - memo: "Some notes", - }, - "human-2": { - user_id: "user-1", - created_at: "2024-01-02T00:00:00Z", - name: "Jane Smith", - email: "jane@example.com", - org_id: "org-2", - job_title: "Manager", - linkedin_username: "janesmith", - memo: "", - }, - }; - - store.setTable("humans", originalHumans); - const json = storeToJson(store); - const [tables] = jsonToContent(json); - - expect(tables).toEqual({ humans: originalHumans }); - }); - - test("handles empty data", () => { - const original = {}; - - const [tables] = jsonToContent(original); - const store = createMergeableStore(); - store.setTables(tables); - const result = storeToJson(store); - - expect(result).toEqual({}); - }); -}); - describe("createHumanPersister", () => { let store: ReturnType; diff --git a/apps/desktop/src/store/tinybase/persister/human.ts b/apps/desktop/src/store/tinybase/persister/human.ts index ee856ec2d..63ae76b66 100644 --- a/apps/desktop/src/store/tinybase/persister/human.ts +++ b/apps/desktop/src/store/tinybase/persister/human.ts @@ -1,86 +1,15 @@ -import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs"; -import { createCustomPersister } from "tinybase/persisters/with-schemas"; -import type { - Content, - MergeableStore, - OptionalSchemas, -} from "tinybase/with-schemas"; +import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas"; -import { commands as path2Commands } from "@hypr/plugin-path2"; -import type { HumanStorage } from "@hypr/store"; - -import { StoreOrMergeableStore } from "../store/shared"; -import type { PersisterMode } from "./utils"; - -const FILENAME = "humans.json"; - -type HumansJson = Record; - -async function getFilePath(): Promise { - const base = await path2Commands.base(); - return `${base}/${FILENAME}`; -} - -export function jsonToContent( - data: HumansJson, -): Content { - return [{ humans: data }, {}] as unknown as Content; -} - -export function storeToJson( - store: MergeableStore, -): HumansJson { - const table = store.getTable("humans") ?? {}; - return table as unknown as HumansJson; -} +import { createSimpleJsonPersister, type PersisterMode } from "./utils"; export function createHumanPersister( store: MergeableStore, - config: { mode: PersisterMode } = { mode: "load-and-save" }, + config: { mode: PersisterMode } = { mode: "save-only" }, ) { - const loadFn = - config.mode === "save-only" - ? async (): Promise | undefined> => undefined - : async (): Promise | undefined> => { - try { - const filePath = await getFilePath(); - const content = await readTextFile(filePath); - const data = JSON.parse(content) as HumansJson; - return jsonToContent(data); - } catch (error) { - const errorStr = String(error); - if ( - errorStr.includes("No such file or directory") || - errorStr.includes("ENOENT") || - errorStr.includes("not found") - ) { - return jsonToContent({}); - } - console.error("[HumanPersister] load error:", error); - return undefined; - } - }; - - const saveFn = - config.mode === "load-only" - ? async () => {} - : async () => { - try { - const data = storeToJson(store); - const filePath = await getFilePath(); - await writeTextFile(filePath, JSON.stringify(data, null, 2)); - } catch (error) { - console.error("[HumanPersister] save error:", error); - } - }; - - return createCustomPersister( - store, - loadFn, - saveFn, - (listener) => setInterval(listener, 1000), - (handle) => clearInterval(handle), - (error) => console.error("[HumanPersister]:", error), - StoreOrMergeableStore, - ); + return createSimpleJsonPersister(store, { + tableName: "humans", + filename: "humans.json", + label: "HumanPersister", + mode: config.mode, + }); } diff --git a/apps/desktop/src/store/tinybase/persister/note.ts b/apps/desktop/src/store/tinybase/persister/note.ts index 9bedd6ff3..0864a6e2a 100644 --- a/apps/desktop/src/store/tinybase/persister/note.ts +++ b/apps/desktop/src/store/tinybase/persister/note.ts @@ -5,6 +5,7 @@ import { commands, type JsonValue } from "@hypr/plugin-export"; import type { EnhancedNoteStorage } from "@hypr/store"; import { isValidTiptapContent } from "@hypr/tiptap/shared"; +import { StoreOrMergeableStore } from "../store/shared"; import { type BatchCollectorResult, type BatchItem, @@ -22,6 +23,9 @@ export function createNotePersister( handleSyncToSession: (sessionId: string, content: string) => void, config: { mode: PersisterMode } = { mode: "save-only" }, ) { + const loadFn = + config.mode === "save-only" ? async () => undefined : async () => undefined; + const saveFn = config.mode === "load-only" ? async () => {} @@ -60,12 +64,12 @@ export function createNotePersister( return createCustomPersister( store, - async () => { - return undefined; - }, + loadFn, saveFn, (listener) => setInterval(listener, 1000), (interval) => clearInterval(interval), + (error) => console.error("[NotePersister]:", error), + StoreOrMergeableStore, ); } diff --git a/apps/desktop/src/store/tinybase/persister/organization.test.ts b/apps/desktop/src/store/tinybase/persister/organization.test.ts index f09bb83de..efb001d66 100644 --- a/apps/desktop/src/store/tinybase/persister/organization.test.ts +++ b/apps/desktop/src/store/tinybase/persister/organization.test.ts @@ -3,11 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { SCHEMA, type Schemas } from "@hypr/store"; -import { - createOrganizationPersister, - jsonToContent, - storeToJson, -} from "./organization"; +import { createOrganizationPersister } from "./organization"; vi.mock("@hypr/plugin-path2", () => ({ commands: { @@ -16,6 +12,7 @@ vi.mock("@hypr/plugin-path2", () => ({ })); vi.mock("@tauri-apps/plugin-fs", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), readTextFile: vi.fn(), writeTextFile: vi.fn().mockResolvedValue(undefined), })); @@ -26,64 +23,6 @@ function createTestStore() { .setValuesSchema(SCHEMA.value); } -describe("organizationPersister roundtrip", () => { - test("json -> store -> json preserves all data", () => { - const original = { - "org-1": { - user_id: "user-1", - created_at: "2024-01-01T00:00:00Z", - name: "Acme Corp", - }, - "org-2": { - user_id: "user-1", - created_at: "2024-01-02T00:00:00Z", - name: "Beta Inc", - }, - }; - - const [tables] = jsonToContent(original); - const store = createMergeableStore(); - store.setTables(tables); - const result = storeToJson(store); - - expect(result).toEqual(original); - }); - - test("store -> json -> store preserves all data", () => { - const store = createMergeableStore(); - - const originalOrganizations = { - "org-1": { - user_id: "user-1", - created_at: "2024-01-01T00:00:00Z", - name: "Acme Corp", - }, - "org-2": { - user_id: "user-1", - created_at: "2024-01-02T00:00:00Z", - name: "Beta Inc", - }, - }; - - store.setTable("organizations", originalOrganizations); - const json = storeToJson(store); - const [tables] = jsonToContent(json); - - expect(tables).toEqual({ organizations: originalOrganizations }); - }); - - test("handles empty data", () => { - const original = {}; - - const [tables] = jsonToContent(original); - const store = createMergeableStore(); - store.setTables(tables); - const result = storeToJson(store); - - expect(result).toEqual({}); - }); -}); - describe("createOrganizationPersister", () => { let store: ReturnType; diff --git a/apps/desktop/src/store/tinybase/persister/organization.ts b/apps/desktop/src/store/tinybase/persister/organization.ts index 95e26f607..b7e3e48e1 100644 --- a/apps/desktop/src/store/tinybase/persister/organization.ts +++ b/apps/desktop/src/store/tinybase/persister/organization.ts @@ -1,86 +1,15 @@ -import { readTextFile, writeTextFile } from "@tauri-apps/plugin-fs"; -import { createCustomPersister } from "tinybase/persisters/with-schemas"; -import type { - Content, - MergeableStore, - OptionalSchemas, -} from "tinybase/with-schemas"; +import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas"; -import { commands as path2Commands } from "@hypr/plugin-path2"; -import type { OrganizationStorage } from "@hypr/store"; - -import { StoreOrMergeableStore } from "../store/shared"; -import type { PersisterMode } from "./utils"; - -const FILENAME = "organizations.json"; - -type OrganizationsJson = Record; - -async function getFilePath(): Promise { - const base = await path2Commands.base(); - return `${base}/${FILENAME}`; -} - -export function jsonToContent( - data: OrganizationsJson, -): Content { - return [{ organizations: data }, {}] as unknown as Content; -} - -export function storeToJson( - store: MergeableStore, -): OrganizationsJson { - const table = store.getTable("organizations") ?? {}; - return table as unknown as OrganizationsJson; -} +import { createSimpleJsonPersister, type PersisterMode } from "./utils"; export function createOrganizationPersister( store: MergeableStore, - config: { mode: PersisterMode } = { mode: "load-and-save" }, + config: { mode: PersisterMode } = { mode: "save-only" }, ) { - const loadFn = - config.mode === "save-only" - ? async (): Promise | undefined> => undefined - : async (): Promise | undefined> => { - try { - const filePath = await getFilePath(); - const content = await readTextFile(filePath); - const data = JSON.parse(content) as OrganizationsJson; - return jsonToContent(data); - } catch (error) { - const errorStr = String(error); - if ( - errorStr.includes("No such file or directory") || - errorStr.includes("ENOENT") || - errorStr.includes("not found") - ) { - return jsonToContent({}); - } - console.error("[OrganizationPersister] load error:", error); - return undefined; - } - }; - - const saveFn = - config.mode === "load-only" - ? async () => {} - : async () => { - try { - const data = storeToJson(store); - const filePath = await getFilePath(); - await writeTextFile(filePath, JSON.stringify(data, null, 2)); - } catch (error) { - console.error("[OrganizationPersister] save error:", error); - } - }; - - return createCustomPersister( - store, - loadFn, - saveFn, - (listener) => setInterval(listener, 1000), - (handle) => clearInterval(handle), - (error) => console.error("[OrganizationPersister]:", error), - StoreOrMergeableStore, - ); + return createSimpleJsonPersister(store, { + tableName: "organizations", + filename: "organizations.json", + label: "OrganizationPersister", + mode: config.mode, + }); } diff --git a/apps/desktop/src/store/tinybase/persister/prompts.ts b/apps/desktop/src/store/tinybase/persister/prompts.ts new file mode 100644 index 000000000..a28f6b989 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/prompts.ts @@ -0,0 +1,15 @@ +import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas"; + +import { createSimpleJsonPersister, type PersisterMode } from "./utils"; + +export function createPromptPersister( + store: MergeableStore, + config: { mode: PersisterMode } = { mode: "save-only" }, +) { + return createSimpleJsonPersister(store, { + tableName: "prompts", + filename: "prompts.json", + label: "PromptPersister", + mode: config.mode, + }); +} diff --git a/apps/desktop/src/store/tinybase/persister/session.test.ts b/apps/desktop/src/store/tinybase/persister/session.test.ts new file mode 100644 index 000000000..f327c30eb --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/session.test.ts @@ -0,0 +1,376 @@ +import { createMergeableStore } from "tinybase/with-schemas"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { SCHEMA, type Schemas } from "@hypr/store"; + +import { + collectSessionMeta, + createSessionPersister, + type SessionMetaJson, +} from "./session"; + +vi.mock("@hypr/plugin-path2", () => ({ + commands: { + base: vi.fn().mockResolvedValue("/mock/data/dir/hyprnote"), + }, +})); + +vi.mock("@tauri-apps/plugin-fs", () => ({ + readDir: vi.fn(), + readTextFile: vi.fn(), + writeTextFile: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + exists: vi.fn().mockResolvedValue(true), +})); + +function createTestStore() { + return createMergeableStore() + .setTablesSchema(SCHEMA.table) + .setValuesSchema(SCHEMA.value); +} + +describe("collectSessionMeta", () => { + test("collects session metadata without tags", () => { + const store = createTestStore(); + + store.setRow("sessions", "session-1", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "Test Session", + raw_md: "", + enhanced_md: "", + }); + + const result = collectSessionMeta(store); + + expect(result.size).toBe(1); + const meta = result.get("session-1"); + expect(meta).toEqual({ + id: "session-1", + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "Test Session", + folder_id: undefined, + event_id: undefined, + participants: [], + tags: undefined, + }); + }); + + test("collects session metadata with tags", () => { + const store = createTestStore(); + + store.setRow("sessions", "session-1", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "Test Session", + raw_md: "", + enhanced_md: "", + }); + + store.setRow("tags", "Important", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + name: "Important", + }); + + store.setRow("tags", "Meeting", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + name: "Meeting", + }); + + store.setRow("mapping_tag_session", "session-1:Important", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + tag_id: "Important", + session_id: "session-1", + }); + + store.setRow("mapping_tag_session", "session-1:Meeting", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + tag_id: "Meeting", + session_id: "session-1", + }); + + const result = collectSessionMeta(store); + + expect(result.size).toBe(1); + const meta = result.get("session-1"); + expect(meta?.tags).toBeDefined(); + expect(meta?.tags).toHaveLength(2); + expect(meta?.tags).toContain("Important"); + expect(meta?.tags).toContain("Meeting"); + }); + + test("handles sessions with and without tags", () => { + const store = createTestStore(); + + store.setRow("sessions", "session-1", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "Session With Tags", + raw_md: "", + enhanced_md: "", + }); + + store.setRow("sessions", "session-2", { + user_id: "user-1", + created_at: "2024-01-02T00:00:00Z", + title: "Session Without Tags", + raw_md: "", + enhanced_md: "", + }); + + store.setRow("tags", "Important", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + name: "Important", + }); + + store.setRow("mapping_tag_session", "session-1:Important", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + tag_id: "Important", + session_id: "session-1", + }); + + const result = collectSessionMeta(store); + + expect(result.size).toBe(2); + + const meta1 = result.get("session-1"); + expect(meta1?.tags).toEqual(["Important"]); + + const meta2 = result.get("session-2"); + expect(meta2?.tags).toBeUndefined(); + }); + + test("includes participants with tags", () => { + const store = createTestStore(); + + store.setRow("sessions", "session-1", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "Test Session", + raw_md: "", + enhanced_md: "", + }); + + store.setRow("mapping_session_participant", "mapping-1", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + session_id: "session-1", + human_id: "human-1", + source: "manual", + }); + + store.setRow("tags", "Important", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + name: "Important", + }); + + store.setRow("mapping_tag_session", "session-1:Important", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + tag_id: "Important", + session_id: "session-1", + }); + + const result = collectSessionMeta(store); + const meta = result.get("session-1"); + + expect(meta?.participants).toHaveLength(1); + expect(meta?.participants[0].id).toBe("mapping-1"); + expect(meta?.tags).toEqual(["Important"]); + }); +}); + +describe("createSessionPersister", () => { + let store: ReturnType; + + beforeEach(() => { + store = createTestStore(); + vi.clearAllMocks(); + }); + + test("returns a persister object with expected methods", () => { + const persister = createSessionPersister(store); + + expect(persister).toBeDefined(); + expect(persister.save).toBeTypeOf("function"); + expect(persister.load).toBeTypeOf("function"); + expect(persister.destroy).toBeTypeOf("function"); + }); + + describe("load", () => { + test("loads sessions with tags from meta files", async () => { + const { readDir, readTextFile } = await import("@tauri-apps/plugin-fs"); + + vi.mocked(readDir).mockResolvedValue([ + { + name: "session-1", + isDirectory: true, + isFile: false, + isSymlink: false, + }, + ]); + + const mockMeta: SessionMetaJson = { + id: "session-1", + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "Test Session", + participants: [], + tags: ["Important", "Meeting"], + }; + vi.mocked(readTextFile).mockResolvedValue(JSON.stringify(mockMeta)); + + const persister = createSessionPersister(store); + await persister.load(); + + expect(store.getTable("sessions")).toEqual({ + "session-1": { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "Test Session", + folder_id: undefined, + event_id: undefined, + raw_md: "", + enhanced_md: "", + }, + }); + + const tags = store.getTable("tags"); + expect(tags["Important"]).toBeDefined(); + expect(tags["Important"].name).toBe("Important"); + expect(tags["Meeting"]).toBeDefined(); + expect(tags["Meeting"].name).toBe("Meeting"); + + const mappings = store.getTable("mapping_tag_session"); + expect(mappings["session-1:Important"]).toBeDefined(); + expect(mappings["session-1:Important"].tag_id).toBe("Important"); + expect(mappings["session-1:Important"].session_id).toBe("session-1"); + expect(mappings["session-1:Meeting"]).toBeDefined(); + }); + + test("handles sessions without tags", async () => { + const { readDir, readTextFile } = await import("@tauri-apps/plugin-fs"); + + vi.mocked(readDir).mockResolvedValue([ + { + name: "session-1", + isDirectory: true, + isFile: false, + isSymlink: false, + }, + ]); + + const mockMeta: SessionMetaJson = { + id: "session-1", + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "Test Session", + participants: [], + }; + vi.mocked(readTextFile).mockResolvedValue(JSON.stringify(mockMeta)); + + const persister = createSessionPersister(store); + await persister.load(); + + expect(store.getTable("tags")).toEqual({}); + expect(store.getTable("mapping_tag_session")).toEqual({}); + }); + + test("deduplicates tags across sessions", async () => { + const { readDir, readTextFile } = await import("@tauri-apps/plugin-fs"); + + vi.mocked(readDir).mockResolvedValue([ + { + name: "session-1", + isDirectory: true, + isFile: false, + isSymlink: false, + }, + { + name: "session-2", + isDirectory: true, + isFile: false, + isSymlink: false, + }, + ]); + + vi.mocked(readTextFile) + .mockResolvedValueOnce( + JSON.stringify({ + id: "session-1", + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "Session 1", + participants: [], + tags: ["Important"], + }), + ) + .mockResolvedValueOnce( + JSON.stringify({ + id: "session-2", + user_id: "user-1", + created_at: "2024-01-02T00:00:00Z", + title: "Session 2", + participants: [], + tags: ["Important"], + }), + ); + + const persister = createSessionPersister(store); + await persister.load(); + + const tags = store.getTable("tags"); + expect(Object.keys(tags)).toHaveLength(1); + expect(tags["Important"].name).toBe("Important"); + + const mappings = store.getTable("mapping_tag_session"); + expect(Object.keys(mappings)).toHaveLength(2); + expect(mappings["session-1:Important"]).toBeDefined(); + expect(mappings["session-2:Important"]).toBeDefined(); + }); + }); + + describe("save", () => { + test("saves session metadata with tags to files", async () => { + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + + store.setRow("sessions", "session-1", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + title: "Test Session", + raw_md: "", + enhanced_md: "", + }); + + store.setRow("tags", "Important", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + name: "Important", + }); + + store.setRow("mapping_tag_session", "session-1:Important", { + user_id: "user-1", + created_at: "2024-01-01T00:00:00Z", + tag_id: "Important", + session_id: "session-1", + }); + + const persister = createSessionPersister(store); + await persister.save(); + + expect(writeTextFile).toHaveBeenCalled(); + const writtenContent = vi.mocked(writeTextFile).mock.calls[0][1]; + const parsed = JSON.parse(writtenContent) as SessionMetaJson; + + expect(parsed.tags).toEqual(["Important"]); + }); + }); +}); diff --git a/apps/desktop/src/store/tinybase/persister/session.ts b/apps/desktop/src/store/tinybase/persister/session.ts new file mode 100644 index 000000000..028b78691 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/session.ts @@ -0,0 +1,249 @@ +import { readDir, readTextFile, writeTextFile } from "@tauri-apps/plugin-fs"; +import { createCustomPersister } from "tinybase/persisters/with-schemas"; +import type { + Content, + MergeableStore, + OptionalSchemas, +} from "tinybase/with-schemas"; + +import type { + MappingSessionParticipantStorage, + MappingTagSession, + SessionStorage, + Tag, +} from "@hypr/store"; + +import { StoreOrMergeableStore } from "../store/shared"; +import { + ensureDirsExist, + getDataDir, + getSessionDir, + type PersisterMode, +} from "./utils"; + +type ParticipantData = MappingSessionParticipantStorage & { id: string }; + +export type SessionMetaJson = { + id: string; + user_id: string; + created_at: string; + title: string; + folder_id?: string; + event_id?: string; + participants: ParticipantData[]; + tags?: string[]; +}; + +type LoadedData = { + sessions: Record; + mapping_session_participant: Record; + tags: Record; + mapping_tag_session: Record; +}; + +async function loadAllSessionMeta(dataDir: string): Promise { + const result: LoadedData = { + sessions: {}, + mapping_session_participant: {}, + tags: {}, + mapping_tag_session: {}, + }; + + const sessionsDir = `${dataDir}/sessions`; + + let entries: { name: string; isDirectory: boolean }[]; + try { + entries = await readDir(sessionsDir); + } catch { + return result; + } + + const now = new Date().toISOString(); + + for (const entry of entries) { + if (!entry.isDirectory) continue; + + const sessionId = entry.name; + const metaPath = `${sessionsDir}/${sessionId}/_meta.json`; + + try { + const content = await readTextFile(metaPath); + const meta = JSON.parse(content) as SessionMetaJson; + + result.sessions[sessionId] = { + user_id: meta.user_id, + created_at: meta.created_at, + title: meta.title, + folder_id: meta.folder_id, + event_id: meta.event_id, + raw_md: "", + enhanced_md: "", + }; + + for (const participant of meta.participants) { + result.mapping_session_participant[participant.id] = { + user_id: participant.user_id, + created_at: participant.created_at, + session_id: sessionId, + human_id: participant.human_id, + source: participant.source, + }; + } + + if (meta.tags) { + for (const tagName of meta.tags) { + if (!result.tags[tagName]) { + result.tags[tagName] = { + user_id: meta.user_id, + created_at: now, + name: tagName, + }; + } + + const mappingId = `${sessionId}:${tagName}`; + result.mapping_tag_session[mappingId] = { + user_id: meta.user_id, + created_at: now, + tag_id: tagName, + session_id: sessionId, + }; + } + } + } catch { + continue; + } + } + + return result; +} + +export function collectSessionMeta( + store: MergeableStore, +): Map { + const result = new Map(); + + const sessions = (store.getTable("sessions") ?? {}) as Record< + string, + SessionStorage + >; + const participants = (store.getTable("mapping_session_participant") ?? + {}) as Record; + const tags = (store.getTable("tags") ?? {}) as Record; + const tagMappings = (store.getTable("mapping_tag_session") ?? {}) as Record< + string, + MappingTagSession + >; + + const participantsBySession = new Map(); + for (const [id, participant] of Object.entries(participants)) { + const sessionId = participant.session_id; + if (!sessionId) continue; + + const list = participantsBySession.get(sessionId) ?? []; + list.push({ ...participant, id }); + participantsBySession.set(sessionId, list); + } + + const tagsBySession = new Map(); + for (const mapping of Object.values(tagMappings)) { + const sessionId = mapping.session_id; + const tagId = mapping.tag_id; + if (!sessionId || !tagId) continue; + + const tag = tags[tagId]; + if (!tag?.name) continue; + + const list = tagsBySession.get(sessionId) ?? []; + list.push(tag.name); + tagsBySession.set(sessionId, list); + } + + for (const [sessionId, session] of Object.entries(sessions)) { + const sessionTags = tagsBySession.get(sessionId); + result.set(sessionId, { + id: sessionId, + user_id: session.user_id ?? "", + created_at: session.created_at ?? "", + title: session.title ?? "", + folder_id: session.folder_id || undefined, + event_id: session.event_id || undefined, + participants: participantsBySession.get(sessionId) ?? [], + tags: sessionTags && sessionTags.length > 0 ? sessionTags : undefined, + }); + } + + return result; +} + +export function createSessionPersister( + store: MergeableStore, + config: { mode: PersisterMode } = { mode: "save-only" }, +) { + const loadFn = + config.mode === "save-only" + ? async (): Promise | undefined> => undefined + : async (): Promise | undefined> => { + try { + const dataDir = await getDataDir(); + const data = await loadAllSessionMeta(dataDir); + return [ + { + sessions: data.sessions, + mapping_session_participant: data.mapping_session_participant, + tags: data.tags, + mapping_tag_session: data.mapping_tag_session, + }, + {}, + ] as unknown as Content; + } catch (error) { + console.error("[SessionPersister] load error:", error); + return undefined; + } + }; + + const saveFn = + config.mode === "load-only" + ? async () => {} + : async () => { + try { + const dataDir = await getDataDir(); + const sessionMetas = collectSessionMeta(store); + + if (sessionMetas.size === 0) { + return; + } + + const dirs = new Set(); + const writeOperations: Array<{ path: string; content: string }> = + []; + + for (const [sessionId, meta] of sessionMetas) { + const sessionDir = getSessionDir(dataDir, sessionId); + dirs.add(sessionDir); + + writeOperations.push({ + path: `${sessionDir}/_meta.json`, + content: JSON.stringify(meta, null, 2), + }); + } + + await ensureDirsExist(dirs); + + for (const op of writeOperations) { + await writeTextFile(op.path, op.content); + } + } catch (error) { + console.error("[SessionPersister] save error:", error); + } + }; + + return createCustomPersister( + store, + loadFn, + saveFn, + (listener) => setInterval(listener, 1000), + (handle) => clearInterval(handle), + (error) => console.error("[SessionPersister]:", error), + StoreOrMergeableStore, + ); +} diff --git a/apps/desktop/src/store/tinybase/persister/templates.ts b/apps/desktop/src/store/tinybase/persister/templates.ts new file mode 100644 index 000000000..bdeb4c6f6 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/templates.ts @@ -0,0 +1,15 @@ +import type { MergeableStore, OptionalSchemas } from "tinybase/with-schemas"; + +import { createSimpleJsonPersister, type PersisterMode } from "./utils"; + +export function createTemplatePersister( + store: MergeableStore, + config: { mode: PersisterMode } = { mode: "save-only" }, +) { + return createSimpleJsonPersister(store, { + tableName: "templates", + filename: "templates.json", + label: "TemplatePersister", + mode: config.mode, + }); +} diff --git a/apps/desktop/src/store/tinybase/persister/transcript.ts b/apps/desktop/src/store/tinybase/persister/transcript.ts index bd0f0a8ca..b8e9bd291 100644 --- a/apps/desktop/src/store/tinybase/persister/transcript.ts +++ b/apps/desktop/src/store/tinybase/persister/transcript.ts @@ -8,6 +8,7 @@ import type { WordStorage, } from "@hypr/store"; +import { StoreOrMergeableStore } from "../store/shared"; import { ensureDirsExist, getDataDir, @@ -79,6 +80,9 @@ export function createTranscriptPersister( store: MergeableStore, config: { mode: PersisterMode } = { mode: "save-only" }, ) { + const loadFn = + config.mode === "save-only" ? async () => undefined : async () => undefined; + const saveFn = config.mode === "load-only" ? async () => {} @@ -123,11 +127,11 @@ export function createTranscriptPersister( return createCustomPersister( store, - async () => { - return undefined; - }, + loadFn, saveFn, (listener) => setInterval(listener, 1000), (interval) => clearInterval(interval), + (error) => console.error("[TranscriptPersister]:", error), + StoreOrMergeableStore, ); } diff --git a/apps/desktop/src/store/tinybase/persister/utils.ts b/apps/desktop/src/store/tinybase/persister/utils.ts index ae4f2355b..e6acef76e 100644 --- a/apps/desktop/src/store/tinybase/persister/utils.ts +++ b/apps/desktop/src/store/tinybase/persister/utils.ts @@ -1,4 +1,10 @@ -import { mkdir } from "@tauri-apps/plugin-fs"; +import { mkdir, readTextFile, writeTextFile } from "@tauri-apps/plugin-fs"; +import { createCustomPersister } from "tinybase/persisters/with-schemas"; +import type { + Content, + MergeableStore, + OptionalSchemas, +} from "tinybase/with-schemas"; import { commands as path2Commands } from "@hypr/plugin-path2"; import type { @@ -12,6 +18,8 @@ import type { WordStorage, } from "@hypr/store"; +import { StoreOrMergeableStore } from "../store/shared"; + export type PersisterMode = "load-only" | "save-only" | "load-and-save"; export async function getDataDir(): Promise { @@ -92,3 +100,69 @@ export function iterateTableRows( } return result; } + +function isFileNotFoundError(error: unknown): boolean { + const errorStr = String(error); + return ( + errorStr.includes("No such file or directory") || + errorStr.includes("ENOENT") || + errorStr.includes("not found") + ); +} + +export function createSimpleJsonPersister( + store: MergeableStore, + options: { + tableName: string; + filename: string; + label: string; + mode?: PersisterMode; + }, +) { + const { tableName, filename, label, mode = "save-only" } = options; + + const jsonToContent = (data: Record): Content => + [{ [tableName]: data }, {}] as unknown as Content; + + const loadFn = + mode === "save-only" + ? async (): Promise | undefined> => undefined + : async (): Promise | undefined> => { + try { + const base = await path2Commands.base(); + const content = await readTextFile(`${base}/${filename}`); + return jsonToContent(JSON.parse(content)); + } catch (error) { + if (isFileNotFoundError(error)) return jsonToContent({}); + console.error(`[${label}] load error:`, error); + return undefined; + } + }; + + const saveFn = + mode === "load-only" + ? async () => {} + : async () => { + try { + const base = await path2Commands.base(); + await mkdir(base, { recursive: true }); + const data = store.getTable(tableName) ?? {}; + await writeTextFile( + `${base}/${filename}`, + JSON.stringify(data, null, 2), + ); + } catch (error) { + console.error(`[${label}] save error:`, error); + } + }; + + return createCustomPersister( + store, + loadFn, + saveFn, + (listener) => setInterval(listener, 1000), + (handle) => clearInterval(handle), + (error) => console.error(`[${label}]:`, error), + StoreOrMergeableStore, + ); +} diff --git a/apps/desktop/src/store/tinybase/store/persisters.ts b/apps/desktop/src/store/tinybase/store/persisters.ts index a88c29704..362b91fc7 100644 --- a/apps/desktop/src/store/tinybase/store/persisters.ts +++ b/apps/desktop/src/store/tinybase/store/persisters.ts @@ -7,12 +7,16 @@ import { getCurrentWebviewWindowLabel } from "@hypr/plugin-windows"; import { type Schemas } from "@hypr/store"; import { DEFAULT_USER_ID } from "../../../utils"; +import { createCalendarPersister } from "../persister/calendar"; import { createChatPersister } from "../persister/chat"; import { createEventPersister } from "../persister/events"; import { createHumanPersister } from "../persister/human"; import { createLocalPersister } from "../persister/local"; import { createNotePersister } from "../persister/note"; import { createOrganizationPersister } from "../persister/organization"; +import { createPromptPersister } from "../persister/prompts"; +import { createSessionPersister } from "../persister/session"; +import { createTemplatePersister } from "../persister/templates"; import { createTranscriptPersister } from "../persister/transcript"; import { maybeImportFromJson } from "./importer"; import { type Store, STORE_ID } from "./main"; @@ -146,9 +150,7 @@ export function useMainPersisters( return undefined; } - return createOrganizationPersister(store as Store, { - mode: "save-only", - }); + return createOrganizationPersister(store as Store); }, [persist], ); @@ -160,9 +162,7 @@ export function useMainPersisters( return undefined; } - return createHumanPersister(store as Store, { - mode: "save-only", - }); + return createHumanPersister(store as Store); }, [persist], ); @@ -178,7 +178,7 @@ export function useMainPersisters( mode: "load-only", }); await persister.load(); - await persister.startAutoPersisting(); + await persister.startAutoLoad(); return persister; }, [persist], @@ -211,6 +211,54 @@ export function useMainPersisters( [persist], ); + const promptPersister = useCreatePersister( + store, + async (store) => { + if (!persist) { + return undefined; + } + + return createPromptPersister(store as Store); + }, + [persist], + ); + + const templatePersister = useCreatePersister( + store, + async (store) => { + if (!persist) { + return undefined; + } + + return createTemplatePersister(store as Store); + }, + [persist], + ); + + const sessionPersister = useCreatePersister( + store, + async (store) => { + if (!persist) { + return undefined; + } + + return createSessionPersister(store as Store); + }, + [persist], + ); + + const calendarPersister = useCreatePersister( + store, + async (store) => { + if (!persist) { + return undefined; + } + + return createCalendarPersister(store as Store); + }, + [persist], + ); + const persisters = useMemo( () => localPersister && @@ -219,7 +267,11 @@ export function useMainPersisters( organizationPersister && humanPersister && eventPersister && - chatPersister + chatPersister && + promptPersister && + templatePersister && + sessionPersister && + calendarPersister ? [ localPersister, markdownPersister, @@ -228,6 +280,10 @@ export function useMainPersisters( humanPersister, eventPersister, chatPersister, + promptPersister, + templatePersister, + sessionPersister, + calendarPersister, ] : null, [ @@ -238,6 +294,10 @@ export function useMainPersisters( humanPersister, eventPersister, chatPersister, + promptPersister, + templatePersister, + sessionPersister, + calendarPersister, ], ); @@ -251,6 +311,10 @@ export function useMainPersisters( humanPersister, eventPersister, chatPersister, + promptPersister, + templatePersister, + sessionPersister, + calendarPersister, }; }