1use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use uuid::Uuid;
10
11use crate::error::{CoreError, Result};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
16#[serde(transparent)]
17pub struct MemoId(pub Uuid);
18
19impl MemoId {
20 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#[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 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
77pub const DEFAULT_CATEGORY: &str = "inbox";
79
80#[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 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 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#[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#[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 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
180pub 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
193fn 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#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
213pub struct MemoFilter {
214 pub include_tags: Vec<String>,
216 pub exclude_tags: Vec<String>,
218 pub match_all: bool,
220 pub categories: Vec<String>,
222 pub favorites_only: bool,
223 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#[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
275pub struct MemoStats {
276 pub memos: u64,
277 pub favorites: u64,
278}
279
280#[derive(Debug, Clone, Default, Serialize, Deserialize)]
282pub struct Facets {
283 pub tags: Vec<(String, u32)>,
285 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 let body = "first line\n\nsecond line\n".to_string();
297 assert_eq!(make_preview(&body), "first line\nsecond line");
298 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}