Something went wrong. Try again.
[mirror] A local-first desktop markdown writer with grammar checking, at proto integration, and more github.com/stormlightlabs/writer/releases
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507use super::AppState;use commonplace_core::{AppError, BackendEvent, DocId, ErrorCode, LocationId};use commonplace_store::{CaptureMode, GlobalCaptureSettings};use std::path::PathBuf;use tauri::{ menu::{Menu, MenuItem, PredefinedMenuItem}, tray::TrayIconBuilder, AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder,};use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
const TRAY_ICON_ID: &str = "global_capture_tray";
enum WindowLabels { QuickCaptureWindow, MainWindow,}
impl WindowLabels { fn as_str(&self) -> &'static str { match self { WindowLabels::QuickCaptureWindow => "quick_capture", WindowLabels::MainWindow => "main", } }}
enum MenuItemLabels { NewQuickNote, OpenCommonplace, ToggleShortcut, Quit, ShortcutStatus, TargetStatus,}
impl MenuItemLabels { fn as_str(&self) -> &'static str { match self { MenuItemLabels::NewQuickNote => "capture.new_quick_note", MenuItemLabels::OpenCommonplace => "capture.open_commonplace", MenuItemLabels::ToggleShortcut => "capture.toggle_shortcut", MenuItemLabels::Quit => "capture.quit", MenuItemLabels::ShortcutStatus => "capture.status.shortcut", MenuItemLabels::TargetStatus => "capture.status.target", } }
fn from_str(s: &str) -> Option<Self> { match s { "capture.new_quick_note" => Some(MenuItemLabels::NewQuickNote), "capture.open_commonplace" => Some(MenuItemLabels::OpenCommonplace), "capture.toggle_shortcut" => Some(MenuItemLabels::ToggleShortcut), "capture.quit" => Some(MenuItemLabels::Quit), "capture.status.shortcut" => Some(MenuItemLabels::ShortcutStatus), "capture.status.target" => Some(MenuItemLabels::TargetStatus), _ => None, } }}
/// Ensures the quick capture window exists and returns it.pub fn ensure_quick_capture_window(app: &AppHandle) -> Result<WebviewWindow, AppError> { if let Some(window) = app.get_webview_window(WindowLabels::QuickCaptureWindow.as_str()) { return Ok(window); }
let window = WebviewWindowBuilder::new( app, WindowLabels::QuickCaptureWindow.as_str(), WebviewUrl::App("/".into()), ) .title("Quick Capture") .inner_size(560.0, 420.0) .min_inner_size(360.0, 260.0) .resizable(true) .always_on_top(true) .visible(false) .center() .focused(true) .build() .map_err(|e| AppError::io(format!("Failed to create quick capture window: {}", e)))?;
Ok(window)}
/// Shows and focuses the quick capture window.pub fn show_quick_capture_window(app: &AppHandle) -> Result<(), AppError> { let window = ensure_quick_capture_window(app)?;
window .show() .map_err(|e| AppError::io(format!("Failed to show window: {}", e)))?; window .set_focus() .map_err(|e| AppError::io(format!("Failed to focus window: {}", e)))?;
Ok(())}
/// Shows and focuses the main window.pub fn show_main_window(app: &AppHandle) -> Result<(), AppError> { if let Some(window) = app.get_webview_window(WindowLabels::MainWindow.as_str()) { window .show() .map_err(|e| AppError::io(format!("Failed to show main window: {}", e)))?; window .set_focus() .map_err(|e| AppError::io(format!("Failed to focus main window: {}", e)))?; } Ok(())}
/// Registers a global shortcut.pub fn register_global_shortcut(app: &AppHandle, shortcut_str: &str) -> Result<(), AppError> { let shortcut = parse_shortcut(shortcut_str)?;
app.global_shortcut() .on_shortcut(shortcut, move |app, shortcut, event| { if event.state == ShortcutState::Pressed { log::info!("Global shortcut triggered: {:?}", shortcut); if let Err(e) = show_quick_capture_window(app) { log::error!("Failed to show quick capture window: {}", e); } } }) .map_err(|e| { AppError::new( ErrorCode::PermissionDenied, format!( "Failed to register global shortcut: {}. This may be due to a conflict with another application.", e ), ) })?;
log::info!("Global shortcut registered: {}", shortcut_str); Ok(())}
/// Reconciles shortcut registration based on current settings.pub fn reconcile_shortcut_registration(app: &AppHandle, settings: &GlobalCaptureSettings) -> Result<(), AppError> { let gs = app.global_shortcut(); if let Err(e) = gs.unregister_all() { log::warn!("Failed to unregister existing shortcuts: {}", e); }
if settings.enabled && !settings.paused { register_global_shortcut(app, &settings.shortcut)?; }
Ok(())}
fn tray_pause_label(paused: bool) -> &'static str { if paused { "Resume Shortcut" } else { "Pause Shortcut" }}
fn tray_shortcut_status(settings: &GlobalCaptureSettings) -> String { if !settings.enabled { return "Shortcut: Disabled".to_string(); } if settings.paused { return format!("Shortcut: Paused ({})", settings.shortcut); } format!("Shortcut: {}", settings.shortcut)}
fn tray_target_status(settings: &GlobalCaptureSettings) -> String { match settings.last_capture_target.as_deref() { Some(target) => format!("Last target: {}", target), None => "Last target: none".to_string(), }}
fn toggle_shortcut_pause_from_tray(app: &AppHandle) -> Result<(), AppError> { let state = app.state::<AppState>(); let mut settings = state.store.global_capture_get()?; settings.paused = !settings.paused; state.store.global_capture_set(&settings)?; reconcile_shortcut_registration(app, &settings)?; build_or_update_tray_menu(app, &settings)?; Ok(())}
fn handle_tray_menu_action(app: &AppHandle, item_id: &str) -> Result<(), AppError> { match MenuItemLabels::from_str(item_id) { Some(MenuItemLabels::NewQuickNote) => show_quick_capture_window(app), Some(MenuItemLabels::OpenCommonplace) => show_main_window(app), Some(MenuItemLabels::ToggleShortcut) => toggle_shortcut_pause_from_tray(app), Some(MenuItemLabels::Quit) => { app.exit(0); Ok(()) } _ => Ok(()), }}
/// Builds or updates tray menu/icon according to current global capture settings.pub fn build_or_update_tray_menu(app: &AppHandle, settings: &GlobalCaptureSettings) -> Result<(), AppError> { if !settings.show_tray_icon { if let Some(tray) = app.tray_by_id(TRAY_ICON_ID) { tray.set_visible(false) .map_err(|e| AppError::io(format!("Failed to hide tray icon: {}", e)))?; } return Ok(()); }
let new_quick_note = MenuItem::with_id( app, MenuItemLabels::NewQuickNote.as_str(), "New Quick Note", true, None::<&str>, ) .map_err(|e| AppError::io(format!("Failed to create tray menu item: {}", e)))?; let open_commonplace = MenuItem::with_id( app, MenuItemLabels::OpenCommonplace.as_str(), "Open Commonplace", true, None::<&str>, ) .map_err(|e| AppError::io(format!("Failed to create tray menu item: {}", e)))?; let toggle_shortcut = MenuItem::with_id( app, MenuItemLabels::ToggleShortcut.as_str(), tray_pause_label(settings.paused), true, None::<&str>, ) .map_err(|e| AppError::io(format!("Failed to create tray menu item: {}", e)))?; let shortcut_status = MenuItem::with_id( app, MenuItemLabels::ShortcutStatus.as_str(), tray_shortcut_status(settings), false, None::<&str>, ) .map_err(|e| AppError::io(format!("Failed to create tray menu item: {}", e)))?; let target_status = MenuItem::with_id( app, MenuItemLabels::TargetStatus.as_str(), tray_target_status(settings), false, None::<&str>, ) .map_err(|e| AppError::io(format!("Failed to create tray menu item: {}", e)))?; let separator_a = PredefinedMenuItem::separator(app) .map_err(|e| AppError::io(format!("Failed to create tray menu separator: {}", e)))?; let separator_b = PredefinedMenuItem::separator(app) .map_err(|e| AppError::io(format!("Failed to create tray menu separator: {}", e)))?; let quit = MenuItem::with_id(app, MenuItemLabels::Quit.as_str(), "Quit", true, None::<&str>) .map_err(|e| AppError::io(format!("Failed to create tray menu item: {}", e)))?;
let menu = Menu::with_items( app, &[ &new_quick_note, &open_commonplace, &toggle_shortcut, &separator_a, &shortcut_status, &target_status, &separator_b, &quit, ], ) .map_err(|e| AppError::io(format!("Failed to build tray menu: {}", e)))?;
if let Some(tray) = app.tray_by_id(TRAY_ICON_ID) { tray.set_menu(Some(menu)) .map_err(|e| AppError::io(format!("Failed to update tray menu: {}", e)))?; tray.set_visible(true) .map_err(|e| AppError::io(format!("Failed to show tray icon: {}", e)))?; return Ok(()); }
let mut builder = TrayIconBuilder::with_id(TRAY_ICON_ID) .menu(&menu) .tooltip("Commonplace") .show_menu_on_left_click(true) .on_menu_event(|app, event| { let menu_id = event.id().as_ref(); if let Err(e) = handle_tray_menu_action(app, menu_id) { log::error!("Tray menu action failed ({}): {}", menu_id, e); } });
if let Some(icon) = app.default_window_icon().cloned() { builder = builder.icon(icon); }
builder .build(app) .map_err(|e| AppError::io(format!("Failed to create tray icon: {}", e)))?;
Ok(())}
/// Reconciles all runtime capture integrations (shortcut + tray).pub fn reconcile_capture_runtime(app: &AppHandle, settings: &GlobalCaptureSettings) -> Result<(), AppError> { reconcile_shortcut_registration(app, settings)?; build_or_update_tray_menu(app, settings)?; Ok(())}
fn emit_capture_saved_event(app: &AppHandle, doc_id: &DocId, mtime: chrono::DateTime<chrono::Utc>) { let event = BackendEvent::DocModifiedExternally { doc_id: doc_id.clone(), new_mtime: mtime }; if let Err(error) = app.emit("backend-event", event) { log::warn!("Failed to emit DocModifiedExternally event for capture save: {}", error); }}
/// Parses a shortcut string into a Shortcut object.fn parse_shortcut(shortcut_str: &str) -> Result<Shortcut, AppError> { shortcut_str.parse().map_err(|e| { AppError::new( ErrorCode::InvalidPath, format!("Invalid shortcut format '{}': {}", shortcut_str, e), ) })}
/// Generates a filename for a quick note capture.pub fn generate_quick_note_path(inbox_dir: &str) -> PathBuf { let now = chrono::Utc::now(); let year = now.format("%Y").to_string(); let filename = format!("{}_{}.md", now.format("%Y_%m_%d"), now.timestamp_millis());
PathBuf::from(inbox_dir).join(year).join(filename)}
/// Validates a shortcut string format.pub fn validate_shortcut_format(shortcut: &str) -> Result<(), AppError> { if shortcut.trim().is_empty() { return Err(AppError::new(ErrorCode::InvalidPath, "Shortcut cannot be empty")); }
let _: Shortcut = shortcut .parse() .map_err(|e| AppError::new(ErrorCode::InvalidPath, format!("Invalid shortcut format: {}", e)))?;
Ok(())}
/// Handles capture submission based on mode.pub async fn handle_capture_submit( app: &AppHandle, mode: CaptureMode, text: String, target_location_id: Option<i64>, inbox_dir: &str, append_target: &Option<commonplace_store::CaptureDocRef>, close_after_save: bool,) -> Result<CaptureSubmitResult, AppError> { let state = app.state::<AppState>();
if text.trim().is_empty() { return Err(AppError::new(ErrorCode::InvalidPath, "Capture text cannot be empty")); }
let location_id = match target_location_id { Some(id) => LocationId(id), None => { let locations = state.store.location_list()?; match locations.first() { Some(loc) => loc.id, None => { return Err(AppError::new( ErrorCode::NotFound, "No location configured. Please add a location first.", )); } } } };
match mode { CaptureMode::QuickNote => { let rel_path = generate_quick_note_path(inbox_dir); let doc_id = DocId::new(location_id, rel_path.clone())?; let result = state.store.doc_save(&doc_id, &text, None)?; if result.success { let new_mtime = result .new_meta .as_ref() .map(|meta| meta.mtime) .unwrap_or_else(chrono::Utc::now); emit_capture_saved_event(app, &doc_id, new_mtime); } let target_str = format!("{}/{}", location_id.0, rel_path.to_string_lossy());
Ok(CaptureSubmitResult { success: result.success, saved_to: Some(rel_path.to_string_lossy().to_string()), location_id: location_id.0, should_close: close_after_save, last_capture_target: Some(target_str), }) } CaptureMode::Append => { let target = match append_target { Some(t) => t, None => { return Err(AppError::new(ErrorCode::InvalidPath, "No append target configured")); } };
let target_location = LocationId(target.location_id); let rel_path = PathBuf::from(&target.rel_path); let doc_id = DocId::new(target_location, rel_path)?; let existing_text = state.store.doc_open(&doc_id).map(|c| c.text).unwrap_or_default(); let new_text = if existing_text.is_empty() { text } else { format!("{}\n\n{}", existing_text, text) }; let result = state.store.doc_save(&doc_id, &new_text, None)?; if result.success { let new_mtime = result .new_meta .as_ref() .map(|meta| meta.mtime) .unwrap_or_else(chrono::Utc::now); emit_capture_saved_event(app, &doc_id, new_mtime); } let target_str = format!("{}/{}", target.location_id, target.rel_path);
Ok(CaptureSubmitResult { success: result.success, saved_to: Some(target.rel_path.clone()), location_id: target.location_id, should_close: close_after_save, last_capture_target: Some(target_str), }) } CaptureMode::WritingSession => { let rel_path = generate_quick_note_path(inbox_dir); let doc_id = DocId::new(location_id, rel_path.clone())?; let result = state.store.doc_save(&doc_id, &text, None)?; if result.success { let new_mtime = result .new_meta .as_ref() .map(|meta| meta.mtime) .unwrap_or_else(chrono::Utc::now); emit_capture_saved_event(app, &doc_id, new_mtime); } let target_str = format!("{}/{}", location_id.0, rel_path.to_string_lossy());
Ok(CaptureSubmitResult { success: result.success, saved_to: Some(rel_path.to_string_lossy().to_string()), location_id: location_id.0, should_close: false, last_capture_target: Some(target_str), }) } }}
/// Result of a capture submission.#[derive(Debug, Clone, serde::Serialize)]pub struct CaptureSubmitResult { pub success: bool, pub saved_to: Option<String>, pub location_id: i64, pub should_close: bool, pub last_capture_target: Option<String>,}
/// Updates the last capture target in settings and refreshes tray status text.pub fn update_last_capture_target(app: &AppHandle, target: Option<String>) -> Result<(), AppError> { let state = app.state::<AppState>(); let mut settings = state.store.global_capture_get()?; settings.last_capture_target = target; state.store.global_capture_set(&settings)?; build_or_update_tray_menu(app, &settings)?; Ok(())}
#[cfg(test)]mod tests { use super::{generate_quick_note_path, validate_shortcut_format};
#[test] fn validate_shortcut_accepts_known_valid_shortcuts() { assert!(validate_shortcut_format("CommandOrControl+Shift+Space").is_ok()); assert!(validate_shortcut_format("Alt+KeyN").is_ok()); }
#[test] fn validate_shortcut_rejects_empty_shortcut() { assert!(validate_shortcut_format("").is_err()); assert!(validate_shortcut_format(" ").is_err()); }
#[test] fn generate_quick_note_path_uses_inbox_year_and_markdown_extension() { let path = generate_quick_note_path("inbox"); let path_str = path.to_string_lossy(); let parts: Vec<&str> = path_str.split('/').collect();
assert!(parts.len() >= 3); assert_eq!(parts[0], "inbox"); assert_eq!(parts[1].len(), 4); assert!(parts[2].ends_with(".md")); assert!(parts[2].starts_with(&format!("{}_", chrono::Utc::now().format("%Y_%m_%d")))); }}