Skip to main content

oximemo_core/
memo.rs

1//! Domain model: notes, identifiers, categories, pagination.
2//!
3//! These types are the shared contract between the file store, the metadata
4//! index, the search index, the CLI and the Tauri commands. Keeping them in one
5//! place means every subsystem agrees on what a "memo" is.
6
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use uuid::Uuid;
10
11use crate::error::{CoreError, Result};
12
13/// Permanent identifier of a memo. A UUIDv7 encodes its creation instant, so
14/// ids are time-ordered and usable as a synchronization tie-breaker.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
16#[serde(transparent)]
17pub struct MemoId(pub Uuid);
18
19impl MemoId {
20    /// Mint a new UUIDv7 id (creation time = now).
21    pub fn now() -> Self {
22        Self(Uuid::now_v7())
23    }
24
25    pub fn parse(s: &str) -> Result<Self> {
26        let u = Uuid::parse_str(s.trim()).map_err(|_| CoreError::InvalidMemoId(s.into()))?;
27        Ok(Self(u))
28    }
29
30    pub fn as_uuid(&self) -> Uuid {
31        self.0
32    }
33}
34
35impl std::fmt::Display for MemoId {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        write!(f, "{}", self.0.hyphenated())
38    }
39}
40
41impl std::str::FromStr for MemoId {
42    type Err = CoreError;
43    fn from_str(s: &str) -> Result<Self> {
44        Self::parse(s)
45    }
46}
47
48/// Content hash. Stored as `<algo>:<hex>`, currently `b3:` for BLAKE3. The
49/// algorithm prefix lets us migrate later without rehashing history.
50#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
51#[serde(transparent)]
52pub struct MemoHash(pub String);
53
54impl MemoHash {
55    pub const ALGO: &'static str = "b3";
56
57    pub fn new(hex: impl Into<String>) -> Self {
58        Self(format!("{}:{}", Self::ALGO, hex.into()))
59    }
60
61    /// Wrap an already-formatted `b3:...` string without re-prefixing.
62    pub fn from_stored(s: impl Into<String>) -> Self {
63        Self(s.into())
64    }
65
66    pub fn as_str(&self) -> &str {
67        &self.0
68    }
69}
70
71impl std::fmt::Display for MemoHash {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.write_str(&self.0)
74    }
75}
76
77/// Default category id for new notes. Used when no category is supplied.
78pub const DEFAULT_CATEGORY: &str = "inbox";
79
80/// Synchronization cursor: `(updated_at, id)`. Stable across reindex because
81/// both components live in the memo file's frontmatter (§5.3).
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83pub struct Cursor {
84    #[serde(with = "time::serde::rfc3339")]
85    pub updated_at: OffsetDateTime,
86    pub id: MemoId,
87}
88
89impl Cursor {
90    /// Parse a cursor from its JSON serialization. The cursor is serialized
91    /// as `{updated_at, id}`; this parses that shape from a string.
92    pub fn parse(s: &str) -> Result<Self> {
93        serde_json::from_str(s).map_err(|e| CoreError::Other(format!("invalid cursor: {e}")))
94    }
95
96    /// Ordering used for pagination and export: newer (larger updated_at, then
97    /// larger id) sorts first.
98    pub fn sort_key(&self) -> (OffsetDateTime, MemoId) {
99        (self.updated_at, self.id)
100    }
101}
102
103impl PartialOrd for Cursor {
104    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
105        Some(self.cmp(other))
106    }
107}
108
109impl Ord for Cursor {
110    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
111        self.sort_key().cmp(&other.sort_key())
112    }
113}
114
115/// A complete note: its body plus all metadata.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct Memo {
118    pub id: MemoId,
119    #[serde(with = "time::serde::rfc3339")]
120    pub created_at: OffsetDateTime,
121    #[serde(with = "time::serde::rfc3339")]
122    pub updated_at: OffsetDateTime,
123    pub hash: MemoHash,
124    pub favorite: bool,
125    #[serde(default = "default_category")]
126    pub category: String,
127    pub tags: Vec<String>,
128    pub body: String,
129    #[serde(
130        default,
131        skip_serializing_if = "Option::is_none",
132        with = "time::serde::rfc3339::option"
133    )]
134    pub deleted_at: Option<OffsetDateTime>,
135}
136
137pub fn default_category() -> String {
138    DEFAULT_CATEGORY.to_string()
139}
140
141/// Lightweight projection used by listings, search results and exports.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct MemoSummary {
144    pub id: MemoId,
145    #[serde(with = "time::serde::rfc3339")]
146    pub created_at: OffsetDateTime,
147    #[serde(with = "time::serde::rfc3339")]
148    pub updated_at: OffsetDateTime,
149    pub hash: MemoHash,
150    pub favorite: bool,
151    #[serde(default = "default_category")]
152    pub category: String,
153    pub tags: Vec<String>,
154    pub preview: String,
155    pub deleted: bool,
156}
157
158impl MemoSummary {
159    /// Max characters kept in a card preview.
160    pub const PREVIEW_MAX: usize = 280;
161}
162
163impl From<Memo> for MemoSummary {
164    fn from(n: Memo) -> Self {
165        let deleted = n.deleted_at.is_some();
166        Self {
167            id: n.id,
168            created_at: n.created_at,
169            updated_at: n.updated_at,
170            hash: n.hash,
171            favorite: n.favorite,
172            category: n.category,
173            tags: n.tags,
174            preview: make_preview(&n.body),
175            deleted,
176        }
177    }
178}
179
180/// Build a card preview of bounded length: non-empty trimmed lines joined by
181/// newlines so a multi-line note renders multi-line in the grid (line breaks
182/// the user typed are preserved), then truncated on a char boundary.
183pub fn make_preview(body: &str) -> String {
184    let joined: String = body
185        .lines()
186        .map(|l| l.trim())
187        .filter(|l| !l.is_empty())
188        .collect::<Vec<_>>()
189        .join("\n");
190    truncate_chars(&joined, MemoSummary::PREVIEW_MAX)
191}
192
193/// Truncate on a char boundary, appending an ellipsis when truncated.
194fn truncate_chars(s: &str, max: usize) -> String {
195    if s.chars().count() <= max {
196        return s.to_string();
197    }
198    let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
199    out.push('\u{2026}');
200    out
201}
202
203/// A page of cursor-paginated results.
204#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
205pub struct Page<T> {
206    pub items: Vec<T>,
207    pub next_cursor: Option<String>,
208}
209
210/// Filter applied to listings (§4.3, §7.5). Composite: include-tag set
211/// (AND or OR), exclude-tag set, category set (OR membership), favorite, deleted.
212#[derive(Debug, Clone, Default, PartialEq, Eq)]
213pub struct MemoFilter {
214    /// Memo must contain these tags. Empty = no constraint.
215    pub include_tags: Vec<String>,
216    /// Memo must contain NONE of these tags.
217    pub exclude_tags: Vec<String>,
218    /// `true` = note must contain ALL `include_tags` (AND); `false` = ANY (OR).
219    pub match_all: bool,
220    /// Non-empty = note's category must equal one of these (OR membership).
221    pub categories: Vec<String>,
222    pub favorites_only: bool,
223    /// When false, soft-deleted notes are excluded.
224    pub include_deleted: bool,
225}
226
227impl MemoFilter {
228    pub fn matches(&self, s: &MemoSummary) -> bool {
229        if !self.include_deleted && s.deleted {
230            return false;
231        }
232        if self.favorites_only && !s.favorite {
233            return false;
234        }
235        if !self.categories.is_empty() && !self.categories.iter().any(|c| c == &s.category) {
236            return false;
237        }
238        if !self.exclude_tags.is_empty()
239            && self
240                .exclude_tags
241                .iter()
242                .any(|t| s.tags.iter().any(|x| x.eq_ignore_ascii_case(t)))
243        {
244            return false;
245        }
246
247        if !self.include_tags.is_empty() {
248            let hit = |t: &String| s.tags.iter().any(|x| x.eq_ignore_ascii_case(t));
249            let ok = if self.match_all {
250                self.include_tags.iter().all(hit)
251            } else {
252                self.include_tags.iter().any(hit)
253            };
254            if !ok {
255                return false;
256            }
257        }
258        true
259    }
260}
261
262/// Statistics produced by `reindex`.
263#[derive(Debug, Clone, Default, Serialize, Deserialize)]
264pub struct IndexStats {
265    pub memos: u64,
266    pub trashed_memos: u64,
267    pub added: u64,
268    pub updated: u64,
269    pub unchanged: u64,
270    pub failed: u64,
271}
272
273/// Live vault statistics (excludes soft-deleted tombstones).
274#[derive(Debug, Clone, Default, Serialize, Deserialize)]
275pub struct MemoStats {
276    pub memos: u64,
277    pub favorites: u64,
278}
279
280/// Tag + category counts across the (non-deleted) vault, for the sidebar (§4.2).
281#[derive(Debug, Clone, Default, Serialize, Deserialize)]
282pub struct Facets {
283    /// `(normalized_tag, count)` sorted by tag.
284    pub tags: Vec<(String, u32)>,
285    /// `(category_id, count)` sorted by category; default-category notes excluded.
286    pub categories: Vec<(String, u32)>,
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn preview_preserves_linebreaks_and_truncates() {
295        // Blank lines are dropped; surviving non-empty lines keep their breaks.
296        let body = "first line\n\nsecond line\n".to_string();
297        assert_eq!(make_preview(&body), "first line\nsecond line");
298        // A long body truncates on a char boundary with an ellipsis.
299        let big = "a\n".repeat(400);
300        let pv = make_preview(&big);
301        assert!(pv.chars().count() <= MemoSummary::PREVIEW_MAX);
302        assert!(pv.ends_with('\u{2026}'));
303    }
304}
305
306#[cfg(test)]
307mod filter_tests {
308    use super::*;
309    use time::OffsetDateTime;
310
311    fn sum(tags: &[&str], category: &str, favorite: bool) -> MemoSummary {
312        MemoSummary {
313            id: MemoId::now(),
314            created_at: OffsetDateTime::now_utc(),
315            updated_at: OffsetDateTime::now_utc(),
316            hash: MemoHash::new("h"),
317            favorite,
318            category: category.to_string(),
319            tags: tags.iter().map(|t| t.to_string()).collect(),
320            preview: String::new(),
321            deleted: false,
322        }
323    }
324
325    #[test]
326    fn include_or_and_exclude() {
327        let f = MemoFilter {
328            include_tags: vec!["a".into(), "b".into()],
329            exclude_tags: vec!["x".into()],
330            match_all: false,
331            ..Default::default()
332        };
333        assert!(f.matches(&sum(&["a"], "inbox", false)));
334        assert!(f.matches(&sum(&["b"], "inbox", false)));
335        assert!(!f.matches(&sum(&["c"], "inbox", false)));
336        assert!(!f.matches(&sum(&["a", "x"], "inbox", false)));
337    }
338
339    #[test]
340    fn include_and_requires_all() {
341        let f = MemoFilter {
342            include_tags: vec!["a".into(), "b".into()],
343            match_all: true,
344            ..Default::default()
345        };
346        assert!(f.matches(&sum(&["a", "b"], "inbox", false)));
347        assert!(!f.matches(&sum(&["a"], "inbox", false)));
348    }
349
350    #[test]
351    fn category_membership() {
352        let f = MemoFilter {
353            categories: vec!["todo".into()],
354            ..Default::default()
355        };
356        assert!(f.matches(&sum(&[], "todo", false)));
357        assert!(!f.matches(&sum(&[], "idea", false)));
358        assert!(MemoFilter::default().matches(&sum(&[], "inbox", false)));
359    }
360}