Clean up architecture
This commit is contained in:
84
CLAUDE.md
84
CLAUDE.md
@@ -1,84 +0,0 @@
|
||||
# Brittle — Literature Management System
|
||||
|
||||
A desktop application for managing academic references, PDFs, and annotations. Written in Rust.
|
||||
|
||||
## Project Vision
|
||||
|
||||
Brittle is a personal literature management tool in the spirit of Zotero. Core capabilities:
|
||||
|
||||
- Organize references in nested libraries (libraries contain sub-libraries)
|
||||
- Display and annotate PDFs (create, edit, view annotations)
|
||||
- Export references as BibTeX
|
||||
|
||||
This is a hobby project built primarily through AI-assisted development. The human operator makes architectural decisions; the agent writes the code.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Mandatory: Library-First Design
|
||||
|
||||
The backend is a **Rust crate** (`brittle-core`) that exposes a public API. This is the primary interface. A frontend like [iced](https://github.com/iced-rs/iced) links directly against this crate.
|
||||
|
||||
The API must be designed so that wrapping it in REST, gRPC, IPC, or any other transport layer requires **zero changes to `brittle-core`**. This means:
|
||||
|
||||
- No UI concepts leak into the core (no widget types, no rendering logic, no event loops)
|
||||
- Return types must be transport-agnostic: no framework-specific channels, callbacks, or handle types. Plain data that any layer can serialize or forward
|
||||
- Errors are structured and meaningful, not stringly-typed
|
||||
- The public API surface is the **single source of truth** for what Brittle can do
|
||||
|
||||
If a frontend needs something that isn't in the core API, the correct response is to extend the core API — not to hack around it.
|
||||
|
||||
### Dependency Philosophy
|
||||
|
||||
Use external crates where they provide real value (PDF rendering, BibTeX parsing, SQLite bindings, etc.). Do not pull in a dependency for something the standard library handles or for trivial utility code. When choosing between crates, prefer those that are well-maintained, have minimal transitive dependencies, and are widely used in the Rust ecosystem.
|
||||
|
||||
### Database Versioning
|
||||
|
||||
The database must support some form of version control or change tracking. The specific mechanism (event sourcing, snapshot-based, migration-based, audit log, etc.) is an **open design question** to be resolved during planning before implementation begins.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
Every non-trivial feature follows this process end-to-end. Do not skip steps.
|
||||
|
||||
### 1. Requirements (what, not how)
|
||||
|
||||
Start from user-facing behavior. What does this feature need to do? Define concrete acceptance criteria. Do not think about implementation yet.
|
||||
|
||||
### 2. Architectural Fit
|
||||
|
||||
Before designing anything, assess how this feature relates to what already exists:
|
||||
|
||||
- **Fit**: Does it integrate cleanly with the current structure?
|
||||
- **Risk**: If restructuring is needed, what existing functionality could break?
|
||||
- **Benefit**: What do we gain from restructuring vs. working within the current design?
|
||||
- **Verdict**: Restructure, adapt, or flag as tech debt?
|
||||
|
||||
Write this assessment as part of the plan. Do not silently restructure.
|
||||
|
||||
### 3. Design (top-down)
|
||||
|
||||
Now decide *how*: types, traits, modules, public API surface. Work downward from the API the feature needs, not upward from utility code you think might be useful.
|
||||
|
||||
### 4. Tests
|
||||
|
||||
Write failing tests that encode the acceptance criteria from step 1. These tests define "done."
|
||||
|
||||
### 5. Implementation
|
||||
|
||||
Make the tests pass. Then refactor with confidence.
|
||||
|
||||
Use **Plan mode** at the start of any multi-file task.
|
||||
|
||||
## Agent Behavior
|
||||
|
||||
Do not blindly implement what is asked. Consider whether the request is actually what the user needs, whether there's a better approach, and whether there are edge cases or implications the user may not have considered.
|
||||
|
||||
If you see a better path, **argue for it**. Explain your reasoning clearly. But if the user disagrees after hearing your case, accept their decision and implement it well.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Prioritize readability and testability over cleverness
|
||||
- Use meaningful names; avoid abbreviations unless they're domain-standard (e.g., `bib`, `doi`, `pdf`)
|
||||
- Keep functions focused — if a function needs a paragraph-long comment to explain, it should probably be split
|
||||
- Use Rust idioms: `Result` for fallible operations, strong types over stringly-typed data, `enum` for state machines
|
||||
- Run `cargo clippy` and `cargo fmt` before considering any task complete
|
||||
- Document public API items with doc comments
|
||||
20
Cargo.lock
generated
20
Cargo.lock
generated
@@ -165,6 +165,7 @@ dependencies = [
|
||||
name = "brittle-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"brittle-model",
|
||||
"chrono",
|
||||
"git2",
|
||||
"serde",
|
||||
@@ -173,7 +174,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.8.2",
|
||||
"ureq",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -181,6 +181,24 @@ dependencies = [
|
||||
name = "brittle-keymap"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "brittle-model"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brittle-seed"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"brittle-core",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[workspace]
|
||||
members = ["brittle-core", "brittle-keymap", "src-tauri"]
|
||||
members = ["brittle-model", "brittle-core", "brittle-keymap", "src-tauri", "tools/brittle-seed"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -4,19 +4,15 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
brittle-model = { path = "../brittle-model" }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
git2 = { version = "0.20", features = ["vendored-libgit2"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
sha2 = "0.10"
|
||||
thiserror = "2"
|
||||
toml = "0.8"
|
||||
ureq = "2"
|
||||
uuid = { version = "1", features = ["v7", "serde"] }
|
||||
|
||||
[[bin]]
|
||||
name = "brittle-seed"
|
||||
path = "src/bin/seed.rs"
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
|
||||
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -18,33 +19,8 @@ pub use store::{FsStore, MemoryStore, Store};
|
||||
use crate::bibtex::export_references;
|
||||
use crate::error::EntityType;
|
||||
use chrono::Utc;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A lightweight summary of a reference, suitable for list views.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ReferenceSummary {
|
||||
pub id: ReferenceId,
|
||||
pub cite_key: String,
|
||||
pub entry_type: EntryType,
|
||||
pub title: Option<String>,
|
||||
pub authors: Vec<Person>,
|
||||
pub year: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&Reference> for ReferenceSummary {
|
||||
fn from(r: &Reference) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
cite_key: r.cite_key.clone(),
|
||||
entry_type: r.entry_type.clone(),
|
||||
title: r.title().map(str::to_owned),
|
||||
authors: r.authors.clone(),
|
||||
year: r.year().map(str::to_owned),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The main entry point for Brittle.
|
||||
///
|
||||
/// Generic over [`Store`] to allow testing with [`MemoryStore`] and production
|
||||
|
||||
@@ -1,229 +1 @@
|
||||
use crate::model::ids::{AnnotationId, ReferenceId};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A point in PDF coordinate space.
|
||||
/// Origin is bottom-left; units are points (1/72 inch), matching ISO 32000.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Point {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
/// A rectangle in PDF coordinate space.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Rect {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
/// A quadrilateral for text markup annotations (highlight, underline, etc.).
|
||||
/// Four points define one region, typically one line of text.
|
||||
/// Matches the PDF spec QuadPoints representation (4 vertices per quad).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Quad {
|
||||
pub points: [Point; 4],
|
||||
}
|
||||
|
||||
/// RGBA color.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Color {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
pub a: u8,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
pub const YELLOW: Color = Color {
|
||||
r: 255,
|
||||
g: 255,
|
||||
b: 0,
|
||||
a: 128,
|
||||
};
|
||||
pub const RED: Color = Color {
|
||||
r: 255,
|
||||
g: 0,
|
||||
b: 0,
|
||||
a: 128,
|
||||
};
|
||||
pub const GREEN: Color = Color {
|
||||
r: 0,
|
||||
g: 255,
|
||||
b: 0,
|
||||
a: 128,
|
||||
};
|
||||
}
|
||||
|
||||
/// The four text markup annotation types defined in ISO 32000.
|
||||
/// All share the same QuadPoints-based geometry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TextMarkupType {
|
||||
Highlight,
|
||||
Underline,
|
||||
Squiggly,
|
||||
StrikeOut,
|
||||
}
|
||||
|
||||
/// The kind of annotation and its type-specific geometry/data.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum AnnotationType {
|
||||
/// Text markup (highlight, underline, squiggly, strikeout).
|
||||
/// Uses QuadPoints per PDF spec for precise multi-line region selection.
|
||||
TextMarkup {
|
||||
markup_type: TextMarkupType,
|
||||
quads: Vec<Quad>,
|
||||
color: Color,
|
||||
/// The selected text, stored for search and export without re-reading the PDF.
|
||||
selected_text: Option<String>,
|
||||
},
|
||||
/// Sticky note (popup comment).
|
||||
Note { position: Point },
|
||||
/// Inline text box.
|
||||
FreeText { rect: Rect },
|
||||
/// Freehand ink drawing (e.g., circling a diagram).
|
||||
Ink {
|
||||
/// Multiple strokes, each a sequence of connected points.
|
||||
paths: Vec<Vec<Point>>,
|
||||
color: Color,
|
||||
/// Stroke width in points.
|
||||
width: f64,
|
||||
},
|
||||
/// Area/image selection for extracting figures from PDFs.
|
||||
Area { rect: Rect },
|
||||
}
|
||||
|
||||
/// A single annotation on a PDF page.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Annotation {
|
||||
pub id: AnnotationId,
|
||||
pub reference_id: ReferenceId,
|
||||
/// 0-indexed physical page number.
|
||||
pub page: u32,
|
||||
/// Display page label (e.g., "iv", "23") — may differ from the physical page index.
|
||||
pub page_label: Option<String>,
|
||||
pub annotation_type: AnnotationType,
|
||||
/// Free-form text: note body, comment on a highlight, etc.
|
||||
pub content: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Annotation {
|
||||
pub fn new(reference_id: ReferenceId, page: u32, annotation_type: AnnotationType) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: AnnotationId::new(),
|
||||
reference_id,
|
||||
page,
|
||||
page_label: None,
|
||||
annotation_type,
|
||||
content: None,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All annotations for a single reference, stored as one file.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnnotationSet {
|
||||
pub reference_id: ReferenceId,
|
||||
pub annotations: Vec<Annotation>,
|
||||
}
|
||||
|
||||
impl AnnotationSet {
|
||||
pub fn new(reference_id: ReferenceId) -> Self {
|
||||
Self {
|
||||
reference_id,
|
||||
annotations: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_highlight() -> AnnotationType {
|
||||
AnnotationType::TextMarkup {
|
||||
markup_type: TextMarkupType::Highlight,
|
||||
quads: vec![Quad {
|
||||
points: [
|
||||
Point { x: 10.0, y: 20.0 },
|
||||
Point { x: 100.0, y: 20.0 },
|
||||
Point { x: 10.0, y: 30.0 },
|
||||
Point { x: 100.0, y: 30.0 },
|
||||
],
|
||||
}],
|
||||
color: Color::YELLOW,
|
||||
selected_text: Some("important text".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_serde_round_trip_highlight() {
|
||||
let ref_id = ReferenceId::new();
|
||||
let set = AnnotationSet {
|
||||
reference_id: ref_id,
|
||||
annotations: vec![Annotation::new(ref_id, 3, make_highlight())],
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string(&set).expect("serialize");
|
||||
let set2: AnnotationSet = toml::from_str(&toml_str).expect("deserialize");
|
||||
|
||||
assert_eq!(set.reference_id, set2.reference_id);
|
||||
assert_eq!(set.annotations.len(), set2.annotations.len());
|
||||
assert_eq!(set.annotations[0].page, set2.annotations[0].page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_serde_round_trip_ink() {
|
||||
let ref_id = ReferenceId::new();
|
||||
let ink = AnnotationType::Ink {
|
||||
paths: vec![vec![Point { x: 0.0, y: 0.0 }, Point { x: 10.0, y: 10.0 }]],
|
||||
color: Color::RED,
|
||||
width: 2.0,
|
||||
};
|
||||
let set = AnnotationSet {
|
||||
reference_id: ref_id,
|
||||
annotations: vec![Annotation::new(ref_id, 0, ink)],
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string(&set).expect("serialize");
|
||||
let set2: AnnotationSet = toml::from_str(&toml_str).expect("deserialize");
|
||||
assert_eq!(set, set2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_markup_types_serialize() {
|
||||
let ref_id = ReferenceId::new();
|
||||
for markup_type in [
|
||||
TextMarkupType::Highlight,
|
||||
TextMarkupType::Underline,
|
||||
TextMarkupType::Squiggly,
|
||||
TextMarkupType::StrikeOut,
|
||||
] {
|
||||
let ann = Annotation::new(
|
||||
ref_id,
|
||||
0,
|
||||
AnnotationType::TextMarkup {
|
||||
markup_type,
|
||||
quads: vec![],
|
||||
color: Color::GREEN,
|
||||
selected_text: None,
|
||||
},
|
||||
);
|
||||
let set = AnnotationSet {
|
||||
reference_id: ref_id,
|
||||
annotations: vec![ann],
|
||||
};
|
||||
let toml_str = toml::to_string(&set).expect("serialize");
|
||||
let _: AnnotationSet = toml::from_str(&toml_str).expect("deserialize");
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use brittle_model::annotation::*;
|
||||
|
||||
@@ -1,67 +1 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use uuid::Uuid;
|
||||
|
||||
macro_rules! define_id {
|
||||
($name:ident) => {
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
|
||||
)]
|
||||
pub struct $name(pub Uuid);
|
||||
|
||||
impl $name {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::now_v7())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Uuid> for $name {
|
||||
fn from(uuid: Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
define_id!(ReferenceId);
|
||||
define_id!(LibraryId);
|
||||
define_id!(AnnotationId);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_generates_unique_ids() {
|
||||
let a = ReferenceId::new();
|
||||
let b = ReferenceId::new();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_is_uuid_format() {
|
||||
let id = ReferenceId::new();
|
||||
let s = id.to_string();
|
||||
assert_eq!(s.len(), 36); // UUID hyphenated format
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip() {
|
||||
let id = LibraryId::new();
|
||||
let json = serde_json::to_string(&id).unwrap();
|
||||
let id2: LibraryId = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(id, id2);
|
||||
}
|
||||
}
|
||||
pub use brittle_model::ids::*;
|
||||
|
||||
@@ -1,66 +1 @@
|
||||
use crate::model::ids::{LibraryId, ReferenceId};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// A named collection of references. Forms a tree via `parent_id`.
|
||||
///
|
||||
/// References are not "owned" by a library — they exist in a flat pool.
|
||||
/// A reference can appear in multiple libraries (multi-membership).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Library {
|
||||
pub id: LibraryId,
|
||||
pub name: String,
|
||||
/// `None` means this is a root library (no parent).
|
||||
pub parent_id: Option<LibraryId>,
|
||||
/// The set of references that are members of this library.
|
||||
/// BTreeSet for deterministic serialization order.
|
||||
pub members: BTreeSet<ReferenceId>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub fn new(name: impl Into<String>, parent_id: Option<LibraryId>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: LibraryId::new(),
|
||||
name: name.into(),
|
||||
parent_id,
|
||||
members: BTreeSet::new(),
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn library_serde_round_trip() {
|
||||
let mut lib = Library::new("Machine Learning", None);
|
||||
let ref_id = ReferenceId::new();
|
||||
lib.members.insert(ref_id);
|
||||
|
||||
let toml_str = toml::to_string(&lib).expect("serialize to TOML");
|
||||
let lib2: Library = toml::from_str(&toml_str).expect("deserialize from TOML");
|
||||
|
||||
assert_eq!(lib.id, lib2.id);
|
||||
assert_eq!(lib.name, lib2.name);
|
||||
assert_eq!(lib.members, lib2.members);
|
||||
assert_eq!(lib.parent_id, lib2.parent_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_library_serde_round_trip() {
|
||||
let parent = Library::new("Science", None);
|
||||
let child = Library::new("Physics", Some(parent.id));
|
||||
|
||||
let toml_str = toml::to_string(&child).expect("serialize to TOML");
|
||||
let child2: Library = toml::from_str(&toml_str).expect("deserialize from TOML");
|
||||
|
||||
assert_eq!(child2.parent_id, Some(parent.id));
|
||||
}
|
||||
}
|
||||
pub use brittle_model::library::*;
|
||||
|
||||
@@ -11,3 +11,70 @@ pub use ids::{AnnotationId, LibraryId, ReferenceId};
|
||||
pub use library::Library;
|
||||
pub use reference::{EntryType, PdfAttachment, Person, Reference};
|
||||
pub use snapshot::Snapshot;
|
||||
|
||||
/// TOML round-trip tests for model types used in on-disk storage.
|
||||
///
|
||||
/// These live in brittle-core (not brittle-model) because TOML is a
|
||||
/// brittle-core persistence concern — brittle-model itself is TOML-agnostic.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reference_toml_round_trip() {
|
||||
let mut r = Reference::new("doe2024", EntryType::Article);
|
||||
r.authors.push(Person {
|
||||
family: "Doe".into(),
|
||||
given: Some("Jane".into()),
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
});
|
||||
r.fields.insert("title".into(), "A Great Paper".into());
|
||||
r.fields.insert("year".into(), "2024".into());
|
||||
|
||||
let toml_str = toml::to_string(&r).expect("serialize to TOML");
|
||||
let r2: Reference = toml::from_str(&toml_str).expect("deserialize from TOML");
|
||||
|
||||
assert_eq!(r.id, r2.id);
|
||||
assert_eq!(r.cite_key, r2.cite_key);
|
||||
assert_eq!(r.authors, r2.authors);
|
||||
assert_eq!(r.fields, r2.fields);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_toml_round_trip() {
|
||||
let mut lib = Library::new("Machine Learning", None);
|
||||
lib.members.insert(ReferenceId::new());
|
||||
|
||||
let toml_str = toml::to_string(&lib).expect("serialize to TOML");
|
||||
let lib2: Library = toml::from_str(&toml_str).expect("deserialize from TOML");
|
||||
|
||||
assert_eq!(lib.id, lib2.id);
|
||||
assert_eq!(lib.name, lib2.name);
|
||||
assert_eq!(lib.members, lib2.members);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_toml_round_trip() {
|
||||
let ref_id = ReferenceId::new();
|
||||
let set = AnnotationSet {
|
||||
reference_id: ref_id,
|
||||
annotations: vec![Annotation::new(
|
||||
ref_id,
|
||||
3,
|
||||
AnnotationType::TextMarkup {
|
||||
markup_type: TextMarkupType::Highlight,
|
||||
quads: vec![],
|
||||
color: Color::YELLOW,
|
||||
selected_text: Some("key phrase".into()),
|
||||
},
|
||||
)],
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string(&set).expect("serialize to TOML");
|
||||
let set2: AnnotationSet = toml::from_str(&toml_str).expect("deserialize from TOML");
|
||||
|
||||
assert_eq!(set.reference_id, set2.reference_id);
|
||||
assert_eq!(set.annotations[0].page, set2.annotations[0].page);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,241 +1 @@
|
||||
use crate::model::ids::ReferenceId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A person (author, editor, translator, etc.).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Person {
|
||||
pub family: String,
|
||||
pub given: Option<String>,
|
||||
/// Name prefix: "von", "de", "van der", etc.
|
||||
pub prefix: Option<String>,
|
||||
/// Name suffix: "Jr.", "III", etc.
|
||||
pub suffix: Option<String>,
|
||||
}
|
||||
|
||||
impl Person {
|
||||
pub fn new(family: impl Into<String>) -> Self {
|
||||
Self {
|
||||
family: family.into(),
|
||||
given: None,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format for display: "Given prefix Family, Suffix" — natural reading order.
|
||||
pub fn display_name(&self) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(given) = &self.given {
|
||||
parts.push(given.as_str());
|
||||
}
|
||||
if let Some(prefix) = &self.prefix {
|
||||
parts.push(prefix.as_str());
|
||||
}
|
||||
parts.push(self.family.as_str());
|
||||
let mut name = parts.join(" ");
|
||||
if let Some(suffix) = &self.suffix {
|
||||
name.push_str(", ");
|
||||
name.push_str(suffix);
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// Format as BibTeX expects: "{prefix} {family}, {suffix}, {given}".
|
||||
/// Falls back gracefully when optional parts are absent.
|
||||
pub fn to_bibtex(&self) -> String {
|
||||
let mut family_part = String::new();
|
||||
if let Some(prefix) = &self.prefix {
|
||||
family_part.push_str(prefix);
|
||||
family_part.push(' ');
|
||||
}
|
||||
family_part.push_str(&self.family);
|
||||
|
||||
match (&self.suffix, &self.given) {
|
||||
(Some(suffix), Some(given)) => {
|
||||
format!("{family_part}, {suffix}, {given}")
|
||||
}
|
||||
(Some(suffix), None) => format!("{family_part}, {suffix}"),
|
||||
(None, Some(given)) => format!("{family_part}, {given}"),
|
||||
(None, None) => family_part,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Person {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.display_name())
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard BibTeX and common BibLaTeX entry types.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EntryType {
|
||||
Article,
|
||||
Book,
|
||||
Booklet,
|
||||
InBook,
|
||||
InCollection,
|
||||
InProceedings,
|
||||
Manual,
|
||||
MastersThesis,
|
||||
Misc,
|
||||
PhdThesis,
|
||||
Proceedings,
|
||||
TechReport,
|
||||
Unpublished,
|
||||
Online,
|
||||
}
|
||||
|
||||
impl EntryType {
|
||||
/// The BibTeX entry type name as it appears in `.bib` files.
|
||||
pub fn bibtex_name(&self) -> &'static str {
|
||||
match self {
|
||||
EntryType::Article => "article",
|
||||
EntryType::Book => "book",
|
||||
EntryType::Booklet => "booklet",
|
||||
EntryType::InBook => "inbook",
|
||||
EntryType::InCollection => "incollection",
|
||||
EntryType::InProceedings => "inproceedings",
|
||||
EntryType::Manual => "manual",
|
||||
EntryType::MastersThesis => "mastersthesis",
|
||||
EntryType::Misc => "misc",
|
||||
EntryType::PhdThesis => "phdthesis",
|
||||
EntryType::Proceedings => "proceedings",
|
||||
EntryType::TechReport => "techreport",
|
||||
EntryType::Unpublished => "unpublished",
|
||||
EntryType::Online => "online",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF file stored inside the Brittle repository.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PdfAttachment {
|
||||
/// Path relative to the repository root (e.g., `"pdfs/550e8400-....pdf"`).
|
||||
pub stored_path: PathBuf,
|
||||
/// SHA-256 hex digest of the file contents for integrity verification.
|
||||
pub content_hash: String,
|
||||
}
|
||||
|
||||
/// A citable work. The core entity of Brittle.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Reference {
|
||||
pub id: ReferenceId,
|
||||
/// The BibTeX cite key (e.g., `"knuth1984texbook"`). User-facing and mutable.
|
||||
pub cite_key: String,
|
||||
pub entry_type: EntryType,
|
||||
/// Authors listed in order.
|
||||
pub authors: Vec<Person>,
|
||||
/// Editors (for edited books, proceedings, etc.).
|
||||
pub editors: Vec<Person>,
|
||||
/// All other fields (title, year, journal, volume, etc.) as plain strings.
|
||||
/// BTreeMap for deterministic serialization order (important for git diffs).
|
||||
pub fields: BTreeMap<String, String>,
|
||||
pub pdf: Option<PdfAttachment>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Reference {
|
||||
pub fn new(cite_key: impl Into<String>, entry_type: EntryType) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: ReferenceId::new(),
|
||||
cite_key: cite_key.into(),
|
||||
entry_type,
|
||||
authors: Vec::new(),
|
||||
editors: Vec::new(),
|
||||
fields: BTreeMap::new(),
|
||||
pdf: None,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the value of the `title` field, if present.
|
||||
pub fn title(&self) -> Option<&str> {
|
||||
self.fields.get("title").map(String::as_str)
|
||||
}
|
||||
|
||||
/// Returns the value of the `year` field, if present.
|
||||
pub fn year(&self) -> Option<&str> {
|
||||
self.fields.get("year").map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn person_bibtex_full() {
|
||||
let p = Person {
|
||||
family: "Dijkstra".into(),
|
||||
given: Some("Edsger W.".into()),
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
};
|
||||
assert_eq!(p.to_bibtex(), "Dijkstra, Edsger W.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_bibtex_with_prefix() {
|
||||
let p = Person {
|
||||
family: "Beethoven".into(),
|
||||
given: Some("Ludwig".into()),
|
||||
prefix: Some("van".into()),
|
||||
suffix: None,
|
||||
};
|
||||
assert_eq!(p.to_bibtex(), "van Beethoven, Ludwig");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_bibtex_with_suffix() {
|
||||
let p = Person {
|
||||
family: "King".into(),
|
||||
given: Some("Martin Luther".into()),
|
||||
prefix: None,
|
||||
suffix: Some("Jr.".into()),
|
||||
};
|
||||
assert_eq!(p.to_bibtex(), "King, Jr., Martin Luther");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_bibtex_family_only() {
|
||||
let p = Person::new("Aristotle");
|
||||
assert_eq!(p.to_bibtex(), "Aristotle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_serde_round_trip() {
|
||||
let mut r = Reference::new("doe2024", EntryType::Article);
|
||||
r.authors.push(Person {
|
||||
family: "Doe".into(),
|
||||
given: Some("Jane".into()),
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
});
|
||||
r.fields.insert("title".into(), "A Great Paper".into());
|
||||
r.fields.insert("year".into(), "2024".into());
|
||||
|
||||
let toml_str = toml::to_string(&r).expect("serialize to TOML");
|
||||
let r2: Reference = toml::from_str(&toml_str).expect("deserialize from TOML");
|
||||
|
||||
assert_eq!(r.id, r2.id);
|
||||
assert_eq!(r.cite_key, r2.cite_key);
|
||||
assert_eq!(r.authors, r2.authors);
|
||||
assert_eq!(r.fields, r2.fields);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_type_bibtex_names() {
|
||||
assert_eq!(EntryType::Article.bibtex_name(), "article");
|
||||
assert_eq!(EntryType::InProceedings.bibtex_name(), "inproceedings");
|
||||
assert_eq!(EntryType::PhdThesis.bibtex_name(), "phdthesis");
|
||||
}
|
||||
}
|
||||
pub use brittle_model::reference::*;
|
||||
|
||||
@@ -1,12 +1 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Metadata about a stored snapshot (git commit).
|
||||
/// Not serialized to files — read directly from git history.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Snapshot {
|
||||
/// Git commit SHA (hex string).
|
||||
pub id: String,
|
||||
pub message: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
pub use brittle_model::snapshot::*;
|
||||
|
||||
12
brittle-model/Cargo.toml
Normal file
12
brittle-model/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "brittle-model"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
uuid = { version = "1", features = ["v7", "serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
214
brittle-model/src/annotation.rs
Normal file
214
brittle-model/src/annotation.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use crate::ids::{AnnotationId, ReferenceId};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A point in PDF coordinate space.
|
||||
/// Origin is bottom-left; units are points (1/72 inch), matching ISO 32000.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Point {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
/// A rectangle in PDF coordinate space.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Rect {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
/// A quadrilateral for text markup annotations (highlight, underline, etc.).
|
||||
/// Four points define one region, typically one line of text.
|
||||
/// Matches the PDF spec QuadPoints representation (4 vertices per quad).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Quad {
|
||||
pub points: [Point; 4],
|
||||
}
|
||||
|
||||
/// RGBA color.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Color {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
pub a: u8,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
pub const YELLOW: Color = Color { r: 255, g: 255, b: 0, a: 128 };
|
||||
pub const RED: Color = Color { r: 255, g: 0, b: 0, a: 128 };
|
||||
pub const GREEN: Color = Color { r: 0, g: 255, b: 0, a: 128 };
|
||||
}
|
||||
|
||||
/// The four text markup annotation types defined in ISO 32000.
|
||||
/// All share the same QuadPoints-based geometry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TextMarkupType {
|
||||
Highlight,
|
||||
Underline,
|
||||
Squiggly,
|
||||
StrikeOut,
|
||||
}
|
||||
|
||||
/// The kind of annotation and its type-specific geometry/data.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum AnnotationType {
|
||||
/// Text markup (highlight, underline, squiggly, strikeout).
|
||||
/// Uses QuadPoints per PDF spec for precise multi-line region selection.
|
||||
TextMarkup {
|
||||
markup_type: TextMarkupType,
|
||||
quads: Vec<Quad>,
|
||||
color: Color,
|
||||
/// The selected text, stored for search and export without re-reading the PDF.
|
||||
selected_text: Option<String>,
|
||||
},
|
||||
/// Sticky note (popup comment).
|
||||
Note { position: Point },
|
||||
/// Inline text box.
|
||||
FreeText { rect: Rect },
|
||||
/// Freehand ink drawing (e.g., circling a diagram).
|
||||
Ink {
|
||||
/// Multiple strokes, each a sequence of connected points.
|
||||
paths: Vec<Vec<Point>>,
|
||||
color: Color,
|
||||
/// Stroke width in points.
|
||||
width: f64,
|
||||
},
|
||||
/// Area/image selection for extracting figures from PDFs.
|
||||
Area { rect: Rect },
|
||||
}
|
||||
|
||||
/// A single annotation on a PDF page.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Annotation {
|
||||
pub id: AnnotationId,
|
||||
pub reference_id: ReferenceId,
|
||||
/// 0-indexed physical page number.
|
||||
pub page: u32,
|
||||
/// Display page label (e.g., "iv", "23") — may differ from the physical page index.
|
||||
pub page_label: Option<String>,
|
||||
pub annotation_type: AnnotationType,
|
||||
/// Free-form text: note body, comment on a highlight, etc.
|
||||
pub content: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Annotation {
|
||||
pub fn new(reference_id: ReferenceId, page: u32, annotation_type: AnnotationType) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: AnnotationId::new(),
|
||||
reference_id,
|
||||
page,
|
||||
page_label: None,
|
||||
annotation_type,
|
||||
content: None,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All annotations for a single reference, stored as one file.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AnnotationSet {
|
||||
pub reference_id: ReferenceId,
|
||||
pub annotations: Vec<Annotation>,
|
||||
}
|
||||
|
||||
impl AnnotationSet {
|
||||
pub fn new(reference_id: ReferenceId) -> Self {
|
||||
Self {
|
||||
reference_id,
|
||||
annotations: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_highlight() -> AnnotationType {
|
||||
AnnotationType::TextMarkup {
|
||||
markup_type: TextMarkupType::Highlight,
|
||||
quads: vec![Quad {
|
||||
points: [
|
||||
Point { x: 10.0, y: 20.0 },
|
||||
Point { x: 100.0, y: 20.0 },
|
||||
Point { x: 10.0, y: 30.0 },
|
||||
Point { x: 100.0, y: 30.0 },
|
||||
],
|
||||
}],
|
||||
color: Color::YELLOW,
|
||||
selected_text: Some("important text".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_serde_round_trip_highlight() {
|
||||
let ref_id = ReferenceId::new();
|
||||
let set = AnnotationSet {
|
||||
reference_id: ref_id,
|
||||
annotations: vec![Annotation::new(ref_id, 3, make_highlight())],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&set).expect("serialize");
|
||||
let set2: AnnotationSet = serde_json::from_str(&json).expect("deserialize");
|
||||
|
||||
assert_eq!(set.reference_id, set2.reference_id);
|
||||
assert_eq!(set.annotations.len(), set2.annotations.len());
|
||||
assert_eq!(set.annotations[0].page, set2.annotations[0].page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_serde_round_trip_ink() {
|
||||
let ref_id = ReferenceId::new();
|
||||
let ink = AnnotationType::Ink {
|
||||
paths: vec![vec![Point { x: 0.0, y: 0.0 }, Point { x: 10.0, y: 10.0 }]],
|
||||
color: Color::RED,
|
||||
width: 2.0,
|
||||
};
|
||||
let set = AnnotationSet {
|
||||
reference_id: ref_id,
|
||||
annotations: vec![Annotation::new(ref_id, 0, ink)],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&set).expect("serialize");
|
||||
let set2: AnnotationSet = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(set, set2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_markup_types_serialize() {
|
||||
let ref_id = ReferenceId::new();
|
||||
for markup_type in [
|
||||
TextMarkupType::Highlight,
|
||||
TextMarkupType::Underline,
|
||||
TextMarkupType::Squiggly,
|
||||
TextMarkupType::StrikeOut,
|
||||
] {
|
||||
let ann = Annotation::new(
|
||||
ref_id,
|
||||
0,
|
||||
AnnotationType::TextMarkup {
|
||||
markup_type,
|
||||
quads: vec![],
|
||||
color: Color::GREEN,
|
||||
selected_text: None,
|
||||
},
|
||||
);
|
||||
let set = AnnotationSet {
|
||||
reference_id: ref_id,
|
||||
annotations: vec![ann],
|
||||
};
|
||||
let json = serde_json::to_string(&set).expect("serialize");
|
||||
let _: AnnotationSet = serde_json::from_str(&json).expect("deserialize");
|
||||
}
|
||||
}
|
||||
}
|
||||
67
brittle-model/src/ids.rs
Normal file
67
brittle-model/src/ids.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use uuid::Uuid;
|
||||
|
||||
macro_rules! define_id {
|
||||
($name:ident) => {
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
|
||||
)]
|
||||
pub struct $name(pub Uuid);
|
||||
|
||||
impl $name {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::now_v7())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Uuid> for $name {
|
||||
fn from(uuid: Uuid) -> Self {
|
||||
Self(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
define_id!(ReferenceId);
|
||||
define_id!(LibraryId);
|
||||
define_id!(AnnotationId);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_generates_unique_ids() {
|
||||
let a = ReferenceId::new();
|
||||
let b = ReferenceId::new();
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_is_uuid_format() {
|
||||
let id = ReferenceId::new();
|
||||
let s = id.to_string();
|
||||
assert_eq!(s.len(), 36); // UUID hyphenated format
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trip() {
|
||||
let id = LibraryId::new();
|
||||
let json = serde_json::to_string(&id).unwrap();
|
||||
let id2: LibraryId = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(id, id2);
|
||||
}
|
||||
}
|
||||
137
brittle-model/src/lib.rs
Normal file
137
brittle-model/src/lib.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
//! `brittle-model` — shared data types for the Brittle reference manager.
|
||||
//!
|
||||
//! This crate contains all model types that cross the IPC boundary between the
|
||||
//! Tauri backend (`brittle-core`) and the Leptos/WASM frontend. It has no
|
||||
//! native-only dependencies and compiles to both `wasm32-unknown-unknown` and
|
||||
//! native targets.
|
||||
//!
|
||||
//! Both `brittle-core` and `brittle-ui` depend on this crate.
|
||||
|
||||
pub mod annotation;
|
||||
pub mod ids;
|
||||
pub mod library;
|
||||
pub mod reference;
|
||||
pub mod snapshot;
|
||||
|
||||
pub use annotation::{
|
||||
Annotation, AnnotationSet, AnnotationType, Color, Point, Quad, Rect, TextMarkupType,
|
||||
};
|
||||
pub use ids::{AnnotationId, LibraryId, ReferenceId};
|
||||
pub use library::Library;
|
||||
pub use reference::{EntryType, PdfAttachment, Person, Reference};
|
||||
pub use snapshot::Snapshot;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// A lightweight summary of a reference, suitable for list views.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
|
||||
pub struct ReferenceSummary {
|
||||
pub id: ReferenceId,
|
||||
pub cite_key: String,
|
||||
pub entry_type: EntryType,
|
||||
pub title: Option<String>,
|
||||
pub authors: Vec<Person>,
|
||||
pub year: Option<String>,
|
||||
}
|
||||
|
||||
impl ReferenceSummary {
|
||||
/// Title or "[no title]" fallback.
|
||||
pub fn title_display(&self) -> &str {
|
||||
self.title.as_deref().unwrap_or("[no title]")
|
||||
}
|
||||
|
||||
/// Compact author string: "Family", "A & B", or "A et al."
|
||||
pub fn author_display(&self) -> String {
|
||||
match self.authors.len() {
|
||||
0 => "—".into(),
|
||||
1 => self.authors[0].family.clone(),
|
||||
2 => format!("{} & {}", self.authors[0].family, self.authors[1].family),
|
||||
_ => format!("{} et al.", self.authors[0].family),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Reference> for ReferenceSummary {
|
||||
fn from(r: &Reference) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
cite_key: r.cite_key.clone(),
|
||||
entry_type: r.entry_type.clone(),
|
||||
title: r.title().map(str::to_owned),
|
||||
authors: r.authors.clone(),
|
||||
year: r.year().map(str::to_owned),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn p(family: &str) -> Person {
|
||||
Person { family: family.into(), given: None, prefix: None, suffix: None }
|
||||
}
|
||||
|
||||
fn base_summary() -> ReferenceSummary {
|
||||
ReferenceSummary {
|
||||
id: ReferenceId::new(),
|
||||
cite_key: "x".into(),
|
||||
entry_type: EntryType::Article,
|
||||
title: None,
|
||||
authors: vec![],
|
||||
year: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_display_fallback() {
|
||||
let rs = base_summary();
|
||||
assert_eq!(rs.title_display(), "[no title]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_display_with_title() {
|
||||
let rs = ReferenceSummary { title: Some("My Paper".into()), ..base_summary() };
|
||||
assert_eq!(rs.title_display(), "My Paper");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_display_empty() {
|
||||
assert_eq!(base_summary().author_display(), "—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_display_one() {
|
||||
let rs = ReferenceSummary { authors: vec![p("Smith")], ..base_summary() };
|
||||
assert_eq!(rs.author_display(), "Smith");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_display_two() {
|
||||
let rs = ReferenceSummary { authors: vec![p("Smith"), p("Jones")], ..base_summary() };
|
||||
assert_eq!(rs.author_display(), "Smith & Jones");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_display_many() {
|
||||
let rs = ReferenceSummary {
|
||||
authors: vec![p("Smith"), p("Jones"), p("Brown")],
|
||||
..base_summary()
|
||||
};
|
||||
assert_eq!(rs.author_display(), "Smith et al.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_from_reference() {
|
||||
let mut r = Reference::new("einstein1905", EntryType::Article);
|
||||
r.fields.insert("title".into(), "On the Electrodynamics of Moving Bodies".into());
|
||||
r.fields.insert("year".into(), "1905".into());
|
||||
r.authors.push(Person::new("Einstein"));
|
||||
|
||||
let s = ReferenceSummary::from(&r);
|
||||
assert_eq!(s.cite_key, "einstein1905");
|
||||
assert_eq!(s.title_display(), "On the Electrodynamics of Moving Bodies");
|
||||
assert_eq!(s.author_display(), "Einstein");
|
||||
assert_eq!(s.year.as_deref(), Some("1905"));
|
||||
}
|
||||
}
|
||||
66
brittle-model/src/library.rs
Normal file
66
brittle-model/src/library.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use crate::ids::{LibraryId, ReferenceId};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// A named collection of references. Forms a tree via `parent_id`.
|
||||
///
|
||||
/// References are not "owned" by a library — they exist in a flat pool.
|
||||
/// A reference can appear in multiple libraries (multi-membership).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Library {
|
||||
pub id: LibraryId,
|
||||
pub name: String,
|
||||
/// `None` means this is a root library (no parent).
|
||||
pub parent_id: Option<LibraryId>,
|
||||
/// The set of references that are members of this library.
|
||||
/// BTreeSet for deterministic serialization order.
|
||||
pub members: BTreeSet<ReferenceId>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub fn new(name: impl Into<String>, parent_id: Option<LibraryId>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: LibraryId::new(),
|
||||
name: name.into(),
|
||||
parent_id,
|
||||
members: BTreeSet::new(),
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn library_serde_round_trip() {
|
||||
let mut lib = Library::new("Machine Learning", None);
|
||||
let ref_id = ReferenceId::new();
|
||||
lib.members.insert(ref_id);
|
||||
|
||||
let json = serde_json::to_string(&lib).expect("serialize to JSON");
|
||||
let lib2: Library = serde_json::from_str(&json).expect("deserialize from JSON");
|
||||
|
||||
assert_eq!(lib.id, lib2.id);
|
||||
assert_eq!(lib.name, lib2.name);
|
||||
assert_eq!(lib.members, lib2.members);
|
||||
assert_eq!(lib.parent_id, lib2.parent_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_library_serde_round_trip() {
|
||||
let parent = Library::new("Science", None);
|
||||
let child = Library::new("Physics", Some(parent.id));
|
||||
|
||||
let json = serde_json::to_string(&child).expect("serialize to JSON");
|
||||
let child2: Library = serde_json::from_str(&json).expect("deserialize from JSON");
|
||||
|
||||
assert_eq!(child2.parent_id, Some(parent.id));
|
||||
}
|
||||
}
|
||||
275
brittle-model/src/reference.rs
Normal file
275
brittle-model/src/reference.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
use crate::ids::ReferenceId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A person (author, editor, translator, etc.).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Person {
|
||||
pub family: String,
|
||||
pub given: Option<String>,
|
||||
/// Name prefix: "von", "de", "van der", etc.
|
||||
pub prefix: Option<String>,
|
||||
/// Name suffix: "Jr.", "III", etc.
|
||||
pub suffix: Option<String>,
|
||||
}
|
||||
|
||||
impl Person {
|
||||
pub fn new(family: impl Into<String>) -> Self {
|
||||
Self {
|
||||
family: family.into(),
|
||||
given: None,
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format for display: "Given prefix Family, Suffix" — natural reading order.
|
||||
pub fn display_name(&self) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(given) = &self.given {
|
||||
parts.push(given.as_str());
|
||||
}
|
||||
if let Some(prefix) = &self.prefix {
|
||||
parts.push(prefix.as_str());
|
||||
}
|
||||
parts.push(self.family.as_str());
|
||||
let mut name = parts.join(" ");
|
||||
if let Some(suffix) = &self.suffix {
|
||||
name.push_str(", ");
|
||||
name.push_str(suffix);
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// Format as BibTeX expects: "{prefix} {family}, {suffix}, {given}".
|
||||
/// Falls back gracefully when optional parts are absent.
|
||||
pub fn to_bibtex(&self) -> String {
|
||||
let mut family_part = String::new();
|
||||
if let Some(prefix) = &self.prefix {
|
||||
family_part.push_str(prefix);
|
||||
family_part.push(' ');
|
||||
}
|
||||
family_part.push_str(&self.family);
|
||||
|
||||
match (&self.suffix, &self.given) {
|
||||
(Some(suffix), Some(given)) => {
|
||||
format!("{family_part}, {suffix}, {given}")
|
||||
}
|
||||
(Some(suffix), None) => format!("{family_part}, {suffix}"),
|
||||
(None, Some(given)) => format!("{family_part}, {given}"),
|
||||
(None, None) => family_part,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Person {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.display_name())
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard BibTeX and common BibLaTeX entry types.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EntryType {
|
||||
Article,
|
||||
Book,
|
||||
Booklet,
|
||||
InBook,
|
||||
InCollection,
|
||||
InProceedings,
|
||||
Manual,
|
||||
MastersThesis,
|
||||
Misc,
|
||||
PhdThesis,
|
||||
Proceedings,
|
||||
TechReport,
|
||||
Unpublished,
|
||||
Online,
|
||||
}
|
||||
|
||||
impl EntryType {
|
||||
/// The BibTeX entry type name as it appears in `.bib` files.
|
||||
pub fn bibtex_name(&self) -> &'static str {
|
||||
match self {
|
||||
EntryType::Article => "article",
|
||||
EntryType::Book => "book",
|
||||
EntryType::Booklet => "booklet",
|
||||
EntryType::InBook => "inbook",
|
||||
EntryType::InCollection => "incollection",
|
||||
EntryType::InProceedings => "inproceedings",
|
||||
EntryType::Manual => "manual",
|
||||
EntryType::MastersThesis => "mastersthesis",
|
||||
EntryType::Misc => "misc",
|
||||
EntryType::PhdThesis => "phdthesis",
|
||||
EntryType::Proceedings => "proceedings",
|
||||
EntryType::TechReport => "techreport",
|
||||
EntryType::Unpublished => "unpublished",
|
||||
EntryType::Online => "online",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EntryType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.bibtex_name())
|
||||
}
|
||||
}
|
||||
|
||||
/// A PDF file stored inside the Brittle repository.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PdfAttachment {
|
||||
/// Path relative to the repository root (e.g., `"pdfs/550e8400-....pdf"`).
|
||||
pub stored_path: PathBuf,
|
||||
/// SHA-256 hex digest of the file contents for integrity verification.
|
||||
pub content_hash: String,
|
||||
}
|
||||
|
||||
/// A citable work. The core entity of Brittle.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Reference {
|
||||
pub id: ReferenceId,
|
||||
/// The BibTeX cite key (e.g., `"knuth1984texbook"`). User-facing and mutable.
|
||||
pub cite_key: String,
|
||||
pub entry_type: EntryType,
|
||||
/// Authors listed in order.
|
||||
pub authors: Vec<Person>,
|
||||
/// Editors (for edited books, proceedings, etc.).
|
||||
pub editors: Vec<Person>,
|
||||
/// All other fields (title, year, journal, volume, etc.) as plain strings.
|
||||
/// BTreeMap for deterministic serialization order (important for git diffs).
|
||||
pub fields: BTreeMap<String, String>,
|
||||
pub pdf: Option<PdfAttachment>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub modified_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Reference {
|
||||
pub fn new(cite_key: impl Into<String>, entry_type: EntryType) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: ReferenceId::new(),
|
||||
cite_key: cite_key.into(),
|
||||
entry_type,
|
||||
authors: Vec::new(),
|
||||
editors: Vec::new(),
|
||||
fields: BTreeMap::new(),
|
||||
pdf: None,
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the value of the `title` field, if present.
|
||||
pub fn title(&self) -> Option<&str> {
|
||||
self.fields.get("title").map(String::as_str)
|
||||
}
|
||||
|
||||
/// Returns the value of the `year` field, if present.
|
||||
pub fn year(&self) -> Option<&str> {
|
||||
self.fields.get("year").map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn person_bibtex_full() {
|
||||
let p = Person {
|
||||
family: "Dijkstra".into(),
|
||||
given: Some("Edsger W.".into()),
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
};
|
||||
assert_eq!(p.to_bibtex(), "Dijkstra, Edsger W.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_bibtex_with_prefix() {
|
||||
let p = Person {
|
||||
family: "Beethoven".into(),
|
||||
given: Some("Ludwig".into()),
|
||||
prefix: Some("van".into()),
|
||||
suffix: None,
|
||||
};
|
||||
assert_eq!(p.to_bibtex(), "van Beethoven, Ludwig");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_bibtex_with_suffix() {
|
||||
let p = Person {
|
||||
family: "King".into(),
|
||||
given: Some("Martin Luther".into()),
|
||||
prefix: None,
|
||||
suffix: Some("Jr.".into()),
|
||||
};
|
||||
assert_eq!(p.to_bibtex(), "King, Jr., Martin Luther");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_bibtex_family_only() {
|
||||
let p = Person::new("Aristotle");
|
||||
assert_eq!(p.to_bibtex(), "Aristotle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_display_name_full() {
|
||||
let p = Person {
|
||||
family: "Turing".into(),
|
||||
given: Some("Alan".into()),
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
};
|
||||
assert_eq!(p.display_name(), "Alan Turing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_display_name_with_prefix_and_suffix() {
|
||||
let p = Person {
|
||||
family: "King".into(),
|
||||
given: Some("Martin Luther".into()),
|
||||
prefix: None,
|
||||
suffix: Some("Jr.".into()),
|
||||
};
|
||||
assert_eq!(p.display_name(), "Martin Luther King, Jr.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_serde_round_trip() {
|
||||
let mut r = Reference::new("doe2024", EntryType::Article);
|
||||
r.authors.push(Person {
|
||||
family: "Doe".into(),
|
||||
given: Some("Jane".into()),
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
});
|
||||
r.fields.insert("title".into(), "A Great Paper".into());
|
||||
r.fields.insert("year".into(), "2024".into());
|
||||
|
||||
let json = serde_json::to_string(&r).expect("serialize to JSON");
|
||||
let r2: Reference = serde_json::from_str(&json).expect("deserialize from JSON");
|
||||
|
||||
assert_eq!(r.id, r2.id);
|
||||
assert_eq!(r.cite_key, r2.cite_key);
|
||||
assert_eq!(r.authors, r2.authors);
|
||||
assert_eq!(r.fields, r2.fields);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_type_bibtex_names() {
|
||||
assert_eq!(EntryType::Article.bibtex_name(), "article");
|
||||
assert_eq!(EntryType::InProceedings.bibtex_name(), "inproceedings");
|
||||
assert_eq!(EntryType::PhdThesis.bibtex_name(), "phdthesis");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_type_display() {
|
||||
assert_eq!(EntryType::Article.to_string(), "article");
|
||||
assert_eq!(EntryType::InProceedings.to_string(), "inproceedings");
|
||||
}
|
||||
}
|
||||
12
brittle-model/src/snapshot.rs
Normal file
12
brittle-model/src/snapshot.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Metadata about a stored snapshot (git commit).
|
||||
/// Not serialized to files — read directly from git history.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
/// Git commit SHA (hex string).
|
||||
pub id: String,
|
||||
pub message: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
@@ -78,10 +78,5 @@ pub fn list_library_references_recursive(
|
||||
state: State<AppState>,
|
||||
library_id: LibraryId,
|
||||
) -> Result<Vec<ReferenceSummary>, String> {
|
||||
let result = state.with_repo_read(|b| b.list_library_references_recursive(library_id));
|
||||
match &result {
|
||||
Ok(refs) => eprintln!("[brittle] list_library_references_recursive({library_id}): {} refs", refs.len()),
|
||||
Err(e) => eprintln!("[brittle] list_library_references_recursive({library_id}): ERROR: {e}"),
|
||||
}
|
||||
result
|
||||
state.with_repo_read(|b| b.list_library_references_recursive(library_id))
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@ pub fn handle<R: Runtime>(app: &AppHandle<R>, req: &Request<Vec<u8>>) -> Respons
|
||||
|
||||
// ── Route handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
fn serve_viewer(ref_id: &str) -> Response<Vec<u8>> {
|
||||
// Substitute the ref_id into the HTML template so the viewer knows which PDF to load.
|
||||
let html = String::from_utf8_lossy(VIEWER_HTML)
|
||||
.replace("ref_id=\"\"", &format!("ref_id=\"{}\"", ref_id));
|
||||
response_ok(html.into_bytes(), "text/html; charset=utf-8")
|
||||
fn serve_viewer(_ref_id: &str) -> Response<Vec<u8>> {
|
||||
// The viewer reads `ref_id` from its own URL query string via JavaScript
|
||||
// (`new URLSearchParams(location.search).get("ref_id")`), so no substitution
|
||||
// into the HTML template is needed.
|
||||
response_ok(VIEWER_HTML.to_vec(), "text/html; charset=utf-8")
|
||||
}
|
||||
|
||||
fn serve_pdfjs_file(rel_path: &str) -> Response<Vec<u8>> {
|
||||
@@ -148,7 +148,6 @@ fn response_500(msg: &str) -> Response<Vec<u8>> {
|
||||
// ── Pure routing logic (unit-testable) ───────────────────────────────────────
|
||||
|
||||
pub mod routing {
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Route {
|
||||
@@ -185,23 +184,6 @@ pub mod routing {
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// Return the appropriate MIME type for a file path based on its extension.
|
||||
pub fn mime_for_path(path: &Path) -> &'static str {
|
||||
match path.extension().and_then(|e| e.to_str()) {
|
||||
Some("js") => "application/javascript; charset=utf-8",
|
||||
Some("mjs") => "application/javascript; charset=utf-8",
|
||||
Some("css") => "text/css; charset=utf-8",
|
||||
Some("html") => "text/html; charset=utf-8",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("woff2") => "font/woff2",
|
||||
Some("woff") => "font/woff",
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("map") => "application/json",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -276,24 +258,6 @@ pub mod routing {
|
||||
assert_eq!(extract_ref_id(Some("foo=bar")), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mime_for_js_files() {
|
||||
assert!(mime_for_path(Path::new("pdf.min.js")).contains("javascript"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mime_for_css_files() {
|
||||
assert!(mime_for_path(Path::new("viewer.css")).contains("css"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mime_for_unknown_extension() {
|
||||
assert_eq!(
|
||||
mime_for_path(Path::new("data.bin")),
|
||||
"application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_traversal_rel_path_contains_dotdot() {
|
||||
// The handler rejects rel_paths containing ".."; verify the routing
|
||||
|
||||
155
src/Cargo.lock
generated
155
src/Cargo.lock
generated
@@ -11,6 +11,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "any_spawner"
|
||||
version = "0.2.0"
|
||||
@@ -80,6 +89,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -96,16 +111,27 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
name = "brittle-keymap"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "brittle-model"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brittle-ui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"brittle-keymap",
|
||||
"brittle-model",
|
||||
"js-sys",
|
||||
"leptos",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"uuid",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
@@ -129,12 +155,36 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codee"
|
||||
version = "0.3.5"
|
||||
@@ -218,6 +268,12 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
@@ -315,6 +371,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
@@ -531,6 +593,30 @@ dependencies = [
|
||||
"throw_error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.1.1"
|
||||
@@ -879,6 +965,15 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oco_ref"
|
||||
version = "0.2.1"
|
||||
@@ -1357,6 +1452,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -1612,6 +1713,7 @@ checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||
dependencies = [
|
||||
"getrandom",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -1774,12 +1876,65 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
|
||||
@@ -6,7 +6,9 @@ edition = "2021"
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
brittle-model = { path = "../brittle-model" }
|
||||
brittle-keymap = { path = "../brittle-keymap" }
|
||||
uuid = { version = "1", features = ["v7", "js"] }
|
||||
js-sys = "0.3"
|
||||
leptos = { version = "0.7", features = ["csr"] }
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
@@ -6,10 +6,11 @@ use brittle_keymap::actions;
|
||||
use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
|
||||
use brittle_model::{Library, LibraryId, Reference, ReferenceId, ReferenceSummary};
|
||||
|
||||
use crate::{
|
||||
command_bar::SearchQuery,
|
||||
lib_tree::{flatten_tree, LibraryTree, TreeRow},
|
||||
models::{Library, LibraryId, Reference, ReferenceId, ReferenceSummary},
|
||||
pub_detail::PubDetail,
|
||||
pub_list::PubList,
|
||||
ActionEvent,
|
||||
@@ -85,7 +86,11 @@ pub fn LibTab() -> impl IntoView {
|
||||
// The library selected by the tree cursor.
|
||||
let selected_library = Memo::new(move |_| {
|
||||
let pos = tree_cursor.get();
|
||||
tree_rows.with(|rows| rows.get(pos).map(|r| LibraryId(r.id.clone())))
|
||||
tree_rows.with(|rows| {
|
||||
rows.get(pos).and_then(|r| {
|
||||
uuid::Uuid::parse_str(&r.id).ok().map(LibraryId)
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
// The reference ID selected by the list cursor.
|
||||
@@ -154,11 +159,11 @@ pub fn LibTab() -> impl IntoView {
|
||||
let title = detail_ref.with_untracked(|r| {
|
||||
r.as_ref()
|
||||
.map(|r| r.cite_key.clone())
|
||||
.unwrap_or_else(|| ref_id.0.clone())
|
||||
.unwrap_or_else(|| ref_id.to_string())
|
||||
});
|
||||
if let Some(ctx) = use_context::<crate::OpenPdfContext>() {
|
||||
ctx.0.set(Some(crate::PdfOpenRequest {
|
||||
ref_id: ref_id.0.clone(),
|
||||
ref_id: ref_id.to_string(),
|
||||
title,
|
||||
}));
|
||||
}
|
||||
@@ -335,3 +340,265 @@ fn tree_collapse(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use brittle_keymap::actions;
|
||||
use leptos::prelude::*;
|
||||
|
||||
fn ev(name: &str) -> ActionEvent {
|
||||
ActionEvent { name: name.to_owned(), count: 1, seq: 0 }
|
||||
}
|
||||
|
||||
fn ev_count(name: &str, count: u32) -> ActionEvent {
|
||||
ActionEvent { name: name.to_owned(), count, seq: 0 }
|
||||
}
|
||||
|
||||
fn make_tree_rows(n: usize) -> Vec<TreeRow> {
|
||||
(0..n)
|
||||
.map(|i| TreeRow {
|
||||
id: format!("row-{i}"),
|
||||
name: format!("Row {i}"),
|
||||
depth: 0,
|
||||
expanded: false,
|
||||
may_have_children: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn make_ref_summaries(n: usize) -> Vec<ReferenceSummary> {
|
||||
use brittle_model::{EntryType, ReferenceId};
|
||||
(0..n)
|
||||
.map(|_| ReferenceSummary {
|
||||
id: ReferenceId::new(),
|
||||
cite_key: "x".into(),
|
||||
entry_type: EntryType::Misc,
|
||||
title: None,
|
||||
authors: vec![],
|
||||
year: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Set up signals and call handle_action in a Leptos reactive owner.
|
||||
// Returns the signal values after the action.
|
||||
fn run<F>(focused_init: Pane, tree_len: usize, list_len: usize, ev: &ActionEvent, check: F)
|
||||
where
|
||||
F: FnOnce(RwSignal<Pane>, RwSignal<usize>, RwSignal<usize>, RwSignal<HashSet<String>>),
|
||||
{
|
||||
let owner = Owner::new();
|
||||
owner.with(|| {
|
||||
let focused = RwSignal::new(focused_init);
|
||||
let tree_cursor = RwSignal::new(0usize);
|
||||
let list_cursor = RwSignal::new(0usize);
|
||||
let expanded = RwSignal::new(HashSet::<String>::new());
|
||||
let rows_data = make_tree_rows(tree_len);
|
||||
let rows = Memo::new(move |_| rows_data.clone());
|
||||
let list_items = RwSignal::new(make_ref_summaries(list_len));
|
||||
|
||||
handle_action(
|
||||
ev,
|
||||
focused,
|
||||
TreeState { cursor: tree_cursor, rows, expanded },
|
||||
ListState { cursor: list_cursor, items: list_items },
|
||||
);
|
||||
|
||||
check(focused, tree_cursor, list_cursor, expanded);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Focus switching ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn focus_left_sets_tree() {
|
||||
run(Pane::List, 0, 0, &ev(actions::FOCUS_LEFT), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::Tree);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_center_sets_list() {
|
||||
run(Pane::Tree, 0, 0, &ev(actions::FOCUS_CENTER), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::List);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_right_sets_detail() {
|
||||
run(Pane::Tree, 0, 0, &ev(actions::FOCUS_RIGHT), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::Detail);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_next_cycles_forward() {
|
||||
run(Pane::Tree, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::List);
|
||||
});
|
||||
run(Pane::List, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::Detail);
|
||||
});
|
||||
run(Pane::Detail, 0, 0, &ev(actions::FOCUS_NEXT), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::Tree);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_prev_cycles_backward() {
|
||||
run(Pane::Tree, 0, 0, &ev(actions::FOCUS_PREV), |f, _, _, _| {
|
||||
assert_eq!(f.get_untracked(), Pane::Detail);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tree navigation ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn nav_down_advances_tree_cursor() {
|
||||
run(Pane::Tree, 5, 0, &ev(actions::NAV_DOWN), |_, tc, _, _| {
|
||||
assert_eq!(tc.get_untracked(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_down_clamps_at_tree_end() {
|
||||
let owner = Owner::new();
|
||||
owner.with(|| {
|
||||
let focused = RwSignal::new(Pane::Tree);
|
||||
let tree_cursor = RwSignal::new(4usize); // already at last row
|
||||
let expanded = RwSignal::new(HashSet::<String>::new());
|
||||
let rows_data = make_tree_rows(5);
|
||||
let rows = Memo::new(move |_| rows_data.clone());
|
||||
let list_cursor = RwSignal::new(0usize);
|
||||
let list_items = RwSignal::new(vec![]);
|
||||
|
||||
handle_action(
|
||||
&ev(actions::NAV_DOWN),
|
||||
focused,
|
||||
TreeState { cursor: tree_cursor, rows, expanded },
|
||||
ListState { cursor: list_cursor, items: list_items },
|
||||
);
|
||||
|
||||
assert_eq!(tree_cursor.get_untracked(), 4); // stayed at 4
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_up_decrements_tree_cursor() {
|
||||
let owner = Owner::new();
|
||||
owner.with(|| {
|
||||
let focused = RwSignal::new(Pane::Tree);
|
||||
let tree_cursor = RwSignal::new(3usize);
|
||||
let expanded = RwSignal::new(HashSet::<String>::new());
|
||||
let rows_data = make_tree_rows(5);
|
||||
let rows = Memo::new(move |_| rows_data.clone());
|
||||
handle_action(
|
||||
&ev(actions::NAV_UP),
|
||||
focused,
|
||||
TreeState { cursor: tree_cursor, rows, expanded },
|
||||
ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) },
|
||||
);
|
||||
assert_eq!(tree_cursor.get_untracked(), 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_up_clamps_at_zero() {
|
||||
run(Pane::Tree, 5, 0, &ev(actions::NAV_UP), |_, tc, _, _| {
|
||||
assert_eq!(tc.get_untracked(), 0); // already 0, stays 0
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_top_jumps_tree_to_start() {
|
||||
let owner = Owner::new();
|
||||
owner.with(|| {
|
||||
let focused = RwSignal::new(Pane::Tree);
|
||||
let tree_cursor = RwSignal::new(4usize);
|
||||
let expanded = RwSignal::new(HashSet::<String>::new());
|
||||
let rows_data = make_tree_rows(5);
|
||||
let rows = Memo::new(move |_| rows_data.clone());
|
||||
handle_action(
|
||||
&ev(actions::NAV_TOP),
|
||||
focused,
|
||||
TreeState { cursor: tree_cursor, rows, expanded },
|
||||
ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) },
|
||||
);
|
||||
assert_eq!(tree_cursor.get_untracked(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_bottom_jumps_tree_to_end() {
|
||||
run(Pane::Tree, 5, 0, &ev(actions::NAV_BOTTOM), |_, tc, _, _| {
|
||||
assert_eq!(tc.get_untracked(), 4);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_down_with_count_advances_multiple() {
|
||||
run(Pane::Tree, 10, 0, &ev_count(actions::NAV_DOWN, 3), |_, tc, _, _| {
|
||||
assert_eq!(tc.get_untracked(), 3);
|
||||
});
|
||||
}
|
||||
|
||||
// ── List navigation ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn nav_down_advances_list_cursor_when_focused() {
|
||||
run(Pane::List, 0, 5, &ev(actions::NAV_DOWN), |_, _, lc, _| {
|
||||
assert_eq!(lc.get_untracked(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_down_on_tree_does_not_advance_list_cursor() {
|
||||
run(Pane::Tree, 3, 3, &ev(actions::NAV_DOWN), |_, tc, lc, _| {
|
||||
assert_eq!(tc.get_untracked(), 1); // tree moved
|
||||
assert_eq!(lc.get_untracked(), 0); // list unchanged
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_bottom_jumps_list_to_last_item() {
|
||||
run(Pane::List, 0, 5, &ev(actions::NAV_BOTTOM), |_, _, lc, _| {
|
||||
assert_eq!(lc.get_untracked(), 4);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tree expand/collapse ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tree_expand_action_inserts_id_into_expanded() {
|
||||
run(Pane::Tree, 3, 0, &ev(actions::TREE_EXPAND), |_, _, _, exp| {
|
||||
assert!(exp.with_untracked(|e| e.contains("row-0")));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_collapse_action_removes_id_from_expanded() {
|
||||
let owner = Owner::new();
|
||||
owner.with(|| {
|
||||
let focused = RwSignal::new(Pane::Tree);
|
||||
let tree_cursor = RwSignal::new(0usize);
|
||||
let expanded = RwSignal::new(HashSet::from_iter(["row-0".to_string()]));
|
||||
let rows_data = make_tree_rows(3);
|
||||
let rows = Memo::new(move |_| rows_data.clone());
|
||||
handle_action(
|
||||
&ev(actions::TREE_COLLAPSE),
|
||||
focused,
|
||||
TreeState { cursor: tree_cursor, rows, expanded },
|
||||
ListState { cursor: RwSignal::new(0), items: RwSignal::new(vec![]) },
|
||||
);
|
||||
assert!(!expanded.with_untracked(|e| e.contains("row-0")));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_actions_are_no_ops_when_list_focused() {
|
||||
run(Pane::List, 3, 0, &ev(actions::TREE_EXPAND), |_, _, _, exp| {
|
||||
assert!(exp.with_untracked(|e| e.is_empty()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use leptos::prelude::*;
|
||||
use leptos::task::spawn_local;
|
||||
use web_sys::DragEvent;
|
||||
|
||||
use crate::models::{Library, LibraryId, ReferenceId};
|
||||
use brittle_model::{Library, LibraryId, ReferenceId};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,7 +15,10 @@ pub fn load_children_if_needed(id: String, children_cache: RwSignal<HashMap<Stri
|
||||
let already = children_cache.with_untracked(|c| c.contains_key(&id));
|
||||
if !already {
|
||||
spawn_local(async move {
|
||||
let lib_id = LibraryId(id.clone());
|
||||
let lib_id = match uuid::Uuid::parse_str(&id) {
|
||||
Ok(u) => LibraryId(u),
|
||||
Err(_) => return,
|
||||
};
|
||||
match crate::tauri::list_child_libraries(&lib_id).await {
|
||||
Ok(children) => children_cache.update(|c| { c.insert(id, children); }),
|
||||
Err(e) => leptos::logging::error!("load children ({id}): {e}"),
|
||||
@@ -54,7 +57,7 @@ pub fn flatten_tree(
|
||||
) -> Vec<TreeRow> {
|
||||
let mut rows = Vec::new();
|
||||
for lib in libs {
|
||||
let id = lib.id.0.clone();
|
||||
let id = lib.id.to_string();
|
||||
let is_expanded = expanded.contains(&id);
|
||||
let children = children_cache.get(&id);
|
||||
let may_have_children = children.is_none_or(|c| !c.is_empty());
|
||||
@@ -175,8 +178,10 @@ pub fn LibraryTree(
|
||||
let Some(dt) = ev.data_transfer() else { return };
|
||||
let Ok(ref_id_str) = dt.get_data("application/brittle-ref-id") else { return };
|
||||
if ref_id_str.is_empty() { return }
|
||||
let lib_id = LibraryId(row_id_drop.clone());
|
||||
let ref_id = ReferenceId(ref_id_str);
|
||||
let Ok(lib_uuid) = uuid::Uuid::parse_str(&row_id_drop) else { return };
|
||||
let Ok(ref_uuid) = uuid::Uuid::parse_str(&ref_id_str) else { return };
|
||||
let lib_id = LibraryId(lib_uuid);
|
||||
let ref_id = ReferenceId(ref_uuid);
|
||||
spawn_local(async move {
|
||||
if let Err(e) = crate::tauri::add_to_library(&lib_id, &ref_id).await {
|
||||
leptos::logging::warn!("add_to_library: {e}");
|
||||
@@ -202,14 +207,17 @@ pub fn LibraryTree(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::LibraryId;
|
||||
|
||||
fn lib(id: &str, name: &str, parent: Option<&str>) -> Library {
|
||||
Library {
|
||||
id: LibraryId(id.into()),
|
||||
name: name.into(),
|
||||
parent_id: parent.map(|p| LibraryId(p.into())),
|
||||
}
|
||||
/// Create a Library with a deterministic UUID from a small integer.
|
||||
/// `n = 1` → `00000000-0000-0000-0000-000000000001`, etc.
|
||||
fn test_lib(n: u128, name: &str, parent: Option<u128>) -> Library {
|
||||
let mut lib = Library::new(name, parent.map(|p| LibraryId(uuid::Uuid::from_u128(p))));
|
||||
lib.id = LibraryId(uuid::Uuid::from_u128(n));
|
||||
lib
|
||||
}
|
||||
|
||||
fn id_str(n: u128) -> String {
|
||||
uuid::Uuid::from_u128(n).to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -220,10 +228,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn single_root_unloaded_children() {
|
||||
let root = lib("r", "Root", None);
|
||||
let root = test_lib(1, "Root", None);
|
||||
let rows = flatten_tree(&[root], &Default::default(), &Default::default(), 0);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].id, "r");
|
||||
assert_eq!(rows[0].name, "Root");
|
||||
assert_eq!(rows[0].depth, 0);
|
||||
assert!(!rows[0].expanded);
|
||||
@@ -233,10 +240,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn collapsed_node_hides_children() {
|
||||
let parent = lib("p", "Parent", None);
|
||||
let child = lib("c", "Child", Some("p"));
|
||||
let parent = test_lib(1, "Parent", None);
|
||||
let child = test_lib(2, "Child", Some(1));
|
||||
let mut cache = HashMap::new();
|
||||
cache.insert("p".into(), vec![child]);
|
||||
cache.insert(id_str(1), vec![child]);
|
||||
// Not in expanded set → children hidden
|
||||
let rows = flatten_tree(&[parent], &cache, &Default::default(), 0);
|
||||
assert_eq!(rows.len(), 1);
|
||||
@@ -245,12 +252,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn expanded_node_shows_children() {
|
||||
let parent = lib("p", "Parent", None);
|
||||
let child = lib("c", "Child", Some("p"));
|
||||
let parent = test_lib(1, "Parent", None);
|
||||
let child = test_lib(2, "Child", Some(1));
|
||||
let mut cache = HashMap::new();
|
||||
cache.insert("p".into(), vec![child]);
|
||||
cache.insert(id_str(1), vec![child]);
|
||||
let mut expanded = HashSet::new();
|
||||
expanded.insert("p".into());
|
||||
expanded.insert(id_str(1));
|
||||
|
||||
let rows = flatten_tree(&[parent], &cache, &expanded, 0);
|
||||
assert_eq!(rows.len(), 2);
|
||||
@@ -262,9 +269,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn loaded_empty_children_marks_leaf() {
|
||||
let node = lib("n", "Node", None);
|
||||
let node = test_lib(1, "Node", None);
|
||||
let mut cache = HashMap::new();
|
||||
cache.insert("n".into(), vec![]); // explicitly empty
|
||||
cache.insert(id_str(1), vec![]); // explicitly empty
|
||||
let rows = flatten_tree(&[node], &cache, &Default::default(), 0);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert!(!rows[0].may_have_children);
|
||||
@@ -272,17 +279,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn multi_level_nesting() {
|
||||
let root = lib("r", "Root", None);
|
||||
let mid = lib("m", "Mid", Some("r"));
|
||||
let leaf = lib("l", "Leaf", Some("m"));
|
||||
let root = test_lib(1, "Root", None);
|
||||
let mid = test_lib(2, "Mid", Some(1));
|
||||
let leaf = test_lib(3, "Leaf", Some(2));
|
||||
|
||||
let mut cache = HashMap::new();
|
||||
cache.insert("r".into(), vec![mid]);
|
||||
cache.insert("m".into(), vec![leaf]);
|
||||
cache.insert(id_str(1), vec![mid]);
|
||||
cache.insert(id_str(2), vec![leaf]);
|
||||
|
||||
let mut expanded = HashSet::new();
|
||||
expanded.insert("r".into());
|
||||
expanded.insert("m".into());
|
||||
expanded.insert(id_str(1));
|
||||
expanded.insert(id_str(2));
|
||||
|
||||
let rows = flatten_tree(&[root], &cache, &expanded, 0);
|
||||
assert_eq!(rows.len(), 3);
|
||||
@@ -294,8 +301,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn multiple_roots_ordered() {
|
||||
let a = lib("a", "Alpha", None);
|
||||
let b = lib("b", "Beta", None);
|
||||
let a = test_lib(1, "Alpha", None);
|
||||
let b = test_lib(2, "Beta", None);
|
||||
let rows = flatten_tree(&[a, b], &Default::default(), &Default::default(), 0);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].name, "Alpha");
|
||||
@@ -305,15 +312,16 @@ mod tests {
|
||||
#[test]
|
||||
fn only_expanded_subtrees_included() {
|
||||
// Parent expanded, but sibling collapsed
|
||||
let p = lib("p", "P", None);
|
||||
let s = lib("s", "S", None); // sibling root, also collapsed
|
||||
let c = lib("c", "C", Some("p"));
|
||||
let p = test_lib(1, "P", None);
|
||||
let s = test_lib(2, "S", None); // sibling root, also collapsed
|
||||
let c = test_lib(3, "C", Some(1));
|
||||
let sc = test_lib(4, "SC", Some(2));
|
||||
let mut cache = HashMap::new();
|
||||
cache.insert("p".into(), vec![c]);
|
||||
cache.insert("s".into(), vec![lib("sc", "SC", Some("s"))]);
|
||||
cache.insert(id_str(1), vec![c]);
|
||||
cache.insert(id_str(2), vec![sc]);
|
||||
|
||||
let mut expanded = HashSet::new();
|
||||
expanded.insert("p".into()); // expand only P
|
||||
expanded.insert(id_str(1)); // expand only P
|
||||
|
||||
let rows = flatten_tree(&[p, s], &cache, &expanded, 0);
|
||||
assert_eq!(rows.len(), 3); // P, C, S (SC hidden)
|
||||
|
||||
137
src/src/main.rs
137
src/src/main.rs
@@ -2,7 +2,6 @@ mod command_bar;
|
||||
mod commands;
|
||||
mod lib_tab;
|
||||
mod lib_tree;
|
||||
mod models;
|
||||
mod mode;
|
||||
mod pdf_viewer;
|
||||
mod pub_detail;
|
||||
@@ -287,16 +286,14 @@ fn key_from_parts(key_str: &str, ctrl: bool, shift: bool, alt: bool, meta: bool)
|
||||
Some(Key { code, ctrl, shift, alt, meta })
|
||||
}
|
||||
|
||||
// ── Root component ─────────────────────────────────────────────────────────────
|
||||
// ── Tab provider ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn App() -> impl IntoView {
|
||||
provide_keymap();
|
||||
provide_theme();
|
||||
let mode = provide_mode();
|
||||
provide_search_query();
|
||||
|
||||
// ── Tab state ─────────────────────────────────────────────────────────────
|
||||
/// Set up tab state and related contexts, following the same pattern as
|
||||
/// `provide_keymap()` and `provide_theme()`.
|
||||
///
|
||||
/// Provides: [`OpenPdfContext`], [`ReloadTrigger`].
|
||||
/// Returns: `(tabs, active_tab)` signals for the view layer.
|
||||
fn provide_tabs() -> (RwSignal<Vec<AppTab>>, RwSignal<usize>) {
|
||||
// The Library tab is always present at index 0 and cannot be closed.
|
||||
let tabs = RwSignal::new(vec![AppTab::Library]);
|
||||
let active_tab = RwSignal::new(0usize);
|
||||
@@ -318,17 +315,11 @@ fn App() -> impl IntoView {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Keymap wiring ─────────────────────────────────────────────────────────
|
||||
// Tab keymap effects (cycling and closing).
|
||||
let keymap_action = use_context::<KeymapAction>().unwrap().0;
|
||||
Effect::new(move |_| {
|
||||
let Some(ev) = keymap_action.get() else { return };
|
||||
match ev.name.as_str() {
|
||||
// Mode switching
|
||||
actions::MODE_COMMAND => mode.set(AppMode::Command),
|
||||
actions::MODE_SEARCH => mode.set(AppMode::Search),
|
||||
actions::MODE_NORMAL => mode.set(AppMode::Normal),
|
||||
|
||||
// Tab cycling
|
||||
actions::TAB_NEXT => {
|
||||
let len = tabs.with_untracked(Vec::len);
|
||||
if len > 1 {
|
||||
@@ -341,8 +332,7 @@ fn App() -> impl IntoView {
|
||||
active_tab.update(|i| *i = if *i == 0 { len - 1 } else { *i - 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// Close the active tab (Library tab is immortal)
|
||||
// Close the active tab (Library tab is immortal).
|
||||
actions::TAB_CLOSE => {
|
||||
let idx = active_tab.get_untracked();
|
||||
if idx > 0 {
|
||||
@@ -350,13 +340,12 @@ fn App() -> impl IntoView {
|
||||
active_tab.update(|i| *i = i.saturating_sub(1));
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Open-PDF request handler ───────────────────────────────────────────────
|
||||
// Watches for requests from LibTab and opens or activates the PDF tab.
|
||||
// Open-PDF request handler: watches for requests from LibTab and opens or
|
||||
// activates the appropriate PDF tab.
|
||||
Effect::new(move |_| {
|
||||
let Some(req) = open_pdf_req.get() else { return };
|
||||
|
||||
@@ -381,6 +370,31 @@ fn App() -> impl IntoView {
|
||||
open_pdf_req.set(None);
|
||||
});
|
||||
|
||||
(tabs, active_tab)
|
||||
}
|
||||
|
||||
// ── Root component ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn App() -> impl IntoView {
|
||||
provide_keymap();
|
||||
provide_theme();
|
||||
let mode = provide_mode();
|
||||
provide_search_query();
|
||||
let (tabs, active_tab) = provide_tabs();
|
||||
|
||||
// Mode-switching keymap effect (tab actions are handled inside provide_tabs).
|
||||
let keymap_action = use_context::<KeymapAction>().unwrap().0;
|
||||
Effect::new(move |_| {
|
||||
let Some(ev) = keymap_action.get() else { return };
|
||||
match ev.name.as_str() {
|
||||
actions::MODE_COMMAND => mode.set(AppMode::Command),
|
||||
actions::MODE_SEARCH => mode.set(AppMode::Search),
|
||||
actions::MODE_NORMAL => mode.set(AppMode::Normal),
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
// ── View ──────────────────────────────────────────────────────────────────
|
||||
view! {
|
||||
<div class="app">
|
||||
@@ -442,7 +456,8 @@ fn App() -> impl IntoView {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::action_key_to_name;
|
||||
use super::{action_key_to_name, key_from_parts};
|
||||
use brittle_keymap::{Key, KeyCode};
|
||||
|
||||
#[test]
|
||||
fn single_segment_unchanged() {
|
||||
@@ -474,4 +489,80 @@ mod tests {
|
||||
assert_eq!(action_key_to_name("mode_command"), "mode.command");
|
||||
assert_eq!(action_key_to_name("mode_normal"), "mode.normal");
|
||||
}
|
||||
|
||||
// ── key_from_parts ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn char_key_basic() {
|
||||
let k = key_from_parts("g", false, false, false, false).unwrap();
|
||||
assert_eq!(k, Key { code: KeyCode::Char('g'), ctrl: false, shift: false, alt: false, meta: false });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_key_shift_suppressed() {
|
||||
// 'G' already encodes the shift; shift flag must be suppressed to avoid <S-G>
|
||||
let k = key_from_parts("G", false, true, false, false).unwrap();
|
||||
assert_eq!(k.code, KeyCode::Char('G'));
|
||||
assert!(!k.shift, "shift must be false for Char keys");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_key_colon_shift_suppressed() {
|
||||
// ':' is the shifted ';' on many keyboards
|
||||
let k = key_from_parts(":", false, true, false, false).unwrap();
|
||||
assert_eq!(k.code, KeyCode::Char(':'));
|
||||
assert!(!k.shift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_key_ctrl_preserved() {
|
||||
let k = key_from_parts("a", true, false, false, false).unwrap();
|
||||
assert_eq!(k.code, KeyCode::Char('a'));
|
||||
assert!(k.ctrl);
|
||||
assert!(!k.shift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_key_shift_preserved() {
|
||||
let k = key_from_parts("Tab", false, true, false, false).unwrap();
|
||||
assert_eq!(k.code, KeyCode::Tab);
|
||||
assert!(k.shift, "shift must be preserved for special keys like Tab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_shift_preserved() {
|
||||
let k = key_from_parts("ArrowUp", false, true, false, false).unwrap();
|
||||
assert_eq!(k.code, KeyCode::ArrowUp);
|
||||
assert!(k.shift);
|
||||
|
||||
let k = key_from_parts("ArrowDown", false, true, false, false).unwrap();
|
||||
assert_eq!(k.code, KeyCode::ArrowDown);
|
||||
assert!(k.shift);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_escape_backspace() {
|
||||
assert_eq!(key_from_parts("Enter", false, false, false, false).unwrap().code, KeyCode::Enter);
|
||||
assert_eq!(key_from_parts("Escape", false, false, false, false).unwrap().code, KeyCode::Escape);
|
||||
assert_eq!(key_from_parts("Backspace", false, false, false, false).unwrap().code, KeyCode::Backspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_keys() {
|
||||
assert_eq!(key_from_parts("F1", false, false, false, false).unwrap().code, KeyCode::F(1));
|
||||
assert_eq!(key_from_parts("F12", false, false, false, false).unwrap().code, KeyCode::F(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_key_returns_none() {
|
||||
assert!(key_from_parts("Dead", false, false, false, false).is_none());
|
||||
assert!(key_from_parts("Unidentified", false, false, false, false).is_none());
|
||||
assert!(key_from_parts("AudioVolumeUp", false, false, false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_key() {
|
||||
let k = key_from_parts(" ", false, false, false, false).unwrap();
|
||||
assert_eq!(k.code, KeyCode::Space);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
//! Frontend mirror of `brittle-core` data types.
|
||||
//!
|
||||
//! These types are intentionally minimal — they carry only the fields the
|
||||
//! frontend needs. Unknown fields coming from the Tauri IPC are silently
|
||||
//! ignored by serde's default behaviour.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// ── IDs ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A library identifier (UUID string).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct LibraryId(pub String);
|
||||
|
||||
/// A reference identifier (UUID string).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ReferenceId(pub String);
|
||||
|
||||
// ── Supporting types ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A person (author, editor, etc.).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct Person {
|
||||
pub family: String,
|
||||
pub given: Option<String>,
|
||||
pub prefix: Option<String>,
|
||||
pub suffix: Option<String>,
|
||||
}
|
||||
|
||||
impl Person {
|
||||
/// "Given Family" or just "Family" when given is absent.
|
||||
pub fn display_name(&self) -> String {
|
||||
match &self.given {
|
||||
Some(g) => format!("{} {}", g, self.family),
|
||||
None => self.family.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BibTeX entry type.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EntryType {
|
||||
Article,
|
||||
Book,
|
||||
Booklet,
|
||||
InBook,
|
||||
InCollection,
|
||||
InProceedings,
|
||||
Manual,
|
||||
MastersThesis,
|
||||
Misc,
|
||||
PhdThesis,
|
||||
Proceedings,
|
||||
TechReport,
|
||||
Unpublished,
|
||||
Online,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EntryType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
EntryType::Article => "article",
|
||||
EntryType::Book => "book",
|
||||
EntryType::Booklet => "booklet",
|
||||
EntryType::InBook => "inbook",
|
||||
EntryType::InCollection => "incollection",
|
||||
EntryType::InProceedings => "inproceedings",
|
||||
EntryType::Manual => "manual",
|
||||
EntryType::MastersThesis => "mastersthesis",
|
||||
EntryType::Misc => "misc",
|
||||
EntryType::PhdThesis => "phdthesis",
|
||||
EntryType::Proceedings => "proceedings",
|
||||
EntryType::TechReport => "techreport",
|
||||
EntryType::Unpublished => "unpublished",
|
||||
EntryType::Online => "online",
|
||||
};
|
||||
write!(f, "{s}")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Library ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A library node in the tree (subset of `brittle_core::Library`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct Library {
|
||||
pub id: LibraryId,
|
||||
pub name: String,
|
||||
pub parent_id: Option<LibraryId>,
|
||||
}
|
||||
|
||||
// ── References ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A lightweight reference record used in the publication list.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub struct ReferenceSummary {
|
||||
pub id: ReferenceId,
|
||||
pub cite_key: String,
|
||||
pub entry_type: EntryType,
|
||||
pub title: Option<String>,
|
||||
pub authors: Vec<Person>,
|
||||
pub year: Option<String>,
|
||||
}
|
||||
|
||||
impl ReferenceSummary {
|
||||
/// Title or "[no title]" fallback.
|
||||
pub fn title_display(&self) -> &str {
|
||||
self.title.as_deref().unwrap_or("[no title]")
|
||||
}
|
||||
|
||||
/// Compact author string: "Family", "A & B", or "A et al."
|
||||
pub fn author_display(&self) -> String {
|
||||
match self.authors.len() {
|
||||
0 => "—".into(),
|
||||
1 => self.authors[0].family.clone(),
|
||||
2 => format!("{} & {}", self.authors[0].family, self.authors[1].family),
|
||||
_ => format!("{} et al.", self.authors[0].family),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full reference record used in the detail pane.
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct Reference {
|
||||
pub id: ReferenceId,
|
||||
pub cite_key: String,
|
||||
pub entry_type: EntryType,
|
||||
pub authors: Vec<Person>,
|
||||
pub editors: Vec<Person>,
|
||||
/// BibTeX fields: title, year, journal, doi, abstract, …
|
||||
pub fields: BTreeMap<String, String>,
|
||||
pub created_at: String,
|
||||
pub modified_at: String,
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::from_str;
|
||||
|
||||
#[test]
|
||||
fn library_id_transparent() {
|
||||
let id: LibraryId = from_str(r#""abc-123""#).unwrap();
|
||||
assert_eq!(id.0, "abc-123");
|
||||
|
||||
// Also verify round-trips back to a plain string.
|
||||
let back = serde_json::to_string(&id).unwrap();
|
||||
assert_eq!(back, r#""abc-123""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_ignores_extra_fields() {
|
||||
// brittle-core sends members, timestamps, etc. — we ignore them.
|
||||
let json = r#"{
|
||||
"id": "lib-1",
|
||||
"name": "Physics",
|
||||
"parent_id": null,
|
||||
"members": ["ref-x"],
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"modified_at": "2024-01-01T00:00:00Z"
|
||||
}"#;
|
||||
let lib: Library = from_str(json).unwrap();
|
||||
assert_eq!(lib.id.0, "lib-1");
|
||||
assert_eq!(lib.name, "Physics");
|
||||
assert_eq!(lib.parent_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_with_parent() {
|
||||
let json = r#"{"id": "child-1", "name": "QM", "parent_id": "lib-1"}"#;
|
||||
let lib: Library = from_str(json).unwrap();
|
||||
assert_eq!(lib.parent_id, Some(LibraryId("lib-1".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_summary_fields() {
|
||||
let json = r#"{
|
||||
"id": "ref-1",
|
||||
"cite_key": "einstein1905",
|
||||
"entry_type": "article",
|
||||
"title": "On the Electrodynamics of Moving Bodies",
|
||||
"authors": [{"family": "Einstein", "given": "Albert", "prefix": null, "suffix": null}],
|
||||
"year": "1905"
|
||||
}"#;
|
||||
let rs: ReferenceSummary = from_str(json).unwrap();
|
||||
assert_eq!(rs.cite_key, "einstein1905");
|
||||
assert_eq!(rs.title_display(), "On the Electrodynamics of Moving Bodies");
|
||||
assert_eq!(rs.author_display(), "Einstein");
|
||||
assert_eq!(rs.year.as_deref(), Some("1905"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_summary_no_title() {
|
||||
let rs = ReferenceSummary {
|
||||
id: ReferenceId("x".into()),
|
||||
cite_key: "x".into(),
|
||||
entry_type: EntryType::Misc,
|
||||
title: None,
|
||||
authors: vec![],
|
||||
year: None,
|
||||
};
|
||||
assert_eq!(rs.title_display(), "[no title]");
|
||||
assert_eq!(rs.author_display(), "—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_display_variants() {
|
||||
fn p(family: &str) -> Person {
|
||||
Person { family: family.into(), given: None, prefix: None, suffix: None }
|
||||
}
|
||||
let base = ReferenceSummary {
|
||||
id: ReferenceId("x".into()),
|
||||
cite_key: "x".into(),
|
||||
entry_type: EntryType::Article,
|
||||
title: None,
|
||||
authors: vec![],
|
||||
year: None,
|
||||
};
|
||||
let one = ReferenceSummary { authors: vec![p("Smith")], ..base.clone() };
|
||||
assert_eq!(one.author_display(), "Smith");
|
||||
|
||||
let two = ReferenceSummary { authors: vec![p("Smith"), p("Jones")], ..base.clone() };
|
||||
assert_eq!(two.author_display(), "Smith & Jones");
|
||||
|
||||
let many = ReferenceSummary {
|
||||
authors: vec![p("Smith"), p("Jones"), p("Brown")],
|
||||
..base
|
||||
};
|
||||
assert_eq!(many.author_display(), "Smith et al.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn person_display_name() {
|
||||
let p = Person {
|
||||
family: "Turing".into(),
|
||||
given: Some("Alan".into()),
|
||||
prefix: None,
|
||||
suffix: None,
|
||||
};
|
||||
assert_eq!(p.display_name(), "Alan Turing");
|
||||
|
||||
let p2 = Person { given: None, ..p };
|
||||
assert_eq!(p2.display_name(), "Turing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_type_display() {
|
||||
assert_eq!(EntryType::Article.to_string(), "article");
|
||||
assert_eq!(EntryType::InProceedings.to_string(), "inproceedings");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::models::Reference;
|
||||
use brittle_model::Reference;
|
||||
|
||||
/// Right pane: displays the fields of the currently selected reference.
|
||||
///
|
||||
@@ -78,7 +78,7 @@ pub fn PubDetail(reference: RwSignal<Option<Reference>>) -> impl IntoView {
|
||||
}
|
||||
</dl>
|
||||
<p class="detail-timestamps">
|
||||
"Modified: "{r.modified_at.clone()}
|
||||
"Modified: "{r.modified_at.format("%Y-%m-%d %H:%M UTC").to_string()}
|
||||
</p>
|
||||
</div>
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
use crate::{lib_tab::Pane, models::ReferenceSummary};
|
||||
use brittle_model::ReferenceSummary;
|
||||
|
||||
use crate::lib_tab::Pane;
|
||||
|
||||
/// Centre pane: a scrollable list of publication summaries.
|
||||
///
|
||||
@@ -37,7 +39,7 @@ pub fn PubList(
|
||||
let authors = item.author_display();
|
||||
let year = item.year.clone().unwrap_or_default();
|
||||
let kind = item.entry_type.to_string();
|
||||
let ref_id = item.id.0.clone();
|
||||
let ref_id = item.id.to_string();
|
||||
let cite_key = item.cite_key.clone();
|
||||
let ref_id_for_dblclick = ref_id.clone();
|
||||
view! {
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::models::{Library, LibraryId, Reference, ReferenceId, ReferenceSummary};
|
||||
use brittle_model::{Library, LibraryId, Reference, ReferenceId, ReferenceSummary};
|
||||
|
||||
// ── Low-level invoke ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
12
tools/brittle-seed/Cargo.toml
Normal file
12
tools/brittle-seed/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "brittle-seed"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "brittle-seed"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
brittle-core = { path = "../../brittle-core" }
|
||||
ureq = "2"
|
||||
Reference in New Issue
Block a user