tawasuyu

tawasuyu/khipu

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

Rust 23Markdown 14TOML 6409 Definitions

⎇ 6f4d193835a2ce5005c65fcfa6dbff51f2940e64 00_unanchay/khipu/khipu-core/src/store.rs Rust · 348 lines · 11.0 KB History Who touched what raw

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

1//! `NoteStore` — el almacén de notas y su grafo de enlaces.
2//!
3//! Guarda las notas en un `BTreeMap` para que toda iteración sea
4//! determinista (ordenada por id). Los enlaces se resuelven por título
5//! sin distinguir mayúsculas: `[[cocina]]` y `[[Cocina]]` apuntan a la
6//! misma nota.
7
8use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12use crate::note::{Note, NoteId};
13
14/// El almacén de notas de khipu_app.
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct NoteStore {
17    notes: BTreeMap<NoteId, Note>,
18    /// Siguiente id a asignar — monótono, nunca reutiliza huecos.
19    next_id: NoteId,
20}
21
22impl NoteStore {
23    pub fn new() -> Self {
24        Self { notes: BTreeMap::new(), next_id: 1 }
25    }
26
27    /// Crea una nota y devuelve su id. Empieza con `mass = 1.0` y
28    /// `last_access = now` — recién nacida, plenamente visible.
29    pub fn create(
30        &mut self,
31        title: impl Into<String>,
32        body: impl Into<String>,
33        tags: Vec<String>,
34        now: u64,
35    ) -> NoteId {
36        let id = self.next_id;
37        self.next_id += 1;
38        self.notes.insert(
39            id,
40            Note {
41                id,
42                title: title.into(),
43                body: body.into(),
44                tags,
45                created_at: now,
46                updated_at: now,
47                last_access: now,
48                mass: 1.0,
49                pos: None,
50            },
51        );
52        id
53    }
54
55    pub fn len(&self) -> usize {
56        self.notes.len()
57    }
58
59    pub fn is_empty(&self) -> bool {
60        self.notes.is_empty()
61    }
62
63    pub fn get(&self, id: NoteId) -> Option<&Note> {
64        self.notes.get(&id)
65    }
66
67    pub fn get_mut(&mut self, id: NoteId) -> Option<&mut Note> {
68        self.notes.get_mut(&id)
69    }
70
71    /// Itera las notas en orden de id (determinista).
72    pub fn iter(&self) -> impl Iterator<Item = &Note> {
73        self.notes.values()
74    }
75
76    /// Reemplaza el cuerpo de una nota y actualiza su marca de tiempo.
77    /// También marca `last_access` — editar cuenta como acceso.
78    /// `false` si la nota no existe.
79    pub fn update_body(&mut self, id: NoteId, body: impl Into<String>, now: u64) -> bool {
80        match self.notes.get_mut(&id) {
81            Some(n) => {
82                n.body = body.into();
83                n.updated_at = now;
84                n.last_access = now;
85                true
86            }
87            None => false,
88        }
89    }
90
91    /// Marca `last_access = now`. La señal que `khipu-gravity` usa para
92    /// reforzar la masa. No-op si la nota no existe; `true` si tocó.
93    pub fn touch(&mut self, id: NoteId, now: u64) -> bool {
94        match self.notes.get_mut(&id) {
95            Some(n) => {
96                n.last_access = now;
97                true
98            }
99            None => false,
100        }
101    }
102
103    /// Asigna directamente la masa de una nota. La física vive en
104    /// `khipu-gravity`; el store sólo persiste el resultado.
105    pub fn set_mass(&mut self, id: NoteId, mass: f32) -> bool {
106        match self.notes.get_mut(&id) {
107            Some(n) => {
108                n.mass = mass;
109                true
110            }
111            None => false,
112        }
113    }
114
115    /// Fija la posición ancla de una nota en el lienzo. Se llama una sola
116    /// vez al colocarla; de ahí en más el domicilio del pensamiento no se
117    /// mueve salvo que el usuario lo arrastre. `false` si la nota no existe.
118    pub fn set_pos(&mut self, id: NoteId, x: f32, y: f32) -> bool {
119        match self.notes.get_mut(&id) {
120            Some(n) => {
121                n.pos = Some((x, y));
122                true
123            }
124            None => false,
125        }
126    }
127
128    /// Elimina una nota y la devuelve.
129    pub fn remove(&mut self, id: NoteId) -> Option<Note> {
130        self.notes.remove(&id)
131    }
132
133    /// Notas que llevan la etiqueta `tag`, en orden de id.
134    pub fn by_tag(&self, tag: &str) -> Vec<&Note> {
135        self.notes.values().filter(|n| n.has_tag(tag)).collect()
136    }
137
138    /// Notas cuyo título o cuerpo contienen `query`, en orden de id.
139    pub fn search(&self, query: &str) -> Vec<&Note> {
140        self.notes.values().filter(|n| n.matches(query)).collect()
141    }
142
143    /// Ids de las notas cuyo título es `title` (sin distinguir
144    /// mayúsculas). Pueden ser varias: los títulos no son únicos.
145    pub fn resolve_title(&self, title: &str) -> Vec<NoteId> {
146        self.notes
147            .values()
148            .filter(|n| n.title.eq_ignore_ascii_case(title))
149            .map(|n| n.id)
150            .collect()
151    }
152
153    /// Ids de las notas a las que `id` enlaza por `[[...]]`, resueltas y
154    /// deduplicadas. Un enlace a un título inexistente simplemente no
155    /// aporta ningún id (enlace colgante).
156    pub fn forward_links(&self, id: NoteId) -> Vec<NoteId> {
157        let Some(note) = self.notes.get(&id) else {
158            return Vec::new();
159        };
160        let mut out: Vec<NoteId> = Vec::new();
161        for title in note.outgoing_links() {
162            for target in self.resolve_title(&title) {
163                if !out.contains(&target) {
164                    out.push(target);
165                }
166            }
167        }
168        out.sort_unstable();
169        out
170    }
171
172    /// Ids de las notas que enlazan hacia `id` (backlinks), en orden de id.
173    pub fn backlinks(&self, id: NoteId) -> Vec<NoteId> {
174        let Some(target) = self.notes.get(&id) else {
175            return Vec::new();
176        };
177        let title = &target.title;
178        self.notes
179            .values()
180            .filter(|n| {
181                n.id != id
182                    && n.outgoing_links()
183                        .iter()
184                        .any(|l| l.eq_ignore_ascii_case(title))
185            })
186            .map(|n| n.id)
187            .collect()
188    }
189
190    /// Notas sin ningún backlink — las islas del grafo.
191    pub fn orphans(&self) -> Vec<&Note> {
192        self.notes
193            .values()
194            .filter(|n| self.backlinks(n.id).is_empty())
195            .collect()
196    }
197
198    /// Destinos `[[...]]` que ninguna nota satisface — enlaces colgantes.
199    pub fn dangling_links(&self) -> Vec<String> {
200        let mut out: Vec<String> = Vec::new();
201        for note in self.notes.values() {
202            for title in note.outgoing_links() {
203                if self.resolve_title(&title).is_empty()
204                    && !out.iter().any(|t| t.eq_ignore_ascii_case(&title))
205                {
206                    out.push(title);
207                }
208            }
209        }
210        out
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    /// Almacén con tres notas enlazadas: Índice → Cocina, Índice → Jardín.
219    fn seeded() -> (NoteStore, NoteId, NoteId, NoteId) {
220        let mut s = NoteStore::new();
221        let indice = s.create("Índice", "ver [[Cocina]] y [[Jardín]]", vec!["meta".into()], 100);
222        let cocina = s.create("Cocina", "recetas; vuelve al [[Índice]]", vec!["casa".into()], 100);
223        let jardin = s.create("Jardín", "plantas y riego", vec!["casa".into()], 100);
224        (s, indice, cocina, jardin)
225    }
226
227    #[test]
228    fn create_assigns_monotonic_ids() {
229        let (s, indice, cocina, jardin) = seeded();
230        assert_eq!((indice, cocina, jardin), (1, 2, 3));
231        assert_eq!(s.len(), 3);
232    }
233
234    #[test]
235    fn forward_links_resolve_by_title() {
236        let (s, indice, cocina, jardin) = seeded();
237        assert_eq!(s.forward_links(indice), vec![cocina, jardin]);
238    }
239
240    #[test]
241    fn backlinks_find_incoming_references() {
242        let (s, indice, cocina, _) = seeded();
243        // Cocina enlaza al Índice → el Índice tiene a Cocina como backlink.
244        assert_eq!(s.backlinks(indice), vec![cocina]);
245    }
246
247    #[test]
248    fn link_resolution_is_case_insensitive() {
249        let mut s = NoteStore::new();
250        let a = s.create("Taller", "trabajo", vec![], 0);
251        let b = s.create("Notas", "voy al [[taller]]", vec![], 0);
252        assert_eq!(s.forward_links(b), vec![a]);
253        assert_eq!(s.backlinks(a), vec![b]);
254    }
255
256    #[test]
257    fn by_tag_filters() {
258        let (s, _, cocina, jardin) = seeded();
259        let casa: Vec<_> = s.by_tag("casa").iter().map(|n| n.id).collect();
260        assert_eq!(casa, vec![cocina, jardin]);
261    }
262
263    #[test]
264    fn search_scans_title_and_body() {
265        let (s, _, cocina, _) = seeded();
266        let hits: Vec<_> = s.search("recetas").iter().map(|n| n.id).collect();
267        assert_eq!(hits, vec![cocina]);
268    }
269
270    #[test]
271    fn update_body_changes_links_and_timestamp() {
272        let (mut s, indice, _, jardin) = seeded();
273        assert!(s.update_body(indice, "ahora sólo [[Jardín]]", 200));
274        assert_eq!(s.forward_links(indice), vec![jardin]);
275        assert_eq!(s.get(indice).unwrap().updated_at, 200);
276    }
277
278    #[test]
279    fn orphans_have_no_backlinks() {
280        let (s, _, _, jardin) = seeded();
281        // Jardín no recibe enlaces de vuelta... pero el Índice sí lo enlaza.
282        // El único huérfano real sería una nota aislada.
283        let mut s2 = s;
284        let aislada = s2.create("Aislada", "sin conexiones", vec![], 0);
285        let orphan_ids: Vec<_> = s2.orphans().iter().map(|n| n.id).collect();
286        assert!(orphan_ids.contains(&aislada));
287        assert!(!orphan_ids.contains(&jardin));
288    }
289
290    #[test]
291    fn dangling_links_report_missing_targets() {
292        let mut s = NoteStore::new();
293        s.create("Nota", "apunta a [[Inexistente]]", vec![], 0);
294        assert_eq!(s.dangling_links(), vec!["Inexistente"]);
295    }
296
297    #[test]
298    fn create_initializes_mass_and_last_access() {
299        let mut s = NoteStore::new();
300        let id = s.create("x", "y", vec![], 1_700_000_000);
301        let n = s.get(id).unwrap();
302        assert_eq!(n.mass, 1.0);
303        assert_eq!(n.last_access, 1_700_000_000);
304    }
305
306    #[test]
307    fn touch_refreshes_last_access() {
308        let mut s = NoteStore::new();
309        let id = s.create("x", "y", vec![], 100);
310        assert!(s.touch(id, 500));
311        assert_eq!(s.get(id).unwrap().last_access, 500);
312        assert!(!s.touch(9_999, 500));
313    }
314
315    #[test]
316    fn update_body_also_marks_last_access() {
317        let mut s = NoteStore::new();
318        let id = s.create("x", "y", vec![], 100);
319        assert!(s.update_body(id, "z", 700));
320        assert_eq!(s.get(id).unwrap().last_access, 700);
321    }
322
323    #[test]
324    fn set_mass_persists_the_value() {
325        let mut s = NoteStore::new();
326        let id = s.create("x", "y", vec![], 100);
327        assert!(s.set_mass(id, 0.42));
328        assert!((s.get(id).unwrap().mass - 0.42).abs() < 1e-6);
329    }
330
331    #[test]
332    fn set_pos_anchors_the_note_once() {
333        let mut s = NoteStore::new();
334        let id = s.create("x", "y", vec![], 100);
335        assert_eq!(s.get(id).unwrap().pos, None);
336        assert!(s.set_pos(id, 12.0, -8.0));
337        assert_eq!(s.get(id).unwrap().pos, Some((12.0, -8.0)));
338        assert!(!s.set_pos(9_999, 0.0, 0.0));
339    }
340
341    #[test]
342    fn remove_drops_the_note() {
343        let (mut s, indice, ..) = seeded();
344        assert!(s.remove(indice).is_some());
345        assert_eq!(s.len(), 2);
346        assert!(s.get(indice).is_none());
347    }
348}