use rusqlite::{
Connection, OptionalExtension, Result, Row, Transaction, TransactionBehavior, params,
params_from_iter, types::Value as Sql,
};
use serde_json::{Map, Value, json};
use crate::query::{ItemQuery, QMode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Item,
Collection,
Search,
}
impl Kind {
pub fn as_str(self) -> &'static str {
match self {
Kind::Item => "item",
Kind::Collection => "collection",
Kind::Search => "search",
}
}
pub fn key_param(self) -> &'static str {
match self {
Kind::Item => "itemKey",
Kind::Collection => "collectionKey",
Kind::Search => "searchKey",
}
}
}
#[derive(Debug)]
pub enum Outcome<T> {
Done(T),
Conflict(i64),
}
#[derive(Debug)]
pub enum Single {
Done {
version: i64,
file_removed: bool,
},
Conflict(i64),
NotFound,
Invalid(String),
}
const KEY_ALPHABET: &[u8] = b"23456789ABCDEFGHIJKLMNPQRSTUVWXYZ";
pub fn valid_object_key(key: &str) -> bool {
key.len() == 8 && key.bytes().all(|b| KEY_ALPHABET.contains(&b))
}
pub fn generate_key() -> String {
let n = KEY_ALPHABET.len();
let limit = (256 / n * n) as u8;
let mut key = String::with_capacity(8);
while key.len() < 8 {
let mut buf = [0u8; 16];
getrandom::fill(&mut buf).expect("OS random number generator");
for b in buf {
if b < limit && key.len() < 8 {
key.push(KEY_ALPHABET[b as usize % n] as char);
}
}
}
key
}
fn order_fields(data: Value) -> Value {
const FIRST: [&str; 2] = ["annotationType", "linkMode"];
let Value::Object(map) = data else {
return data;
};
if !FIRST.iter().any(|k| map.contains_key(*k)) {
return Value::Object(map);
}
let mut ordered = Map::new();
for key in FIRST {
if let Some(value) = map.get(key) {
ordered.insert(key.to_string(), value.clone());
}
}
for (key, value) in map {
if !FIRST.contains(&key.as_str()) {
ordered.insert(key, value);
}
}
Value::Object(ordered)
}
#[derive(Debug, Default, PartialEq)]
struct Derived {
item_type: Option<String>,
parent_item: Option<String>,
deleted: bool,
title: Option<String>,
creator: Option<String>,
date_year: Option<String>,
date_added: Option<String>,
date_modified: Option<String>,
search_text: String,
everything_text: String,
tags: Vec<String>,
collections: Vec<String>,
}
fn text_field(data: &Value, name: &str) -> Option<String> {
data.get(name)
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
}
fn truthy(value: Option<&Value>) -> bool {
match value {
Some(Value::Bool(b)) => *b,
Some(Value::Number(n)) => n.as_i64().is_some_and(|n| n != 0),
Some(Value::String(s)) => s == "1" || s == "true",
_ => false,
}
}
fn year_of(date: &str) -> Option<String> {
let bytes = date.as_bytes();
let digit = |i: usize| bytes.get(i).is_some_and(u8::is_ascii_digit);
(0..bytes.len().saturating_sub(3)).find_map(|i| {
let run = (i..i + 4).all(digit);
let bounded = (i == 0 || !digit(i - 1)) && !digit(i + 4);
(run && bounded).then(|| date[i..i + 4].to_string())
})
}
const NOT_SEARCHED: &[&str] = &[
"key",
"version",
"parentItem",
"collections",
"relations",
"dateAdded",
"dateModified",
"md5",
"mtime",
];
fn collect_text(value: &Value, out: &mut Vec<String>) {
match value {
Value::String(s) if !s.is_empty() => out.push(s.to_lowercase()),
Value::Array(items) => items.iter().for_each(|v| collect_text(v, out)),
Value::Object(map) => map.values().for_each(|v| collect_text(v, out)),
_ => {}
}
}
fn derive(data: &Value) -> Derived {
let creators: Vec<&Value> = data
.get("creators")
.and_then(Value::as_array)
.map(|a| a.iter().collect())
.unwrap_or_default();
let creator = creators
.first()
.and_then(|c| text_field(c, "lastName").or_else(|| text_field(c, "name")));
let mut search = Vec::new();
for field in ["title", "date"] {
if let Some(text) = text_field(data, field) {
search.push(text.to_lowercase());
}
}
for c in &creators {
for field in ["lastName", "firstName", "name"] {
if let Some(text) = text_field(c, field) {
search.push(text.to_lowercase());
}
}
}
let mut everything = Vec::new();
if let Value::Object(map) = data {
for (field, value) in map {
if !NOT_SEARCHED.contains(&field.as_str()) {
collect_text(value, &mut everything);
}
}
}
let tags = data
.get("tags")
.and_then(Value::as_array)
.map(|tags| {
tags.iter()
.filter_map(|t| t.get("tag").and_then(Value::as_str))
.filter(|t| !t.is_empty())
.map(String::from)
.collect()
})
.unwrap_or_default();
let collections = data
.get("collections")
.and_then(Value::as_array)
.map(|keys| {
keys.iter()
.filter_map(Value::as_str)
.map(String::from)
.collect()
})
.unwrap_or_default();
Derived {
item_type: text_field(data, "itemType"),
parent_item: text_field(data, "parentItem"),
deleted: truthy(data.get("deleted")),
title: text_field(data, "title"),
creator,
date_year: text_field(data, "date").and_then(|d| year_of(&d)),
date_added: text_field(data, "dateAdded"),
date_modified: text_field(data, "dateModified"),
search_text: search.join(" "),
everything_text: everything.join(" "),
tags,
collections,
}
}
fn begin(conn: &mut Connection) -> Result<Transaction<'_>> {
conn.transaction_with_behavior(TransactionBehavior::Immediate)
}
pub fn current_version(conn: &Connection) -> Result<i64> {
conn.query_row("SELECT version FROM library WHERE id = 1", [], |r| r.get(0))
}
fn set_version(tx: &Connection, version: i64) -> Result<()> {
tx.execute("UPDATE library SET version = ?1 WHERE id = 1", [version])?;
Ok(())
}
fn guarded_version(tx: &Connection, expected: Option<i64>) -> Result<Outcome<i64>> {
let current = current_version(tx)?;
if matches!(expected, Some(v) if v != current) {
return Ok(Outcome::Conflict(current));
}
Ok(Outcome::Done(current + 1))
}
fn no_change(conn: &Connection, expected: Option<i64>) -> Result<Outcome<i64>> {
let current = current_version(conn)?;
Ok(match expected {
Some(v) if v != current => Outcome::Conflict(current),
_ => Outcome::Done(current),
})
}
fn placeholders(n: usize) -> String {
vec!["?"; n].join(",")
}
fn parse_json(text: &str) -> Value {
serde_json::from_str(text).unwrap_or(Value::Null)
}
fn get_object(conn: &Connection, kind: Kind, key: &str) -> Result<Option<(i64, Value)>> {
conn.query_row(
"SELECT version, data FROM object WHERE kind = ?1 AND key = ?2",
params![kind.as_str(), key],
|r| Ok((r.get(0)?, parse_json(&r.get::<_, String>(1)?))),
)
.optional()
}
fn put_object(tx: &Connection, kind: Kind, key: &str, version: i64, data: &Value) -> Result<()> {
let d = derive(data);
tx.execute(
"INSERT INTO object (kind, key, version, data, item_type, parent_item, deleted, title,
creator, date_year, date_added, date_modified, search_text,
everything_text)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
ON CONFLICT (kind, key) DO UPDATE SET
version = excluded.version, data = excluded.data,
item_type = excluded.item_type, parent_item = excluded.parent_item,
deleted = excluded.deleted, title = excluded.title, creator = excluded.creator,
date_year = excluded.date_year, date_added = excluded.date_added,
date_modified = excluded.date_modified, search_text = excluded.search_text,
everything_text = excluded.everything_text",
params![
kind.as_str(),
key,
version,
data.to_string(),
d.item_type,
d.parent_item,
d.deleted,
d.title,
d.creator,
d.date_year,
d.date_added,
d.date_modified,
d.search_text,
d.everything_text,
],
)?;
if kind == Kind::Item {
tx.execute("DELETE FROM item_tag WHERE item_key = ?1", [key])?;
for tag in &d.tags {
tx.execute(
"INSERT OR IGNORE INTO item_tag (item_key, tag) VALUES (?1, ?2)",
[key, tag],
)?;
}
tx.execute("DELETE FROM item_collection WHERE item_key = ?1", [key])?;
for collection in &d.collections {
tx.execute(
"INSERT OR IGNORE INTO item_collection (item_key, collection_key) VALUES (?1, ?2)",
[key, collection],
)?;
}
}
tx.execute(
"DELETE FROM deletion WHERE kind = ?1 AND key = ?2",
[kind.as_str(), key],
)?;
Ok(())
}
fn log_deletion(tx: &Connection, kind: &str, key: &str, version: i64) -> Result<()> {
tx.execute(
"INSERT INTO deletion (kind, key, version) VALUES (?1, ?2, ?3)
ON CONFLICT (kind, key) DO UPDATE SET version = excluded.version",
params![kind, key, version],
)?;
Ok(())
}
fn remove_object(tx: &Connection, kind: Kind, key: &str, version: i64) -> Result<(bool, bool)> {
let existed = tx.execute(
"DELETE FROM object WHERE kind = ?1 AND key = ?2",
[kind.as_str(), key],
)? > 0;
let mut had_file = false;
if kind == Kind::Item {
tx.execute("DELETE FROM item_tag WHERE item_key = ?1", [key])?;
tx.execute("DELETE FROM item_collection WHERE item_key = ?1", [key])?;
tx.execute("DELETE FROM fulltext WHERE item_key = ?1", [key])?;
had_file = tx.execute("DELETE FROM file WHERE item_key = ?1", [key])? > 0;
}
log_deletion(tx, kind.as_str(), key, version)?;
Ok((existed, had_file))
}
const OBJECT_COLUMNS: &str = "key, version, data,
CASE WHEN kind = 'item' AND parent_item IS NULL THEN
(SELECT count(*) FROM object c
WHERE c.kind = 'item' AND c.parent_item = object.key AND c.deleted = 0)
END";
fn object_row(row: &Row) -> Result<Value> {
let key: String = row.get(0)?;
let version: i64 = row.get(1)?;
let data = order_fields(parse_json(&row.get::<_, String>(2)?));
let num_children: Option<i64> = row.get(3)?;
let mut object = Map::new();
object.insert("key".into(), key.into());
object.insert("version".into(), version.into());
if let Some(n) = num_children {
object.insert("meta".into(), json!({ "numChildren": n }));
}
object.insert("data".into(), data);
Ok(Value::Object(object))
}
pub fn versions(conn: &Connection, kind: Kind, since: i64, top: bool) -> Result<Value> {
let sql = format!(
"SELECT key, version FROM object WHERE kind = ?1 AND version > ?2{}",
if top { " AND parent_item IS NULL" } else { "" }
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![kind.as_str(), since], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
})?;
let mut map = Map::new();
for row in rows {
let (key, version) = row?;
map.insert(key, version.into());
}
Ok(Value::Object(map))
}
pub fn objects(conn: &Connection, kind: Kind, keys: &[String]) -> Result<Vec<Value>> {
if keys.is_empty() {
return Ok(Vec::new());
}
let sql = format!(
"SELECT {OBJECT_COLUMNS} FROM object WHERE kind = ? AND key IN ({}) ORDER BY key",
placeholders(keys.len())
);
let mut values = vec![Sql::from(kind.as_str().to_string())];
values.extend(keys.iter().cloned().map(Sql::from));
let mut stmt = conn.prepare(&sql)?;
stmt.query_map(params_from_iter(values), object_row)?
.collect()
}
pub fn object(conn: &Connection, kind: Kind, key: &str) -> Result<Option<(i64, Value)>> {
let sql = format!("SELECT {OBJECT_COLUMNS} FROM object WHERE kind = ?1 AND key = ?2");
conn.query_row(&sql, params![kind.as_str(), key], |r| {
Ok((r.get::<_, i64>(1)?, object_row(r)?))
})
.optional()
}
pub fn list_objects(
conn: &Connection,
kind: Kind,
limit: i64,
start: i64,
) -> Result<(Vec<Value>, i64)> {
let total = conn.query_row(
"SELECT count(*) FROM object WHERE kind = ?1",
[kind.as_str()],
|r| r.get(0),
)?;
let sql = format!(
"SELECT {OBJECT_COLUMNS} FROM object WHERE kind = ?1 ORDER BY key LIMIT ?2 OFFSET ?3"
);
let mut stmt = conn.prepare(&sql)?;
let items = stmt
.query_map(params![kind.as_str(), limit, start], object_row)?
.collect::<Result<_>>()?;
Ok((items, total))
}
fn push_item_filters(sql: &mut String, values: &mut Vec<Sql>, q: &ItemQuery) {
sql.push_str(" WHERE kind = 'item'");
if q.only_trashed {
sql.push_str(" AND deleted = 1");
} else if !q.include_trashed {
sql.push_str(" AND deleted = 0");
}
if let Some(term) = &q.q {
let term = term.to_lowercase();
match q.qmode {
QMode::TitleCreatorYear => {
sql.push_str(" AND instr(search_text, ?) > 0");
values.push(term.into());
}
QMode::Everything => {
let like = format!(
"%{}%",
term.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
);
sql.push_str(
" AND (instr(everything_text, ?) > 0 OR EXISTS (
SELECT 1 FROM fulltext f WHERE f.item_key = object.key
AND f.content LIKE ? ESCAPE '\\'))",
);
values.push(term.into());
values.push(like.into());
}
}
}
if !q.item_type.include.is_empty() {
sql.push_str(&format!(
" AND item_type IN ({})",
placeholders(q.item_type.include.len())
));
values.extend(q.item_type.include.iter().cloned().map(Sql::from));
}
if !q.item_type.exclude.is_empty() {
sql.push_str(&format!(
" AND (item_type IS NULL OR item_type NOT IN ({}))",
placeholders(q.item_type.exclude.len())
));
values.extend(q.item_type.exclude.iter().cloned().map(Sql::from));
}
for group in &q.tags {
sql.push_str(&format!(
" AND EXISTS (SELECT 1 FROM item_tag t WHERE t.item_key = object.key AND t.tag IN ({}))",
placeholders(group.len())
));
values.extend(group.iter().cloned().map(Sql::from));
}
if q.top {
sql.push_str(" AND parent_item IS NULL");
}
if let Some(parent) = &q.parent {
sql.push_str(" AND parent_item = ?");
values.push(parent.clone().into());
}
if let Some(collection) = &q.collection {
sql.push_str(
" AND EXISTS (SELECT 1 FROM item_collection c
WHERE c.item_key = object.key AND c.collection_key = ?)",
);
values.push(collection.clone().into());
}
}
pub fn query_items(conn: &Connection, q: &ItemQuery) -> Result<(Vec<Value>, i64)> {
let mut count_sql = String::from("SELECT count(*) FROM object");
let mut values = Vec::new();
push_item_filters(&mut count_sql, &mut values, q);
let total: i64 = conn.query_row(&count_sql, params_from_iter(values), |r| r.get(0))?;
let mut sql = format!("SELECT {OBJECT_COLUMNS} FROM object");
let mut values = Vec::new();
push_item_filters(&mut sql, &mut values, q);
sql.push_str(&format!(
" ORDER BY {} {} NULLS LAST, key ASC LIMIT ? OFFSET ?",
q.sort.order_expr(),
q.direction.sql()
));
values.push(q.limit.into());
values.push(q.start.into());
let mut stmt = conn.prepare(&sql)?;
let items = stmt
.query_map(params_from_iter(values), object_row)?
.collect::<Result<_>>()?;
Ok((items, total))
}
pub fn item_keys(conn: &Connection, q: &ItemQuery) -> Result<Vec<String>> {
let mut sql = String::from("SELECT key FROM object");
let mut values = Vec::new();
push_item_filters(&mut sql, &mut values, q);
sql.push_str(" ORDER BY key");
let mut stmt = conn.prepare(&sql)?;
stmt.query_map(params_from_iter(values), |r| r.get(0))?
.collect()
}
pub fn tags(conn: &Connection) -> Result<Vec<Value>> {
let mut stmt = conn.prepare(
"SELECT t.tag, count(*) FROM item_tag t
JOIN object o ON o.kind = 'item' AND o.key = t.item_key
WHERE o.deleted = 0
GROUP BY t.tag ORDER BY t.tag",
)?;
stmt.query_map([], |r| {
Ok(json!({
"tag": r.get::<_, String>(0)?,
"meta": { "type": 0, "numItems": r.get::<_, i64>(1)? },
}))
})?
.collect()
}
#[derive(Debug, Default)]
pub struct BatchResult {
pub version: i64,
pub successful: Map<String, Value>,
pub failed: Map<String, Value>,
}
fn failure(key: Option<&str>, code: u16, message: &str) -> Value {
json!({ "key": key.unwrap_or(""), "code": code, "message": message })
}
pub fn write_batch(
conn: &mut Connection,
kind: Kind,
batch: Vec<Value>,
expected: Option<i64>,
) -> Result<Outcome<BatchResult>> {
if batch.is_empty() {
return Ok(match no_change(conn, expected)? {
Outcome::Done(version) => Outcome::Done(BatchResult {
version,
..Default::default()
}),
Outcome::Conflict(current) => Outcome::Conflict(current),
});
}
let tx = begin(conn)?;
let version = match guarded_version(&tx, expected)? {
Outcome::Done(version) => version,
Outcome::Conflict(current) => return Ok(Outcome::Conflict(current)),
};
let mut result = BatchResult {
version,
..Default::default()
};
for (index, provided) in batch.into_iter().enumerate() {
let index = index.to_string();
let Value::Object(fields) = provided else {
result
.failed
.insert(index, failure(None, 400, "object expected"));
continue;
};
let key = match fields.get("key").and_then(Value::as_str) {
Some(key) if !valid_object_key(key) => {
result
.failed
.insert(index, failure(Some(key), 400, "invalid object key"));
continue;
}
Some(key) => key.to_string(),
None => generate_key(),
};
let existing = get_object(&tx, kind, &key)?;
if expected.is_none()
&& let (Some((stored, _)), Some(given)) =
(&existing, fields.get("version").and_then(Value::as_i64))
&& given != *stored
{
result.failed.insert(
index,
failure(
Some(&key),
412,
&format!("object has been modified since version {given}"),
),
);
continue;
}
let mut data = match existing {
Some((_, Value::Object(mut base))) => {
base.extend(fields);
base
}
_ => fields,
};
data.insert("key".into(), key.clone().into());
data.insert("version".into(), version.into());
let data = Value::Object(data);
put_object(&tx, kind, &key, version, &data)?;
result.successful.insert(
index,
json!({ "key": key, "version": version, "data": order_fields(data) }),
);
}
if result.successful.is_empty() {
drop(tx);
result.version = current_version(conn)?;
return Ok(Outcome::Done(result));
}
set_version(&tx, version)?;
tx.commit()?;
Ok(Outcome::Done(result))
}
pub fn write_one(
conn: &mut Connection,
kind: Kind,
key: &str,
provided: Value,
expected: i64,
merge: bool,
) -> Result<Single> {
let Value::Object(fields) = provided else {
return Ok(Single::Invalid("object expected".into()));
};
if !valid_object_key(key) {
return Ok(Single::Invalid(format!("invalid object key: {key}")));
}
if fields
.get("key")
.and_then(Value::as_str)
.is_some_and(|k| k != key)
{
return Ok(Single::Invalid("key does not match the URL".into()));
}
let tx = begin(conn)?;
let mut data = match get_object(&tx, kind, key)? {
Some((stored, _)) if stored != expected => return Ok(Single::Conflict(stored)),
Some((_, Value::Object(mut base))) if merge => {
base.extend(fields);
base
}
Some(_) => fields,
None if merge || expected != 0 => return Ok(Single::NotFound),
None => fields,
};
let version = current_version(&tx)? + 1;
data.insert("key".into(), key.into());
data.insert("version".into(), version.into());
put_object(&tx, kind, key, version, &Value::Object(data))?;
set_version(&tx, version)?;
tx.commit()?;
Ok(Single::Done {
version,
file_removed: false,
})
}
pub fn delete_one(conn: &mut Connection, kind: Kind, key: &str, expected: i64) -> Result<Single> {
let tx = begin(conn)?;
match get_object(&tx, kind, key)? {
None => return Ok(Single::NotFound),
Some((stored, _)) if stored != expected => return Ok(Single::Conflict(stored)),
Some(_) => {}
}
let version = current_version(&tx)? + 1;
let (_, file_removed) = remove_object(&tx, kind, key, version)?;
set_version(&tx, version)?;
tx.commit()?;
Ok(Single::Done {
version,
file_removed,
})
}
pub fn delete_batch(
conn: &mut Connection,
kind: Kind,
keys: &[String],
expected: Option<i64>,
) -> Result<Outcome<(i64, Vec<String>)>> {
if keys.is_empty() {
return Ok(match no_change(conn, expected)? {
Outcome::Done(v) => Outcome::Done((v, Vec::new())),
Outcome::Conflict(c) => Outcome::Conflict(c),
});
}
let tx = begin(conn)?;
let version = match guarded_version(&tx, expected)? {
Outcome::Done(version) => version,
Outcome::Conflict(current) => return Ok(Outcome::Conflict(current)),
};
let mut files = Vec::new();
for key in keys {
let (_, had_file) = remove_object(&tx, kind, key, version)?;
if had_file {
files.push(key.clone());
}
}
set_version(&tx, version)?;
tx.commit()?;
Ok(Outcome::Done((version, files)))
}
pub fn delete_tags(
conn: &mut Connection,
tags: &[String],
expected: Option<i64>,
) -> Result<Outcome<i64>> {
if tags.is_empty() {
return no_change(conn, expected);
}
let tx = begin(conn)?;
let version = match guarded_version(&tx, expected)? {
Outcome::Done(version) => version,
Outcome::Conflict(current) => return Ok(Outcome::Conflict(current)),
};
for tag in tags {
let keys: Vec<String> = {
let mut stmt = tx.prepare("SELECT item_key FROM item_tag WHERE tag = ?1")?;
stmt.query_map([tag], |r| r.get(0))?
.collect::<Result<_>>()?
};
for key in keys {
let Some((_, Value::Object(mut data))) = get_object(&tx, Kind::Item, &key)? else {
continue;
};
if let Some(Value::Array(item_tags)) = data.get_mut("tags") {
item_tags.retain(|t| t.get("tag").and_then(Value::as_str) != Some(tag.as_str()));
}
data.insert("version".into(), version.into());
put_object(&tx, Kind::Item, &key, version, &Value::Object(data))?;
}
log_deletion(&tx, "tag", tag, version)?;
}
set_version(&tx, version)?;
tx.commit()?;
Ok(Outcome::Done(version))
}
pub fn settings(conn: &Connection, since: i64) -> Result<Value> {
let mut stmt = conn.prepare("SELECT key, version, value FROM setting WHERE version > ?1")?;
let rows = stmt.query_map([since], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
parse_json(&r.get::<_, String>(2)?),
))
})?;
let mut map = Map::new();
for row in rows {
let (key, version, value) = row?;
map.insert(key, json!({ "value": value, "version": version }));
}
Ok(Value::Object(map))
}
pub fn write_settings(
conn: &mut Connection,
body: Value,
expected: Option<i64>,
) -> Result<Outcome<i64>> {
let Value::Object(entries) = body else {
return no_change(conn, expected);
};
if entries.is_empty() {
return no_change(conn, expected);
}
let tx = begin(conn)?;
let version = match guarded_version(&tx, expected)? {
Outcome::Done(version) => version,
Outcome::Conflict(current) => return Ok(Outcome::Conflict(current)),
};
for (key, entry) in entries {
let value = entry.get("value").cloned().unwrap_or(entry);
tx.execute(
"INSERT INTO setting (key, version, value) VALUES (?1, ?2, ?3)
ON CONFLICT (key) DO UPDATE SET version = excluded.version, value = excluded.value",
params![key, version, value.to_string()],
)?;
tx.execute(
"DELETE FROM deletion WHERE kind = 'setting' AND key = ?1",
[&key],
)?;
}
set_version(&tx, version)?;
tx.commit()?;
Ok(Outcome::Done(version))
}
pub fn delete_settings(
conn: &mut Connection,
keys: &[String],
expected: Option<i64>,
) -> Result<Outcome<i64>> {
if keys.is_empty() {
return no_change(conn, expected);
}
let tx = begin(conn)?;
let version = match guarded_version(&tx, expected)? {
Outcome::Done(version) => version,
Outcome::Conflict(current) => return Ok(Outcome::Conflict(current)),
};
for key in keys {
tx.execute("DELETE FROM setting WHERE key = ?1", [key])?;
log_deletion(&tx, "setting", key, version)?;
}
set_version(&tx, version)?;
tx.commit()?;
Ok(Outcome::Done(version))
}
pub fn deleted(conn: &Connection, since: i64) -> Result<Value> {
let mut stmt = conn.prepare("SELECT kind, key FROM deletion WHERE version > ?1")?;
let rows = stmt.query_map([since], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
})?;
let (mut collections, mut searches, mut items, mut settings, mut tags) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
for row in rows {
let (kind, key) = row?;
match kind.as_str() {
"collection" => collections.push(Value::from(key)),
"search" => searches.push(Value::from(key)),
"setting" => settings.push(Value::from(key)),
"tag" => tags.push(json!({ "tag": key, "type": 0 })),
_ => items.push(Value::from(key)),
}
}
Ok(json!({
"collections": collections,
"searches": searches,
"items": items,
"settings": settings,
"tags": tags,
}))
}
#[derive(Debug, Clone, PartialEq)]
pub struct FileMeta {
pub md5: String,
pub filename: String,
pub filesize: i64,
pub mtime: i64,
pub content_type: Option<String>,
pub compressed: bool,
}
pub fn file_meta(conn: &Connection, item_key: &str) -> Result<Option<FileMeta>> {
conn.query_row(
"SELECT md5, filename, filesize, mtime, content_type, compressed
FROM file WHERE item_key = ?1",
[item_key],
|r| {
Ok(FileMeta {
md5: r.get(0)?,
filename: r.get(1)?,
filesize: r.get(2)?,
mtime: r.get(3)?,
content_type: r.get(4)?,
compressed: r.get(5)?,
})
},
)
.optional()
}
pub fn item_file_state(conn: &Connection, item_key: &str) -> Result<Option<(i64, Option<String>)>> {
conn.query_row(
"SELECT o.version, f.md5 FROM object o LEFT JOIN file f ON f.item_key = o.key
WHERE o.kind = 'item' AND o.key = ?1",
[item_key],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()
}
pub fn register_file(conn: &mut Connection, item_key: &str, file: &FileMeta) -> Result<i64> {
let tx = begin(conn)?;
let version = current_version(&tx)? + 1;
tx.execute(
"INSERT INTO file (item_key, md5, filename, filesize, mtime, content_type, compressed,
version)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT (item_key) DO UPDATE SET
md5 = excluded.md5, filename = excluded.filename, filesize = excluded.filesize,
mtime = excluded.mtime, content_type = excluded.content_type,
compressed = excluded.compressed, version = excluded.version",
params![
item_key,
file.md5,
file.filename,
file.filesize,
file.mtime,
file.content_type,
file.compressed,
version
],
)?;
if let Some((_, Value::Object(mut data))) = get_object(&tx, Kind::Item, item_key)? {
data.insert("md5".into(), file.md5.clone().into());
data.insert("mtime".into(), file.mtime.into());
data.insert("version".into(), version.into());
put_object(&tx, Kind::Item, item_key, version, &Value::Object(data))?;
}
set_version(&tx, version)?;
tx.commit()?;
Ok(version)
}
pub fn fulltext_versions(conn: &Connection, since: i64) -> Result<Value> {
let mut stmt = conn.prepare("SELECT item_key, version FROM fulltext WHERE version > ?1")?;
let rows = stmt.query_map([since], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
})?;
let mut map = Map::new();
for row in rows {
let (key, version) = row?;
map.insert(key, version.into());
}
Ok(Value::Object(map))
}
pub fn fulltext_item(conn: &Connection, item_key: &str) -> Result<Option<(i64, Value)>> {
conn.query_row(
"SELECT content, indexed_chars, total_chars, indexed_pages, total_pages, version
FROM fulltext WHERE item_key = ?1",
[item_key],
|r| {
Ok((
r.get::<_, i64>(5)?,
json!({
"content": r.get::<_, String>(0)?,
"indexedChars": r.get::<_, i64>(1)?,
"totalChars": r.get::<_, i64>(2)?,
"indexedPages": r.get::<_, i64>(3)?,
"totalPages": r.get::<_, i64>(4)?,
}),
))
},
)
.optional()
}
pub fn write_fulltext(
conn: &mut Connection,
batch: Vec<Value>,
expected: Option<i64>,
) -> Result<Outcome<BatchResult>> {
if batch.is_empty() {
return Ok(match no_change(conn, expected)? {
Outcome::Done(version) => Outcome::Done(BatchResult {
version,
..Default::default()
}),
Outcome::Conflict(current) => Outcome::Conflict(current),
});
}
let tx = begin(conn)?;
let version = match guarded_version(&tx, expected)? {
Outcome::Done(version) => version,
Outcome::Conflict(current) => return Ok(Outcome::Conflict(current)),
};
let mut result = BatchResult {
version,
..Default::default()
};
for (index, item) in batch.into_iter().enumerate() {
let key = item.get("key").and_then(Value::as_str).unwrap_or_default();
if !valid_object_key(key) {
result.failed.insert(
index.to_string(),
failure(Some(key), 400, "invalid item key"),
);
continue;
}
let count = |name: &str| item.get(name).and_then(Value::as_i64).unwrap_or(0);
tx.execute(
"INSERT INTO fulltext (item_key, content, indexed_chars, total_chars,
indexed_pages, total_pages, version)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT (item_key) DO UPDATE SET
content = excluded.content, indexed_chars = excluded.indexed_chars,
total_chars = excluded.total_chars, indexed_pages = excluded.indexed_pages,
total_pages = excluded.total_pages, version = excluded.version",
params![
key,
item.get("content")
.and_then(Value::as_str)
.unwrap_or_default(),
count("indexedChars"),
count("totalChars"),
count("indexedPages"),
count("totalPages"),
version
],
)?;
result
.successful
.insert(index.to_string(), json!({ "key": key }));
}
if result.successful.is_empty() {
drop(tx);
result.version = current_version(conn)?;
return Ok(Outcome::Done(result));
}
set_version(&tx, version)?;
tx.commit()?;
Ok(Outcome::Done(result))
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LibraryStats {
pub version: i64,
pub items: i64,
pub attachments: i64,
pub notes: i64,
pub trashed: i64,
pub collections: i64,
pub searches: i64,
pub tags: i64,
pub files: i64,
pub file_bytes: i64,
pub fulltext: i64,
}
pub fn stats(conn: &Connection) -> Result<LibraryStats> {
let count = |sql: &str| conn.query_row(sql, [], |r| r.get::<_, i64>(0));
Ok(LibraryStats {
version: current_version(conn)?,
items: count(
"SELECT count(*) FROM object WHERE kind = 'item' AND deleted = 0
AND parent_item IS NULL
AND coalesce(item_type, '') NOT IN ('attachment', 'note', 'annotation')",
)?,
attachments: count(
"SELECT count(*) FROM object WHERE kind = 'item' AND deleted = 0
AND item_type = 'attachment'",
)?,
notes: count(
"SELECT count(*) FROM object WHERE kind = 'item' AND deleted = 0 AND item_type = 'note'",
)?,
trashed: count("SELECT count(*) FROM object WHERE kind = 'item' AND deleted = 1")?,
collections: count("SELECT count(*) FROM object WHERE kind = 'collection'")?,
searches: count("SELECT count(*) FROM object WHERE kind = 'search'")?,
tags: count("SELECT count(DISTINCT tag) FROM item_tag")?,
files: count("SELECT count(*) FROM file")?,
file_bytes: count("SELECT coalesce(sum(filesize), 0) FROM file")?,
fulltext: count("SELECT count(*) FROM fulltext")?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query::Params;
fn conn() -> Connection {
let mut conn = Connection::open_in_memory().unwrap();
crate::db::migrate(&mut conn).unwrap();
conn
}
fn done<T: std::fmt::Debug>(outcome: Outcome<T>) -> T {
match outcome {
Outcome::Done(v) => v,
other => panic!("expected Done, got {other:?}"),
}
}
fn post(conn: &mut Connection, objects: Value, expected: Option<i64>) -> BatchResult {
let Value::Array(batch) = objects else {
panic!()
};
done(write_batch(conn, Kind::Item, batch, expected).unwrap())
}
#[test]
fn keys_are_zotero_shaped() {
for _ in 0..100 {
assert!(valid_object_key(&generate_key()));
}
assert!(!valid_object_key("ABCDEFG0"));
assert!(!valid_object_key("abc"));
}
#[test]
fn years_are_standalone_four_digit_runs() {
assert_eq!(year_of("circa 1990").as_deref(), Some("1990"));
assert_eq!(year_of("2001-03-04").as_deref(), Some("2001"));
assert_eq!(year_of("12345"), None);
assert_eq!(year_of(""), None);
}
#[test]
fn derives_query_columns() {
let d = derive(&json!({
"itemType": "journalArticle",
"title": "Deep Learning",
"date": "May 2015",
"creators": [{"lastName": "LeCun", "firstName": "Yann"}],
"DOI": "10.1038/nature14539",
"tags": [{"tag": "ml"}, {"tag": "ai", "type": 1}, "junk"],
"collections": ["ABCD2345"],
"parentItem": false,
"deleted": 1,
}));
assert_eq!(d.item_type.as_deref(), Some("journalArticle"));
assert_eq!(d.parent_item, None);
assert!(d.deleted);
assert_eq!(d.creator.as_deref(), Some("LeCun"));
assert_eq!(d.date_year.as_deref(), Some("2015"));
assert_eq!(d.search_text, "deep learning may 2015 lecun yann");
assert!(d.everything_text.contains("10.1038/nature14539"));
assert!(d.everything_text.contains("ml"));
assert_eq!(d.tags, vec!["ml", "ai"]);
assert_eq!(d.collections, vec!["ABCD2345"]);
}
#[test]
fn attachment_fields_come_back_link_mode_first() {
let data = order_fields(json!({"filename": "a.pdf", "linkMode": "imported_file"}));
let keys: Vec<_> = data.as_object().unwrap().keys().cloned().collect();
assert_eq!(keys, vec!["linkMode", "filename"]);
}
#[test]
fn batch_writes_merge_and_bump_the_version() {
let mut c = conn();
let r = post(
&mut c,
json!([{"key": "AAAAAAAA", "itemType": "book", "title": "One"}]),
Some(0),
);
assert_eq!(r.version, 1);
let r = post(
&mut c,
json!([{"key": "AAAAAAAA", "title": "Two"}]),
Some(1),
);
assert_eq!(r.version, 2);
let data = &r.successful["0"]["data"];
assert_eq!(data["itemType"], "book");
assert_eq!(data["title"], "Two");
assert_eq!(data["version"], 2);
assert!(matches!(
write_batch(&mut c, Kind::Item, vec![json!({"title": "x"})], Some(1)).unwrap(),
Outcome::Conflict(2)
));
}
#[test]
fn batch_without_precondition_checks_object_versions() {
let mut c = conn();
post(
&mut c,
json!([{"key": "AAAAAAAA", "itemType": "book"}]),
None,
);
let r = post(
&mut c,
json!([
{"key": "AAAAAAAA", "version": 7, "title": "stale"},
{"itemType": "note", "note": "new"},
{"key": "bad"},
]),
None,
);
assert_eq!(r.failed["0"]["code"], 412);
assert_eq!(r.failed["2"]["code"], 400);
let created = r.successful["1"]["key"].as_str().unwrap();
assert!(valid_object_key(created));
assert_eq!(r.version, 2);
}
#[test]
fn nothing_written_leaves_the_version() {
let mut c = conn();
let r = post(&mut c, json!([{"key": "bad"}]), None);
assert_eq!(r.version, 0);
assert_eq!(current_version(&c).unwrap(), 0);
}
#[test]
fn single_writes_check_the_object_version() {
let mut c = conn();
let created = write_one(
&mut c,
Kind::Item,
"BBBBBBBB",
json!({"itemType": "book"}),
0,
false,
)
.unwrap();
assert!(matches!(created, Single::Done { version: 1, .. }));
post(
&mut c,
json!([{"key": "CCCCCCCC", "itemType": "book"}]),
Some(1),
);
let patched = write_one(
&mut c,
Kind::Item,
"BBBBBBBB",
json!({"title": "T"}),
1,
true,
)
.unwrap();
assert!(matches!(patched, Single::Done { version: 3, .. }));
let (version, stored) = object(&c, Kind::Item, "BBBBBBBB").unwrap().unwrap();
assert_eq!(version, 3);
assert_eq!(stored["data"]["itemType"], "book");
assert!(matches!(
write_one(
&mut c,
Kind::Item,
"BBBBBBBB",
json!({"title": "U"}),
1,
true
)
.unwrap(),
Single::Conflict(3)
));
assert!(matches!(
write_one(&mut c, Kind::Item, "DDDDDDDD", json!({}), 0, true).unwrap(),
Single::NotFound
));
write_one(
&mut c,
Kind::Item,
"BBBBBBBB",
json!({"itemType": "book"}),
3,
false,
)
.unwrap();
let (_, stored) = object(&c, Kind::Item, "BBBBBBBB").unwrap().unwrap();
assert!(stored["data"].get("title").is_none());
assert!(matches!(
delete_one(&mut c, Kind::Item, "BBBBBBBB", 4).unwrap(),
Single::Done { version: 5, .. }
));
assert_eq!(deleted(&c, 4).unwrap()["items"], json!(["BBBBBBBB"]));
}
#[test]
fn item_queries_filter_search_and_count_children() {
let mut c = conn();
post(
&mut c,
json!([
{"key": "AAAAAAAA", "itemType": "journalArticle", "title": "Deep Learning",
"DOI": "10.1038/nature14539", "tags": [{"tag": "ml"}], "collections": ["CCCCCCCC"]},
{"key": "BBBBBBBB", "itemType": "attachment", "parentItem": "AAAAAAAA",
"contentType": "application/pdf", "linkMode": "imported_file"},
{"key": "DDDDDDDD", "itemType": "book", "title": "Other", "deleted": 1},
]),
Some(0),
);
let query = |raw: &str| ItemQuery::from_params(&Params::parse(Some(raw)));
let (items, total) = query_items(&c, &query("q=deep")).unwrap();
assert_eq!((items.len(), total), (1, 1));
assert_eq!(query_items(&c, &query("q=nature14539")).unwrap().1, 0);
assert_eq!(
query_items(&c, &query("q=nature14539&qmode=everything"))
.unwrap()
.1,
1
);
assert_eq!(query_items(&c, &query("tag=ml")).unwrap().1, 1);
assert_eq!(
query_items(&c, &query("itemType=-attachment")).unwrap().1,
1
);
assert_eq!(query_items(&c, &query("includeTrashed=1")).unwrap().1, 3);
let mut top = query("");
top.top = true;
let (items, _) = query_items(&c, &top).unwrap();
assert_eq!(items[0]["meta"]["numChildren"], 1);
let mut children = query("");
children.parent = Some("AAAAAAAA".into());
assert_eq!(item_keys(&c, &children).unwrap(), vec!["BBBBBBBB"]);
let mut in_collection = query("");
in_collection.collection = Some("CCCCCCCC".into());
assert_eq!(item_keys(&c, &in_collection).unwrap(), vec!["AAAAAAAA"]);
assert_eq!(
versions(&c, Kind::Item, 0, true).unwrap(),
json!({"AAAAAAAA": 1, "DDDDDDDD": 1})
);
}
#[test]
fn deleting_a_tag_strips_it_from_items() {
let mut c = conn();
post(
&mut c,
json!([{"key": "AAAAAAAA", "itemType": "book", "tags": [{"tag": "a"}, {"tag": "b"}]}]),
Some(0),
);
done(delete_tags(&mut c, &["a".into()], Some(1)).unwrap());
let (_, item) = object(&c, Kind::Item, "AAAAAAAA").unwrap().unwrap();
assert_eq!(item["data"]["tags"], json!([{"tag": "b"}]));
assert_eq!(tags(&c).unwrap().len(), 1);
assert_eq!(deleted(&c, 1).unwrap()["tags"][0]["tag"], "a");
}
#[test]
fn registering_a_file_stamps_the_item() {
let mut c = conn();
post(
&mut c,
json!([{"key": "AAAAAAAA", "itemType": "attachment", "linkMode": "imported_file"}]),
Some(0),
);
let file = FileMeta {
md5: "abc".into(),
filename: "a.pdf".into(),
filesize: 3,
mtime: 1000,
content_type: Some("application/pdf".into()),
compressed: false,
};
assert_eq!(register_file(&mut c, "AAAAAAAA", &file).unwrap(), 2);
assert_eq!(file_meta(&c, "AAAAAAAA").unwrap(), Some(file));
let (version, item) = object(&c, Kind::Item, "AAAAAAAA").unwrap().unwrap();
assert_eq!(version, 2);
assert_eq!(item["data"]["md5"], "abc");
assert_eq!(item["data"]["mtime"], 1000);
let (_, files) =
done(delete_batch(&mut c, Kind::Item, &["AAAAAAAA".into()], Some(2)).unwrap());
assert_eq!(files, vec!["AAAAAAAA"]);
assert_eq!(file_meta(&c, "AAAAAAAA").unwrap(), None);
}
#[test]
fn settings_and_fulltext_round_trip() {
let mut c = conn();
done(write_settings(&mut c, json!({"tagColors": {"value": [1]}}), Some(0)).unwrap());
assert_eq!(
settings(&c, 0).unwrap(),
json!({"tagColors": {"value": [1], "version": 1}})
);
assert_eq!(settings(&c, 1).unwrap(), json!({}));
done(delete_settings(&mut c, &["tagColors".into()], Some(1)).unwrap());
assert_eq!(deleted(&c, 0).unwrap()["settings"], json!(["tagColors"]));
let r = done(
write_fulltext(
&mut c,
vec![json!({"key": "AAAAAAAA", "content": "Hello", "indexedPages": 1})],
Some(2),
)
.unwrap(),
);
assert_eq!(r.version, 3);
assert_eq!(fulltext_versions(&c, 0).unwrap(), json!({"AAAAAAAA": 3}));
let (version, content) = fulltext_item(&c, "AAAAAAAA").unwrap().unwrap();
assert_eq!(version, 3);
assert_eq!(content["content"], "Hello");
}
}