Skip to content

Commit 46e7bd8

Browse files
Jonathan D.A. Jewellclaude
andcommitted
Add MVP scaffold: Rust core, ReScript UI, Tauri desktop
Core: - note.rs: Note data structure with links, positions, attributes - notebook.rs: Collection with backlinks, CRUD, search - storage.rs: JSON file persistence UI (ReScript + TEA): - Types.res: Mirror of Rust types - Model.res: Application state - Msg.res: All message types - Update.res: State transitions - View.res: React components (Sidebar, Editor, Canvas) - Main.res: Entry point with keyboard shortcuts Desktop: - Tauri 2.0 config for Linux/macOS/Windows/iOS/Android - Commands bridging UI to Rust core Web: - index.html, styles.css for browser build Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 384b790 commit 46e7bd8

21 files changed

Lines changed: 2960 additions & 0 deletions

LICENSE

Lines changed: 661 additions & 0 deletions
Large diffs are not rendered by default.

core/Cargo.toml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
[package]
3+
name = "nexia-core"
4+
version = "0.1.0"
5+
edition = "2021"
6+
license = "AGPL-3.0-or-later"
7+
description = "Core engine for Nexia knowledge management"
8+
repository = "https://github.com/hyperpolymath/nexia-list"
9+
10+
[lib]
11+
crate-type = ["cdylib", "rlib"]
12+
13+
[features]
14+
default = []
15+
wasm = ["wasm-bindgen", "console_error_panic_hook"]
16+
17+
[dependencies]
18+
serde = { version = "1.0", features = ["derive"] }
19+
serde_json = "1.0"
20+
uuid = { version = "1.0", features = ["v4", "serde"] }
21+
chrono = { version = "0.4", features = ["serde"] }
22+
thiserror = "1.0"
23+
24+
# Optional WASM support
25+
wasm-bindgen = { version = "0.2", optional = true }
26+
console_error_panic_hook = { version = "0.1", optional = true }
27+
28+
[dev-dependencies]
29+
pretty_assertions = "1.0"

core/src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
//! Nexia Core - Knowledge graph engine
3+
//!
4+
//! This crate provides the core data structures and operations for Nexia,
5+
//! a cross-platform personal knowledge management tool.
6+
7+
pub mod note;
8+
pub mod notebook;
9+
pub mod storage;
10+
11+
pub use note::{Note, NoteId, Point2D};
12+
pub use notebook::Notebook;
13+
pub use storage::Storage;
14+
15+
/// Library version
16+
pub const VERSION: &str = env!("CARGO_PKG_VERSION");

core/src/note.rs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
//! Note data structures
3+
4+
use chrono::{DateTime, Utc};
5+
use serde::{Deserialize, Serialize};
6+
use std::collections::HashMap;
7+
use uuid::Uuid;
8+
9+
/// Unique identifier for a note
10+
pub type NoteId = Uuid;
11+
12+
/// 2D position on the spatial canvas
13+
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
14+
pub struct Point2D {
15+
pub x: f64,
16+
pub y: f64,
17+
}
18+
19+
impl Point2D {
20+
pub fn new(x: f64, y: f64) -> Self {
21+
Self { x, y }
22+
}
23+
24+
pub fn origin() -> Self {
25+
Self { x: 0.0, y: 0.0 }
26+
}
27+
}
28+
29+
impl Default for Point2D {
30+
fn default() -> Self {
31+
Self::origin()
32+
}
33+
}
34+
35+
/// A single note in the knowledge graph
36+
#[derive(Debug, Clone, Serialize, Deserialize)]
37+
pub struct Note {
38+
/// Unique identifier
39+
pub id: NoteId,
40+
41+
/// Note title
42+
pub title: String,
43+
44+
/// Note content (plain text for MVP, rich text later)
45+
pub content: String,
46+
47+
/// Position on the spatial canvas (None if not placed)
48+
#[serde(skip_serializing_if = "Option::is_none")]
49+
pub position: Option<Point2D>,
50+
51+
/// Size on canvas (width, height)
52+
#[serde(skip_serializing_if = "Option::is_none")]
53+
pub size: Option<(f64, f64)>,
54+
55+
/// When the note was created
56+
pub created_at: DateTime<Utc>,
57+
58+
/// When the note was last modified
59+
pub modified_at: DateTime<Utc>,
60+
61+
/// Outgoing links to other notes
62+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
63+
pub links: Vec<NoteId>,
64+
65+
/// Prototype note for inheritance (None if no prototype)
66+
#[serde(skip_serializing_if = "Option::is_none")]
67+
pub prototype: Option<NoteId>,
68+
69+
/// Custom attributes
70+
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
71+
pub attributes: HashMap<String, serde_json::Value>,
72+
}
73+
74+
impl Note {
75+
/// Create a new note with default values
76+
pub fn new(title: impl Into<String>) -> Self {
77+
let now = Utc::now();
78+
Self {
79+
id: Uuid::new_v4(),
80+
title: title.into(),
81+
content: String::new(),
82+
position: None,
83+
size: None,
84+
created_at: now,
85+
modified_at: now,
86+
links: Vec::new(),
87+
prototype: None,
88+
attributes: HashMap::new(),
89+
}
90+
}
91+
92+
/// Create a note with a specific position on the canvas
93+
pub fn with_position(mut self, x: f64, y: f64) -> Self {
94+
self.position = Some(Point2D::new(x, y));
95+
self
96+
}
97+
98+
/// Update the modified timestamp
99+
pub fn touch(&mut self) {
100+
self.modified_at = Utc::now();
101+
}
102+
103+
/// Add a link to another note
104+
pub fn add_link(&mut self, target: NoteId) {
105+
if !self.links.contains(&target) && target != self.id {
106+
self.links.push(target);
107+
self.touch();
108+
}
109+
}
110+
111+
/// Remove a link to another note
112+
pub fn remove_link(&mut self, target: &NoteId) -> bool {
113+
if let Some(pos) = self.links.iter().position(|id| id == target) {
114+
self.links.remove(pos);
115+
self.touch();
116+
true
117+
} else {
118+
false
119+
}
120+
}
121+
122+
/// Check if this note links to another
123+
pub fn links_to(&self, target: &NoteId) -> bool {
124+
self.links.contains(target)
125+
}
126+
127+
/// Set an attribute value
128+
pub fn set_attribute(&mut self, key: impl Into<String>, value: serde_json::Value) {
129+
self.attributes.insert(key.into(), value);
130+
self.touch();
131+
}
132+
133+
/// Get an attribute value
134+
pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
135+
self.attributes.get(key)
136+
}
137+
}
138+
139+
#[cfg(test)]
140+
mod tests {
141+
use super::*;
142+
143+
#[test]
144+
fn test_new_note() {
145+
let note = Note::new("Test Note");
146+
assert_eq!(note.title, "Test Note");
147+
assert!(note.content.is_empty());
148+
assert!(note.position.is_none());
149+
assert!(note.links.is_empty());
150+
}
151+
152+
#[test]
153+
fn test_note_with_position() {
154+
let note = Note::new("Positioned").with_position(100.0, 200.0);
155+
assert_eq!(note.position, Some(Point2D::new(100.0, 200.0)));
156+
}
157+
158+
#[test]
159+
fn test_add_link() {
160+
let mut note = Note::new("Source");
161+
let target_id = Uuid::new_v4();
162+
163+
note.add_link(target_id);
164+
assert!(note.links_to(&target_id));
165+
166+
// Adding same link twice should not duplicate
167+
note.add_link(target_id);
168+
assert_eq!(note.links.len(), 1);
169+
}
170+
171+
#[test]
172+
fn test_remove_link() {
173+
let mut note = Note::new("Source");
174+
let target_id = Uuid::new_v4();
175+
176+
note.add_link(target_id);
177+
assert!(note.remove_link(&target_id));
178+
assert!(!note.links_to(&target_id));
179+
180+
// Removing non-existent link returns false
181+
assert!(!note.remove_link(&target_id));
182+
}
183+
184+
#[test]
185+
fn test_self_link_prevented() {
186+
let mut note = Note::new("Self");
187+
let self_id = note.id;
188+
189+
note.add_link(self_id);
190+
assert!(note.links.is_empty(), "Should not allow self-links");
191+
}
192+
}

0 commit comments

Comments
 (0)