Initial commit
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "faro"
|
||||
version = "0.1.0"
|
||||
description = "A local-first developer snippet manager"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "faro_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
thiserror = "2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main and quicknote windows",
|
||||
"windows": ["main", "quicknote"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"global-shortcut:allow-register",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"store:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,3 @@
|
||||
pub mod snippets;
|
||||
pub mod tags;
|
||||
pub mod settings;
|
||||
@@ -0,0 +1,13 @@
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use crate::error::AppError;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_hotkey(app: tauri::AppHandle, hotkey: String) -> Result<(), AppError> {
|
||||
app.global_shortcut()
|
||||
.unregister_all()
|
||||
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||
app.global_shortcut()
|
||||
.register(hotkey.as_str())
|
||||
.map_err(|e| AppError::Other(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
use std::sync::Mutex;
|
||||
use rusqlite::Connection;
|
||||
use tauri::State;
|
||||
use crate::error::AppError;
|
||||
use crate::models::{ContentType, Snippet, Tag, TagInput};
|
||||
use crate::commands::tags::attach_tags;
|
||||
|
||||
// ── Private DB helpers ───────────────────────────────────────────────────────
|
||||
|
||||
fn load_tags(conn: &Connection, snippet_id: i64) -> Result<Vec<Tag>, AppError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT t.id, t.name, st.source \
|
||||
FROM tag t \
|
||||
JOIN snippet_tag st ON st.tag_id = t.id \
|
||||
WHERE st.snippet_id = ?1 \
|
||||
ORDER BY t.name",
|
||||
)?;
|
||||
let tags = stmt
|
||||
.query_map([snippet_id], |r| {
|
||||
Ok(Tag { id: r.get(0)?, name: r.get(1)?, source: r.get(2)?, count: 0 })
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
fn load_snippet(conn: &Connection, id: i64) -> Result<Snippet, AppError> {
|
||||
let mut snippet = conn.query_row(
|
||||
"SELECT id, title, content, content_type, created_at, updated_at \
|
||||
FROM snippet WHERE id = ?1",
|
||||
[id],
|
||||
|r| {
|
||||
let ct: String = r.get(3)?;
|
||||
Ok(Snippet {
|
||||
id: r.get(0)?,
|
||||
title: r.get(1)?,
|
||||
content: r.get(2)?,
|
||||
content_type: ct.parse::<ContentType>().unwrap_or(ContentType::Text),
|
||||
created_at: r.get(4)?,
|
||||
updated_at: r.get(5)?,
|
||||
tags: vec![],
|
||||
})
|
||||
},
|
||||
)?;
|
||||
snippet.tags = load_tags(conn, snippet.id)?;
|
||||
Ok(snippet)
|
||||
}
|
||||
|
||||
fn db_create_snippet(
|
||||
conn: &Connection,
|
||||
title: &str,
|
||||
content: &str,
|
||||
content_type: &str,
|
||||
tags: &[TagInput],
|
||||
) -> Result<Snippet, AppError> {
|
||||
conn.execute(
|
||||
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
|
||||
VALUES (?1, ?2, ?3, datetime('now'), datetime('now'))",
|
||||
rusqlite::params![title, content, content_type],
|
||||
)?;
|
||||
let id = conn.last_insert_rowid();
|
||||
attach_tags(conn, id, tags)?;
|
||||
load_snippet(conn, id)
|
||||
}
|
||||
|
||||
fn load_snippets_for_ids(conn: &Connection, ids: &[i64]) -> Result<Vec<Snippet>, AppError> {
|
||||
if ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let placeholders: String = std::iter::repeat("?")
|
||||
.take(ids.len())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let snippet_sql = format!(
|
||||
"SELECT id, title, content, content_type, created_at, updated_at \
|
||||
FROM snippet WHERE id IN ({placeholders})"
|
||||
);
|
||||
let mut stmt = conn.prepare(&snippet_sql)?;
|
||||
let mut map: std::collections::HashMap<i64, Snippet> = stmt
|
||||
.query_map(rusqlite::params_from_iter(ids.iter().copied()), |r| {
|
||||
let ct: String = r.get(3)?;
|
||||
Ok(Snippet {
|
||||
id: r.get(0)?,
|
||||
title: r.get(1)?,
|
||||
content: r.get(2)?,
|
||||
content_type: ct.parse::<ContentType>().unwrap_or(ContentType::Text),
|
||||
created_at: r.get(4)?,
|
||||
updated_at: r.get(5)?,
|
||||
tags: vec![],
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.map(|s| (s.id, s))
|
||||
.collect();
|
||||
let tag_sql = format!(
|
||||
"SELECT st.snippet_id, t.id, t.name, st.source \
|
||||
FROM tag t JOIN snippet_tag st ON st.tag_id = t.id \
|
||||
WHERE st.snippet_id IN ({placeholders}) \
|
||||
ORDER BY t.name"
|
||||
);
|
||||
let mut tag_stmt = conn.prepare(&tag_sql)?;
|
||||
let tag_rows: Vec<(i64, Tag)> = tag_stmt
|
||||
.query_map(rusqlite::params_from_iter(ids.iter().copied()), |r| {
|
||||
Ok((r.get::<_, i64>(0)?, Tag { id: r.get(1)?, name: r.get(2)?, source: r.get(3)?, count: 0 }))
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
for (snippet_id, tag) in tag_rows {
|
||||
if let Some(s) = map.get_mut(&snippet_id) {
|
||||
s.tags.push(tag);
|
||||
}
|
||||
}
|
||||
Ok(ids.iter().filter_map(|id| map.remove(id)).collect())
|
||||
}
|
||||
|
||||
fn db_list_snippets(
|
||||
conn: &Connection,
|
||||
tag_filter: Option<&str>,
|
||||
) -> Result<Vec<Snippet>, AppError> {
|
||||
let ids: Vec<i64> = match tag_filter {
|
||||
None => {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id FROM snippet ORDER BY created_at DESC, id DESC")?;
|
||||
let rows = stmt.query_map([], |r| r.get(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
rows
|
||||
}
|
||||
Some(tag) => {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT s.id FROM snippet s \
|
||||
JOIN snippet_tag st ON st.snippet_id = s.id \
|
||||
JOIN tag t ON t.id = st.tag_id \
|
||||
WHERE t.name = ?1 AND st.source != 'suppressed' \
|
||||
ORDER BY s.created_at DESC, s.id DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([tag], |r| r.get(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
rows
|
||||
}
|
||||
};
|
||||
load_snippets_for_ids(conn, &ids)
|
||||
}
|
||||
|
||||
fn db_get_snippet(conn: &Connection, id: i64) -> Result<Snippet, AppError> {
|
||||
load_snippet(conn, id)
|
||||
}
|
||||
|
||||
fn db_update_snippet(
|
||||
conn: &Connection,
|
||||
id: i64,
|
||||
title: &str,
|
||||
content: &str,
|
||||
content_type: &str,
|
||||
tags: &[TagInput],
|
||||
) -> Result<Snippet, AppError> {
|
||||
let rows = conn.execute(
|
||||
"UPDATE snippet SET title=?1, content=?2, content_type=?3, \
|
||||
updated_at=datetime('now') WHERE id=?4",
|
||||
rusqlite::params![title, content, content_type, id],
|
||||
)?;
|
||||
if rows == 0 {
|
||||
return Err(AppError::Other(format!("snippet {id} not found")));
|
||||
}
|
||||
conn.execute("DELETE FROM snippet_tag WHERE snippet_id=?1", [id])?;
|
||||
attach_tags(conn, id, tags)?;
|
||||
load_snippet(conn, id)
|
||||
}
|
||||
|
||||
fn db_delete_snippet(conn: &Connection, id: i64) -> Result<(), AppError> {
|
||||
let rows = conn.execute("DELETE FROM snippet WHERE id=?1", [id])?;
|
||||
if rows == 0 {
|
||||
return Err(AppError::Other(format!("snippet {id} not found")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fts_prefix_query(query: &str) -> String {
|
||||
query
|
||||
.split_whitespace()
|
||||
.map(|token| {
|
||||
if token.ends_with('*') { token.to_string() } else { format!("{token}*") }
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn db_search_snippets(conn: &Connection, query: &str) -> Result<Vec<Snippet>, AppError> {
|
||||
if query.trim().is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let fts_query = fts_prefix_query(query);
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT s.id \
|
||||
FROM snippet s \
|
||||
JOIN snippet_fts ON snippet_fts.rowid = s.id \
|
||||
WHERE snippet_fts MATCH ?1 \
|
||||
ORDER BY bm25(snippet_fts)",
|
||||
)?;
|
||||
let ids: Vec<i64> = stmt
|
||||
.query_map([fts_query], |r| r.get(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
load_snippets_for_ids(conn, &ids)
|
||||
}
|
||||
|
||||
fn db_list_snippets_by_period(
|
||||
conn: &Connection,
|
||||
period: &str,
|
||||
) -> Result<Vec<Snippet>, AppError> {
|
||||
let date_filter = match period {
|
||||
"today" => "date(created_at, 'localtime') = date('now', 'localtime')",
|
||||
"yesterday" => "date(created_at, 'localtime') = date('now', 'localtime', '-1 day')",
|
||||
"this-week" => "date(created_at, 'localtime') >= date('now', 'localtime', '-6 days') AND date(created_at, 'localtime') < date('now', 'localtime', '-1 day')",
|
||||
"last-week" => "date(created_at, 'localtime') >= date('now', 'localtime', '-13 days') AND date(created_at, 'localtime') < date('now', 'localtime', '-6 days')",
|
||||
"older" => "date(created_at, 'localtime') < date('now', 'localtime', '-13 days')",
|
||||
_ => return Err(AppError::Other(format!("unknown period: {period}"))),
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT id FROM snippet WHERE {} ORDER BY created_at DESC, id DESC",
|
||||
date_filter
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let ids: Vec<i64> = stmt
|
||||
.query_map([], |r| r.get(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
load_snippets_for_ids(conn, &ids)
|
||||
}
|
||||
|
||||
fn db_record_copy(conn: &Connection, id: i64) -> Result<(), AppError> {
|
||||
let rows = conn.execute(
|
||||
"UPDATE snippet SET copy_count = copy_count + 1, last_used_at = datetime('now') WHERE id = ?1",
|
||||
[id],
|
||||
)?;
|
||||
if rows == 0 {
|
||||
return Err(AppError::Other(format!("snippet {id} not found")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Tauri commands ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_snippet(
|
||||
db: State<Mutex<Connection>>,
|
||||
title: String,
|
||||
content: String,
|
||||
content_type: String,
|
||||
tags: Vec<TagInput>,
|
||||
) -> Result<Snippet, AppError> {
|
||||
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
|
||||
db_create_snippet(&conn, &title, &content, &content_type, &tags)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_snippets(
|
||||
db: State<Mutex<Connection>>,
|
||||
tag_filter: Option<String>,
|
||||
) -> Result<Vec<Snippet>, AppError> {
|
||||
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
|
||||
db_list_snippets(&conn, tag_filter.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_snippet(db: State<Mutex<Connection>>, id: i64) -> Result<Snippet, AppError> {
|
||||
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
|
||||
db_get_snippet(&conn, id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_snippet(
|
||||
db: State<Mutex<Connection>>,
|
||||
id: i64,
|
||||
title: String,
|
||||
content: String,
|
||||
content_type: String,
|
||||
tags: Vec<TagInput>,
|
||||
) -> Result<Snippet, AppError> {
|
||||
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
|
||||
db_update_snippet(&conn, id, &title, &content, &content_type, &tags)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_snippet(db: State<Mutex<Connection>>, id: i64) -> Result<(), AppError> {
|
||||
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
|
||||
db_delete_snippet(&conn, id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_snippets(
|
||||
db: State<Mutex<Connection>>,
|
||||
query: String,
|
||||
) -> Result<Vec<Snippet>, AppError> {
|
||||
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
|
||||
db_search_snippets(&conn, &query)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_snippets_by_period(
|
||||
db: State<Mutex<Connection>>,
|
||||
period: String,
|
||||
) -> Result<Vec<Snippet>, AppError> {
|
||||
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
|
||||
db_list_snippets_by_period(&conn, &period)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn record_copy(db: State<Mutex<Connection>>, id: i64) -> Result<(), AppError> {
|
||||
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
|
||||
db_record_copy(&conn, id)
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use crate::db::apply_migrations;
|
||||
|
||||
fn setup() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
|
||||
apply_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn tag(name: &str, source: &str) -> TagInput {
|
||||
TagInput { name: name.to_string(), source: source.to_string() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_snippet_returns_snippet_with_tags() {
|
||||
let conn = setup();
|
||||
let s = db_create_snippet(
|
||||
&conn, "My title", "fn main() {}", "code", &[tag("rust", "user")],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.title, "My title");
|
||||
assert_eq!(s.content_type, ContentType::Code);
|
||||
assert_eq!(s.tags.len(), 1);
|
||||
assert_eq!(s.tags[0].name, "rust");
|
||||
assert_eq!(s.tags[0].source, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_snippet_no_tags() {
|
||||
let conn = setup();
|
||||
let s = db_create_snippet(&conn, "Bare", "bare content", "text", &[]).unwrap();
|
||||
assert!(s.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_snippets_ordered_newest_first() {
|
||||
let conn = setup();
|
||||
let s1 = db_create_snippet(&conn, "First", "a", "text", &[]).unwrap();
|
||||
let s2 = db_create_snippet(&conn, "Second", "b", "text", &[]).unwrap();
|
||||
let snippets = db_list_snippets(&conn, None).unwrap();
|
||||
assert_eq!(snippets.len(), 2);
|
||||
assert_eq!(snippets[0].id, s2.id, "newer snippet must come first");
|
||||
assert_eq!(snippets[1].id, s1.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_snippets_filtered_by_tag() {
|
||||
let conn = setup();
|
||||
db_create_snippet(&conn, "Rust snippet", "code", "code", &[tag("rust", "user")]).unwrap();
|
||||
db_create_snippet(&conn, "Plain snippet", "text", "text", &[]).unwrap();
|
||||
let snippets = db_list_snippets(&conn, Some("rust")).unwrap();
|
||||
assert_eq!(snippets.len(), 1);
|
||||
assert_eq!(snippets[0].title, "Rust snippet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_snippet_returns_with_tags() {
|
||||
let conn = setup();
|
||||
let created = db_create_snippet(
|
||||
&conn, "JS snippet", "const x=1", "code", &[tag("js", "system")],
|
||||
)
|
||||
.unwrap();
|
||||
let fetched = db_get_snippet(&conn, created.id).unwrap();
|
||||
assert_eq!(fetched.id, created.id);
|
||||
assert_eq!(fetched.tags[0].source, "system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_snippet_replaces_tags() {
|
||||
let conn = setup();
|
||||
let original = db_create_snippet(
|
||||
&conn, "Title", "content", "code", &[tag("rust", "user")],
|
||||
)
|
||||
.unwrap();
|
||||
let updated = db_update_snippet(
|
||||
&conn, original.id, "New title", "new", "text", &[tag("python", "user")],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.title, "New title");
|
||||
assert_eq!(updated.content_type, ContentType::Text);
|
||||
assert_eq!(updated.tags.len(), 1);
|
||||
assert_eq!(updated.tags[0].name, "python");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_snippet_removes_row() {
|
||||
let conn = setup();
|
||||
let s = db_create_snippet(&conn, "To delete", "x", "text", &[]).unwrap();
|
||||
db_delete_snippet(&conn, s.id).unwrap();
|
||||
assert!(db_list_snippets(&conn, None).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_snippet_removes_snippet_tag_rows() {
|
||||
let conn = setup();
|
||||
let s = db_create_snippet(&conn, "Tagged", "x", "text", &[tag("go", "user")]).unwrap();
|
||||
db_delete_snippet(&conn, s.id).unwrap();
|
||||
let orphans: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM snippet_tag WHERE snippet_id=?1",
|
||||
[s.id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(orphans, 0, "cascade must remove snippet_tag rows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_snippets_matches_title() {
|
||||
let conn = setup();
|
||||
db_create_snippet(&conn, "Rust async tips", "use tokio::runtime", "code", &[]).unwrap();
|
||||
db_create_snippet(&conn, "Python basics", "print('hello')", "code", &[]).unwrap();
|
||||
let results = db_search_snippets(&conn, "Rust").unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].title, "Rust async tips");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_snippets_matches_content() {
|
||||
let conn = setup();
|
||||
db_create_snippet(&conn, "Untitled", "SELECT * FROM users", "code", &[]).unwrap();
|
||||
db_create_snippet(&conn, "Other", "print('hello')", "code", &[]).unwrap();
|
||||
let results = db_search_snippets(&conn, "SELECT").unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].content, "SELECT * FROM users");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_snippets_prefix_matches_partial_word() {
|
||||
let conn = setup();
|
||||
db_create_snippet(&conn, "Website tips", "visit the website", "text", &[]).unwrap();
|
||||
db_create_snippet(&conn, "Other", "unrelated content", "text", &[]).unwrap();
|
||||
let results = db_search_snippets(&conn, "web").unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].title, "Website tips");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_snippets_empty_query_returns_empty() {
|
||||
let conn = setup();
|
||||
db_create_snippet(&conn, "Some snippet", "content here", "text", &[]).unwrap();
|
||||
let results = db_search_snippets(&conn, "").unwrap();
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_snippets_by_period_today() {
|
||||
let conn = setup();
|
||||
db_create_snippet(&conn, "Today snippet", "x", "text", &[]).unwrap();
|
||||
let results = db_list_snippets_by_period(&conn, "today").unwrap();
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].title, "Today snippet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_snippets_by_period_unknown_period_errors() {
|
||||
let conn = setup();
|
||||
let result = db_list_snippets_by_period(&conn, "bogus");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_copy_increments_count() {
|
||||
let conn = setup();
|
||||
let s = db_create_snippet(&conn, "t", "c", "text", &[]).unwrap();
|
||||
db_record_copy(&conn, s.id).unwrap();
|
||||
db_record_copy(&conn, s.id).unwrap();
|
||||
let (count, last_used): (i64, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT copy_count, last_used_at FROM snippet WHERE id = ?1",
|
||||
[s.id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 2);
|
||||
assert!(last_used.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_copy_unknown_id_errors() {
|
||||
let conn = setup();
|
||||
assert!(db_record_copy(&conn, 9999).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_snippets_for_ids_batches_tags() {
|
||||
let conn = setup();
|
||||
let s1 = db_create_snippet(&conn, "A", "a", "text", &[tag("go", "user")]).unwrap();
|
||||
let s2 = db_create_snippet(&conn, "B", "b", "code", &[tag("rust", "user"), tag("async", "user")]).unwrap();
|
||||
let result = load_snippets_for_ids(&conn, &[s1.id, s2.id]).unwrap();
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(result[0].id, s1.id);
|
||||
assert_eq!(result[0].tags.len(), 1);
|
||||
assert_eq!(result[1].id, s2.id);
|
||||
assert_eq!(result[1].tags.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_snippets_for_ids_empty_returns_empty() {
|
||||
let conn = setup();
|
||||
let result = load_snippets_for_ids(&conn, &[]).unwrap();
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_filter_excludes_suppressed_snippets() {
|
||||
let conn = setup();
|
||||
let s = db_create_snippet(&conn, "Tagged", "x", "text", &[
|
||||
tag("rust", "suppressed"),
|
||||
]).unwrap();
|
||||
// Suppressed tag should not match the filter
|
||||
let results = db_list_snippets(&conn, Some("rust")).unwrap();
|
||||
assert!(!results.iter().any(|r| r.id == s.id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
use rusqlite::Connection;
|
||||
use crate::error::AppError;
|
||||
use crate::models::{Tag, TagInput};
|
||||
|
||||
/// Creates a tag if it does not exist, then inserts the snippet_tag join row.
|
||||
/// INSERT OR IGNORE makes both operations idempotent.
|
||||
pub fn attach_tags(conn: &Connection, snippet_id: i64, tags: &[TagInput]) -> Result<(), AppError> {
|
||||
for tag in tags {
|
||||
let name = tag.name.to_lowercase();
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO tag (name) VALUES (?1)",
|
||||
[&name],
|
||||
)?;
|
||||
let tag_id: i64 = conn.query_row(
|
||||
"SELECT id FROM tag WHERE name = ?1",
|
||||
[&name],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO snippet_tag (snippet_id, tag_id, source) VALUES (?1, ?2, ?3)",
|
||||
rusqlite::params![snippet_id, tag_id, &tag.source],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn db_list_tags(conn: &Connection) -> Result<Vec<Tag>, AppError> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT t.id, t.name, count(st.snippet_id) \
|
||||
FROM tag t \
|
||||
LEFT JOIN snippet_tag st ON st.tag_id = t.id AND st.source != 'suppressed' \
|
||||
GROUP BY t.id \
|
||||
ORDER BY t.name",
|
||||
)?;
|
||||
let tags = stmt
|
||||
.query_map([], |r| {
|
||||
Ok(Tag { id: r.get(0)?, name: r.get(1)?, source: String::new(), count: r.get(2)? })
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_tags(
|
||||
db: tauri::State<std::sync::Mutex<Connection>>,
|
||||
) -> Result<Vec<Tag>, AppError> {
|
||||
let conn = db.lock().map_err(|e| AppError::Other(e.to_string()))?;
|
||||
db_list_tags(&conn)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use crate::db::apply_migrations;
|
||||
|
||||
fn setup() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
|
||||
apply_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_tags_creates_tag_if_missing() {
|
||||
let conn = setup();
|
||||
conn.execute(
|
||||
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
|
||||
VALUES ('t', 'c', 'text', datetime('now'), datetime('now'))",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let snippet_id = conn.last_insert_rowid();
|
||||
|
||||
attach_tags(
|
||||
&conn,
|
||||
snippet_id,
|
||||
&[TagInput { name: "rust".to_string(), source: "user".to_string() }],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tag_count: i64 = conn
|
||||
.query_row("SELECT count(*) FROM tag WHERE name='rust'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(tag_count, 1);
|
||||
|
||||
let link_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM snippet_tag WHERE snippet_id=?1",
|
||||
[snippet_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(link_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_tags_reuses_existing_tag() {
|
||||
let conn = setup();
|
||||
conn.execute("INSERT INTO tag (name) VALUES ('rust')", []).unwrap();
|
||||
let existing_tag_id: i64 = conn
|
||||
.query_row("SELECT id FROM tag WHERE name='rust'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
|
||||
VALUES ('t', 'c', 'text', datetime('now'), datetime('now'))",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let snippet_id = conn.last_insert_rowid();
|
||||
|
||||
attach_tags(
|
||||
&conn,
|
||||
snippet_id,
|
||||
&[TagInput { name: "rust".to_string(), source: "system".to_string() }],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let tag_count: i64 = conn
|
||||
.query_row("SELECT count(*) FROM tag", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(tag_count, 1);
|
||||
|
||||
let linked_id: i64 = conn
|
||||
.query_row(
|
||||
"SELECT tag_id FROM snippet_tag WHERE snippet_id=?1",
|
||||
[snippet_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(linked_id, existing_tag_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn db_list_tags_returns_all_sorted_by_name() {
|
||||
let conn = setup();
|
||||
conn.execute("INSERT INTO tag (name) VALUES ('zebra')", []).unwrap();
|
||||
conn.execute("INSERT INTO tag (name) VALUES ('apple')", []).unwrap();
|
||||
|
||||
let tags = db_list_tags(&conn).unwrap();
|
||||
assert_eq!(tags.len(), 2);
|
||||
assert_eq!(tags[0].name, "apple");
|
||||
assert_eq!(tags[1].name, "zebra");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
use std::path::Path;
|
||||
use rusqlite::Connection;
|
||||
use crate::error::AppError;
|
||||
|
||||
const MIGRATION_1: &str = "
|
||||
CREATE TABLE snippet (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
content_type TEXT NOT NULL DEFAULT 'text',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE tag (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE snippet_tag (
|
||||
snippet_id INTEGER NOT NULL REFERENCES snippet(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tag(id) ON DELETE CASCADE,
|
||||
source TEXT NOT NULL DEFAULT 'user',
|
||||
PRIMARY KEY (snippet_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE snippet_fts USING fts5(
|
||||
title,
|
||||
content,
|
||||
content='snippet',
|
||||
content_rowid='id'
|
||||
);
|
||||
|
||||
CREATE TRIGGER snippet_ai AFTER INSERT ON snippet BEGIN
|
||||
INSERT INTO snippet_fts(rowid, title, content)
|
||||
VALUES (new.id, new.title, new.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER snippet_ad AFTER DELETE ON snippet BEGIN
|
||||
INSERT INTO snippet_fts(snippet_fts, rowid, title, content)
|
||||
VALUES ('delete', old.id, old.title, old.content);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER snippet_au AFTER UPDATE ON snippet BEGIN
|
||||
INSERT INTO snippet_fts(snippet_fts, rowid, title, content)
|
||||
VALUES ('delete', old.id, old.title, old.content);
|
||||
INSERT INTO snippet_fts(rowid, title, content)
|
||||
VALUES (new.id, new.title, new.content);
|
||||
END;
|
||||
";
|
||||
|
||||
const MIGRATION_2: &str = "
|
||||
ALTER TABLE snippet ADD COLUMN copy_count INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE snippet ADD COLUMN last_used_at TEXT;
|
||||
|
||||
-- Deduplicate tags case-insensitively before lowercasing:
|
||||
-- 1. Reassign snippet_tag rows that point to a non-canonical (non-min-id) tag
|
||||
UPDATE snippet_tag
|
||||
SET tag_id = (
|
||||
SELECT MIN(t2.id) FROM tag t2
|
||||
WHERE LOWER(t2.name) = LOWER((SELECT t3.name FROM tag t3 WHERE t3.id = snippet_tag.tag_id))
|
||||
)
|
||||
WHERE tag_id NOT IN (SELECT MIN(id) FROM tag GROUP BY LOWER(name));
|
||||
|
||||
-- 2. Delete orphaned duplicates
|
||||
DELETE FROM tag WHERE id NOT IN (SELECT MIN(id) FROM tag GROUP BY LOWER(name));
|
||||
|
||||
-- 3. Lowercase all tag names
|
||||
UPDATE tag SET name = LOWER(name);
|
||||
";
|
||||
|
||||
/// Open (or create) the database at `path`, enable WAL + foreign keys, run migrations.
|
||||
pub fn open_db(path: &Path) -> Result<Connection, AppError> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
|
||||
apply_migrations(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub(crate) fn apply_migrations(conn: &Connection) -> Result<(), AppError> {
|
||||
let version: i64 = conn.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
if version < 1 {
|
||||
conn.execute_batch(&format!("BEGIN;\n{MIGRATION_1}\nCOMMIT;"))?;
|
||||
conn.execute_batch("PRAGMA user_version = 1;")?;
|
||||
}
|
||||
if version < 2 {
|
||||
conn.execute_batch(&format!("BEGIN;\n{MIGRATION_2}\nCOMMIT;"))?;
|
||||
conn.execute_batch("PRAGMA user_version = 2;")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn setup() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
|
||||
super::apply_migrations(&conn).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_sets_user_version_to_2() {
|
||||
let conn = setup();
|
||||
let ver: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(ver, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_2_adds_copy_columns() {
|
||||
let conn = setup();
|
||||
conn.execute(
|
||||
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
|
||||
VALUES ('t', 'c', 'text', datetime('now'), datetime('now'))",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let id = conn.last_insert_rowid();
|
||||
let (copy_count, last_used_at): (i64, Option<String>) = conn
|
||||
.query_row(
|
||||
"SELECT copy_count, last_used_at FROM snippet WHERE id = ?1",
|
||||
[id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(copy_count, 0);
|
||||
assert!(last_used_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_2_normalizes_tag_case() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
|
||||
let m1 = super::MIGRATION_1;
|
||||
conn.execute_batch(&format!("BEGIN;\n{m1}\nCOMMIT;")).unwrap();
|
||||
conn.execute_batch("PRAGMA user_version = 1;").unwrap();
|
||||
conn.execute("INSERT INTO tag (name) VALUES ('Rust')", []).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
|
||||
VALUES ('t', 'c', 'text', datetime('now'), datetime('now'))",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let snippet_id = conn.last_insert_rowid();
|
||||
let tag_id: i64 = conn
|
||||
.query_row("SELECT id FROM tag WHERE name = 'Rust'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO snippet_tag (snippet_id, tag_id, source) VALUES (?1, ?2, 'user')",
|
||||
rusqlite::params![snippet_id, tag_id],
|
||||
)
|
||||
.unwrap();
|
||||
// Now run migration 2
|
||||
super::apply_migrations(&conn).unwrap();
|
||||
let name: String = conn
|
||||
.query_row("SELECT name FROM tag WHERE id = ?1", [tag_id], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(name, "rust");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_and_tag_round_trip() {
|
||||
let conn = setup();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
|
||||
VALUES (?1, ?2, ?3, datetime('now'), datetime('now'))",
|
||||
rusqlite::params!["My snippet", "fn main() {}", "code"],
|
||||
)
|
||||
.unwrap();
|
||||
let snippet_id = conn.last_insert_rowid();
|
||||
|
||||
conn.execute("INSERT INTO tag (name) VALUES (?1)", ["rust"])
|
||||
.unwrap();
|
||||
let tag_id: i64 = conn
|
||||
.query_row("SELECT id FROM tag WHERE name = ?1", ["rust"], |r| r.get(0))
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO snippet_tag (snippet_id, tag_id, source) VALUES (?1, ?2, 'user')",
|
||||
rusqlite::params![snippet_id, tag_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let title: String = conn
|
||||
.query_row(
|
||||
"SELECT title FROM snippet WHERE id = ?1",
|
||||
[snippet_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(title, "My snippet");
|
||||
|
||||
let tag_name: String = conn
|
||||
.query_row(
|
||||
"SELECT t.name FROM tag t \
|
||||
JOIN snippet_tag st ON st.tag_id = t.id \
|
||||
WHERE st.snippet_id = ?1",
|
||||
[snippet_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tag_name, "rust");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts5_indexes_on_insert() {
|
||||
let conn = setup();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
|
||||
VALUES ('Hello World', 'fn greet() {}', 'code', datetime('now'), datetime('now'))",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM snippet_fts WHERE snippet_fts MATCH 'Hello'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts5_deindexes_on_delete() {
|
||||
let conn = setup();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
|
||||
VALUES ('Delete me', 'ephemeral', 'text', datetime('now'), datetime('now'))",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let id = conn.last_insert_rowid();
|
||||
|
||||
let before: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM snippet_fts WHERE snippet_fts MATCH 'Delete'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(before, 1);
|
||||
|
||||
conn.execute("DELETE FROM snippet WHERE id = ?1", [id])
|
||||
.unwrap();
|
||||
|
||||
let after: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM snippet_fts WHERE snippet_fts MATCH 'Delete'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(after, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts5_reindexes_on_update() {
|
||||
let conn = setup();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
|
||||
VALUES ('Old title', 'body', 'text', datetime('now'), datetime('now'))",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let id = conn.last_insert_rowid();
|
||||
|
||||
conn.execute(
|
||||
"UPDATE snippet SET title = 'New title', updated_at = datetime('now') WHERE id = ?1",
|
||||
[id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let old_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM snippet_fts WHERE snippet_fts MATCH 'Old'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(old_count, 0);
|
||||
|
||||
let new_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM snippet_fts WHERE snippet_fts MATCH 'New'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(new_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_tag_cascade_deletes_with_snippet() {
|
||||
let conn = setup();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO snippet (title, content, content_type, created_at, updated_at) \
|
||||
VALUES ('t', 'c', 'text', datetime('now'), datetime('now'))",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let snippet_id = conn.last_insert_rowid();
|
||||
|
||||
conn.execute("INSERT INTO tag (name) VALUES ('x')", [])
|
||||
.unwrap();
|
||||
let tag_id: i64 = conn
|
||||
.query_row("SELECT id FROM tag WHERE name = 'x'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO snippet_tag (snippet_id, tag_id, source) VALUES (?1, ?2, 'user')",
|
||||
rusqlite::params![snippet_id, tag_id],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn.execute("DELETE FROM snippet WHERE id = ?1", [snippet_id])
|
||||
.unwrap();
|
||||
|
||||
let orphan_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM snippet_tag WHERE snippet_id = ?1",
|
||||
[snippet_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(orphan_count, 0, "snippet_tag rows must be cascade-deleted");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AppError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] rusqlite::Error),
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl serde::Serialize for AppError {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState};
|
||||
|
||||
pub mod commands;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
|
||||
fn open_quick_note(app: &tauri::AppHandle) {
|
||||
if let Some(w) = app.get_webview_window("quicknote") {
|
||||
let _ = w.set_focus();
|
||||
return;
|
||||
}
|
||||
match tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"quicknote",
|
||||
tauri::WebviewUrl::App("quicknote".into()),
|
||||
)
|
||||
.title("Quick Note")
|
||||
.inner_size(600.0, 480.0)
|
||||
.always_on_top(true)
|
||||
.center()
|
||||
.resizable(false)
|
||||
.build()
|
||||
{
|
||||
Ok(w) => { let _ = w.set_focus(); }
|
||||
Err(e) => eprintln!("[faro] failed to open quick-note window: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(|app, _shortcut, event| {
|
||||
if event.state() == ShortcutState::Pressed {
|
||||
open_quick_note(app);
|
||||
}
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.setup(|app| {
|
||||
let data_dir = app.path().app_data_dir()?;
|
||||
std::fs::create_dir_all(&data_dir)?;
|
||||
let db_path = data_dir.join("faro.db");
|
||||
let conn = db::open_db(&db_path)
|
||||
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
|
||||
app.manage(Mutex::new(conn));
|
||||
app.global_shortcut().register("Ctrl+Shift+Space")?;
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::snippets::create_snippet,
|
||||
commands::snippets::list_snippets,
|
||||
commands::snippets::get_snippet,
|
||||
commands::snippets::update_snippet,
|
||||
commands::snippets::delete_snippet,
|
||||
commands::snippets::search_snippets,
|
||||
commands::snippets::list_snippets_by_period,
|
||||
commands::snippets::record_copy,
|
||||
commands::tags::list_tags,
|
||||
commands::settings::update_hotkey,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
faro_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentType {
|
||||
Code,
|
||||
Cli,
|
||||
Text,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ContentType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ContentType::Code => write!(f, "code"),
|
||||
ContentType::Cli => write!(f, "cli"),
|
||||
ContentType::Text => write!(f, "text"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ContentType {
|
||||
type Err = String;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"code" => Ok(ContentType::Code),
|
||||
"cli" => Ok(ContentType::Cli),
|
||||
"text" => Ok(ContentType::Text),
|
||||
other => Err(format!("unknown content_type: {other}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Snippet {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub content_type: ContentType,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub tags: Vec<Tag>,
|
||||
}
|
||||
|
||||
/// A tag, optionally carrying its `source` from the `snippet_tag` join table.
|
||||
/// `source` is always populated when a tag is loaded through a snippet;
|
||||
/// it is an empty string when listing all tags without snippet context.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Tag {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
#[serde(default)]
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TagInput {
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Faro",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.faro.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Faro",
|
||||
"width": 1100,
|
||||
"height": 720,
|
||||
"minWidth": 800,
|
||||
"minHeight": 520,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||