1028 lines
34 KiB
Rust
1028 lines
34 KiB
Rust
//! `brittle-core` — the library crate for the Brittle literature management system.
|
|
//!
|
|
//! All operations go through the [`Brittle`] struct. The API is transport-agnostic:
|
|
//! every return type is plain data that any layer (UI, REST, CLI) can use directly.
|
|
|
|
pub mod bibtex;
|
|
pub mod error;
|
|
pub mod model;
|
|
pub mod store;
|
|
|
|
pub use brittle_model::ReferenceSummary;
|
|
pub use error::{BibtexError, BrittleError, StoreError, ValidationError};
|
|
pub use model::{
|
|
Annotation, AnnotationId, AnnotationSet, AnnotationType, Color, EntryType, Library, LibraryId,
|
|
PdfAttachment, Person, Point, Quad, Rect, Reference, ReferenceId, Snapshot, TextMarkupType,
|
|
};
|
|
pub use store::{FsStore, MemoryStore, Store};
|
|
|
|
use crate::bibtex::export_references;
|
|
use crate::error::EntityType;
|
|
use chrono::Utc;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// The main entry point for Brittle.
|
|
///
|
|
/// Generic over [`Store`] to allow testing with [`MemoryStore`] and production
|
|
/// use with [`FsStore`] without duplicating business logic.
|
|
pub struct Brittle<S: Store> {
|
|
store: S,
|
|
}
|
|
|
|
// ---- Constructors ----
|
|
|
|
impl Brittle<FsStore> {
|
|
/// Create a new Brittle repository at the given path.
|
|
pub fn create(path: &Path) -> Result<Self, BrittleError> {
|
|
let store = FsStore::create(path)?;
|
|
Ok(Self { store })
|
|
}
|
|
|
|
/// Open an existing Brittle repository.
|
|
pub fn open(path: &Path) -> Result<Self, BrittleError> {
|
|
let store = FsStore::open(path)?;
|
|
Ok(Self { store })
|
|
}
|
|
|
|
/// Returns the root path of the open repository.
|
|
pub fn repository_root(&self) -> &Path {
|
|
self.store.root()
|
|
}
|
|
|
|
/// Return the absolute filesystem path to the PDF attached to a reference.
|
|
///
|
|
/// Returns [`ValidationError::NoPdfAttached`] if the reference has no PDF.
|
|
pub fn get_pdf_path(&self, ref_id: ReferenceId) -> Result<PathBuf, BrittleError> {
|
|
let reference = self.store.load_reference(ref_id)?;
|
|
reference
|
|
.pdf
|
|
.map(|att| self.store.root().join(&att.stored_path))
|
|
.ok_or_else(|| {
|
|
ValidationError::NoPdfAttached {
|
|
reference_id: ref_id.to_string(),
|
|
}
|
|
.into()
|
|
})
|
|
}
|
|
|
|
/// Attach a PDF to a reference.
|
|
///
|
|
/// Copies the file from `source_path` into the repository's `pdfs/` directory,
|
|
/// computes its SHA-256 content hash, and updates the reference record.
|
|
pub fn attach_pdf(
|
|
&mut self,
|
|
id: ReferenceId,
|
|
source_path: &Path,
|
|
) -> Result<PdfAttachment, BrittleError> {
|
|
use sha2::{Digest, Sha256};
|
|
|
|
if !source_path.exists() {
|
|
return Err(ValidationError::PdfNotFound {
|
|
path: source_path.to_owned(),
|
|
}
|
|
.into());
|
|
}
|
|
|
|
let mut reference = self.store.load_reference(id)?;
|
|
let dest_name = format!("{id}.pdf");
|
|
let dest_path = self.store.pdf_dir().join(&dest_name);
|
|
let stored_path = Path::new("pdfs").join(&dest_name);
|
|
|
|
let bytes = std::fs::read(source_path).map_err(StoreError::Io)?;
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(&bytes);
|
|
let hash = format!("{:x}", hasher.finalize());
|
|
|
|
std::fs::write(&dest_path, &bytes).map_err(StoreError::Io)?;
|
|
|
|
let attachment = PdfAttachment {
|
|
stored_path,
|
|
content_hash: hash,
|
|
};
|
|
reference.pdf = Some(attachment.clone());
|
|
reference.modified_at = Utc::now();
|
|
self.store.save_reference(&reference)?;
|
|
|
|
Ok(attachment)
|
|
}
|
|
}
|
|
|
|
impl<S: Store> Brittle<S> {
|
|
/// Construct with an arbitrary store. Primarily for testing with [`MemoryStore`].
|
|
pub fn with_store(store: S) -> Self {
|
|
Self { store }
|
|
}
|
|
|
|
// ---- Search ----
|
|
|
|
/// Search all references. Returns summaries whose cite key, title, year, or
|
|
/// author family name contains `query` (case-insensitive). If `query` is
|
|
/// empty, returns all references.
|
|
pub fn search_references(&self, query: &str) -> Result<Vec<ReferenceSummary>, BrittleError> {
|
|
if query.is_empty() {
|
|
return self.list_references();
|
|
}
|
|
let q = query.to_lowercase();
|
|
let ids = self.store.list_reference_ids()?;
|
|
let mut results = Vec::new();
|
|
for id in ids {
|
|
let r = self.store.load_reference(id)?;
|
|
if reference_matches(&r, &q) {
|
|
results.push(ReferenceSummary::from(&r));
|
|
}
|
|
}
|
|
Ok(results)
|
|
}
|
|
|
|
/// Search references within a specific library.
|
|
pub fn search_library_references(
|
|
&self,
|
|
library_id: LibraryId,
|
|
query: &str,
|
|
) -> Result<Vec<ReferenceSummary>, BrittleError> {
|
|
if query.is_empty() {
|
|
return self.list_library_references(library_id);
|
|
}
|
|
let q = query.to_lowercase();
|
|
let library = self.store.load_library(library_id)?;
|
|
let mut results = Vec::new();
|
|
for ref_id in &library.members {
|
|
let r = self.store.load_reference(*ref_id)?;
|
|
if reference_matches(&r, &q) {
|
|
results.push(ReferenceSummary::from(&r));
|
|
}
|
|
}
|
|
Ok(results)
|
|
}
|
|
|
|
// ---- Reference CRUD ----
|
|
|
|
/// Create a new reference with the given cite key and entry type.
|
|
///
|
|
/// Returns an error if the cite key is empty or already in use.
|
|
pub fn create_reference(
|
|
&mut self,
|
|
cite_key: impl Into<String>,
|
|
entry_type: EntryType,
|
|
) -> Result<Reference, BrittleError> {
|
|
let cite_key = cite_key.into();
|
|
if cite_key.is_empty() {
|
|
return Err(ValidationError::EmptyCiteKey.into());
|
|
}
|
|
// Check for duplicate cite keys.
|
|
for id in self.store.list_reference_ids()? {
|
|
let existing = self.store.load_reference(id)?;
|
|
if existing.cite_key == cite_key {
|
|
return Err(ValidationError::DuplicateCiteKey { cite_key }.into());
|
|
}
|
|
}
|
|
let reference = Reference::new(cite_key, entry_type);
|
|
self.store.save_reference(&reference)?;
|
|
Ok(reference)
|
|
}
|
|
|
|
/// Get a reference by ID.
|
|
pub fn get_reference(&self, id: ReferenceId) -> Result<Reference, BrittleError> {
|
|
Ok(self.store.load_reference(id)?)
|
|
}
|
|
|
|
/// Replace the mutable fields of a reference. `id` and `created_at` are preserved.
|
|
/// `modified_at` is updated automatically.
|
|
pub fn update_reference(
|
|
&mut self,
|
|
mut reference: Reference,
|
|
) -> Result<Reference, BrittleError> {
|
|
// Ensure the reference exists.
|
|
let existing = self.store.load_reference(reference.id)?;
|
|
reference.created_at = existing.created_at;
|
|
reference.modified_at = Utc::now();
|
|
|
|
// Check that the (possibly changed) cite key doesn't conflict.
|
|
if reference.cite_key.is_empty() {
|
|
return Err(ValidationError::EmptyCiteKey.into());
|
|
}
|
|
for id in self.store.list_reference_ids()? {
|
|
if id == reference.id {
|
|
continue;
|
|
}
|
|
let other = self.store.load_reference(id)?;
|
|
if other.cite_key == reference.cite_key {
|
|
return Err(ValidationError::DuplicateCiteKey {
|
|
cite_key: reference.cite_key,
|
|
}
|
|
.into());
|
|
}
|
|
}
|
|
|
|
self.store.save_reference(&reference)?;
|
|
Ok(reference)
|
|
}
|
|
|
|
/// Delete a reference and cascade-remove all associated data:
|
|
/// library memberships and annotations.
|
|
pub fn delete_reference(&mut self, id: ReferenceId) -> Result<(), BrittleError> {
|
|
// Ensure it exists first.
|
|
self.store.load_reference(id)?;
|
|
|
|
// Remove from all libraries.
|
|
for lib_id in self.store.list_library_ids()? {
|
|
let mut lib = self.store.load_library(lib_id)?;
|
|
if lib.members.remove(&id) {
|
|
lib.modified_at = Utc::now();
|
|
self.store.save_library(&lib)?;
|
|
}
|
|
}
|
|
|
|
// Delete annotations.
|
|
self.store.delete_annotations(id)?;
|
|
|
|
// Delete the reference itself.
|
|
self.store.delete_reference(id)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// List all references as lightweight summaries.
|
|
pub fn list_references(&self) -> Result<Vec<ReferenceSummary>, BrittleError> {
|
|
let ids = self.store.list_reference_ids()?;
|
|
let mut summaries = Vec::with_capacity(ids.len());
|
|
for id in ids {
|
|
let r = self.store.load_reference(id)?;
|
|
summaries.push(ReferenceSummary::from(&r));
|
|
}
|
|
Ok(summaries)
|
|
}
|
|
|
|
/// Set a single field on a reference. Use `"title"`, `"year"`, `"journal"`, etc.
|
|
pub fn set_field(
|
|
&mut self,
|
|
id: ReferenceId,
|
|
field: &str,
|
|
value: impl Into<String>,
|
|
) -> Result<(), BrittleError> {
|
|
let mut reference = self.store.load_reference(id)?;
|
|
reference.fields.insert(field.to_owned(), value.into());
|
|
reference.modified_at = Utc::now();
|
|
self.store.save_reference(&reference)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove a field from a reference.
|
|
pub fn remove_field(&mut self, id: ReferenceId, field: &str) -> Result<(), BrittleError> {
|
|
let mut reference = self.store.load_reference(id)?;
|
|
reference.fields.remove(field);
|
|
reference.modified_at = Utc::now();
|
|
self.store.save_reference(&reference)?;
|
|
Ok(())
|
|
}
|
|
|
|
// ---- Library CRUD ----
|
|
|
|
/// Create a new library.
|
|
pub fn create_library(
|
|
&mut self,
|
|
name: impl Into<String>,
|
|
parent_id: Option<LibraryId>,
|
|
) -> Result<Library, BrittleError> {
|
|
let name = name.into();
|
|
if name.is_empty() {
|
|
return Err(ValidationError::EmptyLibraryName.into());
|
|
}
|
|
// Verify parent exists if provided.
|
|
if let Some(pid) = parent_id {
|
|
self.store.load_library(pid)?;
|
|
}
|
|
let library = Library::new(name, parent_id);
|
|
self.store.save_library(&library)?;
|
|
Ok(library)
|
|
}
|
|
|
|
/// Get a library by ID.
|
|
pub fn get_library(&self, id: LibraryId) -> Result<Library, BrittleError> {
|
|
Ok(self.store.load_library(id)?)
|
|
}
|
|
|
|
/// Rename a library.
|
|
pub fn rename_library(
|
|
&mut self,
|
|
id: LibraryId,
|
|
new_name: impl Into<String>,
|
|
) -> Result<Library, BrittleError> {
|
|
let new_name = new_name.into();
|
|
if new_name.is_empty() {
|
|
return Err(ValidationError::EmptyLibraryName.into());
|
|
}
|
|
let mut library = self.store.load_library(id)?;
|
|
library.name = new_name;
|
|
library.modified_at = Utc::now();
|
|
self.store.save_library(&library)?;
|
|
Ok(library)
|
|
}
|
|
|
|
/// Move a library to a new parent. Validates that no cycle is created.
|
|
pub fn move_library(
|
|
&mut self,
|
|
id: LibraryId,
|
|
new_parent: Option<LibraryId>,
|
|
) -> Result<Library, BrittleError> {
|
|
// Verify new parent exists if provided.
|
|
if let Some(pid) = new_parent {
|
|
self.store.load_library(pid)?;
|
|
}
|
|
|
|
// Cycle detection: walk from new_parent up to root.
|
|
// If we encounter `id` in the ancestry chain, moving would create a cycle.
|
|
if let Some(pid) = new_parent {
|
|
let mut current = pid;
|
|
loop {
|
|
if current == id {
|
|
return Err(ValidationError::LibraryCycle {
|
|
library_id: id.to_string(),
|
|
parent_id: pid.to_string(),
|
|
}
|
|
.into());
|
|
}
|
|
let lib = self.store.load_library(current).map_err(|_| {
|
|
// Broken ancestor chain — treat as no cycle.
|
|
BrittleError::Validation(ValidationError::LibraryCycle {
|
|
library_id: id.to_string(),
|
|
parent_id: pid.to_string(),
|
|
})
|
|
});
|
|
match lib {
|
|
Ok(l) => match l.parent_id {
|
|
Some(next) => current = next,
|
|
None => break,
|
|
},
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut library = self.store.load_library(id)?;
|
|
library.parent_id = new_parent;
|
|
library.modified_at = Utc::now();
|
|
self.store.save_library(&library)?;
|
|
Ok(library)
|
|
}
|
|
|
|
/// Delete a library. Fails if it has children. References are disassociated, not deleted.
|
|
pub fn delete_library(&mut self, id: LibraryId) -> Result<(), BrittleError> {
|
|
// Ensure it exists.
|
|
self.store.load_library(id)?;
|
|
|
|
// Reject if any library has this one as its parent.
|
|
for lib_id in self.store.list_library_ids()? {
|
|
if lib_id == id {
|
|
continue;
|
|
}
|
|
let child = self.store.load_library(lib_id)?;
|
|
if child.parent_id == Some(id) {
|
|
return Err(ValidationError::LibraryHasChildren { id: id.to_string() }.into());
|
|
}
|
|
}
|
|
|
|
self.store.delete_library(id)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// List all root libraries (those with no parent).
|
|
pub fn list_root_libraries(&self) -> Result<Vec<Library>, BrittleError> {
|
|
self.list_libraries_where(|lib| lib.parent_id.is_none())
|
|
}
|
|
|
|
/// List direct children of a library.
|
|
pub fn list_child_libraries(&self, parent_id: LibraryId) -> Result<Vec<Library>, BrittleError> {
|
|
// Ensure parent exists.
|
|
self.store.load_library(parent_id)?;
|
|
self.list_libraries_where(|lib| lib.parent_id == Some(parent_id))
|
|
}
|
|
|
|
fn list_libraries_where(
|
|
&self,
|
|
predicate: impl Fn(&Library) -> bool,
|
|
) -> Result<Vec<Library>, BrittleError> {
|
|
let ids = self.store.list_library_ids()?;
|
|
let mut libs = Vec::new();
|
|
for id in ids {
|
|
let lib = self.store.load_library(id)?;
|
|
if predicate(&lib) {
|
|
libs.push(lib);
|
|
}
|
|
}
|
|
Ok(libs)
|
|
}
|
|
|
|
// ---- Library Membership ----
|
|
|
|
/// Add a reference to a library.
|
|
pub fn add_to_library(
|
|
&mut self,
|
|
library_id: LibraryId,
|
|
reference_id: ReferenceId,
|
|
) -> Result<(), BrittleError> {
|
|
// Verify both exist.
|
|
self.store.load_reference(reference_id)?;
|
|
let mut library = self.store.load_library(library_id)?;
|
|
library.members.insert(reference_id);
|
|
library.modified_at = Utc::now();
|
|
self.store.save_library(&library)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove a reference from a library.
|
|
pub fn remove_from_library(
|
|
&mut self,
|
|
library_id: LibraryId,
|
|
reference_id: ReferenceId,
|
|
) -> Result<(), BrittleError> {
|
|
let mut library = self.store.load_library(library_id)?;
|
|
library.members.remove(&reference_id);
|
|
library.modified_at = Utc::now();
|
|
self.store.save_library(&library)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// List all references directly in a library as lightweight summaries.
|
|
pub fn list_library_references(
|
|
&self,
|
|
library_id: LibraryId,
|
|
) -> Result<Vec<ReferenceSummary>, BrittleError> {
|
|
let library = self.store.load_library(library_id)?;
|
|
let mut summaries = Vec::new();
|
|
for ref_id in &library.members {
|
|
let r = self.store.load_reference(*ref_id)?;
|
|
summaries.push(ReferenceSummary::from(&r));
|
|
}
|
|
Ok(summaries)
|
|
}
|
|
|
|
/// List all references in a library and all its descendant libraries.
|
|
///
|
|
/// De-duplicates references that appear in multiple sub-libraries.
|
|
pub fn list_library_references_recursive(
|
|
&self,
|
|
library_id: LibraryId,
|
|
) -> Result<Vec<ReferenceSummary>, BrittleError> {
|
|
use std::collections::BTreeSet;
|
|
let mut ref_ids: BTreeSet<ReferenceId> = BTreeSet::new();
|
|
let mut queue = vec![library_id];
|
|
while let Some(id) = queue.pop() {
|
|
let lib = self.store.load_library(id)?;
|
|
ref_ids.extend(lib.members.iter().copied());
|
|
let children = self.list_child_libraries(id)?;
|
|
queue.extend(children.into_iter().map(|c| c.id));
|
|
}
|
|
let mut summaries = Vec::new();
|
|
for ref_id in ref_ids {
|
|
summaries.push(ReferenceSummary::from(&self.store.load_reference(ref_id)?));
|
|
}
|
|
Ok(summaries)
|
|
}
|
|
|
|
/// List all libraries that contain a given reference.
|
|
pub fn list_reference_libraries(
|
|
&self,
|
|
reference_id: ReferenceId,
|
|
) -> Result<Vec<Library>, BrittleError> {
|
|
// Ensure the reference exists.
|
|
self.store.load_reference(reference_id)?;
|
|
self.list_libraries_where(|lib| lib.members.contains(&reference_id))
|
|
}
|
|
|
|
// ---- Library ancestry & cascade delete ----
|
|
|
|
/// Return the ancestor chain of a library, ordered from root down to its
|
|
/// direct parent.
|
|
///
|
|
/// Returns an empty `Vec` for root libraries (those with no parent).
|
|
///
|
|
/// Example: given `Root > Science > Physics`, calling this on `Physics`
|
|
/// returns `[Root, Science]`.
|
|
pub fn get_library_ancestors(&self, id: LibraryId) -> Result<Vec<Library>, BrittleError> {
|
|
let mut lib = self.store.load_library(id)?;
|
|
let mut ancestors = Vec::new();
|
|
while let Some(parent_id) = lib.parent_id {
|
|
let parent = self.store.load_library(parent_id)?;
|
|
ancestors.push(parent.clone());
|
|
lib = parent;
|
|
}
|
|
ancestors.reverse();
|
|
Ok(ancestors)
|
|
}
|
|
|
|
/// Delete a library and **all its descendants**, disassociating member
|
|
/// references (references are not deleted, only removed from the deleted
|
|
/// libraries).
|
|
///
|
|
/// Use after confirming with the user. For the safe, rejection-on-children
|
|
/// variant see [`delete_library`].
|
|
pub fn force_delete_library(&mut self, id: LibraryId) -> Result<(), BrittleError> {
|
|
// Ensure the target exists before doing anything.
|
|
self.store.load_library(id)?;
|
|
|
|
// Collect the target and all its descendants via BFS.
|
|
let mut to_delete = vec![id];
|
|
let mut i = 0;
|
|
while i < to_delete.len() {
|
|
let current = to_delete[i];
|
|
for lib_id in self.store.list_library_ids()? {
|
|
if to_delete.contains(&lib_id) {
|
|
continue;
|
|
}
|
|
let lib = self.store.load_library(lib_id)?;
|
|
if lib.parent_id == Some(current) {
|
|
to_delete.push(lib_id);
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
// Library records hold membership; deleting the record removes
|
|
// memberships for free. References themselves are unaffected.
|
|
for lib_id in to_delete {
|
|
self.store.delete_library(lib_id)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---- BibTeX Export ----
|
|
|
|
/// Export a set of references as a BibTeX string.
|
|
/// Returns an error if any referenced ID is not found.
|
|
/// References with missing required fields are skipped; their errors are collected.
|
|
pub fn export_bibtex(
|
|
&self,
|
|
reference_ids: &[ReferenceId],
|
|
) -> Result<(String, Vec<BibtexError>), BrittleError> {
|
|
let mut refs = Vec::with_capacity(reference_ids.len());
|
|
for &id in reference_ids {
|
|
refs.push(self.store.load_reference(id)?);
|
|
}
|
|
Ok(export_references(&refs))
|
|
}
|
|
|
|
/// Export all references in a library as a BibTeX string.
|
|
pub fn export_library_bibtex(
|
|
&self,
|
|
library_id: LibraryId,
|
|
) -> Result<(String, Vec<BibtexError>), BrittleError> {
|
|
let library = self.store.load_library(library_id)?;
|
|
let ids: Vec<_> = library.members.iter().copied().collect();
|
|
self.export_bibtex(&ids)
|
|
}
|
|
|
|
// ---- Annotations ----
|
|
|
|
/// Add an annotation to a reference's PDF.
|
|
pub fn create_annotation(
|
|
&mut self,
|
|
reference_id: ReferenceId,
|
|
page: u32,
|
|
annotation_type: AnnotationType,
|
|
content: Option<String>,
|
|
) -> Result<Annotation, BrittleError> {
|
|
// Ensure the reference exists.
|
|
self.store.load_reference(reference_id)?;
|
|
|
|
let mut set = self.store.load_annotations(reference_id)?;
|
|
let mut ann = Annotation::new(reference_id, page, annotation_type);
|
|
ann.content = content;
|
|
set.annotations.push(ann.clone());
|
|
self.store.save_annotations(&set)?;
|
|
Ok(ann)
|
|
}
|
|
|
|
/// Get all annotations for a reference.
|
|
pub fn get_annotations(
|
|
&self,
|
|
reference_id: ReferenceId,
|
|
) -> Result<Vec<Annotation>, BrittleError> {
|
|
Ok(self.store.load_annotations(reference_id)?.annotations)
|
|
}
|
|
|
|
/// Update an annotation. Matches by `annotation.id`; fails if not found.
|
|
pub fn update_annotation(
|
|
&mut self,
|
|
mut annotation: Annotation,
|
|
) -> Result<Annotation, BrittleError> {
|
|
let ref_id = annotation.reference_id;
|
|
let mut set = self.store.load_annotations(ref_id)?;
|
|
|
|
let slot = set
|
|
.annotations
|
|
.iter_mut()
|
|
.find(|a| a.id == annotation.id)
|
|
.ok_or_else(|| {
|
|
BrittleError::Store(StoreError::NotFound {
|
|
entity_type: EntityType::Annotation,
|
|
id: annotation.id.to_string(),
|
|
})
|
|
})?;
|
|
|
|
annotation.created_at = slot.created_at;
|
|
annotation.modified_at = Utc::now();
|
|
*slot = annotation.clone();
|
|
self.store.save_annotations(&set)?;
|
|
Ok(annotation)
|
|
}
|
|
|
|
/// Delete an annotation by ID.
|
|
pub fn delete_annotation(
|
|
&mut self,
|
|
reference_id: ReferenceId,
|
|
annotation_id: AnnotationId,
|
|
) -> Result<(), BrittleError> {
|
|
let mut set = self.store.load_annotations(reference_id)?;
|
|
let before = set.annotations.len();
|
|
set.annotations.retain(|a| a.id != annotation_id);
|
|
if set.annotations.len() == before {
|
|
return Err(StoreError::NotFound {
|
|
entity_type: EntityType::Annotation,
|
|
id: annotation_id.to_string(),
|
|
}
|
|
.into());
|
|
}
|
|
self.store.save_annotations(&set)?;
|
|
Ok(())
|
|
}
|
|
|
|
// ---- Snapshots ----
|
|
|
|
/// Create a named snapshot of the current state.
|
|
pub fn create_snapshot(&mut self, message: &str) -> Result<Snapshot, BrittleError> {
|
|
Ok(self.store.create_snapshot(message)?)
|
|
}
|
|
|
|
/// List all snapshots in reverse chronological order (most recent first).
|
|
pub fn list_snapshots(&self) -> Result<Vec<Snapshot>, BrittleError> {
|
|
Ok(self.store.list_snapshots()?)
|
|
}
|
|
|
|
/// Restore to a previous snapshot.
|
|
///
|
|
/// Returns an error if there are uncommitted changes. Use [`discard_changes`] first.
|
|
pub fn restore_snapshot(&mut self, snapshot_id: &str) -> Result<(), BrittleError> {
|
|
if self.store.has_uncommitted_changes()? {
|
|
return Err(ValidationError::UncommittedChanges.into());
|
|
}
|
|
Ok(self.store.restore_snapshot(snapshot_id)?)
|
|
}
|
|
|
|
/// Returns `true` if there are unsaved changes not yet captured in a snapshot.
|
|
pub fn has_uncommitted_changes(&self) -> Result<bool, BrittleError> {
|
|
Ok(self.store.has_uncommitted_changes()?)
|
|
}
|
|
|
|
/// Discard all uncommitted changes, reverting to the state of the last snapshot.
|
|
pub fn discard_changes(&mut self) -> Result<(), BrittleError> {
|
|
let snapshots = self.store.list_snapshots()?;
|
|
if let Some(latest) = snapshots.into_iter().next() {
|
|
self.store.restore_snapshot(&latest.id)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Case-insensitive substring match across key reference fields.
|
|
fn reference_matches(r: &Reference, query: &str) -> bool {
|
|
r.cite_key.to_lowercase().contains(query)
|
|
|| r.title().is_some_and(|t| t.to_lowercase().contains(query))
|
|
|| r.year().is_some_and(|y| y.to_lowercase().contains(query))
|
|
|| r.authors
|
|
.iter()
|
|
.any(|a| a.family.to_lowercase().contains(query))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::model::{AnnotationType, Color, TextMarkupType};
|
|
|
|
fn make_brittle() -> Brittle<MemoryStore> {
|
|
Brittle::with_store(MemoryStore::new())
|
|
}
|
|
|
|
// ---- Reference tests ----
|
|
|
|
#[test]
|
|
fn create_and_get_reference() {
|
|
let mut b = make_brittle();
|
|
let r = b.create_reference("doe2024", EntryType::Article).unwrap();
|
|
let fetched = b.get_reference(r.id).unwrap();
|
|
assert_eq!(fetched.cite_key, "doe2024");
|
|
}
|
|
|
|
#[test]
|
|
fn empty_cite_key_rejected() {
|
|
let mut b = make_brittle();
|
|
assert!(matches!(
|
|
b.create_reference("", EntryType::Misc).unwrap_err(),
|
|
BrittleError::Validation(ValidationError::EmptyCiteKey)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_cite_key_rejected() {
|
|
let mut b = make_brittle();
|
|
b.create_reference("same", EntryType::Article).unwrap();
|
|
assert!(matches!(
|
|
b.create_reference("same", EntryType::Book).unwrap_err(),
|
|
BrittleError::Validation(ValidationError::DuplicateCiteKey { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn delete_reference_cascades_to_libraries_and_annotations() {
|
|
let mut b = make_brittle();
|
|
let r = b.create_reference("cascade2024", EntryType::Misc).unwrap();
|
|
let lib = b.create_library("Test Lib", None).unwrap();
|
|
b.add_to_library(lib.id, r.id).unwrap();
|
|
|
|
// Create annotation.
|
|
b.create_annotation(
|
|
r.id,
|
|
0,
|
|
AnnotationType::Note {
|
|
position: Point { x: 0.0, y: 0.0 },
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
b.delete_reference(r.id).unwrap();
|
|
|
|
// Reference gone.
|
|
assert!(b.get_reference(r.id).is_err());
|
|
|
|
// Library no longer contains the reference.
|
|
let lib2 = b.get_library(lib.id).unwrap();
|
|
assert!(!lib2.members.contains(&r.id));
|
|
|
|
// Annotations gone.
|
|
let anns = b.get_annotations(r.id).unwrap();
|
|
assert!(anns.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn set_and_remove_field() {
|
|
let mut b = make_brittle();
|
|
let r = b
|
|
.create_reference("fields2024", EntryType::Article)
|
|
.unwrap();
|
|
b.set_field(r.id, "title", "Great Paper").unwrap();
|
|
let r2 = b.get_reference(r.id).unwrap();
|
|
assert_eq!(r2.fields.get("title").unwrap(), "Great Paper");
|
|
|
|
b.remove_field(r.id, "title").unwrap();
|
|
let r3 = b.get_reference(r.id).unwrap();
|
|
assert!(!r3.fields.contains_key("title"));
|
|
}
|
|
|
|
// ---- Library tests ----
|
|
|
|
#[test]
|
|
fn create_and_list_root_libraries() {
|
|
let mut b = make_brittle();
|
|
b.create_library("Science", None).unwrap();
|
|
b.create_library("Arts", None).unwrap();
|
|
let roots = b.list_root_libraries().unwrap();
|
|
assert_eq!(roots.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_library_name_rejected() {
|
|
let mut b = make_brittle();
|
|
assert!(matches!(
|
|
b.create_library("", None).unwrap_err(),
|
|
BrittleError::Validation(ValidationError::EmptyLibraryName)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn nested_libraries_and_children() {
|
|
let mut b = make_brittle();
|
|
let parent = b.create_library("Science", None).unwrap();
|
|
b.create_library("Physics", Some(parent.id)).unwrap();
|
|
b.create_library("Biology", Some(parent.id)).unwrap();
|
|
let children = b.list_child_libraries(parent.id).unwrap();
|
|
assert_eq!(children.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn delete_library_with_children_fails() {
|
|
let mut b = make_brittle();
|
|
let parent = b.create_library("Parent", None).unwrap();
|
|
b.create_library("Child", Some(parent.id)).unwrap();
|
|
assert!(matches!(
|
|
b.delete_library(parent.id).unwrap_err(),
|
|
BrittleError::Validation(ValidationError::LibraryHasChildren { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn move_library_cycle_detection() {
|
|
let mut b = make_brittle();
|
|
let a = b.create_library("A", None).unwrap();
|
|
let b_lib = b.create_library("B", Some(a.id)).unwrap();
|
|
// Moving A under B would create A -> B -> A cycle.
|
|
assert!(matches!(
|
|
b.move_library(a.id, Some(b_lib.id)).unwrap_err(),
|
|
BrittleError::Validation(ValidationError::LibraryCycle { .. })
|
|
));
|
|
}
|
|
|
|
// ---- Membership tests ----
|
|
|
|
#[test]
|
|
fn multi_membership() {
|
|
let mut b = make_brittle();
|
|
let r = b.create_reference("multi2024", EntryType::Article).unwrap();
|
|
let lib1 = b.create_library("Lib 1", None).unwrap();
|
|
let lib2 = b.create_library("Lib 2", None).unwrap();
|
|
|
|
b.add_to_library(lib1.id, r.id).unwrap();
|
|
b.add_to_library(lib2.id, r.id).unwrap();
|
|
|
|
let containing = b.list_reference_libraries(r.id).unwrap();
|
|
let ids: Vec<_> = containing.iter().map(|l| l.id).collect();
|
|
assert!(ids.contains(&lib1.id));
|
|
assert!(ids.contains(&lib2.id));
|
|
}
|
|
|
|
#[test]
|
|
fn list_library_references() {
|
|
let mut b = make_brittle();
|
|
let r1 = b.create_reference("r1", EntryType::Misc).unwrap();
|
|
let r2 = b.create_reference("r2", EntryType::Misc).unwrap();
|
|
let lib = b.create_library("All", None).unwrap();
|
|
b.add_to_library(lib.id, r1.id).unwrap();
|
|
b.add_to_library(lib.id, r2.id).unwrap();
|
|
|
|
let summaries = b.list_library_references(lib.id).unwrap();
|
|
assert_eq!(summaries.len(), 2);
|
|
}
|
|
|
|
// ---- BibTeX export tests ----
|
|
|
|
#[test]
|
|
fn export_bibtex_valid_reference() {
|
|
let mut b = make_brittle();
|
|
let mut r = b
|
|
.create_reference("turing1950", EntryType::Article)
|
|
.unwrap();
|
|
r.authors.push(Person::new("Turing"));
|
|
r.fields
|
|
.insert("title".into(), "Computing Machinery".into());
|
|
r.fields.insert("journal".into(), "Mind".into());
|
|
r.fields.insert("year".into(), "1950".into());
|
|
b.update_reference(r.clone()).unwrap();
|
|
|
|
let (bibtex, errors) = b.export_bibtex(&[r.id]).unwrap();
|
|
assert!(errors.is_empty());
|
|
assert!(bibtex.contains("@article{turing1950,"));
|
|
}
|
|
|
|
// ---- Annotation tests ----
|
|
|
|
#[test]
|
|
fn create_update_delete_annotation() {
|
|
let mut b = make_brittle();
|
|
let r = b.create_reference("ann2024", EntryType::Misc).unwrap();
|
|
|
|
let ann = b
|
|
.create_annotation(
|
|
r.id,
|
|
0,
|
|
AnnotationType::TextMarkup {
|
|
markup_type: TextMarkupType::Highlight,
|
|
quads: vec![],
|
|
color: Color::YELLOW,
|
|
selected_text: Some("key text".into()),
|
|
},
|
|
Some("Important!".into()),
|
|
)
|
|
.unwrap();
|
|
|
|
let anns = b.get_annotations(r.id).unwrap();
|
|
assert_eq!(anns.len(), 1);
|
|
assert_eq!(anns[0].content.as_deref(), Some("Important!"));
|
|
|
|
// Update content.
|
|
let mut updated = ann.clone();
|
|
updated.content = Some("Very important!".into());
|
|
b.update_annotation(updated).unwrap();
|
|
let anns2 = b.get_annotations(r.id).unwrap();
|
|
assert_eq!(anns2[0].content.as_deref(), Some("Very important!"));
|
|
|
|
// Delete.
|
|
b.delete_annotation(r.id, ann.id).unwrap();
|
|
assert!(b.get_annotations(r.id).unwrap().is_empty());
|
|
}
|
|
|
|
// ---- Library ancestors tests ----
|
|
|
|
#[test]
|
|
fn ancestors_of_root_library_is_empty() {
|
|
let mut b = make_brittle();
|
|
let root = b.create_library("Root", None).unwrap();
|
|
assert!(b.get_library_ancestors(root.id).unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn ancestors_of_child_returns_parent() {
|
|
let mut b = make_brittle();
|
|
let root = b.create_library("Root", None).unwrap();
|
|
let child = b.create_library("Child", Some(root.id)).unwrap();
|
|
let ancestors = b.get_library_ancestors(child.id).unwrap();
|
|
assert_eq!(ancestors.len(), 1);
|
|
assert_eq!(ancestors[0].id, root.id);
|
|
}
|
|
|
|
#[test]
|
|
fn ancestors_are_ordered_root_first() {
|
|
let mut b = make_brittle();
|
|
let root = b.create_library("Root", None).unwrap();
|
|
let mid = b.create_library("Mid", Some(root.id)).unwrap();
|
|
let leaf = b.create_library("Leaf", Some(mid.id)).unwrap();
|
|
|
|
let ancestors = b.get_library_ancestors(leaf.id).unwrap();
|
|
assert_eq!(ancestors.len(), 2);
|
|
assert_eq!(ancestors[0].id, root.id);
|
|
assert_eq!(ancestors[1].id, mid.id);
|
|
}
|
|
|
|
#[test]
|
|
fn ancestors_of_nonexistent_library_returns_error() {
|
|
let b = make_brittle();
|
|
let fake_id = LibraryId::new();
|
|
assert!(b.get_library_ancestors(fake_id).is_err());
|
|
}
|
|
|
|
// ---- Force-delete library tests ----
|
|
|
|
#[test]
|
|
fn force_delete_removes_library_with_children() {
|
|
let mut b = make_brittle();
|
|
let parent = b.create_library("Parent", None).unwrap();
|
|
let child = b.create_library("Child", Some(parent.id)).unwrap();
|
|
|
|
b.force_delete_library(parent.id).unwrap();
|
|
|
|
assert!(b.get_library(parent.id).is_err());
|
|
assert!(b.get_library(child.id).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn force_delete_removes_entire_subtree() {
|
|
let mut b = make_brittle();
|
|
let root = b.create_library("Root", None).unwrap();
|
|
let a = b.create_library("A", Some(root.id)).unwrap();
|
|
let b_lib = b.create_library("B", Some(root.id)).unwrap();
|
|
let a1 = b.create_library("A1", Some(a.id)).unwrap();
|
|
|
|
b.force_delete_library(root.id).unwrap();
|
|
|
|
for id in [root.id, a.id, b_lib.id, a1.id] {
|
|
assert!(b.get_library(id).is_err());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn force_delete_leaves_references_intact() {
|
|
let mut b = make_brittle();
|
|
let lib = b.create_library("Lib", None).unwrap();
|
|
let r = b.create_reference("keep2024", EntryType::Misc).unwrap();
|
|
b.add_to_library(lib.id, r.id).unwrap();
|
|
|
|
b.force_delete_library(lib.id).unwrap();
|
|
|
|
// Library is gone; reference survives.
|
|
assert!(b.get_library(lib.id).is_err());
|
|
assert!(b.get_reference(r.id).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn force_delete_nonexistent_library_returns_error() {
|
|
let mut b = make_brittle();
|
|
let fake_id = LibraryId::new();
|
|
assert!(b.force_delete_library(fake_id).is_err());
|
|
}
|
|
|
|
// ---- Snapshot tests ----
|
|
|
|
#[test]
|
|
fn snapshot_workflow() {
|
|
let mut b = make_brittle();
|
|
let r = b.create_reference("snap2024", EntryType::Misc).unwrap();
|
|
let snap = b.create_snapshot("baseline").unwrap();
|
|
|
|
b.delete_reference(r.id).unwrap();
|
|
assert!(b.get_reference(r.id).is_err());
|
|
|
|
// MemoryStore allows restore without uncommitted-changes check.
|
|
b.store.restore_snapshot(&snap.id).unwrap();
|
|
assert!(b.get_reference(r.id).is_ok());
|
|
}
|
|
}
|