diff --git a/Cargo.lock b/Cargo.lock index 588c378dd3..ef6b70b3d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18254,9 +18254,13 @@ dependencies = [ "specta-typescript", "tauri", "tauri-plugin", + "tauri-plugin-path2", "tauri-specta", + "tempfile", "thiserror 2.0.17", "tokio", + "tracing", + "uuid", ] [[package]] @@ -18401,6 +18405,7 @@ dependencies = [ "strum 0.26.3", "tauri", "tauri-plugin", + "tauri-plugin-folder", "tauri-plugin-hooks", "tauri-plugin-local-stt", "tauri-plugin-tray", @@ -18546,6 +18551,7 @@ dependencies = [ "sysinfo 0.37.2", "tauri", "tauri-plugin", + "tauri-plugin-folder", "tauri-plugin-opener", "tauri-plugin-path2", "tauri-specta", @@ -18608,6 +18614,7 @@ dependencies = [ "specta-typescript", "tauri", "tauri-plugin", + "tauri-plugin-folder", "tauri-plugin-path2", "tauri-specta", "thiserror 2.0.17", diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/shared/folder.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/shared/folder.tsx index 0909ead716..6bac48ac8f 100644 --- a/apps/desktop/src/components/main/body/sessions/outer-header/shared/folder.tsx +++ b/apps/desktop/src/components/main/body/sessions/outer-header/shared/folder.tsx @@ -1,6 +1,7 @@ import { FolderIcon } from "lucide-react"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useCallback, useState } from "react"; +import { commands as folderCommands } from "@hypr/plugin-folder"; import { Command, CommandEmpty, @@ -31,13 +32,7 @@ export function SearchableFolderDropdown({ main.STORE_ID, ); - const handleSelectFolder = main.UI.useSetPartialRowCallback( - "sessions", - sessionId, - (folderId: string) => ({ folder_id: folderId }), - [], - main.STORE_ID, - ); + const handleSelectFolder = useMoveSessionToFolder(sessionId); return ( @@ -71,13 +66,7 @@ export function SearchableFolderSubmenuContent({ main.STORE_ID, ); - const handleSelectFolder = main.UI.useSetPartialRowCallback( - "sessions", - sessionId, - (folderId: string) => ({ folder_id: folderId }), - [], - main.STORE_ID, - ); + const handleSelectFolder = useMoveSessionToFolder(sessionId); return ( @@ -102,11 +91,11 @@ function SearchableFolderContent({ setOpen, }: { folders: Record; - onSelectFolder: (folderId: string) => void; + onSelectFolder: (folderId: string) => Promise; setOpen?: (open: boolean) => void; }) { - const handleSelect = (folderId: string) => { - onSelectFolder(folderId); + const handleSelect = async (folderId: string) => { + await onSelectFolder(folderId); setOpen?.(false); }; @@ -131,3 +120,21 @@ function SearchableFolderContent({ ); } + +function useMoveSessionToFolder(sessionId: string) { + const setFolderId = main.UI.useSetPartialRowCallback( + "sessions", + sessionId, + (folderId: string) => ({ folder_id: folderId }), + [], + main.STORE_ID, + ); + + return useCallback( + async (targetFolderId: string) => { + await folderCommands.moveSession(sessionId, targetFolderId); + setFolderId(targetFolderId); + }, + [sessionId, setFolderId], + ); +} diff --git a/apps/desktop/src/store/tinybase/persister/folder.ts b/apps/desktop/src/store/tinybase/persister/folder.ts new file mode 100644 index 0000000000..4faf43b582 --- /dev/null +++ b/apps/desktop/src/store/tinybase/persister/folder.ts @@ -0,0 +1,176 @@ +import { exists, readDir } from "@tauri-apps/plugin-fs"; +import { createCustomPersister } from "tinybase/persisters/with-schemas"; +import type { + Content, + MergeableStore, + OptionalSchemas, +} from "tinybase/with-schemas"; + +import { + commands as notifyCommands, + events as notifyEvents, +} from "@hypr/plugin-notify"; +import { commands as path2Commands } from "@hypr/plugin-path2"; +import type { FolderStorage } from "@hypr/store"; + +import { DEFAULT_USER_ID } from "../../../utils"; +import { StoreOrMergeableStore } from "../store/shared"; +import { getParentFolderPath, type PersisterMode } from "./utils"; + +type FoldersJson = Record; + +interface ScanResult { + folders: FoldersJson; + sessionFolderMap: Map; +} + +async function getSessionsDir(): Promise { + const base = await path2Commands.base(); + return `${base}/sessions`; +} + +async function scanDirectoryRecursively( + sessionsDir: string, + currentPath: string = "", +): Promise { + const folders: FoldersJson = {}; + const sessionFolderMap = new Map(); + + const fullPath = currentPath ? `${sessionsDir}/${currentPath}` : sessionsDir; + + try { + const entries = await readDir(fullPath); + + for (const entry of entries) { + if (!entry.isDirectory) { + continue; + } + + const entryPath = currentPath + ? `${currentPath}/${entry.name}` + : entry.name; + + const hasMemoMd = await exists(`${sessionsDir}/${entryPath}/_memo.md`); + + if (hasMemoMd) { + const folderPath = currentPath === "_default" ? "" : currentPath; + sessionFolderMap.set(entry.name, folderPath); + } else { + if (entry.name !== "_default") { + folders[entryPath] = { + user_id: DEFAULT_USER_ID, + created_at: new Date().toISOString(), + name: entry.name, + parent_folder_id: getParentFolderPath(entryPath), + }; + } + + const subResult = await scanDirectoryRecursively( + sessionsDir, + entryPath, + ); + + for (const [id, folder] of Object.entries(subResult.folders)) { + folders[id] = folder; + } + for (const [sessionId, folderPath] of subResult.sessionFolderMap) { + sessionFolderMap.set(sessionId, folderPath); + } + } + } + } catch (error) { + const errorStr = String(error); + if ( + !errorStr.includes("No such file or directory") && + !errorStr.includes("ENOENT") && + !errorStr.includes("not found") + ) { + console.error("[FolderPersister] scan error:", error); + } + } + + return { folders, sessionFolderMap }; +} + +export function jsonToContent( + data: FoldersJson, +): Content { + return [{ folders: data }, {}] as unknown as Content; +} + +export function createFolderPersister( + store: MergeableStore, + config: { mode: PersisterMode } = { mode: "load-only" }, +) { + const loadFn = + config.mode === "save-only" + ? async (): Promise | undefined> => undefined + : async (): Promise | undefined> => { + try { + const sessionsDir = await getSessionsDir(); + const dirExists = await exists(sessionsDir); + + if (!dirExists) { + return jsonToContent({}); + } + + const { folders, sessionFolderMap } = + await scanDirectoryRecursively(sessionsDir); + + for (const [sessionId, folderPath] of sessionFolderMap) { + // @ts-ignore - we're setting cells on the sessions table + if (store.hasRow("sessions", sessionId)) { + // @ts-ignore + store.setCell("sessions", sessionId, "folder_id", folderPath); + } + } + + return jsonToContent(folders); + } catch (error) { + console.error("[FolderPersister] load error:", error); + return undefined; + } + }; + + const saveFn = async () => {}; + + return createCustomPersister( + store, + loadFn, + saveFn, + (listener) => setInterval(listener, 5000), + (handle) => clearInterval(handle), + (error) => console.error("[FolderPersister]:", error), + StoreOrMergeableStore, + ); +} + +interface Loadable { + load(): Promise; +} + +export async function startFolderWatcher( + persister: Loadable, +): Promise<() => void> { + const result = await notifyCommands.start(); + if (result.status === "error") { + console.error("[FolderWatcher] Failed to start:", result.error); + return () => {}; + } + + const unlisten = await notifyEvents.fileChanged.listen(async (event) => { + const path = event.payload.path; + if (path.startsWith("sessions/")) { + try { + await persister.load(); + } catch (error) { + console.error("[FolderWatcher] Failed to reload:", error); + } + } + }); + + return () => { + unlisten(); + void notifyCommands.stop(); + }; +} diff --git a/apps/desktop/src/store/tinybase/persister/local.test.ts b/apps/desktop/src/store/tinybase/persister/local.test.ts index 80e4701cae..428d2c4f99 100644 --- a/apps/desktop/src/store/tinybase/persister/local.test.ts +++ b/apps/desktop/src/store/tinybase/persister/local.test.ts @@ -89,7 +89,7 @@ describe("createNotePersister", () => { expect(commands.exportTiptapJsonToMdBatch).toHaveBeenCalledWith([ [ { type: "doc", content: [{ type: "paragraph" }] }, - "/mock/data/dir/hyprnote/sessions/session-1/My Template.md", + "/mock/data/dir/hyprnote/sessions/_default/session-1/My Template.md", ], ]); }); @@ -115,7 +115,7 @@ describe("createNotePersister", () => { expect(commands.exportTiptapJsonToMdBatch).toHaveBeenCalledWith([ [ { type: "doc", content: [{ type: "paragraph" }] }, - "/mock/data/dir/hyprnote/sessions/session-1/_summary.md", + "/mock/data/dir/hyprnote/sessions/_default/session-1/_summary.md", ], ]); }); @@ -199,7 +199,7 @@ describe("createNotePersister", () => { expect(commands.exportTiptapJsonToMdBatch).toHaveBeenCalledWith([ [ expect.any(Object), - "/mock/data/dir/hyprnote/sessions/session-1/My_________Template.md", + "/mock/data/dir/hyprnote/sessions/_default/session-1/My_________Template.md", ], ]); }); @@ -226,7 +226,7 @@ describe("createNotePersister", () => { expect(commands.exportTiptapJsonToMdBatch).toHaveBeenCalledWith([ [ expect.any(Object), - "/mock/data/dir/hyprnote/sessions/session-1/unknown-template-id.md", + "/mock/data/dir/hyprnote/sessions/_default/session-1/unknown-template-id.md", ], ]); }); @@ -292,7 +292,7 @@ describe("createNotePersister", () => { expect(commands.exportTiptapJsonToMdBatch).toHaveBeenCalledWith([ [ { type: "doc", content: [{ type: "paragraph" }] }, - "/mock/data/dir/hyprnote/sessions/session-1/_memo.md", + "/mock/data/dir/hyprnote/sessions/_default/session-1/_memo.md", ], ]); }); @@ -338,7 +338,7 @@ describe("createNotePersister", () => { await persister.save(); expect(mkdir).toHaveBeenCalledWith( - "/mock/data/dir/hyprnote/sessions/session-1", + "/mock/data/dir/hyprnote/sessions/_default/session-1", { recursive: true }, ); }); diff --git a/apps/desktop/src/store/tinybase/persister/note.test.ts b/apps/desktop/src/store/tinybase/persister/note.test.ts index 80e4701cae..428d2c4f99 100644 --- a/apps/desktop/src/store/tinybase/persister/note.test.ts +++ b/apps/desktop/src/store/tinybase/persister/note.test.ts @@ -89,7 +89,7 @@ describe("createNotePersister", () => { expect(commands.exportTiptapJsonToMdBatch).toHaveBeenCalledWith([ [ { type: "doc", content: [{ type: "paragraph" }] }, - "/mock/data/dir/hyprnote/sessions/session-1/My Template.md", + "/mock/data/dir/hyprnote/sessions/_default/session-1/My Template.md", ], ]); }); @@ -115,7 +115,7 @@ describe("createNotePersister", () => { expect(commands.exportTiptapJsonToMdBatch).toHaveBeenCalledWith([ [ { type: "doc", content: [{ type: "paragraph" }] }, - "/mock/data/dir/hyprnote/sessions/session-1/_summary.md", + "/mock/data/dir/hyprnote/sessions/_default/session-1/_summary.md", ], ]); }); @@ -199,7 +199,7 @@ describe("createNotePersister", () => { expect(commands.exportTiptapJsonToMdBatch).toHaveBeenCalledWith([ [ expect.any(Object), - "/mock/data/dir/hyprnote/sessions/session-1/My_________Template.md", + "/mock/data/dir/hyprnote/sessions/_default/session-1/My_________Template.md", ], ]); }); @@ -226,7 +226,7 @@ describe("createNotePersister", () => { expect(commands.exportTiptapJsonToMdBatch).toHaveBeenCalledWith([ [ expect.any(Object), - "/mock/data/dir/hyprnote/sessions/session-1/unknown-template-id.md", + "/mock/data/dir/hyprnote/sessions/_default/session-1/unknown-template-id.md", ], ]); }); @@ -292,7 +292,7 @@ describe("createNotePersister", () => { expect(commands.exportTiptapJsonToMdBatch).toHaveBeenCalledWith([ [ { type: "doc", content: [{ type: "paragraph" }] }, - "/mock/data/dir/hyprnote/sessions/session-1/_memo.md", + "/mock/data/dir/hyprnote/sessions/_default/session-1/_memo.md", ], ]); }); @@ -338,7 +338,7 @@ describe("createNotePersister", () => { await persister.save(); expect(mkdir).toHaveBeenCalledWith( - "/mock/data/dir/hyprnote/sessions/session-1", + "/mock/data/dir/hyprnote/sessions/_default/session-1", { recursive: true }, ); }); diff --git a/apps/desktop/src/store/tinybase/persister/note.ts b/apps/desktop/src/store/tinybase/persister/note.ts index 9bedd6ff37..57b10e6063 100644 --- a/apps/desktop/src/store/tinybase/persister/note.ts +++ b/apps/desktop/src/store/tinybase/persister/note.ts @@ -127,7 +127,13 @@ function collectEnhancedNoteBatchItems( handleSyncToSession(enhancedNote.session_id, enhancedNote.content); } - const sessionDir = getSessionDir(dataDir, enhancedNote.session_id); + const session = tables?.sessions?.[enhancedNote.session_id]; + const folderPath = session?.folder_id ?? ""; + const sessionDir = getSessionDir( + dataDir, + enhancedNote.session_id, + folderPath, + ); dirs.add(sessionDir); items.push([parsed, `${sessionDir}/${filename}`]); } @@ -152,7 +158,8 @@ function collectSessionBatchItems( continue; } - const sessionDir = getSessionDir(dataDir, session.id); + const folderPath = session.folder_id ?? ""; + const sessionDir = getSessionDir(dataDir, session.id, folderPath); dirs.add(sessionDir); items.push([parsed, `${sessionDir}/_memo.md`]); } diff --git a/apps/desktop/src/store/tinybase/persister/transcript.test.ts b/apps/desktop/src/store/tinybase/persister/transcript.test.ts index aa6eaf4883..5a0e6c7f86 100644 --- a/apps/desktop/src/store/tinybase/persister/transcript.test.ts +++ b/apps/desktop/src/store/tinybase/persister/transcript.test.ts @@ -100,7 +100,7 @@ describe("createTranscriptPersister", () => { expect(writeTextFile).toHaveBeenCalledTimes(1); expect(writeTextFile).toHaveBeenCalledWith( - "/mock/data/dir/hyprnote/sessions/session-1/_transcript.json", + "/mock/data/dir/hyprnote/sessions/_default/session-1/_transcript.json", expect.any(String), ); @@ -179,10 +179,10 @@ describe("createTranscriptPersister", () => { const paths = vi.mocked(writeTextFile).mock.calls.map((call) => call[0]); expect(paths).toContain( - "/mock/data/dir/hyprnote/sessions/session-1/_transcript.json", + "/mock/data/dir/hyprnote/sessions/_default/session-1/_transcript.json", ); expect(paths).toContain( - "/mock/data/dir/hyprnote/sessions/session-2/_transcript.json", + "/mock/data/dir/hyprnote/sessions/_default/session-2/_transcript.json", ); }); @@ -202,7 +202,7 @@ describe("createTranscriptPersister", () => { await persister.save(); expect(mkdir).toHaveBeenCalledWith( - "/mock/data/dir/hyprnote/sessions/session-1", + "/mock/data/dir/hyprnote/sessions/_default/session-1", { recursive: true }, ); }); diff --git a/apps/desktop/src/store/tinybase/persister/transcript.ts b/apps/desktop/src/store/tinybase/persister/transcript.ts index bd0f0a8cab..06b93e7e99 100644 --- a/apps/desktop/src/store/tinybase/persister/transcript.ts +++ b/apps/desktop/src/store/tinybase/persister/transcript.ts @@ -95,7 +95,9 @@ export function createTranscriptPersister( const writeOperations: Array<{ path: string; content: string }> = []; for (const [sessionId, transcripts] of transcriptsBySession) { - const sessionDir = getSessionDir(dataDir, sessionId); + const session = tables?.sessions?.[sessionId]; + const folderPath = session?.folder_id ?? ""; + const sessionDir = getSessionDir(dataDir, sessionId, folderPath); dirs.add(sessionDir); const json: TranscriptJson = { transcripts }; diff --git a/apps/desktop/src/store/tinybase/persister/utils.ts b/apps/desktop/src/store/tinybase/persister/utils.ts index ae4f2355b3..3c689ae770 100644 --- a/apps/desktop/src/store/tinybase/persister/utils.ts +++ b/apps/desktop/src/store/tinybase/persister/utils.ts @@ -18,8 +18,13 @@ export async function getDataDir(): Promise { return path2Commands.base(); } -export function getSessionDir(dataDir: string, sessionId: string): string { - return `${dataDir}/sessions/${sessionId}`; +export function getSessionDir( + dataDir: string, + sessionId: string, + folderPath: string = "", +): string { + const folder = folderPath || "_default"; + return `${dataDir}/sessions/${folder}/${sessionId}`; } export function getChatDir(dataDir: string, chatGroupId: string): string { @@ -42,6 +47,15 @@ export function sanitizeFilename(name: string): string { return name.replace(/[<>:"/\\|?*]/g, "_").trim(); } +export function getParentFolderPath(folderPath: string): string { + if (!folderPath) { + return ""; + } + const parts = folderPath.split("/"); + parts.pop(); + return parts.join("/"); +} + export function safeParseJson( value: unknown, ): Record | undefined { diff --git a/apps/desktop/src/store/tinybase/store/persisters.ts b/apps/desktop/src/store/tinybase/store/persisters.ts index a88c297047..ec51820fe0 100644 --- a/apps/desktop/src/store/tinybase/store/persisters.ts +++ b/apps/desktop/src/store/tinybase/store/persisters.ts @@ -9,6 +9,7 @@ import { type Schemas } from "@hypr/store"; import { DEFAULT_USER_ID } from "../../../utils"; import { createChatPersister } from "../persister/chat"; import { createEventPersister } from "../persister/events"; +import { createFolderPersister, startFolderWatcher } from "../persister/folder"; import { createHumanPersister } from "../persister/human"; import { createLocalPersister } from "../persister/local"; import { createNotePersister } from "../persister/note"; @@ -92,6 +93,28 @@ export function useMainPersisters( [persist], ); + const folderPersister = useCreatePersister( + store, + async (store) => { + if (!persist) { + return undefined; + } + + const persister = createFolderPersister(store as Store, { + mode: "load-only", + }); + await persister.load(); + await persister.startAutoLoad(); + + if (getCurrentWebviewWindowLabel() === "main") { + void startFolderWatcher(persister); + } + + return persister; + }, + [persist], + ); + const markdownPersister = useCreatePersister( store, async (store) => { @@ -214,6 +237,7 @@ export function useMainPersisters( const persisters = useMemo( () => localPersister && + folderPersister && markdownPersister && transcriptPersister && organizationPersister && @@ -222,6 +246,7 @@ export function useMainPersisters( chatPersister ? [ localPersister, + folderPersister, markdownPersister, transcriptPersister, organizationPersister, @@ -232,6 +257,7 @@ export function useMainPersisters( : null, [ localPersister, + folderPersister, markdownPersister, transcriptPersister, organizationPersister, @@ -245,6 +271,7 @@ export function useMainPersisters( return { localPersister, + folderPersister, markdownPersister, transcriptPersister, organizationPersister, diff --git a/plugins/folder/Cargo.toml b/plugins/folder/Cargo.toml index 037382a509..ba8f367219 100644 --- a/plugins/folder/Cargo.toml +++ b/plugins/folder/Cargo.toml @@ -12,13 +12,17 @@ tauri-plugin = { workspace = true, features = ["build"] } [dev-dependencies] specta-typescript = { workspace = true } +tempfile = "3" tokio = { workspace = true, features = ["macros"] } [dependencies] tauri = { workspace = true, features = ["test"] } +tauri-plugin-path2 = { path = "../path2" } tauri-specta = { workspace = true, features = ["derive", "typescript"] } serde = { workspace = true } specta = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } diff --git a/plugins/folder/build.rs b/plugins/folder/build.rs index 029861396b..cc22cedd81 100644 --- a/plugins/folder/build.rs +++ b/plugins/folder/build.rs @@ -1,4 +1,10 @@ -const COMMANDS: &[&str] = &["ping"]; +const COMMANDS: &[&str] = &[ + "ping", + "move_session", + "create_folder", + "rename_folder", + "delete_folder", +]; fn main() { tauri_plugin::Builder::new(COMMANDS).build(); diff --git a/plugins/folder/js/bindings.gen.ts b/plugins/folder/js/bindings.gen.ts index c3a9782078..acec2cbe4e 100644 --- a/plugins/folder/js/bindings.gen.ts +++ b/plugins/folder/js/bindings.gen.ts @@ -13,6 +13,38 @@ async ping() : Promise> { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } +}, +async moveSession(sessionId: string, targetFolderPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:folder|move_session", { sessionId, targetFolderPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async createFolder(folderPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:folder|create_folder", { folderPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async renameFolder(oldPath: string, newPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:folder|rename_folder", { oldPath, newPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} +}, +async deleteFolder(folderPath: string) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("plugin:folder|delete_folder", { folderPath }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } diff --git a/plugins/folder/permissions/autogenerated/commands/create_folder.toml b/plugins/folder/permissions/autogenerated/commands/create_folder.toml new file mode 100644 index 0000000000..2a85399943 --- /dev/null +++ b/plugins/folder/permissions/autogenerated/commands/create_folder.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-create-folder" +description = "Enables the create_folder command without any pre-configured scope." +commands.allow = ["create_folder"] + +[[permission]] +identifier = "deny-create-folder" +description = "Denies the create_folder command without any pre-configured scope." +commands.deny = ["create_folder"] diff --git a/plugins/folder/permissions/autogenerated/commands/delete_folder.toml b/plugins/folder/permissions/autogenerated/commands/delete_folder.toml new file mode 100644 index 0000000000..9e3a0504e4 --- /dev/null +++ b/plugins/folder/permissions/autogenerated/commands/delete_folder.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-delete-folder" +description = "Enables the delete_folder command without any pre-configured scope." +commands.allow = ["delete_folder"] + +[[permission]] +identifier = "deny-delete-folder" +description = "Denies the delete_folder command without any pre-configured scope." +commands.deny = ["delete_folder"] diff --git a/plugins/folder/permissions/autogenerated/commands/move_session.toml b/plugins/folder/permissions/autogenerated/commands/move_session.toml new file mode 100644 index 0000000000..690f47df03 --- /dev/null +++ b/plugins/folder/permissions/autogenerated/commands/move_session.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-move-session" +description = "Enables the move_session command without any pre-configured scope." +commands.allow = ["move_session"] + +[[permission]] +identifier = "deny-move-session" +description = "Denies the move_session command without any pre-configured scope." +commands.deny = ["move_session"] diff --git a/plugins/folder/permissions/autogenerated/commands/rename_folder.toml b/plugins/folder/permissions/autogenerated/commands/rename_folder.toml new file mode 100644 index 0000000000..9853ca0c1f --- /dev/null +++ b/plugins/folder/permissions/autogenerated/commands/rename_folder.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-rename-folder" +description = "Enables the rename_folder command without any pre-configured scope." +commands.allow = ["rename_folder"] + +[[permission]] +identifier = "deny-rename-folder" +description = "Denies the rename_folder command without any pre-configured scope." +commands.deny = ["rename_folder"] diff --git a/plugins/folder/permissions/autogenerated/reference.md b/plugins/folder/permissions/autogenerated/reference.md index 3921775504..938272ce8b 100644 --- a/plugins/folder/permissions/autogenerated/reference.md +++ b/plugins/folder/permissions/autogenerated/reference.md @@ -5,6 +5,10 @@ Default permissions for the plugin #### This default permission set includes the following: - `allow-ping` +- `allow-move-session` +- `allow-create-folder` +- `allow-rename-folder` +- `allow-delete-folder` ## Permission Table @@ -15,6 +19,84 @@ Default permissions for the plugin + + + +`folder:allow-create-folder` + + + + +Enables the create_folder command without any pre-configured scope. + + + + + + + +`folder:deny-create-folder` + + + + +Denies the create_folder command without any pre-configured scope. + + + + + + + +`folder:allow-delete-folder` + + + + +Enables the delete_folder command without any pre-configured scope. + + + + + + + +`folder:deny-delete-folder` + + + + +Denies the delete_folder command without any pre-configured scope. + + + + + + + +`folder:allow-move-session` + + + + +Enables the move_session command without any pre-configured scope. + + + + + + + +`folder:deny-move-session` + + + + +Denies the move_session command without any pre-configured scope. + + + + @@ -38,6 +120,32 @@ Enables the ping command without any pre-configured scope. Denies the ping command without any pre-configured scope. + + + + + + +`folder:allow-rename-folder` + + + + +Enables the rename_folder command without any pre-configured scope. + + + + + + + +`folder:deny-rename-folder` + + + + +Denies the rename_folder command without any pre-configured scope. + diff --git a/plugins/folder/permissions/default.toml b/plugins/folder/permissions/default.toml index cc5a76f22e..4aee22fc96 100644 --- a/plugins/folder/permissions/default.toml +++ b/plugins/folder/permissions/default.toml @@ -1,3 +1,9 @@ [default] description = "Default permissions for the plugin" -permissions = ["allow-ping"] +permissions = [ + "allow-ping", + "allow-move-session", + "allow-create-folder", + "allow-rename-folder", + "allow-delete-folder", +] diff --git a/plugins/folder/permissions/schemas/schema.json b/plugins/folder/permissions/schemas/schema.json index ac68e129e2..93b9b0e362 100644 --- a/plugins/folder/permissions/schemas/schema.json +++ b/plugins/folder/permissions/schemas/schema.json @@ -294,6 +294,42 @@ "PermissionKind": { "type": "string", "oneOf": [ + { + "description": "Enables the create_folder command without any pre-configured scope.", + "type": "string", + "const": "allow-create-folder", + "markdownDescription": "Enables the create_folder command without any pre-configured scope." + }, + { + "description": "Denies the create_folder command without any pre-configured scope.", + "type": "string", + "const": "deny-create-folder", + "markdownDescription": "Denies the create_folder command without any pre-configured scope." + }, + { + "description": "Enables the delete_folder command without any pre-configured scope.", + "type": "string", + "const": "allow-delete-folder", + "markdownDescription": "Enables the delete_folder command without any pre-configured scope." + }, + { + "description": "Denies the delete_folder command without any pre-configured scope.", + "type": "string", + "const": "deny-delete-folder", + "markdownDescription": "Denies the delete_folder command without any pre-configured scope." + }, + { + "description": "Enables the move_session command without any pre-configured scope.", + "type": "string", + "const": "allow-move-session", + "markdownDescription": "Enables the move_session command without any pre-configured scope." + }, + { + "description": "Denies the move_session command without any pre-configured scope.", + "type": "string", + "const": "deny-move-session", + "markdownDescription": "Denies the move_session command without any pre-configured scope." + }, { "description": "Enables the ping command without any pre-configured scope.", "type": "string", @@ -307,10 +343,22 @@ "markdownDescription": "Denies the ping command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-ping`", + "description": "Enables the rename_folder command without any pre-configured scope.", + "type": "string", + "const": "allow-rename-folder", + "markdownDescription": "Enables the rename_folder command without any pre-configured scope." + }, + { + "description": "Denies the rename_folder command without any pre-configured scope.", + "type": "string", + "const": "deny-rename-folder", + "markdownDescription": "Denies the rename_folder command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-ping`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-ping`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-ping`\n- `allow-move-session`\n- `allow-create-folder`\n- `allow-rename-folder`\n- `allow-delete-folder`" } ] } diff --git a/plugins/folder/src/commands.rs b/plugins/folder/src/commands.rs index 2684be50ba..1e08076d60 100644 --- a/plugins/folder/src/commands.rs +++ b/plugins/folder/src/commands.rs @@ -5,3 +5,49 @@ use crate::FolderPluginExt; pub(crate) async fn ping(app: tauri::AppHandle) -> Result { app.folder().ping().map_err(|e| e.to_string()) } + +#[tauri::command] +#[specta::specta] +pub(crate) async fn move_session( + app: tauri::AppHandle, + session_id: String, + target_folder_path: String, +) -> Result<(), String> { + app.folder() + .move_session(&session_id, &target_folder_path) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn create_folder( + app: tauri::AppHandle, + folder_path: String, +) -> Result<(), String> { + app.folder() + .create_folder(&folder_path) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn rename_folder( + app: tauri::AppHandle, + old_path: String, + new_path: String, +) -> Result<(), String> { + app.folder() + .rename_folder(&old_path, &new_path) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +#[specta::specta] +pub(crate) async fn delete_folder( + app: tauri::AppHandle, + folder_path: String, +) -> Result<(), String> { + app.folder() + .delete_folder(&folder_path) + .map_err(|e| e.to_string()) +} diff --git a/plugins/folder/src/error.rs b/plugins/folder/src/error.rs index b816bf4ccd..6b7a67d9d7 100644 --- a/plugins/folder/src/error.rs +++ b/plugins/folder/src/error.rs @@ -6,6 +6,10 @@ pub type Result = std::result::Result; pub enum Error { #[error("Unknown error")] Unknown, + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Path error: {0}")] + Path(String), } impl Serialize for Error { diff --git a/plugins/folder/src/ext.rs b/plugins/folder/src/ext.rs index 387c42f13e..4655a2bc9e 100644 --- a/plugins/folder/src/ext.rs +++ b/plugins/folder/src/ext.rs @@ -1,3 +1,114 @@ +use std::path::{Path, PathBuf}; + +use tauri_plugin_path2::Path2PluginExt; +use uuid::Uuid; + +pub fn is_uuid(name: &str) -> bool { + Uuid::try_parse(name).is_ok() +} + +pub fn find_session_dir(sessions_base: &Path, session_id: &str) -> PathBuf { + if let Some(found) = find_session_dir_recursive(sessions_base, session_id) { + return found; + } + sessions_base.join("_default").join(session_id) +} + +fn find_session_dir_recursive(dir: &Path, session_id: &str) -> Option { + let entries = std::fs::read_dir(dir).ok()?; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = path.file_name()?.to_str()?; + + if name == session_id { + return Some(path); + } + + if !is_uuid(name) { + if let Some(found) = find_session_dir_recursive(&path, session_id) { + return Some(found); + } + } + } + + None +} + +pub fn migrate_session_to_default(sessions_base: &Path, session_id: &str) { + let source = sessions_base.join(session_id); + if !source.is_dir() { + return; + } + + let default_dir = sessions_base.join("_default"); + if !default_dir.exists() { + if let Err(e) = std::fs::create_dir_all(&default_dir) { + tracing::warn!("Failed to create _default directory: {}", e); + return; + } + } + + let target = default_dir.join(session_id); + if target.exists() { + return; + } + + if let Err(e) = std::fs::rename(&source, &target) { + tracing::warn!("Failed to migrate {}: {}", session_id, e); + } else { + tracing::info!("Migrated session {} to _default", session_id); + } +} + +pub fn migrate_all_uuid_folders(sessions_base: &Path) { + let entries = match std::fs::read_dir(sessions_base) { + Ok(entries) => entries, + Err(_) => return, + }; + + let default_dir = sessions_base.join("_default"); + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(name) => name, + None => continue, + }; + + if !is_uuid(name) { + continue; + } + + if !default_dir.exists() { + if let Err(e) = std::fs::create_dir_all(&default_dir) { + tracing::warn!("Failed to create _default directory: {}", e); + return; + } + } + + let new_path = default_dir.join(name); + if new_path.exists() { + tracing::warn!("Skipping migration of {}: already exists in _default", name); + continue; + } + + if let Err(e) = std::fs::rename(&path, &new_path) { + tracing::warn!("Failed to migrate {}: {}", name, e); + } else { + tracing::info!("Migrated session {} to _default", name); + } + } +} + pub struct Folder<'a, R: tauri::Runtime, M: tauri::Manager> { manager: &'a M, _runtime: std::marker::PhantomData R>, @@ -7,6 +118,106 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Folder<'a, R, M> { pub fn ping(&self) -> Result { Ok("pong".to_string()) } + + fn sessions_dir(&self) -> Result { + let base = self + .manager + .app_handle() + .path2() + .base() + .map_err(|e| crate::Error::Path(e.to_string()))?; + Ok(base.join("sessions")) + } + + pub fn move_session( + &self, + session_id: &str, + target_folder_path: &str, + ) -> Result<(), crate::Error> { + let sessions_dir = self.sessions_dir()?; + let source = find_session_dir(&sessions_dir, session_id); + + if !source.exists() { + return Ok(()); + } + + let target_folder = if target_folder_path.is_empty() { + sessions_dir.join("_default") + } else { + sessions_dir.join(target_folder_path) + }; + let target = target_folder.join(session_id); + + if source == target { + return Ok(()); + } + + std::fs::create_dir_all(&target_folder)?; + std::fs::rename(&source, &target)?; + + tracing::info!( + "Moved session {} from {:?} to {:?}", + session_id, + source, + target + ); + + Ok(()) + } + + pub fn create_folder(&self, folder_path: &str) -> Result<(), crate::Error> { + let sessions_dir = self.sessions_dir()?; + let folder = sessions_dir.join(folder_path); + + if folder.exists() { + return Ok(()); + } + + std::fs::create_dir_all(&folder)?; + tracing::info!("Created folder: {:?}", folder); + Ok(()) + } + + pub fn rename_folder(&self, old_path: &str, new_path: &str) -> Result<(), crate::Error> { + let sessions_dir = self.sessions_dir()?; + let source = sessions_dir.join(old_path); + let target = sessions_dir.join(new_path); + + if !source.exists() { + return Err(crate::Error::Path(format!( + "Folder does not exist: {:?}", + source + ))); + } + + if target.exists() { + return Err(crate::Error::Path(format!( + "Target folder already exists: {:?}", + target + ))); + } + + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::rename(&source, &target)?; + tracing::info!("Renamed folder from {:?} to {:?}", source, target); + Ok(()) + } + + pub fn delete_folder(&self, folder_path: &str) -> Result<(), crate::Error> { + let sessions_dir = self.sessions_dir()?; + let folder = sessions_dir.join(folder_path); + + if !folder.exists() { + return Ok(()); + } + + std::fs::remove_dir_all(&folder)?; + tracing::info!("Deleted folder: {:?}", folder); + Ok(()) + } } pub trait FolderPluginExt { @@ -26,3 +237,53 @@ impl> FolderPluginExt for T { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_is_uuid() { + assert!(is_uuid("550e8400-e29b-41d4-a716-446655440000")); + assert!(is_uuid("550E8400-E29B-41D4-A716-446655440000")); + assert!(!is_uuid("_default")); + assert!(!is_uuid("work")); + assert!(!is_uuid("not-a-uuid")); + } + + #[test] + fn test_find_session_dir_in_default() { + let temp = tempfile::tempdir().unwrap(); + let sessions = temp.path().join("sessions"); + let session_id = "550e8400-e29b-41d4-a716-446655440000"; + let expected = sessions.join("_default").join(session_id); + fs::create_dir_all(&expected).unwrap(); + + let result = find_session_dir(&sessions, session_id); + assert_eq!(result, expected); + } + + #[test] + fn test_find_session_dir_in_subfolder() { + let temp = tempfile::tempdir().unwrap(); + let sessions = temp.path().join("sessions"); + let session_id = "550e8400-e29b-41d4-a716-446655440000"; + let expected = sessions.join("work").join("project").join(session_id); + fs::create_dir_all(&expected).unwrap(); + + let result = find_session_dir(&sessions, session_id); + assert_eq!(result, expected); + } + + #[test] + fn test_find_session_dir_fallback() { + let temp = tempfile::tempdir().unwrap(); + let sessions = temp.path().join("sessions"); + fs::create_dir_all(&sessions).unwrap(); + + let session_id = "550e8400-e29b-41d4-a716-446655440000"; + let result = find_session_dir(&sessions, session_id); + assert_eq!(result, sessions.join("_default").join(session_id)); + } +} diff --git a/plugins/folder/src/lib.rs b/plugins/folder/src/lib.rs index bf28902911..4373466fd1 100644 --- a/plugins/folder/src/lib.rs +++ b/plugins/folder/src/lib.rs @@ -12,6 +12,10 @@ fn make_specta_builder() -> tauri_specta::Builder { .plugin_name(PLUGIN_NAME) .commands(tauri_specta::collect_commands![ commands::ping::, + commands::move_session::, + commands::create_folder::, + commands::rename_folder::, + commands::delete_folder::, ]) .error_handling(tauri_specta::ErrorHandlingMode::Result) } diff --git a/plugins/listener/Cargo.toml b/plugins/listener/Cargo.toml index c95b12413d..8851f4f988 100644 --- a/plugins/listener/Cargo.toml +++ b/plugins/listener/Cargo.toml @@ -30,6 +30,7 @@ hypr-language = { workspace = true } hypr-llm = { workspace = true } hypr-vad-ext = { workspace = true } hypr-vad2 = { workspace = true } +tauri-plugin-folder = { workspace = true } owhisper-client = { workspace = true } owhisper-interface = { workspace = true } diff --git a/plugins/listener/src/actors/recorder.rs b/plugins/listener/src/actors/recorder.rs index 31f2f4c94b..8fe1953861 100644 --- a/plugins/listener/src/actors/recorder.rs +++ b/plugins/listener/src/actors/recorder.rs @@ -8,6 +8,7 @@ use hypr_audio_utils::{ VorbisEncodeSettings, decode_vorbis_to_wav_file, encode_wav_to_vorbis_file, mix_audio_f32, }; use ractor::{Actor, ActorName, ActorProcessingErr, ActorRef}; +use tauri_plugin_folder::find_session_dir; const FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(1000); @@ -49,7 +50,7 @@ impl Actor for RecorderActor { _myself: ActorRef, args: Self::Arguments, ) -> Result { - let dir = args.app_dir.join(&args.session_id); + let dir = find_session_dir(&args.app_dir, &args.session_id); std::fs::create_dir_all(&dir)?; let filename_base = "audio".to_string(); diff --git a/plugins/misc/Cargo.toml b/plugins/misc/Cargo.toml index f9ff3686f1..181b7cd06d 100644 --- a/plugins/misc/Cargo.toml +++ b/plugins/misc/Cargo.toml @@ -23,6 +23,7 @@ tauri-plugin-path2 = { workspace = true } hypr-audio-utils = { workspace = true } hypr-buffer = { workspace = true } hypr-host = { workspace = true } +tauri-plugin-folder = { workspace = true } tauri = { workspace = true, features = ["test"] } tauri-specta = { workspace = true, features = ["derive", "typescript"] } diff --git a/plugins/misc/src/commands.rs b/plugins/misc/src/commands.rs index 9dd8c9f74a..bf59b3c4a6 100644 --- a/plugins/misc/src/commands.rs +++ b/plugins/misc/src/commands.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use tauri_plugin_folder::find_session_dir; use tauri_plugin_opener::OpenerExt; use tauri_plugin_path2::Path2PluginExt; @@ -37,7 +38,7 @@ pub async fn audio_exist( session_id: String, ) -> Result { let base = app.path2().base().map_err(|e| e.to_string())?; - let session_dir = base.join("sessions").join(session_id); + let session_dir = find_session_dir(&base.join("sessions"), &session_id); ["audio.wav", "audio.ogg"] .iter() @@ -56,7 +57,7 @@ pub async fn audio_delete( session_id: String, ) -> Result<(), String> { let base = app.path2().base().map_err(|e| e.to_string())?; - let session_dir = base.join("sessions").join(session_id); + let session_dir = find_session_dir(&base.join("sessions"), &session_id); ["audio.wav", "audio.ogg"] .iter() @@ -92,7 +93,7 @@ fn audio_import_internal( .path2() .base() .map_err(|e| AudioImportError::PathResolver(e.to_string()))?; - let session_dir = base.join("sessions").join(session_id); + let session_dir = find_session_dir(&base.join("sessions"), session_id); std::fs::create_dir_all(&session_dir)?; @@ -122,7 +123,7 @@ pub async fn audio_path( session_id: String, ) -> Result { let base = app.path2().base().map_err(|e| e.to_string())?; - let session_dir = base.join("sessions").join(session_id); + let session_dir = find_session_dir(&base.join("sessions"), &session_id); let path = ["audio.ogg", "audio.wav"] .iter() @@ -140,7 +141,7 @@ pub async fn audio_open( session_id: String, ) -> Result<(), String> { let base = app.path2().base().map_err(|e| e.to_string())?; - let session_dir = base.join("sessions").join(session_id); + let session_dir = find_session_dir(&base.join("sessions"), &session_id); app.opener() .open_path(session_dir.to_string_lossy(), None::<&str>) @@ -156,7 +157,7 @@ pub async fn delete_session_folder( session_id: String, ) -> Result<(), String> { let base = app.path2().base().map_err(|e| e.to_string())?; - let session_dir = base.join("sessions").join(session_id); + let session_dir = find_session_dir(&base.join("sessions"), &session_id); if session_dir.exists() { std::fs::remove_dir_all(session_dir).map_err(|e| e.to_string())?; diff --git a/plugins/notify/Cargo.toml b/plugins/notify/Cargo.toml index 5edf490489..01917e5ead 100644 --- a/plugins/notify/Cargo.toml +++ b/plugins/notify/Cargo.toml @@ -15,13 +15,15 @@ specta-typescript = { workspace = true } tokio = { workspace = true, features = ["macros"] } [dependencies] -notify = "8" -notify-debouncer-full = "0.5" - tauri = { workspace = true, features = ["test"] } -tauri-plugin-path2 = { workspace = true } tauri-specta = { workspace = true, features = ["derive", "typescript"] } +tauri-plugin-folder = { workspace = true } +tauri-plugin-path2 = { workspace = true } + +notify = "8" +notify-debouncer-full = "0.5" + serde = { workspace = true } specta = { workspace = true } diff --git a/plugins/notify/src/ext.rs b/plugins/notify/src/ext.rs index 361df44b65..cf8497272b 100644 --- a/plugins/notify/src/ext.rs +++ b/plugins/notify/src/ext.rs @@ -5,7 +5,7 @@ use notify_debouncer_full::{DebouncedEvent, new_debouncer}; use tauri_plugin_path2::Path2PluginExt; use tauri_specta::Event; -use crate::{FileChanged, WatcherState}; +use crate::{FileChanged, WatcherState, migration}; const DEBOUNCE_DELAY_MS: u64 = 500; @@ -39,6 +39,9 @@ impl<'a, R: tauri::Runtime, M: tauri::Manager> Notify<'a, R, M> { .unwrap_or(path) .to_string_lossy() .to_string(); + + migration::maybe_migrate_path(&base_for_closure, &relative_path); + let _ = FileChanged { path: relative_path, } diff --git a/plugins/notify/src/lib.rs b/plugins/notify/src/lib.rs index c1b7a7f516..372bbd5991 100644 --- a/plugins/notify/src/lib.rs +++ b/plugins/notify/src/lib.rs @@ -2,6 +2,7 @@ mod commands; mod error; mod events; mod ext; +mod migration; use std::sync::Mutex; @@ -13,6 +14,7 @@ pub use commands::*; pub use error::*; pub use events::*; pub use ext::*; +pub use migration::*; const PLUGIN_NAME: &str = "notify"; @@ -38,6 +40,9 @@ pub fn init() -> tauri::plugin::TauriPlugin { .invoke_handler(specta_builder.invoke_handler()) .setup(move |app, _api| { specta_builder.mount_events(app); + + migration::migrate_uuid_folders(app); + app.manage(WatcherState { debouncer: Mutex::new(None), }); diff --git a/plugins/notify/src/migration.rs b/plugins/notify/src/migration.rs new file mode 100644 index 0000000000..9dde82dcf1 --- /dev/null +++ b/plugins/notify/src/migration.rs @@ -0,0 +1,38 @@ +use std::path::Path; + +use tauri_plugin_path2::Path2PluginExt; + +pub use tauri_plugin_folder::{is_uuid, migrate_all_uuid_folders, migrate_session_to_default}; + +pub fn migrate_uuid_folders(app: &tauri::AppHandle) { + let base = match app.path2().base() { + Ok(base) => base, + Err(e) => { + tracing::warn!("Failed to get base path for migration: {}", e); + return; + } + }; + + let sessions_dir = base.join("sessions"); + if !sessions_dir.exists() { + return; + } + + migrate_all_uuid_folders(&sessions_dir); +} + +pub fn maybe_migrate_path(base: &Path, relative_path: &str) { + let parts: Vec<&str> = relative_path.split('/').collect(); + + if parts.len() != 2 || parts[0] != "sessions" { + return; + } + + let folder_name = parts[1]; + if !is_uuid(folder_name) { + return; + } + + let sessions_dir = base.join("sessions"); + migrate_session_to_default(&sessions_dir, folder_name); +}