use anyhow::{Context, Result};
use rusqlite::{params, OptionalExtension, TransactionBehavior};
use super::ocr::{enqueue_ocr_for_snapshot_tx, rebuild_snapshot_ocr_cache};
use super::rebuild::{
insert_item, rebuild_snapshot_projection_cache_for_snapshot, set_representation_cache_deferred,
};
use super::revision::bump_revision_tx;
use super::search_document::rebuild_snapshot_search_document;
use crate::db::sqlite_helpers::usize_to_i64;
use crate::db::types::{ArchiveChangeKind, CaptureMode, CaptureOutcome, Database};
use crate::model::{CaptureStoreResult, ClipboardSnapshot};
use crate::sensitive;
impl Database {
pub fn store_capture(&mut self, snapshot: &ClipboardSnapshot) -> Result<CaptureStoreResult> {
let tx = self
.conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.context("begin capture transaction")?;
let result = store_capture_tx(&tx, snapshot)?;
tx.commit().context("commit capture transaction")?;
Ok(result)
}
pub(crate) fn capture_with_policy(
&mut self,
snapshot: &ClipboardSnapshot,
_mode: CaptureMode,
) -> Result<CaptureOutcome> {
let db_path = self.path.clone();
let tx = self
.conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.context("begin capture application transaction")?;
expire_restore_operations(&tx)?;
let (paused, api_key_filter_enabled, ocr_enabled): (bool, bool, bool) = tx
.query_row(
"SELECT paused, api_key_filter_enabled, ocr_enabled FROM clipmem_settings WHERE id = 1",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.context("load capture policy")?;
if paused {
tx.commit().context("commit paused capture decision")?;
return Ok(CaptureOutcome::SkippedPaused);
}
for bundle_id in [
snapshot.frontmost_app_bundle_id(),
snapshot.content_origin_bundle_id(),
]
.into_iter()
.flatten()
{
let ignored: bool = tx
.query_row(
"SELECT EXISTS(SELECT 1 FROM ignored_bundle_ids WHERE bundle_id = ?1 COLLATE NOCASE)",
[bundle_id],
|row| row.get(0),
)
.context("check ignored application")?;
if ignored {
tx.commit().context("commit ignored-app capture decision")?;
return Ok(CaptureOutcome::SkippedIgnoredApp {
bundle_id: bundle_id.to_string(),
});
}
}
if api_key_filter_enabled && sensitive::should_skip_snapshot_for_api_key_filter(snapshot) {
tx.commit().context("commit sensitive capture decision")?;
return Ok(CaptureOutcome::SkippedSensitive);
}
let suppressed_operation: Option<String> = tx
.query_row(
"SELECT ro.operation_id
FROM restore_operations ro
JOIN restore_operation_generations rog
ON rog.operation_id = ro.operation_id
WHERE datetime(ro.expires_at) >= datetime('now')
AND ro.state IN ('preparing', 'written', 'failed')
AND rog.change_count = ?1
ORDER BY ro.created_at DESC, ro.operation_id DESC
LIMIT 1",
[snapshot.change_count()],
|row| row.get(0),
)
.optional()
.context("check restore suppression operations")?
.or(super::restore_journal::operation_for_generation(
&db_path,
snapshot.change_count(),
));
if let Some(operation_id) = suppressed_operation {
tx.commit().context("commit restore suppression decision")?;
return Ok(CaptureOutcome::SuppressedRestore { operation_id });
}
let store = store_capture_tx(&tx, snapshot)?;
let enqueue_ocr = if ocr_enabled {
let inserted = enqueue_ocr_for_snapshot_tx(&tx, store.snapshot_id())?;
rebuild_snapshot_ocr_cache(&tx, store.snapshot_id())?;
if inserted != 0 {
bump_revision_tx(&tx, &[ArchiveChangeKind::Ocr])?;
}
true
} else {
false
};
let new_snapshot = store.inserted_new_snapshot();
tx.commit()
.context("commit capture application transaction")?;
if new_snapshot {
Ok(CaptureOutcome::Stored { store, enqueue_ocr })
} else {
Ok(CaptureOutcome::ObservedExisting { store, enqueue_ocr })
}
}
#[cfg(test)]
pub(crate) fn begin_restore_operation(
&self,
snapshot_id: i64,
snapshot_sha256: &str,
expected_change_count: i64,
) -> Result<String> {
self.conn.execute(
"UPDATE restore_operations SET state = 'expired' WHERE state IN ('preparing', 'written', 'failed') AND datetime(expires_at) < datetime('now')",
[],
).context("expire restore operations")?;
let operation_id: String = self
.conn
.query_row(
"INSERT INTO restore_operations (operation_id, snapshot_id, snapshot_sha256, state, expected_change_count, expires_at)
VALUES (lower(hex(randomblob(16))), ?1, ?2, 'preparing', ?3, datetime('now', '+5 seconds'))
RETURNING operation_id",
params![snapshot_id, snapshot_sha256, expected_change_count],
|row| row.get(0),
)
.context("begin restore operation")?;
self.conn
.execute(
"INSERT OR IGNORE INTO restore_operation_generations (operation_id, change_count)
VALUES (?1, ?2)",
params![operation_id, expected_change_count],
)
.context("register restore operation generation")?;
Ok(operation_id)
}
#[cfg(test)]
pub(crate) fn reassign_restore_operation_generation(
&self,
operation_id: &str,
expected_change_count: i64,
) -> Result<()> {
self.conn
.execute(
"INSERT OR IGNORE INTO restore_operation_generations (operation_id, change_count)
VALUES (?1, ?2)",
params![operation_id, expected_change_count],
)
.context("append restore operation generation")?;
self.conn
.execute(
"UPDATE restore_operations SET expected_change_count = ?2,
expires_at = datetime('now', '+5 seconds') WHERE operation_id = ?1",
params![operation_id, expected_change_count],
)
.context("reassign restore operation generation")?;
Ok(())
}
#[cfg(test)]
pub(crate) fn mark_restore_written(&self, operation_id: &str, change_count: i64) -> Result<()> {
self.conn
.execute(
"INSERT OR IGNORE INTO restore_operation_generations (operation_id, change_count)
VALUES (?1, ?2)",
params![operation_id, change_count],
)
.context("register written restore generation")?;
self.conn
.execute(
"UPDATE restore_operations SET state = 'written', result_change_count = ?2,
expires_at = datetime('now', '+30 seconds') WHERE operation_id = ?1",
params![operation_id, change_count],
)
.context("mark restore operation written")?;
Ok(())
}
#[cfg(test)]
pub(crate) fn mark_restore_failed(&self, operation_id: &str, failure: &str) -> Result<()> {
self.conn.execute(
"UPDATE restore_operations SET state = 'failed', failure = ?2 WHERE operation_id = ?1",
params![operation_id, failure],
).context("mark restore operation failed")?;
Ok(())
}
}
fn store_capture_tx(
tx: &rusqlite::Transaction<'_>,
snapshot: &ClipboardSnapshot,
) -> Result<CaptureStoreResult> {
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 {
set_representation_cache_deferred(tx, true)?;
for item in snapshot.items() {
insert_item(tx, id, item)
.with_context(|| format!("insert snapshot item {}", item.item_index()))?;
}
rebuild_snapshot_projection_cache_for_snapshot(tx, id)?;
set_representation_cache_deferred(tx, false)?;
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,
content_origin_bundle_id,
content_origin_name,
content_origin_kind
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
snapshot_id,
snapshot.change_count(),
snapshot.frontmost_app_bundle_id(),
snapshot.frontmost_app_name(),
snapshot.content_origin_bundle_id(),
snapshot.content_origin_name(),
snapshot.content_origin_kind(),
],
)
.context("insert capture event row")?;
let event_id = tx.last_insert_rowid();
rebuild_snapshot_search_document(tx, snapshot_id)?;
bump_revision_tx(tx, &[ArchiveChangeKind::ArchiveContent])?;
Ok(CaptureStoreResult::new(
snapshot_id,
event_id,
inserted_snapshot_id.is_some(),
))
}
pub(super) fn expire_restore_operations(conn: &rusqlite::Connection) -> Result<()> {
conn.execute(
"UPDATE restore_operations SET state = 'expired'
WHERE state IN ('preparing', 'written', 'failed') AND datetime(expires_at) < datetime('now')",
[],
)
.context("expire restore operations")?;
Ok(())
}