3 Commits

Author SHA1 Message Date
jake
ca8555f6ad fix(build): add /FORCE:MULTIPLE for ggml duplicate symbol collision
whisper-rs-sys and llama-cpp-sys-2 both bundle ggml statically, causing
LNK2005/LNK1169 on MSVC. This flag tells the linker to accept duplicates.

TEMPORARY: replace with shared system ggml or process separation before release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:49:38 +00:00
jake
0685428c90 feat(ui): add export section to SettingsPage with directory picker and format selection
Export Your Data section with three buttons: Export Transcriptions (MD/TXT),
Export Tasks (JSON/CSV), and Export to Obsidian. Each opens a directory picker
and shows status on completion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:20:42 +00:00
jake
eace0e88c3 feat(export): add Tauri commands for transcript, task, and Obsidian export
Three new commands: export_transcriptions (MD/TXT), export_tasks (JSON/CSV),
and export_to_obsidian (MD with wikilinks and checklists). All write plain
text files to a user-selected directory.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:20:38 +00:00
10 changed files with 809 additions and 3 deletions

11
.cargo/config.toml Normal file
View File

@@ -0,0 +1,11 @@
# TEMPORARY WORKAROUND: whisper-rs-sys and llama-cpp-sys-2 both bundle ggml
# statically, causing duplicate symbol errors on MSVC (LNK2005/LNK1169).
#
# /FORCE:MULTIPLE tells the linker to accept duplicate symbols and pick the
# first definition. This is safe for development since both ggml copies are
# from recent crate versions with compatible ABIs.
#
# DO NOT SHIP WITH THIS FLAG. Replace with shared system ggml or process
# separation before release. See: input/tasks/kon-ggml-dedup.md (in CORBEL repo)
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "link-arg=/FORCE:MULTIPLE"]

View File

@@ -39,3 +39,4 @@ arboard = "3.6.1"
tauri-plugin-mcp = "0.7.1" tauri-plugin-mcp = "0.7.1"
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] } sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] }
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
chrono = "0.4"

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","tasks-float"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister"]}} {"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main","tasks-float"],"permissions":["core:default","core:window:allow-start-dragging","core:window:allow-set-always-on-top","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-is-maximized","core:window:allow-close","core:window:allow-hide","core:window:allow-show","core:window:allow-set-focus","opener:default","dialog:default","global-shortcut:allow-register","global-shortcut:allow-unregister","notification:default"]}}

View File

@@ -2498,6 +2498,204 @@
"const": "mcp:deny-list-tools", "const": "mcp:deny-list-tools",
"markdownDescription": "Denies the list_tools command without any pre-configured scope." "markdownDescription": "Denies the list_tools command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{ {
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string", "type": "string",

View File

@@ -2498,6 +2498,204 @@
"const": "mcp:deny-list-tools", "const": "mcp:deny-list-tools",
"markdownDescription": "Denies the list_tools command without any pre-configured scope." "markdownDescription": "Denies the list_tools command without any pre-configured scope."
}, },
{
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string",
"const": "notification:default",
"markdownDescription": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`"
},
{
"description": "Enables the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-batch",
"markdownDescription": "Enables the batch command without any pre-configured scope."
},
{
"description": "Enables the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-cancel",
"markdownDescription": "Enables the cancel command without any pre-configured scope."
},
{
"description": "Enables the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-check-permissions",
"markdownDescription": "Enables the check_permissions command without any pre-configured scope."
},
{
"description": "Enables the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-create-channel",
"markdownDescription": "Enables the create_channel command without any pre-configured scope."
},
{
"description": "Enables the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-delete-channel",
"markdownDescription": "Enables the delete_channel command without any pre-configured scope."
},
{
"description": "Enables the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-active",
"markdownDescription": "Enables the get_active command without any pre-configured scope."
},
{
"description": "Enables the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-get-pending",
"markdownDescription": "Enables the get_pending command without any pre-configured scope."
},
{
"description": "Enables the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-is-permission-granted",
"markdownDescription": "Enables the is_permission_granted command without any pre-configured scope."
},
{
"description": "Enables the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-list-channels",
"markdownDescription": "Enables the list_channels command without any pre-configured scope."
},
{
"description": "Enables the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-notify",
"markdownDescription": "Enables the notify command without any pre-configured scope."
},
{
"description": "Enables the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-permission-state",
"markdownDescription": "Enables the permission_state command without any pre-configured scope."
},
{
"description": "Enables the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-action-types",
"markdownDescription": "Enables the register_action_types command without any pre-configured scope."
},
{
"description": "Enables the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-register-listener",
"markdownDescription": "Enables the register_listener command without any pre-configured scope."
},
{
"description": "Enables the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-remove-active",
"markdownDescription": "Enables the remove_active command without any pre-configured scope."
},
{
"description": "Enables the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-request-permission",
"markdownDescription": "Enables the request_permission command without any pre-configured scope."
},
{
"description": "Enables the show command without any pre-configured scope.",
"type": "string",
"const": "notification:allow-show",
"markdownDescription": "Enables the show command without any pre-configured scope."
},
{
"description": "Denies the batch command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-batch",
"markdownDescription": "Denies the batch command without any pre-configured scope."
},
{
"description": "Denies the cancel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-cancel",
"markdownDescription": "Denies the cancel command without any pre-configured scope."
},
{
"description": "Denies the check_permissions command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
{
"description": "Denies the create_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-create-channel",
"markdownDescription": "Denies the create_channel command without any pre-configured scope."
},
{
"description": "Denies the delete_channel command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-delete-channel",
"markdownDescription": "Denies the delete_channel command without any pre-configured scope."
},
{
"description": "Denies the get_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-active",
"markdownDescription": "Denies the get_active command without any pre-configured scope."
},
{
"description": "Denies the get_pending command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-get-pending",
"markdownDescription": "Denies the get_pending command without any pre-configured scope."
},
{
"description": "Denies the is_permission_granted command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-is-permission-granted",
"markdownDescription": "Denies the is_permission_granted command without any pre-configured scope."
},
{
"description": "Denies the list_channels command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-list-channels",
"markdownDescription": "Denies the list_channels command without any pre-configured scope."
},
{
"description": "Denies the notify command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-notify",
"markdownDescription": "Denies the notify command without any pre-configured scope."
},
{
"description": "Denies the permission_state command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-permission-state",
"markdownDescription": "Denies the permission_state command without any pre-configured scope."
},
{
"description": "Denies the register_action_types command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-action-types",
"markdownDescription": "Denies the register_action_types command without any pre-configured scope."
},
{
"description": "Denies the register_listener command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-register-listener",
"markdownDescription": "Denies the register_listener command without any pre-configured scope."
},
{
"description": "Denies the remove_active command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-remove-active",
"markdownDescription": "Denies the remove_active command without any pre-configured scope."
},
{
"description": "Denies the request_permission command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-request-permission",
"markdownDescription": "Denies the request_permission command without any pre-configured scope."
},
{
"description": "Denies the show command without any pre-configured scope.",
"type": "string",
"const": "notification:deny-show",
"markdownDescription": "Denies the show command without any pre-configured scope."
},
{ {
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string", "type": "string",

View File

@@ -0,0 +1,282 @@
use std::path::Path;
use crate::AppState;
use kon_storage::{list_subtasks, list_tasks, list_transcripts, TaskRow, TranscriptRow};
/// Slugify a string for use in filenames.
fn slugify(s: &str) -> String {
s.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
/// Parse a `created_at` timestamp into a filename-safe prefix.
/// Expected format: "YYYY-MM-DD HH:MM:SS" → "YYYY-MM-DD_HH-MM-SS"
fn timestamp_to_filename(created_at: &str) -> String {
created_at
.replace(' ', "_")
.replace(':', "-")
}
/// Build Markdown content for a single transcription with YAML frontmatter.
fn transcription_to_markdown(t: &TranscriptRow) -> String {
let title = t.title.as_deref().unwrap_or("Untitled");
let word_count = t.text.split_whitespace().count();
format!(
"---\ntitle: \"{}\"\ndate: \"{}\"\nduration_seconds: {}\nword_count: {}\n---\n\n{}",
title, t.created_at, t.duration, word_count, t.text
)
}
/// Build Obsidian-compatible Markdown for a transcription.
fn transcription_to_obsidian(t: &TranscriptRow, task_titles: &[String]) -> String {
let title = t.title.as_deref().unwrap_or("Untitled");
let mut md = format!(
"---\ntitle: \"{}\"\ndate: \"{}\"\nduration: {}\ntags: [kon, transcription]\n---\n\n{}",
title, t.created_at, t.duration, t.text
);
if !task_titles.is_empty() {
md.push_str("\n\n## Extracted Tasks\n\n");
for title in task_titles {
md.push_str(&format!("- [[{}]]\n", title));
}
}
md
}
/// Build Obsidian-compatible Markdown for a task.
fn task_to_obsidian(task: &TaskRow, subtasks: &[TaskRow]) -> String {
let title = &task.text;
let mut md = format!(
"---\ntitle: \"{}\"\nstatus: \"{}\"\npriority: \"{}\"\nproject: \"{}\"\ncreated: \"{}\"\nsource_transcription: \"{}\"\n---\n\n{}",
title,
task.status,
task.priority,
task.project.as_deref().unwrap_or(""),
task.created_at,
task.source_transcript_id.as_deref().unwrap_or(""),
task.text,
);
if !subtasks.is_empty() {
md.push_str("\n\n## Micro-steps\n\n");
for st in subtasks {
let check = if st.done { "x" } else { " " };
md.push_str(&format!("- [{}] {}\n", check, st.text));
}
}
md
}
/// Build a CSV row, escaping fields that contain commas or quotes.
fn csv_escape(field: &str) -> String {
if field.contains(',') || field.contains('"') || field.contains('\n') {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
}
}
/// Export all transcriptions as individual files (Markdown or plain text).
#[tauri::command]
pub async fn export_transcriptions(
state: tauri::State<'_, AppState>,
directory: String,
format: String,
) -> Result<u32, String> {
let dir = Path::new(&directory);
if !dir.exists() {
return Err(format!("Directory does not exist: {}", directory));
}
let transcripts = list_transcripts(&state.db, i64::MAX)
.await
.map_err(|e| e.to_string())?;
let mut count = 0u32;
for t in &transcripts {
let prefix = timestamp_to_filename(&t.created_at);
let (ext, content) = match format.as_str() {
"txt" => ("txt", t.text.clone()),
_ => ("md", transcription_to_markdown(t)),
};
let filename = format!("{}_transcription.{}", prefix, ext);
let path = dir.join(&filename);
std::fs::write(&path, &content)
.map_err(|e| format!("Failed to write {}: {}", filename, e))?;
count += 1;
}
Ok(count)
}
/// Export all top-level tasks with subtasks as JSON or CSV.
#[tauri::command]
pub async fn export_tasks(
state: tauri::State<'_, AppState>,
directory: String,
format: String,
) -> Result<String, String> {
let dir = Path::new(&directory);
if !dir.exists() {
return Err(format!("Directory does not exist: {}", directory));
}
let all_tasks = list_tasks(&state.db).await.map_err(|e| e.to_string())?;
let top_level: Vec<&TaskRow> = all_tasks.iter().filter(|t| t.parent_task_id.is_none()).collect();
let today = chrono::Local::now().format("%Y-%m-%d").to_string();
let (filename, content) = match format.as_str() {
"csv" => {
let mut csv = String::from("id,text,priority,project,status,bucket,effort,done,done_at,created_at,parent_task_id\n");
for task in &top_level {
csv.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},\n",
csv_escape(&task.id),
csv_escape(&task.text),
csv_escape(&task.priority),
csv_escape(task.project.as_deref().unwrap_or("")),
csv_escape(&task.status),
csv_escape(&task.bucket),
csv_escape(task.effort.as_deref().unwrap_or("")),
task.done,
csv_escape(task.done_at.as_deref().unwrap_or("")),
csv_escape(&task.created_at),
));
// Include subtasks
let subs = list_subtasks(&state.db, &task.id)
.await
.map_err(|e| e.to_string())?;
for st in &subs {
csv.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{}\n",
csv_escape(&st.id),
csv_escape(&st.text),
csv_escape(&st.priority),
csv_escape(st.project.as_deref().unwrap_or("")),
csv_escape(&st.status),
csv_escape(&st.bucket),
csv_escape(st.effort.as_deref().unwrap_or("")),
st.done,
csv_escape(st.done_at.as_deref().unwrap_or("")),
csv_escape(&st.created_at),
csv_escape(&task.id),
));
}
}
(format!("kon-tasks-{}.csv", today), csv)
}
_ => {
// JSON format
let mut tasks_json = Vec::new();
for task in &top_level {
let subs = list_subtasks(&state.db, &task.id)
.await
.map_err(|e| e.to_string())?;
let subtasks_json: Vec<serde_json::Value> = subs
.iter()
.map(|st| {
serde_json::json!({
"id": st.id,
"text": st.text,
"priority": st.priority,
"project": st.project,
"status": st.status,
"bucket": st.bucket,
"effort": st.effort,
"done": st.done,
"done_at": st.done_at,
"created_at": st.created_at,
})
})
.collect();
tasks_json.push(serde_json::json!({
"id": task.id,
"text": task.text,
"priority": task.priority,
"project": task.project,
"status": task.status,
"bucket": task.bucket,
"effort": task.effort,
"done": task.done,
"done_at": task.done_at,
"created_at": task.created_at,
"notes": task.notes,
"source_transcript_id": task.source_transcript_id,
"subtasks": subtasks_json,
}));
}
let json_str = serde_json::to_string_pretty(&tasks_json)
.map_err(|e| format!("JSON serialization failed: {}", e))?;
(format!("kon-tasks-{}.json", today), json_str)
}
};
let path = dir.join(&filename);
std::fs::write(&path, &content)
.map_err(|e| format!("Failed to write {}: {}", filename, e))?;
Ok(path.to_string_lossy().to_string())
}
/// Export transcriptions and tasks as Obsidian-compatible Markdown.
#[tauri::command]
pub async fn export_to_obsidian(
state: tauri::State<'_, AppState>,
directory: String,
) -> Result<u32, String> {
let dir = Path::new(&directory);
if !dir.exists() {
return Err(format!("Directory does not exist: {}", directory));
}
let transcripts = list_transcripts(&state.db, i64::MAX)
.await
.map_err(|e| e.to_string())?;
let all_tasks = list_tasks(&state.db).await.map_err(|e| e.to_string())?;
let top_level: Vec<&TaskRow> = all_tasks.iter().filter(|t| t.parent_task_id.is_none()).collect();
let mut count = 0u32;
// Export transcriptions
for t in &transcripts {
// Find tasks sourced from this transcription
let linked_titles: Vec<String> = top_level
.iter()
.filter(|task| task.source_transcript_id.as_deref() == Some(&t.id))
.map(|task| task.text.clone())
.collect();
let content = transcription_to_obsidian(t, &linked_titles);
let prefix = timestamp_to_filename(&t.created_at);
let filename = format!("{}_transcription.md", prefix);
let path = dir.join(&filename);
std::fs::write(&path, &content)
.map_err(|e| format!("Failed to write {}: {}", filename, e))?;
count += 1;
}
// Export tasks
for task in &top_level {
let subs = list_subtasks(&state.db, &task.id)
.await
.map_err(|e| e.to_string())?;
let content = task_to_obsidian(task, &subs);
let slug = slugify(&task.text);
let filename = format!("task-{}.md", slug);
let path = dir.join(&filename);
std::fs::write(&path, &content)
.map_err(|e| format!("Failed to write {}: {}", filename, e))?;
count += 1;
}
Ok(count)
}

View File

@@ -1,5 +1,6 @@
pub mod audio; pub mod audio;
pub mod clipboard; pub mod clipboard;
pub mod export;
pub mod hardware; pub mod hardware;
pub mod history; pub mod history;
pub mod llm; pub mod llm;

View File

@@ -192,6 +192,10 @@ pub fn run() {
// Hardware // Hardware
commands::hardware::probe_system, commands::hardware::probe_system,
commands::hardware::rank_models, commands::hardware::rank_models,
// Export
commands::export::export_transcriptions,
commands::export::export_tasks,
commands::export::export_to_obsidian,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running Kon"); .expect("error while running Kon");

View File

@@ -10,7 +10,7 @@
import ZonePicker from "$lib/components/ZonePicker.svelte"; import ZonePicker from "$lib/components/ZonePicker.svelte";
import AccessibilityControls from "$lib/components/AccessibilityControls.svelte"; import AccessibilityControls from "$lib/components/AccessibilityControls.svelte";
import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js"; import { getPreferences, updatePreferences } from "$lib/stores/preferences.svelte.js";
import { Check, ChevronRight } from "lucide-svelte"; import { Check, ChevronRight, Download } from "lucide-svelte";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
const prefs = getPreferences(); const prefs = getPreferences();
@@ -173,6 +173,37 @@
} }
} }
// --- Export state ---
let exportTranscriptFormat = $state("md");
let exportTaskFormat = $state("json");
let exportStatus = $state("");
let exporting = $state("");
async function doExport(type) {
try {
const folder = await open({ directory: true, title: "Select export folder" });
if (!folder) return;
exporting = type;
exportStatus = "";
if (type === "transcriptions") {
const count = await invoke("export_transcriptions", { directory: folder, format: exportTranscriptFormat });
exportStatus = count === 0 ? "No transcriptions yet" : `Exported ${count} transcription${count === 1 ? "" : "s"} to ${folder}`;
} else if (type === "tasks") {
const path = await invoke("export_tasks", { directory: folder, format: exportTaskFormat });
exportStatus = `Tasks exported to ${path}`;
} else if (type === "obsidian") {
const count = await invoke("export_to_obsidian", { directory: folder });
exportStatus = count === 0 ? "Nothing to export yet" : `Exported ${count} file${count === 1 ? "" : "s"} to ${folder}`;
}
} catch (err) {
exportStatus = typeof err === "string" ? err : "Export failed";
} finally {
exporting = "";
}
}
// --- Profile management --- // --- Profile management ---
function createProfile() { function createProfile() {
if (!newProfileName.trim()) return; if (!newProfileName.trim()) return;
@@ -621,6 +652,86 @@
{/if} {/if}
</div> </div>
<!-- Export -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={() => openSection = openSection === 'export' ? null : 'export'}
>
<h3 class="font-display text-[18px] italic text-text">Export Your Data</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'export' ? '' : '+'}</span>
</button>
{#if openSection === 'export'}
<div class="px-5 pb-5 animate-fade-in">
<p class="text-[11px] text-text-tertiary mb-4">All your data stays yours. Export everything as plain text files.</p>
<div class="space-y-3">
<!-- Export Transcriptions -->
<div class="flex items-center gap-3">
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium
bg-accent/10 text-accent hover:bg-accent/20 disabled:opacity-50"
disabled={exporting === "transcriptions"}
onclick={() => doExport("transcriptions")}
>
<Download class="w-3.5 h-3.5" />
{exporting === "transcriptions" ? "Exporting..." : "Export Transcriptions"}
</button>
<select
class="bg-bg-input border border-border-subtle rounded-lg px-2 py-1 text-[11px] text-text-secondary
focus:outline-none focus:border-accent appearance-none cursor-pointer"
bind:value={exportTranscriptFormat}
>
<option value="md">Markdown</option>
<option value="txt">Plain Text</option>
</select>
</div>
<!-- Export Tasks -->
<div class="flex items-center gap-3">
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium
bg-accent/10 text-accent hover:bg-accent/20 disabled:opacity-50"
disabled={exporting === "tasks"}
onclick={() => doExport("tasks")}
>
<Download class="w-3.5 h-3.5" />
{exporting === "tasks" ? "Exporting..." : "Export Tasks"}
</button>
<select
class="bg-bg-input border border-border-subtle rounded-lg px-2 py-1 text-[11px] text-text-secondary
focus:outline-none focus:border-accent appearance-none cursor-pointer"
bind:value={exportTaskFormat}
>
<option value="json">JSON</option>
<option value="csv">CSV</option>
</select>
</div>
<!-- Export to Obsidian -->
<div class="flex items-center gap-3">
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium
bg-accent/10 text-accent hover:bg-accent/20 disabled:opacity-50"
disabled={exporting === "obsidian"}
onclick={() => doExport("obsidian")}
>
<Download class="w-3.5 h-3.5" />
{exporting === "obsidian" ? "Exporting..." : "Export to Obsidian"}
</button>
<span class="text-[10px] text-text-tertiary">Markdown + wikilinks, both transcriptions and tasks</span>
</div>
</div>
{#if exportStatus}
<p class="text-[11px] text-text-secondary mt-3 bg-bg-input rounded-lg px-3 py-2 border border-border-subtle">
{exportStatus}
</p>
{/if}
</div>
{/if}
</div>
<!-- Hotkey --> <!-- Hotkey -->
<div class="border-b border-border-subtle"> <div class="border-b border-border-subtle">
<button <button