clipmem 0.1.2

macOS clipboard memory backed by SQLite and searchable from OpenClaw
Documentation
use anyhow::{Context, Result};
use rusqlite::{params, OptionalExtension, TransactionBehavior};

use crate::model::{CaptureStoreResult, ClipboardItem, ClipboardSnapshot};

use super::{usize_to_i64, Database};

impl Database {
    /// Store a captured clipboard snapshot and append a capture event for the observation.
    ///
    /// # Errors
    ///
    /// Returns an error if any transaction step fails, including snapshot, item,
    /// representation, or capture-event inserts, or the final commit.
    pub fn store_capture(&mut self, snapshot: &ClipboardSnapshot) -> Result<CaptureStoreResult> {
        let tx = self
            .conn
            .transaction_with_behavior(TransactionBehavior::Immediate)
            .context("begin capture transaction")?;

        let inserted_snapshot_id: Option<i64> = tx
            .query_row(
                "INSERT INTO snapshots (
                    sha256,
                    snapshot_kind,
                    preview_text,
                    search_text,
                    item_count,
                    total_bytes
                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
                ON CONFLICT(sha256) DO NOTHING
                RETURNING id",
                params![
                    snapshot.fingerprint(),
                    snapshot.snapshot_kind().as_str(),
                    snapshot.preview_text(),
                    snapshot.search_text(),
                    usize_to_i64(snapshot.item_count())?,
                    usize_to_i64(snapshot.total_bytes())?,
                ],
                |row| row.get(0),
            )
            .optional()
            .context("insert snapshot row")?;

        let snapshot_id = if let Some(id) = inserted_snapshot_id {
            for item in snapshot.items() {
                insert_item(&tx, id, item)
                    .with_context(|| format!("insert snapshot item {}", item.item_index()))?;
            }

            id
        } else {
            tx.query_row(
                "SELECT id FROM snapshots WHERE sha256 = ?1",
                [snapshot.fingerprint()],
                |row| row.get(0),
            )
            .context("lookup existing snapshot by fingerprint")?
        };

        tx.execute(
            "INSERT INTO capture_events (
                snapshot_id,
                change_count,
                frontmost_app_bundle_id,
                frontmost_app_name
            ) VALUES (?1, ?2, ?3, ?4)",
            params![
                snapshot_id,
                snapshot.change_count(),
                snapshot.frontmost_app_bundle_id(),
                snapshot.frontmost_app_name(),
            ],
        )
        .context("insert capture event row")?;
        let event_id = tx.last_insert_rowid();

        tx.commit().context("commit capture transaction")?;

        Ok(CaptureStoreResult::new(
            snapshot_id,
            event_id,
            inserted_snapshot_id.is_some(),
        ))
    }
}

fn insert_item(
    tx: &rusqlite::Transaction<'_>,
    snapshot_id: i64,
    item: &ClipboardItem,
) -> Result<()> {
    tx.execute(
        "INSERT INTO snapshot_items (
            snapshot_id,
            item_index,
            primary_kind,
            primary_uti,
            preview_text,
            search_text,
            total_bytes
        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
        params![
            snapshot_id,
            usize_to_i64(item.item_index())?,
            item.primary_kind().as_str(),
            item.primary_uti(),
            item.preview_text(),
            item.search_text(),
            usize_to_i64(item.total_bytes())?,
        ],
    )
    .with_context(|| format!("insert snapshot_items row for item {}", item.item_index()))?;

    for rep in item.representations() {
        tx.execute(
            "INSERT INTO item_representations (
                snapshot_id,
                item_index,
                uti,
                kind,
                byte_len,
                raw_sha256,
                text_value,
                blob_value
            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
            params![
                snapshot_id,
                usize_to_i64(item.item_index())?,
                rep.uti(),
                rep.kind().as_str(),
                usize_to_i64(rep.byte_len())?,
                rep.raw_sha256(),
                rep.text_value(),
                rep.raw_bytes(),
            ],
        )
        .with_context(|| {
            format!(
                "insert item_representations row for item {} and uti {}",
                item.item_index(),
                rep.uti()
            )
        })?;
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use anyhow::Result;

    use super::Database;
    use crate::model::{build_item, build_representation, build_snapshot, CaptureContext};

    fn fake_snapshot(change_count: i64, text: &str) -> crate::model::ClipboardSnapshot {
        build_snapshot(
            CaptureContext::new(change_count).with_frontmost_app_name("Editor"),
            vec![build_item(
                0,
                vec![build_representation(
                    "public.utf8-plain-text".to_string(),
                    None,
                    text.as_bytes().to_vec(),
                )],
            )],
        )
    }

    #[test]
    fn empty_snapshots_store_without_placeholder_rows() -> Result<()> {
        let mut db = Database::open_in_memory()?;
        let snapshot = build_snapshot(CaptureContext::new(9), Vec::new());

        let store = db.store_capture(&snapshot)?;
        let stored_item_count: i64 = db.conn.query_row(
            "SELECT item_count FROM snapshots WHERE id = ?1",
            [store.snapshot_id()],
            |row| row.get(0),
        )?;
        let stored_row_count: i64 = db.conn.query_row(
            "SELECT COUNT(*) FROM snapshot_items WHERE snapshot_id = ?1",
            [store.snapshot_id()],
            |row| row.get(0),
        )?;

        assert_eq!(stored_item_count, 0);
        assert_eq!(stored_row_count, 0);
        Ok(())
    }

    #[test]
    fn duplicate_snapshots_reuse_content_row_and_append_events() -> Result<()> {
        let mut db = Database::open_in_memory()?;
        let first = fake_snapshot(1, "git status");
        let second = fake_snapshot(2, "git status");

        let first_store = db.store_capture(&first)?;
        let second_store = db.store_capture(&second)?;
        let event_count: i64 = db.conn.query_row(
            "SELECT COUNT(*) FROM capture_events WHERE snapshot_id = ?1",
            [first_store.snapshot_id()],
            |row| row.get(0),
        )?;

        assert!(first_store.inserted_new_snapshot());
        assert!(!second_store.inserted_new_snapshot());
        assert_eq!(first_store.snapshot_id(), second_store.snapshot_id());
        assert_eq!(event_count, 2);
        Ok(())
    }
}