Phase 2
This commit is contained in:
Generated
+46
-2
@@ -9,12 +9,16 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"license": "MIT OR Apache-2.0",
|
"license": "MIT OR Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0"
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
|
"marked": "^18.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
"@tsconfig/svelte": "^5.0.0",
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-plugin-svelte": "^3.20.0",
|
"eslint-plugin-svelte": "^3.20.0",
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.7.0",
|
||||||
@@ -1383,6 +1387,15 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/plugin-dialog": {
|
||||||
|
"version": "2.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
|
||||||
|
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
|
||||||
|
"license": "MIT OR Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tsconfig/svelte": {
|
"node_modules/@tsconfig/svelte": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
|
||||||
@@ -1390,6 +1403,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/dompurify": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/trusted-types": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1408,7 +1431,7 @@
|
|||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
@@ -1944,6 +1967,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.4.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||||
|
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@@ -2575,6 +2607,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "18.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz",
|
||||||
|
"integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
|
|||||||
+5
-1
@@ -18,6 +18,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
"@tsconfig/svelte": "^5.0.0",
|
"@tsconfig/svelte": "^5.0.0",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-plugin-svelte": "^3.20.0",
|
"eslint-plugin-svelte": "^3.20.0",
|
||||||
"globals": "^17.7.0",
|
"globals": "^17.7.0",
|
||||||
@@ -31,6 +32,9 @@
|
|||||||
"vite": "^5.4.0"
|
"vite": "^5.4.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2.0.0"
|
"@tauri-apps/api": "^2.0.0",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
"dompurify": "^3.4.11",
|
||||||
|
"marked": "^18.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+84
@@ -2408,6 +2408,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.0",
|
"bitflags 2.13.0",
|
||||||
"block2",
|
"block2",
|
||||||
|
"libc",
|
||||||
"objc2",
|
"objc2",
|
||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
]
|
]
|
||||||
@@ -3109,6 +3110,30 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||||
|
dependencies = [
|
||||||
|
"block2",
|
||||||
|
"dispatch2",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"gtk-sys",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"objc2",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation",
|
||||||
|
"raw-window-handle",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -4153,6 +4178,64 @@ dependencies = [
|
|||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin"
|
||||||
|
version = "2.6.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"glob",
|
||||||
|
"plist",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri-utils",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-dialog"
|
||||||
|
version = "2.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"raw-window-handle",
|
||||||
|
"rfd",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-plugin-fs",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs"
|
||||||
|
version = "2.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"log",
|
||||||
|
"objc2-foundation",
|
||||||
|
"percent-encoding",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"toml 1.1.2+spec-1.1.0",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.11.3"
|
version = "2.11.3"
|
||||||
@@ -5138,6 +5221,7 @@ dependencies = [
|
|||||||
"sqlx",
|
"sqlx",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ rmcp = { version = "0.16", optional = true, features = ["server"] } # MCP serve
|
|||||||
# feature-gated so the CPU-only build always compiles (NFR-MNT-4).
|
# feature-gated so the CPU-only build always compiles (NFR-MNT-4).
|
||||||
hound = { version = "3", optional = true } # WAV I/O (Phase 1)
|
hound = { version = "3", optional = true } # WAV I/O (Phase 1)
|
||||||
whisper-rs = { version = "0.16", optional = true } # whisper.cpp bindings (Phase 1)
|
whisper-rs = { version = "0.16", optional = true } # whisper.cpp bindings (Phase 1)
|
||||||
|
tauri-plugin-dialog = "2" # native Save/choose-folder (Phase 2 export)
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows = { version = "0.58", features = [
|
windows = { version = "0.58", features = [
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window. App-defined commands (start_recording, etc.) are always invokable; this grants the core plugin permissions the frontend needs on top of that — window/event/app basics plus listening for recording://*, transcript://*, and the other Rust->UI events.",
|
"description": "Capability for the main window. App-defined commands (start_recording, etc.) are always invokable; this grants the core plugin permissions the frontend needs on top of that — window/event/app basics plus listening for recording://*, transcript://*, and the other Rust->UI events.",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": ["core:default"]
|
"permissions": ["core:default", "dialog:default"]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"default":{"identifier":"default","description":"Capability for the main window. App-defined commands (start_recording, etc.) are always invokable; this grants the core plugin permissions the frontend needs on top of that — window/event/app basics plus listening for recording://*, transcript://*, and the other Rust->UI events.","local":true,"windows":["main"],"permissions":["core:default"]}}
|
{"default":{"identifier":"default","description":"Capability for the main window. App-defined commands (start_recording, etc.) are always invokable; this grants the core plugin permissions the frontend needs on top of that — window/event/app basics plus listening for recording://*, transcript://*, and the other Rust->UI events.","local":true,"windows":["main"],"permissions":["core:default","dialog:default"]}}
|
||||||
@@ -2191,6 +2191,72 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "core:window:deny-unminimize",
|
"const": "core:window:deny-unminimize",
|
||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:default",
|
||||||
|
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-ask",
|
||||||
|
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-confirm",
|
||||||
|
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-message",
|
||||||
|
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-open",
|
||||||
|
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-save",
|
||||||
|
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-ask",
|
||||||
|
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-confirm",
|
||||||
|
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-message",
|
||||||
|
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-open",
|
||||||
|
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-save",
|
||||||
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2191,6 +2191,72 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "core:window:deny-unminimize",
|
"const": "core:window:deny-unminimize",
|
||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:default",
|
||||||
|
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-ask",
|
||||||
|
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-confirm",
|
||||||
|
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-message",
|
||||||
|
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-open",
|
||||||
|
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-save",
|
||||||
|
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-ask",
|
||||||
|
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-confirm",
|
||||||
|
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-message",
|
||||||
|
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-open",
|
||||||
|
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-save",
|
||||||
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -344,6 +344,66 @@ impl Resampler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a WAV file written by this app's own `WavWriter` (Float32 or Int16;
|
||||||
|
/// see `write_wav_bytes`) and return mono `f32` samples at 16kHz — the batch
|
||||||
|
/// counterpart to the live capture path's downmix/resample, used for crash
|
||||||
|
/// recovery (`Transcriber::transcribe_file`, T2.8).
|
||||||
|
///
|
||||||
|
/// A working WAV left behind by an abnormal exit never had `finalize()` patch
|
||||||
|
/// its RIFF/`data` chunk sizes, so they read back as 0 — exactly the file
|
||||||
|
/// recovery has to work from (FR-REL-1). Trusting hound's own duration/sample
|
||||||
|
/// iterator would silently see zero frames despite real PCM bytes on disk (and
|
||||||
|
/// feeding whisper.cpp zero samples hangs rather than erroring — verified).
|
||||||
|
/// So this reads the `data` payload as raw bytes directly off disk instead of
|
||||||
|
/// through hound's size-aware iterator; hound is only used to parse the
|
||||||
|
/// format/`fmt ` chunk.
|
||||||
|
pub fn read_wav_mono_16k(path: &Path) -> Result<Vec<f32>, AudioError> {
|
||||||
|
let reader =
|
||||||
|
hound::WavReader::open(path).map_err(|e| AudioError::Capture(format!("open wav: {e}")))?;
|
||||||
|
let spec = reader.spec();
|
||||||
|
let channels = spec.channels.max(1) as usize;
|
||||||
|
drop(reader);
|
||||||
|
|
||||||
|
let file = std::fs::read(path).map_err(|e| AudioError::Capture(format!("read wav: {e}")))?;
|
||||||
|
let data_marker = file
|
||||||
|
.windows(4)
|
||||||
|
.position(|w| w == b"data")
|
||||||
|
.ok_or_else(|| AudioError::Capture("wav has no data chunk".into()))?;
|
||||||
|
let payload_start = data_marker + 8; // "data" + 4-byte (possibly unfinalized) size field
|
||||||
|
let bytes = file.get(payload_start..).unwrap_or(&[]);
|
||||||
|
|
||||||
|
let mono: Vec<f32> = match (spec.sample_format, spec.bits_per_sample) {
|
||||||
|
(SampleFormat::Float, 32) => bytes
|
||||||
|
.chunks_exact(4 * channels)
|
||||||
|
.map(|frame| {
|
||||||
|
frame
|
||||||
|
.chunks_exact(4)
|
||||||
|
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
|
||||||
|
.sum::<f32>()
|
||||||
|
/ channels as f32
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
(SampleFormat::Int, 16) => bytes
|
||||||
|
.chunks_exact(2 * channels)
|
||||||
|
.map(|frame| {
|
||||||
|
frame
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|c| i16::from_le_bytes(c.try_into().unwrap()) as f32 / i16::MAX as f32)
|
||||||
|
.sum::<f32>()
|
||||||
|
/ channels as f32
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
(fmt, bits) => {
|
||||||
|
return Err(AudioError::Device(format!(
|
||||||
|
"unsupported wav format: {fmt:?} {bits}-bit"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut resampler = Resampler::new(spec.sample_rate);
|
||||||
|
Ok(resampler.process(&mono))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -381,6 +441,73 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_wav_mono_16k_reads_a_normally_finalized_file() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("wa-test-{}", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("audio.wav");
|
||||||
|
|
||||||
|
let spec = WavSpec {
|
||||||
|
channels: 1,
|
||||||
|
sample_rate: 16_000,
|
||||||
|
bits_per_sample: 32,
|
||||||
|
sample_format: SampleFormat::Float,
|
||||||
|
};
|
||||||
|
let mut writer = WavWriter::create(&path, spec).unwrap();
|
||||||
|
for i in 0..1600 {
|
||||||
|
writer.write_sample((i as f32 / 1600.0) - 0.5).unwrap();
|
||||||
|
}
|
||||||
|
writer.finalize().unwrap();
|
||||||
|
|
||||||
|
let mono = read_wav_mono_16k(&path).unwrap();
|
||||||
|
assert_eq!(mono.len(), 1600); // already 16kHz mono, no resampling needed
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_wav_mono_16k_recovers_samples_from_an_unfinalized_crash_file() {
|
||||||
|
// hound's own `WavWriter` patches the RIFF/data sizes in both
|
||||||
|
// `finalize()` and `flush()`, and its `Drop` impl finalizes too — so
|
||||||
|
// there's no way to get it to leave an unfinalized file on disk short
|
||||||
|
// of `mem::forget`-ing mid-write, which depends on `BufWriter`'s
|
||||||
|
// internal (unspecified) flush threshold. Building the crashed file
|
||||||
|
// by hand instead gives a deterministic repro of exactly what a real
|
||||||
|
// crash leaves: a valid `fmt ` chunk with the RIFF/data sizes at 0.
|
||||||
|
let dir = std::env::temp_dir().join(format!("wa-test-{}", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("audio.wav");
|
||||||
|
|
||||||
|
let samples: Vec<f32> = (0..1600).map(|i| (i as f32 / 1600.0) - 0.5).collect();
|
||||||
|
let pcm: Vec<u8> = samples.iter().flat_map(|s| s.to_le_bytes()).collect();
|
||||||
|
|
||||||
|
let mut file = Vec::new();
|
||||||
|
file.extend_from_slice(b"RIFF");
|
||||||
|
file.extend_from_slice(&0u32.to_le_bytes()); // unfinalized RIFF size
|
||||||
|
file.extend_from_slice(b"WAVE");
|
||||||
|
file.extend_from_slice(b"fmt ");
|
||||||
|
file.extend_from_slice(&16u32.to_le_bytes()); // fmt chunk size
|
||||||
|
file.extend_from_slice(&3u16.to_le_bytes()); // WAVE_FORMAT_IEEE_FLOAT
|
||||||
|
file.extend_from_slice(&1u16.to_le_bytes()); // channels
|
||||||
|
file.extend_from_slice(&16_000u32.to_le_bytes()); // sample rate
|
||||||
|
file.extend_from_slice(&(16_000 * 4u32).to_le_bytes()); // byte rate
|
||||||
|
file.extend_from_slice(&4u16.to_le_bytes()); // block align
|
||||||
|
file.extend_from_slice(&32u16.to_le_bytes()); // bits per sample
|
||||||
|
file.extend_from_slice(b"data");
|
||||||
|
file.extend_from_slice(&0u32.to_le_bytes()); // unfinalized data size
|
||||||
|
file.extend_from_slice(&pcm);
|
||||||
|
std::fs::write(&path, &file).unwrap();
|
||||||
|
|
||||||
|
let mono = read_wav_mono_16k(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
mono.len(),
|
||||||
|
1600,
|
||||||
|
"should recover real samples despite the zeroed header"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decode_mono_f32_downmixes_stereo() {
|
fn decode_mono_f32_downmixes_stereo() {
|
||||||
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
let format = WaveFormat::new(32, 32, &SampleType::Float, 48_000, 2, None);
|
||||||
|
|||||||
+238
-44
@@ -8,10 +8,14 @@
|
|||||||
use crate::audio::{AudioCapture, WasapiCapture};
|
use crate::audio::{AudioCapture, WasapiCapture};
|
||||||
use crate::error::WaResult;
|
use crate::error::WaResult;
|
||||||
use crate::models::*;
|
use crate::models::*;
|
||||||
|
use crate::notes::NotesRenderer;
|
||||||
|
use crate::paths::{meeting_dir, settings_path, wa_root, whisper_model_path};
|
||||||
|
use crate::storage::{FinalizeMeeting, Meeting, NewMeeting};
|
||||||
use crate::transcription::{run_streaming_worker, Transcriber, WhisperTranscriber};
|
use crate::transcription::{run_streaming_worker, Transcriber, WhisperTranscriber};
|
||||||
use crate::{error::WaError, AppState, RecordingSession};
|
use crate::{error::WaError, AppState, RecordingSession};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -23,30 +27,9 @@ pub struct StartRecordingArgs {
|
|||||||
pub record: bool,
|
pub record: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Phase 1 file-backed settings + storage paths ----
|
// ---- File-backed settings (consent/default-retention/storage policy) ----
|
||||||
// Full SQLite persistence is Phase 2 (`storage::Store`, still `todo!()`); Phase
|
// `settings.json` per `docs/03-data-model.md`; meeting rows/files go through
|
||||||
// 1 only needs `settings.json` (consent/default-retention, FR-REC-1/2) and the
|
// `AppState.store` (Phase 2, `storage::SqliteStore`).
|
||||||
// `meetings/<id>/` folder layout from `docs/03-data-model.md`.
|
|
||||||
|
|
||||||
fn local_appdata() -> PathBuf {
|
|
||||||
PathBuf::from(std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wa_root() -> PathBuf {
|
|
||||||
local_appdata().join("WhispAssist")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn settings_path() -> PathBuf {
|
|
||||||
wa_root().join("settings.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn meetings_dir() -> PathBuf {
|
|
||||||
wa_root().join("meetings")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn whisper_model_path() -> PathBuf {
|
|
||||||
wa_root().join("models").join("ggml-base.en-q5_1.bin")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_settings() -> Settings {
|
fn default_settings() -> Settings {
|
||||||
Settings {
|
Settings {
|
||||||
@@ -60,10 +43,12 @@ fn default_settings() -> Settings {
|
|||||||
default_record: false,
|
default_record: false,
|
||||||
consent_acknowledged: false,
|
consent_acknowledged: false,
|
||||||
sync_enabled: false,
|
sync_enabled: false,
|
||||||
|
retention_max_age_days: None,
|
||||||
|
retention_max_size_gb: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_settings() -> Settings {
|
pub(crate) fn load_settings() -> Settings {
|
||||||
std::fs::read_to_string(settings_path())
|
std::fs::read_to_string(settings_path())
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| serde_json::from_str(&s).ok())
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
@@ -114,10 +99,17 @@ pub async fn start_recording(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let meeting_id = uuid::Uuid::new_v4().to_string();
|
let meeting_id = state
|
||||||
let meeting_dir = meetings_dir().join(&meeting_id);
|
.store
|
||||||
std::fs::create_dir_all(&meeting_dir).map_err(|e| WaError::new("storage", e.to_string()))?;
|
.create_meeting(NewMeeting {
|
||||||
let wav_path = meeting_dir.join("audio.wav");
|
title: args
|
||||||
|
.meeting_title
|
||||||
|
.unwrap_or_else(|| "Untitled meeting".to_string()),
|
||||||
|
calendar_event_id: args.calendar_event_id,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||||
|
let wav_path = meeting_dir(&meeting_id).join("audio.wav");
|
||||||
|
|
||||||
// Bounded so a slow/stalled transcription worker can never back up the capture thread.
|
// Bounded so a slow/stalled transcription worker can never back up the capture thread.
|
||||||
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
|
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
|
||||||
@@ -125,6 +117,8 @@ pub async fn start_recording(
|
|||||||
.start(&wav_path, frame_tx)
|
.start(&wav_path, frame_tx)
|
||||||
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
||||||
|
|
||||||
|
let segments: Arc<StdMutex<Vec<TranscriptSegment>>> = Arc::new(StdMutex::new(Vec::new()));
|
||||||
|
let segments_for_worker = segments.clone();
|
||||||
let app_for_worker = app.clone();
|
let app_for_worker = app.clone();
|
||||||
let meeting_id_for_worker = meeting_id.clone();
|
let meeting_id_for_worker = meeting_id.clone();
|
||||||
let transcription_worker = std::thread::Builder::new()
|
let transcription_worker = std::thread::Builder::new()
|
||||||
@@ -132,6 +126,9 @@ pub async fn start_recording(
|
|||||||
.spawn(
|
.spawn(
|
||||||
move || match WhisperTranscriber::load(&model_path, BackendId::Cpu) {
|
move || match WhisperTranscriber::load(&model_path, BackendId::Cpu) {
|
||||||
Ok(transcriber) => run_streaming_worker(&transcriber, frame_rx, |segment| {
|
Ok(transcriber) => run_streaming_worker(&transcriber, frame_rx, |segment| {
|
||||||
|
if let Ok(mut buf) = segments_for_worker.lock() {
|
||||||
|
buf.push(segment.clone());
|
||||||
|
}
|
||||||
let _ = app_for_worker.emit(
|
let _ = app_for_worker.emit(
|
||||||
"transcript://segment",
|
"transcript://segment",
|
||||||
serde_json::json!({ "meetingId": meeting_id_for_worker, "segment": segment }),
|
serde_json::json!({ "meetingId": meeting_id_for_worker, "segment": segment }),
|
||||||
@@ -149,6 +146,7 @@ pub async fn start_recording(
|
|||||||
wav_path,
|
wav_path,
|
||||||
started_at: std::time::Instant::now(),
|
started_at: std::time::Instant::now(),
|
||||||
transcription_worker,
|
transcription_worker,
|
||||||
|
segments,
|
||||||
});
|
});
|
||||||
drop(guard);
|
drop(guard);
|
||||||
|
|
||||||
@@ -193,13 +191,53 @@ pub async fn stop_recording(
|
|||||||
// guarantee it has fully drained the audio before we act on retention.
|
// guarantee it has fully drained the audio before we act on retention.
|
||||||
let _ = session.transcription_worker.join();
|
let _ = session.transcription_worker.join();
|
||||||
|
|
||||||
|
let segments = session
|
||||||
|
.segments
|
||||||
|
.lock()
|
||||||
|
.map(|g| g.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let segment_count = segments.len();
|
||||||
|
// No diarization until Phase 4 — every segment is provisionally "S1".
|
||||||
|
let speakers = vec![SpeakerInfo {
|
||||||
|
label: "S1".to_string(),
|
||||||
|
display_name: None,
|
||||||
|
participant_id: None,
|
||||||
|
}];
|
||||||
|
let model_used = whisper_model_path()
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
|
// T2.10: persist transcript.json (via finalize_meeting) and notes.md
|
||||||
|
// *before* touching the working WAV, so a crash here still leaves a
|
||||||
|
// recoverable, regenerable meeting.
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.finalize_meeting(
|
||||||
|
&meeting_id,
|
||||||
|
FinalizeMeeting {
|
||||||
|
segments: segments.clone(),
|
||||||
|
speakers: speakers.clone(),
|
||||||
|
duration_secs: (summary.duration_ms / 1000) as i64,
|
||||||
|
recorded: session.retention,
|
||||||
|
language: None,
|
||||||
|
backend_used: Some(BackendId::Cpu.as_str().to_string()),
|
||||||
|
model_used,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||||
|
|
||||||
|
let notes_md = crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None);
|
||||||
|
let _ = std::fs::write(meeting_dir(&meeting_id).join("notes.md"), notes_md);
|
||||||
|
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"transcript://finalized",
|
"transcript://finalized",
|
||||||
serde_json::json!({ "meetingId": meeting_id, "segmentCount": serde_json::Value::Null }),
|
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segment_count }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ADR-0009: delete the working WAV only after the transcript worker has
|
// ADR-0009: delete the working WAV only after the transcript is finalized
|
||||||
// finished reading it, and only when retention is off.
|
// above, and only when retention is off.
|
||||||
if !session.retention {
|
if !session.retention {
|
||||||
let _ = std::fs::remove_file(&session.wav_path);
|
let _ = std::fs::remove_file(&session.wav_path);
|
||||||
}
|
}
|
||||||
@@ -299,35 +337,191 @@ pub async fn hardware_status() -> WaResult<Vec<BackendInfo>> {
|
|||||||
todo!("Phase 3 — hardware_status")
|
todo!("Phase 3 — hardware_status")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Re-run transcription from a `recovering` meeting's working `audio.wav`
|
||||||
|
/// (T2.8, FR-REL-1). CPU-bound, so it runs on a blocking task rather than
|
||||||
|
/// tying up an async worker.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn resume_transcription(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
meeting_id: MeetingId,
|
||||||
|
) -> WaResult<()> {
|
||||||
|
let model_path = whisper_model_path();
|
||||||
|
if !model_path.exists() {
|
||||||
|
return Err(WaError::new(
|
||||||
|
"transcription",
|
||||||
|
format!(
|
||||||
|
"whisper model not found at {}; run `npm run download-models` first",
|
||||||
|
model_path.display()
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let wav_path = meeting_dir(&meeting_id).join("audio.wav");
|
||||||
|
if !wav_path.exists() {
|
||||||
|
return Err(WaError::new(
|
||||||
|
"recovery",
|
||||||
|
"no working audio.wav to recover from",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: deliberately doesn't emit `recording://state` — that event drives
|
||||||
|
// the main header's single live-session Record/Stop toggle, and reusing
|
||||||
|
// it here would make the header show a phantom "Stop" button for a
|
||||||
|
// recovery run that isn't a `RecordingSession` at all. The "recovering"
|
||||||
|
// badge in the meetings list is this operation's own progress signal.
|
||||||
|
|
||||||
|
let segments = tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
WhisperTranscriber::load(&model_path, BackendId::Cpu)
|
||||||
|
.and_then(|transcriber| transcriber.transcribe_file(&wav_path))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| WaError::new("transcription", e.to_string()))?
|
||||||
|
.map_err(|e| WaError::new("transcription", e.to_string()))?;
|
||||||
|
|
||||||
|
let speakers = vec![SpeakerInfo {
|
||||||
|
label: "S1".to_string(),
|
||||||
|
display_name: None,
|
||||||
|
participant_id: None,
|
||||||
|
}];
|
||||||
|
let duration_secs = segments
|
||||||
|
.last()
|
||||||
|
.map(|s| (s.end_ms / 1000) as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let model_used = whisper_model_path()
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.finalize_meeting(
|
||||||
|
&meeting_id,
|
||||||
|
FinalizeMeeting {
|
||||||
|
segments: segments.clone(),
|
||||||
|
speakers: speakers.clone(),
|
||||||
|
duration_secs,
|
||||||
|
// The working WAV surviving a crash is the only signal we have
|
||||||
|
// left about intent; keep it rather than silently discard it.
|
||||||
|
recorded: true,
|
||||||
|
language: None,
|
||||||
|
backend_used: Some(BackendId::Cpu.as_str().to_string()),
|
||||||
|
model_used,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||||
|
|
||||||
|
let notes_md = crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None);
|
||||||
|
let _ = std::fs::write(meeting_dir(&meeting_id).join("notes.md"), notes_md);
|
||||||
|
|
||||||
|
let _ = app.emit(
|
||||||
|
"transcript://finalized",
|
||||||
|
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }),
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Meetings / storage (Phase 2) ----
|
// ---- Meetings / storage (Phase 2) ----
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_meetings(_query: Option<String>) -> WaResult<Vec<MeetingListItem>> {
|
pub async fn list_meetings(
|
||||||
todo!("Phase 2 — list_meetings")
|
state: State<'_, AppState>,
|
||||||
|
query: Option<String>,
|
||||||
|
) -> WaResult<Vec<MeetingListItem>> {
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.list_meetings(query)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_meeting(_meeting_id: MeetingId) -> WaResult<MeetingListItem> {
|
pub async fn get_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult<Meeting> {
|
||||||
todo!("Phase 2 — get_meeting (returns full Meeting incl. transcript)")
|
state
|
||||||
|
.store
|
||||||
|
.get_meeting(&meeting_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_meeting(_meeting_id: MeetingId) -> WaResult<()> {
|
pub async fn delete_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult<()> {
|
||||||
todo!("Phase 2 — delete_meeting")
|
let guard = state.session.lock().await;
|
||||||
|
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
|
||||||
|
return Err(WaError::new(
|
||||||
|
"recording",
|
||||||
|
"cannot delete a meeting that is currently recording",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
drop(guard);
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.delete_meeting(&meeting_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_notes(_meeting_id: MeetingId, _markdown: String) -> WaResult<()> {
|
pub async fn update_notes(
|
||||||
todo!("Phase 2 — update_notes")
|
state: State<'_, AppState>,
|
||||||
|
meeting_id: MeetingId,
|
||||||
|
markdown: String,
|
||||||
|
) -> WaResult<()> {
|
||||||
|
state
|
||||||
|
.store
|
||||||
|
.update_notes(&meeting_id, &markdown)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WaError::new("storage", e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `format` ∈ `md | bundle` (PDF/Word stay Phase 8). `dest` is a file path for
|
||||||
|
/// `md`, a destination folder for `bundle` (the frontend gets it from a native
|
||||||
|
/// Save/choose-folder dialog).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn export_meeting(
|
pub async fn export_meeting(
|
||||||
_meeting_id: MeetingId,
|
state: State<'_, AppState>,
|
||||||
_dest: String,
|
meeting_id: MeetingId,
|
||||||
_format: String,
|
dest: String,
|
||||||
|
format: String,
|
||||||
) -> WaResult<String> {
|
) -> WaResult<String> {
|
||||||
todo!("Phase 2/8 — export_meeting (md|pdf|docx|bundle)")
|
let meeting = state
|
||||||
|
.store
|
||||||
|
.get_meeting(&meeting_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||||
|
let dest_path = PathBuf::from(&dest);
|
||||||
|
match format.as_str() {
|
||||||
|
"md" => {
|
||||||
|
crate::notes::MarkdownNotes
|
||||||
|
.export(
|
||||||
|
&meeting.notes_markdown,
|
||||||
|
&dest_path,
|
||||||
|
crate::notes::ExportFormat::Md,
|
||||||
|
)
|
||||||
|
.map_err(|e| WaError::new("export", e.to_string()))?;
|
||||||
|
}
|
||||||
|
"bundle" => {
|
||||||
|
std::fs::create_dir_all(&dest_path)
|
||||||
|
.map_err(|e| WaError::new("export", e.to_string()))?;
|
||||||
|
let source_dir = meeting_dir(&meeting_id);
|
||||||
|
for name in ["audio.wav", "transcript.json"] {
|
||||||
|
let src = source_dir.join(name);
|
||||||
|
if src.exists() {
|
||||||
|
std::fs::copy(&src, dest_path.join(name))
|
||||||
|
.map_err(|e| WaError::new("export", e.to_string()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::fs::write(dest_path.join("notes.md"), &meeting.notes_markdown)
|
||||||
|
.map_err(|e| WaError::new("export", e.to_string()))?;
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(WaError::new(
|
||||||
|
"export",
|
||||||
|
format!("unsupported export format: {other}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- LLM (Phase 5) ----
|
// ---- LLM (Phase 5) ----
|
||||||
|
|||||||
+42
-6
@@ -15,11 +15,13 @@ pub mod llm;
|
|||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod notes;
|
pub mod notes;
|
||||||
|
pub mod paths;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
use std::thread::JoinHandle;
|
use std::thread::JoinHandle;
|
||||||
use tauri::tray::TrayIcon;
|
use tauri::tray::TrayIcon;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
@@ -27,12 +29,11 @@ use tokio::sync::Mutex;
|
|||||||
|
|
||||||
/// Shared application state handed to every Tauri command via `tauri::State`.
|
/// Shared application state handed to every Tauri command via `tauri::State`.
|
||||||
///
|
///
|
||||||
/// Phase 1 only needs the in-flight recording session. `hardware`/`store`/
|
/// `hardware`/`llm`/`sync`/`mcp` service handles join this struct once their
|
||||||
/// `llm`/`sync`/`mcp` service handles join this struct once their concrete
|
/// concrete implementations exist (Phase 3/5/9/10) — several don't have one to
|
||||||
/// implementations exist (Phase 2/3/5/9/10) — wiring them in ahead of that
|
/// construct yet, so wiring them in early would just be unused plumbing.
|
||||||
/// isn't possible yet (several don't have a concrete impl to construct) and
|
|
||||||
/// would just be unused plumbing until then.
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
|
pub store: Arc<dyn storage::Store>,
|
||||||
/// The single in-flight recording, if any. Guarded so start/stop/pause are atomic.
|
/// The single in-flight recording, if any. Guarded so start/stop/pause are atomic.
|
||||||
pub session: Mutex<Option<RecordingSession>>,
|
pub session: Mutex<Option<RecordingSession>>,
|
||||||
}
|
}
|
||||||
@@ -46,6 +47,10 @@ pub struct RecordingSession {
|
|||||||
pub wav_path: PathBuf,
|
pub wav_path: PathBuf,
|
||||||
pub started_at: std::time::Instant,
|
pub started_at: std::time::Instant,
|
||||||
pub transcription_worker: JoinHandle<()>,
|
pub transcription_worker: JoinHandle<()>,
|
||||||
|
/// Segments produced so far, appended by the same closure that emits
|
||||||
|
/// `transcript://segment` — lets `stop_recording` persist the full
|
||||||
|
/// transcript without a second round-trip through the event channel.
|
||||||
|
pub segments: Arc<StdMutex<Vec<models::TranscriptSegment>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wraps the tray icon so it can be looked up from commands to update its
|
/// Wraps the tray icon so it can be looked up from commands to update its
|
||||||
@@ -56,17 +61,47 @@ pub struct TrayHandle(pub TrayIcon);
|
|||||||
pub fn run() {
|
pub fn run() {
|
||||||
tracing_subscriber::fmt().with_env_filter("info").init();
|
tracing_subscriber::fmt().with_env_filter("info").init();
|
||||||
|
|
||||||
|
let store: Arc<dyn storage::Store> = Arc::new(
|
||||||
|
tauri::async_runtime::block_on(storage::SqliteStore::connect())
|
||||||
|
.expect("failed to initialize storage (wa.db)"),
|
||||||
|
);
|
||||||
|
let store_for_setup = store.clone();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
|
store,
|
||||||
session: Mutex::new(None),
|
session: Mutex::new(None),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(move |app| {
|
||||||
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
|
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/tray.png"))?;
|
||||||
let tray = tauri::tray::TrayIconBuilder::new()
|
let tray = tauri::tray::TrayIconBuilder::new()
|
||||||
.icon(icon)
|
.icon(icon)
|
||||||
.tooltip("WhispAssist — idle")
|
.tooltip("WhispAssist — idle")
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
app.manage(TrayHandle(tray));
|
app.manage(TrayHandle(tray));
|
||||||
|
|
||||||
|
// Startup recovery + retention pass (FR-REL-1, FR-STORE-2). Spawned so
|
||||||
|
// it never blocks the window from showing (NFR-PERF-4); nothing here
|
||||||
|
// repeats on a timer (NFR-RES-1).
|
||||||
|
let store = store_for_setup;
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
match store.recover_scan().await {
|
||||||
|
Ok(recovered) if !recovered.is_empty() => {
|
||||||
|
tracing::info!("recovered {} interrupted meeting(s)", recovered.len());
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => tracing::error!("startup recovery scan failed: {e}"),
|
||||||
|
}
|
||||||
|
let settings = commands::load_settings();
|
||||||
|
let policy = storage::Retention {
|
||||||
|
max_age_days: settings.retention_max_age_days,
|
||||||
|
max_size_gb: settings.retention_max_size_gb,
|
||||||
|
};
|
||||||
|
if let Err(e) = store.enforce_retention(policy).await {
|
||||||
|
tracing::error!("startup retention enforcement failed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@@ -76,6 +111,7 @@ pub fn run() {
|
|||||||
commands::resume_recording,
|
commands::resume_recording,
|
||||||
commands::set_recording_retention,
|
commands::set_recording_retention,
|
||||||
commands::acknowledge_recording_consent,
|
commands::acknowledge_recording_consent,
|
||||||
|
commands::resume_transcription,
|
||||||
commands::hardware_status,
|
commands::hardware_status,
|
||||||
commands::list_meetings,
|
commands::list_meetings,
|
||||||
commands::get_meeting,
|
commands::get_meeting,
|
||||||
|
|||||||
+41
-1
@@ -15,6 +15,18 @@ pub enum BackendId {
|
|||||||
Cpu,
|
Cpu,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl BackendId {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
BackendId::Npu => "npu",
|
||||||
|
BackendId::Nvidia => "nvidia",
|
||||||
|
BackendId::Amd => "amd",
|
||||||
|
BackendId::Intel => "intel",
|
||||||
|
BackendId::Cpu => "cpu",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct BackendInfo {
|
pub struct BackendInfo {
|
||||||
pub id: BackendId,
|
pub id: BackendId,
|
||||||
@@ -25,7 +37,7 @@ pub struct BackendInfo {
|
|||||||
pub vram_mb: Option<u32>,
|
pub vram_mb: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum MeetingStatus {
|
pub enum MeetingStatus {
|
||||||
Recording,
|
Recording,
|
||||||
@@ -35,6 +47,31 @@ pub enum MeetingStatus {
|
|||||||
Error,
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl MeetingStatus {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
MeetingStatus::Recording => "recording",
|
||||||
|
MeetingStatus::Transcribing => "transcribing",
|
||||||
|
MeetingStatus::Ready => "ready",
|
||||||
|
MeetingStatus::Recovering => "recovering",
|
||||||
|
MeetingStatus::Error => "error",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a `meetings.status` DB value. Unrecognized values map to `Error`
|
||||||
|
/// rather than panicking — a stored value should always be one written by
|
||||||
|
/// this app, but a row is not worth crashing the app over.
|
||||||
|
pub fn parse(s: &str) -> Self {
|
||||||
|
match s {
|
||||||
|
"recording" => MeetingStatus::Recording,
|
||||||
|
"transcribing" => MeetingStatus::Transcribing,
|
||||||
|
"ready" => MeetingStatus::Ready,
|
||||||
|
"recovering" => MeetingStatus::Recovering,
|
||||||
|
_ => MeetingStatus::Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TranscriptSegment {
|
pub struct TranscriptSegment {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
@@ -112,6 +149,9 @@ pub struct Settings {
|
|||||||
pub consent_acknowledged: bool,
|
pub consent_acknowledged: bool,
|
||||||
// Sync master switch (ADR-0010). Default OFF. Target rows live in the DB; secrets in OS keychain.
|
// Sync master switch (ADR-0010). Default OFF. Target rows live in the DB; secrets in OS keychain.
|
||||||
pub sync_enabled: bool,
|
pub sync_enabled: bool,
|
||||||
|
// Storage retention policy (FR-STORE-2). None = no cap on that dimension.
|
||||||
|
pub retention_max_age_days: Option<u32>,
|
||||||
|
pub retention_max_size_gb: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Sync (ADR-0010) ----
|
// ---- Sync (ADR-0010) ----
|
||||||
|
|||||||
+121
-10
@@ -42,20 +42,131 @@ pub struct MarkdownNotes;
|
|||||||
impl NotesRenderer for MarkdownNotes {
|
impl NotesRenderer for MarkdownNotes {
|
||||||
fn to_markdown(
|
fn to_markdown(
|
||||||
&self,
|
&self,
|
||||||
_segments: &[TranscriptSegment],
|
segments: &[TranscriptSegment],
|
||||||
_speakers: &[SpeakerInfo],
|
speakers: &[SpeakerInfo],
|
||||||
_summary_md: Option<&str>,
|
summary_md: Option<&str>,
|
||||||
) -> String {
|
) -> String {
|
||||||
// T2.4: resolve speaker names, group dialogue, prepend summary section.
|
let mut out = String::new();
|
||||||
todo!("Phase 2 — assemble Markdown")
|
if let Some(summary) = summary_md {
|
||||||
|
let summary = summary.trim();
|
||||||
|
if !summary.is_empty() {
|
||||||
|
out.push_str(summary);
|
||||||
|
out.push_str("\n\n---\n\n");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let name_for = |label: &str| -> String {
|
||||||
|
speakers
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.label == label)
|
||||||
|
.and_then(|s| s.display_name.clone())
|
||||||
|
.unwrap_or_else(|| label.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group consecutive segments from the same speaker into one paragraph
|
||||||
|
// (matters once Phase 4 diarization produces more than one speaker).
|
||||||
|
let mut current_speaker: Option<&str> = None;
|
||||||
|
let mut buffer = String::new();
|
||||||
|
for seg in segments {
|
||||||
|
let text = seg.text.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if current_speaker != Some(seg.speaker.as_str()) {
|
||||||
|
if let Some(speaker) = current_speaker {
|
||||||
|
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
|
||||||
|
}
|
||||||
|
current_speaker = Some(seg.speaker.as_str());
|
||||||
|
buffer.clear();
|
||||||
|
}
|
||||||
|
if !buffer.is_empty() {
|
||||||
|
buffer.push(' ');
|
||||||
|
}
|
||||||
|
buffer.push_str(text);
|
||||||
|
}
|
||||||
|
if let Some(speaker) = current_speaker {
|
||||||
|
out.push_str(&format!("**{}:** {}\n\n", name_for(speaker), buffer.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
out.trim_end().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn export(
|
fn export(
|
||||||
&self,
|
&self,
|
||||||
_markdown: &str,
|
markdown: &str,
|
||||||
_dest: &Path,
|
dest: &Path,
|
||||||
_fmt: ExportFormat,
|
fmt: ExportFormat,
|
||||||
) -> Result<PathBuf, NotesError> {
|
) -> Result<PathBuf, NotesError> {
|
||||||
// T2.6 (.md/bundle); T8.4 (pdf/docx via local conversion).
|
match fmt {
|
||||||
todo!("Phase 2/8 — export notes")
|
ExportFormat::Md => {
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
std::fs::write(dest, markdown)?;
|
||||||
|
Ok(dest.to_path_buf())
|
||||||
|
}
|
||||||
|
// Bundling pulls in audio.wav/transcript.json alongside notes.md —
|
||||||
|
// that's meeting-folder orchestration, not Markdown rendering, so
|
||||||
|
// `commands::export_meeting` handles it directly rather than here.
|
||||||
|
ExportFormat::Bundle => Err(NotesError::Export(
|
||||||
|
"bundle export is assembled in commands::export_meeting, not NotesRenderer".into(),
|
||||||
|
)),
|
||||||
|
ExportFormat::Pdf | ExportFormat::Docx => Err(NotesError::Export(
|
||||||
|
"PDF/Word export lands in Phase 8".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn seg(id: u64, speaker: &str, text: &str) -> TranscriptSegment {
|
||||||
|
TranscriptSegment {
|
||||||
|
id,
|
||||||
|
start_ms: id * 1000,
|
||||||
|
end_ms: id * 1000 + 900,
|
||||||
|
speaker: speaker.to_string(),
|
||||||
|
text: text.to_string(),
|
||||||
|
confidence: Some(0.9),
|
||||||
|
interim: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn groups_consecutive_same_speaker_segments_into_one_paragraph() {
|
||||||
|
let segments = vec![
|
||||||
|
seg(0, "S1", "Hello"),
|
||||||
|
seg(1, "S1", "world."),
|
||||||
|
seg(2, "S2", "Hi there."),
|
||||||
|
];
|
||||||
|
let md = MarkdownNotes.to_markdown(&segments, &[], None);
|
||||||
|
assert_eq!(md, "**S1:** Hello world.\n\n**S2:** Hi there.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_display_name_and_falls_back_to_label() {
|
||||||
|
let segments = vec![seg(0, "S1", "Hi")];
|
||||||
|
let speakers = [SpeakerInfo {
|
||||||
|
label: "S1".to_string(),
|
||||||
|
display_name: Some("Alex".to_string()),
|
||||||
|
participant_id: None,
|
||||||
|
}];
|
||||||
|
assert_eq!(
|
||||||
|
MarkdownNotes.to_markdown(&segments, &speakers, None),
|
||||||
|
"**Alex:** Hi"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
MarkdownNotes.to_markdown(&segments, &[], None),
|
||||||
|
"**S1:** Hi"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skips_blank_segments_and_prepends_summary() {
|
||||||
|
let segments = vec![seg(0, "S1", " "), seg(1, "S1", "Real text.")];
|
||||||
|
let md = MarkdownNotes.to_markdown(&segments, &[], Some("## Summary\nDone."));
|
||||||
|
assert_eq!(md, "## Summary\nDone.\n\n---\n\n**S1:** Real text.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//! Shared on-disk layout helpers (`docs/03-data-model.md`). Used by `storage`,
|
||||||
|
//! `commands`, and `lib` so the root/meetings/models/settings paths are computed
|
||||||
|
//! exactly one way.
|
||||||
|
|
||||||
|
use crate::models::MeetingId;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
pub fn local_appdata() -> PathBuf {
|
||||||
|
PathBuf::from(std::env::var("LOCALAPPDATA").unwrap_or_else(|_| ".".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wa_root() -> PathBuf {
|
||||||
|
local_appdata().join("WhispAssist")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn db_path() -> PathBuf {
|
||||||
|
wa_root().join("wa.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn settings_path() -> PathBuf {
|
||||||
|
wa_root().join("settings.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn meetings_dir() -> PathBuf {
|
||||||
|
wa_root().join("meetings")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn meeting_dir(id: &MeetingId) -> PathBuf {
|
||||||
|
meetings_dir().join(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn whisper_model_path() -> PathBuf {
|
||||||
|
wa_root().join("models").join("ggml-base.en-q5_1.bin")
|
||||||
|
}
|
||||||
+357
-18
@@ -4,8 +4,14 @@
|
|||||||
//! Invariant: audio is the source of truth. Derived artifacts (transcript/notes/
|
//! Invariant: audio is the source of truth. Derived artifacts (transcript/notes/
|
||||||
//! summary) are regenerable; retention never touches an in-progress meeting.
|
//! summary) are regenerable; retention never touches an in-progress meeting.
|
||||||
|
|
||||||
use crate::models::{MeetingId, MeetingListItem};
|
use crate::models::{MeetingId, MeetingListItem, MeetingStatus, SpeakerInfo, TranscriptSegment};
|
||||||
|
use crate::paths;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||||
|
use sqlx::Row;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum StoreError {
|
pub enum StoreError {
|
||||||
@@ -17,11 +23,49 @@ pub enum StoreError {
|
|||||||
NotFound(String),
|
NotFound(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for StoreError {
|
||||||
|
fn from(e: sqlx::Error) -> Self {
|
||||||
|
StoreError::Db(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct NewMeeting {
|
pub struct NewMeeting {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub calendar_event_id: Option<String>,
|
pub calendar_event_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What actually happened during the session — richer than the doc's bare
|
||||||
|
/// `&MeetingId` because `finalize_meeting` has to persist what was captured.
|
||||||
|
pub struct FinalizeMeeting {
|
||||||
|
pub segments: Vec<TranscriptSegment>,
|
||||||
|
pub speakers: Vec<SpeakerInfo>,
|
||||||
|
pub duration_secs: i64,
|
||||||
|
pub recorded: bool,
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub backend_used: Option<String>,
|
||||||
|
pub model_used: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full meeting detail: DB row + transcript + speakers + notes (`get_meeting`'s
|
||||||
|
/// documented contract — `docs/04-api-contracts.md` — which the old
|
||||||
|
/// `MeetingListItem`-only stub couldn't represent).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Meeting {
|
||||||
|
pub id: MeetingId,
|
||||||
|
pub title: String,
|
||||||
|
pub started_at: i64,
|
||||||
|
pub ended_at: Option<i64>,
|
||||||
|
pub duration_secs: Option<i64>,
|
||||||
|
pub status: MeetingStatus,
|
||||||
|
pub recorded: bool,
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub backend_used: Option<String>,
|
||||||
|
pub model_used: Option<String>,
|
||||||
|
pub segments: Vec<TranscriptSegment>,
|
||||||
|
pub speakers: Vec<SpeakerInfo>,
|
||||||
|
pub notes_markdown: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Retention {
|
pub struct Retention {
|
||||||
pub max_age_days: Option<u32>,
|
pub max_age_days: Option<u32>,
|
||||||
pub max_size_gb: Option<u32>,
|
pub max_size_gb: Option<u32>,
|
||||||
@@ -30,51 +74,346 @@ pub struct Retention {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Store: Send + Sync {
|
pub trait Store: Send + Sync {
|
||||||
async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError>;
|
async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError>;
|
||||||
async fn finalize_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
|
async fn finalize_meeting(&self, id: &MeetingId, s: FinalizeMeeting) -> Result<(), StoreError>;
|
||||||
async fn list_meetings(
|
async fn list_meetings(
|
||||||
&self,
|
&self,
|
||||||
query: Option<String>,
|
query: Option<String>,
|
||||||
) -> Result<Vec<MeetingListItem>, StoreError>;
|
) -> Result<Vec<MeetingListItem>, StoreError>;
|
||||||
|
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError>;
|
||||||
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
|
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
|
||||||
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError>;
|
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError>;
|
||||||
/// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1).
|
/// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1).
|
||||||
async fn search(&self, query: &str) -> Result<Vec<MeetingListItem>, StoreError>;
|
async fn search(&self, query: &str) -> Result<Vec<MeetingListItem>, StoreError>;
|
||||||
/// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1).
|
/// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1).
|
||||||
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError>;
|
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError>;
|
||||||
/// Enforce retention; returns count removed. Skips in-progress meetings.
|
/// Enforce retention; returns count removed. Skips in-progress meetings
|
||||||
|
/// (anything whose `status` isn't a terminal `ready`/`error`).
|
||||||
async fn enforce_retention(&self, policy: Retention) -> Result<u32, StoreError>;
|
async fn enforce_retention(&self, policy: Retention) -> Result<u32, StoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SQLite-backed store. Migrations live in `migrations/` (sqlx::migrate!).
|
/// SQLite-backed store. Migrations live in `migrations/` (`sqlx::migrate!`).
|
||||||
pub struct SqliteStore;
|
pub struct SqliteStore {
|
||||||
|
pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteStore {
|
||||||
|
pub async fn connect() -> Result<Self, StoreError> {
|
||||||
|
let root = paths::wa_root();
|
||||||
|
std::fs::create_dir_all(&root)?;
|
||||||
|
let opts = SqliteConnectOptions::new()
|
||||||
|
.filename(paths::db_path())
|
||||||
|
.create_if_missing(true)
|
||||||
|
.foreign_keys(true);
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect_with(opts)
|
||||||
|
.await?;
|
||||||
|
sqlx::migrate!("./migrations")
|
||||||
|
.run(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| StoreError::Db(e.to_string()))?;
|
||||||
|
Ok(Self { pool })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_unix() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On-disk shape of `transcript.json` (`docs/03-data-model.md`).
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||||
|
struct TranscriptFile {
|
||||||
|
schema: u32,
|
||||||
|
meeting_id: MeetingId,
|
||||||
|
language: Option<String>,
|
||||||
|
model: Option<String>,
|
||||||
|
backend: Option<String>,
|
||||||
|
segments: Vec<TranscriptSegment>,
|
||||||
|
speakers: Vec<TranscriptSpeaker>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
struct TranscriptSpeaker {
|
||||||
|
label: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn row_to_list_item(row: &sqlx::sqlite::SqliteRow) -> MeetingListItem {
|
||||||
|
MeetingListItem {
|
||||||
|
id: row.get("id"),
|
||||||
|
title: row.get("title"),
|
||||||
|
started_at: row.get("started_at"),
|
||||||
|
duration_secs: row.get("duration_secs"),
|
||||||
|
status: MeetingStatus::parse(row.get::<String, _>("status").as_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Store for SqliteStore {
|
impl Store for SqliteStore {
|
||||||
async fn create_meeting(&self, _m: NewMeeting) -> Result<MeetingId, StoreError> {
|
async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError> {
|
||||||
todo!("Phase 2 — create meeting row + folder")
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let folder = paths::meeting_dir(&id);
|
||||||
|
std::fs::create_dir_all(&folder)?;
|
||||||
|
let audio_path = folder.join("audio.wav");
|
||||||
|
let now = now_unix();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO meetings (id, title, started_at, folder_path, audio_path, status, calendar_event_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'recording', ?, ?, ?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&m.title)
|
||||||
|
.bind(now)
|
||||||
|
.bind(folder.display().to_string())
|
||||||
|
.bind(audio_path.display().to_string())
|
||||||
|
.bind(&m.calendar_event_id)
|
||||||
|
.bind(now)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(id)
|
||||||
}
|
}
|
||||||
async fn finalize_meeting(&self, _id: &MeetingId) -> Result<(), StoreError> {
|
|
||||||
todo!("Phase 2 — persist transcript.json + metadata")
|
async fn finalize_meeting(&self, id: &MeetingId, s: FinalizeMeeting) -> Result<(), StoreError> {
|
||||||
|
let now = now_unix();
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE meetings SET status = 'ready', ended_at = ?, duration_secs = ?, recorded = ?,
|
||||||
|
language = ?, backend_used = ?, model_used = ?, updated_at = ? WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(now)
|
||||||
|
.bind(s.duration_secs)
|
||||||
|
.bind(s.recorded as i64)
|
||||||
|
.bind(&s.language)
|
||||||
|
.bind(&s.backend_used)
|
||||||
|
.bind(&s.model_used)
|
||||||
|
.bind(now)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for speaker in &s.speakers {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO speakers (id, meeting_id, label, display_name)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(meeting_id, label) DO UPDATE SET display_name = excluded.display_name",
|
||||||
|
)
|
||||||
|
.bind(uuid::Uuid::new_v4().to_string())
|
||||||
|
.bind(id)
|
||||||
|
.bind(&speaker.label)
|
||||||
|
.bind(&speaker.display_name)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let transcript = TranscriptFile {
|
||||||
|
schema: 1,
|
||||||
|
meeting_id: id.clone(),
|
||||||
|
language: s.language.clone(),
|
||||||
|
model: s.model_used.clone(),
|
||||||
|
backend: s.backend_used.clone(),
|
||||||
|
segments: s.segments,
|
||||||
|
speakers: s
|
||||||
|
.speakers
|
||||||
|
.iter()
|
||||||
|
.map(|sp| TranscriptSpeaker {
|
||||||
|
label: sp.label.clone(),
|
||||||
|
display_name: sp.display_name.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
let json =
|
||||||
|
serde_json::to_string_pretty(&transcript).map_err(|e| StoreError::Db(e.to_string()))?;
|
||||||
|
std::fs::write(paths::meeting_dir(id).join("transcript.json"), json)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_meetings(
|
async fn list_meetings(
|
||||||
&self,
|
&self,
|
||||||
_query: Option<String>,
|
query: Option<String>,
|
||||||
) -> Result<Vec<MeetingListItem>, StoreError> {
|
) -> Result<Vec<MeetingListItem>, StoreError> {
|
||||||
todo!("Phase 2 — list meetings")
|
let rows = match query.filter(|q| !q.trim().is_empty()) {
|
||||||
|
Some(q) => {
|
||||||
|
let like = format!("%{}%", q.trim());
|
||||||
|
sqlx::query("SELECT id, title, started_at, duration_secs, status FROM meetings WHERE title LIKE ? ORDER BY started_at DESC")
|
||||||
|
.bind(like)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
async fn delete_meeting(&self, _id: &MeetingId) -> Result<(), StoreError> {
|
None => {
|
||||||
todo!("Phase 2 — delete meeting + folder")
|
sqlx::query("SELECT id, title, started_at, duration_secs, status FROM meetings ORDER BY started_at DESC")
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?
|
||||||
}
|
}
|
||||||
async fn update_notes(&self, _id: &MeetingId, _markdown: &str) -> Result<(), StoreError> {
|
};
|
||||||
todo!("Phase 2 — write notes.md + FTS")
|
Ok(rows.iter().map(row_to_list_item).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT id, title, started_at, ended_at, duration_secs, status, recorded, language, backend_used, model_used
|
||||||
|
FROM meetings WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| StoreError::NotFound(id.clone()))?;
|
||||||
|
|
||||||
|
let speaker_rows = sqlx::query(
|
||||||
|
"SELECT label, display_name FROM speakers WHERE meeting_id = ? ORDER BY label",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
let speakers: Vec<SpeakerInfo> = speaker_rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| SpeakerInfo {
|
||||||
|
label: r.get("label"),
|
||||||
|
display_name: r.get("display_name"),
|
||||||
|
participant_id: None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let folder = paths::meeting_dir(id);
|
||||||
|
let segments = std::fs::read_to_string(folder.join("transcript.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str::<TranscriptFile>(&s).ok())
|
||||||
|
.map(|t| t.segments)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let notes_markdown = std::fs::read_to_string(folder.join("notes.md")).unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(Meeting {
|
||||||
|
id: row.get("id"),
|
||||||
|
title: row.get("title"),
|
||||||
|
started_at: row.get("started_at"),
|
||||||
|
ended_at: row.get("ended_at"),
|
||||||
|
duration_secs: row.get("duration_secs"),
|
||||||
|
status: MeetingStatus::parse(row.get::<String, _>("status").as_str()),
|
||||||
|
recorded: row.get::<i64, _>("recorded") != 0,
|
||||||
|
language: row.get("language"),
|
||||||
|
backend_used: row.get("backend_used"),
|
||||||
|
model_used: row.get("model_used"),
|
||||||
|
segments,
|
||||||
|
speakers,
|
||||||
|
notes_markdown,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError> {
|
||||||
|
sqlx::query("DELETE FROM meetings WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
let folder = paths::meeting_dir(id);
|
||||||
|
if folder.exists() {
|
||||||
|
std::fs::remove_dir_all(folder)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError> {
|
||||||
|
std::fs::write(paths::meeting_dir(id).join("notes.md"), markdown)?;
|
||||||
|
sqlx::query("UPDATE meetings SET updated_at = ? WHERE id = ?")
|
||||||
|
.bind(now_unix())
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn search(&self, _query: &str) -> Result<Vec<MeetingListItem>, StoreError> {
|
async fn search(&self, _query: &str) -> Result<Vec<MeetingListItem>, StoreError> {
|
||||||
todo!("Phase 8 — FTS5 search")
|
todo!("Phase 8 — FTS5 search")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError> {
|
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError> {
|
||||||
todo!("Phase 2 — recovery scan")
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, audio_path FROM meetings WHERE status IN ('recording', 'transcribing')",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
let mut recovered = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let id: MeetingId = row.get("id");
|
||||||
|
let audio_path: String = row.get("audio_path");
|
||||||
|
if PathBuf::from(&audio_path).exists() {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE meetings SET status = 'recovering', updated_at = ? WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(now_unix())
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
recovered.push(id);
|
||||||
|
} else {
|
||||||
|
// No working audio and never finalized: nothing to recover from.
|
||||||
|
sqlx::query("UPDATE meetings SET status = 'error', updated_at = ? WHERE id = ?")
|
||||||
|
.bind(now_unix())
|
||||||
|
.bind(&id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
async fn enforce_retention(&self, _policy: Retention) -> Result<u32, StoreError> {
|
}
|
||||||
todo!("Phase 2 — retention enforcement")
|
Ok(recovered)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enforce_retention(&self, policy: Retention) -> Result<u32, StoreError> {
|
||||||
|
if policy.max_age_days.is_none() && policy.max_size_gb.is_none() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let candidates = sqlx::query(
|
||||||
|
"SELECT id, folder_path, started_at FROM meetings WHERE status IN ('ready', 'error') ORDER BY started_at ASC",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut removed = 0u32;
|
||||||
|
let now = now_unix();
|
||||||
|
let max_age_secs = policy.max_age_days.map(|d| d as i64 * 86_400);
|
||||||
|
|
||||||
|
// Age-based pruning first (cheap, no disk walk needed).
|
||||||
|
let mut kept = Vec::new();
|
||||||
|
for row in &candidates {
|
||||||
|
let id: MeetingId = row.get("id");
|
||||||
|
let started_at: i64 = row.get("started_at");
|
||||||
|
if let Some(max_age) = max_age_secs {
|
||||||
|
if now - started_at > max_age {
|
||||||
|
self.delete_meeting(&id).await?;
|
||||||
|
removed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kept.push((id, row.get::<String, _>("folder_path")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size-based pruning, oldest first, until under the cap.
|
||||||
|
if let Some(max_gb) = policy.max_size_gb {
|
||||||
|
let cap_bytes = max_gb as u64 * 1_073_741_824;
|
||||||
|
let sizes: Vec<(MeetingId, u64)> = kept
|
||||||
|
.iter()
|
||||||
|
.map(|(id, folder)| (id.clone(), dir_size(folder)))
|
||||||
|
.collect();
|
||||||
|
let mut total: u64 = sizes.iter().map(|(_, s)| s).sum();
|
||||||
|
let mut idx = 0;
|
||||||
|
while total > cap_bytes && idx < sizes.len() {
|
||||||
|
let (id, size) = &sizes[idx];
|
||||||
|
self.delete_meeting(id).await?;
|
||||||
|
total = total.saturating_sub(*size);
|
||||||
|
removed += 1;
|
||||||
|
idx += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(removed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dir_size(path: &str) -> u64 {
|
||||||
|
std::fs::read_dir(path)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|entry| entry.metadata().ok())
|
||||||
|
.map(|m| m.len())
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ pub trait Transcriber: Send + Sync {
|
|||||||
Self: Sized;
|
Self: Sized;
|
||||||
/// Streaming: emit interim + final segments for a window (FR-TRX-2).
|
/// Streaming: emit interim + final segments for a window (FR-TRX-2).
|
||||||
fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError>;
|
fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError>;
|
||||||
/// Batch: one-shot, higher accuracy (FR-TRX-3).
|
/// Batch: one-shot over a whole file, higher accuracy — also the crash-recovery
|
||||||
|
/// path (FR-TRX-3, T2.8): re-run over the working `audio.wav` from scratch.
|
||||||
fn transcribe_file(&self, wav: &Path) -> Result<Vec<TranscriptSegment>, TrxError>;
|
fn transcribe_file(&self, wav: &Path) -> Result<Vec<TranscriptSegment>, TrxError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +43,88 @@ pub struct WhisperTranscriber {
|
|||||||
next_id: std::sync::atomic::AtomicU64,
|
next_id: std::sync::atomic::AtomicU64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "cpu-transcription")]
|
||||||
|
impl WhisperTranscriber {
|
||||||
|
/// Shared decode path for both streaming windows and batch files.
|
||||||
|
///
|
||||||
|
/// `single_segment` forces whisper.cpp to treat the whole buffer as one
|
||||||
|
/// segment — needed for the short (~4s) streaming windows (see
|
||||||
|
/// `transcribe_stream`'s doc comment for why), but wrong for batch/file
|
||||||
|
/// transcription: a multi-minute recording should get whisper's normal
|
||||||
|
/// speech-boundary-driven multi-segment output, and its internal 30s-chunk
|
||||||
|
/// seek loop handles audio longer than one chunk on its own.
|
||||||
|
fn run_full(
|
||||||
|
&self,
|
||||||
|
samples: &[f32],
|
||||||
|
offset_ms: u64,
|
||||||
|
single_segment: bool,
|
||||||
|
) -> Result<Vec<TranscriptSegment>, TrxError> {
|
||||||
|
// whisper.cpp hangs (not errors) on an empty buffer — verified against
|
||||||
|
// a crash-recovered WAV whose header hadn't been finalized. Belt and
|
||||||
|
// suspenders alongside `read_wav_mono_16k` reading raw bytes directly.
|
||||||
|
if samples.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = self
|
||||||
|
.ctx
|
||||||
|
.create_state()
|
||||||
|
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||||
|
|
||||||
|
// best_of: 1 — fastest greedy decode, matching the CPU/typical-laptop
|
||||||
|
// baseline (NFR-PERF-3).
|
||||||
|
let mut params =
|
||||||
|
whisper_rs::FullParams::new(whisper_rs::SamplingStrategy::Greedy { best_of: 1 });
|
||||||
|
params.set_n_threads(available_threads());
|
||||||
|
params.set_translate(false);
|
||||||
|
params.set_print_progress(false);
|
||||||
|
params.set_print_realtime(false);
|
||||||
|
params.set_print_timestamps(false);
|
||||||
|
params.set_suppress_blank(true);
|
||||||
|
params.set_single_segment(single_segment);
|
||||||
|
|
||||||
|
state
|
||||||
|
.full(params, samples)
|
||||||
|
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
||||||
|
|
||||||
|
// A forced single_segment's reported end_timestamp() reflects
|
||||||
|
// whisper.cpp's internal 30s-padded mel frame, not the real window
|
||||||
|
// length — confirmed even with `duration_ms` set, so don't trust it.
|
||||||
|
// We already know the window's true length from `samples`, which is
|
||||||
|
// the more reliable source for `single_segment` windows; whisper's own
|
||||||
|
// multi-segment timestamps (batch/`transcribe_file`) stay authoritative.
|
||||||
|
let window_end_ms = offset_ms + (samples.len() as u64 * 1000) / 16_000;
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for seg in state.as_iter() {
|
||||||
|
let text = seg.to_str_lossy().unwrap_or_default().trim().to_string();
|
||||||
|
if text.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Whisper timestamps are centiseconds (10ms units).
|
||||||
|
let start_ms = offset_ms + seg.start_timestamp().max(0) as u64 * 10;
|
||||||
|
let end_ms = if single_segment {
|
||||||
|
window_end_ms.max(start_ms)
|
||||||
|
} else {
|
||||||
|
offset_ms + seg.end_timestamp().max(0) as u64 * 10
|
||||||
|
};
|
||||||
|
out.push(TranscriptSegment {
|
||||||
|
id: self
|
||||||
|
.next_id
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
|
||||||
|
start_ms,
|
||||||
|
end_ms,
|
||||||
|
// Diarization lands in Phase 4; every segment is provisionally "S1" until then.
|
||||||
|
speaker: "S1".to_string(),
|
||||||
|
text,
|
||||||
|
confidence: Some((1.0 - seg.no_speech_probability()).clamp(0.0, 1.0)),
|
||||||
|
interim: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "cpu-transcription")]
|
#[cfg(feature = "cpu-transcription")]
|
||||||
impl Transcriber for WhisperTranscriber {
|
impl Transcriber for WhisperTranscriber {
|
||||||
fn load(model: &Path, _backend: BackendId) -> Result<Self, TrxError> {
|
fn load(model: &Path, _backend: BackendId) -> Result<Self, TrxError> {
|
||||||
@@ -57,55 +140,13 @@ impl Transcriber for WhisperTranscriber {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError> {
|
fn transcribe_stream(&self, audio: AudioWindow, out: SegmentSink) -> Result<(), TrxError> {
|
||||||
let mut state = self
|
|
||||||
.ctx
|
|
||||||
.create_state()
|
|
||||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
|
||||||
|
|
||||||
// best_of: 1 — fastest greedy decode, matching the CPU/typical-laptop
|
|
||||||
// baseline (NFR-PERF-3); real-time windowed transcription favors low
|
|
||||||
// latency per window over the extra accuracy of a wider search.
|
|
||||||
let mut params =
|
|
||||||
whisper_rs::FullParams::new(whisper_rs::SamplingStrategy::Greedy { best_of: 1 });
|
|
||||||
params.set_n_threads(available_threads());
|
|
||||||
params.set_translate(false);
|
|
||||||
params.set_print_progress(false);
|
|
||||||
params.set_print_realtime(false);
|
|
||||||
params.set_print_timestamps(false);
|
|
||||||
params.set_suppress_blank(true);
|
|
||||||
// Each call already gets exactly one fixed-size window (not a full 30s
|
// Each call already gets exactly one fixed-size window (not a full 30s
|
||||||
// clip); without this, whisper.cpp's segment-boundary heuristic hits
|
// clip); without `single_segment: true`, whisper.cpp's segment-boundary
|
||||||
// "single timestamp ending" on short buffers and silently drops the
|
// heuristic hits "single timestamp ending" on short buffers and drops
|
||||||
// whole window even when decoding succeeded (verified against real
|
// the whole window even when decoding succeeded (verified against real
|
||||||
// audio: text decoded correctly but zero segments came out until this
|
// audio — see whisper.cpp's own `stream` example, which sets this for
|
||||||
// was set — see whisper.cpp's own `stream` example, which sets this
|
// the same reason).
|
||||||
// for the same reason).
|
for segment in self.run_full(&audio.samples, audio.offset_ms, true)? {
|
||||||
params.set_single_segment(true);
|
|
||||||
|
|
||||||
state
|
|
||||||
.full(params, &audio.samples)
|
|
||||||
.map_err(|e| TrxError::Inference(e.to_string()))?;
|
|
||||||
|
|
||||||
for seg in state.as_iter() {
|
|
||||||
let text = seg.to_str_lossy().unwrap_or_default().trim().to_string();
|
|
||||||
if text.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Whisper timestamps are centiseconds (10ms units).
|
|
||||||
let start_ms = audio.offset_ms + seg.start_timestamp().max(0) as u64 * 10;
|
|
||||||
let end_ms = audio.offset_ms + seg.end_timestamp().max(0) as u64 * 10;
|
|
||||||
let segment = TranscriptSegment {
|
|
||||||
id: self
|
|
||||||
.next_id
|
|
||||||
.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
|
|
||||||
start_ms,
|
|
||||||
end_ms,
|
|
||||||
// Diarization lands in Phase 4; every segment is provisionally "S1" until then.
|
|
||||||
speaker: "S1".to_string(),
|
|
||||||
text,
|
|
||||||
confidence: Some((1.0 - seg.no_speech_probability()).clamp(0.0, 1.0)),
|
|
||||||
interim: false,
|
|
||||||
};
|
|
||||||
if out.send(segment).is_err() {
|
if out.send(segment).is_err() {
|
||||||
break; // receiver gone (meeting stopped) — nothing left to do
|
break; // receiver gone (meeting stopped) — nothing left to do
|
||||||
}
|
}
|
||||||
@@ -113,8 +154,10 @@ impl Transcriber for WhisperTranscriber {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transcribe_file(&self, _wav: &Path) -> Result<Vec<TranscriptSegment>, TrxError> {
|
fn transcribe_file(&self, wav: &Path) -> Result<Vec<TranscriptSegment>, TrxError> {
|
||||||
todo!("Phase 3 — batch transcription")
|
let samples =
|
||||||
|
crate::audio::read_wav_mono_16k(wav).map_err(|e| TrxError::Load(e.to_string()))?;
|
||||||
|
self.run_full(&samples, 0, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import ConsentNotice from "./lib/components/ConsentNotice.svelte";
|
import ConsentNotice from "./lib/components/ConsentNotice.svelte";
|
||||||
import { recording } from "./lib/stores/recording.svelte";
|
import { recording } from "./lib/stores/recording.svelte";
|
||||||
import { settings } from "./lib/stores/settings.svelte";
|
import { settings } from "./lib/stores/settings.svelte";
|
||||||
|
import { meetings } from "./lib/stores/meetings.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
let showSettings = $state(false);
|
let showSettings = $state(false);
|
||||||
@@ -16,9 +17,11 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
recording.init();
|
recording.init();
|
||||||
settings.load();
|
settings.load();
|
||||||
|
meetings.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
function startRecording() {
|
function startRecording() {
|
||||||
|
meetings.deselect(); // show the live view, not whatever past meeting was open
|
||||||
recording.start(undefined, settings.settings.default_record);
|
recording.start(undefined, settings.settings.default_record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-3
@@ -8,12 +8,14 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
|||||||
export type MeetingId = string;
|
export type MeetingId = string;
|
||||||
export type BackendId = "npu" | "nvidia" | "amd" | "intel" | "cpu";
|
export type BackendId = "npu" | "nvidia" | "amd" | "intel" | "cpu";
|
||||||
|
|
||||||
|
export type MeetingStatus = "recording" | "transcribing" | "ready" | "recovering" | "error";
|
||||||
|
|
||||||
export interface MeetingListItem {
|
export interface MeetingListItem {
|
||||||
id: MeetingId;
|
id: MeetingId;
|
||||||
title: string;
|
title: string;
|
||||||
started_at: number;
|
started_at: number;
|
||||||
duration_secs: number | null;
|
duration_secs: number | null;
|
||||||
status: "recording" | "transcribing" | "ready" | "recovering" | "error";
|
status: MeetingStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranscriptSegment {
|
export interface TranscriptSegment {
|
||||||
@@ -26,6 +28,29 @@ export interface TranscriptSegment {
|
|||||||
interim: boolean;
|
interim: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SpeakerInfo {
|
||||||
|
label: string;
|
||||||
|
display_name: string | null;
|
||||||
|
participant_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full meeting detail (get_meeting) — DB row + transcript + speakers + notes.
|
||||||
|
export interface Meeting {
|
||||||
|
id: MeetingId;
|
||||||
|
title: string;
|
||||||
|
started_at: number;
|
||||||
|
ended_at: number | null;
|
||||||
|
duration_secs: number | null;
|
||||||
|
status: MeetingStatus;
|
||||||
|
recorded: boolean;
|
||||||
|
language: string | null;
|
||||||
|
backend_used: string | null;
|
||||||
|
model_used: string | null;
|
||||||
|
segments: TranscriptSegment[];
|
||||||
|
speakers: SpeakerInfo[];
|
||||||
|
notes_markdown: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type SyncKind = "webdav" | "onedrive" | "dropbox" | "box";
|
export type SyncKind = "webdav" | "onedrive" | "dropbox" | "box";
|
||||||
|
|
||||||
// What the UI sees about a target — NEVER includes the secret (FR-SYNC-6).
|
// What the UI sees about a target — NEVER includes the secret (FR-SYNC-6).
|
||||||
@@ -74,6 +99,8 @@ export interface AppSettings {
|
|||||||
consent_acknowledged: boolean;
|
consent_acknowledged: boolean;
|
||||||
sync_enabled: boolean;
|
sync_enabled: boolean;
|
||||||
mcp_enabled: boolean;
|
mcp_enabled: boolean;
|
||||||
|
retention_max_age_days: number | null;
|
||||||
|
retention_max_size_gb: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feature brief — agent-ready spec distilled from a meeting (ADR-0011).
|
// Feature brief — agent-ready spec distilled from a meeting (ADR-0011).
|
||||||
@@ -116,12 +143,15 @@ export const api = {
|
|||||||
acknowledgeRecordingConsent: () => invoke<void>("acknowledge_recording_consent"),
|
acknowledgeRecordingConsent: () => invoke<void>("acknowledge_recording_consent"),
|
||||||
|
|
||||||
hardwareStatus: () => invoke("hardware_status"),
|
hardwareStatus: () => invoke("hardware_status"),
|
||||||
|
resumeTranscription: (meetingId: MeetingId) =>
|
||||||
|
invoke<void>("resume_transcription", { meetingId }),
|
||||||
listMeetings: (query?: string) => invoke<MeetingListItem[]>("list_meetings", { query }),
|
listMeetings: (query?: string) => invoke<MeetingListItem[]>("list_meetings", { query }),
|
||||||
getMeeting: (meetingId: MeetingId) => invoke("get_meeting", { meetingId }),
|
getMeeting: (meetingId: MeetingId) => invoke<Meeting>("get_meeting", { meetingId }),
|
||||||
deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }),
|
deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }),
|
||||||
updateNotes: (meetingId: MeetingId, markdown: string) =>
|
updateNotes: (meetingId: MeetingId, markdown: string) =>
|
||||||
invoke<void>("update_notes", { meetingId, markdown }),
|
invoke<void>("update_notes", { meetingId, markdown }),
|
||||||
exportMeeting: (meetingId: MeetingId, dest: string, format: string) =>
|
// format ∈ "md" | "bundle"; dest is a file path for md, a folder for bundle.
|
||||||
|
exportMeeting: (meetingId: MeetingId, dest: string, format: "md" | "bundle") =>
|
||||||
invoke<string>("export_meeting", { meetingId, dest, format }),
|
invoke<string>("export_meeting", { meetingId, dest, format }),
|
||||||
|
|
||||||
llmStatus: () => invoke("llm_status"),
|
llmStatus: () => invoke("llm_status"),
|
||||||
@@ -194,6 +224,9 @@ export const events = {
|
|||||||
onSegment: (
|
onSegment: (
|
||||||
cb: (p: { meetingId: string; segment: TranscriptSegment }) => void,
|
cb: (p: { meetingId: string; segment: TranscriptSegment }) => void,
|
||||||
): Promise<UnlistenFn> => listen("transcript://segment", (e) => cb(e.payload as never)),
|
): Promise<UnlistenFn> => listen("transcript://segment", (e) => cb(e.payload as never)),
|
||||||
|
onFinalized: (
|
||||||
|
cb: (p: { meetingId: string; segmentCount: number }) => void,
|
||||||
|
): Promise<UnlistenFn> => listen("transcript://finalized", (e) => cb(e.payload as never)),
|
||||||
onLlmToken: (cb: (p: { meetingId: string; text: string }) => void): Promise<UnlistenFn> =>
|
onLlmToken: (cb: (p: { meetingId: string; text: string }) => void): Promise<UnlistenFn> =>
|
||||||
listen("llm://token", (e) => cb(e.payload as never)),
|
listen("llm://token", (e) => cb(e.payload as never)),
|
||||||
onSyncJob: (
|
onSyncJob: (
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Meetings list + selected-meeting detail (Phase 2, FR-STORE-5).
|
||||||
|
// Svelte 5 runes store, same shape as recording.svelte.ts.
|
||||||
|
|
||||||
|
import { api, events, type Meeting, type MeetingId, type MeetingListItem } from "../api";
|
||||||
|
|
||||||
|
class MeetingsStore {
|
||||||
|
list = $state<MeetingListItem[]>([]);
|
||||||
|
selectedId = $state<MeetingId | null>(null);
|
||||||
|
selected = $state<Meeting | null>(null);
|
||||||
|
loading = $state(false);
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.load();
|
||||||
|
// A meeting finishing (live stop, or a recovered meeting resuming
|
||||||
|
// transcription) changes the list and, if it's the open one, its detail.
|
||||||
|
await events.onFinalized(async ({ meetingId }) => {
|
||||||
|
await this.load();
|
||||||
|
if (this.selectedId === meetingId) await this.select(meetingId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(query?: string) {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
this.list = await api.listMeetings(query);
|
||||||
|
} catch {
|
||||||
|
// list_meetings backend not reachable; leave the list as-is.
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async select(id: MeetingId) {
|
||||||
|
this.selectedId = id;
|
||||||
|
this.selected = await api.getMeeting(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
deselect() {
|
||||||
|
this.selectedId = null;
|
||||||
|
this.selected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: MeetingId) {
|
||||||
|
await api.deleteMeeting(id);
|
||||||
|
if (this.selectedId === id) this.deselect();
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(id: MeetingId) {
|
||||||
|
await api.resumeTranscription(id);
|
||||||
|
await this.load();
|
||||||
|
if (this.selectedId === id) await this.select(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveNotes(id: MeetingId, markdown: string) {
|
||||||
|
await api.updateNotes(id, markdown);
|
||||||
|
if (this.selected && this.selectedId === id) this.selected.notes_markdown = markdown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const meetings = new MeetingsStore();
|
||||||
@@ -17,6 +17,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
consent_acknowledged: false,
|
consent_acknowledged: false,
|
||||||
sync_enabled: false, // sync OFF by default (ADR-0010)
|
sync_enabled: false, // sync OFF by default (ADR-0010)
|
||||||
mcp_enabled: false, // MCP server OFF by default (ADR-0011)
|
mcp_enabled: false, // MCP server OFF by default (ADR-0011)
|
||||||
|
retention_max_age_days: null, // no cap by default (FR-STORE-2)
|
||||||
|
retention_max_size_gb: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
class SettingsStore {
|
class SettingsStore {
|
||||||
@@ -29,12 +31,17 @@ class SettingsStore {
|
|||||||
async load() {
|
async load() {
|
||||||
try {
|
try {
|
||||||
this.settings = await api.getSettings();
|
this.settings = await api.getSettings();
|
||||||
this.targets = await api.listSyncTargets();
|
|
||||||
} catch {
|
} catch {
|
||||||
// Backend command is still a todo!(); fall back to local defaults.
|
// Backend command is still a todo!(); fall back to local defaults.
|
||||||
this.backendStub = true;
|
this.backendStub = true;
|
||||||
this.settings = { ...DEFAULT_SETTINGS };
|
this.settings = { ...DEFAULT_SETTINGS };
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
this.targets = await api.listSyncTargets();
|
||||||
|
} catch {
|
||||||
|
// Sync lands in Phase 9 — don't let its stub discard real settings above.
|
||||||
|
this.backendStub = true;
|
||||||
|
}
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +59,10 @@ class SettingsStore {
|
|||||||
return this.patch({ default_record: on });
|
return this.patch({ default_record: on });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setRetentionPolicy(maxAgeDays: number | null, maxSizeGb: number | null) {
|
||||||
|
return this.patch({ retention_max_age_days: maxAgeDays, retention_max_size_gb: maxSizeGb });
|
||||||
|
}
|
||||||
|
|
||||||
async acknowledgeConsent() {
|
async acknowledgeConsent() {
|
||||||
this.settings = { ...this.settings, consent_acknowledged: true };
|
this.settings = { ...this.settings, consent_acknowledged: true };
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,37 +1,177 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Left pane: meetings list + search/filter (Phase 2, FR-STORE-5; Phase 8 search).
|
// Left pane: meetings list + search/filter (Phase 2, FR-STORE-5; Phase 8 search).
|
||||||
import { api, type MeetingListItem } from "../api";
|
import { meetings } from "../stores/meetings.svelte";
|
||||||
import { onMount } from "svelte";
|
import { recording } from "../stores/recording.svelte";
|
||||||
|
import type { MeetingListItem } from "../api";
|
||||||
|
|
||||||
let meetings = $state<MeetingListItem[]>([]);
|
|
||||||
let query = $state("");
|
let query = $state("");
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
onMount(async () => {
|
function onSearchInput() {
|
||||||
try {
|
clearTimeout(searchTimer);
|
||||||
meetings = await api.listMeetings();
|
searchTimer = setTimeout(() => meetings.load(query || undefined), 250);
|
||||||
} catch {
|
|
||||||
// list_meetings is a Phase 2 todo!() — skeleton tolerates the error.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDate(unixSecs: number): string {
|
||||||
|
return new Date(unixSecs * 1000).toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(secs: number | null): string {
|
||||||
|
if (!secs) return "";
|
||||||
|
const m = Math.floor(secs / 60);
|
||||||
|
const s = secs % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMeeting(m: MeetingListItem) {
|
||||||
|
if (recording.state !== "idle" && recording.meetingId === m.id) return; // still live
|
||||||
|
await meetings.select(m.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMeeting(e: Event, id: string) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm("Delete this meeting? This removes its recording, transcript, and notes.")) return;
|
||||||
|
await meetings.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeMeeting(e: Event, id: string) {
|
||||||
|
e.stopPropagation();
|
||||||
|
await meetings.resume(id);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<input placeholder="Search meetings…" bind:value={query} />
|
<input placeholder="Search meetings…" bind:value={query} oninput={onSearchInput} />
|
||||||
{#if meetings.length === 0}
|
{#if meetings.list.length === 0}
|
||||||
<p class="muted">No meetings yet. Click ● Record to start.</p>
|
<p class="muted">No meetings yet. Click ● Record to start.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul>
|
<ul>
|
||||||
{#each meetings as m (m.id)}
|
{#each meetings.list as m (m.id)}
|
||||||
<li>{m.title}</li>
|
<li class:active={meetings.selectedId === m.id}>
|
||||||
|
<button class="item-btn" onclick={() => openMeeting(m)}>
|
||||||
|
<span class="row">
|
||||||
|
<span class="title">{m.title}</span>
|
||||||
|
{#if m.status === "recording" || m.status === "transcribing"}
|
||||||
|
<span class="badge live">{m.status}</span>
|
||||||
|
{:else if m.status === "recovering"}
|
||||||
|
<span class="badge recovering">recovering</span>
|
||||||
|
{:else if m.status === "error"}
|
||||||
|
<span class="badge error">error</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="row muted small">
|
||||||
|
<span>{formatDate(m.started_at)}</span>
|
||||||
|
{#if m.duration_secs}<span>{formatDuration(m.duration_secs)}</span>{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div class="row actions">
|
||||||
|
{#if m.status === "recovering"}
|
||||||
|
<button class="link" onclick={(e) => resumeMeeting(e, m.id)}
|
||||||
|
>Resume transcription</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="link danger"
|
||||||
|
onclick={(e) => removeMeeting(e, m.id)}
|
||||||
|
aria-label="Delete meeting"
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.wrap { padding: 0.75rem; }
|
.wrap {
|
||||||
input { width: 100%; padding: 0.4rem; margin-bottom: 0.5rem; }
|
padding: 0.75rem;
|
||||||
.muted { color: var(--muted); font-size: 0.85rem; }
|
}
|
||||||
ul { list-style: none; padding: 0; margin: 0; }
|
input {
|
||||||
li { padding: 0.4rem 0.2rem; border-bottom: 1px solid var(--border); cursor: pointer; }
|
width: 100%;
|
||||||
|
padding: 0.4rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
padding: 0.5rem 0.4rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
li.active {
|
||||||
|
background: color-mix(in srgb, #2563eb 12%, transparent);
|
||||||
|
}
|
||||||
|
.item-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.badge.live {
|
||||||
|
color: #e11d48;
|
||||||
|
border-color: #e11d48;
|
||||||
|
}
|
||||||
|
.badge.recovering {
|
||||||
|
color: #b45309;
|
||||||
|
border-color: #b45309;
|
||||||
|
}
|
||||||
|
.badge.error {
|
||||||
|
color: #6b7280;
|
||||||
|
border-color: #6b7280;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.link.danger:hover {
|
||||||
|
color: #e11d48;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
let { onClose }: { onClose: () => void } = $props();
|
let { onClose }: { onClose: () => void } = $props();
|
||||||
|
|
||||||
let section = $state<"recording" | "sync">("recording");
|
let section = $state<"recording" | "storage" | "sync">("recording");
|
||||||
let showConsent = $state(false);
|
let showConsent = $state(false);
|
||||||
let testResult = $state<string | null>(null);
|
let testResult = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -31,6 +31,19 @@
|
|||||||
showConsent = false;
|
showConsent = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Storage retention (FR-STORE-2) ----
|
||||||
|
let maxAgeInput = $state("");
|
||||||
|
let maxSizeInput = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
maxAgeInput = settings.settings.retention_max_age_days?.toString() ?? "";
|
||||||
|
maxSizeInput = settings.settings.retention_max_size_gb?.toString() ?? "";
|
||||||
|
});
|
||||||
|
function saveRetention() {
|
||||||
|
const age = maxAgeInput.trim() === "" ? null : Math.max(1, parseInt(maxAgeInput, 10) || 0);
|
||||||
|
const size = maxSizeInput.trim() === "" ? null : Math.max(1, parseInt(maxSizeInput, 10) || 0);
|
||||||
|
settings.setRetentionPolicy(age, size);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Add-target form ----
|
// ---- Add-target form ----
|
||||||
const PROVIDERS = ["nextcloud", "owncloud", "cloudreve", "seafile", "synology", "generic"];
|
const PROVIDERS = ["nextcloud", "owncloud", "cloudreve", "seafile", "synology", "generic"];
|
||||||
let form = $state<SyncTargetConfig>(blankForm("webdav"));
|
let form = $state<SyncTargetConfig>(blankForm("webdav"));
|
||||||
@@ -80,15 +93,18 @@
|
|||||||
<button class:active={section === "recording"} onclick={() => (section = "recording")}
|
<button class:active={section === "recording"} onclick={() => (section = "recording")}
|
||||||
>Recording</button
|
>Recording</button
|
||||||
>
|
>
|
||||||
|
<button class:active={section === "storage"} onclick={() => (section = "storage")}
|
||||||
|
>Storage</button
|
||||||
|
>
|
||||||
<button class:active={section === "sync"} onclick={() => (section = "sync")}>Sync</button>
|
<button class:active={section === "sync"} onclick={() => (section = "sync")}>Sync</button>
|
||||||
</nav>
|
</nav>
|
||||||
<button class="close" onclick={onClose} aria-label="Close">✕</button>
|
<button class="close" onclick={onClose} aria-label="Close">✕</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if settings.backendStub}
|
{#if settings.backendStub && section === "sync"}
|
||||||
<p class="stub">
|
<p class="stub">
|
||||||
Preview mode — the backend for these settings isn't implemented yet (Phase 9). Changes are
|
Preview mode — the sync backend isn't implemented yet (Phase 9). Changes are kept in the UI
|
||||||
kept in the UI only.
|
only.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -117,6 +133,40 @@
|
|||||||
<ConsentNotice onAccept={acceptConsent} onCancel={() => (showConsent = false)} />
|
<ConsentNotice onAccept={acceptConsent} onCancel={() => (showConsent = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
{:else if section === "storage"}
|
||||||
|
<section>
|
||||||
|
<h3>Storage</h3>
|
||||||
|
<div class="row">Location<code>{settings.settings.storage_root}</code></div>
|
||||||
|
<p class="muted">
|
||||||
|
Changing the storage location isn't supported yet — this is where meetings, models, and
|
||||||
|
the database currently live.
|
||||||
|
</p>
|
||||||
|
<h4>Retention</h4>
|
||||||
|
<p class="muted">
|
||||||
|
Automatically delete the oldest meetings past a limit. Checked once at startup; empty
|
||||||
|
means no limit. Never touches a meeting that's currently recording.
|
||||||
|
</p>
|
||||||
|
<div class="grid">
|
||||||
|
<label
|
||||||
|
>Max age (days)<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="no limit"
|
||||||
|
bind:value={maxAgeInput}
|
||||||
|
onchange={saveRetention}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
>Max size (GB)<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="no limit"
|
||||||
|
bind:value={maxSizeInput}
|
||||||
|
onchange={saveRetention}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<section>
|
<section>
|
||||||
<h3>Sync & upload</h3>
|
<h3>Sync & upload</h3>
|
||||||
|
|||||||
@@ -1,27 +1,230 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Center pane: live transcript + Markdown notes (Phase 1/2, FR-TRX-2/FR-NOTE-*).
|
// Center pane: live transcript while recording (FR-TRX-2), or — once a past
|
||||||
|
// meeting is selected — raw transcript + editable/rendered notes side by
|
||||||
|
// side (FR-NOTE-1/2/6) with export (FR-NOTE-3, T2.6).
|
||||||
import { recording } from "../stores/recording.svelte";
|
import { recording } from "../stores/recording.svelte";
|
||||||
|
import { meetings } from "../stores/meetings.svelte";
|
||||||
|
import { api, type SpeakerInfo } from "../api";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { save, open } from "@tauri-apps/plugin-dialog";
|
||||||
|
|
||||||
function speakerName(label: string): string {
|
// Notes can contain pasted/typed Markdown from anywhere; this webview has an
|
||||||
return label; // Phase 4: resolve to display name from mapping.
|
// IPC bridge to the Rust backend, so unsanitized `{@html}` here would be a
|
||||||
|
// real local-privilege risk, not just a cosmetic one — sanitize before render.
|
||||||
|
function renderNotes(markdown: string): string {
|
||||||
|
return DOMPurify.sanitize(marked.parse(markdown || "") as string);
|
||||||
|
}
|
||||||
|
|
||||||
|
function speakerName(label: string, speakers: SpeakerInfo[] = []): string {
|
||||||
|
return speakers.find((s) => s.label === label)?.display_name ?? label;
|
||||||
|
}
|
||||||
|
|
||||||
|
let notesText = $state("");
|
||||||
|
let editorEl: HTMLTextAreaElement | undefined = $state();
|
||||||
|
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let loadedForId: string | null = null;
|
||||||
|
|
||||||
|
// Sync the editor buffer whenever a different meeting is selected.
|
||||||
|
$effect(() => {
|
||||||
|
const m = meetings.selected;
|
||||||
|
if (m && m.id !== loadedForId) {
|
||||||
|
notesText = m.notes_markdown;
|
||||||
|
loadedForId = m.id;
|
||||||
|
} else if (!m) {
|
||||||
|
loadedForId = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function scheduleSave() {
|
||||||
|
const id = meetings.selected?.id;
|
||||||
|
if (!id) return;
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = setTimeout(() => meetings.saveNotes(id, notesText), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapSelection(before: string, after: string = before) {
|
||||||
|
const el = editorEl;
|
||||||
|
if (!el) return;
|
||||||
|
const { selectionStart: s, selectionEnd: e, value } = el;
|
||||||
|
const selected = value.slice(s, e);
|
||||||
|
notesText = value.slice(0, s) + before + selected + after + value.slice(e);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
el.focus();
|
||||||
|
el.selectionStart = s + before.length;
|
||||||
|
el.selectionEnd = s + before.length + selected.length;
|
||||||
|
});
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertLinePrefix(prefix: string) {
|
||||||
|
const el = editorEl;
|
||||||
|
if (!el) return;
|
||||||
|
const { selectionStart: s, value } = el;
|
||||||
|
const lineStart = value.lastIndexOf("\n", s - 1) + 1;
|
||||||
|
notesText = value.slice(0, lineStart) + prefix + value.slice(lineStart);
|
||||||
|
queueMicrotask(() => el.focus());
|
||||||
|
scheduleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportMd() {
|
||||||
|
const m = meetings.selected;
|
||||||
|
if (!m) return;
|
||||||
|
const path = await save({
|
||||||
|
defaultPath: `${m.title}.md`,
|
||||||
|
filters: [{ name: "Markdown", extensions: ["md"] }],
|
||||||
|
});
|
||||||
|
if (path) await api.exportMeeting(m.id, path, "md");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportBundle() {
|
||||||
|
const m = meetings.selected;
|
||||||
|
if (!m) return;
|
||||||
|
const dir = await open({ directory: true, defaultPath: m.title });
|
||||||
|
if (typeof dir === "string") await api.exportMeeting(m.id, dir, "bundle");
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
{#if recording.segments.length === 0}
|
{#if meetings.selected}
|
||||||
<p class="muted">Transcript will appear here as you record.</p>
|
{@const m = meetings.selected}
|
||||||
|
<div class="split">
|
||||||
|
<div class="pane transcript">
|
||||||
|
<h4>Transcript</h4>
|
||||||
|
{#if m.segments.length === 0}
|
||||||
|
<p class="muted">No transcript for this meeting.</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#each m.segments as s (s.id)}
|
||||||
|
<p><strong>{speakerName(s.speaker, m.speakers)}:</strong> {s.text}</p>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="pane notes">
|
||||||
|
<div class="toolbar" role="toolbar" aria-label="Notes formatting">
|
||||||
|
<button onclick={() => wrapSelection("**")} title="Bold"><b>B</b></button>
|
||||||
|
<button onclick={() => wrapSelection("_")} title="Italic"><i>I</i></button>
|
||||||
|
<button onclick={() => insertLinePrefix("# ")} title="Heading 1">H1</button>
|
||||||
|
<button onclick={() => insertLinePrefix("## ")} title="Heading 2">H2</button>
|
||||||
|
<button onclick={() => insertLinePrefix("- ")} title="Bullet list">•</button>
|
||||||
|
<button onclick={() => insertLinePrefix("- [ ] ")} title="Checkbox">☑</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button onclick={exportMd} title="Export notes as .md">Export .md</button>
|
||||||
|
<button onclick={exportBundle} title="Export audio + transcript + notes to a folder">
|
||||||
|
Export bundle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="editor-preview">
|
||||||
|
<textarea
|
||||||
|
bind:this={editorEl}
|
||||||
|
bind:value={notesText}
|
||||||
|
oninput={scheduleSave}
|
||||||
|
placeholder="Notes…"
|
||||||
|
></textarea>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via DOMPurify in renderNotes() -->
|
||||||
|
<div class="preview">{@html renderNotes(notesText)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if recording.segments.length === 0}
|
||||||
|
<p class="muted pad">Transcript will appear here as you record.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="pad">
|
||||||
{#each recording.segments as s (s.id)}
|
{#each recording.segments as s (s.id)}
|
||||||
<p class:interim={s.interim}>
|
<p class:interim={s.interim}>
|
||||||
<strong>{speakerName(s.speaker)}:</strong> {s.text}
|
<strong>{speakerName(s.speaker)}:</strong>
|
||||||
|
{s.text}
|
||||||
</p>
|
</p>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.wrap { padding: 1rem; max-width: 760px; }
|
.wrap {
|
||||||
.muted { color: var(--muted); }
|
height: 100%;
|
||||||
.interim { opacity: 0.55; font-style: italic; }
|
}
|
||||||
p { margin: 0.35rem 0; line-height: 1.5; }
|
.pad {
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.interim {
|
||||||
|
opacity: 0.55;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.pane {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.pane.transcript {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.pane h4 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.toolbar button {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--fg);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.toolbar .spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-preview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
height: calc(100% - 2.5rem);
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
resize: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.preview :global(h1),
|
||||||
|
.preview :global(h2),
|
||||||
|
.preview :global(h3) {
|
||||||
|
margin: 0.6rem 0 0.3rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user