Menu
Commit eb500019
eb500019f03747c3813995ffbc50b64f38a473ab by Claude Code
- Cargo.lock +4 -4
- Cargo.toml +1 -1
- VERSION +1 -1
- crates/notes-api/src/handlers/crud.rs +512 -0
- crates/notes-api/src/router_setup.rs +20 -0
- crates/notes-core/src/entities/note.rs +6 -0
- crates/notes-core/src/entities/notebook.rs +22 -0
- crates/notes-core/src/metadata/mod.rs +24 -0
- crates/notes-mcp/src/tools.rs +476 -8
- crates/notes-mcp/tests/mcp_test.rs +81 -0
- site/backend/collections.toml +20 -0
- site/backend/page.json +1 -1
- site/frontend/notes-app.js +59 -6
- site/frontend/styles.css +3 -1
diff --git a/Cargo.lock b/Cargo.lock
index bcdb288..4b11d0f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -31,7 +31,7 @@ version = "0.8.2"
[[package]]
name = "akurai-notes"
-version = "0.0.8"
+version = "0.0.9"
dependencies = [
"akurai-http",
"akurai-json",
@@ -73,7 +73,7 @@ dependencies = [
[[package]]
name = "notes-api"
-version = "0.0.8"
+version = "0.0.9"
dependencies = [
"akurai-collections",
"akurai-http",
@@ -89,7 +89,7 @@ dependencies = [
[[package]]
name = "notes-core"
-version = "0.0.8"
+version = "0.0.9"
dependencies = [
"akurai-json",
"akurai-schema",
@@ -98,7 +98,7 @@ dependencies = [
[[package]]
name = "notes-mcp"
-version = "0.0.8"
+version = "0.0.9"
dependencies = [
"akurai-http",
"akurai-json",
diff --git a/Cargo.toml b/Cargo.toml
index d1f8fc3..2ea1c7e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/notes-core", "crates/notes-api", "crates/notes-cli", "crates/notes-mcp"]
[workspace.package]
-version = "0.0.8"
+version = "0.0.9"
edition = "2021"
license = "MIT"
repository = "https://github.com/olibuijr/AkurAI-Notes"
diff --git a/VERSION b/VERSION
index d169b2f..c5d54ec 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.0.8
+0.0.9
diff --git a/crates/notes-api/src/handlers/crud.rs b/crates/notes-api/src/handlers/crud.rs
index 5c8664f..8d9bc15 100644
--- a/crates/notes-api/src/handlers/crud.rs
+++ b/crates/notes-api/src/handlers/crud.rs
@@ -21,6 +21,10 @@ fn extract_id(path: &str) -> Option<u64> {
segments.last().and_then(|s| s.parse::<u64>().ok())
}
+fn extract_any_id(path: &str) -> Option<u64> {
+ path.split('/').rev().find_map(|s| s.parse::<u64>().ok())
+}
+
fn build_key(coll_name: &str, id: u64) -> Vec<u8> {
let mut key = format!("{}:", coll_name).into_bytes();
key.extend_from_slice(&id.to_be_bytes());
@@ -125,6 +129,30 @@ pub fn list_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteHan
}
}
+ if coll == "notes" {
+ for record in &mut records {
+ normalize_note(record);
+ }
+ sort_notes(&mut records);
+ } else if coll == "notebooks" {
+ let notes = scan_collection_locked(&mut db, "notes");
+ for record in &mut records {
+ normalize_notebook(record);
+ let id = record.get("id").and_then(value_i64).unwrap_or_default();
+ let note_count = notes
+ .iter()
+ .filter(|note| {
+ owner_matches(note, &owner)
+ && note.get("notebookId").and_then(value_i64) == Some(id)
+ })
+ .count();
+ if let Value::Object(fields) = record {
+ set_field(fields, "noteCount", Value::Int(note_count as i64));
+ }
+ }
+ sort_notebooks(&mut records);
+ }
+
json_response(Value::Array(records))
})
}
@@ -220,6 +248,19 @@ pub fn create_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteH
obj.push(("ownerSub".into(), Value::Str(owner.sub.clone())));
}
+ if coll == "notes" {
+ if !obj.iter().any(|(key, _)| key == "sortOrder") {
+ obj.push(("sortOrder".into(), Value::Int(next_id as i64)));
+ }
+ } else if coll == "notebooks" {
+ if !obj.iter().any(|(key, _)| key == "parentId") {
+ obj.push(("parentId".into(), Value::Null));
+ }
+ if !obj.iter().any(|(key, _)| key == "sortOrder") {
+ obj.push(("sortOrder".into(), Value::Int(next_id as i64)));
+ }
+ }
+
obj.push(("id".into(), Value::Int(next_id as i64)));
obj.push(("createdAt".into(), Value::Int(now)));
obj.push(("updatedAt".into(), Value::Int(now)));
@@ -484,6 +525,314 @@ pub fn edit_note_body_route(state: Arc<Mutex<NotesState>>) -> RouteHandler {
})
}
+pub fn move_note_route(state: Arc<Mutex<NotesState>>) -> RouteHandler {
+ Box::new(move |req: &Request| {
+ let id = match extract_any_id(&req.path) {
+ Some(id) => id,
+ None => return bad_request("missing id"),
+ };
+ let body = match parse_body(req) {
+ Ok(v) => v,
+ Err(e) => return bad_request(&e),
+ };
+ let Value::Object(args) = body else {
+ return bad_request("expected JSON object");
+ };
+ let notebook_id = match get_pair(&args, "notebookId").and_then(value_i64) {
+ Some(id) => id,
+ None => return bad_request("notebookId required"),
+ };
+ let sort_order = get_pair(&args, "sortOrder").and_then(value_i64);
+ let state_lock = match state.lock() {
+ Ok(s) => s,
+ Err(_) => return internal_error("lock poisoned"),
+ };
+ let owner = match get_owner_ids(&state_lock, req) {
+ Some(o) => o,
+ None => {
+ return json_response(Value::Object(vec![(
+ "error".into(),
+ Value::Str("unauthorized".into()),
+ )]))
+ }
+ };
+ let mut db = match state_lock.db.lock() {
+ Ok(db) => db,
+ Err(_) => return internal_error("db lock poisoned"),
+ };
+ if !owned_record_exists(&mut db, "notebooks", notebook_id, &owner) {
+ return not_found(&format!("notebooks/{notebook_id}"));
+ }
+ let key = build_key("notes", id);
+ let mut note = match db.get(&key) {
+ Ok(Some(val)) => match akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
+ Ok(v) => v,
+ Err(_) => return internal_error("corrupted data"),
+ },
+ Ok(None) => return not_found(&format!("notes/{id}")),
+ Err(e) => return internal_error(&format!("storage: {e}")),
+ };
+ if !owner_matches(¬e, &owner) {
+ return not_found(&format!("notes/{id}"));
+ }
+ if let Value::Object(ref mut fields) = note {
+ set_field(fields, "notebookId", Value::Int(notebook_id));
+ if let Some(sort_order) = sort_order {
+ set_field(fields, "sortOrder", Value::Int(sort_order));
+ }
+ set_field(fields, "updatedAt", Value::Int(NotesState::now()));
+ }
+ if let Err(e) = db.insert(&key, note.to_json().as_bytes()) {
+ return internal_error(&format!("insert: {e}"));
+ }
+ if let Err(e) = db.commit() {
+ return internal_error(&format!("commit: {e}"));
+ }
+ json_response(Value::Object(vec![
+ ("updated".into(), Value::Bool(true)),
+ ("id".into(), Value::Int(id as i64)),
+ ("notebookId".into(), Value::Int(notebook_id)),
+ ("sortOrder".into(), optional_int(sort_order)),
+ ]))
+ })
+}
+
+pub fn reorder_notes_route(state: Arc<Mutex<NotesState>>) -> RouteHandler {
+ Box::new(move |req: &Request| {
+ let body = match parse_body(req) {
+ Ok(v) => v,
+ Err(e) => return bad_request(&e),
+ };
+ let Value::Object(args) = body else {
+ return bad_request("expected JSON object");
+ };
+ let notebook_id = match get_pair(&args, "notebookId").and_then(value_i64) {
+ Some(id) => id,
+ None => return bad_request("notebookId required"),
+ };
+ let note_ids = match int_array_arg(&args, "noteIds") {
+ Ok(ids) => ids,
+ Err(e) => return bad_request(&e),
+ };
+ let state_lock = match state.lock() {
+ Ok(s) => s,
+ Err(_) => return internal_error("lock poisoned"),
+ };
+ let owner = match get_owner_ids(&state_lock, req) {
+ Some(o) => o,
+ None => {
+ return json_response(Value::Object(vec![(
+ "error".into(),
+ Value::Str("unauthorized".into()),
+ )]))
+ }
+ };
+ let mut db = match state_lock.db.lock() {
+ Ok(db) => db,
+ Err(_) => return internal_error("db lock poisoned"),
+ };
+ if !owned_record_exists(&mut db, "notebooks", notebook_id, &owner) {
+ return not_found(&format!("notebooks/{notebook_id}"));
+ }
+ for (index, note_id) in note_ids.iter().enumerate() {
+ if *note_id <= 0 {
+ return bad_request("noteIds must contain positive IDs");
+ }
+ let key = build_key("notes", *note_id as u64);
+ let mut note = match db.get(&key) {
+ Ok(Some(val)) => {
+ match akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
+ Ok(v) => v,
+ Err(_) => return internal_error("corrupted data"),
+ }
+ }
+ Ok(None) => return not_found(&format!("notes/{note_id}")),
+ Err(e) => return internal_error(&format!("storage: {e}")),
+ };
+ if !owner_matches(¬e, &owner)
+ || note.get("notebookId").and_then(value_i64) != Some(notebook_id)
+ {
+ return not_found(&format!("notes/{note_id}"));
+ }
+ if let Value::Object(ref mut fields) = note {
+ set_field(fields, "sortOrder", Value::Int((index + 1) as i64));
+ set_field(fields, "updatedAt", Value::Int(NotesState::now()));
+ }
+ if let Err(e) = db.insert(&key, note.to_json().as_bytes()) {
+ return internal_error(&format!("insert: {e}"));
+ }
+ }
+ if let Err(e) = db.commit() {
+ return internal_error(&format!("commit: {e}"));
+ }
+ json_response(Value::Object(vec![
+ ("updated".into(), Value::Bool(true)),
+ ("notebookId".into(), Value::Int(notebook_id)),
+ ("count".into(), Value::Int(note_ids.len() as i64)),
+ ]))
+ })
+}
+
+pub fn move_notebook_route(state: Arc<Mutex<NotesState>>) -> RouteHandler {
+ Box::new(move |req: &Request| {
+ let id = match extract_any_id(&req.path) {
+ Some(id) => id,
+ None => return bad_request("missing id"),
+ };
+ let body = match parse_body(req) {
+ Ok(v) => v,
+ Err(e) => return bad_request(&e),
+ };
+ let Value::Object(args) = body else {
+ return bad_request("expected JSON object");
+ };
+ let parent_id = get_pair(&args, "parentId")
+ .and_then(value_i64)
+ .filter(|id| *id > 0);
+ let sort_order = get_pair(&args, "sortOrder").and_then(value_i64);
+ let state_lock = match state.lock() {
+ Ok(s) => s,
+ Err(_) => return internal_error("lock poisoned"),
+ };
+ let owner = match get_owner_ids(&state_lock, req) {
+ Some(o) => o,
+ None => {
+ return json_response(Value::Object(vec![(
+ "error".into(),
+ Value::Str("unauthorized".into()),
+ )]))
+ }
+ };
+ let mut db = match state_lock.db.lock() {
+ Ok(db) => db,
+ Err(_) => return internal_error("db lock poisoned"),
+ };
+ if let Some(parent_id) = parent_id {
+ if !owned_record_exists(&mut db, "notebooks", parent_id, &owner) {
+ return not_found(&format!("notebooks/{parent_id}"));
+ }
+ }
+ if notebook_parent_would_cycle(&mut db, id as i64, parent_id, &owner) {
+ return bad_request("cannot move a notebook under itself or its descendants");
+ }
+ let key = build_key("notebooks", id);
+ let mut notebook = match db.get(&key) {
+ Ok(Some(val)) => match akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
+ Ok(v) => v,
+ Err(_) => return internal_error("corrupted data"),
+ },
+ Ok(None) => return not_found(&format!("notebooks/{id}")),
+ Err(e) => return internal_error(&format!("storage: {e}")),
+ };
+ if !owner_matches(¬ebook, &owner) {
+ return not_found(&format!("notebooks/{id}"));
+ }
+ if let Value::Object(ref mut fields) = notebook {
+ set_field(fields, "parentId", optional_int(parent_id));
+ if let Some(sort_order) = sort_order {
+ set_field(fields, "sortOrder", Value::Int(sort_order));
+ }
+ set_field(fields, "updatedAt", Value::Int(NotesState::now()));
+ }
+ if let Err(e) = db.insert(&key, notebook.to_json().as_bytes()) {
+ return internal_error(&format!("insert: {e}"));
+ }
+ if let Err(e) = db.commit() {
+ return internal_error(&format!("commit: {e}"));
+ }
+ json_response(Value::Object(vec![
+ ("updated".into(), Value::Bool(true)),
+ ("id".into(), Value::Int(id as i64)),
+ ("parentId".into(), optional_int(parent_id)),
+ ("sortOrder".into(), optional_int(sort_order)),
+ ]))
+ })
+}
+
+pub fn reorder_notebooks_route(state: Arc<Mutex<NotesState>>) -> RouteHandler {
+ Box::new(move |req: &Request| {
+ let body = match parse_body(req) {
+ Ok(v) => v,
+ Err(e) => return bad_request(&e),
+ };
+ let Value::Object(args) = body else {
+ return bad_request("expected JSON object");
+ };
+ let parent_id = get_pair(&args, "parentId")
+ .and_then(value_i64)
+ .filter(|id| *id > 0);
+ let notebook_ids = match int_array_arg(&args, "notebookIds") {
+ Ok(ids) => ids,
+ Err(e) => return bad_request(&e),
+ };
+ let state_lock = match state.lock() {
+ Ok(s) => s,
+ Err(_) => return internal_error("lock poisoned"),
+ };
+ let owner = match get_owner_ids(&state_lock, req) {
+ Some(o) => o,
+ None => {
+ return json_response(Value::Object(vec![(
+ "error".into(),
+ Value::Str("unauthorized".into()),
+ )]))
+ }
+ };
+ let mut db = match state_lock.db.lock() {
+ Ok(db) => db,
+ Err(_) => return internal_error("db lock poisoned"),
+ };
+ if let Some(parent_id) = parent_id {
+ if !owned_record_exists(&mut db, "notebooks", parent_id, &owner) {
+ return not_found(&format!("notebooks/{parent_id}"));
+ }
+ }
+ for (index, notebook_id) in notebook_ids.iter().enumerate() {
+ if *notebook_id <= 0 {
+ return bad_request("notebookIds must contain positive IDs");
+ }
+ if notebook_parent_would_cycle(&mut db, *notebook_id, parent_id, &owner) {
+ return bad_request("cannot move a notebook under itself or its descendants");
+ }
+ let key = build_key("notebooks", *notebook_id as u64);
+ let mut notebook = match db.get(&key) {
+ Ok(Some(val)) => {
+ match akurai_json::parse(std::str::from_utf8(&val).unwrap_or("")) {
+ Ok(v) => v,
+ Err(_) => return internal_error("corrupted data"),
+ }
+ }
+ Ok(None) => return not_found(&format!("notebooks/{notebook_id}")),
+ Err(e) => return internal_error(&format!("storage: {e}")),
+ };
+ if !owner_matches(¬ebook, &owner)
+ || notebook
+ .get("parentId")
+ .and_then(value_i64)
+ .filter(|id| *id > 0)
+ != parent_id
+ {
+ return not_found(&format!("notebooks/{notebook_id}"));
+ }
+ if let Value::Object(ref mut fields) = notebook {
+ set_field(fields, "sortOrder", Value::Int((index + 1) as i64));
+ set_field(fields, "updatedAt", Value::Int(NotesState::now()));
+ }
+ if let Err(e) = db.insert(&key, notebook.to_json().as_bytes()) {
+ return internal_error(&format!("insert: {e}"));
+ }
+ }
+ if let Err(e) = db.commit() {
+ return internal_error(&format!("commit: {e}"));
+ }
+ json_response(Value::Object(vec![
+ ("updated".into(), Value::Bool(true)),
+ ("parentId".into(), optional_int(parent_id)),
+ ("count".into(), Value::Int(notebook_ids.len() as i64)),
+ ]))
+ })
+}
+
pub fn delete_route(state: Arc<Mutex<NotesState>>, coll: &'static str) -> RouteHandler {
Box::new(move |req: &Request| {
let id = match extract_id(&req.path) {
@@ -568,6 +917,169 @@ fn set_field(pairs: &mut Vec<(String, Value)>, key: &str, value: Value) {
pairs.push((key.to_string(), value));
}
+fn value_i64(value: &Value) -> Option<i64> {
+ match value {
+ Value::Int(n) => Some(*n),
+ Value::Float(n) if n.is_finite() => Some(*n as i64),
+ _ => None,
+ }
+}
+
+fn optional_int(value: Option<i64>) -> Value {
+ value.map(Value::Int).unwrap_or(Value::Null)
+}
+
+fn int_array_arg(pairs: &[(String, Value)], key: &str) -> Result<Vec<i64>, String> {
+ let Some(Value::Array(items)) = get_pair(pairs, key) else {
+ return Err(format!("{key} required"));
+ };
+ items
+ .iter()
+ .map(|item| value_i64(item).ok_or_else(|| format!("{key} must contain numbers")))
+ .collect()
+}
+
+fn read_record_locked(db: &mut akurai_storage::BTree, collection: &str, id: i64) -> Option<Value> {
+ if id <= 0 {
+ return None;
+ }
+ let key = build_key(collection, id as u64);
+ db.get(&key)
+ .ok()
+ .flatten()
+ .and_then(|bytes| akurai_json::parse(std::str::from_utf8(&bytes).unwrap_or("")).ok())
+}
+
+fn owned_record_exists(
+ db: &mut akurai_storage::BTree,
+ collection: &str,
+ id: i64,
+ owner: &OwnerIds,
+) -> bool {
+ if collection == "notebooks" && id == 1 && read_record_locked(db, collection, id).is_none() {
+ return true;
+ }
+ read_record_locked(db, collection, id)
+ .as_ref()
+ .is_some_and(|record| owner_matches(record, owner))
+}
+
+fn notebook_parent_would_cycle(
+ db: &mut akurai_storage::BTree,
+ id: i64,
+ parent_id: Option<i64>,
+ owner: &OwnerIds,
+) -> bool {
+ let mut cursor = parent_id;
+ while let Some(parent) = cursor {
+ if parent == id {
+ return true;
+ }
+ let Some(record) = read_record_locked(db, "notebooks", parent) else {
+ return false;
+ };
+ if !owner_matches(&record, owner) {
+ return false;
+ }
+ cursor = record
+ .get("parentId")
+ .and_then(value_i64)
+ .filter(|id| *id > 0);
+ }
+ false
+}
+
+fn scan_collection_locked(db: &mut akurai_storage::BTree, collection: &str) -> Vec<Value> {
+ let prefix = format!("{collection}:").into_bytes();
+ let end = upper_bound(&prefix);
+ let mut records = Vec::new();
+ if let Ok(entries) = db.range(&prefix, &end) {
+ for (_, val_bytes) in entries {
+ if let Ok(json) = akurai_json::parse(std::str::from_utf8(&val_bytes).unwrap_or("")) {
+ records.push(json);
+ }
+ }
+ }
+ records
+}
+
+fn normalize_note(note: &mut Value) {
+ let id = note.get("id").and_then(value_i64).unwrap_or_default();
+ if let Value::Object(fields) = note {
+ if !fields.iter().any(|(key, _)| key == "sortOrder") {
+ set_field(fields, "sortOrder", Value::Int(id));
+ }
+ }
+}
+
+fn normalize_notebook(notebook: &mut Value) {
+ let id = notebook.get("id").and_then(value_i64).unwrap_or_default();
+ let created_at = notebook
+ .get("createdAt")
+ .and_then(value_i64)
+ .unwrap_or_default();
+ if let Value::Object(fields) = notebook {
+ if !fields.iter().any(|(key, _)| key == "parentId") {
+ set_field(fields, "parentId", Value::Null);
+ }
+ if !fields.iter().any(|(key, _)| key == "sortOrder") {
+ set_field(fields, "sortOrder", Value::Int(id));
+ }
+ if !fields.iter().any(|(key, _)| key == "updatedAt") {
+ set_field(fields, "updatedAt", Value::Int(created_at));
+ }
+ }
+}
+
+fn sort_notes(notes: &mut [Value]) {
+ notes.sort_by(|a, b| {
+ let a_notebook = a.get("notebookId").and_then(value_i64).unwrap_or_default();
+ let b_notebook = b.get("notebookId").and_then(value_i64).unwrap_or_default();
+ let a_order = a
+ .get("sortOrder")
+ .and_then(value_i64)
+ .or_else(|| a.get("id").and_then(value_i64))
+ .unwrap_or_default();
+ let b_order = b
+ .get("sortOrder")
+ .and_then(value_i64)
+ .or_else(|| b.get("id").and_then(value_i64))
+ .unwrap_or_default();
+ let a_id = a.get("id").and_then(value_i64).unwrap_or_default();
+ let b_id = b.get("id").and_then(value_i64).unwrap_or_default();
+ a_notebook
+ .cmp(&b_notebook)
+ .then_with(|| a_order.cmp(&b_order))
+ .then_with(|| a_id.cmp(&b_id))
+ });
+}
+
+fn sort_notebooks(notebooks: &mut [Value]) {
+ notebooks.sort_by(|a, b| {
+ let a_parent = a.get("parentId").and_then(value_i64).unwrap_or_default();
+ let b_parent = b.get("parentId").and_then(value_i64).unwrap_or_default();
+ let a_order = a
+ .get("sortOrder")
+ .and_then(value_i64)
+ .or_else(|| a.get("id").and_then(value_i64))
+ .unwrap_or_default();
+ let b_order = b
+ .get("sortOrder")
+ .and_then(value_i64)
+ .or_else(|| b.get("id").and_then(value_i64))
+ .unwrap_or_default();
+ let a_name = a.get("name").and_then(Value::as_str).unwrap_or("");
+ let b_name = b.get("name").and_then(Value::as_str).unwrap_or("");
+ let a_id = a.get("id").and_then(value_i64).unwrap_or_default();
+ let b_id = b.get("id").and_then(value_i64).unwrap_or_default();
+ a_parent
+ .cmp(&b_parent)
+ .then_with(|| a_order.cmp(&b_order))
+ .then_with(|| a_name.cmp(b_name))
+ .then_with(|| a_id.cmp(&b_id))
+ });
+}
+
struct BodyEdit {
body: String,
replacement_count: usize,
diff --git a/crates/notes-api/src/router_setup.rs b/crates/notes-api/src/router_setup.rs
index 2241360..826bfed 100644
--- a/crates/notes-api/src/router_setup.rs
+++ b/crates/notes-api/src/router_setup.rs
@@ -123,11 +123,21 @@ pub fn build_router(state: Arc<Mutex<NotesState>>) -> Vec<Route> {
"/api/notes/:id",
handlers::crud::get_route(Arc::clone(&state), "notes"),
),
+ r(
+ "POST",
+ "/api/notes/reorder",
+ handlers::crud::reorder_notes_route(Arc::clone(&state)),
+ ),
r(
"POST",
"/api/notes",
handlers::crud::create_route(Arc::clone(&state), "notes"),
),
+ r(
+ "POST",
+ "/api/notes/:id/move",
+ handlers::crud::move_note_route(Arc::clone(&state)),
+ ),
r(
"PUT",
"/api/notes/:id",
@@ -154,11 +164,21 @@ pub fn build_router(state: Arc<Mutex<NotesState>>) -> Vec<Route> {
"/api/notebooks/:id",
handlers::crud::get_route(Arc::clone(&state), "notebooks"),
),
+ r(
+ "POST",
+ "/api/notebooks/reorder",
+ handlers::crud::reorder_notebooks_route(Arc::clone(&state)),
+ ),
r(
"POST",
"/api/notebooks",
handlers::crud::create_route(Arc::clone(&state), "notebooks"),
),
+ r(
+ "POST",
+ "/api/notebooks/:id/move",
+ handlers::crud::move_notebook_route(Arc::clone(&state)),
+ ),
r(
"PUT",
"/api/notebooks/:id",
diff --git a/crates/notes-core/src/entities/note.rs b/crates/notes-core/src/entities/note.rs
index a63dea4..31f017d 100644
--- a/crates/notes-core/src/entities/note.rs
+++ b/crates/notes-core/src/entities/note.rs
@@ -6,6 +6,7 @@ pub struct Note {
pub body: String,
pub format: String,
pub notebook_id: i64,
+ pub sort_order: i64,
pub owner_sub: String,
pub tag_ids: Vec<i64>,
pub created_at: i64,
@@ -21,6 +22,10 @@ impl Note {
body: v.get("body")?.as_str().unwrap_or("").to_string(),
format: v.get("format")?.as_str().unwrap_or("markdown").to_string(),
notebook_id: v.get("notebookId")?.as_i64()?,
+ sort_order: v
+ .get("sortOrder")
+ .and_then(|value| value.as_i64())
+ .unwrap_or_else(|| v.get("id").and_then(|value| value.as_i64()).unwrap_or(0)),
owner_sub: v.get("ownerSub")?.as_str()?.to_string(),
tag_ids: v
.get("tagIds")?
@@ -42,6 +47,7 @@ impl Note {
("body".into(), Value::Str(self.body.clone())),
("format".into(), Value::Str(self.format.clone())),
("notebookId".into(), Value::Int(self.notebook_id)),
+ ("sortOrder".into(), Value::Int(self.sort_order)),
("ownerSub".into(), Value::Str(self.owner_sub.clone())),
(
"tagIds".into(),
diff --git a/crates/notes-core/src/entities/notebook.rs b/crates/notes-core/src/entities/notebook.rs
index 68488d1..1744602 100644
--- a/crates/notes-core/src/entities/notebook.rs
+++ b/crates/notes-core/src/entities/notebook.rs
@@ -6,7 +6,10 @@ pub struct Notebook {
pub description: String,
pub owner_sub: String,
pub color: String,
+ pub parent_id: Option<i64>,
+ pub sort_order: i64,
pub created_at: i64,
+ pub updated_at: i64,
}
impl Notebook {
@@ -17,7 +20,20 @@ impl Notebook {
description: v.get("description")?.as_str().unwrap_or("").to_string(),
owner_sub: v.get("ownerSub")?.as_str()?.to_string(),
color: v.get("color")?.as_str().unwrap_or("#5b8cff").to_string(),
+ parent_id: v.get("parentId").and_then(|value| value.as_i64()),
+ sort_order: v
+ .get("sortOrder")
+ .and_then(|value| value.as_i64())
+ .unwrap_or_else(|| v.get("id").and_then(|value| value.as_i64()).unwrap_or(0)),
created_at: v.get("createdAt")?.as_i64()?,
+ updated_at: v
+ .get("updatedAt")
+ .and_then(|value| value.as_i64())
+ .unwrap_or_else(|| {
+ v.get("createdAt")
+ .and_then(|value| value.as_i64())
+ .unwrap_or(0)
+ }),
})
}
@@ -28,7 +44,13 @@ impl Notebook {
("description".into(), Value::Str(self.description.clone())),
("ownerSub".into(), Value::Str(self.owner_sub.clone())),
("color".into(), Value::Str(self.color.clone())),
+ (
+ "parentId".into(),
+ self.parent_id.map(Value::Int).unwrap_or(Value::Null),
+ ),
+ ("sortOrder".into(), Value::Int(self.sort_order)),
("createdAt".into(), Value::Int(self.created_at)),
+ ("updatedAt".into(), Value::Int(self.updated_at)),
])
}
}
diff --git a/crates/notes-core/src/metadata/mod.rs b/crates/notes-core/src/metadata/mod.rs
index 82ae4d2..21275f8 100644
--- a/crates/notes-core/src/metadata/mod.rs
+++ b/crates/notes-core/src/metadata/mod.rs
@@ -40,6 +40,12 @@ impl ObjectMetadata {
required: false,
embed: false,
},
+ FieldMetadata {
+ name: "sortOrder",
+ field_type: "int",
+ required: false,
+ embed: false,
+ },
FieldMetadata {
name: "ownerSub",
field_type: "text",
@@ -99,12 +105,30 @@ impl ObjectMetadata {
required: false,
embed: false,
},
+ FieldMetadata {
+ name: "parentId",
+ field_type: "int",
+ required: false,
+ embed: false,
+ },
+ FieldMetadata {
+ name: "sortOrder",
+ field_type: "int",
+ required: false,
+ embed: false,
+ },
FieldMetadata {
name: "createdAt",
field_type: "int",
required: false,
embed: false,
},
+ FieldMetadata {
+ name: "updatedAt",
+ field_type: "int",
+ required: false,
+ embed: false,
+ },
],
},
ObjectMetadata {
diff --git a/crates/notes-mcp/src/tools.rs b/crates/notes-mcp/src/tools.rs
index ea08cf9..fb12eb2 100644
--- a/crates/notes-mcp/src/tools.rs
+++ b/crates/notes-mcp/src/tools.rs
@@ -61,7 +61,11 @@ impl ToolRegistry {
"List all notes, optionally filtered by notebookId",
vec![("notebookId", "number", "Filter by notebook ID", false)],
),
- self.tool_def("list_notebooks", "List all notebooks", vec![]),
+ self.tool_def(
+ "list_notebooks",
+ "List all notebooks/folders with parentId, sortOrder, and noteCount",
+ vec![],
+ ),
self.tool_def(
"create_note",
"Create a new note",
@@ -83,6 +87,33 @@ impl ToolRegistry {
("archived", "boolean", "Archive or unarchive", false),
],
),
+ self.tool_def(
+ "move_note",
+ "Move a note to a notebook/folder and optionally set its sort order",
+ vec![
+ ("noteId", "number", "Note ID", true),
+ (
+ "notebookId",
+ "number",
+ "Destination notebook/folder ID",
+ true,
+ ),
+ (
+ "sortOrder",
+ "number",
+ "Sort order inside destination",
+ false,
+ ),
+ ],
+ ),
+ self.tool_def(
+ "reorder_notes",
+ "Set note order for a notebook/folder",
+ vec![
+ ("notebookId", "number", "Notebook/folder ID", true),
+ ("noteIds", "array", "Ordered note IDs", true),
+ ],
+ ),
self.tool_def(
"edit_note_body",
"Targeted edit of an existing note body without replacing the whole note",
@@ -127,6 +158,13 @@ impl ToolRegistry {
("name", "string", "Notebook name", true),
("description", "string", "Notebook description", false),
("color", "string", "Hex color like #5b8cff", false),
+ ("parentId", "number", "Parent folder ID", false),
+ (
+ "sortOrder",
+ "number",
+ "Sort order among sibling folders",
+ false,
+ ),
],
),
self.tool_def(
@@ -137,6 +175,35 @@ impl ToolRegistry {
("name", "string", "New name", false),
("description", "string", "New description", false),
("color", "string", "New hex color", false),
+ ("parentId", "number", "New parent folder ID", false),
+ ("sortOrder", "number", "New sort order", false),
+ ],
+ ),
+ self.tool_def(
+ "move_notebook",
+ "Move a notebook/folder under another folder or to the root",
+ vec![
+ ("id", "number", "Notebook/folder ID", true),
+ (
+ "parentId",
+ "number",
+ "New parent folder ID; omit for root",
+ false,
+ ),
+ ("sortOrder", "number", "Sort order among siblings", false),
+ ],
+ ),
+ self.tool_def(
+ "reorder_notebooks",
+ "Set folder order under a parent folder or root",
+ vec![
+ (
+ "parentId",
+ "number",
+ "Parent folder ID; omit for root",
+ false,
+ ),
+ ("notebookIds", "array", "Ordered notebook/folder IDs", true),
],
),
self.tool_def(
@@ -239,10 +306,14 @@ impl ToolRegistry {
"list_notebooks" => self.list_notebooks(),
"create_note" => self.create_note(args),
"update_note" => self.update_note(args),
+ "move_note" => self.move_note(args),
+ "reorder_notes" => self.reorder_notes(args),
"edit_note_body" => self.edit_note_body(args),
"delete_note" => self.delete_note(args),
"create_notebook" => self.create_notebook(args),
"update_notebook" => self.update_notebook(args),
+ "move_notebook" => self.move_notebook(args),
+ "reorder_notebooks" => self.reorder_notebooks(args),
"delete_notebook" => self.delete_notebook(args),
"get_notebook" => self.get_notebook(args),
"create_tag" => self.create_tag(args),
@@ -384,11 +455,35 @@ impl ToolRegistry {
args.iter().find(|(k, _)| k == name).map(|(_, v)| v)
}
+ fn arg_i64(args: &[(String, Value)], name: &str) -> Option<i64> {
+ Self::get_arg(args, name).and_then(value_i64)
+ }
+
+ fn arg_id(args: &[(String, Value)], name: &str) -> Result<u64, String> {
+ Self::arg_i64(args, name)
+ .and_then(|id| u64::try_from(id).ok())
+ .ok_or_else(|| format!("missing {name}"))
+ }
+
+ fn arg_i64_array(args: &[(String, Value)], name: &str) -> Result<Vec<i64>, String> {
+ let Some(Value::Array(items)) = Self::get_arg(args, name) else {
+ return Err(format!("missing {name}"));
+ };
+ items
+ .iter()
+ .map(|item| value_i64(item).ok_or_else(|| format!("{name} must contain numbers")))
+ .collect()
+ }
+
fn scan_collection(&self, collection: &str) -> Result<Vec<Value>, String> {
+ let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+ Self::scan_collection_locked(&mut db, collection)
+ }
+
+ fn scan_collection_locked(db: &mut BTree, collection: &str) -> Result<Vec<Value>, String> {
let prefix = format!("{}:", collection);
let start = prefix.as_bytes().to_vec();
let end = upper_bound(&start);
- let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
let mut results = Vec::new();
if let Ok(entries) = db.range(&start, &end) {
for (_, val_bytes) in entries {
@@ -401,6 +496,68 @@ impl ToolRegistry {
Ok(results)
}
+ fn read_record_locked(
+ db: &mut BTree,
+ collection: &str,
+ id: u64,
+ ) -> Result<Option<Value>, String> {
+ let mut key = format!("{}:", collection).into_bytes();
+ key.extend_from_slice(&id.to_be_bytes());
+ match db.get(&key).map_err(|e| format!("storage: {e}"))? {
+ Some(bytes) => akurai_json::parse(&String::from_utf8_lossy(&bytes))
+ .map(Some)
+ .map_err(|e| format!("parse: {e}")),
+ None => Ok(None),
+ }
+ }
+
+ fn write_record_locked(
+ db: &mut BTree,
+ collection: &str,
+ id: u64,
+ record: &Value,
+ ) -> Result<(), String> {
+ let mut key = format!("{}:", collection).into_bytes();
+ key.extend_from_slice(&id.to_be_bytes());
+ db.insert(&key, record.to_json().as_bytes())
+ .map_err(|e| format!("insert: {e}"))
+ }
+
+ fn owned_notebook_exists_locked(&self, db: &mut BTree, id: i64) -> Result<bool, String> {
+ if id <= 0 {
+ return Ok(false);
+ }
+ let Some(record) = Self::read_record_locked(db, "notebooks", id as u64)? else {
+ return Ok(id == 1);
+ };
+ Ok(is_owned_by(&record, self.owner_sub()))
+ }
+
+ fn notebook_parent_would_cycle_locked(
+ &self,
+ db: &mut BTree,
+ id: i64,
+ parent_id: Option<i64>,
+ ) -> Result<bool, String> {
+ let mut cursor = parent_id;
+ while let Some(parent) = cursor {
+ if parent == id {
+ return Ok(true);
+ }
+ let Some(record) = Self::read_record_locked(db, "notebooks", parent as u64)? else {
+ return Ok(false);
+ };
+ if !is_owned_by(&record, self.owner_sub()) {
+ return Ok(false);
+ }
+ cursor = record
+ .get("parentId")
+ .and_then(value_i64)
+ .filter(|id| *id > 0);
+ }
+ Ok(false)
+ }
+
fn get_record(&self, collection: &str, id: u64) -> Result<Option<Value>, String> {
let mut key = format!("{}:", collection).into_bytes();
key.extend_from_slice(&id.to_be_bytes());
@@ -520,19 +677,38 @@ impl ToolRegistry {
}
fn list_notes(&self, args: &[(String, Value)]) -> Result<Value, String> {
- let filter_notebook =
- Self::get_arg(args, "notebookId").and_then(|v| v.as_f64().map(|f| f as i64));
+ let filter_notebook = Self::arg_i64(args, "notebookId");
let mut notes = self.scan_collection("notes")?;
notes.retain(|n| n.get("ownerSub").and_then(|o| o.as_str()) == Some(self.owner_sub()));
if let Some(nbid) = filter_notebook {
notes.retain(|n| n.get("notebookId").and_then(|v| v.as_i64()) == Some(nbid));
}
+ for note in &mut notes {
+ normalize_note(note);
+ }
+ sort_notes(&mut notes);
Ok(Value::Array(notes))
}
fn list_notebooks(&self) -> Result<Value, String> {
let mut notebooks = self.scan_collection("notebooks")?;
notebooks.retain(|n| n.get("ownerSub").and_then(|o| o.as_str()) == Some(self.owner_sub()));
+ let notes = self.scan_collection("notes")?;
+ for notebook in &mut notebooks {
+ normalize_notebook(notebook);
+ let id = notebook.get("id").and_then(value_i64).unwrap_or_default();
+ let note_count = notes
+ .iter()
+ .filter(|note| {
+ is_owned_by(note, self.owner_sub())
+ && note.get("notebookId").and_then(value_i64) == Some(id)
+ })
+ .count();
+ if let Value::Object(fields) = notebook {
+ set_field(fields, "noteCount", Value::Int(note_count as i64));
+ }
+ }
+ sort_notebooks(&mut notebooks);
Ok(Value::Array(notebooks))
}
@@ -591,6 +767,8 @@ impl ToolRegistry {
let color = Self::get_arg(args, "color")
.and_then(|v| v.as_str())
.unwrap_or("#5b8cff");
+ let parent_id = Self::arg_i64(args, "parentId").filter(|id| *id > 0);
+ let sort_order = Self::arg_i64(args, "sortOrder").unwrap_or(id as i64);
let fields = vec![
("id".into(), Value::Int(id as i64)),
@@ -598,8 +776,18 @@ impl ToolRegistry {
("description".into(), Value::Str(description.into())),
("ownerSub".into(), Value::Str(self.owner_sub().into())),
("color".into(), Value::Str(color.into())),
+ ("parentId".into(), optional_int(parent_id)),
+ ("sortOrder".into(), Value::Int(sort_order)),
("createdAt".into(), Value::Int(now)),
+ ("updatedAt".into(), Value::Int(now)),
];
+ if let Some(parent_id) = parent_id {
+ let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+ if !self.owned_notebook_exists_locked(&mut db, parent_id)? {
+ return Err(format!("parent notebook {parent_id} not found"));
+ }
+ drop(db);
+ }
let record = Value::Object(fields);
let mut key = Vec::from(b"notebooks:" as &[u8]);
@@ -612,6 +800,8 @@ impl ToolRegistry {
Ok(Value::Object(vec![
("id".into(), Value::Int(id as i64)),
("name".into(), Value::Str(name.into())),
+ ("parentId".into(), optional_int(parent_id)),
+ ("sortOrder".into(), Value::Int(sort_order)),
]))
}
@@ -652,6 +842,29 @@ impl ToolRegistry {
set_field(pairs, "color", Value::Str(color.to_string()));
}
}
+ let parent_present = Self::get_arg(args, "parentId").is_some();
+ let parent_id = Self::arg_i64(args, "parentId").filter(|id| *id > 0);
+ if parent_present {
+ if let Some(parent_id) = parent_id {
+ if !self.owned_notebook_exists_locked(&mut db, parent_id)? {
+ return Err(format!("parent notebook {parent_id} not found"));
+ }
+ }
+ if self.notebook_parent_would_cycle_locked(&mut db, id as i64, parent_id)? {
+ return Err("cannot move a notebook under itself or its descendants".into());
+ }
+ if let Value::Object(ref mut pairs) = nb {
+ set_field(pairs, "parentId", optional_int(parent_id));
+ }
+ }
+ if let Some(sort_order) = Self::arg_i64(args, "sortOrder") {
+ if let Value::Object(ref mut pairs) = nb {
+ set_field(pairs, "sortOrder", Value::Int(sort_order));
+ }
+ }
+ if let Value::Object(ref mut pairs) = nb {
+ set_field(pairs, "updatedAt", Value::Int(now()));
+ }
db.insert(&key, nb.to_json().as_bytes())
.map_err(|e| format!("insert: {e}"))?;
@@ -660,6 +873,76 @@ impl ToolRegistry {
Ok(Value::Object(vec![("updated".into(), Value::Bool(true))]))
}
+ fn move_notebook(&self, args: &[(String, Value)]) -> Result<Value, String> {
+ let id = Self::arg_id(args, "id")?;
+ let parent_id = Self::arg_i64(args, "parentId").filter(|id| *id > 0);
+ let sort_order = Self::arg_i64(args, "sortOrder");
+ let mut update_args = vec![("id".into(), Value::Int(id as i64))];
+ update_args.push(("parentId".into(), optional_int(parent_id)));
+ if let Some(sort_order) = sort_order {
+ update_args.push(("sortOrder".into(), Value::Int(sort_order)));
+ }
+ self.update_notebook(&update_args)?;
+
+ Ok(Value::Object(vec![
+ ("updated".into(), Value::Bool(true)),
+ ("id".into(), Value::Int(id as i64)),
+ ("parentId".into(), optional_int(parent_id)),
+ ("sortOrder".into(), optional_int(sort_order)),
+ ]))
+ }
+
+ fn reorder_notebooks(&self, args: &[(String, Value)]) -> Result<Value, String> {
+ let parent_id = Self::arg_i64(args, "parentId").filter(|id| *id > 0);
+ let notebook_ids = Self::arg_i64_array(args, "notebookIds")?;
+
+ let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+ if let Some(parent_id) = parent_id {
+ if !self.owned_notebook_exists_locked(&mut db, parent_id)? {
+ return Err(format!("parent notebook {parent_id} not found"));
+ }
+ }
+
+ for (index, notebook_id) in notebook_ids.iter().enumerate() {
+ if *notebook_id <= 0 {
+ return Err("notebookIds must contain positive IDs".into());
+ }
+ if self.notebook_parent_would_cycle_locked(&mut db, *notebook_id, parent_id)? {
+ return Err("cannot move a notebook under itself or its descendants".into());
+ }
+ let Some(mut notebook) =
+ Self::read_record_locked(&mut db, "notebooks", *notebook_id as u64)?
+ else {
+ return Err(format!("notebook {notebook_id} not found"));
+ };
+ if !is_owned_by(¬ebook, self.owner_sub()) {
+ return Err(format!("notebook {notebook_id} not found or unauthorized"));
+ }
+ if notebook
+ .get("parentId")
+ .and_then(value_i64)
+ .filter(|id| *id > 0)
+ != parent_id
+ {
+ return Err(format!(
+ "notebook {notebook_id} is not under requested parent"
+ ));
+ }
+ if let Value::Object(ref mut fields) = notebook {
+ set_field(fields, "sortOrder", Value::Int((index + 1) as i64));
+ set_field(fields, "updatedAt", Value::Int(now()));
+ }
+ Self::write_record_locked(&mut db, "notebooks", *notebook_id as u64, ¬ebook)?;
+ }
+ db.commit().map_err(|e| format!("commit: {e}"))?;
+
+ Ok(Value::Object(vec![
+ ("updated".into(), Value::Bool(true)),
+ ("parentId".into(), optional_int(parent_id)),
+ ("count".into(), Value::Int(notebook_ids.len() as i64)),
+ ]))
+ }
+
fn delete_notebook(&self, args: &[(String, Value)]) -> Result<Value, String> {
let id = Self::get_arg(args, "id")
.and_then(|v| v.as_f64())
@@ -690,6 +973,23 @@ impl ToolRegistry {
return Err(format!("notebook {id} not found or unauthorized"));
}
+ let notes = Self::scan_collection_locked(&mut db, "notes")?;
+ let has_notes = notes.iter().any(|note| {
+ is_owned_by(note, self.owner_sub())
+ && note.get("notebookId").and_then(value_i64) == Some(id as i64)
+ });
+ if has_notes {
+ return Err("cannot delete a notebook that contains notes".into());
+ }
+ let notebooks = Self::scan_collection_locked(&mut db, "notebooks")?;
+ let has_children = notebooks.iter().any(|notebook| {
+ is_owned_by(notebook, self.owner_sub())
+ && notebook.get("parentId").and_then(value_i64) == Some(id as i64)
+ });
+ if has_children {
+ return Err("cannot delete a notebook that contains child folders".into());
+ }
+
db.delete(&key).map_err(|e| format!("delete: {e}"))?;
db.commit().map_err(|e| format!("commit: {e}"))?;
@@ -844,19 +1144,24 @@ impl ToolRegistry {
.and_then(|v| v.as_str())
.unwrap_or("");
let notebook_id = Self::get_arg(args, "notebookId")
- .and_then(|v| v.as_f64())
- .map(|f| f as i64)
+ .and_then(value_i64)
.unwrap_or(1);
+ let sort_order = Self::arg_i64(args, "sortOrder").unwrap_or(next_id as i64);
let archived = Self::get_arg(args, "archived")
.and_then(|v| v.as_bool())
.unwrap_or(false);
+ if !self.owned_notebook_exists_locked(&mut db, notebook_id)? {
+ return Err(format!("notebook {notebook_id} not found"));
+ }
+
let fields = vec![
("id".into(), Value::Int(next_id as i64)),
("title".into(), Value::Str(title.into())),
("body".into(), Value::Str(body.into())),
("format".into(), Value::Str("markdown".into())),
("notebookId".into(), Value::Int(notebook_id)),
+ ("sortOrder".into(), Value::Int(sort_order)),
("ownerSub".into(), Value::Str(self.owner_sub().into())),
("tagIds".into(), Value::Str("".into())),
("createdAt".into(), Value::Int(now)),
@@ -894,8 +1199,8 @@ impl ToolRegistry {
.ok_or("missing id")?;
let new_title = Self::get_arg(args, "title").and_then(|v| v.as_str());
let new_body = Self::get_arg(args, "body").and_then(|v| v.as_str());
- let new_notebook =
- Self::get_arg(args, "notebookId").and_then(|v| v.as_f64().map(|f| f as i64));
+ let new_notebook = Self::arg_i64(args, "notebookId");
+ let new_sort_order = Self::arg_i64(args, "sortOrder");
let new_archived = Self::get_arg(args, "archived").and_then(|v| v.as_bool());
let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
@@ -913,6 +1218,11 @@ impl ToolRegistry {
if note.get("ownerSub").and_then(|o| o.as_str()) != Some(self.owner_sub()) {
return Err("unauthorized".into());
}
+ if let Some(nbid) = new_notebook {
+ if !self.owned_notebook_exists_locked(&mut db, nbid)? {
+ return Err(format!("notebook {nbid} not found"));
+ }
+ }
let old_body = note
.get("body")
.and_then(|v| v.as_str())
@@ -934,6 +1244,9 @@ impl ToolRegistry {
if let Some(nbid) = new_notebook {
set_field(pairs, "notebookId", Value::Int(nbid));
}
+ if let Some(order) = new_sort_order {
+ set_field(pairs, "sortOrder", Value::Int(order));
+ }
if let Some(arch) = new_archived {
set_field(pairs, "archived", Value::Bool(arch));
}
@@ -963,6 +1276,66 @@ impl ToolRegistry {
Ok(Value::Object(vec![("updated".into(), Value::Bool(true))]))
}
+ fn move_note(&self, args: &[(String, Value)]) -> Result<Value, String> {
+ let note_id = Self::arg_id(args, "noteId").or_else(|_| Self::arg_id(args, "id"))?;
+ let notebook_id = Self::arg_i64(args, "notebookId").ok_or("missing notebookId")?;
+ let sort_order = Self::arg_i64(args, "sortOrder");
+
+ let mut update_args = vec![
+ ("id".into(), Value::Int(note_id as i64)),
+ ("notebookId".into(), Value::Int(notebook_id)),
+ ];
+ if let Some(sort_order) = sort_order {
+ update_args.push(("sortOrder".into(), Value::Int(sort_order)));
+ }
+ self.update_note(&update_args)?;
+
+ Ok(Value::Object(vec![
+ ("updated".into(), Value::Bool(true)),
+ ("noteId".into(), Value::Int(note_id as i64)),
+ ("notebookId".into(), Value::Int(notebook_id)),
+ ("sortOrder".into(), optional_int(sort_order)),
+ ]))
+ }
+
+ fn reorder_notes(&self, args: &[(String, Value)]) -> Result<Value, String> {
+ let notebook_id = Self::arg_i64(args, "notebookId").ok_or("missing notebookId")?;
+ let note_ids = Self::arg_i64_array(args, "noteIds")?;
+
+ let mut db = self.db.lock().map_err(|e| format!("lock: {e}"))?;
+ if !self.owned_notebook_exists_locked(&mut db, notebook_id)? {
+ return Err(format!("notebook {notebook_id} not found"));
+ }
+
+ for (index, note_id) in note_ids.iter().enumerate() {
+ if *note_id <= 0 {
+ return Err("noteIds must contain positive IDs".into());
+ }
+ let Some(mut note) = Self::read_record_locked(&mut db, "notes", *note_id as u64)?
+ else {
+ return Err(format!("note {note_id} not found"));
+ };
+ if !is_owned_by(¬e, self.owner_sub()) {
+ return Err(format!("note {note_id} not found or unauthorized"));
+ }
+ if note.get("notebookId").and_then(value_i64) != Some(notebook_id) {
+ return Err(format!("note {note_id} is not in notebook {notebook_id}"));
+ }
+ if let Value::Object(ref mut fields) = note {
+ set_field(fields, "sortOrder", Value::Int((index + 1) as i64));
+ set_field(fields, "updatedAt", Value::Int(now()));
+ }
+ Self::write_record_locked(&mut db, "notes", *note_id as u64, ¬e)?;
+ }
+ db.commit().map_err(|e| format!("commit: {e}"))?;
+
+ Ok(Value::Object(vec![
+ ("updated".into(), Value::Bool(true)),
+ ("notebookId".into(), Value::Int(notebook_id)),
+ ("count".into(), Value::Int(note_ids.len() as i64)),
+ ]))
+ }
+
fn edit_note_body(&self, args: &[(String, Value)]) -> Result<Value, String> {
let id = Self::get_arg(args, "id")
.and_then(|v| v.as_f64())
@@ -1268,6 +1641,101 @@ fn set_field(pairs: &mut Vec<(String, Value)>, key: &str, value: Value) {
pairs.push((key.to_string(), value));
}
+fn now() -> i64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64
+}
+
+fn value_i64(value: &Value) -> Option<i64> {
+ match value {
+ Value::Int(n) => Some(*n),
+ Value::Float(n) if n.is_finite() => Some(*n as i64),
+ _ => None,
+ }
+}
+
+fn optional_int(value: Option<i64>) -> Value {
+ value.map(Value::Int).unwrap_or(Value::Null)
+}
+
+fn is_owned_by(record: &Value, owner_sub: &str) -> bool {
+ record.get("ownerSub").and_then(Value::as_str) == Some(owner_sub)
+}
+
+fn normalize_note(note: &mut Value) {
+ let id = note.get("id").and_then(value_i64).unwrap_or_default();
+ if let Value::Object(fields) = note {
+ if !fields.iter().any(|(key, _)| key == "sortOrder") {
+ set_field(fields, "sortOrder", Value::Int(id));
+ }
+ }
+}
+
+fn normalize_notebook(notebook: &mut Value) {
+ let id = notebook.get("id").and_then(value_i64).unwrap_or_default();
+ let created_at = notebook
+ .get("createdAt")
+ .and_then(value_i64)
+ .unwrap_or_default();
+ if let Value::Object(fields) = notebook {
+ if !fields.iter().any(|(key, _)| key == "parentId") {
+ set_field(fields, "parentId", Value::Null);
+ }
+ if !fields.iter().any(|(key, _)| key == "sortOrder") {
+ set_field(fields, "sortOrder", Value::Int(id));
+ }
+ if !fields.iter().any(|(key, _)| key == "updatedAt") {
+ set_field(fields, "updatedAt", Value::Int(created_at));
+ }
+ }
+}
+
+fn sort_notes(notes: &mut [Value]) {
+ notes.sort_by(|a, b| {
+ let a_order = a
+ .get("sortOrder")
+ .and_then(value_i64)
+ .or_else(|| a.get("id").and_then(value_i64))
+ .unwrap_or_default();
+ let b_order = b
+ .get("sortOrder")
+ .and_then(value_i64)
+ .or_else(|| b.get("id").and_then(value_i64))
+ .unwrap_or_default();
+ let a_id = a.get("id").and_then(value_i64).unwrap_or_default();
+ let b_id = b.get("id").and_then(value_i64).unwrap_or_default();
+ a_order.cmp(&b_order).then_with(|| a_id.cmp(&b_id))
+ });
+}
+
+fn sort_notebooks(notebooks: &mut [Value]) {
+ notebooks.sort_by(|a, b| {
+ let a_parent = a.get("parentId").and_then(value_i64).unwrap_or_default();
+ let b_parent = b.get("parentId").and_then(value_i64).unwrap_or_default();
+ let a_order = a
+ .get("sortOrder")
+ .and_then(value_i64)
+ .or_else(|| a.get("id").and_then(value_i64))
+ .unwrap_or_default();
+ let b_order = b
+ .get("sortOrder")
+ .and_then(value_i64)
+ .or_else(|| b.get("id").and_then(value_i64))
+ .unwrap_or_default();
+ let a_name = a.get("name").and_then(Value::as_str).unwrap_or("");
+ let b_name = b.get("name").and_then(Value::as_str).unwrap_or("");
+ let a_id = a.get("id").and_then(value_i64).unwrap_or_default();
+ let b_id = b.get("id").and_then(value_i64).unwrap_or_default();
+ a_parent
+ .cmp(&b_parent)
+ .then_with(|| a_order.cmp(&b_order))
+ .then_with(|| a_name.cmp(b_name))
+ .then_with(|| a_id.cmp(&b_id))
+ });
+}
+
fn upper_bound(prefix: &[u8]) -> Vec<u8> {
let mut bound = prefix.to_vec();
bound.push(0xff);
diff --git a/crates/notes-mcp/tests/mcp_test.rs b/crates/notes-mcp/tests/mcp_test.rs
index 77bad98..5fb6693 100644
--- a/crates/notes-mcp/tests/mcp_test.rs
+++ b/crates/notes-mcp/tests/mcp_test.rs
@@ -162,6 +162,12 @@ fn tools_list_returns_all_notes_tools() {
assert!(resp.contains("\"update_note\""));
assert!(resp.contains("\"edit_note_body\""));
assert!(resp.contains("\"delete_note\""));
+ assert!(resp.contains("\"move_note\""));
+ assert!(resp.contains("\"reorder_notes\""));
+ assert!(resp.contains("\"move_notebook\""));
+ assert!(resp.contains("\"reorder_notebooks\""));
+ assert!(resp.contains("\"parentId\""));
+ assert!(resp.contains("\"sortOrder\""));
assert!(resp.contains("\"add_tag\""));
assert!(resp.contains("\"remove_tag\""));
}
@@ -368,6 +374,81 @@ fn create_note_self_heals_stale_counter_before_allocating_id() {
);
}
+#[test]
+fn notebooks_include_parent_order_and_note_count() {
+ let registry = make_registry();
+ let root_req = r##"{"jsonrpc":"2.0","id":50,"method":"tools/call","params":{"name":"create_notebook","arguments":{"name":"Root","sortOrder":2,"color":"#ffffff"}}}"##;
+ let child_req = r##"{"jsonrpc":"2.0","id":51,"method":"tools/call","params":{"name":"create_notebook","arguments":{"name":"Child","parentId":1,"sortOrder":1,"color":"#ffffff"}}}"##;
+ assert!(registry.handle_request(root_req).contains("\\\"id\\\":1"));
+ assert!(registry
+ .handle_request(child_req)
+ .contains("\\\"parentId\\\":1"));
+
+ let note_req = r#"{"jsonrpc":"2.0","id":52,"method":"tools/call","params":{"name":"create_note","arguments":{"title":"Count me","body":"body","notebookId":2}}}"#;
+ assert!(registry.handle_request(note_req).contains("\"result\""));
+
+ let list_req = r#"{"jsonrpc":"2.0","id":53,"method":"tools/call","params":{"name":"list_notebooks","arguments":{}}}"#;
+ let resp = registry.handle_request(list_req);
+ assert!(resp.contains("\\\"name\\\":\\\"Child\\\""), "{resp}");
+ assert!(resp.contains("\\\"parentId\\\":1"), "{resp}");
+ assert!(resp.contains("\\\"sortOrder\\\":1"), "{resp}");
+ assert!(resp.contains("\\\"noteCount\\\":1"), "{resp}");
+}
+
+#[test]
+fn move_and_reorder_notes_updates_folder_order() {
+ let registry = make_registry();
+ let _ = registry.handle_request(r##"{"jsonrpc":"2.0","id":60,"method":"tools/call","params":{"name":"create_notebook","arguments":{"name":"Inbox","color":"#ffffff"}}}"##);
+ let _ = registry.handle_request(r##"{"jsonrpc":"2.0","id":61,"method":"tools/call","params":{"name":"create_notebook","arguments":{"name":"Archive","color":"#ffffff"}}}"##);
+ let _ = registry.handle_request(r#"{"jsonrpc":"2.0","id":62,"method":"tools/call","params":{"name":"create_note","arguments":{"title":"First","body":"body","notebookId":1}}}"#);
+ let _ = registry.handle_request(r#"{"jsonrpc":"2.0","id":63,"method":"tools/call","params":{"name":"create_note","arguments":{"title":"Second","body":"body","notebookId":1}}}"#);
+
+ let move_req = r#"{"jsonrpc":"2.0","id":64,"method":"tools/call","params":{"name":"move_note","arguments":{"noteId":1,"notebookId":2,"sortOrder":9}}}"#;
+ let move_resp = registry.handle_request(move_req);
+ assert!(move_resp.contains("\\\"notebookId\\\":2"), "{move_resp}");
+ assert!(move_resp.contains("\\\"sortOrder\\\":9"), "{move_resp}");
+
+ let reorder_req = r#"{"jsonrpc":"2.0","id":65,"method":"tools/call","params":{"name":"reorder_notes","arguments":{"notebookId":1,"noteIds":[2]}}}"#;
+ let reorder_resp = registry.handle_request(reorder_req);
+ assert!(reorder_resp.contains("\\\"count\\\":1"), "{reorder_resp}");
+
+ let get_req = r#"{"jsonrpc":"2.0","id":66,"method":"tools/call","params":{"name":"get_note","arguments":{"id":2}}}"#;
+ let get_resp = registry.handle_request(get_req);
+ assert!(get_resp.contains("\\\"sortOrder\\\":1"), "{get_resp}");
+}
+
+#[test]
+fn moving_notebook_rejects_cycles() {
+ let registry = make_registry();
+ let _ = registry.handle_request(r##"{"jsonrpc":"2.0","id":70,"method":"tools/call","params":{"name":"create_notebook","arguments":{"name":"Parent","color":"#ffffff"}}}"##);
+ let _ = registry.handle_request(r##"{"jsonrpc":"2.0","id":71,"method":"tools/call","params":{"name":"create_notebook","arguments":{"name":"Child","parentId":1,"color":"#ffffff"}}}"##);
+
+ let cycle_req = r#"{"jsonrpc":"2.0","id":72,"method":"tools/call","params":{"name":"move_notebook","arguments":{"id":1,"parentId":2}}}"#;
+ let cycle_resp = registry.handle_request(cycle_req);
+ assert!(cycle_resp.contains("\"code\":-32603"), "{cycle_resp}");
+ assert!(
+ cycle_resp.contains("cannot move a notebook"),
+ "{cycle_resp}"
+ );
+}
+
+#[test]
+fn reorder_notebooks_updates_sibling_order() {
+ let registry = make_registry();
+ let _ = registry.handle_request(r##"{"jsonrpc":"2.0","id":80,"method":"tools/call","params":{"name":"create_notebook","arguments":{"name":"One","color":"#ffffff"}}}"##);
+ let _ = registry.handle_request(r##"{"jsonrpc":"2.0","id":81,"method":"tools/call","params":{"name":"create_notebook","arguments":{"name":"Two","color":"#ffffff"}}}"##);
+
+ let reorder_req = r#"{"jsonrpc":"2.0","id":82,"method":"tools/call","params":{"name":"reorder_notebooks","arguments":{"notebookIds":[2,1]}}}"#;
+ let reorder_resp = registry.handle_request(reorder_req);
+ assert!(reorder_resp.contains("\\\"count\\\":2"), "{reorder_resp}");
+
+ let list_req = r#"{"jsonrpc":"2.0","id":83,"method":"tools/call","params":{"name":"list_notebooks","arguments":{}}}"#;
+ let list_resp = registry.handle_request(list_req);
+ let two_pos = list_resp.find("\\\"name\\\":\\\"Two\\\"").unwrap();
+ let one_pos = list_resp.find("\\\"name\\\":\\\"One\\\"").unwrap();
+ assert!(two_pos < one_pos, "{list_resp}");
+}
+
#[test]
fn unknown_tool_returns_error() {
let registry = make_registry();
diff --git a/site/backend/collections.toml b/site/backend/collections.toml
index 9a7a7b3..fcc0518 100644
--- a/site/backend/collections.toml
+++ b/site/backend/collections.toml
@@ -25,6 +25,11 @@ name = "notes"
type = "int"
index = true
+ [[collection.field]]
+ name = "sortOrder"
+ type = "int"
+ index = true
+
[[collection.field]]
name = "ownerSub"
type = "text"
@@ -70,11 +75,26 @@ name = "notebooks"
name = "color"
type = "text"
+ [[collection.field]]
+ name = "parentId"
+ type = "int"
+ index = true
+
+ [[collection.field]]
+ name = "sortOrder"
+ type = "int"
+ index = true
+
[[collection.field]]
name = "createdAt"
type = "int"
index = true
+ [[collection.field]]
+ name = "updatedAt"
+ type = "int"
+ index = true
+
[[collection]]
name = "tags"
diff --git a/site/backend/page.json b/site/backend/page.json
index 738c7a8..47972e2 100644
--- a/site/backend/page.json
+++ b/site/backend/page.json
@@ -1,7 +1,7 @@
{
"title": "AkurAI-Notes — Private notes with semantic search",
"description": "Private notes with semantic search — running on your own server, never scanned by a third party.",
- "version": "v0.0.8",
+ "version": "v0.0.9",
"url": "https://akurai-notes.olibuijr.com",
"year": 2026,
"brand_name": "AkurAI-Notes",
diff --git a/site/frontend/notes-app.js b/site/frontend/notes-app.js
index fdbebe6..9c9f3e4 100644
--- a/site/frontend/notes-app.js
+++ b/site/frontend/notes-app.js
@@ -42,6 +42,63 @@
);
}
+ function numeric(value, fallback) {
+ const n = Number(value);
+ return Number.isFinite(n) ? n : fallback;
+ }
+
+ function sortedNotes(notes) {
+ return notes.slice().sort(function (a, b) {
+ return (
+ numeric(a.notebookId, 0) - numeric(b.notebookId, 0) ||
+ numeric(a.sortOrder, numeric(a.id, 0)) - numeric(b.sortOrder, numeric(b.id, 0)) ||
+ numeric(a.id, 0) - numeric(b.id, 0)
+ );
+ });
+ }
+
+ function sortedNotebooks(notebooks) {
+ return notebooks.slice().sort(function (a, b) {
+ const nameCmp = String(a.name || "").localeCompare(String(b.name || ""));
+ return (
+ numeric(a.parentId, 0) - numeric(b.parentId, 0) ||
+ numeric(a.sortOrder, numeric(a.id, 0)) - numeric(b.sortOrder, numeric(b.id, 0)) ||
+ nameCmp ||
+ numeric(a.id, 0) - numeric(b.id, 0)
+ );
+ });
+ }
+
+ function renderNotebookTree(notebooks) {
+ const ordered = sortedNotebooks(notebooks);
+ const children = new Map();
+ ordered.forEach(function (notebook) {
+ const parentId = numeric(notebook.parentId, 0);
+ const bucket = children.get(parentId) || [];
+ bucket.push(notebook);
+ children.set(parentId, bucket);
+ });
+
+ function renderBranch(parentId, depth) {
+ return (children.get(parentId) || [])
+ .map(function (nb) {
+ const id = numeric(nb.id, 0);
+ const count = numeric(nb.noteCount, 0);
+ const inset = Math.min(depth * 14, 56);
+ return (
+ '<li class="notebook-item" style="--depth:' + inset + 'px">' +
+ '<span class="notebook-name">' + esc(nb.name || "Untitled") + "</span>" +
+ '<span class="notebook-count">' + count + "</span>" +
+ "</li>" +
+ renderBranch(id, depth + 1)
+ );
+ })
+ .join("");
+ }
+
+ return renderBranch(0, 0);
+ }
+
function renderEmpty(target, message) {
target.innerHTML = '<p class="empty-state">' + esc(message) + "</p>";
}
@@ -67,18 +124,14 @@
const noteList = Array.isArray(notes) ? notes : [];
notebooksEl.innerHTML = notebookList.length
- ? notebookList
- .map(function (nb) {
- return '<li class="notebook-item">' + esc(nb.name || "Untitled") + "</li>";
- })
- .join("")
+ ? renderNotebookTree(notebookList)
: '<li class="notebook-item muted">No notebooks</li>';
if (!noteList.length) {
renderEmpty(content, "No notes found");
return;
}
- content.innerHTML = '<div class="note-grid">' + noteList.map(renderNoteCard).join("") + "</div>";
+ content.innerHTML = '<div class="note-grid">' + sortedNotes(noteList).map(renderNoteCard).join("") + "</div>";
} catch (err) {
if (String(err.message).includes("Redirecting")) return;
renderEmpty(content, "Could not load notes: " + err.message);
diff --git a/site/frontend/styles.css b/site/frontend/styles.css
index af83bfa..3b70607 100644
--- a/site/frontend/styles.css
+++ b/site/frontend/styles.css
@@ -80,7 +80,9 @@ a:hover { text-decoration: underline; }
.sidebar { padding: 18px; position: sticky; top: 78px; }
.notebook-list h3 { margin: 0 0 12px; font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
.notebook-list ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
-.notebook-item { min-height: 34px; display: flex; align-items: center; padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-2); color: var(--fg); font-size: 14px; }
+.notebook-item { min-height: 34px; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 7px 10px 7px calc(10px + var(--depth, 0px)); border: 1px solid var(--border); border-radius: 8px; background: var(--bg-2); color: var(--fg); font-size: 14px; }
+.notebook-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.notebook-count { flex: 0 0 auto; min-width: 22px; padding: 2px 7px; border-radius: 999px; background: var(--panel-2); color: var(--muted); font-size: 12px; text-align: center; }
.content { min-width: 0; }
.app-panel { padding: 22px; }
.panel-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }