clipmem 0.4.0

macOS clipboard memory backed by SQLite and searchable from agent runtimes
Documentation
use super::*;

pub(in crate::cli) fn watch(db_path: &Path, args: &WatchArgs) -> Result<()> {
    let mut db = open_or_init_db(db_path)?;
    let interval_ms = args.interval_ms.max(50);
    let mut state = WatchState::new();

    loop {
        if let Err(err) = run_watch_iteration(&mut db, args, &mut state) {
            eprintln!("{err:#}");
        }

        thread::sleep(Duration::from_millis(interval_ms));
    }
}

pub(in crate::cli) fn run_watch_iteration(
    db: &mut Database,
    args: &WatchArgs,
    state: &mut WatchState,
) -> Result<()> {
    run_watch_iteration_with_capture(db, args, state, current_change_count, capture_snapshot)
}

pub(in crate::cli) fn run_watch_iteration_with_capture<CountFn, CaptureFn>(
    db: &mut Database,
    args: &WatchArgs,
    state: &mut WatchState,
    current_change_count_fn: CountFn,
    capture_snapshot_fn: CaptureFn,
) -> Result<()>
where
    CountFn: FnOnce() -> Result<i64>,
    CaptureFn: FnOnce() -> Result<ClipboardSnapshot>,
{
    let change_count = anyhow::Context::context(
        current_change_count_fn(),
        "read clipboard change count failed",
    )?;

    if !crate::app::should_capture_change(change_count, args.skip_initial, state) {
        return Ok(());
    }

    let snapshot = anyhow::Context::context(capture_snapshot_fn(), "capture failed")?;
    let settings = db
        .capture_settings()
        .context("load capture settings failed")?;
    if settings.paused() {
        mark_change_handled(snapshot.change_count(), state);
        return Ok(());
    }
    if db
        .bundle_id_is_ignored(snapshot.frontmost_app_bundle_id())
        .context("load ignored bundle ids failed")?
    {
        mark_change_handled(snapshot.change_count(), state);
        return Ok(());
    }
    let outcome = anyhow::Context::context(
        db.store_watched_capture_if_allowed(&snapshot),
        "database write failed",
    )?;
    match outcome {
        CaptureStoreOutcome::Stored(result) => {
            if settings.ocr_enabled() {
                let snapshot_id = result.snapshot_id();
                if let Err(err) = db.enqueue_ocr_for_snapshot(snapshot_id) {
                    eprintln!("ocr enqueue failed for snapshot {snapshot_id}: {err:#}");
                } else {
                    start_ocr_worker(db.path().to_path_buf());
                }
            }
            mark_change_handled(snapshot.change_count(), state);
            db.apply_retention_policy()
                .context("apply retention policy failed")?;
            if !args.quiet {
                println!("{}", format_watch_capture_line(&snapshot, &result));
            }
        }
        CaptureStoreOutcome::Skipped(reason) => {
            mark_change_handled(snapshot.change_count(), state);
            if !args.quiet {
                println!(
                    "skipped capture reason={} kind={} bytes={} source={}",
                    capture_skip_reason_label(reason),
                    snapshot.snapshot_kind(),
                    snapshot.total_bytes(),
                    snapshot
                        .frontmost_app_name()
                        .or(snapshot.frontmost_app_bundle_id())
                        .unwrap_or("unknown app")
                );
            }
        }
    }

    Ok(())
}

pub(in crate::cli) fn start_ocr_worker(db_path: PathBuf) {
    if OCR_WORKER_RUNNING.swap(true, AtomicOrdering::AcqRel) {
        return;
    }

    thread::spawn(move || {
        struct ResetWorkerFlag;

        impl Drop for ResetWorkerFlag {
            fn drop(&mut self) {
                OCR_WORKER_RUNNING.store(false, AtomicOrdering::Release);
            }
        }

        let _reset = ResetWorkerFlag;
        let run = || -> Result<()> {
            let mut worker_db = Database::open_or_init(&db_path)?;
            let engine = crate::ocr::default_engine();
            loop {
                let report = crate::ocr::run_ocr_jobs(&mut worker_db, &engine, 1, None, false)?;
                if report.processed() == 0 {
                    break;
                }
            }
            Ok(())
        };
        if let Err(err) = run() {
            eprintln!("ocr failed: {err:#}");
        }
    });
}

pub(in crate::cli) fn capture_once(db_path: &Path, args: &CaptureOnceArgs) -> Result<()> {
    let mut db = open_or_init_db(db_path)?;
    let snapshot =
        anyhow::Context::context(capture_snapshot(), "capture-once clipboard read failed")?;
    let payload = match anyhow::Context::context(
        db.store_capture_if_allowed(&snapshot),
        "capture-once database write failed",
    )? {
        CaptureStoreOutcome::Stored(store) => {
            db.apply_retention_policy()
                .context("apply retention policy failed")?;
            CaptureOnceOutput::Stored(CaptureOnceStoredOutput { store, snapshot })
        }
        CaptureStoreOutcome::Skipped(reason) => {
            CaptureOnceOutput::Skipped(CaptureOnceSkippedOutput {
                status: "skipped",
                reason,
                kind: snapshot.snapshot_kind().as_str().to_string(),
                total_bytes: snapshot.total_bytes(),
                frontmost_app_name: snapshot.frontmost_app_name().map(str::to_string),
                frontmost_app_bundle_id: snapshot.frontmost_app_bundle_id().map(str::to_string),
            })
        }
    };
    if args.human {
        print!("{}", render_capture_once_human(&payload));
    } else {
        emit_json_or_text(args.json, &payload, render_capture_once_text)?;
    }

    Ok(())
}

pub(in crate::cli) fn open_existing_db(path: &Path) -> Result<Database> {
    if !path.is_file() {
        bail!(
            "database does not exist at {}. Run `clipmem setup` to initialize capture.",
            path.display()
        );
    }
    match Database::open_existing(path) {
        Ok(db) => {
            if let Err(error) = db.ensure_supported_schema_shape() {
                if error
                    .chain()
                    .any(|cause| cause.to_string().contains("prerelease schema"))
                {
                    bail!(
                        "database operation failed; this may be an incompatible prerelease schema. Move the database aside and run `clipmem setup`."
                    );
                }
                return Err(error);
            }
            Ok(db)
        }
        Err(error) => {
            if error
                .chain()
                .any(|cause| cause.to_string().contains("incompatible prerelease schema"))
            {
                Err(anyhow!(
                    "incompatible prerelease schema detected at {}. Move the database aside and run `clipmem setup`.",
                    path.display()
                ))
            } else {
                Err(error).with_context(|| format!("failed to open database at {}", path.display()))
            }
        }
    }
}

pub(in crate::cli) fn open_or_init_db(path: &Path) -> Result<Database> {
    anyhow::Context::with_context(Database::open_or_init(path), || {
        format!("failed to open database at {}", path.display())
    })
}