From 0ae431b8ac4bbd6775f4766ff1147867bc8ac33b Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 20 Feb 2026 15:40:02 +0200 Subject: [PATCH] feat: comprehensive export with all tables and auto-backup command --- src-tauri/src/commands.rs | 451 +++++++++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 1 + 2 files changed, 449 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3869bd3..5c503a0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -716,6 +716,19 @@ pub fn export_data(state: State) -> Result rows }; + let tasks = { + let mut stmt = conn.prepare("SELECT id, project_id, name, estimated_hours FROM tasks").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "project_id": row.get::<_, i64>(1)?, + "name": row.get::<_, String>(2)?, + "estimated_hours": row.get::<_, Option>(3)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + let time_entries = { let mut stmt = conn.prepare("SELECT id, project_id, task_id, description, start_time, end_time, duration, billable FROM time_entries").map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map([], |row| { @@ -733,8 +746,31 @@ pub fn export_data(state: State) -> Result rows }; + let tags = { + let mut stmt = conn.prepare("SELECT id, name, color FROM tags").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "name": row.get::<_, String>(1)?, + "color": row.get::<_, Option>(2)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let entry_tags = { + let mut stmt = conn.prepare("SELECT entry_id, tag_id FROM entry_tags").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "entry_id": row.get::<_, i64>(0)?, + "tag_id": row.get::<_, i64>(1)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + let invoices = { - let mut stmt = conn.prepare("SELECT id, client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status FROM invoices").map_err(|e| e.to_string())?; + let mut stmt = conn.prepare("SELECT id, client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id FROM invoices").map_err(|e| e.to_string())?; let rows: Vec = stmt.query_map([], |row| { Ok(serde_json::json!({ "id": row.get::<_, i64>(0)?, @@ -748,7 +784,193 @@ pub fn export_data(state: State) -> Result "discount": row.get::<_, f64>(8)?, "total": row.get::<_, f64>(9)?, "notes": row.get::<_, Option>(10)?, - "status": row.get::<_, String>(11)? + "status": row.get::<_, String>(11)?, + "template_id": row.get::<_, Option>(12)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let invoice_items = { + let mut stmt = conn.prepare("SELECT id, invoice_id, description, quantity, rate, amount, time_entry_id FROM invoice_items").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "invoice_id": row.get::<_, i64>(1)?, + "description": row.get::<_, String>(2)?, + "quantity": row.get::<_, f64>(3)?, + "rate": row.get::<_, f64>(4)?, + "amount": row.get::<_, f64>(5)?, + "time_entry_id": row.get::<_, Option>(6)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let tracked_apps = { + let mut stmt = conn.prepare("SELECT id, project_id, exe_name, exe_path, display_name FROM tracked_apps").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "project_id": row.get::<_, i64>(1)?, + "exe_name": row.get::<_, String>(2)?, + "exe_path": row.get::<_, Option>(3)?, + "display_name": row.get::<_, Option>(4)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let favorites = { + let mut stmt = conn.prepare("SELECT id, project_id, task_id, description, sort_order FROM favorites").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "project_id": row.get::<_, i64>(1)?, + "task_id": row.get::<_, Option>(2)?, + "description": row.get::<_, Option>(3)?, + "sort_order": row.get::<_, i32>(4)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let recurring_entries = { + let mut stmt = conn.prepare("SELECT id, project_id, task_id, description, duration, recurrence_rule, time_of_day, mode, enabled, last_triggered FROM recurring_entries").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "project_id": row.get::<_, i64>(1)?, + "task_id": row.get::<_, Option>(2)?, + "description": row.get::<_, Option>(3)?, + "duration": row.get::<_, i64>(4)?, + "recurrence_rule": row.get::<_, String>(5)?, + "time_of_day": row.get::<_, Option>(6)?, + "mode": row.get::<_, Option>(7)?, + "enabled": row.get::<_, i32>(8)?, + "last_triggered": row.get::<_, Option>(9)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let expenses = { + let mut stmt = conn.prepare("SELECT id, project_id, client_id, category, description, amount, date, receipt_path, invoiced FROM expenses").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "project_id": row.get::<_, i64>(1)?, + "client_id": row.get::<_, Option>(2)?, + "category": row.get::<_, Option>(3)?, + "description": row.get::<_, Option>(4)?, + "amount": row.get::<_, f64>(5)?, + "date": row.get::<_, String>(6)?, + "receipt_path": row.get::<_, Option>(7)?, + "invoiced": row.get::<_, i32>(8)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let timeline_events = { + let mut stmt = conn.prepare("SELECT id, project_id, exe_name, exe_path, window_title, started_at, ended_at, duration FROM timeline_events").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "project_id": row.get::<_, i64>(1)?, + "exe_name": row.get::<_, Option>(2)?, + "exe_path": row.get::<_, Option>(3)?, + "window_title": row.get::<_, Option>(4)?, + "started_at": row.get::<_, String>(5)?, + "ended_at": row.get::<_, Option>(6)?, + "duration": row.get::<_, i64>(7)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let calendar_sources = { + let mut stmt = conn.prepare("SELECT id, name, type, url, last_synced, sync_interval, enabled FROM calendar_sources").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "name": row.get::<_, String>(1)?, + "source_type": row.get::<_, String>(2)?, + "url": row.get::<_, Option>(3)?, + "last_synced": row.get::<_, Option>(4)?, + "sync_interval": row.get::<_, i32>(5)?, + "enabled": row.get::<_, i32>(6)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let calendar_events = { + let mut stmt = conn.prepare("SELECT id, source_id, uid, summary, start_time, end_time, duration, location FROM calendar_events").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "source_id": row.get::<_, i64>(1)?, + "uid": row.get::<_, Option>(2)?, + "summary": row.get::<_, Option>(3)?, + "start_time": row.get::<_, Option>(4)?, + "end_time": row.get::<_, Option>(5)?, + "duration": row.get::<_, i64>(6)?, + "location": row.get::<_, Option>(7)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let timesheet_locks = { + let mut stmt = conn.prepare("SELECT id, week_start, status, locked_at FROM timesheet_locks").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "week_start": row.get::<_, String>(1)?, + "status": row.get::<_, Option>(2)?, + "locked_at": row.get::<_, Option>(3)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let entry_templates = { + let mut stmt = conn.prepare("SELECT id, name, project_id, task_id, description, duration, billable FROM entry_templates").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "name": row.get::<_, String>(1)?, + "project_id": row.get::<_, i64>(2)?, + "task_id": row.get::<_, Option>(3)?, + "description": row.get::<_, Option>(4)?, + "duration": row.get::<_, i64>(5)?, + "billable": row.get::<_, i32>(6)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let timesheet_rows = { + let mut stmt = conn.prepare("SELECT id, week_start, project_id, task_id, sort_order FROM timesheet_rows").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "id": row.get::<_, i64>(0)?, + "week_start": row.get::<_, String>(1)?, + "project_id": row.get::<_, i64>(2)?, + "task_id": row.get::<_, Option>(3)?, + "sort_order": row.get::<_, i32>(4)? + })) + }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; + rows + }; + + let settings = { + let mut stmt = conn.prepare("SELECT key, value FROM settings").map_err(|e| e.to_string())?; + let rows: Vec = stmt.query_map([], |row| { + Ok(serde_json::json!({ + "key": row.get::<_, String>(0)?, + "value": row.get::<_, Option>(1)? })) }).map_err(|e| e.to_string())?.collect::, _>>().map_err(|e| e.to_string())?; rows @@ -757,8 +979,23 @@ pub fn export_data(state: State) -> Result Ok(serde_json::json!({ "clients": clients, "projects": projects, + "tasks": tasks, "time_entries": time_entries, - "invoices": invoices + "tags": tags, + "entry_tags": entry_tags, + "invoices": invoices, + "invoice_items": invoice_items, + "tracked_apps": tracked_apps, + "favorites": favorites, + "recurring_entries": recurring_entries, + "expenses": expenses, + "timeline_events": timeline_events, + "calendar_sources": calendar_sources, + "calendar_events": calendar_events, + "timesheet_locks": timesheet_locks, + "entry_templates": entry_templates, + "timesheet_rows": timesheet_rows, + "settings": settings })) } @@ -1667,6 +1904,23 @@ pub fn import_json_data(state: State, data: serde_json::Value) -> Resu } } + // Import tasks + if let Some(tasks) = data.get("tasks").and_then(|v| v.as_array()) { + for task in tasks { + let name = task.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if name.is_empty() { continue; } + let project_id = task.get("project_id").and_then(|v| v.as_i64()); + if let Some(pid) = project_id { + let estimated_hours = task.get("estimated_hours").and_then(|v| v.as_f64()); + conn.execute( + "INSERT OR IGNORE INTO tasks (project_id, name, estimated_hours) VALUES (?1, ?2, ?3)", + params![pid, name, estimated_hours], + ).map_err(|e| e.to_string())?; + counts["tasks"] = serde_json::json!(counts["tasks"].as_i64().unwrap_or(0) + 1); + } + } + } + // Import time entries if let Some(entries) = data.get("time_entries").and_then(|v| v.as_array()) { for entry in entries { @@ -1699,6 +1953,186 @@ pub fn import_json_data(state: State, data: serde_json::Value) -> Resu } } + // Import entry_tags + if let Some(et_list) = data.get("entry_tags").and_then(|v| v.as_array()) { + for et in et_list { + let entry_id = et.get("entry_id").and_then(|v| v.as_i64()); + let tag_id = et.get("tag_id").and_then(|v| v.as_i64()); + if let (Some(eid), Some(tid)) = (entry_id, tag_id) { + conn.execute( + "INSERT OR IGNORE INTO entry_tags (entry_id, tag_id) VALUES (?1, ?2)", + params![eid, tid], + ).ok(); + } + } + } + + // Import invoices + if let Some(invoices) = data.get("invoices").and_then(|v| v.as_array()) { + for inv in invoices { + let client_id = inv.get("client_id").and_then(|v| v.as_i64()).unwrap_or(0); + let invoice_number = inv.get("invoice_number").and_then(|v| v.as_str()).unwrap_or(""); + if invoice_number.is_empty() { continue; } + let date = inv.get("date").and_then(|v| v.as_str()).unwrap_or(""); + let due_date = inv.get("due_date").and_then(|v| v.as_str()); + let subtotal = inv.get("subtotal").and_then(|v| v.as_f64()).unwrap_or(0.0); + let tax_rate = inv.get("tax_rate").and_then(|v| v.as_f64()).unwrap_or(0.0); + let tax_amount = inv.get("tax_amount").and_then(|v| v.as_f64()).unwrap_or(0.0); + let discount = inv.get("discount").and_then(|v| v.as_f64()).unwrap_or(0.0); + let total = inv.get("total").and_then(|v| v.as_f64()).unwrap_or(0.0); + let notes = inv.get("notes").and_then(|v| v.as_str()); + let status = inv.get("status").and_then(|v| v.as_str()).unwrap_or("draft"); + let template_id = inv.get("template_id").and_then(|v| v.as_str()); + conn.execute( + "INSERT INTO invoices (client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + params![client_id, invoice_number, date, due_date, subtotal, tax_rate, tax_amount, discount, total, notes, status, template_id], + ).ok(); + } + } + + // Import invoice_items + if let Some(items) = data.get("invoice_items").and_then(|v| v.as_array()) { + for item in items { + let invoice_id = item.get("invoice_id").and_then(|v| v.as_i64()).unwrap_or(0); + let description = item.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let quantity = item.get("quantity").and_then(|v| v.as_f64()).unwrap_or(1.0); + let rate = item.get("rate").and_then(|v| v.as_f64()).unwrap_or(0.0); + let amount = item.get("amount").and_then(|v| v.as_f64()).unwrap_or(0.0); + let time_entry_id = item.get("time_entry_id").and_then(|v| v.as_i64()); + conn.execute( + "INSERT INTO invoice_items (invoice_id, description, quantity, rate, amount, time_entry_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![invoice_id, description, quantity, rate, amount, time_entry_id], + ).ok(); + } + } + + // Import tracked_apps + if let Some(apps) = data.get("tracked_apps").and_then(|v| v.as_array()) { + for app in apps { + let project_id = app.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); + let exe_name = app.get("exe_name").and_then(|v| v.as_str()).unwrap_or(""); + if exe_name.is_empty() { continue; } + let exe_path = app.get("exe_path").and_then(|v| v.as_str()); + let display_name = app.get("display_name").and_then(|v| v.as_str()); + conn.execute( + "INSERT INTO tracked_apps (project_id, exe_name, exe_path, display_name) VALUES (?1, ?2, ?3, ?4)", + params![project_id, exe_name, exe_path, display_name], + ).ok(); + } + } + + // Import favorites + if let Some(favs) = data.get("favorites").and_then(|v| v.as_array()) { + for fav in favs { + let project_id = fav.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); + let task_id = fav.get("task_id").and_then(|v| v.as_i64()); + let description = fav.get("description").and_then(|v| v.as_str()); + let sort_order = fav.get("sort_order").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + conn.execute( + "INSERT INTO favorites (project_id, task_id, description, sort_order) VALUES (?1, ?2, ?3, ?4)", + params![project_id, task_id, description, sort_order], + ).ok(); + } + } + + // Import recurring_entries + if let Some(recs) = data.get("recurring_entries").and_then(|v| v.as_array()) { + for rec in recs { + let project_id = rec.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); + let task_id = rec.get("task_id").and_then(|v| v.as_i64()); + let description = rec.get("description").and_then(|v| v.as_str()); + let duration = rec.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); + let recurrence_rule = rec.get("recurrence_rule").and_then(|v| v.as_str()).unwrap_or(""); + if recurrence_rule.is_empty() { continue; } + let time_of_day = rec.get("time_of_day").and_then(|v| v.as_str()); + let mode = rec.get("mode").and_then(|v| v.as_str()); + let enabled = rec.get("enabled").and_then(|v| v.as_i64()).unwrap_or(1) as i32; + let last_triggered = rec.get("last_triggered").and_then(|v| v.as_str()); + conn.execute( + "INSERT INTO recurring_entries (project_id, task_id, description, duration, recurrence_rule, time_of_day, mode, enabled, last_triggered) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![project_id, task_id, description, duration, recurrence_rule, time_of_day, mode, enabled, last_triggered], + ).ok(); + } + } + + // Import expenses + if let Some(exps) = data.get("expenses").and_then(|v| v.as_array()) { + for exp in exps { + let project_id = exp.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); + let client_id = exp.get("client_id").and_then(|v| v.as_i64()); + let category = exp.get("category").and_then(|v| v.as_str()); + let description = exp.get("description").and_then(|v| v.as_str()); + let amount = exp.get("amount").and_then(|v| v.as_f64()).unwrap_or(0.0); + let date = exp.get("date").and_then(|v| v.as_str()).unwrap_or(""); + if date.is_empty() { continue; } + let receipt_path = exp.get("receipt_path").and_then(|v| v.as_str()); + let invoiced = exp.get("invoiced").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + conn.execute( + "INSERT INTO expenses (project_id, client_id, category, description, amount, date, receipt_path, invoiced) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![project_id, client_id, category, description, amount, date, receipt_path, invoiced], + ).ok(); + } + } + + // Import settings + if let Some(settings_list) = data.get("settings").and_then(|v| v.as_array()) { + for setting in settings_list { + let key = setting.get("key").and_then(|v| v.as_str()).unwrap_or(""); + if key.is_empty() { continue; } + let value = setting.get("value").and_then(|v| v.as_str()); + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![key, value], + ).ok(); + } + } + + // Import entry_templates + if let Some(templates) = data.get("entry_templates").and_then(|v| v.as_array()) { + for tmpl in templates { + let name = tmpl.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if name.is_empty() { continue; } + let project_id = tmpl.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); + let task_id = tmpl.get("task_id").and_then(|v| v.as_i64()); + let description = tmpl.get("description").and_then(|v| v.as_str()); + let duration = tmpl.get("duration").and_then(|v| v.as_i64()).unwrap_or(0); + let billable = tmpl.get("billable").and_then(|v| v.as_i64()).unwrap_or(1) as i32; + conn.execute( + "INSERT INTO entry_templates (name, project_id, task_id, description, duration, billable) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![name, project_id, task_id, description, duration, billable], + ).ok(); + } + } + + // Import timesheet_rows + if let Some(rows) = data.get("timesheet_rows").and_then(|v| v.as_array()) { + for row in rows { + let week_start = row.get("week_start").and_then(|v| v.as_str()).unwrap_or(""); + if week_start.is_empty() { continue; } + let project_id = row.get("project_id").and_then(|v| v.as_i64()).unwrap_or(0); + let task_id = row.get("task_id").and_then(|v| v.as_i64()); + let sort_order = row.get("sort_order").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + conn.execute( + "INSERT INTO timesheet_rows (week_start, project_id, task_id, sort_order) VALUES (?1, ?2, ?3, ?4)", + params![week_start, project_id, task_id, sort_order], + ).ok(); + } + } + + // Import timesheet_locks + if let Some(locks) = data.get("timesheet_locks").and_then(|v| v.as_array()) { + for lock in locks { + let week_start = lock.get("week_start").and_then(|v| v.as_str()).unwrap_or(""); + if week_start.is_empty() { continue; } + let status = lock.get("status").and_then(|v| v.as_str()); + let locked_at = lock.get("locked_at").and_then(|v| v.as_str()); + conn.execute( + "INSERT OR IGNORE INTO timesheet_locks (week_start, status, locked_at) VALUES (?1, ?2, ?3)", + params![week_start, status, locked_at], + ).ok(); + } + } + Ok(counts) } @@ -1794,6 +2228,17 @@ pub fn get_invoice_templates(state: State) -> Result, backup_dir: String) -> Result { + let data = export_data(state)?; + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + let filename = format!("zeroclock-backup-{}.json", today); + let path = std::path::Path::new(&backup_dir).join(&filename); + let json = serde_json::to_string_pretty(&data).map_err(|e| e.to_string())?; + std::fs::write(&path, json).map_err(|e| e.to_string())?; + Ok(path.to_string_lossy().to_string()) +} + pub fn seed_default_templates(data_dir: &std::path::Path) { let templates_dir = data_dir.join("templates"); std::fs::create_dir_all(&templates_dir).ok(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1286843..d24979a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -138,6 +138,7 @@ pub fn run() { commands::get_timesheet_rows, commands::save_timesheet_rows, commands::get_previous_week_structure, + commands::auto_backup, ]) .setup(|app| { #[cfg(desktop)]