gioser

tawasuyu/khipu

Sovereign local-first P2P notes, content-addressed, in Rust.

⎇ bf782ebef1b560208994c5868a1f222678cd82f1 00_unanchay/khipu/khipu-core/src/note.rs Rust · 108 siq'ikuna · 3.5 KB Ñawpa willay Pi imata llamkarqan ch'uya

feat: khipu standalone — notas P2P soberanas content-addressed, local-first (front-door, git-dep al monorepo) bf782ebef · Sergio ·

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}