feat: khipu standalone — notas P2P soberanas content-addressed, local-first (front-door, git-dep al monorepo)
Front-door limpio: solo crates del dominio; Llimphi y lo fundacional por git-dep del monorepo gioser.git. cargo check pasa (5 crates, 0 errores). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
**/*.rs.bk
|
||||||
|
*.pdb
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# khipu
|
||||||
|
|
||||||
|
> `khipu` (quechua: nudos de cuerdas para registrar memoria). Notas con gravedad temporal.
|
||||||
|
|
||||||
|
Captura de notas rápidas donde el olvido es parte del modelo: cada nota tiene una masa que decae con el tiempo y se refuerza con cada acceso. Lo recurrente queda visible; lo que no se vuelve a tocar se va difuminando hasta caer del horizonte.
|
||||||
|
|
||||||
|
## Instalación
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --release -p khipu-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compatibilidad
|
||||||
|
|
||||||
|
- **Linux / macOS / Windows** — UI Llimphi (Wayland/X11/Win32 vía `winit`).
|
||||||
|
- Persistencia local en `$XDG_DATA_HOME/khipu/`.
|
||||||
|
|
||||||
|
## Crates
|
||||||
|
|
||||||
|
| Crate | Rol |
|
||||||
|
|---|---|
|
||||||
|
| [`khipu-core`](khipu-core/README.md) | Modelo de nota + store; sin UI. |
|
||||||
|
| [`khipu-gravity`](khipu-gravity/README.md) | Algoritmo de masa/decay; refuerzo por acceso. |
|
||||||
|
| `khipu-share` | Sobres de notas firmados (Ed25519) y direccionados por contenido (BLAKE3) sobre agora; transporte TCP/LAN + descubrimiento UDP + identidad cifrada. |
|
||||||
|
| `khipu-brahman` | Transporte de sobres sobre libp2p (BrahmanNet): stream cifrado + descubrimiento por DHT. |
|
||||||
|
| [`khipu-app`](khipu-app/README.md) | UI Llimphi sobre el core. |
|
||||||
|
|
||||||
|
## Gravedad semántica (embeddings)
|
||||||
|
|
||||||
|
El canvas de la derecha agrupa las notas por afinidad. Los vectores salen del `verbo-daemon` si está corriendo; si no, de un hash-trigram local.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Embeddings reales (clústeres y vecinos semánticos de verdad):
|
||||||
|
cargo run -p rimay-verbo-daemon-bin -- --provider fastembed # escucha en $XDG_RUNTIME_DIR/verbo.sock
|
||||||
|
cargo run --release -p khipu-app # lo detecta solo al arrancar
|
||||||
|
```
|
||||||
|
|
||||||
|
Sin daemon, khipu cae al embebedor trigram de 16d — determinista y offline, idéntico al comportamiento histórico. El cálculo nunca bloquea la UI: viaja a un worker y reentra al bucle cuando termina. Si el espacio vectorial cambia entre dos arranques (arrancó/cayó el daemon, otro modelo), los vectores se recalculan automáticamente.
|
||||||
|
|
||||||
|
## Compartir (agora)
|
||||||
|
|
||||||
|
`exportar` sella en `compartido.khipu` un sobre firmado Ed25519 con la identidad del cuaderno y direccionado por su hash BLAKE3 de contenido.
|
||||||
|
|
||||||
|
La identidad vive **cifrada** (Argon2id + ChaCha20-Poly1305, vía `agora-keystore`) en `<datos>/keys/` — la semilla privada nunca queda en claro en disco. Al primer intento de compartir, khipu pide una passphrase: la primera vez la crea, después la usa para descifrar. `KHIPU_PASSPHRASE` en el entorno desbloquea sin prompt (headless). Una `identidad.seed` en claro de versiones viejas se migra al keystore (y se borra el claro) automáticamente. Comparte **lo que el buscador esté filtrando** (vacío = todo el cuaderno), así que escribir en la búsqueda y exportar manda sólo ese subconjunto. `importar` verifica firma + hash y, si cuadra, ingiere las notas, marcándolas con una etiqueta de procedencia `de:<autor>` (visible en el editor como «✎ de: …»).
|
||||||
|
|
||||||
|
Lo que viaja es el **contenido** (título, cuerpo, etiquetas), nunca la física temporal: al importar, cada nota nace fresca (masa plena, acceso = ahora) — su gravedad arranca en el cuaderno que la recibe. Los wiki-links `[[Título]]` se rearman solos porque khipu resuelve enlaces por título. Reimportar el mismo sobre no duplica (se omiten títulos ya presentes). Un sobre alterado o con firma ajena se rechaza entero, sin autoridad central.
|
||||||
|
|
||||||
|
Para compartir en vivo sin copiar archivos: `publicar` levanta un servidor TCP que sirve el cuaderno (puerto `KHIPU_BIND`, default `127.0.0.1:7700`) **y anuncia una baliza UDP** para que lo descubran en la LAN. `recibir` abre un panel con un **campo de dirección** (`host:puerto`, prellenado y editable) y, debajo, los **pares descubiertos en la LAN** (nombre · autor · dirección): click en uno para jalarle el cuaderno, o escribí una dirección y «jalar». El transporte es `std::net` puro y **no necesita ser confiable** — el receptor verifica firma + hash antes de ingerir; la baliza sólo dice *dónde*, no *qué*.
|
||||||
|
|
||||||
|
**WAN / libp2p**: el campo de dirección de `recibir` acepta dos formas, autodetectadas: `host:puerto` (TCP directo) o una **multiaddr libp2p** —directa `/ip4/…/p2p/<id>` o de circuito `/ip4/…/p2p/<relay>/p2p-circuit/p2p/<id>`. Al `publicar`, khipu sirve por libp2p (stream cifrado Noise sobre `BrahmanNet`, protocolo `/khipu/sobre/1.0.0`, vía `khipu-brahman`) y muestra tu dirección de marcado.
|
||||||
|
|
||||||
|
**NAT traversal**: `BrahmanNet` ahora trae **Circuit Relay v2 + DCUtR** (`card-net`). Un nodo alcanzable hace de relay; uno detrás de NAT reserva un circuito ahí y queda accesible vía la dirección de circuito. Configurá `KHIPU_RELAY=/ip4/…/tcp/…/p2p/<relay-id>` antes de `publicar` y khipu reserva el circuito y muestra la dirección para compartir. Las direcciones externas no se confían a ciegas: **AutoNAT** las confirma pidiendo dial-backs a otros peers, y sólo las confirmadas se anuncian (y entran en las reservas de relay). En la malla Brahman AutoNAT corre con `only_global_ips=false` para confirmar también en LAN/loopback.
|
||||||
|
|
||||||
|
**Descubrimiento por DHT**: con `KHIPU_BOOTSTRAP=/ip4/…/p2p/<id>` (un nodo de la malla), khipu se une a la DHT Kademlia al arrancar; `publicar` se anuncia bajo la clave khipu y `recibir` lista —además de los pares LAN— los pares hallados por DHT (filas `DHT · …<id>`), que se jalan por peer-id. Así dos khipu se encuentran sin compartir IP ni multiaddr a mano, sólo conociendo un bootstrap común. Verificado end-to-end en localhost (rendezvous + publicador + receptor; 4 tests en `khipu-brahman`).
|
||||||
|
|
||||||
|
La lógica vive en `khipu-share`: `net` (transporte TCP) y `discovery` (baliza UDP). 15 tests + un test de integración que recorre la cadena completa descubrir→jalar→verificar en loopback.
|
||||||
|
|
||||||
|
## Estado (2026-05-31)
|
||||||
|
|
||||||
|
### Hecho
|
||||||
|
|
||||||
|
- `khipu-core` (modelo de nota + store) + `khipu-gravity` (masa/decay con refuerzo por acceso).
|
||||||
|
- `khipu-app`: UI Llimphi sobre el core, con menú principal y contextual.
|
||||||
|
- Gravedad semántica: clustering por embeddings del `verbo-daemon` (rimay), con fallback trigram 16d offline; cálculo en worker que no bloquea la UI.
|
||||||
|
- Compartir vía agora (`khipu-share`): sobres firmados Ed25519 + direccionados BLAKE3, identidad cifrada (Argon2id + ChaCha20-Poly1305 en keystore), compartir selectivo + procedencia del autor; transporte TCP/LAN + descubrimiento por baliza UDP (15 tests + integración loopback).
|
||||||
|
- WAN/P2P (`khipu-brahman` sobre libp2p/BrahmanNet): stream cifrado Noise, NAT traversal (Circuit Relay v2 + DCUtR), AutoNAT, descubrimiento por DHT Kademlia (4 tests e2e localhost).
|
||||||
|
|
||||||
|
### Pendiente
|
||||||
|
|
||||||
|
- Sincronización bidireccional / resolución de conflictos entre cuadernos (hoy es import unidireccional de sobres).
|
||||||
|
- Transferir física temporal opcional al compartir (hoy el contenido nace fresco en el receptor — decisión de diseño, no bug).
|
||||||
|
- Endurecimiento de la malla DHT en WAN real (probado en localhost/LAN).
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
|
||||||
|
- **No es un sistema de "todo"** — no hay due-dates ni recordatorios; es un cuaderno con física propia.
|
||||||
|
- El decay es transparente: cada nota expone su masa actual; el usuario decide si la salva.
|
||||||
|
- La gravedad es local y no transferible: compartir mueve el contenido, no la atención.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# khipu
|
||||||
|
|
||||||
|
> `khipu` (Quechua: knotted cord recordkeeping). Notes with temporal gravity.
|
||||||
|
|
||||||
|
Quick-note capture where forgetting is part of the model: each note has a mass that decays with time and reinforces with each access. What's recurrent stays visible; what isn't touched fades until it falls off the horizon.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --release -p khipu-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- **Linux / macOS / Windows** — Llimphi UI (Wayland/X11/Win32 via `winit`).
|
||||||
|
- Local persistence in `$XDG_DATA_HOME/khipu/`.
|
||||||
|
|
||||||
|
## Crates
|
||||||
|
|
||||||
|
| Crate | Role |
|
||||||
|
|---|---|
|
||||||
|
| [`khipu-core`](khipu-core/README.md) | Note model + store; no UI. |
|
||||||
|
| [`khipu-gravity`](khipu-gravity/README.md) | Mass/decay algorithm; reinforcement on access. |
|
||||||
|
| [`khipu-app`](khipu-app/README.md) | Llimphi UI over the core. |
|
||||||
|
|
||||||
|
## Considerations
|
||||||
|
|
||||||
|
- **It's not a "todo" system** — no due dates, no reminders; it's a notebook with its own physics.
|
||||||
|
- Decay is transparent: each note shows its current mass; the user decides whether to save it.
|
||||||
|
- Plays well with the [agora](../../03_ukupacha/agora/README.md) network: notes can be shared without losing their local gravity.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
|
||||||
|
|
||||||
|
# khipu
|
||||||
|
|
||||||
|
> `khipu` (runa-simi: watasqa wask'akuna yuyanapaq). Qillqakuna pacha tinkuywan.
|
||||||
|
|
||||||
|
Usqhay nota hap'iy. Qunqaytaqa modelopa ukhunpi: sapanka nota llasayuq, pachawan miran, sapa kuti rikuptin kallpachayuq. Sapa kuti rikuq nota qhipanqa; mana takyaqkuna chinkanqaku, ñawi hawamanta wikch'urunqakama.
|
||||||
|
|
||||||
|
## Churay
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --release -p khipu-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tinkuy
|
||||||
|
|
||||||
|
- **Linux / macOS / Windows** — Llimphi UI (Wayland/X11/Win32 `winit` patapi).
|
||||||
|
- Allpa-yana waqaychay `$XDG_DATA_HOME/khipu/`-pi.
|
||||||
|
|
||||||
|
## Crateskuna
|
||||||
|
|
||||||
|
| Crate | Ima ruwan |
|
||||||
|
|---|---|
|
||||||
|
| [`khipu-core`](khipu-core/README.md) | Nota modelo + waqaychay; mana UI. |
|
||||||
|
| [`khipu-gravity`](khipu-gravity/README.md) | Llasay/chinkay algoritmo, rikuq kallpachay. |
|
||||||
|
| [`khipu-app`](khipu-app/README.md) | Llimphi UI core patapi. |
|
||||||
|
|
||||||
|
## Yuyaykunaq
|
||||||
|
|
||||||
|
- **Manan "todo" sistemachu** — mana fechakuna, mana willaqkuna; pi-físikayuq qillqana kanmi.
|
||||||
|
- Chinkayqa rikukullan: sapanka nota llasayninta rikuchin; runa salvanaqa rikun.
|
||||||
|
- [agora](../../03_ukupacha/agora/README.md) ayllu-redwan tinkuy atin: nota willanakuyqa mana lokal llasayninmanta hark'aqasqa.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
[package]
|
||||||
|
name = "khipu-app"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
description = "khipu-app — cuaderno de notas sobre Llimphi: lista editable in-situ + canvas de gravedad semántica con persistencia en $XDG_DATA_HOME/khipu/."
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "khipu-app"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "demo_cli"
|
||||||
|
path = "examples/demo_cli.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
khipu-core = { path = "../khipu-core" }
|
||||||
|
khipu-gravity = { path = "../khipu-gravity" }
|
||||||
|
khipu-share = { path = "../khipu-share" }
|
||||||
|
khipu-brahman = { path = "../khipu-brahman" }
|
||||||
|
agora-core = { workspace = true }
|
||||||
|
rimay-verbo = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
llimphi-ui = { workspace = true }
|
||||||
|
llimphi-theme = { workspace = true }
|
||||||
|
llimphi-widget-list = { workspace = true }
|
||||||
|
llimphi-widget-text-editor = { workspace = true }
|
||||||
|
llimphi-widget-text-input = { workspace = true }
|
||||||
|
llimphi-widget-tiled = { workspace = true }
|
||||||
|
llimphi-widget-menubar = { workspace = true }
|
||||||
|
llimphi-widget-edit-menu = { workspace = true }
|
||||||
|
llimphi-widget-context-menu = { workspace = true }
|
||||||
|
llimphi-motion = { workspace = true }
|
||||||
|
llimphi-clipboard = { workspace = true }
|
||||||
|
app-bus = { workspace = true }
|
||||||
|
directories = { workspace = true }
|
||||||
|
postcard = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# khipu-app
|
||||||
|
|
||||||
|
> UI Llimphi sobre el core de [khipu](../README.md). Binario del usuario.
|
||||||
|
|
||||||
|
App de escritorio: lista de notas ordenadas por masa actual, editor inline (Markdown ligero), captura rápida (`Ctrl+N`), búsqueda fuzzy. Cada redibujo recalcula masa con [`khipu-gravity`](../khipu-gravity/README.md) y muestra notas con `mass > umbral`; las que cayeron se acceden por menú "archivo".
|
||||||
|
|
||||||
|
## Uso
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --release -p khipu-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deps
|
||||||
|
|
||||||
|
- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md)
|
||||||
|
- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`
|
||||||
|
- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/) para preferencias compartidas
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# khipu-app
|
||||||
|
|
||||||
|
> Llimphi UI over the core of [khipu](../README.md). The user binary.
|
||||||
|
|
||||||
|
Desktop app: list of notes sorted by current mass, inline editor (lightweight Markdown), quick capture (`Ctrl+N`), fuzzy search. Each redraw recomputes mass via [`khipu-gravity`](../khipu-gravity/README.md) and shows notes with `mass > threshold`; fallen ones are accessed via the "archive" menu.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run --release -p khipu-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deps
|
||||||
|
|
||||||
|
- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md)
|
||||||
|
- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`
|
||||||
|
- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/) for shared prefs
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
//! `demo_cli` — demostración no gráfica del cuaderno.
|
||||||
|
//!
|
||||||
|
//! Siembra un cuaderno personal, imprime el grafo de wiki-links
|
||||||
|
//! (forward-links, backlinks, huérfanas, enlaces colgantes) y luego la
|
||||||
|
//! gravedad semántica: los clústeres por afinidad y los vecinos más
|
||||||
|
//! cercanos de una nota.
|
||||||
|
//!
|
||||||
|
//! Los vectores semánticos van a mano —tres tópicos: cocina, jardín,
|
||||||
|
//! oficina— para que el clustering se vea con claridad. En la app real
|
||||||
|
//! los produce `verbo`. Corre con
|
||||||
|
//! `cargo run -p khipu-app --example demo_cli --release`.
|
||||||
|
|
||||||
|
use khipu_core::{NoteId, NoteStore};
|
||||||
|
use khipu_gravity::{Gravity, GravityConfig, Params, SemanticField};
|
||||||
|
|
||||||
|
/// Vector de tópico con un leve sesgo — notas del mismo tema quedan
|
||||||
|
/// afines sin ser idénticas.
|
||||||
|
fn topic(base: [f32; 3], nudge: f32) -> Vec<f32> {
|
||||||
|
vec![base[0] + nudge, base[1] + nudge * 0.3, base[2] - nudge * 0.2]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let cocina = [1.0, 0.0, 0.0];
|
||||||
|
let jardin = [0.0, 1.0, 0.0];
|
||||||
|
let oficina = [0.0, 0.0, 1.0];
|
||||||
|
|
||||||
|
let mut store = NoteStore::new();
|
||||||
|
let mut field = SemanticField::new();
|
||||||
|
|
||||||
|
let seed: [(&str, &str, &[&str], Vec<f32>); 7] = [
|
||||||
|
(
|
||||||
|
"Índice",
|
||||||
|
"mi cuaderno: [[Recetas de la abuela]], [[Jardín]] y [[Oficina]]",
|
||||||
|
&["meta"],
|
||||||
|
topic(cocina, 0.0),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Recetas de la abuela",
|
||||||
|
"sopa de auyama; ver también [[Lista del mercado]]",
|
||||||
|
&["cocina"],
|
||||||
|
topic(cocina, 0.05),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Lista del mercado",
|
||||||
|
"auyama, cilantro, pan; vuelve al [[Índice]]",
|
||||||
|
&["cocina"],
|
||||||
|
topic(cocina, 0.10),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Jardín",
|
||||||
|
"riego semanal; las [[Semillas de cilantro]] van en marzo",
|
||||||
|
&["jardín"],
|
||||||
|
topic(jardin, 0.05),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Semillas de cilantro",
|
||||||
|
"germinan en diez días",
|
||||||
|
&["jardín"],
|
||||||
|
topic(jardin, 0.10),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Oficina",
|
||||||
|
"[[Reunión del lunes]] y pendientes varios",
|
||||||
|
&["trabajo"],
|
||||||
|
topic(oficina, 0.05),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Diario sin enlaces",
|
||||||
|
"una nota suelta, no la enlaza nadie y enlaza a [[Algo Perdido]]",
|
||||||
|
&["personal"],
|
||||||
|
topic(oficina, 0.50),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut ids: Vec<(NoteId, String)> = Vec::new();
|
||||||
|
for (title, body, tags, vector) in seed {
|
||||||
|
let tags = tags.iter().map(|t| t.to_string()).collect();
|
||||||
|
let id = store.create(title, body, tags, 1_700_000_000);
|
||||||
|
field.insert(id, vector);
|
||||||
|
ids.push((id, title.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = |id: NoteId| {
|
||||||
|
ids.iter()
|
||||||
|
.find(|(i, _)| *i == id)
|
||||||
|
.map(|(_, n)| n.as_str())
|
||||||
|
.unwrap_or("?")
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("\n khipu · cuaderno de notas — {} notas\n", store.len());
|
||||||
|
|
||||||
|
println!(" grafo de enlaces:");
|
||||||
|
for note in store.iter() {
|
||||||
|
let fwd: Vec<&str> = store.forward_links(note.id).into_iter().map(name).collect();
|
||||||
|
let back: Vec<&str> = store.backlinks(note.id).into_iter().map(name).collect();
|
||||||
|
println!(" «{}»", note.title);
|
||||||
|
println!(" enlaza a : {}", fmt_list(&fwd));
|
||||||
|
println!(" backlinks : {}", fmt_list(&back));
|
||||||
|
}
|
||||||
|
|
||||||
|
let orphans: Vec<&str> = store.orphans().iter().map(|n| n.title.as_str()).collect();
|
||||||
|
println!("\n notas huérfanas (sin backlinks): {}", fmt_list(&orphans));
|
||||||
|
let dangling_owned = store.dangling_links();
|
||||||
|
let dangling: Vec<&str> = dangling_owned.iter().map(|s| s.as_str()).collect();
|
||||||
|
println!(" enlaces colgantes (destino inexistente): {}", fmt_list(&dangling));
|
||||||
|
|
||||||
|
println!("\n gravedad semántica — clústeres (afinidad ≥ 0.85):");
|
||||||
|
for (n, cluster) in field.clusters(0.85).iter().enumerate() {
|
||||||
|
let titles: Vec<&str> = cluster.iter().map(|id| name(*id)).collect();
|
||||||
|
println!(" grupo {}: {}", n + 1, fmt_list(&titles));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pivot = ids[1].0; // "Recetas de la abuela"
|
||||||
|
println!("\n vecinos más afines a «{}»:", name(pivot));
|
||||||
|
for (id, score) in field.nearest(pivot, 3) {
|
||||||
|
println!(" {:.3} {}", score, name(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
let layout = field.gravity_layout(&GravityConfig::default());
|
||||||
|
println!("\n layout 2D por gravedad ({} posiciones):", layout.len());
|
||||||
|
for p in &layout {
|
||||||
|
println!(" ({:7.1}, {:7.1}) {}", p.x, p.y, name(p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n masa temporal — vida media 7 días, boost 0.4, horizonte 0.10:");
|
||||||
|
let g = Gravity::new(Params::default());
|
||||||
|
let pivot_b = ids[4].0; // "Semillas de cilantro"
|
||||||
|
println!(" «{}» — escenario:", name(pivot_b));
|
||||||
|
let mut mass = 1.0_f32;
|
||||||
|
println!(" inicial mass = {:.3}", mass);
|
||||||
|
mass = g.decay(mass, 3.0 * 24.0 * 3600.0);
|
||||||
|
println!(
|
||||||
|
" 3 días sin acceso → decay mass = {:.3} ({})",
|
||||||
|
mass,
|
||||||
|
if g.is_visible(mass) { "visible" } else { "archivo" }
|
||||||
|
);
|
||||||
|
mass = g.decay(mass, 14.0 * 24.0 * 3600.0);
|
||||||
|
println!(
|
||||||
|
" +14 días sin acceso → decay mass = {:.3} ({})",
|
||||||
|
mass,
|
||||||
|
if g.is_visible(mass) { "visible" } else { "archivo" }
|
||||||
|
);
|
||||||
|
mass = g.reinforce(mass);
|
||||||
|
println!(
|
||||||
|
" el usuario abre la nota → reinforce mass = {:.3} ({})",
|
||||||
|
mass,
|
||||||
|
if g.is_visible(mass) { "visible" } else { "archivo" }
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formatea una lista de nombres, o `—` si está vacía.
|
||||||
|
fn fmt_list(items: &[&str]) -> String {
|
||||||
|
if items.is_empty() {
|
||||||
|
"—".to_string()
|
||||||
|
} else {
|
||||||
|
items.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,655 @@
|
|||||||
|
//! `map` — el lienzo de pensamientos: geometría de cámara, colocación
|
||||||
|
//! determinista de notas (anclaje), detección de regiones emergentes y el
|
||||||
|
//! pintado del mapa (nodos que respiran por masa, filamentos, topónimos).
|
||||||
|
//!
|
||||||
|
//! El resto de la app le pide vistas y consultas; los helpers compartidos
|
||||||
|
//! (`current_mass`, `now_secs`, `button`, `CLUSTER_THRESHOLD`) viven en la
|
||||||
|
//! raíz y se alcanzan por `crate::`.
|
||||||
|
|
||||||
|
use llimphi_theme::Theme;
|
||||||
|
use llimphi_ui::llimphi_layout::taffy::{
|
||||||
|
prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
|
||||||
|
AlignItems, Dimension, JustifyContent,
|
||||||
|
};
|
||||||
|
use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle as KurboCircle, Stroke};
|
||||||
|
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
|
||||||
|
use llimphi_ui::llimphi_text::{draw_block, Alignment, TextBlock};
|
||||||
|
use llimphi_ui::{DragPhase, View};
|
||||||
|
use llimphi_widget_text_input::{text_input_view, TextInputPalette};
|
||||||
|
use khipu_core::NoteId;
|
||||||
|
|
||||||
|
use crate::panels::button;
|
||||||
|
use crate::{current_mass, now_secs, Focus, Model, Msg, CLUSTER_THRESHOLD};
|
||||||
|
|
||||||
|
/// Un nodo del mapa, ya resuelto a coordenadas de mundo + su masa viva.
|
||||||
|
/// Datos planos para viajar dentro de la closure de pintura.
|
||||||
|
pub(crate) struct MapNode {
|
||||||
|
id: NoteId,
|
||||||
|
/// Coordenadas de mundo (el domicilio fijo de la nota).
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
/// Masa "vivida" en el instante del render: enciende el brillo y el
|
||||||
|
/// tamaño. Decae con el tiempo → el mapa respira sin que toques nada.
|
||||||
|
mass: f32,
|
||||||
|
/// `false` si cayó bajo el horizonte (sólo se ve con archivo activo).
|
||||||
|
visible: bool,
|
||||||
|
color: Color,
|
||||||
|
label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mundo → pantalla local (relativa al rect del lienzo). El centro del
|
||||||
|
/// rect es el ancla del zoom; `pan` se suma en mundo, luego se escala.
|
||||||
|
pub(crate) fn world_to_local(wx: f32, wy: f32, w: f32, h: f32, pan: (f32, f32), zoom: f32) -> (f32, f32) {
|
||||||
|
(w * 0.5 + (wx + pan.0) * zoom, h * 0.5 + (wy + pan.1) * zoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inversa de [`world_to_local`]: pantalla local → mundo. Para resolver
|
||||||
|
/// qué nota cae bajo un click.
|
||||||
|
pub(crate) fn local_to_world(lx: f32, ly: f32, w: f32, h: f32, pan: (f32, f32), zoom: f32) -> (f32, f32) {
|
||||||
|
let z = zoom.max(1e-3);
|
||||||
|
((lx - w * 0.5) / z - pan.0, (ly - h * 0.5) / z - pan.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// La nota colocada más cercana a un click en coords locales, dentro de un
|
||||||
|
/// radio de tolerancia (~18 px de pantalla). `None` si el click cae en el
|
||||||
|
/// vacío — así arrastrar el fondo no cambia la selección.
|
||||||
|
pub(crate) fn pick_note(model: &Model, lx: f32, ly: f32, w: f32, h: f32) -> Option<NoteId> {
|
||||||
|
let (wx, wy) = local_to_world(lx, ly, w, h, model.cam_pan, model.cam_zoom);
|
||||||
|
let now = now_secs();
|
||||||
|
let mut best: Option<(NoteId, f32)> = None;
|
||||||
|
for id in &model.order {
|
||||||
|
let Some(n) = model.store.get(*id) else { continue };
|
||||||
|
let Some((px, py)) = n.pos else { continue };
|
||||||
|
if !model.show_archive {
|
||||||
|
let m = current_mass(&model.gravity, n, now);
|
||||||
|
if !model.gravity.is_visible(m) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let d2 = (px - wx).powi(2) + (py - wy).powi(2);
|
||||||
|
if best.map(|(_, bd)| d2 < bd).unwrap_or(true) {
|
||||||
|
best = Some((*id, d2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tol = (18.0 / model.cam_zoom.max(1e-3)).powi(2);
|
||||||
|
best.filter(|(_, d2)| *d2 <= tol).map(|(id, _)| id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separación mínima entre nodos al colocarlos (coordenadas de mundo).
|
||||||
|
pub(crate) const MAP_MIN_SEP: f32 = 30.0;
|
||||||
|
/// Ángulo áureo en radianes — reparte determinísticamente lo que no tiene
|
||||||
|
/// parentela semántica sin amontonarlo.
|
||||||
|
pub(crate) const GOLDEN_ANGLE: f32 = 2.399_963_2;
|
||||||
|
|
||||||
|
/// Le da a `id` un domicilio fijo en el mapa, **una sola vez**: cae en el
|
||||||
|
/// baricentro de sus parientes semánticos (ponderado por afinidad) y, si
|
||||||
|
/// quedó pegada a otra nota, se separa apenas. Determinista y dependiente
|
||||||
|
/// sólo de las notas ya asentadas, así el orden de inserción es estable y
|
||||||
|
/// el mapa nunca se reacomoda solo.
|
||||||
|
pub(crate) fn place_note(model: &mut Model, id: NoteId) {
|
||||||
|
if model.store.get(id).map(|n| n.pos.is_some()).unwrap_or(true) {
|
||||||
|
return; // ya tiene domicilio (o no existe): no se mueve.
|
||||||
|
}
|
||||||
|
// Vecinos ya colocados: su afinidad con la nota nueva y su posición.
|
||||||
|
let mut kin: Vec<(f32, (f32, f32))> = Vec::new();
|
||||||
|
for other in &model.order {
|
||||||
|
if *other == id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(pos) = model.store.get(*other).and_then(|n| n.pos) else { continue };
|
||||||
|
let aff = model.field.affinity(id, *other).unwrap_or(0.0).max(0.0);
|
||||||
|
kin.push((aff, pos));
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = if kin.is_empty() {
|
||||||
|
(0.0, 0.0) // primera nota del cuaderno: centro del mundo.
|
||||||
|
} else {
|
||||||
|
let wsum: f32 = kin.iter().map(|(w, _)| *w).sum();
|
||||||
|
if wsum > 1e-3 {
|
||||||
|
// Cae junto a su parentela: baricentro ponderado por afinidad.
|
||||||
|
let (mut tx, mut ty) = (0.0_f32, 0.0_f32);
|
||||||
|
for (w, (x, y)) in &kin {
|
||||||
|
tx += w * x;
|
||||||
|
ty += w * y;
|
||||||
|
}
|
||||||
|
(tx / wsum, ty / wsum)
|
||||||
|
} else {
|
||||||
|
// Ortogonal a todo: anillo determinista por id, lejos del núcleo.
|
||||||
|
let ang = id as f32 * GOLDEN_ANGLE;
|
||||||
|
let rad = 180.0 + 14.0 * (id as f32).sqrt();
|
||||||
|
(rad * ang.cos(), rad * ang.sin())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Separación: empuja el target hasta despegarlo de cada vecino cercano.
|
||||||
|
let mut p = target;
|
||||||
|
for _ in 0..12 {
|
||||||
|
let mut moved = false;
|
||||||
|
for (_, q) in &kin {
|
||||||
|
let dx = p.0 - q.0;
|
||||||
|
let dy = p.1 - q.1;
|
||||||
|
let d = (dx * dx + dy * dy).sqrt();
|
||||||
|
if d < MAP_MIN_SEP {
|
||||||
|
let (ux, uy) = if d > 1e-3 {
|
||||||
|
(dx / d, dy / d)
|
||||||
|
} else {
|
||||||
|
let a = id as f32 * GOLDEN_ANGLE;
|
||||||
|
(a.cos(), a.sin())
|
||||||
|
};
|
||||||
|
let push = MAP_MIN_SEP - d;
|
||||||
|
p.0 += ux * push;
|
||||||
|
p.1 += uy * push;
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !moved {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
model.store.set_pos(id, p.0, p.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envuelve `child` como cajón absoluto pegado al borde izquierdo, alto
|
||||||
|
/// completo con un margen. El mapa de fondo sigue recibiendo pan/zoom en
|
||||||
|
/// el resto de la ventana; sólo los clicks sobre el cajón los come él.
|
||||||
|
pub(crate) fn overlay_left(child: View<Msg>, width: f32) -> View<Msg> {
|
||||||
|
View::new(Style {
|
||||||
|
position: Position::Absolute,
|
||||||
|
inset: Rect {
|
||||||
|
left: length(8.0_f32),
|
||||||
|
top: length(8.0_f32),
|
||||||
|
bottom: length(8.0_f32),
|
||||||
|
right: auto(),
|
||||||
|
},
|
||||||
|
size: Size {
|
||||||
|
width: length(width),
|
||||||
|
height: auto(),
|
||||||
|
},
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.children(vec![child])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Columna interna del editor: barra de cierre (× ⇒ deselecciona) arriba +
|
||||||
|
/// el editor abajo, sobre `bg_panel`. La comparten el overlay lateral y la
|
||||||
|
/// tarjeta anclada del zoom semántico.
|
||||||
|
pub(crate) fn editor_shell(child: View<Msg>, theme: &Theme) -> View<Msg> {
|
||||||
|
let close = button("× cerrar", theme.bg_button, theme.fg_muted, Msg::Deselect);
|
||||||
|
let close_row = View::new(Style {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: length(28.0_f32),
|
||||||
|
},
|
||||||
|
flex_shrink: 0.0,
|
||||||
|
justify_content: Some(JustifyContent::End),
|
||||||
|
align_items: Some(AlignItems::Center),
|
||||||
|
padding: Rect {
|
||||||
|
left: length(6.0_f32),
|
||||||
|
right: length(6.0_f32),
|
||||||
|
top: length(2.0_f32),
|
||||||
|
bottom: length(2.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(theme.bg_panel_alt)
|
||||||
|
.children(vec![close]);
|
||||||
|
|
||||||
|
View::new(Style {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: percent(1.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(theme.bg_panel)
|
||||||
|
.children(vec![close_row, child])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envuelve `child` como panel absoluto pegado al borde derecho, alto
|
||||||
|
/// completo, con barra de cierre. El editor del nodo abierto cuando se lo
|
||||||
|
/// edita de lejos (zoom bajo): un fallback práctico al anclaje in-situ.
|
||||||
|
pub(crate) fn overlay_right(child: View<Msg>, width: f32, theme: &Theme) -> View<Msg> {
|
||||||
|
View::new(Style {
|
||||||
|
position: Position::Absolute,
|
||||||
|
inset: Rect {
|
||||||
|
left: auto(),
|
||||||
|
top: length(8.0_f32),
|
||||||
|
bottom: length(8.0_f32),
|
||||||
|
right: length(8.0_f32),
|
||||||
|
},
|
||||||
|
size: Size {
|
||||||
|
width: length(width),
|
||||||
|
height: auto(),
|
||||||
|
},
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.children(vec![editor_shell(child, theme)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mundo → pantalla local usando el último tamaño de lienzo conocido + la
|
||||||
|
/// cámara. La versión de `view()`, donde el rect real aún no se sabe.
|
||||||
|
pub(crate) fn world_screen(model: &Model, wx: f32, wy: f32) -> (f32, f32) {
|
||||||
|
let (w, h) = model.canvas_size;
|
||||||
|
world_to_local(wx, wy, w, h, model.cam_pan, model.cam_zoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posición de pantalla (local al lienzo) del nodo `id`. `None` si la nota
|
||||||
|
/// no tiene domicilio todavía.
|
||||||
|
pub(crate) fn node_screen_pos(model: &Model, id: NoteId) -> Option<(f32, f32)> {
|
||||||
|
let (wx, wy) = model.store.get(id).and_then(|n| n.pos)?;
|
||||||
|
Some(world_screen(model, wx, wy))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mínimo de notas para que un clúster cuente como región candidata.
|
||||||
|
pub(crate) const REGION_MIN_MEMBERS: usize = 3;
|
||||||
|
/// Distancia de mundo dentro de la cual una región ya "posee" un clúster:
|
||||||
|
/// si hay un topónimo así de cerca del centroide, no se vuelve a ofrecer.
|
||||||
|
pub(crate) const REGION_MATCH_DIST: f32 = 140.0;
|
||||||
|
|
||||||
|
/// Centroides (mundo) de los clústeres densos que todavía no tienen una
|
||||||
|
/// región cerca — los lugares que el mapa ofrece bautizar. Sólo cuentan
|
||||||
|
/// miembros colocados y visibles.
|
||||||
|
pub(crate) fn unnamed_cluster_centroids(model: &Model) -> Vec<(f32, f32)> {
|
||||||
|
let now = now_secs();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for cluster in model.field.clusters(CLUSTER_THRESHOLD) {
|
||||||
|
let pts: Vec<(f32, f32)> = cluster
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| {
|
||||||
|
let n = model.store.get(*id)?;
|
||||||
|
let p = n.pos?;
|
||||||
|
let m = current_mass(&model.gravity, n, now);
|
||||||
|
(model.show_archive || model.gravity.is_visible(m)).then_some(p)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if pts.len() < REGION_MIN_MEMBERS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (sx, sy) = pts.iter().fold((0.0, 0.0), |(ax, ay), (x, y)| (ax + x, ay + y));
|
||||||
|
let c = (sx / pts.len() as f32, sy / pts.len() as f32);
|
||||||
|
let d2 = REGION_MATCH_DIST * REGION_MATCH_DIST;
|
||||||
|
let near_named = model
|
||||||
|
.regions
|
||||||
|
.iter()
|
||||||
|
.any(|r| (r.x - c.0).powi(2) + (r.y - c.1).powi(2) <= d2);
|
||||||
|
let naming_here = model
|
||||||
|
.naming
|
||||||
|
.map(|(nx, ny)| (nx - c.0).powi(2) + (ny - c.1).powi(2) <= d2)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !near_named && !naming_here {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chip clickeable "✛ nombrar zona" que ofrece bautizar el clúster denso
|
||||||
|
/// en `(wx, wy)`. Al click abre el input de bautizo en esa coordenada.
|
||||||
|
pub(crate) fn name_region_chip(wx: f32, wy: f32, theme: &Theme) -> View<Msg> {
|
||||||
|
View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: percent(1.0_f32),
|
||||||
|
},
|
||||||
|
align_items: Some(AlignItems::Center),
|
||||||
|
justify_content: Some(JustifyContent::Center),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(theme.bg_button)
|
||||||
|
.radius(12.0)
|
||||||
|
.hover_fill(theme.bg_button_hover)
|
||||||
|
.text_aligned("✛ nombrar zona", 11.0, theme.fg_muted, Alignment::Center)
|
||||||
|
.on_click(Msg::BeginNaming(wx, wy))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mini-input del bautizo en curso: una tarjeta con el campo de texto
|
||||||
|
/// enfocado. Enter confirma, Esc cancela (en `on_key`).
|
||||||
|
pub(crate) fn naming_input(model: &Model, input_palette: &TextInputPalette) -> View<Msg> {
|
||||||
|
let input = text_input_view(
|
||||||
|
&model.region_input,
|
||||||
|
"nombre de la zona…",
|
||||||
|
model.focus == Focus::Region,
|
||||||
|
input_palette,
|
||||||
|
Msg::Focus(Focus::Region),
|
||||||
|
);
|
||||||
|
View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: percent(1.0_f32),
|
||||||
|
},
|
||||||
|
align_items: Some(AlignItems::Center),
|
||||||
|
padding: Rect {
|
||||||
|
left: length(6.0_f32),
|
||||||
|
right: length(6.0_f32),
|
||||||
|
top: length(4.0_f32),
|
||||||
|
bottom: length(4.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(model.theme.bg_panel)
|
||||||
|
.radius(8.0)
|
||||||
|
.children(vec![input])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posiciona `child` como vista absoluta de tamaño `(w, h)` centrada en la
|
||||||
|
/// pantalla `(sx, sy)`, clampeada al lienzo. Para chips y mini-inputs que
|
||||||
|
/// viven en el mapa (sugerencia de bautizo, input de nombre).
|
||||||
|
pub(crate) fn pinned(child: View<Msg>, sx: f32, sy: f32, w: f32, h: f32, canvas: (f32, f32)) -> View<Msg> {
|
||||||
|
let left = (sx - w * 0.5).clamp(4.0, (canvas.0 - w - 4.0).max(4.0));
|
||||||
|
let top = (sy - h * 0.5).clamp(4.0, (canvas.1 - h - 4.0).max(4.0));
|
||||||
|
View::new(Style {
|
||||||
|
position: Position::Absolute,
|
||||||
|
inset: Rect {
|
||||||
|
left: length(left),
|
||||||
|
top: length(top),
|
||||||
|
right: auto(),
|
||||||
|
bottom: auto(),
|
||||||
|
},
|
||||||
|
size: Size {
|
||||||
|
width: length(w),
|
||||||
|
height: length(h),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.children(vec![child])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// La tarjeta del nodo abierto, anclada a su coordenada `(nx, ny)` de
|
||||||
|
/// pantalla: el zoom semántico hecho carne — el editor vive EN el lugar del
|
||||||
|
/// pensamiento, no en un panel aparte. Se clampea para no salirse del
|
||||||
|
/// lienzo. Hija del canvas, así pan/zoom la arrastran con el nodo.
|
||||||
|
pub(crate) fn node_card(child: View<Msg>, nx: f32, ny: f32, canvas: (f32, f32), theme: &Theme) -> View<Msg> {
|
||||||
|
let (cw_max, ch_max) = canvas;
|
||||||
|
let cw = 380.0_f32.min((cw_max - 16.0).max(220.0));
|
||||||
|
let ch = 440.0_f32.min((ch_max - 16.0).max(200.0));
|
||||||
|
// Anclada bajo el nodo, centrada en X, clampeada a la ventana.
|
||||||
|
let left = (nx - cw * 0.5).clamp(8.0, (cw_max - cw - 8.0).max(8.0));
|
||||||
|
let top = (ny + 16.0).clamp(8.0, (ch_max - ch - 8.0).max(8.0));
|
||||||
|
|
||||||
|
View::new(Style {
|
||||||
|
position: Position::Absolute,
|
||||||
|
inset: Rect {
|
||||||
|
left: length(left),
|
||||||
|
top: length(top),
|
||||||
|
right: auto(),
|
||||||
|
bottom: auto(),
|
||||||
|
},
|
||||||
|
size: Size {
|
||||||
|
width: length(cw),
|
||||||
|
height: length(ch),
|
||||||
|
},
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.radius(8.0)
|
||||||
|
.children(vec![editor_shell(child, theme)])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn gravity_panel(model: &Model, injected: Vec<View<Msg>>) -> View<Msg> {
|
||||||
|
let theme = model.theme;
|
||||||
|
let now = now_secs();
|
||||||
|
let clusters = model.field.clusters(CLUSTER_THRESHOLD);
|
||||||
|
let selected = model.selected;
|
||||||
|
let pan = model.cam_pan;
|
||||||
|
let zoom = model.cam_zoom;
|
||||||
|
|
||||||
|
// Nodos colocados (los que ya tienen domicilio), con su masa viva.
|
||||||
|
let mut nodes: Vec<MapNode> = Vec::new();
|
||||||
|
for id in &model.order {
|
||||||
|
let Some(n) = model.store.get(*id) else { continue };
|
||||||
|
let Some((x, y)) = n.pos else { continue };
|
||||||
|
let mass = current_mass(&model.gravity, n, now);
|
||||||
|
let visible = model.gravity.is_visible(mass);
|
||||||
|
if !visible && !model.show_archive {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nodes.push(MapNode {
|
||||||
|
id: *id,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
mass,
|
||||||
|
visible,
|
||||||
|
color: cluster_color(*id, &clusters, theme),
|
||||||
|
label: short_label(&n.title),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Topónimos: las regiones bautizadas, para pintarlas como rótulos de
|
||||||
|
// continente detrás de los nodos.
|
||||||
|
let regions: Vec<(String, f32, f32)> = model
|
||||||
|
.regions
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r.name.clone(), r.x, r.y))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Filamentos del nodo seleccionado: sus parientes más afines ya
|
||||||
|
// colocados. Elegir un pensamiento enciende sus vecinos (activación
|
||||||
|
// por difusión) — el motor de serendipia.
|
||||||
|
let mut links: Vec<((f32, f32), (f32, f32), f32)> = Vec::new();
|
||||||
|
if let Some(sel) = selected {
|
||||||
|
if let Some(sp) = model.store.get(sel).and_then(|n| n.pos) {
|
||||||
|
for (nid, aff) in model.field.nearest(sel, 6) {
|
||||||
|
if aff < 0.20 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(np) = model.store.get(nid).and_then(|n| n.pos) {
|
||||||
|
links.push((sp, np, aff));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let canvas = View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: percent(1.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(theme.bg_panel_alt)
|
||||||
|
.paint_with(move |scene, ts, rect| {
|
||||||
|
paint_map(scene, ts, rect, &nodes, &links, ®ions, selected, pan, zoom, theme);
|
||||||
|
})
|
||||||
|
.draggable_at(|phase, dx, dy, _lx, _ly| match phase {
|
||||||
|
DragPhase::Move => Some(Msg::MapPan(dx, dy)),
|
||||||
|
DragPhase::End => None,
|
||||||
|
})
|
||||||
|
.on_scroll(|_dx, dy| Some(Msg::MapZoom(dy)))
|
||||||
|
.on_click_at(|lx, ly, w, h| Some(Msg::MapClick(lx, ly, w, h)))
|
||||||
|
// La tarjeta del nodo abierto (zoom semántico) viaja como hija del
|
||||||
|
// canvas: se pinta encima de los nodos y la cámara la arrastra con el
|
||||||
|
// pensamiento al que pertenece.
|
||||||
|
.children(injected);
|
||||||
|
|
||||||
|
View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: Dimension::auto(),
|
||||||
|
height: percent(1.0_f32),
|
||||||
|
},
|
||||||
|
flex_grow: 1.0,
|
||||||
|
flex_basis: length(0.0_f32),
|
||||||
|
min_size: Size {
|
||||||
|
width: length(0.0_f32),
|
||||||
|
height: length(0.0_f32),
|
||||||
|
},
|
||||||
|
padding: Rect {
|
||||||
|
left: length(4.0_f32),
|
||||||
|
right: length(4.0_f32),
|
||||||
|
top: length(4.0_f32),
|
||||||
|
bottom: length(4.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(theme.bg_panel)
|
||||||
|
.children(vec![canvas])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(crate) fn paint_map(
|
||||||
|
scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
|
||||||
|
ts: &mut llimphi_ui::llimphi_text::Typesetter,
|
||||||
|
rect: llimphi_ui::PaintRect,
|
||||||
|
nodes: &[MapNode],
|
||||||
|
links: &[((f32, f32), (f32, f32), f32)],
|
||||||
|
regions: &[(String, f32, f32)],
|
||||||
|
selected: Option<NoteId>,
|
||||||
|
pan: (f32, f32),
|
||||||
|
zoom: f32,
|
||||||
|
theme: Theme,
|
||||||
|
) {
|
||||||
|
if rect.w <= 0.0 || rect.h <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Pantalla absoluta = origen del rect + pantalla local.
|
||||||
|
let to_screen = |wx: f32, wy: f32| -> (f64, f64) {
|
||||||
|
let (lx, ly) = world_to_local(wx, wy, rect.w, rect.h, pan, zoom);
|
||||||
|
((rect.x + lx) as f64, (rect.y + ly) as f64)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Topónimos al fondo: el nombre de cada región, grande y tenue, como
|
||||||
|
// rótulo de continente; un halo suave insinúa su territorio.
|
||||||
|
for (name, rx, ry) in regions {
|
||||||
|
let (cx, cy) = to_screen(*rx, *ry);
|
||||||
|
let blob = KurboCircle::new((cx, cy), (96.0 * zoom as f64).max(34.0));
|
||||||
|
scene.fill(
|
||||||
|
Fill::NonZero,
|
||||||
|
Affine::IDENTITY,
|
||||||
|
with_alpha(theme.accent, 0.05),
|
||||||
|
None,
|
||||||
|
&blob,
|
||||||
|
);
|
||||||
|
let size = (15.0 * zoom).clamp(11.0, 28.0);
|
||||||
|
// Centrado aproximado: `simple` alinea a la izquierda en (x, y).
|
||||||
|
let est_w = name.chars().count() as f64 * size as f64 * 0.52;
|
||||||
|
draw_block(
|
||||||
|
scene,
|
||||||
|
ts,
|
||||||
|
&TextBlock::simple(
|
||||||
|
name,
|
||||||
|
size,
|
||||||
|
with_alpha(theme.fg_text, 0.30),
|
||||||
|
(cx - est_w * 0.5, cy - size as f64 * 0.6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filamentos primero (debajo de los nodos). Más opacos cuanto más afín.
|
||||||
|
for (a, b, aff) in links {
|
||||||
|
let (ax, ay) = to_screen(a.0, a.1);
|
||||||
|
let (bx, by) = to_screen(b.0, b.1);
|
||||||
|
let mut path = BezPath::new();
|
||||||
|
path.move_to((ax, ay));
|
||||||
|
path.line_to((bx, by));
|
||||||
|
let alpha = (0.18 + aff * 0.55).clamp(0.0, 0.85);
|
||||||
|
scene.stroke(
|
||||||
|
&Stroke::new((0.8 + *aff as f64 * 1.6).max(0.6)),
|
||||||
|
Affine::IDENTITY,
|
||||||
|
with_alpha(theme.accent, alpha),
|
||||||
|
None,
|
||||||
|
&path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nodos: tamaño y brillo crecen con la masa viva (el mapa respira).
|
||||||
|
for n in nodes {
|
||||||
|
let (px, py) = to_screen(n.x, n.y);
|
||||||
|
let m = n.mass.clamp(0.0, 2.0);
|
||||||
|
// Radio base por masa, escalado apenas por zoom para no inflarse.
|
||||||
|
let r = (3.0 + m * 4.5) * (0.6 + 0.4 * zoom.clamp(0.5, 1.5));
|
||||||
|
// Brillo: las notas frescas arden; las que se enfrían se apagan
|
||||||
|
// hacia el fondo. Bajo el horizonte (archivo) van casi transparentes.
|
||||||
|
let glow = if n.visible {
|
||||||
|
(0.35 + m * 0.45).clamp(0.0, 1.0)
|
||||||
|
} else {
|
||||||
|
0.18
|
||||||
|
};
|
||||||
|
let color = with_alpha(n.color, glow);
|
||||||
|
// Halo tenue alrededor de las notas más encendidas.
|
||||||
|
if n.visible && m > 0.6 {
|
||||||
|
let halo = KurboCircle::new((px, py), (r + 5.0) as f64);
|
||||||
|
scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(n.color, 0.10), None, &halo);
|
||||||
|
}
|
||||||
|
let circle = KurboCircle::new((px, py), r as f64);
|
||||||
|
scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &circle);
|
||||||
|
|
||||||
|
if selected == Some(n.id) {
|
||||||
|
let ring = KurboCircle::new((px, py), (r + 3.0) as f64);
|
||||||
|
scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, theme.accent, None, &ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Etiqueta: sólo si el zoom da espacio o es la seleccionada — para
|
||||||
|
// no saturar el mapa lejano. El texto sale del Typesetter.
|
||||||
|
if (zoom >= 0.9 || selected == Some(n.id)) && n.visible {
|
||||||
|
let lbl_col = with_alpha(theme.fg_text, (glow + 0.25).clamp(0.0, 1.0));
|
||||||
|
draw_block(
|
||||||
|
scene,
|
||||||
|
ts,
|
||||||
|
&TextBlock::simple(&n.label, 10.0, lbl_col, (px + r as f64 + 4.0, py - 7.0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cluster_color(id: NoteId, clusters: &[Vec<NoteId>], theme: Theme) -> Color {
|
||||||
|
let idx = clusters.iter().position(|c| c.contains(&id)).unwrap_or(0);
|
||||||
|
// Paleta tomada del theme + matices generados por golden-ratio
|
||||||
|
// sobre el hue del accent. Determinista por índice.
|
||||||
|
let palette: [Color; 6] = [
|
||||||
|
theme.accent,
|
||||||
|
with_alpha(rotate_hue(theme.accent, 0.16), 1.0),
|
||||||
|
with_alpha(rotate_hue(theme.accent, 0.33), 1.0),
|
||||||
|
with_alpha(rotate_hue(theme.accent, 0.50), 1.0),
|
||||||
|
with_alpha(rotate_hue(theme.accent, 0.66), 1.0),
|
||||||
|
with_alpha(rotate_hue(theme.accent, 0.83), 1.0),
|
||||||
|
];
|
||||||
|
palette[idx % palette.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_alpha(c: Color, alpha: f32) -> Color {
|
||||||
|
let [r, g, b, _] = c.components;
|
||||||
|
Color::new([r, g, b, alpha])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn rotate_hue(c: Color, dh: f32) -> Color {
|
||||||
|
// RGB → HSV → rota H → RGB. Aproximación, alpha fijo.
|
||||||
|
let [r, g, b, a] = c.components;
|
||||||
|
let max = r.max(g).max(b);
|
||||||
|
let min = r.min(g).min(b);
|
||||||
|
let v = max;
|
||||||
|
let s = if max <= 0.0 { 0.0 } else { (max - min) / max };
|
||||||
|
let h = if (max - min).abs() < 1e-6 {
|
||||||
|
0.0
|
||||||
|
} else if max == r {
|
||||||
|
((g - b) / (max - min)) % 6.0
|
||||||
|
} else if max == g {
|
||||||
|
(b - r) / (max - min) + 2.0
|
||||||
|
} else {
|
||||||
|
(r - g) / (max - min) + 4.0
|
||||||
|
};
|
||||||
|
let h2 = ((h / 6.0) + dh).rem_euclid(1.0) * 6.0;
|
||||||
|
let c2 = v * s;
|
||||||
|
let x = c2 * (1.0 - ((h2 % 2.0) - 1.0).abs());
|
||||||
|
let (r2, g2, b2) = match h2 as i32 {
|
||||||
|
0 => (c2, x, 0.0),
|
||||||
|
1 => (x, c2, 0.0),
|
||||||
|
2 => (0.0, c2, x),
|
||||||
|
3 => (0.0, x, c2),
|
||||||
|
4 => (x, 0.0, c2),
|
||||||
|
_ => (c2, 0.0, x),
|
||||||
|
};
|
||||||
|
let m = v - c2;
|
||||||
|
Color::new([r2 + m, g2 + m, b2 + m, a])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn short_label(s: &str) -> String {
|
||||||
|
let mut out: String = s.chars().take(24).collect();
|
||||||
|
if s.chars().count() > 24 {
|
||||||
|
out.push('…');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
//! `net` — el lado P2P del cuaderno: sellar/exportar el sobre firmado,
|
||||||
|
//! verificar e ingerir uno ajeno, descubrir pares y publicarse por libp2p.
|
||||||
|
//! El transporte (LAN directo + WAN vía `card-net`/Kademlia + relay/NAT)
|
||||||
|
//! vive en `khipu-share`/`khipu-brahman`; acá sólo se orquesta.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use khipu_share::{SharedNote, SignedBundle};
|
||||||
|
use llimphi_ui::Handle;
|
||||||
|
|
||||||
|
use crate::panels::note_matches;
|
||||||
|
use crate::{khipu_dir, now_secs, schedule_embedding, Model, Msg, P2p};
|
||||||
|
|
||||||
|
pub(crate) fn export_notebook(model: &Model) -> String {
|
||||||
|
let Some(kp) = model.keypair.as_ref() else {
|
||||||
|
return "sin identidad para firmar".into();
|
||||||
|
};
|
||||||
|
let Some(dir) = khipu_dir() else {
|
||||||
|
return "sin directorio de datos".into();
|
||||||
|
};
|
||||||
|
// Compartir selectivo: si hay texto en el buscador, exportamos sólo
|
||||||
|
// las notas que filtra (mismo criterio que la lista); si está vacío,
|
||||||
|
// todo el cuaderno.
|
||||||
|
let query = model.search.text();
|
||||||
|
let q = query.trim();
|
||||||
|
let notes: Vec<SharedNote> = model
|
||||||
|
.order
|
||||||
|
.iter()
|
||||||
|
.filter_map(|id| model.store.get(*id))
|
||||||
|
.filter(|n| q.is_empty() || note_matches(n, q))
|
||||||
|
.map(SharedNote::from_note)
|
||||||
|
.collect();
|
||||||
|
if notes.is_empty() {
|
||||||
|
return "no hay notas para exportar (¿el filtro no coincide?)".into();
|
||||||
|
}
|
||||||
|
let n = notes.len();
|
||||||
|
let sobre = match khipu_share::seal(kp, notes, now_secs()) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return "falló el sellado".into(),
|
||||||
|
};
|
||||||
|
let Ok(bytes) = sobre.to_bytes() else {
|
||||||
|
return "falló serializar el sobre".into();
|
||||||
|
};
|
||||||
|
let path = dir.join("compartido.khipu");
|
||||||
|
let tmp = path.with_extension("khipu.tmp");
|
||||||
|
if std::fs::write(&tmp, &bytes)
|
||||||
|
.and_then(|_| std::fs::rename(&tmp, &path))
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return "no se pudo escribir el sobre".into();
|
||||||
|
}
|
||||||
|
let hash = sobre.content_address().unwrap_or([0u8; 32]);
|
||||||
|
let filtro = if q.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" (filtro «{q}»)")
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"exportadas {n} notas{filtro} → compartido.khipu · {}",
|
||||||
|
hex8(&hash)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verifica e ingiere `compartido.khipu`. Las notas nuevas nacen con
|
||||||
|
/// gravedad fresca; sus embeddings se recalculan en segundo plano. Un
|
||||||
|
/// sobre con firma inválida se rechaza entero. Devuelve la línea de estado.
|
||||||
|
pub(crate) fn import_notebook(model: &mut Model, h: &Handle<Msg>) -> String {
|
||||||
|
let Some(dir) = khipu_dir() else {
|
||||||
|
return "sin directorio de datos".into();
|
||||||
|
};
|
||||||
|
let path = dir.join("compartido.khipu");
|
||||||
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
|
return "no hay compartido.khipu para importar".into();
|
||||||
|
};
|
||||||
|
let sobre = match SignedBundle::from_bytes(&bytes) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return "sobre ilegible".into(),
|
||||||
|
};
|
||||||
|
let outcome = match khipu_share::open(&sobre) {
|
||||||
|
Ok(bundle) => khipu_share::import_into(&mut model.store, bundle, now_secs()),
|
||||||
|
Err(_) => return "firma inválida — sobre rechazado".into(),
|
||||||
|
};
|
||||||
|
for id in &outcome.created {
|
||||||
|
model.order.push(*id);
|
||||||
|
schedule_embedding(model, *id, h);
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"importadas {} · omitidas {} (ya existían)",
|
||||||
|
outcome.created.len(),
|
||||||
|
outcome.skipped
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dirección donde el servidor escucha. `KHIPU_BIND` la sobrescribe;
|
||||||
|
/// default localhost para no exponerse sin querer.
|
||||||
|
pub(crate) fn bind_addr() -> String {
|
||||||
|
std::env::var("KHIPU_BIND").unwrap_or_else(|_| "127.0.0.1:7700".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dirección del par a quien jalarle el cuaderno. `KHIPU_PEER` la
|
||||||
|
/// sobrescribe; default coincide con [`bind_addr`] para probar en local.
|
||||||
|
pub(crate) fn peer_addr() -> String {
|
||||||
|
std::env::var("KHIPU_PEER").unwrap_or_else(|_| "127.0.0.1:7700".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Arma el nodo libp2p la primera vez que se necesita: runtime tokio
|
||||||
|
/// dedicado + `KhipuNode` que empieza a escuchar (para ser alcanzable y
|
||||||
|
/// obtener nuestra dirección de marcado). Idempotente. `false` si no se
|
||||||
|
/// pudo (sin runtime o sin red).
|
||||||
|
pub(crate) fn ensure_p2p(model: &mut Model) -> bool {
|
||||||
|
if model.p2p.is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let Ok(rt) = tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.worker_threads(2)
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// `KhipuNode::standalone` arranca el swarm con `tokio::spawn`: hay que
|
||||||
|
// estar dentro del runtime.
|
||||||
|
let node = {
|
||||||
|
let _g = rt.enter();
|
||||||
|
match khipu_brahman::KhipuNode::standalone() {
|
||||||
|
Ok(n) => Arc::new(n),
|
||||||
|
Err(_) => return false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let dial_addr = rt
|
||||||
|
.block_on(node.listen_str("/ip4/0.0.0.0/tcp/0"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
// Si hay un nodo bootstrap configurado, nos unimos a la malla DHT para
|
||||||
|
// poder descubrir y ser descubiertos (`anunciar`/`descubrir`).
|
||||||
|
if let Ok(boot) = std::env::var("KHIPU_BOOTSTRAP") {
|
||||||
|
let _ = node.dial_str(&boot);
|
||||||
|
}
|
||||||
|
model.p2p = Some(P2p {
|
||||||
|
rt: Arc::new(rt),
|
||||||
|
node,
|
||||||
|
dial_addr,
|
||||||
|
serving: false,
|
||||||
|
});
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Levanta (una sola vez) el servidor TCP que sirve `compartido.khipu`.
|
||||||
|
/// El hilo lee el archivo en cada conexión, así sirve siempre la versión
|
||||||
|
/// vigente; vive hasta que el proceso termina. Devuelve la línea de estado.
|
||||||
|
pub(crate) fn start_publishing(model: &mut Model, h: &Handle<Msg>) -> String {
|
||||||
|
if model.publishing {
|
||||||
|
return format!("ya publicando en {}", bind_addr());
|
||||||
|
}
|
||||||
|
let Some(dir) = khipu_dir() else {
|
||||||
|
return "sin directorio de datos".into();
|
||||||
|
};
|
||||||
|
let addr = bind_addr();
|
||||||
|
let listener = match std::net::TcpListener::bind(&addr) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => return format!("no se pudo escuchar en {addr}: {e}"),
|
||||||
|
};
|
||||||
|
// Puerto efectivo (resuelve `:0` si se usara) para anunciarlo en la baliza.
|
||||||
|
let tcp_port = listener.local_addr().map(|a| a.port()).unwrap_or(0);
|
||||||
|
let path = dir.join("compartido.khipu");
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
khipu_share::net::serve_loop(listener, move || std::fs::read(&path));
|
||||||
|
});
|
||||||
|
// Baliza periódica para que los pares nos descubran sin saber la IP.
|
||||||
|
let beacon = khipu_share::discovery::Beacon {
|
||||||
|
author: model.keypair.as_ref().map(|k| k.public_key()).unwrap_or([0u8; 32]),
|
||||||
|
port: tcp_port,
|
||||||
|
name: "khipu".into(),
|
||||||
|
};
|
||||||
|
std::thread::spawn(move || loop {
|
||||||
|
let _ = khipu_share::discovery::anunciar(&beacon);
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||||
|
});
|
||||||
|
model.publishing = true;
|
||||||
|
|
||||||
|
// Además del TCP/LAN, servimos por libp2p (cifrado, WAN). El nodo se
|
||||||
|
// arma perezoso; servimos `compartido.khipu` y nos anunciamos en la DHT.
|
||||||
|
let p2p_status = if ensure_p2p(model) {
|
||||||
|
let dir2 = dir.clone();
|
||||||
|
if let Some(p) = model.p2p.as_mut() {
|
||||||
|
if !p.serving {
|
||||||
|
let path2 = dir2.join("compartido.khipu");
|
||||||
|
let node = p.node.clone();
|
||||||
|
let _g = p.rt.enter();
|
||||||
|
node.run_serve(move || std::fs::read(&path2).ok());
|
||||||
|
node.anunciar();
|
||||||
|
p.serving = true;
|
||||||
|
}
|
||||||
|
// Si hay un relay configurado (KHIPU_RELAY=/ip4/.../p2p/<id>),
|
||||||
|
// reservamos un circuito ahí para ser alcanzables detrás de NAT.
|
||||||
|
// Async (dial + identify + reserva tardan ~2s): cuando termina,
|
||||||
|
// reentra con Msg::RelayReady para mostrar la dirección.
|
||||||
|
if let Ok(relay) = std::env::var("KHIPU_RELAY") {
|
||||||
|
let (rt, node, h2) = (p.rt.clone(), p.node.clone(), h.clone());
|
||||||
|
rt.spawn(async move {
|
||||||
|
let _ = node.dial_str(&relay);
|
||||||
|
// Esperamos a que AutoNAT confirme la dirección del relay
|
||||||
|
// (boot_delay + dial-back) antes de pedir la reserva.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(6)).await;
|
||||||
|
let circuit = format!("{relay}/p2p-circuit");
|
||||||
|
let msg = match node.listen_str(&circuit).await {
|
||||||
|
Ok(addr) => addr,
|
||||||
|
Err(e) => format!("falló reservar circuito: {e}"),
|
||||||
|
};
|
||||||
|
h2.dispatch(Msg::RelayReady(msg));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
format!(" · libp2p: {}", p.dial_addr)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
format!("publicando en {addr} (LAN){p2p_status}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefijo hex (4 bytes / 8 hex) de un hash, para mostrar una dirección
|
||||||
|
/// de contenido sin abrumar.
|
||||||
|
pub(crate) fn hex8(hash: &[u8; 32]) -> String {
|
||||||
|
hash[..4].iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,728 @@
|
|||||||
|
//! `panels` — los constructores de vistas en flujo: cabecera, cajón de
|
||||||
|
//! notas (lista + búsqueda), panel de recibir (pares P2P), y el editor de
|
||||||
|
//! la nota (título/cuerpo/etiquetas + stats). Frontends puros sobre el
|
||||||
|
//! `Model`; la lógica vive en la raíz y en `map`.
|
||||||
|
|
||||||
|
use llimphi_ui::llimphi_layout::taffy::{
|
||||||
|
prelude::{length, percent, FlexDirection, Rect, Size, Style},
|
||||||
|
AlignItems, Dimension, JustifyContent,
|
||||||
|
};
|
||||||
|
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||||
|
use llimphi_ui::llimphi_text::Alignment;
|
||||||
|
use llimphi_ui::View;
|
||||||
|
use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
|
||||||
|
use llimphi_widget_text_editor::{text_editor_view, EditorMetrics, EditorPalette};
|
||||||
|
use llimphi_widget_text_input::{text_input_view, TextInputPalette};
|
||||||
|
use khipu_core::{Note, NoteId};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
current_mass, now_secs, Focus, Model, Msg, EDITOR_VISIBLE_LINES, FIELD_LABEL_SIZE, HEADER_H,
|
||||||
|
LIST_WIDTH, ROW_H,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) fn header_view(model: &Model) -> View<Msg> {
|
||||||
|
let title = format!("khipu · {} notas", model.store.len());
|
||||||
|
let title_node = View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: Dimension::auto(),
|
||||||
|
height: percent(1.0_f32),
|
||||||
|
},
|
||||||
|
flex_grow: 1.0,
|
||||||
|
padding: Rect {
|
||||||
|
left: length(14.0_f32),
|
||||||
|
right: length(8.0_f32),
|
||||||
|
top: length(0.0_f32),
|
||||||
|
bottom: length(0.0_f32),
|
||||||
|
},
|
||||||
|
align_items: Some(AlignItems::Center),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.text_aligned(title, 14.0, model.theme.fg_text, Alignment::Start);
|
||||||
|
|
||||||
|
let list_label = if model.show_list { "ocultar notas" } else { "☰ notas" };
|
||||||
|
let list_btn = button(
|
||||||
|
list_label,
|
||||||
|
model.theme.bg_button,
|
||||||
|
if model.show_list { model.theme.accent } else { model.theme.fg_muted },
|
||||||
|
Msg::ToggleList,
|
||||||
|
);
|
||||||
|
let new_btn = button(
|
||||||
|
"+ nueva (Ctrl+N)",
|
||||||
|
model.theme.bg_button,
|
||||||
|
model.theme.fg_text,
|
||||||
|
Msg::NewNote,
|
||||||
|
);
|
||||||
|
let archive_label = if model.show_archive {
|
||||||
|
"ocultar archivo"
|
||||||
|
} else {
|
||||||
|
"ver archivo"
|
||||||
|
};
|
||||||
|
let archive_btn = button(
|
||||||
|
archive_label,
|
||||||
|
model.theme.bg_button,
|
||||||
|
model.theme.fg_muted,
|
||||||
|
Msg::ToggleArchive,
|
||||||
|
);
|
||||||
|
let del_btn = button(
|
||||||
|
"borrar",
|
||||||
|
model.theme.bg_button,
|
||||||
|
model.theme.fg_muted,
|
||||||
|
Msg::DeleteSelected,
|
||||||
|
);
|
||||||
|
let export_btn = button(
|
||||||
|
"exportar",
|
||||||
|
model.theme.bg_button,
|
||||||
|
model.theme.fg_muted,
|
||||||
|
Msg::Export,
|
||||||
|
);
|
||||||
|
let import_btn = button(
|
||||||
|
"importar",
|
||||||
|
model.theme.bg_button,
|
||||||
|
model.theme.fg_muted,
|
||||||
|
Msg::Import,
|
||||||
|
);
|
||||||
|
let publish_label = if model.publishing {
|
||||||
|
"publicando"
|
||||||
|
} else {
|
||||||
|
"publicar"
|
||||||
|
};
|
||||||
|
let publish_btn = button(
|
||||||
|
publish_label,
|
||||||
|
model.theme.bg_button,
|
||||||
|
if model.publishing {
|
||||||
|
model.theme.accent
|
||||||
|
} else {
|
||||||
|
model.theme.fg_muted
|
||||||
|
},
|
||||||
|
Msg::Publish,
|
||||||
|
);
|
||||||
|
let receive_btn = button(
|
||||||
|
"recibir",
|
||||||
|
model.theme.bg_button,
|
||||||
|
model.theme.fg_muted,
|
||||||
|
Msg::Receive,
|
||||||
|
);
|
||||||
|
|
||||||
|
View::new(Style {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: length(HEADER_H),
|
||||||
|
},
|
||||||
|
flex_shrink: 0.0,
|
||||||
|
padding: Rect {
|
||||||
|
left: length(0.0_f32),
|
||||||
|
right: length(10.0_f32),
|
||||||
|
top: length(4.0_f32),
|
||||||
|
bottom: length(4.0_f32),
|
||||||
|
},
|
||||||
|
align_items: Some(AlignItems::Center),
|
||||||
|
gap: Size {
|
||||||
|
width: length(8.0_f32),
|
||||||
|
height: length(0.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(model.theme.bg_panel_alt)
|
||||||
|
.children(vec![
|
||||||
|
title_node,
|
||||||
|
list_btn,
|
||||||
|
new_btn,
|
||||||
|
archive_btn,
|
||||||
|
del_btn,
|
||||||
|
export_btn,
|
||||||
|
import_btn,
|
||||||
|
publish_btn,
|
||||||
|
receive_btn,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn button(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
|
||||||
|
// El ancho crece con el largo del texto — los labels más
|
||||||
|
// explícitos («+ nueva (Ctrl+N)», «ocultar archivo») piden más
|
||||||
|
// espacio que un «borrar» seco.
|
||||||
|
let chars = label.chars().count() as f32;
|
||||||
|
let width = (chars * 7.2 + 22.0).max(86.0);
|
||||||
|
View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: length(width),
|
||||||
|
height: length(26.0_f32),
|
||||||
|
},
|
||||||
|
padding: Rect {
|
||||||
|
left: length(10.0_f32),
|
||||||
|
right: length(10.0_f32),
|
||||||
|
top: length(0.0_f32),
|
||||||
|
bottom: length(0.0_f32),
|
||||||
|
},
|
||||||
|
align_items: Some(AlignItems::Center),
|
||||||
|
justify_content: Some(JustifyContent::Center),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(bg)
|
||||||
|
.radius(4.0)
|
||||||
|
.text_aligned(label.to_string(), 11.0, fg, Alignment::Center)
|
||||||
|
.on_click(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn list_panel(
|
||||||
|
model: &Model,
|
||||||
|
palette: &ListPalette,
|
||||||
|
input_palette: &TextInputPalette,
|
||||||
|
) -> View<Msg> {
|
||||||
|
let now = now_secs();
|
||||||
|
let query = model.search.text();
|
||||||
|
let q = query.trim();
|
||||||
|
|
||||||
|
// Particionamos en horizonte vs archivo y ordenamos cada parte por
|
||||||
|
// masa viva decreciente. Si hay query, ambas listas quedan
|
||||||
|
// pre-filtradas por coincidencia en título/cuerpo/etiquetas.
|
||||||
|
let mut visible: Vec<(NoteId, f32, &Note)> = Vec::new();
|
||||||
|
let mut archive: Vec<(NoteId, f32, &Note)> = Vec::new();
|
||||||
|
let mut hidden_by_query = 0usize;
|
||||||
|
for id in &model.order {
|
||||||
|
let Some(n) = model.store.get(*id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !q.is_empty() && !note_matches(n, q) {
|
||||||
|
hidden_by_query += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let m = current_mass(&model.gravity, n, now);
|
||||||
|
if model.gravity.is_visible(m) {
|
||||||
|
visible.push((*id, m, n));
|
||||||
|
} else {
|
||||||
|
archive.push((*id, m, n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let by_mass_desc = |a: &(NoteId, f32, &Note), b: &(NoteId, f32, &Note)| {
|
||||||
|
b.1.partial_cmp(&a.1)
|
||||||
|
.unwrap_or(core::cmp::Ordering::Equal)
|
||||||
|
.then(a.0.cmp(&b.0))
|
||||||
|
};
|
||||||
|
visible.sort_by(by_mass_desc);
|
||||||
|
archive.sort_by(by_mass_desc);
|
||||||
|
|
||||||
|
let mut chain: Vec<(NoteId, f32, &Note)> = visible.clone();
|
||||||
|
if model.show_archive {
|
||||||
|
chain.extend(archive.iter().cloned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows: Vec<ListRow<Msg>> = chain
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, mass, n)| ListRow {
|
||||||
|
label: row_label(n, mass),
|
||||||
|
selected: Some(id) == model.selected,
|
||||||
|
on_click: Msg::SelectNote(id),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let caption = if !q.is_empty() {
|
||||||
|
format!(
|
||||||
|
"buscar «{}» · {}/{} coinciden",
|
||||||
|
q,
|
||||||
|
visible.len() + if model.show_archive { archive.len() } else { 0 },
|
||||||
|
visible.len() + archive.len() + hidden_by_query
|
||||||
|
)
|
||||||
|
} else if archive.is_empty() {
|
||||||
|
format!("notas · {}", visible.len())
|
||||||
|
} else if model.show_archive {
|
||||||
|
format!(
|
||||||
|
"notas · {} horizonte + {} archivo",
|
||||||
|
visible.len(),
|
||||||
|
archive.len()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"notas · {} horizonte (+{} archivo)",
|
||||||
|
visible.len(),
|
||||||
|
archive.len()
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let spec = ListSpec {
|
||||||
|
total: rows.len(),
|
||||||
|
rows,
|
||||||
|
caption: Some(caption),
|
||||||
|
truncated_hint: None,
|
||||||
|
row_height: ROW_H,
|
||||||
|
palette: *palette,
|
||||||
|
};
|
||||||
|
|
||||||
|
let search_input = text_input_view(
|
||||||
|
&model.search,
|
||||||
|
"buscar (título, cuerpo, etiquetas)",
|
||||||
|
model.focus == Focus::Search,
|
||||||
|
input_palette,
|
||||||
|
Msg::Focus(Focus::Search),
|
||||||
|
);
|
||||||
|
let search_row = View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: length(28.0_f32),
|
||||||
|
},
|
||||||
|
flex_shrink: 0.0,
|
||||||
|
padding: Rect {
|
||||||
|
left: length(6.0_f32),
|
||||||
|
right: length(6.0_f32),
|
||||||
|
top: length(4.0_f32),
|
||||||
|
bottom: length(4.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(model.theme.bg_panel_alt)
|
||||||
|
.children(vec![search_input]);
|
||||||
|
|
||||||
|
let list_wrap = View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: Dimension::auto(),
|
||||||
|
},
|
||||||
|
flex_grow: 1.0,
|
||||||
|
flex_basis: length(0.0_f32),
|
||||||
|
min_size: Size {
|
||||||
|
width: length(0.0_f32),
|
||||||
|
height: length(0.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.children(vec![list_view(spec)]);
|
||||||
|
|
||||||
|
View::new(Style {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
size: Size {
|
||||||
|
width: length(LIST_WIDTH),
|
||||||
|
height: percent(1.0_f32),
|
||||||
|
},
|
||||||
|
flex_shrink: 0.0,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.children(vec![search_row, list_wrap])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panel izquierdo en modo "recibir": arriba un input de dirección manual
|
||||||
|
/// (`host:puerto`, habilita WAN) con botones jalar/cancelar; debajo, la
|
||||||
|
/// lista de pares descubiertos en la LAN (click ⇒ jalar de él). Reemplaza
|
||||||
|
/// transitoriamente la lista de notas.
|
||||||
|
pub(crate) fn receive_panel(
|
||||||
|
model: &Model,
|
||||||
|
palette: &ListPalette,
|
||||||
|
input_palette: &TextInputPalette,
|
||||||
|
) -> View<Msg> {
|
||||||
|
// Fila de dirección manual + jalar.
|
||||||
|
let addr_input = text_input_view(
|
||||||
|
&model.peer_input,
|
||||||
|
"host:puerto o /ip4/…/p2p/…",
|
||||||
|
model.focus == Focus::PeerAddr,
|
||||||
|
input_palette,
|
||||||
|
Msg::Focus(Focus::PeerAddr),
|
||||||
|
);
|
||||||
|
let addr_wrap = View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: Dimension::auto(),
|
||||||
|
height: length(26.0_f32),
|
||||||
|
},
|
||||||
|
flex_grow: 1.0,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.children(vec![addr_input]);
|
||||||
|
let jalar = button(
|
||||||
|
"jalar",
|
||||||
|
model.theme.bg_button,
|
||||||
|
model.theme.accent,
|
||||||
|
Msg::FetchManual,
|
||||||
|
);
|
||||||
|
let addr_row = View::new(Style {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: length(34.0_f32),
|
||||||
|
},
|
||||||
|
flex_shrink: 0.0,
|
||||||
|
padding: Rect {
|
||||||
|
left: length(6.0_f32),
|
||||||
|
right: length(6.0_f32),
|
||||||
|
top: length(4.0_f32),
|
||||||
|
bottom: length(4.0_f32),
|
||||||
|
},
|
||||||
|
align_items: Some(AlignItems::Center),
|
||||||
|
gap: Size {
|
||||||
|
width: length(6.0_f32),
|
||||||
|
height: length(0.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(model.theme.bg_panel_alt)
|
||||||
|
.children(vec![addr_wrap, jalar]);
|
||||||
|
|
||||||
|
let cancel = button(
|
||||||
|
"cancelar",
|
||||||
|
model.theme.bg_button,
|
||||||
|
model.theme.fg_muted,
|
||||||
|
Msg::CancelPeers,
|
||||||
|
);
|
||||||
|
let cancel_row = View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: length(34.0_f32),
|
||||||
|
},
|
||||||
|
flex_shrink: 0.0,
|
||||||
|
padding: Rect {
|
||||||
|
left: length(6.0_f32),
|
||||||
|
right: length(6.0_f32),
|
||||||
|
top: length(2.0_f32),
|
||||||
|
bottom: length(4.0_f32),
|
||||||
|
},
|
||||||
|
align_items: Some(AlignItems::Center),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(model.theme.bg_panel_alt)
|
||||||
|
.children(vec![cancel]);
|
||||||
|
|
||||||
|
let rows: Vec<ListRow<Msg>> = model
|
||||||
|
.peers
|
||||||
|
.iter()
|
||||||
|
.map(|p| ListRow {
|
||||||
|
label: p.label.clone(),
|
||||||
|
selected: false,
|
||||||
|
on_click: Msg::FetchFrom(p.addr.clone()),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let caption = if model.peers.is_empty() {
|
||||||
|
"pares en la LAN: ninguno aún".to_string()
|
||||||
|
} else {
|
||||||
|
format!("pares en la LAN · {} (click para jalar)", model.peers.len())
|
||||||
|
};
|
||||||
|
let spec = ListSpec {
|
||||||
|
total: rows.len(),
|
||||||
|
rows,
|
||||||
|
caption: Some(caption),
|
||||||
|
truncated_hint: None,
|
||||||
|
row_height: ROW_H,
|
||||||
|
palette: *palette,
|
||||||
|
};
|
||||||
|
let list_wrap = View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: Dimension::auto(),
|
||||||
|
},
|
||||||
|
flex_grow: 1.0,
|
||||||
|
flex_basis: length(0.0_f32),
|
||||||
|
min_size: Size {
|
||||||
|
width: length(0.0_f32),
|
||||||
|
height: length(0.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.children(vec![list_view(spec)]);
|
||||||
|
|
||||||
|
View::new(Style {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
size: Size {
|
||||||
|
width: length(LIST_WIDTH),
|
||||||
|
height: percent(1.0_f32),
|
||||||
|
},
|
||||||
|
flex_shrink: 0.0,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.children(vec![addr_row, cancel_row, list_wrap])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coincidencia sobre título, cuerpo y etiquetas. Case-insensitive.
|
||||||
|
pub(crate) fn note_matches(n: &Note, query: &str) -> bool {
|
||||||
|
if n.matches(query) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let q = query.to_lowercase();
|
||||||
|
n.tags.iter().any(|t| t.to_lowercase().contains(&q))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn row_label(n: &Note, mass: f32) -> String {
|
||||||
|
let title = if n.title.is_empty() {
|
||||||
|
"(sin título)"
|
||||||
|
} else {
|
||||||
|
n.title.as_str()
|
||||||
|
};
|
||||||
|
// Una barra de tres bloques visualiza la masa (0..1.5 mapeada a
|
||||||
|
// 0..3). Sobre el horizonte se ve llena; cayendo, se vacía.
|
||||||
|
let bars = (mass.clamp(0.0, 1.5) / 0.5).round() as usize;
|
||||||
|
let glyph: String = (0..3)
|
||||||
|
.map(|i| if i < bars { '▮' } else { '▯' })
|
||||||
|
.collect();
|
||||||
|
format!("{glyph} {title}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn editor_panel(
|
||||||
|
model: &Model,
|
||||||
|
input_palette: &TextInputPalette,
|
||||||
|
editor_palette: &EditorPalette,
|
||||||
|
) -> View<Msg> {
|
||||||
|
let none_view = || -> View<Msg> {
|
||||||
|
View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: percent(1.0_f32),
|
||||||
|
},
|
||||||
|
align_items: Some(AlignItems::Center),
|
||||||
|
justify_content: Some(JustifyContent::Center),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(model.theme.bg_panel)
|
||||||
|
.text_aligned(
|
||||||
|
"selecciona o crea una nota".to_string(),
|
||||||
|
12.0,
|
||||||
|
model.theme.fg_muted,
|
||||||
|
Alignment::Center,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
if model.selected.is_none() {
|
||||||
|
return wrap_panel(model, none_view());
|
||||||
|
}
|
||||||
|
|
||||||
|
let metrics = EditorMetrics::for_font_size(13.0);
|
||||||
|
|
||||||
|
let title_field = field(
|
||||||
|
model,
|
||||||
|
"título",
|
||||||
|
text_input_view(
|
||||||
|
&model.title,
|
||||||
|
"(sin título)",
|
||||||
|
model.focus == Focus::Title,
|
||||||
|
input_palette,
|
||||||
|
Msg::Focus(Focus::Title),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let body_input = text_editor_view(
|
||||||
|
&model.body,
|
||||||
|
editor_palette,
|
||||||
|
metrics,
|
||||||
|
EDITOR_VISIBLE_LINES,
|
||||||
|
|ev| Some(Msg::EditorPointer(ev)),
|
||||||
|
);
|
||||||
|
let body_field = body_field_view(model, body_input);
|
||||||
|
|
||||||
|
let tags_field = field(
|
||||||
|
model,
|
||||||
|
"etiquetas (coma separadas)",
|
||||||
|
text_input_view(
|
||||||
|
&model.tags,
|
||||||
|
"p. ej. cocina, jardín",
|
||||||
|
model.focus == Focus::Tags,
|
||||||
|
input_palette,
|
||||||
|
Msg::Focus(Focus::Tags),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let stats = stats_view(model);
|
||||||
|
|
||||||
|
let column = View::new(Style {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: percent(1.0_f32),
|
||||||
|
},
|
||||||
|
padding: Rect {
|
||||||
|
left: length(12.0_f32),
|
||||||
|
right: length(12.0_f32),
|
||||||
|
top: length(10.0_f32),
|
||||||
|
bottom: length(10.0_f32),
|
||||||
|
},
|
||||||
|
gap: Size {
|
||||||
|
width: length(0.0_f32),
|
||||||
|
height: length(8.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(model.theme.bg_panel)
|
||||||
|
.children(vec![title_field, body_field, tags_field, stats]);
|
||||||
|
|
||||||
|
wrap_panel(model, column)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn wrap_panel(_model: &Model, child: View<Msg>) -> View<Msg> {
|
||||||
|
View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: Dimension::auto(),
|
||||||
|
height: percent(1.0_f32),
|
||||||
|
},
|
||||||
|
flex_grow: 1.0,
|
||||||
|
flex_basis: length(0.0_f32),
|
||||||
|
min_size: Size {
|
||||||
|
width: length(0.0_f32),
|
||||||
|
height: length(0.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.children(vec![child])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn field(model: &Model, label: &str, control: View<Msg>) -> View<Msg> {
|
||||||
|
let label_node = View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: length(14.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.text_aligned(
|
||||||
|
label.to_string(),
|
||||||
|
FIELD_LABEL_SIZE,
|
||||||
|
model.theme.fg_muted,
|
||||||
|
Alignment::Start,
|
||||||
|
);
|
||||||
|
|
||||||
|
View::new(Style {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: Dimension::auto(),
|
||||||
|
},
|
||||||
|
flex_shrink: 0.0,
|
||||||
|
gap: Size {
|
||||||
|
width: length(0.0_f32),
|
||||||
|
height: length(2.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.children(vec![label_node, control])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn body_field_view(model: &Model, editor: View<Msg>) -> View<Msg> {
|
||||||
|
let label_node = View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: length(14.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.text_aligned(
|
||||||
|
"cuerpo (wiki-links con [[Título]])".to_string(),
|
||||||
|
FIELD_LABEL_SIZE,
|
||||||
|
model.theme.fg_muted,
|
||||||
|
Alignment::Start,
|
||||||
|
);
|
||||||
|
|
||||||
|
let focused = model.focus == Focus::Body;
|
||||||
|
let border = if focused {
|
||||||
|
model.theme.border_focus
|
||||||
|
} else {
|
||||||
|
model.theme.border
|
||||||
|
};
|
||||||
|
|
||||||
|
let editor_wrap = View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: Dimension::auto(),
|
||||||
|
},
|
||||||
|
flex_grow: 1.0,
|
||||||
|
padding: Rect {
|
||||||
|
left: length(1.0_f32),
|
||||||
|
right: length(1.0_f32),
|
||||||
|
top: length(1.0_f32),
|
||||||
|
bottom: length(1.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.fill(border)
|
||||||
|
.radius(4.0)
|
||||||
|
.on_click(Msg::Focus(Focus::Body))
|
||||||
|
.children(vec![editor]);
|
||||||
|
|
||||||
|
View::new(Style {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: Dimension::auto(),
|
||||||
|
},
|
||||||
|
flex_grow: 1.0,
|
||||||
|
min_size: Size {
|
||||||
|
width: length(0.0_f32),
|
||||||
|
height: length(0.0_f32),
|
||||||
|
},
|
||||||
|
gap: Size {
|
||||||
|
width: length(0.0_f32),
|
||||||
|
height: length(2.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.children(vec![label_node, editor_wrap])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn stats_view(model: &Model) -> View<Msg> {
|
||||||
|
let Some(id) = model.selected else {
|
||||||
|
return View::new(Style::default());
|
||||||
|
};
|
||||||
|
let fwd = model.store.forward_links(id);
|
||||||
|
let back = model.store.backlinks(id);
|
||||||
|
let fwd_titles: Vec<String> = fwd
|
||||||
|
.iter()
|
||||||
|
.filter_map(|i| model.store.get(*i).map(|n| n.title.clone()))
|
||||||
|
.collect();
|
||||||
|
let back_titles: Vec<String> = back
|
||||||
|
.iter()
|
||||||
|
.filter_map(|i| model.store.get(*i).map(|n| n.title.clone()))
|
||||||
|
.collect();
|
||||||
|
let nearest: Vec<String> = model
|
||||||
|
.field
|
||||||
|
.nearest(id, 3)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(nid, score)| {
|
||||||
|
model
|
||||||
|
.store
|
||||||
|
.get(nid)
|
||||||
|
.map(|n| format!("{} ({:.2})", n.title, score))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut lines = vec![
|
||||||
|
format!("→ enlaza a: {}", join_or_dash(&fwd_titles)),
|
||||||
|
format!("← backlinks: {}", join_or_dash(&back_titles)),
|
||||||
|
format!("∼ vecinos: {}", join_or_dash(&nearest)),
|
||||||
|
];
|
||||||
|
// Procedencia: si la nota llegó por compartir, lleva una etiqueta
|
||||||
|
// `de:<autor>`. La mostramos explícita.
|
||||||
|
if let Some(n) = model.store.get(id) {
|
||||||
|
let autores: Vec<&str> = n
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.filter_map(|t| t.strip_prefix("de:"))
|
||||||
|
.collect();
|
||||||
|
if !autores.is_empty() {
|
||||||
|
lines.push(format!("✎ de: {}", autores.join(", ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nodes: Vec<View<Msg>> = lines
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| {
|
||||||
|
View::new(Style {
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: length(16.0_f32),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.text_aligned(s, 11.0, model.theme.fg_muted, Alignment::Start)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
View::new(Style {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
size: Size {
|
||||||
|
width: percent(1.0_f32),
|
||||||
|
height: Dimension::auto(),
|
||||||
|
},
|
||||||
|
flex_shrink: 0.0,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.children(nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn join_or_dash(items: &[String]) -> String {
|
||||||
|
if items.is_empty() {
|
||||||
|
"—".to_string()
|
||||||
|
} else {
|
||||||
|
items.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "khipu-brahman"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
description = "khipu-brahman — transporte de sobres khipu sobre libp2p (BrahmanNet): stream cifrado /khipu/sobre/1.0.0 + descubrimiento por DHT. El sobre se verifica con khipu-share::open; el transporte no necesita ser confiable."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
khipu-share = { path = "../khipu-share" }
|
||||||
|
card-net = { workspace = true }
|
||||||
|
libp2p = { workspace = true }
|
||||||
|
libp2p-stream = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tokio-util = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
agora-core = { workspace = true }
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
//! `khipu-brahman` — transporte de sobres khipu sobre libp2p, vía la capa
|
||||||
|
//! P2P compartida [`card_net::BrahmanNet`] (TCP + Noise + Yamux + Kademlia).
|
||||||
|
//!
|
||||||
|
//! Es el hermano WAN del transporte LAN de `khipu-share::net`: en vez de un
|
||||||
|
//! `TcpStream` directo, abre un stream libp2p sobre el protocolo
|
||||||
|
//! [`SOBRE_PROTOCOL`] y manda el mismo sobre serializado. Como el sobre va
|
||||||
|
//! firmado y direccionado por contenido, **el transporte no necesita ser
|
||||||
|
//! confiable**: quien recibe verifica con [`khipu_share::open`] antes de
|
||||||
|
//! ingerir. El cifrado Noise sólo agrega confidencialidad en tránsito.
|
||||||
|
//!
|
||||||
|
//! El descubrimiento es por DHT Kademlia: [`KhipuNode::anunciar`] publica
|
||||||
|
//! bajo una clave fija y [`KhipuNode::descubrir`] lista a quién la provee —
|
||||||
|
//! rendezvous sin saber la `Multiaddr` de antemano (hace falta al menos un
|
||||||
|
//! peer bootstrap en la tabla, vía [`KhipuNode::add_peer`]). La travesía de
|
||||||
|
//! NAT (relay + dcutr + autonat) **sí** está cableada en `BrahmanNet`
|
||||||
|
//! (`shared/card/card-net`); ver el test `jalar_a_traves_de_un_relay` en
|
||||||
|
//! `tests/p2p_roundtrip.rs`, que jala un sobre a través de un circuito relay.
|
||||||
|
//! Detrás de NAT simétrico todavía conviene un relay público alcanzable.
|
||||||
|
//!
|
||||||
|
//! Marco de cable: `u32` big-endian con el largo del sobre + el sobre
|
||||||
|
//! (postcard). Un sobre por stream — espejo del marco de `khipu-share::net`.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use card_net::{BrahmanNet, Multiaddr, PeerId, Protocol};
|
||||||
|
use futures::StreamExt;
|
||||||
|
use khipu_share::SignedBundle;
|
||||||
|
use libp2p::StreamProtocol;
|
||||||
|
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||||
|
use tokio_util::compat::FuturesAsyncReadCompatExt;
|
||||||
|
|
||||||
|
/// Protocolo de stream libp2p para transferir un sobre khipu.
|
||||||
|
pub const SOBRE_PROTOCOL: StreamProtocol = StreamProtocol::new("/khipu/sobre/1.0.0");
|
||||||
|
|
||||||
|
/// Clave DHT bajo la que los cuadernos khipu se anuncian y se descubren.
|
||||||
|
const KHIPU_DHT_KEY: &[u8] = b"khipu/sobre/1.0.0";
|
||||||
|
|
||||||
|
/// Tope defensivo de un sobre entrante (64 MiB), igual que el transporte LAN.
|
||||||
|
const MAX_SOBRE: u32 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Falla del transporte libp2p de sobres.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum BrahmanError {
|
||||||
|
#[error("nodo libp2p: {0}")]
|
||||||
|
Nodo(String),
|
||||||
|
#[error("abrir stream: {0}")]
|
||||||
|
Stream(String),
|
||||||
|
#[error("marco inválido: {0}")]
|
||||||
|
Marco(String),
|
||||||
|
#[error("io de red: {0}")]
|
||||||
|
Io(String),
|
||||||
|
#[error("sobre ilegible: {0}")]
|
||||||
|
Sobre(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Un cuaderno khipu en la red P2P. Envoltura fina sobre [`BrahmanNet`]
|
||||||
|
/// que habla el protocolo de sobres.
|
||||||
|
pub struct KhipuNode {
|
||||||
|
net: Arc<BrahmanNet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KhipuNode {
|
||||||
|
/// Crea un nodo con su propio [`BrahmanNet`] (keypair efímera). Debe
|
||||||
|
/// llamarse dentro de un runtime de tokio — el swarm corre en una task.
|
||||||
|
pub fn standalone() -> Result<Self, BrahmanError> {
|
||||||
|
let net = BrahmanNet::new().map_err(|e| BrahmanError::Nodo(e.to_string()))?;
|
||||||
|
Ok(Self { net: Arc::new(net) })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Comparte un [`BrahmanNet`] ya existente (p. ej. el mismo nodo que
|
||||||
|
/// usan agora/minga), registrando el protocolo de sobres encima.
|
||||||
|
pub fn sharing(net: Arc<BrahmanNet>) -> Self {
|
||||||
|
Self { net }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identidad de red de este nodo.
|
||||||
|
pub fn peer_id(&self) -> PeerId {
|
||||||
|
self.net.peer_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empieza a escuchar en `addr`; devuelve la dirección efectiva.
|
||||||
|
pub async fn listen(&self, addr: Multiaddr) -> Multiaddr {
|
||||||
|
self.net.listen(addr).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escucha en una multiaddr dada como texto y devuelve la **dirección
|
||||||
|
/// para compartir** ya con `/p2p/<peer-id>` — lista para pegarle a un
|
||||||
|
/// par. Pensada para callers que no quieren tocar tipos de libp2p.
|
||||||
|
pub async fn listen_str(&self, addr: &str) -> Result<String, BrahmanError> {
|
||||||
|
let m: Multiaddr = addr
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| BrahmanError::Nodo(format!("multiaddr inválida: {e}")))?;
|
||||||
|
let bound = self.net.listen(m).await;
|
||||||
|
let s = bound.to_string();
|
||||||
|
// Una reserva de circuito ya viene con `…/p2p-circuit/p2p/<self>`:
|
||||||
|
// no le agregamos otro `/p2p/`. Un tcp pelado no trae peer-id, así
|
||||||
|
// que sí se lo anexamos para que sea una dirección de marcado.
|
||||||
|
if s.contains("/p2p/") {
|
||||||
|
Ok(s)
|
||||||
|
} else {
|
||||||
|
Ok(format!("{s}/p2p/{}", self.net.peer_id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marca a un par para conectarse.
|
||||||
|
pub fn dial(&self, addr: Multiaddr) {
|
||||||
|
self.net.dial(addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marca a un par dado como texto (multiaddr).
|
||||||
|
pub fn dial_str(&self, addr: &str) -> Result<(), BrahmanError> {
|
||||||
|
let m: Multiaddr = addr
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| BrahmanError::Nodo(format!("multiaddr inválida: {e}")))?;
|
||||||
|
self.net.dial(m);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Siembra la tabla DHT con un par conocido (bootstrap para descubrir).
|
||||||
|
pub fn add_peer(&self, peer: PeerId, addr: Multiaddr) {
|
||||||
|
self.net.add_dht_peer(peer, addr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anuncia que este nodo sirve un cuaderno khipu (clave DHT fija).
|
||||||
|
pub fn anunciar(&self) {
|
||||||
|
self.net.start_providing(KHIPU_DHT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Descubre nodos khipu por DHT (sin incluirse a sí mismo).
|
||||||
|
pub async fn descubrir(&self) -> Vec<PeerId> {
|
||||||
|
let mut peers = self.net.find_providers(KHIPU_DHT_KEY).await;
|
||||||
|
let me = self.net.peer_id;
|
||||||
|
peers.retain(|p| *p != me);
|
||||||
|
peers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sirve sobres: por cada stream entrante llama a `supply` y manda lo
|
||||||
|
/// que devuelva (típicamente leer `compartido.khipu`). `None` ⇒ no hay
|
||||||
|
/// sobre, cierra el stream. Bloqueante: corre en su propia task hasta
|
||||||
|
/// el shutdown del nodo.
|
||||||
|
pub fn run_serve<F>(&self, supply: F) -> tokio::task::JoinHandle<()>
|
||||||
|
where
|
||||||
|
F: Fn() -> Option<Vec<u8>> + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let mut control = self.net.control.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut incoming = match control.accept(SOBRE_PROTOCOL) {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(_) => return, // ya hay un accept para este protocolo
|
||||||
|
};
|
||||||
|
let supply = Arc::new(supply);
|
||||||
|
while let Some((_peer, stream)) = incoming.next().await {
|
||||||
|
let supply = Arc::clone(&supply);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Some(bytes) = supply() {
|
||||||
|
let mut compat = stream.compat();
|
||||||
|
let _ = write_frame(&mut compat, &bytes).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jala el sobre de un par dado por su **dirección de marcado** como
|
||||||
|
/// texto (`/ip4/.../tcp/.../p2p/<peer-id>`): extrae el peer-id, dial-ea
|
||||||
|
/// y reintenta el fetch unos segundos mientras se establece la
|
||||||
|
/// conexión. La forma que usa la app.
|
||||||
|
pub async fn fetch_addr_str(&self, addr: &str) -> Result<SignedBundle, BrahmanError> {
|
||||||
|
let m: Multiaddr = addr
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| BrahmanError::Nodo(format!("multiaddr inválida: {e}")))?;
|
||||||
|
let peer = peer_from_multiaddr(&m)
|
||||||
|
.ok_or_else(|| BrahmanError::Nodo("la multiaddr no trae /p2p/<peer-id>".into()))?;
|
||||||
|
self.net.dial(m);
|
||||||
|
let mut intentos = 0u32;
|
||||||
|
loop {
|
||||||
|
match self.fetch(peer).await {
|
||||||
|
Ok(s) => return Ok(s),
|
||||||
|
Err(_) if intentos < 60 => {
|
||||||
|
intentos += 1;
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jala el sobre de un par dado por su **peer-id** como texto (la forma
|
||||||
|
/// que devuelve [`descubrir`](Self::descubrir)). Reintenta mientras el
|
||||||
|
/// swarm establece la conexión usando las direcciones que aprendió por
|
||||||
|
/// la DHT/identify. La app la usa para jalar de un par descubierto.
|
||||||
|
pub async fn fetch_peer_str(&self, peer: &str) -> Result<SignedBundle, BrahmanError> {
|
||||||
|
let pid: PeerId = peer
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| BrahmanError::Nodo(format!("peer-id inválido: {e}")))?;
|
||||||
|
let mut intentos = 0u32;
|
||||||
|
loop {
|
||||||
|
match self.fetch(pid).await {
|
||||||
|
Ok(s) => return Ok(s),
|
||||||
|
Err(_) if intentos < 60 => {
|
||||||
|
intentos += 1;
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abre un stream a `peer` y jala su sobre. **No lo verifica** — el
|
||||||
|
/// caller debe pasarlo por [`khipu_share::open`] antes de confiar.
|
||||||
|
pub async fn fetch(&self, peer: PeerId) -> Result<SignedBundle, BrahmanError> {
|
||||||
|
let mut control = self.net.control.clone();
|
||||||
|
let stream = control
|
||||||
|
.open_stream(peer, SOBRE_PROTOCOL)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrahmanError::Stream(e.to_string()))?;
|
||||||
|
let mut compat = stream.compat();
|
||||||
|
let bytes = read_frame(&mut compat).await?;
|
||||||
|
SignedBundle::from_bytes(&bytes).map_err(|e| BrahmanError::Sobre(format!("{e:?}")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extrae el `PeerId` **destino** de una multiaddr: el ÚLTIMO componente
|
||||||
|
/// `/p2p/<id>`. En una directa (`…/tcp/P/p2p/<peer>`) es el único; en un
|
||||||
|
/// circuito (`…/p2p/<relay>/p2p-circuit/p2p/<destino>`) es el de después
|
||||||
|
/// del relay, no el relay.
|
||||||
|
fn peer_from_multiaddr(addr: &Multiaddr) -> Option<PeerId> {
|
||||||
|
addr.iter()
|
||||||
|
.filter_map(|p| match p {
|
||||||
|
Protocol::P2p(id) => Some(id),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.last()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_frame<S>(stream: &mut S, payload: &[u8]) -> Result<(), BrahmanError>
|
||||||
|
where
|
||||||
|
S: AsyncWrite + Unpin,
|
||||||
|
{
|
||||||
|
if payload.len() as u64 > MAX_SOBRE as u64 {
|
||||||
|
return Err(BrahmanError::Marco("sobre demasiado grande".into()));
|
||||||
|
}
|
||||||
|
stream
|
||||||
|
.write_all(&(payload.len() as u32).to_be_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrahmanError::Io(e.to_string()))?;
|
||||||
|
stream
|
||||||
|
.write_all(payload)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrahmanError::Io(e.to_string()))?;
|
||||||
|
stream.flush().await.map_err(|e| BrahmanError::Io(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_frame<S>(stream: &mut S) -> Result<Vec<u8>, BrahmanError>
|
||||||
|
where
|
||||||
|
S: AsyncRead + Unpin,
|
||||||
|
{
|
||||||
|
let mut len_buf = [0u8; 4];
|
||||||
|
stream
|
||||||
|
.read_exact(&mut len_buf)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrahmanError::Io(e.to_string()))?;
|
||||||
|
let len = u32::from_be_bytes(len_buf);
|
||||||
|
if len > MAX_SOBRE {
|
||||||
|
return Err(BrahmanError::Marco(format!("largo {len} excede el tope")));
|
||||||
|
}
|
||||||
|
let mut buf = vec![0u8; len as usize];
|
||||||
|
stream
|
||||||
|
.read_exact(&mut buf)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BrahmanError::Io(e.to_string()))?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
//! Integración: transferir un sobre khipu entre dos nodos libp2p reales en
|
||||||
|
//! localhost. Espeja el molde de `minga-p2p`/`agora-net-brahman`: un nodo
|
||||||
|
//! escucha y sirve, el otro dial-ea, abre stream y jala; el sobre que llega
|
||||||
|
//! se verifica con `khipu_share::open`.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use agora_core::Keypair;
|
||||||
|
use card_net::Multiaddr;
|
||||||
|
use khipu_brahman::KhipuNode;
|
||||||
|
use khipu_share::{open, seal, SharedNote};
|
||||||
|
|
||||||
|
/// Camino que usa la app: `listen_str` da la dirección para compartir y
|
||||||
|
/// `fetch_addr_str` la consume (dial + reintento + fetch).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn jalar_por_direccion_str_como_la_app() {
|
||||||
|
let autor = Keypair::from_seed([32u8; 32]);
|
||||||
|
let sobre = seal(
|
||||||
|
&autor,
|
||||||
|
vec![SharedNote {
|
||||||
|
title: "via str".into(),
|
||||||
|
body: "listen_str + fetch_addr_str".into(),
|
||||||
|
tags: vec![],
|
||||||
|
}],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bytes = sobre.to_bytes().unwrap();
|
||||||
|
|
||||||
|
let server = KhipuNode::standalone().unwrap();
|
||||||
|
let client = KhipuNode::standalone().unwrap();
|
||||||
|
let dial = server.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
|
||||||
|
let _serve = server.run_serve(move || Some(bytes.clone()));
|
||||||
|
|
||||||
|
// fetch_addr_str ya reintenta internamente mientras se conecta.
|
||||||
|
let recibido = client.fetch_addr_str(&dial).await.expect("fetch por str");
|
||||||
|
let bundle = open(&recibido).expect("verifica tras el viaje");
|
||||||
|
assert_eq!(bundle.notes[0].title, "via str");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Descubrimiento por DHT: A publica y se anuncia bajo la clave khipu en
|
||||||
|
/// la DHT; B —unido a la malla por un rendezvous— la descubre con
|
||||||
|
/// `descubrir()` y le jala el cuaderno por peer-id, sin conocer su
|
||||||
|
/// dirección de antemano.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn descubrir_por_dht_y_jalar() {
|
||||||
|
let autor = Keypair::from_seed([34u8; 32]);
|
||||||
|
let sobre = seal(
|
||||||
|
&autor,
|
||||||
|
vec![SharedNote {
|
||||||
|
title: "via dht".into(),
|
||||||
|
body: "descubierto por la DHT".into(),
|
||||||
|
tags: vec![],
|
||||||
|
}],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bytes = sobre.to_bytes().unwrap();
|
||||||
|
|
||||||
|
// Rendezvous: nodo de la malla al que ambos se conectan.
|
||||||
|
let rendezvous = KhipuNode::standalone().unwrap();
|
||||||
|
let r_addr = rendezvous.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
|
||||||
|
|
||||||
|
// A publica, se une a la malla y se anuncia en la DHT.
|
||||||
|
let a = KhipuNode::standalone().unwrap();
|
||||||
|
let _a_listen = a.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
|
||||||
|
let _serve = a.run_serve(move || Some(bytes.clone()));
|
||||||
|
a.dial_str(&r_addr).unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
a.anunciar();
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
|
||||||
|
// B se une por el rendezvous y descubre a A por DHT.
|
||||||
|
let b = KhipuNode::standalone().unwrap();
|
||||||
|
b.dial_str(&r_addr).unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
let mut peers = Vec::new();
|
||||||
|
for _ in 0..20 {
|
||||||
|
peers = b.descubrir().await;
|
||||||
|
if peers.contains(&a.peer_id()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
}
|
||||||
|
assert!(peers.contains(&a.peer_id()), "B debe descubrir a A por DHT");
|
||||||
|
|
||||||
|
// B jala de A por su peer-id (la dirección la aprendió por la DHT).
|
||||||
|
let recibido = tokio::time::timeout(
|
||||||
|
Duration::from_secs(20),
|
||||||
|
b.fetch_peer_str(&a.peer_id().to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("el fetch por peer-id no debería colgar")
|
||||||
|
.expect("recibir por peer-id descubierto");
|
||||||
|
let bundle = open(&recibido).expect("verifica");
|
||||||
|
assert_eq!(bundle.notes[0].title, "via dht");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// NAT traversal: A reserva un circuito en un relay público y sirve;
|
||||||
|
/// B le jala el cuaderno *a través del relay* (Circuit Relay v2), sin
|
||||||
|
/// dirección directa a A. Verifica la maquinaria relay/dcutr de card-net.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn jalar_a_traves_de_un_relay() {
|
||||||
|
let autor = Keypair::from_seed([33u8; 32]);
|
||||||
|
let sobre = seal(
|
||||||
|
&autor,
|
||||||
|
vec![SharedNote {
|
||||||
|
title: "relay".into(),
|
||||||
|
body: "viajó por un circuito relay".into(),
|
||||||
|
tags: vec![],
|
||||||
|
}],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bytes = sobre.to_bytes().unwrap();
|
||||||
|
|
||||||
|
// Relay público.
|
||||||
|
let relay = KhipuNode::standalone().unwrap();
|
||||||
|
let relay_addr = relay.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
|
||||||
|
|
||||||
|
// A: sirve y reserva un circuito en el relay (su dirección pasa a ser
|
||||||
|
// `…/p2p/<relay>/p2p-circuit/p2p/<A>`).
|
||||||
|
let a = KhipuNode::standalone().unwrap();
|
||||||
|
let _serve = a.run_serve(move || Some(bytes.clone()));
|
||||||
|
// A se conecta al relay; AutoNAT (con el dial-back de A) le confirma al
|
||||||
|
// relay su dirección externa, necesaria para la reserva. Esperamos a
|
||||||
|
// que ese sondeo (boot_delay + round-trip) ocurra antes de reservar.
|
||||||
|
a.dial_str(&relay_addr).unwrap();
|
||||||
|
tokio::time::sleep(Duration::from_secs(6)).await;
|
||||||
|
let circuit = format!("{relay_addr}/p2p-circuit");
|
||||||
|
let a_addr = tokio::time::timeout(Duration::from_secs(15), a.listen_str(&circuit))
|
||||||
|
.await
|
||||||
|
.expect("la reserva del circuito no debería colgar")
|
||||||
|
.unwrap();
|
||||||
|
assert!(a_addr.contains("p2p-circuit"), "A debe anunciarse vía circuito");
|
||||||
|
|
||||||
|
// B jala el cuaderno de A a través del relay.
|
||||||
|
let b = KhipuNode::standalone().unwrap();
|
||||||
|
let recibido = tokio::time::timeout(Duration::from_secs(25), b.fetch_addr_str(&a_addr))
|
||||||
|
.await
|
||||||
|
.expect("el fetch por relay no debería colgar")
|
||||||
|
.expect("recibir vía relay");
|
||||||
|
let bundle = open(&recibido).expect("verifica tras el viaje por relay");
|
||||||
|
assert_eq!(bundle.notes[0].title, "relay");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn jalar_un_sobre_entre_dos_nodos_libp2p() {
|
||||||
|
// Sellar el cuaderno a servir.
|
||||||
|
let autor = Keypair::from_seed([31u8; 32]);
|
||||||
|
let sobre = seal(
|
||||||
|
&autor,
|
||||||
|
vec![SharedNote {
|
||||||
|
title: "P2P".into(),
|
||||||
|
body: "viajó por libp2p".into(),
|
||||||
|
tags: vec!["brahman".into()],
|
||||||
|
}],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bytes = sobre.to_bytes().unwrap();
|
||||||
|
|
||||||
|
// Dos nodos en localhost.
|
||||||
|
let server = KhipuNode::standalone().unwrap();
|
||||||
|
let client = KhipuNode::standalone().unwrap();
|
||||||
|
let server_pid = server.peer_id();
|
||||||
|
|
||||||
|
let addr = server.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
|
||||||
|
let _serve = server.run_serve(move || Some(bytes.clone()));
|
||||||
|
|
||||||
|
// El cliente dial-ea por multiaddr + peer-id.
|
||||||
|
let dial: Multiaddr = format!("{addr}/p2p/{server_pid}").parse().unwrap();
|
||||||
|
client.dial(dial);
|
||||||
|
|
||||||
|
// Reintentar el fetch hasta que la conexión esté lista.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(8);
|
||||||
|
let recibido = loop {
|
||||||
|
match client.fetch(server_pid).await {
|
||||||
|
Ok(s) => break s,
|
||||||
|
Err(_) if Instant::now() < deadline => {
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
Err(e) => panic!("fetch falló: {e}"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Idéntico bit a bit y verificable.
|
||||||
|
assert_eq!(recibido, sobre);
|
||||||
|
let bundle = open(&recibido).expect("firma válida tras el viaje libp2p");
|
||||||
|
assert_eq!(bundle.notes[0].title, "P2P");
|
||||||
|
assert_eq!(bundle.author, autor.public_key());
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "khipu-core"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
description = "khipu-app — núcleo de toma de notas: el modelo Note, el almacén con etiquetas y búsqueda, y el grafo de wiki-links [[...]] con backlinks."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { workspace = true }
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# khipu-core
|
||||||
|
|
||||||
|
> Modelo de nota + store para [khipu](../README.md). Sin UI.
|
||||||
|
|
||||||
|
`Note` lleva contenido, timestamp de creación, timestamp de último acceso, y `mass: f32`. El store es CRUD simple sobre archivos JSON en `$XDG_DATA_HOME/khipu/`. Cada vez que se lee una nota se actualiza su `last_access` (señal que [khipu-gravity](../khipu-gravity/README.md) usa para reforzar).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use khipu_core::{Note, Store};
|
||||||
|
|
||||||
|
let store = Store::open()?;
|
||||||
|
let id = store.create("nota nueva")?;
|
||||||
|
let note = store.read(id)?;
|
||||||
|
store.touch(id)?; // refresca last_access
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deps
|
||||||
|
|
||||||
|
- `serde`, `serde_json`
|
||||||
|
- `directories` para el XDG path
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# khipu-core
|
||||||
|
|
||||||
|
> Note model + store for [khipu](../README.md). No UI.
|
||||||
|
|
||||||
|
`Note` carries content, creation timestamp, last-access timestamp, and `mass: f32`. The store is simple CRUD over JSON files in `$XDG_DATA_HOME/khipu/`. Every read updates `last_access` (the signal [khipu-gravity](../khipu-gravity/README.md) uses to reinforce).
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use khipu_core::{Note, Store};
|
||||||
|
|
||||||
|
let store = Store::open()?;
|
||||||
|
let id = store.create("new note")?;
|
||||||
|
let note = store.read(id)?;
|
||||||
|
store.touch(id)?; // refresh last_access
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deps
|
||||||
|
|
||||||
|
- `serde`, `serde_json`
|
||||||
|
- `directories` for XDG path
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//! `khipu_app-core` — el núcleo agnóstico de la toma de notas.
|
||||||
|
//!
|
||||||
|
//! Una nota es texto con título, etiquetas y enlaces `[[...]]`. El
|
||||||
|
//! [`NoteStore`] las guarda y deriva el grafo: forward-links, backlinks,
|
||||||
|
//! huérfanas y enlaces colgantes. Sin UI, sin storage en disco, sin red
|
||||||
|
//! — tipos puros y deterministas.
|
||||||
|
//!
|
||||||
|
//! - [`note`] — el modelo [`Note`].
|
||||||
|
//! - [`links`] — el parser de wiki-links `[[...]]`.
|
||||||
|
//! - [`store`] — el [`NoteStore`] y el grafo de enlaces.
|
||||||
|
//!
|
||||||
|
//! La gravedad semántica (clustering por afinidad de embeddings) vive en
|
||||||
|
//! `khipu_app-gravity`; las lentes visuales, en los crates de frontend.
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod links;
|
||||||
|
pub mod note;
|
||||||
|
pub mod store;
|
||||||
|
|
||||||
|
pub use links::parse_links;
|
||||||
|
pub use note::{Note, NoteId};
|
||||||
|
pub use store::NoteStore;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
//! Parser de wiki-links — los destinos `[[...]]` dentro de una nota.
|
||||||
|
//!
|
||||||
|
//! Un solo format: dobles corchetes con el título adentro. El texto se
|
||||||
|
//! recorta; los enlaces vacíos se descartan; el orden de aparición se
|
||||||
|
//! conserva y se deduplica (un mismo destino enlazado dos veces cuenta
|
||||||
|
//! una sola vez como arista).
|
||||||
|
|
||||||
|
/// Extrae los destinos `[[...]]` de `text`, recortados y deduplicados,
|
||||||
|
/// en orden de aparición.
|
||||||
|
pub fn parse_links(text: &str) -> Vec<String> {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i + 3 < bytes.len() {
|
||||||
|
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
|
||||||
|
if let Some(close) = find_close(text, i + 2) {
|
||||||
|
let inner = text[i + 2..close].trim();
|
||||||
|
if !inner.is_empty() && !out.iter().any(|l| l == inner) {
|
||||||
|
out.push(inner.to_string());
|
||||||
|
}
|
||||||
|
i = close + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Avanza un carácter UTF-8 completo, no un byte.
|
||||||
|
i += utf8_len(bytes[i]);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posición del `]]` que cierra a partir de `from`, si existe.
|
||||||
|
fn find_close(text: &str, from: usize) -> Option<usize> {
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
let mut i = from;
|
||||||
|
while i + 1 < bytes.len() {
|
||||||
|
if bytes[i] == b']' && bytes[i + 1] == b']' {
|
||||||
|
return Some(i);
|
||||||
|
}
|
||||||
|
// Un `[[` antes del cierre aborta: enlaces anidados no son válidos.
|
||||||
|
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Largo en bytes del carácter UTF-8 que empieza en `b`.
|
||||||
|
fn utf8_len(b: u8) -> usize {
|
||||||
|
match b {
|
||||||
|
0x00..=0x7F => 1,
|
||||||
|
0xC0..=0xDF => 2,
|
||||||
|
0xE0..=0xEF => 3,
|
||||||
|
_ => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_simple_links() {
|
||||||
|
let links = parse_links("ver [[Cocina]] y también [[Jardín]].");
|
||||||
|
assert_eq!(links, vec!["Cocina", "Jardín"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trims_inner_whitespace() {
|
||||||
|
assert_eq!(parse_links("[[ Taller ]]"), vec!["Taller"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_links_are_dropped() {
|
||||||
|
assert_eq!(parse_links("[[]] y [[ ]]"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicates_collapse_to_one() {
|
||||||
|
assert_eq!(parse_links("[[A]] [[B]] [[A]]"), vec!["A", "B"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unclosed_bracket_is_ignored() {
|
||||||
|
assert_eq!(parse_links("texto [[sin cerrar"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_unicode_content_around_links() {
|
||||||
|
let links = parse_links("café ☕ con [[Niños]] — añoño");
|
||||||
|
assert_eq!(links, vec!["Niños"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
//! El modelo `Note` — la unidad de khipu_app.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::links::parse_links;
|
||||||
|
|
||||||
|
/// Identificador de una nota. Lo asigna el almacén, monótono y estable.
|
||||||
|
pub type NoteId = u64;
|
||||||
|
|
||||||
|
/// Una nota: título, cuerpo, etiquetas y marcas de tiempo. Los enlaces
|
||||||
|
/// no se guardan aparte — se derivan del cuerpo bajo demanda.
|
||||||
|
///
|
||||||
|
/// `mass` y `last_access` son la señal temporal: la masa decae con el
|
||||||
|
/// tiempo y `last_access` registra cuándo fue la última lectura. La
|
||||||
|
/// física la aplica el caller (típicamente con `khipu-gravity`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Note {
|
||||||
|
pub id: NoteId,
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
/// Segundo Unix de creación.
|
||||||
|
pub created_at: u64,
|
||||||
|
/// Segundo Unix de la última edición.
|
||||||
|
pub updated_at: u64,
|
||||||
|
/// Segundo Unix del último acceso (lectura). Defecto: `created_at`
|
||||||
|
/// en notas nuevas; en payloads viejos sin el campo, `0` y el
|
||||||
|
/// caller decide cómo arrancar la masa.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_access: u64,
|
||||||
|
/// Masa de la nota. 1.0 al crearse; decae con el tiempo, sube con
|
||||||
|
/// cada acceso. Cuando cae bajo el umbral del horizonte la nota
|
||||||
|
/// pasa al archivo (no se borra). En payloads viejos arranca en 1.0.
|
||||||
|
#[serde(default = "default_mass")]
|
||||||
|
pub mass: f32,
|
||||||
|
/// Posición ancla en el lienzo del mapa, en coordenadas de mundo.
|
||||||
|
/// Se fija **una sola vez** cuando la nota se coloca (por gravedad
|
||||||
|
/// semántica, contra las notas ya asentadas) y se persiste: el mapa
|
||||||
|
/// es estable — un pensamiento conserva su domicilio para que la
|
||||||
|
/// memoria espacial pueda recordarlo. `None` = todavía sin colocar
|
||||||
|
/// (el caller le asigna lugar la primera vez que la pinta). En
|
||||||
|
/// payloads viejos arranca en `None` y se coloca al cargar.
|
||||||
|
#[serde(default)]
|
||||||
|
pub pos: Option<(f32, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_mass() -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Note {
|
||||||
|
/// Destinos `[[...]]` que el cuerpo de la nota referencia.
|
||||||
|
pub fn outgoing_links(&self) -> Vec<String> {
|
||||||
|
parse_links(&self.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` si la nota lleva la etiqueta `tag` (sin distinguir mayúsculas).
|
||||||
|
pub fn has_tag(&self, tag: &str) -> bool {
|
||||||
|
self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` si `query` aparece en el título o el cuerpo (sin distinguir
|
||||||
|
/// mayúsculas).
|
||||||
|
pub fn matches(&self, query: &str) -> bool {
|
||||||
|
let q = query.to_lowercase();
|
||||||
|
self.title.to_lowercase().contains(&q) || self.body.to_lowercase().contains(&q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn note(title: &str, body: &str) -> Note {
|
||||||
|
Note {
|
||||||
|
id: 1,
|
||||||
|
title: title.into(),
|
||||||
|
body: body.into(),
|
||||||
|
tags: vec!["casa".into()],
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
last_access: 0,
|
||||||
|
mass: 1.0,
|
||||||
|
pos: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outgoing_links_reads_the_body() {
|
||||||
|
let n = note("Cocina", "preparar con [[Horno]] y [[Cuchillos]]");
|
||||||
|
assert_eq!(n.outgoing_links(), vec!["Horno", "Cuchillos"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_tag_is_case_insensitive() {
|
||||||
|
let n = note("x", "y");
|
||||||
|
assert!(n.has_tag("CASA"));
|
||||||
|
assert!(!n.has_tag("trabajo"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_searches_title_and_body() {
|
||||||
|
let n = note("Lista de mercado", "comprar pan");
|
||||||
|
assert!(n.matches("MERCADO"));
|
||||||
|
assert!(n.matches("pan"));
|
||||||
|
assert!(!n.matches("ausente"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
//! `NoteStore` — el almacén de notas y su grafo de enlaces.
|
||||||
|
//!
|
||||||
|
//! Guarda las notas en un `BTreeMap` para que toda iteración sea
|
||||||
|
//! determinista (ordenada por id). Los enlaces se resuelven por título
|
||||||
|
//! sin distinguir mayúsculas: `[[cocina]]` y `[[Cocina]]` apuntan a la
|
||||||
|
//! misma nota.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::note::{Note, NoteId};
|
||||||
|
|
||||||
|
/// El almacén de notas de khipu_app.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct NoteStore {
|
||||||
|
notes: BTreeMap<NoteId, Note>,
|
||||||
|
/// Siguiente id a asignar — monótono, nunca reutiliza huecos.
|
||||||
|
next_id: NoteId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NoteStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { notes: BTreeMap::new(), next_id: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crea una nota y devuelve su id. Empieza con `mass = 1.0` y
|
||||||
|
/// `last_access = now` — recién nacida, plenamente visible.
|
||||||
|
pub fn create(
|
||||||
|
&mut self,
|
||||||
|
title: impl Into<String>,
|
||||||
|
body: impl Into<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
now: u64,
|
||||||
|
) -> NoteId {
|
||||||
|
let id = self.next_id;
|
||||||
|
self.next_id += 1;
|
||||||
|
self.notes.insert(
|
||||||
|
id,
|
||||||
|
Note {
|
||||||
|
id,
|
||||||
|
title: title.into(),
|
||||||
|
body: body.into(),
|
||||||
|
tags,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_access: now,
|
||||||
|
mass: 1.0,
|
||||||
|
pos: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.notes.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.notes.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: NoteId) -> Option<&Note> {
|
||||||
|
self.notes.get(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_mut(&mut self, id: NoteId) -> Option<&mut Note> {
|
||||||
|
self.notes.get_mut(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Itera las notas en orden de id (determinista).
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &Note> {
|
||||||
|
self.notes.values()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reemplaza el cuerpo de una nota y actualiza su marca de tiempo.
|
||||||
|
/// También marca `last_access` — editar cuenta como acceso.
|
||||||
|
/// `false` si la nota no existe.
|
||||||
|
pub fn update_body(&mut self, id: NoteId, body: impl Into<String>, now: u64) -> bool {
|
||||||
|
match self.notes.get_mut(&id) {
|
||||||
|
Some(n) => {
|
||||||
|
n.body = body.into();
|
||||||
|
n.updated_at = now;
|
||||||
|
n.last_access = now;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marca `last_access = now`. La señal que `khipu-gravity` usa para
|
||||||
|
/// reforzar la masa. No-op si la nota no existe; `true` si tocó.
|
||||||
|
pub fn touch(&mut self, id: NoteId, now: u64) -> bool {
|
||||||
|
match self.notes.get_mut(&id) {
|
||||||
|
Some(n) => {
|
||||||
|
n.last_access = now;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asigna directamente la masa de una nota. La física vive en
|
||||||
|
/// `khipu-gravity`; el store sólo persiste el resultado.
|
||||||
|
pub fn set_mass(&mut self, id: NoteId, mass: f32) -> bool {
|
||||||
|
match self.notes.get_mut(&id) {
|
||||||
|
Some(n) => {
|
||||||
|
n.mass = mass;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fija la posición ancla de una nota en el lienzo. Se llama una sola
|
||||||
|
/// vez al colocarla; de ahí en más el domicilio del pensamiento no se
|
||||||
|
/// mueve salvo que el usuario lo arrastre. `false` si la nota no existe.
|
||||||
|
pub fn set_pos(&mut self, id: NoteId, x: f32, y: f32) -> bool {
|
||||||
|
match self.notes.get_mut(&id) {
|
||||||
|
Some(n) => {
|
||||||
|
n.pos = Some((x, y));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Elimina una nota y la devuelve.
|
||||||
|
pub fn remove(&mut self, id: NoteId) -> Option<Note> {
|
||||||
|
self.notes.remove(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notas que llevan la etiqueta `tag`, en orden de id.
|
||||||
|
pub fn by_tag(&self, tag: &str) -> Vec<&Note> {
|
||||||
|
self.notes.values().filter(|n| n.has_tag(tag)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notas cuyo título o cuerpo contienen `query`, en orden de id.
|
||||||
|
pub fn search(&self, query: &str) -> Vec<&Note> {
|
||||||
|
self.notes.values().filter(|n| n.matches(query)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ids de las notas cuyo título es `title` (sin distinguir
|
||||||
|
/// mayúsculas). Pueden ser varias: los títulos no son únicos.
|
||||||
|
pub fn resolve_title(&self, title: &str) -> Vec<NoteId> {
|
||||||
|
self.notes
|
||||||
|
.values()
|
||||||
|
.filter(|n| n.title.eq_ignore_ascii_case(title))
|
||||||
|
.map(|n| n.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ids de las notas a las que `id` enlaza por `[[...]]`, resueltas y
|
||||||
|
/// deduplicadas. Un enlace a un título inexistente simplemente no
|
||||||
|
/// aporta ningún id (enlace colgante).
|
||||||
|
pub fn forward_links(&self, id: NoteId) -> Vec<NoteId> {
|
||||||
|
let Some(note) = self.notes.get(&id) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut out: Vec<NoteId> = Vec::new();
|
||||||
|
for title in note.outgoing_links() {
|
||||||
|
for target in self.resolve_title(&title) {
|
||||||
|
if !out.contains(&target) {
|
||||||
|
out.push(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_unstable();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ids de las notas que enlazan hacia `id` (backlinks), en orden de id.
|
||||||
|
pub fn backlinks(&self, id: NoteId) -> Vec<NoteId> {
|
||||||
|
let Some(target) = self.notes.get(&id) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let title = &target.title;
|
||||||
|
self.notes
|
||||||
|
.values()
|
||||||
|
.filter(|n| {
|
||||||
|
n.id != id
|
||||||
|
&& n.outgoing_links()
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.eq_ignore_ascii_case(title))
|
||||||
|
})
|
||||||
|
.map(|n| n.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notas sin ningún backlink — las islas del grafo.
|
||||||
|
pub fn orphans(&self) -> Vec<&Note> {
|
||||||
|
self.notes
|
||||||
|
.values()
|
||||||
|
.filter(|n| self.backlinks(n.id).is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Destinos `[[...]]` que ninguna nota satisface — enlaces colgantes.
|
||||||
|
pub fn dangling_links(&self) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for note in self.notes.values() {
|
||||||
|
for title in note.outgoing_links() {
|
||||||
|
if self.resolve_title(&title).is_empty()
|
||||||
|
&& !out.iter().any(|t| t.eq_ignore_ascii_case(&title))
|
||||||
|
{
|
||||||
|
out.push(title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Almacén con tres notas enlazadas: Índice → Cocina, Índice → Jardín.
|
||||||
|
fn seeded() -> (NoteStore, NoteId, NoteId, NoteId) {
|
||||||
|
let mut s = NoteStore::new();
|
||||||
|
let indice = s.create("Índice", "ver [[Cocina]] y [[Jardín]]", vec!["meta".into()], 100);
|
||||||
|
let cocina = s.create("Cocina", "recetas; vuelve al [[Índice]]", vec!["casa".into()], 100);
|
||||||
|
let jardin = s.create("Jardín", "plantas y riego", vec!["casa".into()], 100);
|
||||||
|
(s, indice, cocina, jardin)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_assigns_monotonic_ids() {
|
||||||
|
let (s, indice, cocina, jardin) = seeded();
|
||||||
|
assert_eq!((indice, cocina, jardin), (1, 2, 3));
|
||||||
|
assert_eq!(s.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forward_links_resolve_by_title() {
|
||||||
|
let (s, indice, cocina, jardin) = seeded();
|
||||||
|
assert_eq!(s.forward_links(indice), vec![cocina, jardin]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backlinks_find_incoming_references() {
|
||||||
|
let (s, indice, cocina, _) = seeded();
|
||||||
|
// Cocina enlaza al Índice → el Índice tiene a Cocina como backlink.
|
||||||
|
assert_eq!(s.backlinks(indice), vec![cocina]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn link_resolution_is_case_insensitive() {
|
||||||
|
let mut s = NoteStore::new();
|
||||||
|
let a = s.create("Taller", "trabajo", vec![], 0);
|
||||||
|
let b = s.create("Notas", "voy al [[taller]]", vec![], 0);
|
||||||
|
assert_eq!(s.forward_links(b), vec![a]);
|
||||||
|
assert_eq!(s.backlinks(a), vec![b]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn by_tag_filters() {
|
||||||
|
let (s, _, cocina, jardin) = seeded();
|
||||||
|
let casa: Vec<_> = s.by_tag("casa").iter().map(|n| n.id).collect();
|
||||||
|
assert_eq!(casa, vec![cocina, jardin]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_scans_title_and_body() {
|
||||||
|
let (s, _, cocina, _) = seeded();
|
||||||
|
let hits: Vec<_> = s.search("recetas").iter().map(|n| n.id).collect();
|
||||||
|
assert_eq!(hits, vec![cocina]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_body_changes_links_and_timestamp() {
|
||||||
|
let (mut s, indice, _, jardin) = seeded();
|
||||||
|
assert!(s.update_body(indice, "ahora sólo [[Jardín]]", 200));
|
||||||
|
assert_eq!(s.forward_links(indice), vec![jardin]);
|
||||||
|
assert_eq!(s.get(indice).unwrap().updated_at, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orphans_have_no_backlinks() {
|
||||||
|
let (s, _, _, jardin) = seeded();
|
||||||
|
// Jardín no recibe enlaces de vuelta... pero el Índice sí lo enlaza.
|
||||||
|
// El único huérfano real sería una nota aislada.
|
||||||
|
let mut s2 = s;
|
||||||
|
let aislada = s2.create("Aislada", "sin conexiones", vec![], 0);
|
||||||
|
let orphan_ids: Vec<_> = s2.orphans().iter().map(|n| n.id).collect();
|
||||||
|
assert!(orphan_ids.contains(&aislada));
|
||||||
|
assert!(!orphan_ids.contains(&jardin));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dangling_links_report_missing_targets() {
|
||||||
|
let mut s = NoteStore::new();
|
||||||
|
s.create("Nota", "apunta a [[Inexistente]]", vec![], 0);
|
||||||
|
assert_eq!(s.dangling_links(), vec!["Inexistente"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_initializes_mass_and_last_access() {
|
||||||
|
let mut s = NoteStore::new();
|
||||||
|
let id = s.create("x", "y", vec![], 1_700_000_000);
|
||||||
|
let n = s.get(id).unwrap();
|
||||||
|
assert_eq!(n.mass, 1.0);
|
||||||
|
assert_eq!(n.last_access, 1_700_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn touch_refreshes_last_access() {
|
||||||
|
let mut s = NoteStore::new();
|
||||||
|
let id = s.create("x", "y", vec![], 100);
|
||||||
|
assert!(s.touch(id, 500));
|
||||||
|
assert_eq!(s.get(id).unwrap().last_access, 500);
|
||||||
|
assert!(!s.touch(9_999, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_body_also_marks_last_access() {
|
||||||
|
let mut s = NoteStore::new();
|
||||||
|
let id = s.create("x", "y", vec![], 100);
|
||||||
|
assert!(s.update_body(id, "z", 700));
|
||||||
|
assert_eq!(s.get(id).unwrap().last_access, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_mass_persists_the_value() {
|
||||||
|
let mut s = NoteStore::new();
|
||||||
|
let id = s.create("x", "y", vec![], 100);
|
||||||
|
assert!(s.set_mass(id, 0.42));
|
||||||
|
assert!((s.get(id).unwrap().mass - 0.42).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_pos_anchors_the_note_once() {
|
||||||
|
let mut s = NoteStore::new();
|
||||||
|
let id = s.create("x", "y", vec![], 100);
|
||||||
|
assert_eq!(s.get(id).unwrap().pos, None);
|
||||||
|
assert!(s.set_pos(id, 12.0, -8.0));
|
||||||
|
assert_eq!(s.get(id).unwrap().pos, Some((12.0, -8.0)));
|
||||||
|
assert!(!s.set_pos(9_999, 0.0, 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_drops_the_note() {
|
||||||
|
let (mut s, indice, ..) = seeded();
|
||||||
|
assert!(s.remove(indice).is_some());
|
||||||
|
assert_eq!(s.len(), 2);
|
||||||
|
assert!(s.get(indice).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "khipu-gravity"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
description = "khipu — dos físicas puras: masa temporal (decay/refuerzo con vida media y horizonte) y gravedad semántica (afinidad coseno, vecinos, clústeres y layout 2D)."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
khipu-core = { path = "../khipu-core" }
|
||||||
|
serde = { workspace = true }
|
||||||
|
# `libm`: la masa decae con `exp` y queremos mantenernos compatibles
|
||||||
|
# con futuros consumidores `no_std` (kernel wawa, WASM bare).
|
||||||
|
libm = { workspace = true }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# khipu-gravity
|
||||||
|
|
||||||
|
> Algoritmo de masa/decay para [khipu](../README.md).
|
||||||
|
|
||||||
|
Pura física de notas. Cada nota tiene `mass: f32`. Cada tick (configurable, default 1h) la masa decae: `mass *= exp(-dt / half_life)`. Cada acceso la refuerza: `mass += boost`. Cuando `mass < umbral`, la nota cae del horizonte visible (no se borra, queda en archivo). El cálculo es puro — no toca el store; el caller decide qué hacer con el resultado.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use khipu_gravity::{Gravity, Params};
|
||||||
|
|
||||||
|
let g = Gravity::new(Params::default());
|
||||||
|
let new_mass = g.decay(mass, dt);
|
||||||
|
let new_mass = g.reinforce(mass, boost);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deps
|
||||||
|
|
||||||
|
- `libm` para `exp` (sin `std::math` cuando se compila a WASM)
|
||||||
|
- Cero deps de I/O o tiempo (el caller pasa `dt`)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# khipu-gravity
|
||||||
|
|
||||||
|
> Mass/decay algorithm for [khipu](../README.md).
|
||||||
|
|
||||||
|
Pure physics of notes. Each note has `mass: f32`. Every tick (configurable, default 1h) mass decays: `mass *= exp(-dt / half_life)`. Each access reinforces it: `mass += boost`. When `mass < threshold`, the note falls off the visible horizon (not deleted, kept in archive). Pure computation — doesn't touch the store; the caller decides what to do with the result.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use khipu_gravity::{Gravity, Params};
|
||||||
|
|
||||||
|
let g = Gravity::new(Params::default());
|
||||||
|
let new_mass = g.decay(mass, dt);
|
||||||
|
let new_mass = g.reinforce(mass, boost);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deps
|
||||||
|
|
||||||
|
- `libm` for `exp` (no `std::math` when compiled to WASM)
|
||||||
|
- Zero I/O or time deps (caller passes `dt`)
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
//! `khipu-gravity` — dos físicas, ambas puras y deterministas.
|
||||||
|
//!
|
||||||
|
//! 1. **Masa temporal** ([`Gravity`] + [`Params`]): cada nota tiene una
|
||||||
|
//! `mass: f32`. Con el tiempo `decay(mass, dt)` la apaga; con cada
|
||||||
|
//! acceso `reinforce(mass, boost)` la enciende. Bajo un umbral la
|
||||||
|
//! nota cae al «archivo» — no se borra, sólo deja el horizonte
|
||||||
|
//! visible. El cálculo no toca el store; el caller decide qué hacer
|
||||||
|
//! con el resultado.
|
||||||
|
//! 2. **Gravedad semántica** ([`SemanticField`]): afinidad coseno entre
|
||||||
|
//! vectores, vecinos, clústeres por umbral y layout 2D dirigido por
|
||||||
|
//! fuerzas. Las notas afines se atraen, todas se repelen.
|
||||||
|
//!
|
||||||
|
//! Las dos están desacopladas — un caller puede usar sólo una. El
|
||||||
|
//! crate compila sin alcanzar `std::f32::exp` (usa [`libm`]) para
|
||||||
|
//! mantenerse apto a futuros consumos `no_std` (kernel wawa, WASM).
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use khipu_core::NoteId;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Parámetros de la física temporal de una nota.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Params {
|
||||||
|
/// Vida media: el `dt` (en segundos) tras el cual una masa se
|
||||||
|
/// reduce a la mitad sin acceso. Default 7 días.
|
||||||
|
pub half_life_secs: f32,
|
||||||
|
/// Incremento de masa por acceso. Default 0.4.
|
||||||
|
pub boost: f32,
|
||||||
|
/// Bajo este valor la nota cae al archivo (no se borra).
|
||||||
|
/// Default 0.10.
|
||||||
|
pub horizon: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Params {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
half_life_secs: 7.0 * 24.0 * 3600.0,
|
||||||
|
boost: 0.4,
|
||||||
|
horizon: 0.10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Física temporal: decay con el tiempo, refuerzo con cada acceso.
|
||||||
|
/// Pura — no toca reloj ni store. El caller pasa `dt` y aplica el
|
||||||
|
/// resultado donde corresponda.
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Gravity {
|
||||||
|
pub params: Params,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Gravity {
|
||||||
|
pub fn new(params: Params) -> Self {
|
||||||
|
Self { params }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Masa decaída tras `dt` segundos sin acceso. `dt` negativo o
|
||||||
|
/// `half_life_secs <= 0` devuelven la masa intacta.
|
||||||
|
pub fn decay(&self, mass: f32, dt_secs: f32) -> f32 {
|
||||||
|
if dt_secs <= 0.0 || self.params.half_life_secs <= 0.0 {
|
||||||
|
return mass.max(0.0);
|
||||||
|
}
|
||||||
|
let k = core::f32::consts::LN_2 / self.params.half_life_secs;
|
||||||
|
let factor = libm::expf(-k * dt_secs);
|
||||||
|
(mass * factor).max(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Masa tras un acceso — suma el `boost` configurado.
|
||||||
|
pub fn reinforce(&self, mass: f32) -> f32 {
|
||||||
|
(mass + self.params.boost).max(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` si la masa está sobre el horizonte (visible). `false`
|
||||||
|
/// significa archivo, no borrada.
|
||||||
|
pub fn is_visible(&self, mass: f32) -> bool {
|
||||||
|
mass >= self.params.horizon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Una nube de notas con su vector semántico — el dominio de la gravedad.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct SemanticField {
|
||||||
|
/// `(id, vector)` en orden de inserción.
|
||||||
|
entries: Vec<(NoteId, Vec<f32>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posición 2D resultante de una nota tras el layout por gravedad.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct NotePlacement {
|
||||||
|
pub id: NoteId,
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parámetros del layout dirigido por fuerzas.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
pub struct GravityConfig {
|
||||||
|
/// Pasos de relajación.
|
||||||
|
pub iterations: usize,
|
||||||
|
/// Fuerza de atracción entre notas afines.
|
||||||
|
pub attraction: f32,
|
||||||
|
/// Fuerza de repulsión entre todo par de notas.
|
||||||
|
pub repulsion: f32,
|
||||||
|
/// Radio del círculo de posiciones iniciales.
|
||||||
|
pub radius: f32,
|
||||||
|
/// Fracción de la fuerza neta que se aplica por paso (amortiguación).
|
||||||
|
pub step: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GravityConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
iterations: 120,
|
||||||
|
attraction: 0.02,
|
||||||
|
repulsion: 800.0,
|
||||||
|
radius: 240.0,
|
||||||
|
step: 0.85,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Similitud coseno de dos vectores. `None` si difieren de largo.
|
||||||
|
fn cosine(a: &[f32], b: &[f32]) -> Option<f32> {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
|
||||||
|
let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||||
|
if na == 0.0 || nb == 0.0 {
|
||||||
|
return Some(0.0);
|
||||||
|
}
|
||||||
|
Some((dot / (na * nb)).clamp(-1.0, 1.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SemanticField {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cantidad de notas en el campo.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.entries.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserta o reemplaza el vector de una nota.
|
||||||
|
pub fn insert(&mut self, id: NoteId, vector: Vec<f32>) {
|
||||||
|
if let Some(slot) = self.entries.iter_mut().find(|(eid, _)| *eid == id) {
|
||||||
|
slot.1 = vector;
|
||||||
|
} else {
|
||||||
|
self.entries.push((id, vector));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saca la nota del campo. No-op si no estaba. `true` si se removió.
|
||||||
|
pub fn remove(&mut self, id: NoteId) -> bool {
|
||||||
|
let before = self.entries.len();
|
||||||
|
self.entries.retain(|(eid, _)| *eid != id);
|
||||||
|
self.entries.len() != before
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Itera `(id, &vector)` en orden de inserción. Útil para serializar
|
||||||
|
/// el campo completo (persistencia, exportar).
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = (NoteId, &[f32])> {
|
||||||
|
self.entries.iter().map(|(id, v)| (*id, v.as_slice()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vector_of(&self, id: NoteId) -> Option<&[f32]> {
|
||||||
|
self.entries
|
||||||
|
.iter()
|
||||||
|
.find(|(eid, _)| *eid == id)
|
||||||
|
.map(|(_, v)| v.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Afinidad (similitud coseno) entre dos notas. `None` si alguna no
|
||||||
|
/// existe o los vectores difieren de largo.
|
||||||
|
pub fn affinity(&self, a: NoteId, b: NoteId) -> Option<f32> {
|
||||||
|
cosine(self.vector_of(a)?, self.vector_of(b)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Las `k` notas más afines a `id`, de mayor a menor afinidad.
|
||||||
|
/// Empata por id ascendente para que el orden sea determinista.
|
||||||
|
pub fn nearest(&self, id: NoteId, k: usize) -> Vec<(NoteId, f32)> {
|
||||||
|
let Some(base) = self.vector_of(id) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut scored: Vec<(NoteId, f32)> = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|(eid, _)| *eid != id)
|
||||||
|
.filter_map(|(eid, v)| cosine(base, v).map(|s| (*eid, s)))
|
||||||
|
.collect();
|
||||||
|
scored.sort_by(|a, b| {
|
||||||
|
b.1.partial_cmp(&a.1)
|
||||||
|
.unwrap_or(core::cmp::Ordering::Equal)
|
||||||
|
.then(a.0.cmp(&b.0))
|
||||||
|
});
|
||||||
|
scored.truncate(k);
|
||||||
|
scored
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agrupa las notas en clústeres: dos notas quedan en el mismo grupo
|
||||||
|
/// si su afinidad alcanza `threshold` (transitivamente). Cada
|
||||||
|
/// clúster viene ordenado por id, y la lista de clústeres también.
|
||||||
|
pub fn clusters(&self, threshold: f32) -> Vec<Vec<NoteId>> {
|
||||||
|
let n = self.entries.len();
|
||||||
|
let mut parent: Vec<usize> = (0..n).collect();
|
||||||
|
|
||||||
|
fn find(parent: &mut [usize], i: usize) -> usize {
|
||||||
|
let mut root = i;
|
||||||
|
while parent[root] != root {
|
||||||
|
root = parent[root];
|
||||||
|
}
|
||||||
|
let mut cur = i;
|
||||||
|
while parent[cur] != root {
|
||||||
|
let next = parent[cur];
|
||||||
|
parent[cur] = root;
|
||||||
|
cur = next;
|
||||||
|
}
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
for j in (i + 1)..n {
|
||||||
|
let sim = cosine(&self.entries[i].1, &self.entries[j].1).unwrap_or(0.0);
|
||||||
|
if sim >= threshold {
|
||||||
|
let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
|
||||||
|
if ri != rj {
|
||||||
|
parent[ri] = rj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut groups: std::collections::BTreeMap<usize, Vec<NoteId>> = Default::default();
|
||||||
|
for i in 0..n {
|
||||||
|
let root = find(&mut parent, i);
|
||||||
|
groups.entry(root).or_default().push(self.entries[i].0);
|
||||||
|
}
|
||||||
|
let mut out: Vec<Vec<NoteId>> = groups.into_values().collect();
|
||||||
|
for c in &mut out {
|
||||||
|
c.sort_unstable();
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.first().cmp(&b.first()));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layout 2D por gravedad: las notas afines se atraen, todas se
|
||||||
|
/// repelen. Determinista — posiciones iniciales en círculo, sin RNG.
|
||||||
|
pub fn gravity_layout(&self, cfg: &GravityConfig) -> Vec<NotePlacement> {
|
||||||
|
let n = self.entries.len();
|
||||||
|
if n == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if n == 1 {
|
||||||
|
return vec![NotePlacement { id: self.entries[0].0, x: 0.0, y: 0.0 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posiciones iniciales repartidas en un círculo.
|
||||||
|
let mut pos: Vec<(f32, f32)> = (0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let a = core::f32::consts::TAU * i as f32 / n as f32;
|
||||||
|
(cfg.radius * a.cos(), cfg.radius * a.sin())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Afinidades precomputadas (no cambian entre pasos).
|
||||||
|
let mut aff = vec![0.0f32; n * n];
|
||||||
|
for i in 0..n {
|
||||||
|
for j in (i + 1)..n {
|
||||||
|
let s = cosine(&self.entries[i].1, &self.entries[j].1)
|
||||||
|
.unwrap_or(0.0)
|
||||||
|
.max(0.0);
|
||||||
|
aff[i * n + j] = s;
|
||||||
|
aff[j * n + i] = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const EPS: f32 = 0.001;
|
||||||
|
for _ in 0..cfg.iterations {
|
||||||
|
let mut force = vec![(0.0f32, 0.0f32); n];
|
||||||
|
for i in 0..n {
|
||||||
|
for j in (i + 1)..n {
|
||||||
|
let dx = pos[j].0 - pos[i].0;
|
||||||
|
let dy = pos[j].1 - pos[i].1;
|
||||||
|
let dist = (dx * dx + dy * dy).sqrt().max(EPS);
|
||||||
|
let (ux, uy) = (dx / dist, dy / dist);
|
||||||
|
// Atracción crece con la distancia y la afinidad;
|
||||||
|
// repulsión cae con el cuadrado de la distancia.
|
||||||
|
let attract = cfg.attraction * aff[i * n + j] * dist;
|
||||||
|
let repel = cfg.repulsion / (dist * dist);
|
||||||
|
let net = attract - repel; // >0 → acercar
|
||||||
|
force[i].0 += net * ux;
|
||||||
|
force[i].1 += net * uy;
|
||||||
|
force[j].0 -= net * ux;
|
||||||
|
force[j].1 -= net * uy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i in 0..n {
|
||||||
|
pos[i].0 += force[i].0 * cfg.step;
|
||||||
|
pos[i].1 += force[i].1 * cfg.step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.entries
|
||||||
|
.iter()
|
||||||
|
.zip(pos)
|
||||||
|
.map(|((id, _), (x, y))| NotePlacement { id: *id, x, y })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Tres vectores: 1 y 2 casi paralelos, 3 ortogonal.
|
||||||
|
fn field() -> SemanticField {
|
||||||
|
let mut f = SemanticField::new();
|
||||||
|
f.insert(1, vec![1.0, 0.0, 0.0]);
|
||||||
|
f.insert(2, vec![0.9, 0.1, 0.0]);
|
||||||
|
f.insert(3, vec![0.0, 0.0, 1.0]);
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn affinity_is_high_for_aligned_vectors() {
|
||||||
|
let f = field();
|
||||||
|
let near = f.affinity(1, 2).unwrap();
|
||||||
|
let far = f.affinity(1, 3).unwrap();
|
||||||
|
assert!(near > 0.95);
|
||||||
|
assert!(far.abs() < 1e-6);
|
||||||
|
assert!(near > far);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn affinity_missing_note_is_none() {
|
||||||
|
assert!(field().affinity(1, 99).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nearest_ranks_by_affinity() {
|
||||||
|
let f = field();
|
||||||
|
let near = f.nearest(1, 2);
|
||||||
|
assert_eq!(near[0].0, 2); // el más afín a 1
|
||||||
|
assert_eq!(near.len(), 2);
|
||||||
|
assert!(near[0].1 > near[1].1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_replaces_existing_vector() {
|
||||||
|
let mut f = SemanticField::new();
|
||||||
|
f.insert(1, vec![1.0, 0.0]);
|
||||||
|
f.insert(1, vec![0.0, 1.0]);
|
||||||
|
assert_eq!(f.len(), 1);
|
||||||
|
assert_eq!(f.vector_of(1), Some([0.0, 1.0].as_slice()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clusters_group_affine_notes() {
|
||||||
|
let f = field();
|
||||||
|
// Umbral alto: 1 y 2 juntos, 3 solo.
|
||||||
|
let cs = f.clusters(0.8);
|
||||||
|
assert_eq!(cs, vec![vec![1, 2], vec![3]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn low_threshold_merges_everything() {
|
||||||
|
let cs = field().clusters(-1.0);
|
||||||
|
assert_eq!(cs, vec![vec![1, 2, 3]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gravity_layout_places_every_note() {
|
||||||
|
let placements = field().gravity_layout(&GravityConfig::default());
|
||||||
|
assert_eq!(placements.len(), 3);
|
||||||
|
let ids: Vec<_> = placements.iter().map(|p| p.id).collect();
|
||||||
|
assert_eq!(ids, vec![1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gravity_pulls_affine_notes_closer() {
|
||||||
|
let f = field();
|
||||||
|
let p = f.gravity_layout(&GravityConfig::default());
|
||||||
|
let dist = |a: NoteId, b: NoteId| {
|
||||||
|
let pa = p.iter().find(|x| x.id == a).unwrap();
|
||||||
|
let pb = p.iter().find(|x| x.id == b).unwrap();
|
||||||
|
((pa.x - pb.x).powi(2) + (pa.y - pb.y).powi(2)).sqrt()
|
||||||
|
};
|
||||||
|
// Las notas afines (1,2) terminan más cerca que las disímiles (1,3).
|
||||||
|
assert!(dist(1, 2) < dist(1, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gravity_layout_is_deterministic() {
|
||||||
|
let f = field();
|
||||||
|
let a = f.gravity_layout(&GravityConfig::default());
|
||||||
|
let b = f.gravity_layout(&GravityConfig::default());
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_drops_a_note() {
|
||||||
|
let mut f = field();
|
||||||
|
assert!(f.remove(2));
|
||||||
|
assert_eq!(f.len(), 2);
|
||||||
|
assert!(!f.remove(99));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iter_returns_vectors_in_insertion_order() {
|
||||||
|
let f = field();
|
||||||
|
let collected: Vec<_> = f.iter().map(|(id, v)| (id, v.to_vec())).collect();
|
||||||
|
assert_eq!(collected.len(), 3);
|
||||||
|
assert_eq!(collected[0].0, 1);
|
||||||
|
assert_eq!(collected[2].0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_and_single_fields_are_handled() {
|
||||||
|
assert!(SemanticField::new().gravity_layout(&GravityConfig::default()).is_empty());
|
||||||
|
let mut one = SemanticField::new();
|
||||||
|
one.insert(7, vec![1.0, 1.0]);
|
||||||
|
let p = one.gravity_layout(&GravityConfig::default());
|
||||||
|
assert_eq!(p, vec![NotePlacement { id: 7, x: 0.0, y: 0.0 }]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod mass_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decay_halves_mass_after_one_half_life() {
|
||||||
|
let g = Gravity::new(Params {
|
||||||
|
half_life_secs: 1000.0,
|
||||||
|
..Params::default()
|
||||||
|
});
|
||||||
|
let after = g.decay(1.0, 1000.0);
|
||||||
|
assert!((after - 0.5).abs() < 1e-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decay_with_zero_dt_is_a_no_op() {
|
||||||
|
let g = Gravity::default();
|
||||||
|
assert!((g.decay(0.7, 0.0) - 0.7).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decay_negative_dt_returns_input() {
|
||||||
|
let g = Gravity::default();
|
||||||
|
assert!((g.decay(0.7, -10.0) - 0.7).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reinforce_adds_boost() {
|
||||||
|
let g = Gravity::new(Params { boost: 0.25, ..Params::default() });
|
||||||
|
assert!((g.reinforce(0.5) - 0.75).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_visible_uses_horizon() {
|
||||||
|
let g = Gravity::new(Params { horizon: 0.1, ..Params::default() });
|
||||||
|
assert!(g.is_visible(0.10));
|
||||||
|
assert!(g.is_visible(0.99));
|
||||||
|
assert!(!g.is_visible(0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_decay_keeps_mass_non_negative() {
|
||||||
|
let g = Gravity::default();
|
||||||
|
assert!(g.decay(1.0, 1_000_000_000.0) >= 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "khipu-share"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
description = "khipu-share — sobres de notas portables, firmados Ed25519 y direccionados por contenido (BLAKE3) sobre agora. La gravedad temporal es local: se comparte el contenido, no la atención."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
khipu-core = { path = "../khipu-core" }
|
||||||
|
agora-core = { workspace = true }
|
||||||
|
agora-keystore = { workspace = true }
|
||||||
|
rand = { workspace = true }
|
||||||
|
blake3 = { workspace = true }
|
||||||
|
postcard = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde-big-array = "0.5"
|
||||||
|
thiserror = { workspace = true }
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
//! Descubrimiento de pares khipu en la LAN por baliza UDP.
|
||||||
|
//!
|
||||||
|
//! Un cuaderno que publica emite cada pocos segundos una [`Beacon`] —su
|
||||||
|
//! clave pública, el puerto TCP donde sirve y un nombre— por broadcast de
|
||||||
|
//! la LAN y por loopback. Quien quiere recibir escucha una ventana corta,
|
||||||
|
//! junta los pares vistos y le jala el cuaderno al primero (con
|
||||||
|
//! [`crate::net::fetch`]), sin tener que saber la IP de antemano.
|
||||||
|
//!
|
||||||
|
//! `std::net` puro, best-effort: redes que bloquean broadcast no
|
||||||
|
//! descubren nada, y ahí sigue valiendo apuntar a un par por dirección
|
||||||
|
//! explícita. El descubrimiento no afloja la seguridad: la baliza sólo
|
||||||
|
//! dice "dónde", el sobre que llega por TCP se verifica igual con
|
||||||
|
//! [`crate::open`].
|
||||||
|
|
||||||
|
use std::net::{Ipv4Addr, SocketAddr, UdpSocket};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Puerto UDP estándar donde se emiten y escuchan las balizas.
|
||||||
|
pub const PUERTO_BALIZA: u16 = 7701;
|
||||||
|
|
||||||
|
/// Prefijo que marca un datagrama como baliza khipu — descarta tráfico
|
||||||
|
/// UDP ajeno antes de intentar parsearlo.
|
||||||
|
const MAGIA: [u8; 4] = *b"KHPU";
|
||||||
|
|
||||||
|
/// Lo que un cuaderno anuncia de sí mismo.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Beacon {
|
||||||
|
/// Clave pública Ed25519 del cuaderno (identidad de quien publica).
|
||||||
|
pub author: [u8; 32],
|
||||||
|
/// Puerto TCP donde ese cuaderno sirve el sobre (para `fetch`).
|
||||||
|
pub port: u16,
|
||||||
|
/// Nombre legible para mostrar en una lista de pares.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Beacon {
|
||||||
|
/// Serializa la baliza al cable: `MAGIA` seguido del postcard.
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
let mut out = MAGIA.to_vec();
|
||||||
|
if let Ok(body) = postcard::to_allocvec(self) {
|
||||||
|
out.extend_from_slice(&body);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsea un datagrama. `None` si no lleva la magia o no decodifica —
|
||||||
|
/// así un paquete UDP cualquiera no nos hace ruido.
|
||||||
|
pub fn decode(bytes: &[u8]) -> Option<Beacon> {
|
||||||
|
let body = bytes.strip_prefix(&MAGIA)?;
|
||||||
|
postcard::from_bytes(body).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Un par visto en la red: dónde jalarle el cuaderno y qué anunció.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PeerVisto {
|
||||||
|
/// Dirección TCP de `fetch`: la IP de origen del datagrama + el
|
||||||
|
/// `port` de la baliza. El que anuncia no necesita conocer su propia
|
||||||
|
/// IP — la deduce quien recibe.
|
||||||
|
pub fetch_addr: SocketAddr,
|
||||||
|
pub beacon: Beacon,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emite la baliza una vez: a broadcast de la LAN y a loopback (para que
|
||||||
|
/// dos cuadernos en la misma máquina se vean). Best-effort — los errores
|
||||||
|
/// de envío de cada destino se ignoran; sólo falla si no se pudo ni armar
|
||||||
|
/// el socket emisor.
|
||||||
|
pub fn anunciar(beacon: &Beacon) -> std::io::Result<()> {
|
||||||
|
let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
|
||||||
|
sock.set_broadcast(true)?;
|
||||||
|
let bytes = beacon.encode();
|
||||||
|
let _ = sock.send_to(&bytes, (Ipv4Addr::BROADCAST, PUERTO_BALIZA));
|
||||||
|
let _ = sock.send_to(&bytes, (Ipv4Addr::LOCALHOST, PUERTO_BALIZA));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escucha balizas en un socket ya bindeado durante `ventana`, devolviendo
|
||||||
|
/// los pares vistos (deduplicados por dirección de fetch). Bloqueante.
|
||||||
|
/// Separar el socket del bind permite testear sobre un puerto efímero.
|
||||||
|
pub fn escuchar_en(sock: &UdpSocket, ventana: Duration) -> Vec<PeerVisto> {
|
||||||
|
let _ = sock.set_read_timeout(Some(Duration::from_millis(150)));
|
||||||
|
let fin = Instant::now() + ventana;
|
||||||
|
let mut vistos: Vec<PeerVisto> = Vec::new();
|
||||||
|
let mut buf = [0u8; 2048];
|
||||||
|
while Instant::now() < fin {
|
||||||
|
match sock.recv_from(&mut buf) {
|
||||||
|
Ok((n, origen)) => {
|
||||||
|
if let Some(beacon) = Beacon::decode(&buf[..n]) {
|
||||||
|
let fetch_addr = SocketAddr::new(origen.ip(), beacon.port);
|
||||||
|
if !vistos.iter().any(|p| p.fetch_addr == fetch_addr) {
|
||||||
|
vistos.push(PeerVisto { fetch_addr, beacon });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e)
|
||||||
|
if matches!(
|
||||||
|
e.kind(),
|
||||||
|
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vistos
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bindea el socket de escucha en el puerto estándar de balizas.
|
||||||
|
pub fn bind_balizas() -> std::io::Result<UdpSocket> {
|
||||||
|
UdpSocket::bind((Ipv4Addr::UNSPECIFIED, PUERTO_BALIZA))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Conveniencia: bindea el puerto estándar y escucha `ventana`.
|
||||||
|
pub fn descubrir(ventana: Duration) -> std::io::Result<Vec<PeerVisto>> {
|
||||||
|
Ok(escuchar_en(&bind_balizas()?, ventana))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn beacon() -> Beacon {
|
||||||
|
Beacon {
|
||||||
|
author: [9u8; 32],
|
||||||
|
port: 7700,
|
||||||
|
name: "khipu de prueba".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_decode_roundtrips() {
|
||||||
|
let b = beacon();
|
||||||
|
assert_eq!(Beacon::decode(&b.encode()), Some(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rejects_non_beacon_traffic() {
|
||||||
|
assert_eq!(Beacon::decode(b"hola mundo"), None);
|
||||||
|
assert_eq!(Beacon::decode(b"KHP"), None); // magia truncada
|
||||||
|
assert_eq!(Beacon::decode(&[]), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escuchar_recibe_una_baliza_por_loopback() {
|
||||||
|
// Escucha en un puerto efímero (no el estándar, para no chocar con
|
||||||
|
// otros tests ni con un khipu corriendo).
|
||||||
|
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||||
|
let destino = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let b = beacon();
|
||||||
|
let emisor = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
|
||||||
|
emisor.send_to(&b.encode(), destino).unwrap();
|
||||||
|
|
||||||
|
let vistos = escuchar_en(&listener, Duration::from_millis(500));
|
||||||
|
assert_eq!(vistos.len(), 1);
|
||||||
|
// La dirección de fetch combina la IP de origen con el port anunciado.
|
||||||
|
assert_eq!(vistos[0].fetch_addr.ip(), Ipv4Addr::LOCALHOST);
|
||||||
|
assert_eq!(vistos[0].fetch_addr.port(), b.port);
|
||||||
|
assert_eq!(vistos[0].beacon, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escuchar_deduplica_balizas_repetidas() {
|
||||||
|
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||||
|
let destino = listener.local_addr().unwrap();
|
||||||
|
|
||||||
|
let b = beacon();
|
||||||
|
let emisor = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
|
||||||
|
// Tres balizas idénticas: un solo par visto.
|
||||||
|
for _ in 0..3 {
|
||||||
|
emisor.send_to(&b.encode(), destino).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let vistos = escuchar_en(&listener, Duration::from_millis(400));
|
||||||
|
assert_eq!(vistos.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! Identidad del cuaderno, guardada cifrada con [`agora_keystore`].
|
||||||
|
//!
|
||||||
|
//! La clave privada (la semilla Ed25519 de 32 bytes) nunca queda en claro
|
||||||
|
//! en disco: vive cifrada bajo una passphrase (Argon2id + ChaCha20-Poly1305).
|
||||||
|
//! [`unlock`] la descifra a pedido. Quien tenga acceso de lectura al disco
|
||||||
|
//! no obtiene la identidad sin la passphrase — el endurecimiento frente al
|
||||||
|
//! `identidad.seed` en claro de las primeras versiones.
|
||||||
|
//!
|
||||||
|
//! Migración: si encuentra una semilla legacy en claro, la cifra dentro
|
||||||
|
//! del keystore y borra el archivo en claro, conservando la identidad.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use agora_core::Keypair;
|
||||||
|
use agora_keystore::Keystore;
|
||||||
|
use rand::RngCore;
|
||||||
|
|
||||||
|
/// Falla al desbloquear o crear la identidad.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum IdentityError {
|
||||||
|
#[error("keystore: {0}")]
|
||||||
|
Keystore(String),
|
||||||
|
#[error("semilla legacy inválida (no son 32 bytes)")]
|
||||||
|
SemillaLegacy,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Desbloquea —o crea— la identidad del cuaderno guardada cifrada en
|
||||||
|
/// `keys_dir`, descifrándola con `passphrase`. Orden de resolución:
|
||||||
|
///
|
||||||
|
/// 1. Si el keystore ya tiene una identidad → la descifra. Passphrase
|
||||||
|
/// incorrecta ⇒ [`IdentityError::Keystore`] (no se distingue de otros
|
||||||
|
/// fallos del keystore a propósito: no filtramos si existe o no).
|
||||||
|
/// 2. Si hay una semilla legacy en claro en `legacy_seed` → la **migra**:
|
||||||
|
/// la cifra en el keystore con `passphrase`, borra el archivo en claro
|
||||||
|
/// y devuelve esa identidad.
|
||||||
|
/// 3. Si no hay nada → genera una identidad nueva (semilla de `OsRng`),
|
||||||
|
/// la guarda cifrada y la devuelve.
|
||||||
|
pub fn unlock(
|
||||||
|
keys_dir: &Path,
|
||||||
|
legacy_seed: Option<&Path>,
|
||||||
|
passphrase: &str,
|
||||||
|
) -> Result<Keypair, IdentityError> {
|
||||||
|
let ks = Keystore::open(keys_dir).map_err(|e| IdentityError::Keystore(e.to_string()))?;
|
||||||
|
let ids = ks.list().map_err(|e| IdentityError::Keystore(e.to_string()))?;
|
||||||
|
|
||||||
|
// 1. Identidad ya guardada: descifrar.
|
||||||
|
if let Some(id) = ids.first().copied() {
|
||||||
|
let seed = ks
|
||||||
|
.load(id, passphrase)
|
||||||
|
.map_err(|e| IdentityError::Keystore(e.to_string()))?;
|
||||||
|
return Ok(Keypair::from_seed(seed));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Migrar una semilla legacy en claro, si la hay.
|
||||||
|
if let Some(path) = legacy_seed {
|
||||||
|
if let Ok(bytes) = std::fs::read(path) {
|
||||||
|
let seed =
|
||||||
|
<[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| IdentityError::SemillaLegacy)?;
|
||||||
|
let kp = Keypair::from_seed(seed);
|
||||||
|
ks.save(kp.identity_id(), &seed, passphrase)
|
||||||
|
.map_err(|e| IdentityError::Keystore(e.to_string()))?;
|
||||||
|
let _ = std::fs::remove_file(path); // ya está cifrada; fuera el claro
|
||||||
|
return Ok(kp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Identidad nueva.
|
||||||
|
let mut seed = [0u8; 32];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut seed);
|
||||||
|
let kp = Keypair::from_seed(seed);
|
||||||
|
ks.save(kp.identity_id(), &seed, passphrase)
|
||||||
|
.map_err(|e| IdentityError::Keystore(e.to_string()))?;
|
||||||
|
Ok(kp)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Directorio temporal único por nombre de test (evita tempfile como
|
||||||
|
/// dependencia y las colisiones entre tests del mismo proceso).
|
||||||
|
fn temp_dir(etiqueta: &str) -> std::path::PathBuf {
|
||||||
|
let d = std::env::temp_dir().join(format!("khipu-id-{}-{}", std::process::id(), etiqueta));
|
||||||
|
let _ = std::fs::remove_dir_all(&d);
|
||||||
|
d
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn crear_y_desbloquear_da_la_misma_identidad() {
|
||||||
|
let dir = temp_dir("crear");
|
||||||
|
let a = unlock(&dir, None, "secreta").unwrap();
|
||||||
|
let b = unlock(&dir, None, "secreta").unwrap();
|
||||||
|
assert_eq!(a.public_key(), b.public_key());
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passphrase_incorrecta_falla() {
|
||||||
|
let dir = temp_dir("malpass");
|
||||||
|
let _ = unlock(&dir, None, "correcta").unwrap();
|
||||||
|
assert!(unlock(&dir, None, "incorrecta").is_err());
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migra_semilla_legacy_en_claro() {
|
||||||
|
let base = temp_dir("migra");
|
||||||
|
std::fs::create_dir_all(&base).unwrap();
|
||||||
|
let legacy = base.join("identidad.seed");
|
||||||
|
let semilla = [7u8; 32];
|
||||||
|
std::fs::write(&legacy, semilla).unwrap();
|
||||||
|
|
||||||
|
let keys = base.join("keys");
|
||||||
|
let kp = unlock(&keys, Some(&legacy), "secreta").unwrap();
|
||||||
|
// Conserva la identidad de la semilla legacy.
|
||||||
|
assert_eq!(kp.public_key(), Keypair::from_seed(semilla).public_key());
|
||||||
|
// El archivo en claro fue borrado.
|
||||||
|
assert!(!legacy.exists());
|
||||||
|
// Y ahora se descifra del keystore, sin legacy.
|
||||||
|
let kp2 = unlock(&keys, None, "secreta").unwrap();
|
||||||
|
assert_eq!(kp2.public_key(), kp.public_key());
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
//! `khipu-share` — compartir notas por la red soberana de gioser sin
|
||||||
|
//! perder su gravedad local.
|
||||||
|
//!
|
||||||
|
//! Lo que viaja es el **contenido** de la nota (título, cuerpo, etiquetas),
|
||||||
|
//! nunca su física temporal: la masa y el último acceso son la atención
|
||||||
|
//! *de quien tiene la nota*, no una propiedad transferible. Al importar,
|
||||||
|
//! cada nota nace fresca (`mass = 1.0`, `last_access = now`) — su gravedad
|
||||||
|
//! arranca en el cuaderno que la recibe.
|
||||||
|
//!
|
||||||
|
//! El sobre es:
|
||||||
|
//! - **direccionado por contenido**: su identidad es `BLAKE3(postcard(bundle))`,
|
||||||
|
//! así dos sobres con las mismas notas tienen el mismo hash;
|
||||||
|
//! - **firmado Ed25519** sobre ese hash con la clave del autor, vía
|
||||||
|
//! [`agora_core`] — verificable sin autoridad central ni red.
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use agora_core::Keypair;
|
||||||
|
//! use khipu_share::{seal, open, SharedNote};
|
||||||
|
//!
|
||||||
|
//! let kp = Keypair::from_seed([7u8; 32]);
|
||||||
|
//! let notas = vec![SharedNote {
|
||||||
|
//! title: "Receta".into(),
|
||||||
|
//! body: "sopa; ver [[Mercado]]".into(),
|
||||||
|
//! tags: vec!["cocina".into()],
|
||||||
|
//! }];
|
||||||
|
//! let sobre = seal(&kp, notas, 1_700_000_000).unwrap();
|
||||||
|
//!
|
||||||
|
//! // El receptor verifica firma + hash antes de confiar en el contenido.
|
||||||
|
//! let bundle = open(&sobre).unwrap();
|
||||||
|
//! assert_eq!(bundle.notes[0].title, "Receta");
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
pub mod discovery;
|
||||||
|
pub mod identity;
|
||||||
|
pub mod net;
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use agora_core::{verify_signature, Keypair};
|
||||||
|
use khipu_core::{Note, NoteId, NoteStore};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_big_array::BigArray;
|
||||||
|
|
||||||
|
/// El contenido transferible de una nota — sin id, masa ni timestamps.
|
||||||
|
/// La gravedad temporal se queda en el cuaderno de origen.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SharedNote {
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedNote {
|
||||||
|
/// Proyecta una [`Note`] local a su contenido compartible, descartando
|
||||||
|
/// id, masa y marcas de tiempo.
|
||||||
|
pub fn from_note(n: &Note) -> Self {
|
||||||
|
Self {
|
||||||
|
title: n.title.clone(),
|
||||||
|
body: n.body.clone(),
|
||||||
|
tags: n.tags.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// El cuerpo firmable: el autor, cuándo se selló, y las notas. Es lo que
|
||||||
|
/// se serializa y se hashea — el orden de las notas y las etiquetas es
|
||||||
|
/// significativo para que el hash sea estable.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Bundle {
|
||||||
|
/// Clave pública Ed25519 del autor (32 bytes).
|
||||||
|
pub author: [u8; 32],
|
||||||
|
/// Segundo Unix en que se selló el sobre.
|
||||||
|
pub created_at: u64,
|
||||||
|
pub notes: Vec<SharedNote>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Un [`Bundle`] con la firma del autor sobre su hash de contenido.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SignedBundle {
|
||||||
|
pub bundle: Bundle,
|
||||||
|
/// Firma Ed25519 sobre [`Bundle::content_hash`].
|
||||||
|
#[serde(with = "BigArray")]
|
||||||
|
pub signature: [u8; 64],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bundle {
|
||||||
|
/// La dirección de contenido del sobre: `BLAKE3(postcard(self))`. Es
|
||||||
|
/// también el mensaje que el autor firma.
|
||||||
|
pub fn content_hash(&self) -> Result<[u8; 32], ShareError> {
|
||||||
|
let bytes = postcard::to_allocvec(self).map_err(|_| ShareError::Serializacion)?;
|
||||||
|
Ok(*blake3::hash(&bytes).as_bytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignedBundle {
|
||||||
|
/// Serializa el sobre a bytes (postcard) para escribirlo a disco o
|
||||||
|
/// mandarlo por cualquier canal.
|
||||||
|
pub fn to_bytes(&self) -> Result<Vec<u8>, ShareError> {
|
||||||
|
postcard::to_allocvec(self).map_err(|_| ShareError::Serializacion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconstruye un sobre desde bytes. No verifica la firma — para eso
|
||||||
|
/// está [`open`].
|
||||||
|
pub fn from_bytes(bytes: &[u8]) -> Result<Self, ShareError> {
|
||||||
|
postcard::from_bytes(bytes).map_err(|_| ShareError::Serializacion)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// La dirección de contenido del sobre (delegada a [`Bundle::content_hash`]).
|
||||||
|
pub fn content_address(&self) -> Result<[u8; 32], ShareError> {
|
||||||
|
self.bundle.content_hash()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sella las notas en un sobre firmado por `kp`.
|
||||||
|
pub fn seal(
|
||||||
|
kp: &Keypair,
|
||||||
|
notes: Vec<SharedNote>,
|
||||||
|
created_at: u64,
|
||||||
|
) -> Result<SignedBundle, ShareError> {
|
||||||
|
let bundle = Bundle {
|
||||||
|
author: kp.public_key(),
|
||||||
|
created_at,
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
let hash = bundle.content_hash()?;
|
||||||
|
let signature = kp.sign(&hash);
|
||||||
|
Ok(SignedBundle { bundle, signature })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abre un sobre: recomputa su hash de contenido y verifica que la firma
|
||||||
|
/// corresponda a la clave del autor declarado. Devuelve el [`Bundle`]
|
||||||
|
/// verificado, o un error si el contenido fue alterado o la firma no
|
||||||
|
/// corresponde. Verificación offline — sin autoridad central.
|
||||||
|
pub fn open(signed: &SignedBundle) -> Result<&Bundle, ShareError> {
|
||||||
|
let hash = signed.bundle.content_hash()?;
|
||||||
|
verify_signature(&signed.bundle.author, &hash, &signed.signature)
|
||||||
|
.map_err(|_| ShareError::FirmaInvalida)?;
|
||||||
|
Ok(&signed.bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resultado de ingerir un sobre en un cuaderno.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ImportOutcome {
|
||||||
|
/// Ids de las notas efectivamente creadas.
|
||||||
|
pub created: Vec<NoteId>,
|
||||||
|
/// Notas omitidas por título ya presente (la importación repetida es
|
||||||
|
/// inofensiva — no duplica).
|
||||||
|
pub skipped: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserta las notas de un `bundle` en `store` como notas nuevas: id e
|
||||||
|
/// historia local frescos, `mass = 1.0`, `last_access = now`. La gravedad
|
||||||
|
/// arranca en el cuaderno receptor.
|
||||||
|
///
|
||||||
|
/// Las notas cuyo título (sin distinguir mayúsculas) ya existe en el
|
||||||
|
/// cuaderno se omiten, de modo que importar dos veces el mismo sobre no
|
||||||
|
/// duplica. Los wiki-links `[[Título]]` sobreviven solos: khipu resuelve
|
||||||
|
/// enlaces por título, así que si las notas enlazadas también se importan,
|
||||||
|
/// el grafo se rearma sin remapear ids.
|
||||||
|
///
|
||||||
|
/// Cada nota importada recibe una etiqueta de procedencia [`tag_de`]
|
||||||
|
/// (`de:<hex8 del autor>`) — así queda asentado de quién vino sin tocar
|
||||||
|
/// el modelo `Note` ni la persistencia. Es una etiqueta normal: visible,
|
||||||
|
/// buscable y removible.
|
||||||
|
pub fn import_into(store: &mut NoteStore, bundle: &Bundle, now: u64) -> ImportOutcome {
|
||||||
|
let mut existing: HashSet<String> =
|
||||||
|
store.iter().map(|n| n.title.to_lowercase()).collect();
|
||||||
|
let marca = tag_de(&bundle.author);
|
||||||
|
let mut out = ImportOutcome::default();
|
||||||
|
for sn in &bundle.notes {
|
||||||
|
let key = sn.title.to_lowercase();
|
||||||
|
// Sólo deduplicamos por títulos no vacíos: dos "(sin título)" no
|
||||||
|
// son la misma nota.
|
||||||
|
if !sn.title.is_empty() && existing.contains(&key) {
|
||||||
|
out.skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut tags = sn.tags.clone();
|
||||||
|
if !tags.iter().any(|t| t == &marca) {
|
||||||
|
tags.push(marca.clone());
|
||||||
|
}
|
||||||
|
let id = store.create(sn.title.clone(), sn.body.clone(), tags, now);
|
||||||
|
existing.insert(key);
|
||||||
|
out.created.push(id);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefijo hex (4 bytes / 8 hex) de una clave o hash — identifica un
|
||||||
|
/// autor de forma legible sin volcar los 32 bytes.
|
||||||
|
pub fn hex8(bytes: &[u8; 32]) -> String {
|
||||||
|
bytes[..4].iter().map(|b| format!("{b:02x}")).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// La etiqueta de procedencia para un autor: `de:<hex8>`.
|
||||||
|
pub fn tag_de(author: &[u8; 32]) -> String {
|
||||||
|
format!("de:{}", hex8(author))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Falla al sellar, abrir o transportar un sobre.
|
||||||
|
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||||
|
pub enum ShareError {
|
||||||
|
#[error("no se pudo (de)serializar el sobre")]
|
||||||
|
Serializacion,
|
||||||
|
#[error("la firma no corresponde al contenido y la clave del autor")]
|
||||||
|
FirmaInvalida,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn nota(title: &str, body: &str, tags: &[&str]) -> SharedNote {
|
||||||
|
SharedNote {
|
||||||
|
title: title.into(),
|
||||||
|
body: body.into(),
|
||||||
|
tags: tags.iter().map(|s| s.to_string()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seal_then_open_roundtrips() {
|
||||||
|
let kp = Keypair::from_seed([1u8; 32]);
|
||||||
|
let notes = vec![nota("A", "cuerpo a", &["x"]), nota("B", "[[A]]", &[])];
|
||||||
|
let sobre = seal(&kp, notes.clone(), 42).unwrap();
|
||||||
|
let bundle = open(&sobre).unwrap();
|
||||||
|
assert_eq!(bundle.author, kp.public_key());
|
||||||
|
assert_eq!(bundle.created_at, 42);
|
||||||
|
assert_eq!(bundle.notes, notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn content_hash_is_stable_and_content_addressed() {
|
||||||
|
let kp = Keypair::from_seed([2u8; 32]);
|
||||||
|
let notes = vec![nota("A", "a", &["t"])];
|
||||||
|
let a = seal(&kp, notes.clone(), 100).unwrap();
|
||||||
|
let b = seal(&kp, notes, 100).unwrap();
|
||||||
|
// Mismas notas + mismo autor + mismo instante → mismo hash y firma.
|
||||||
|
assert_eq!(a.content_address().unwrap(), b.content_address().unwrap());
|
||||||
|
assert_eq!(a.signature, b.signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampering_with_content_is_rejected() {
|
||||||
|
let kp = Keypair::from_seed([3u8; 32]);
|
||||||
|
let mut sobre = seal(&kp, vec![nota("real", "intacto", &[])], 1).unwrap();
|
||||||
|
// Alteramos el cuerpo sin rehacer la firma.
|
||||||
|
sobre.bundle.notes[0].body = "manipulado".into();
|
||||||
|
assert_eq!(open(&sobre), Err(ShareError::FirmaInvalida));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forged_author_is_rejected() {
|
||||||
|
let autor = Keypair::from_seed([4u8; 32]);
|
||||||
|
let impostor = Keypair::from_seed([5u8; 32]);
|
||||||
|
let mut sobre = seal(&autor, vec![nota("n", "c", &[])], 1).unwrap();
|
||||||
|
// El impostor pone su clave pero no puede producir la firma válida.
|
||||||
|
sobre.bundle.author = impostor.public_key();
|
||||||
|
assert_eq!(open(&sobre), Err(ShareError::FirmaInvalida));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bytes_roundtrip_preserves_verification() {
|
||||||
|
let kp = Keypair::from_seed([6u8; 32]);
|
||||||
|
let sobre = seal(&kp, vec![nota("n", "c", &["a", "b"])], 9).unwrap();
|
||||||
|
let bytes = sobre.to_bytes().unwrap();
|
||||||
|
let recuperado = SignedBundle::from_bytes(&bytes).unwrap();
|
||||||
|
assert_eq!(recuperado, sobre);
|
||||||
|
assert!(open(&recuperado).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_creates_fresh_notes_with_local_gravity() {
|
||||||
|
let kp = Keypair::from_seed([7u8; 32]);
|
||||||
|
let sobre = seal(&kp, vec![nota("Receta", "sopa", &["cocina"])], 1).unwrap();
|
||||||
|
let bundle = open(&sobre).unwrap();
|
||||||
|
|
||||||
|
let mut store = NoteStore::new();
|
||||||
|
let out = import_into(&mut store, bundle, 5_000);
|
||||||
|
assert_eq!(out.created.len(), 1);
|
||||||
|
assert_eq!(out.skipped, 0);
|
||||||
|
|
||||||
|
let n = store.get(out.created[0]).unwrap();
|
||||||
|
assert_eq!(n.title, "Receta");
|
||||||
|
// Conserva sus etiquetas y suma la de procedencia.
|
||||||
|
assert!(n.tags.contains(&"cocina".to_string()));
|
||||||
|
assert!(n.tags.iter().any(|t| t.starts_with("de:")));
|
||||||
|
// Gravedad fresca: masa plena y acceso = ahora del receptor.
|
||||||
|
assert_eq!(n.mass, 1.0);
|
||||||
|
assert_eq!(n.last_access, 5_000);
|
||||||
|
assert_eq!(n.created_at, 5_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_marks_author_provenance() {
|
||||||
|
let kp = Keypair::from_seed([13u8; 32]);
|
||||||
|
let sobre = seal(&kp, vec![nota("N", "c", &[])], 1).unwrap();
|
||||||
|
let bundle = open(&sobre).unwrap();
|
||||||
|
let mut store = NoteStore::new();
|
||||||
|
let out = import_into(&mut store, bundle, 1);
|
||||||
|
let n = store.get(out.created[0]).unwrap();
|
||||||
|
assert!(n.tags.contains(&tag_de(&kp.public_key())));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reimport_skips_existing_titles() {
|
||||||
|
let kp = Keypair::from_seed([8u8; 32]);
|
||||||
|
let sobre = seal(
|
||||||
|
&kp,
|
||||||
|
vec![nota("A", "a", &[]), nota("B", "b", &[])],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bundle = open(&sobre).unwrap();
|
||||||
|
|
||||||
|
let mut store = NoteStore::new();
|
||||||
|
let first = import_into(&mut store, bundle, 10);
|
||||||
|
assert_eq!(first.created.len(), 2);
|
||||||
|
|
||||||
|
// Segunda importación del mismo sobre: nada nuevo.
|
||||||
|
let second = import_into(&mut store, bundle, 20);
|
||||||
|
assert_eq!(second.created.len(), 0);
|
||||||
|
assert_eq!(second.skipped, 2);
|
||||||
|
assert_eq!(store.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wiki_links_survive_import_by_title() {
|
||||||
|
let kp = Keypair::from_seed([9u8; 32]);
|
||||||
|
let sobre = seal(
|
||||||
|
&kp,
|
||||||
|
vec![
|
||||||
|
nota("Índice", "ver [[Receta]]", &[]),
|
||||||
|
nota("Receta", "sopa", &[]),
|
||||||
|
],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bundle = open(&sobre).unwrap();
|
||||||
|
|
||||||
|
let mut store = NoteStore::new();
|
||||||
|
let out = import_into(&mut store, bundle, 1);
|
||||||
|
let indice = out.created[0];
|
||||||
|
// El enlace [[Receta]] resuelve a la otra nota importada.
|
||||||
|
assert_eq!(store.forward_links(indice).len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! Transporte LAN mínimo para sobres khipu sobre TCP.
|
||||||
|
//!
|
||||||
|
//! El sobre ya es firmado y direccionado por contenido, así que el
|
||||||
|
//! transporte no necesita ser confiable: quien recibe verifica con
|
||||||
|
//! [`crate::open`] antes de creer nada. Esto es `std::net` puro — sin
|
||||||
|
//! libp2p ni async — pensado para "jalar el cuaderno de un par en la LAN".
|
||||||
|
//!
|
||||||
|
//! Marco de cable: un `u32` big-endian con el largo del sobre, seguido
|
||||||
|
//! del sobre serializado (postcard). Un sobre por conexión.
|
||||||
|
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::net::{TcpListener, TcpStream, ToSocketAddrs};
|
||||||
|
|
||||||
|
use crate::{ShareError, SignedBundle};
|
||||||
|
|
||||||
|
/// Tope defensivo para un sobre entrante (64 MiB): evita que un par
|
||||||
|
/// hostil pida un alloc gigante declarando un largo inflado.
|
||||||
|
const MAX_SOBRE: u32 = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Falla del transporte de sobres.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum NetError {
|
||||||
|
#[error("io de red: {0}")]
|
||||||
|
Io(String),
|
||||||
|
#[error("marco inválido: {0}")]
|
||||||
|
Protocolo(String),
|
||||||
|
#[error(transparent)]
|
||||||
|
Sobre(#[from] ShareError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<io::Error> for NetError {
|
||||||
|
fn from(e: io::Error) -> Self {
|
||||||
|
NetError::Io(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> {
|
||||||
|
if payload.len() as u64 > MAX_SOBRE as u64 {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"sobre demasiado grande para el marco",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
stream.write_all(&(payload.len() as u32).to_be_bytes())?;
|
||||||
|
stream.write_all(payload)?;
|
||||||
|
stream.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_frame(stream: &mut TcpStream) -> Result<Vec<u8>, NetError> {
|
||||||
|
let mut len_buf = [0u8; 4];
|
||||||
|
stream.read_exact(&mut len_buf)?;
|
||||||
|
let len = u32::from_be_bytes(len_buf);
|
||||||
|
if len > MAX_SOBRE {
|
||||||
|
return Err(NetError::Protocolo(format!(
|
||||||
|
"largo declarado {len} excede el tope"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut buf = vec![0u8; len as usize];
|
||||||
|
stream.read_exact(&mut buf)?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Conecta a `addr`, lee un sobre y lo deserializa. **No lo verifica** —
|
||||||
|
/// el caller debe pasar el resultado por [`crate::open`] antes de confiar.
|
||||||
|
pub fn fetch(addr: impl ToSocketAddrs) -> Result<SignedBundle, NetError> {
|
||||||
|
let mut stream = TcpStream::connect(addr)?;
|
||||||
|
let bytes = read_frame(&mut stream)?;
|
||||||
|
Ok(SignedBundle::from_bytes(&bytes)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atiende una sola conexión: manda `payload` y vuelve. Útil para tests
|
||||||
|
/// y para un "compartir una vez".
|
||||||
|
pub fn serve_once(listener: &TcpListener, payload: &[u8]) -> io::Result<()> {
|
||||||
|
let (mut stream, _) = listener.accept()?;
|
||||||
|
write_frame(&mut stream, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atiende conexiones para siempre. Por cada una llama a `supply` para
|
||||||
|
/// obtener los bytes a mandar — típicamente leer `compartido.khipu` del
|
||||||
|
/// disco, así sirve siempre la versión vigente. Una conexión cuyo
|
||||||
|
/// `supply` falla (todavía no hay sobre, p. ej.) se salta sin tumbar el
|
||||||
|
/// servidor. Bloqueante: pensado para correr en su propio hilo.
|
||||||
|
pub fn serve_loop<F>(listener: TcpListener, supply: F)
|
||||||
|
where
|
||||||
|
F: Fn() -> io::Result<Vec<u8>>,
|
||||||
|
{
|
||||||
|
for conn in listener.incoming() {
|
||||||
|
let Ok(mut stream) = conn else { continue };
|
||||||
|
if let Ok(bytes) = supply() {
|
||||||
|
let _ = write_frame(&mut stream, &bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{open, seal, SharedNote};
|
||||||
|
use agora_core::Keypair;
|
||||||
|
|
||||||
|
fn nota(title: &str, body: &str) -> SharedNote {
|
||||||
|
SharedNote {
|
||||||
|
title: title.into(),
|
||||||
|
body: body.into(),
|
||||||
|
tags: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_recovers_a_served_bundle_and_verifies() {
|
||||||
|
let kp = Keypair::from_seed([11u8; 32]);
|
||||||
|
let sobre = seal(&kp, vec![nota("Red", "hola por TCP")], 1).unwrap();
|
||||||
|
let bytes = sobre.to_bytes().unwrap();
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let server = std::thread::spawn(move || serve_once(&listener, &bytes).unwrap());
|
||||||
|
|
||||||
|
let recibido = fetch(addr).unwrap();
|
||||||
|
server.join().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(recibido, sobre);
|
||||||
|
// El sobre que llegó por la red verifica firma + hash.
|
||||||
|
let bundle = open(&recibido).unwrap();
|
||||||
|
assert_eq!(bundle.notes[0].title, "Red");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serve_loop_serves_the_current_supply_each_time() {
|
||||||
|
let kp = Keypair::from_seed([12u8; 32]);
|
||||||
|
let sobre = seal(&kp, vec![nota("A", "a")], 1).unwrap();
|
||||||
|
let bytes = sobre.to_bytes().unwrap();
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
// El servidor corre en su hilo; lo dejamos colgado al terminar el
|
||||||
|
// test (los dos fetch ya probaron lo que importa).
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
serve_loop(listener, move || Ok(bytes.clone()));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dos pares distintos jalan el mismo cuaderno.
|
||||||
|
let a = fetch(addr).unwrap();
|
||||||
|
let b = fetch(addr).unwrap();
|
||||||
|
assert_eq!(a, sobre);
|
||||||
|
assert_eq!(b, sobre);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fetch_against_nothing_is_an_error_not_a_panic() {
|
||||||
|
// Puerto cerrado: connect falla, devolvemos NetError::Io.
|
||||||
|
let err = fetch("127.0.0.1:1").unwrap_err();
|
||||||
|
assert!(matches!(err, NetError::Io(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
//! Integración: el camino completo de compartir por LAN, todo en loopback.
|
||||||
|
//!
|
||||||
|
//! Sella un cuaderno → lo sirve por TCP → anuncia su baliza → otro par lo
|
||||||
|
//! descubre por UDP, le jala el sobre y lo verifica. Es la cadena que
|
||||||
|
//! ejercitan los botones «publicar» y «recibir» de khipu-app, sin GUI.
|
||||||
|
|
||||||
|
use std::net::{Ipv4Addr, TcpListener, UdpSocket};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use agora_core::Keypair;
|
||||||
|
use khipu_core::NoteStore;
|
||||||
|
use khipu_share::discovery::{escuchar_en, Beacon};
|
||||||
|
use khipu_share::{net, open, seal, SharedNote};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn descubrir_jalar_y_verificar_de_punta_a_punta() {
|
||||||
|
// --- Lado que publica ---
|
||||||
|
let autor = Keypair::from_seed([21u8; 32]);
|
||||||
|
let sobre = seal(
|
||||||
|
&autor,
|
||||||
|
vec![SharedNote {
|
||||||
|
title: "Compartida por LAN".into(),
|
||||||
|
body: "viajó por descubrimiento + TCP".into(),
|
||||||
|
tags: vec!["red".into()],
|
||||||
|
}],
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let bytes = sobre.to_bytes().unwrap();
|
||||||
|
|
||||||
|
// Sirve el sobre por TCP en un puerto efímero.
|
||||||
|
let tcp = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||||
|
let tcp_port = tcp.local_addr().unwrap().port();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = net::serve_once(&tcp, &bytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Anuncia la baliza apuntando a ese puerto TCP.
|
||||||
|
let beacon = Beacon {
|
||||||
|
author: autor.public_key(),
|
||||||
|
port: tcp_port,
|
||||||
|
name: "khipu publicador".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Lado que recibe ---
|
||||||
|
// Escucha balizas en un puerto UDP efímero (en la app sería el
|
||||||
|
// estándar 7701); el publicador le manda la suya por loopback.
|
||||||
|
let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||||
|
let destino = listener.local_addr().unwrap();
|
||||||
|
let emisor = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
|
||||||
|
emisor.send_to(&beacon.encode(), destino).unwrap();
|
||||||
|
|
||||||
|
// Descubre al par y le jala el cuaderno.
|
||||||
|
let pares = escuchar_en(&listener, Duration::from_millis(500));
|
||||||
|
assert_eq!(pares.len(), 1, "debería haber descubierto un par");
|
||||||
|
let par = &pares[0];
|
||||||
|
assert_eq!(par.fetch_addr.port(), tcp_port);
|
||||||
|
assert_eq!(par.beacon.author, autor.public_key());
|
||||||
|
|
||||||
|
let recibido = net::fetch(par.fetch_addr).unwrap();
|
||||||
|
|
||||||
|
// Verifica firma + hash e ingiere como nota fresca.
|
||||||
|
let bundle = open(&recibido).unwrap();
|
||||||
|
let mut store = NoteStore::new();
|
||||||
|
let resultado = khipu_share::import_into(&mut store, bundle, 9_000);
|
||||||
|
assert_eq!(resultado.created.len(), 1);
|
||||||
|
let nota = store.get(resultado.created[0]).unwrap();
|
||||||
|
assert_eq!(nota.title, "Compartida por LAN");
|
||||||
|
assert!(nota.tags.contains(&"red".to_string()));
|
||||||
|
// La nota lleva la procedencia del autor que la selló.
|
||||||
|
assert!(nota.tags.contains(&khipu_share::tag_de(&autor.public_key())));
|
||||||
|
// Gravedad fresca en el receptor.
|
||||||
|
assert_eq!(nota.mass, 1.0);
|
||||||
|
assert_eq!(nota.last_access, 9_000);
|
||||||
|
}
|
||||||
Generated
+6692
File diff suppressed because it is too large
Load Diff
+439
@@ -0,0 +1,439 @@
|
|||||||
|
# Cargo.toml raíz STANDALONE de khipu — front-door sobre Llimphi.
|
||||||
|
# Solo el código de khipu; Llimphi y lo fundacional por git-dep del monorepo gioser.git.
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"00_unanchay/khipu/khipu-app",
|
||||||
|
"00_unanchay/khipu/khipu-brahman",
|
||||||
|
"00_unanchay/khipu/khipu-core",
|
||||||
|
"00_unanchay/khipu/khipu-gravity",
|
||||||
|
"00_unanchay/khipu/khipu-share",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.80"
|
||||||
|
license = "MIT"
|
||||||
|
authors = ["Sergio <gerencia@jlsoltech.com>"]
|
||||||
|
publish = false
|
||||||
|
repository = "https://gitea.gioser.net/sergio/khipu"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
|
||||||
|
# === Registro de apps / menú global ===
|
||||||
|
app-bus = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
# === Serialización ===
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
lsp-types = "0.97"
|
||||||
|
serde-big-array = "0.5"
|
||||||
|
postcard = { version = "1", features = ["use-std"] }
|
||||||
|
toml = "0.8"
|
||||||
|
ron = "0.8"
|
||||||
|
bincode = "1"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
||||||
|
# === Errores ===
|
||||||
|
thiserror = "2" # bump uniforme; arje (era 1) puede requerir ajustes menores
|
||||||
|
anyhow = "1"
|
||||||
|
|
||||||
|
# === Async ===
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["compat"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
futures = "0.3"
|
||||||
|
|
||||||
|
# === Observabilidad ===
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
|
||||||
|
# === Linux primitives (arje) ===
|
||||||
|
nix = { version = "0.29", features = ["signal", "process", "sched", "mount", "fs", "socket", "net", "user"] }
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
# === IDs / Hash / Crypto ===
|
||||||
|
ulid = { version = "1", features = ["serde"] }
|
||||||
|
uuid = { version = "1", features = ["v4", "rng-getrandom"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
blake3 = "1.5"
|
||||||
|
ed25519-dalek = "2"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
argon2 = "0.5"
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
|
# === WASM (arje) ===
|
||||||
|
# wasmi 1.0: unifica la versión con renaser (su kernel ya corre 1.0), para
|
||||||
|
# que el ABI WASM del host sea idéntico en Linux y en bare-metal.
|
||||||
|
wasmi = "1.0"
|
||||||
|
wat = "1"
|
||||||
|
|
||||||
|
# === Storage / DB ===
|
||||||
|
sled = "0.34"
|
||||||
|
rusqlite = { version = "0.31", features = ["bundled", "blob"] }
|
||||||
|
|
||||||
|
# === Ingesta de documentos (iniy-ingest: PDF / EPUB) ===
|
||||||
|
pdf-extract = "0.7"
|
||||||
|
epub = "2.1"
|
||||||
|
|
||||||
|
# === Bulk import Wikipedia (iniy-wiki dump) ===
|
||||||
|
bzip2 = "0.4"
|
||||||
|
|
||||||
|
# === Compresión (minga multi-bundle) ===
|
||||||
|
zstd = "0.13"
|
||||||
|
|
||||||
|
# === HTTP server (iniy-server) ===
|
||||||
|
axum = "0.7"
|
||||||
|
tower = "0.5"
|
||||||
|
|
||||||
|
# === ANN sobre embeddings (iniy nli --ann) ===
|
||||||
|
instant-distance = "0.6"
|
||||||
|
|
||||||
|
# === P2P (minga) ===
|
||||||
|
libp2p = { version = "0.56", features = ["tokio", "tcp", "noise", "yamux", "macros", "kad", "identify", "relay", "dcutr", "autonat", "mdns"] }
|
||||||
|
libp2p-stream = "=0.4.0-alpha"
|
||||||
|
libp2p-allow-block-list = "0.6"
|
||||||
|
|
||||||
|
# === SSH (ssh, sandokan RemoteEngine, matilda) ===
|
||||||
|
russh = "0.54"
|
||||||
|
|
||||||
|
# === Math determinista cross-platform (dominium) ===
|
||||||
|
libm = "0.2"
|
||||||
|
|
||||||
|
# === SMF (takiy-midi) ===
|
||||||
|
# midly: parser/emitter SMF tipo 0/1, no_std-friendly, sin allocs en hot path.
|
||||||
|
midly = "0.5"
|
||||||
|
|
||||||
|
# === Code parsing (minga) ===
|
||||||
|
arboard = "3"
|
||||||
|
ropey = "1.6"
|
||||||
|
tree-sitter = "0.24"
|
||||||
|
tree-sitter-rust = "0.23"
|
||||||
|
tree-sitter-python = "0.23"
|
||||||
|
tree-sitter-typescript = "0.23"
|
||||||
|
tree-sitter-javascript = "0.23"
|
||||||
|
tree-sitter-go = "0.23"
|
||||||
|
|
||||||
|
# === FS notify ===
|
||||||
|
notify = "6.1"
|
||||||
|
|
||||||
|
# === Grafos (iniy, nakui-core ya lo usa directo en 0.6) ===
|
||||||
|
petgraph = "0.6"
|
||||||
|
|
||||||
|
# === Image decoding (nahual-image-viewer-llimphi) ===
|
||||||
|
# default-features = false: nos quedamos con PNG + JPEG + WebP (lossless).
|
||||||
|
# tullpu-render exporta a las tres; AVIF/TIFF/… los habilitamos si una app
|
||||||
|
# los pide específicamente.
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
|
|
||||||
|
# === FUSE (minga-vfs) ===
|
||||||
|
# default-features = false: prescinde de pkg-config/libfuse-dev en build.
|
||||||
|
# El montaje pasa a ser Rust puro (vía el helper `fusermount3` en runtime).
|
||||||
|
fuser = { version = "0.15", default-features = false }
|
||||||
|
|
||||||
|
# === CLI / auth (minga) ===
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
rpassword = "7"
|
||||||
|
|
||||||
|
# === PAM (auth-core) ===
|
||||||
|
pam = "0.8"
|
||||||
|
|
||||||
|
# === D-Bus (arje compat) ===
|
||||||
|
zbus = { version = "4", default-features = false, features = ["tokio"] }
|
||||||
|
|
||||||
|
# === Tests ===
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
|
# === Llimphi (motor gráfico soberano) ===
|
||||||
|
# wgpu sobre Vulkan/Metal/DX12, winit para ventana en dev Linux.
|
||||||
|
# raw-window-handle 0.6 alinea winit 0.30 con wgpu 24.
|
||||||
|
# vello 0.5 = rasterizador vectorial sobre wgpu 24.
|
||||||
|
# taffy 0.9 = motor Flexbox/Grid puro Rust (ya pulled por transitivos, lo alineamos).
|
||||||
|
# parley 0.2 = shaping/layout de texto compatible con peniko 0.4 (que vello 0.5 expone).
|
||||||
|
wgpu = "24"
|
||||||
|
winit = "0.30"
|
||||||
|
raw-window-handle = "0.6"
|
||||||
|
pollster = "0.4"
|
||||||
|
vello = "0.5"
|
||||||
|
taffy = "0.9"
|
||||||
|
# parley = shaping completo (bidi, ligatures, fallback CJK/emoji vía fontique, line break).
|
||||||
|
parley = "0.4"
|
||||||
|
# Bucle Elm (input→update→view→layout→raster→present). Lo consumen las apps.
|
||||||
|
llimphi-ui = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
# Paleta semántica compartida por las apps y los widgets.
|
||||||
|
llimphi-theme = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
# Tweens y helpers de animación sobre el bucle Elm.
|
||||||
|
llimphi-motion = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
# Iconos vectoriales (BezPath en grid 24×24) compartidos por todas las apps.
|
||||||
|
llimphi-icons = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
# Widgets reusables sobre llimphi-ui — uno por crate.
|
||||||
|
llimphi-widget-app-header = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-banner = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-button = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-card = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-clipboard = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-context-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-edit-menu = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-menubar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-list = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-grid = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-slider = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-scroll = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-splitter = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-stat-card = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-tabs = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-module-command-palette = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-module-diff-viewer = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-module-fif = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-module-file-picker = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-module-bookmarks = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-module-mini-map = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-module-shuma-term = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-module-symbol-outline = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-plugin-host = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-theme-switcher = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-text-area = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-text-editor-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-text-editor = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-text-editor-lsp = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-text-input = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-tiled = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-nodegraph = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-tree = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-navigator = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
# Sello vectorial wawa (rombo + W implícita + Merkle Core).
|
||||||
|
llimphi-widget-wawa-mark = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
# Widgets de elegancia transversal (tooltip, spinner, progress, toast,
|
||||||
|
# modal, empty, status-bar, shortcuts-help, splash).
|
||||||
|
llimphi-widget-tooltip = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-spinner = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-progress = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-toast = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-modal = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-empty = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-status-bar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-shortcuts-help = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-timeline = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-splash = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
# Controles de formulario y signaling (switch, segmented, breadcrumb,
|
||||||
|
# badge, avatar, skeleton, field).
|
||||||
|
llimphi-widget-switch = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-segmented = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-dock-rail = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-breadcrumb = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-badge = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-avatar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-skeleton = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-field = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
# Firma visual transversal (gradient sutil + hairline accent).
|
||||||
|
llimphi-widget-panel = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-widget-panes = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
llimphi-workspace = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
# Abstracción Selector — host (paths) + wawa (khipus).
|
||||||
|
llimphi-module-selector = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
|
||||||
|
# === Filesystem helpers ===
|
||||||
|
directories = "5"
|
||||||
|
|
||||||
|
# === Diff line-based (llimphi-module-diff-viewer) ===
|
||||||
|
# `similar` es la crate de facto: implementa Myers + Patience + LCS,
|
||||||
|
# expone `TextDiff` con ChangeTag por línea (Equal/Insert/Delete),
|
||||||
|
# zero deps fuera de std. La 2.x es estable hace años.
|
||||||
|
similar = "2"
|
||||||
|
|
||||||
|
# === Fuzzy matching (shuma-history) ===
|
||||||
|
# nucleo-matcher = mismo matcher que helix-editor: rápido, Unicode-correct,
|
||||||
|
# bonus por prefijos, ranking estable. La versión 0.3 expone el API simple
|
||||||
|
# que necesitamos (Matcher + Pattern + score).
|
||||||
|
nucleo-matcher = "0.3"
|
||||||
|
|
||||||
|
# === Transporte autenticado (shuma-link) ===
|
||||||
|
# snow = framework Noise pure-rust. Lo usamos en modo Noise_XK (cliente
|
||||||
|
# conoce la pubkey del servidor, server descubre la del cliente y la
|
||||||
|
# valida contra una allowlist). ChaCha20-Poly1305 + X25519 + BLAKE2s.
|
||||||
|
# La versión 0.9 viene pinneada por libp2p, así nos alineamos.
|
||||||
|
snow = "0.9"
|
||||||
|
hex = "0.4"
|
||||||
|
|
||||||
|
# === PTY + emulador de terminal (shuma-exec, módulos REPL) ===
|
||||||
|
# portable-pty aloja un PTY cross-platform; lo usamos para los
|
||||||
|
# comandos TUI tipo vim/htop/less que necesitan un terminal de verdad.
|
||||||
|
# vt100 parsea la secuencia de bytes que el PTY emite (ANSI + cursor
|
||||||
|
# movement + erase + screen state) y mantiene un buffer de pantalla
|
||||||
|
# renderizable como grid.
|
||||||
|
portable-pty = "0.9"
|
||||||
|
vt100 = "0.16"
|
||||||
|
|
||||||
|
# === WASM web (gioser) ===
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
js-sys = "0.3"
|
||||||
|
web-sys = "0.3"
|
||||||
|
glam = "0.30"
|
||||||
|
|
||||||
|
# === Markdown (pluma) ===
|
||||||
|
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
|
||||||
|
|
||||||
|
# === Archivos comprimidos (nahual archive viewer) ===
|
||||||
|
# Sólo listamos el directorio central (nombres/tamaños); no descomprimimos,
|
||||||
|
# por eso default-features=false alcanza para ZIP. Para tar.gz sí
|
||||||
|
# descomprimimos en streaming con flate2 (ya declarado arriba), saltando
|
||||||
|
# los datos de cada entrada — sólo leemos headers.
|
||||||
|
zip = { version = "2.4", default-features = false }
|
||||||
|
tar = { version = "0.4", default-features = false }
|
||||||
|
|
||||||
|
# === Fuentes (nahual font viewer) ===
|
||||||
|
# Parseo de TTF/OTF/TTC y extracción de contornos de glifo a paths.
|
||||||
|
ttf-parser = "0.25"
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Intra-workspace deps de nahual (referenciadas por workspace = true)
|
||||||
|
# ============================================================
|
||||||
|
nahual-text-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-image-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-thumb-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-gallery-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-video-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-card-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-audio-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-tree-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-hex-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-table-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-markdown-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-archive-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-font-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-map-viewer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-geo-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-viewer-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
nahual-file-explorer-llimphi = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Intra-workspace deps de pineal (módulo de gráficos)
|
||||||
|
# ============================================================
|
||||||
|
pineal-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-render = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-cartesian = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-stream = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-mesh = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-financial = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-polar = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-heatmap = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-treemap = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-flow = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-phosphor = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-export = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-hexbin = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-contour = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal-bars = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
pineal = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Intra-workspace deps de iniy (laboratorio semántico de creencias)
|
||||||
|
# ============================================================
|
||||||
|
iniy-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
iniy-ingest = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
iniy-extract = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
iniy-nli = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
iniy-nli-llm = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
iniy-graph = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
iniy-store = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
|
||||||
|
# === auto: declarados por crates internos faltantes ===
|
||||||
|
cosmos-coords = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
cosmos-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
cosmos-ephemeris = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
cosmos-time = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
cosmos-wcs = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
|
||||||
|
# === auto: externas de eternal ===
|
||||||
|
celestial-eop-data = { version = "0.1"}
|
||||||
|
approx = "0.5"
|
||||||
|
byteorder = "1.5"
|
||||||
|
cc = "1.0"
|
||||||
|
chrono = "0.4"
|
||||||
|
crc32fast = "1.4"
|
||||||
|
criterion = "0.5"
|
||||||
|
csv = "1.4"
|
||||||
|
flate2 = "1.0"
|
||||||
|
glob = "0.3"
|
||||||
|
indicatif = "0.18"
|
||||||
|
lz4_flex = "0.11"
|
||||||
|
memmap2 = "0.9"
|
||||||
|
mockito = "1.0"
|
||||||
|
ndarray = "0.15"
|
||||||
|
num-traits = "0.2"
|
||||||
|
once_cell = "1.19"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
png = "0.18"
|
||||||
|
proptest = "1.4"
|
||||||
|
quick-xml = "0.31"
|
||||||
|
rayon = "1.8"
|
||||||
|
regex = "1.11"
|
||||||
|
reqwest = "0.12"
|
||||||
|
tiff = "0.11"
|
||||||
|
wide = "0.7"
|
||||||
|
wiremock = "0.6"
|
||||||
|
|
||||||
|
# === i18n (rimay-localize) ===
|
||||||
|
fluent-bundle = "0.15"
|
||||||
|
unic-langid = { version = "0.9", features = ["macros"] }
|
||||||
|
sys-locale = "0.3"
|
||||||
|
|
||||||
|
# === Servo (puriy-engine) ===
|
||||||
|
# Crates publicados de Servo embebibles individualmente. html5ever/markup5ever
|
||||||
|
# ya entran via ammonia→surrealdb→nakui, así que alineamos versión para no
|
||||||
|
# duplicar el árbol. markup5ever_rcdom es el DOM Rc-based simple (suficiente
|
||||||
|
# para Fase 2: parsear y renderizar, sin scripting). cssparser es el tokenizer
|
||||||
|
# CSS de Stylo, sirve para inline styles. ureq = HTTP síncrono minimalista,
|
||||||
|
# evita pull de tokio en el engine.
|
||||||
|
html5ever = "0.39"
|
||||||
|
markup5ever = "0.39"
|
||||||
|
markup5ever_rcdom = "0.39"
|
||||||
|
cssparser = "0.35"
|
||||||
|
url = "2"
|
||||||
|
ureq = { version = "2", default-features = false, features = ["tls"] }
|
||||||
|
|
||||||
|
# === takiy-synth (SoundFont MIDI) ===
|
||||||
|
# rustysynth = sintetizador SF2 puro Rust, MIT. Reemplaza el oscilador
|
||||||
|
# feo de takiy-synth por muestras reales (FluidR3, GeneralUser GS, etc).
|
||||||
|
rustysynth = "1.3"
|
||||||
|
|
||||||
|
# === takiy-playback (audio device output) ===
|
||||||
|
# cpal = backend de audio cross-platform (ALSA/PulseAudio/Pipewire en
|
||||||
|
# Linux, WASAPI en Windows, CoreAudio en macOS). Lo usamos sólo para
|
||||||
|
# abrir el device default y empujar muestras f32 — nada de mezclado
|
||||||
|
# ni efectos en el callback.
|
||||||
|
cpal = "0.15"
|
||||||
|
|
||||||
|
# === media-source-wav (decoder PCM en disco) ===
|
||||||
|
# hound = lector/escritor WAV puro-Rust, sin deps nativas. Soporta PCM
|
||||||
|
# entero (8/16/24/32) y float (32). Suficiente para abrir samples y
|
||||||
|
# stems de prueba sin meter ffmpeg/symphonia.
|
||||||
|
hound = "3.5"
|
||||||
|
|
||||||
|
# === media-source-{mp3,flac,vorbis} (decoders vía symphonia) ===
|
||||||
|
# symphonia es una colección de decoders puro-Rust mantenida. `mp3` cubre
|
||||||
|
# media-source-mp3; `flac` (decoder + demuxer FLAC nativo) cubre
|
||||||
|
# media-source-flac (lossless); `vorbis` + `ogg` (codec + demuxer Ogg)
|
||||||
|
# cubren media-source-vorbis (lossy clásico, libre de patentes). Sin aac:
|
||||||
|
# ese tier patentado entra por shared/foreign-av.
|
||||||
|
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg"] }
|
||||||
|
|
||||||
|
# === media-source-opus (decoder Opus NATIVO puro-Rust) ===
|
||||||
|
# Opus es el formato de audio nativo de gioser (par del video AV1). ogg
|
||||||
|
# demuxea las páginas Ogg; opus-wave es un port puro-Rust de libopus
|
||||||
|
# (SILK+CELT, sin C ni FFI) — par del rav1d del lado video.
|
||||||
|
ogg = "0.9"
|
||||||
|
opus-wave = "3"
|
||||||
|
|
||||||
|
# === media-source-webm (demux nativo Matroska/WebM) ===
|
||||||
|
# matroska-demuxer es un demuxer puro-Rust de MKV/WebM (EBML). Saca los
|
||||||
|
# paquetes de los tracks V_AV1 y A_OPUS para alimentar a media-source-av1
|
||||||
|
# y media-source-opus — un .webm AV1+Opus se reproduce 100% nativo.
|
||||||
|
matroska-demuxer = "0.7"
|
||||||
|
# === git-deps al monorepo (agregados por la extracción) ===
|
||||||
|
agora-core = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
agora-keystore = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
card-net = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
|
rimay-verbo = { git = "https://gitea.gioser.net/sergio/gioser.git" }
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Sergio
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# khipu
|
||||||
|
|
||||||
|
> Sovereign P2P "notes to oblivion" — local-first, content-addressed, in Rust.
|
||||||
|
|
||||||
|
`khipu` (Quechua: *knotted-cord record*) is a sovereign note system: local-first, content-addressed, synced peer-to-peer over the `card`/`chasqui` layer (LAN + WAN + relay/NAT traversal, UDP & DHT discovery, encrypted identity). Notes are designed to be let go of as much as kept — a record that forgets on purpose.
|
||||||
|
|
||||||
|
## How dependencies work
|
||||||
|
Front-door repo: only `khipu-*` crates here. Identity (`card`), networking (`chasqui`) and shared leaves are git-dependencies of the [`gioser`](https://gitea.gioser.net/sergio/gioser) monorepo.
|
||||||
|
|
||||||
|
## License
|
||||||
|
MIT. Part of the [gioser](https://gitea.gioser.net/sergio/gioser) suite.
|
||||||
Reference in New Issue
Block a user