⎇ 6f4d193835a2ce5005c65fcfa6dbff51f2940e64
00_unanchay/khipu/khipu-core/src/store.rs
History
Who touched what
raw
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}