Initial commit

This commit is contained in:
Bogdan
2026-06-15 09:41:14 +03:00
commit 5f389bee0f
105 changed files with 11511 additions and 0 deletions
+7
View File
@@ -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
+5110
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+13
View File
@@ -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"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

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>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

+3
View File
@@ -0,0 +1,3 @@
pub mod snippets;
pub mod tags;
pub mod settings;
+13
View File
@@ -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(())
}
+530
View File
@@ -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));
}
}
+146
View File
@@ -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");
}
}
+338
View File
@@ -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");
}
}
+20
View File
@@ -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())
}
}
+70
View File
@@ -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");
}
+6
View File
@@ -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()
}
+60
View File
@@ -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,
}
+36
View File
@@ -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"
]
}
}