⎇ bf782ebef1b560208994c5868a1f222678cd82f1
00_unanchay/khipu/khipu-core/src/note.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
1//! El modelo `Note` — la unidad de khipu_app.
2
3use serde::{Deserialize, Serialize};
4
5use crate::links::parse_links;
6
7/// Identificador de una nota. Lo asigna el almacén, monótono y estable.
8pub type NoteId = u64;
9
10/// Una nota: título, cuerpo, etiquetas y marcas de tiempo. Los enlaces
11/// no se guardan aparte — se derivan del cuerpo bajo demanda.
12///
13/// `mass` y `last_access` son la señal temporal: la masa decae con el
14/// tiempo y `last_access` registra cuándo fue la última lectura. La
15/// física la aplica el caller (típicamente con `khipu-gravity`).
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct Note {
18 pub id: NoteId,
19 pub title: String,
20 pub body: String,
21 pub tags: Vec<String>,
22 /// Segundo Unix de creación.
23 pub created_at: u64,
24 /// Segundo Unix de la última edición.
25 pub updated_at: u64,
26 /// Segundo Unix del último acceso (lectura). Defecto: `created_at`
27 /// en notas nuevas; en payloads viejos sin el campo, `0` y el
28 /// caller decide cómo arrancar la masa.
29 #[serde(default)]
30 pub last_access: u64,
31 /// Masa de la nota. 1.0 al crearse; decae con el tiempo, sube con
32 /// cada acceso. Cuando cae bajo el umbral del horizonte la nota
33 /// pasa al archivo (no se borra). En payloads viejos arranca en 1.0.
34 #[serde(default = "default_mass")]
35 pub mass: f32,
36 /// Posición ancla en el lienzo del mapa, en coordenadas de mundo.
37 /// Se fija **una sola vez** cuando la nota se coloca (por gravedad
38 /// semántica, contra las notas ya asentadas) y se persiste: el mapa
39 /// es estable — un pensamiento conserva su domicilio para que la
40 /// memoria espacial pueda recordarlo. `None` = todavía sin colocar
41 /// (el caller le asigna lugar la primera vez que la pinta). En
42 /// payloads viejos arranca en `None` y se coloca al cargar.
43 #[serde(default)]
44 pub pos: Option<(f32, f32)>,
45}
46
47fn default_mass() -> f32 {
48 1.0
49}
50
51impl Note {
52 /// Destinos `[[...]]` que el cuerpo de la nota referencia.
53 pub fn outgoing_links(&self) -> Vec<String> {
54 parse_links(&self.body)
55 }
56
57 /// `true` si la nota lleva la etiqueta `tag` (sin distinguir mayúsculas).
58 pub fn has_tag(&self, tag: &str) -> bool {
59 self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))
60 }
61
62 /// `true` si `query` aparece en el título o el cuerpo (sin distinguir
63 /// mayúsculas).
64 pub fn matches(&self, query: &str) -> bool {
65 let q = query.to_lowercase();
66 self.title.to_lowercase().contains(&q) || self.body.to_lowercase().contains(&q)
67 }
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73
74 fn note(title: &str, body: &str) -> Note {
75 Note {
76 id: 1,
77 title: title.into(),
78 body: body.into(),
79 tags: vec!["casa".into()],
80 created_at: 0,
81 updated_at: 0,
82 last_access: 0,
83 mass: 1.0,
84 pos: None,
85 }
86 }
87
88 #[test]
89 fn outgoing_links_reads_the_body() {
90 let n = note("Cocina", "preparar con [[Horno]] y [[Cuchillos]]");
91 assert_eq!(n.outgoing_links(), vec!["Horno", "Cuchillos"]);
92 }
93
94 #[test]
95 fn has_tag_is_case_insensitive() {
96 let n = note("x", "y");
97 assert!(n.has_tag("CASA"));
98 assert!(!n.has_tag("trabajo"));
99 }
100
101 #[test]
102 fn matches_searches_title_and_body() {
103 let n = note("Lista de mercado", "comprar pan");
104 assert!(n.matches("MERCADO"));
105 assert!(n.matches("pan"));
106 assert!(!n.matches("ausente"));
107 }
108}