AkurAI Build
Menu

AkurAI-NotesCommit eb500019

Commit eb500019

eb500019f03747c3813995ffbc50b64f38a473ab by Claude Code

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(&note, &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(&note, &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(&notebook, &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(&notebook, &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(&notebook, 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, &notebook)?;
+        }
+        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(&note, 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, &note)?;
+        }
+        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; }