Files
Marcos Paulo 181aad91e6 feat: sync, accounts, live editing, vault launcher, compact layout
Sync and the server
- crates/sync: revision ledger (stale writers conflict, edit beats delete), client planner, three-way merge, conflict names, path and hash gates
- apps/notescraft-sync-server: pairing, device tokens, change log with long poll, blobs, accounts with many vaults, roles, invites, audit log, quota, GC, readiness, request ids
- server history (revisions, restore, retention), chunked resumable uploads, live editing endpoints (SSE rooms, presence, flush through the ledger)
- engine: HttpRemote, sync_once, SyncWorker, sync.* commands, share.* and server.* commands, live session client, note.capture, vault catalog and vaults.* commands

Editing together
- crates/collab: operational transformation core (TextOp, transform, client state machine, room, presence)
- ui-egui: Settings > Sync, sync pill, conflicts, Share dialog, live pill and remote carets, launcher Connect and Join flows

App shell
- vault launcher (desktop and phone layouts) wired into the app host (--launcher, File > Vaults...)
- compact (phone) layout with a bottom tab bar

Verified with cargo xtask ci (fmt, clippy -D warnings, workspace tests, layers, tree, i18n). Android and iOS shells are not built: the build machine has no NDK or Xcode.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 07:46:24 -03:00

153 lines
8.0 KiB
Rust

//! `note.capture` over a `Session`: the daily note, the inbox, a folder, an attachment, an open
//! buffer and hostile input.
use notescraft_engine::{Registry, Session};
use serde_json::{Value, json};
mod common;
use common::{on_disk, temp_config, temp_vault};
/// A session over a fresh vault plus the registry that holds the core commands.
fn setup(name: &str) -> (common::TempVault, common::TempVault, Session, Registry<Session>) {
let vault = temp_vault(name);
let config = temp_config(&format!("{name}-cfg"));
let mut session = Session::open(vault.path()).expect("open the session");
session.use_settings_dir(config.path());
let mut registry = Registry::new();
notescraft_engine::register_core(&mut registry).expect("register the core commands");
(vault, config, session, registry)
}
#[expect(clippy::needless_pass_by_value, reason = "the call sites pass json! literals")]
fn capture(registry: &Registry<Session>, session: &mut Session, params: Value) -> Result<Value, String> {
registry.run("note.capture", session, &params).map_err(|error| error.to_string())
}
fn path_of(answer: &Value) -> String {
answer.get("path").and_then(Value::as_str).unwrap_or_default().to_owned()
}
/// A tiny valid PNG (1x1) as base64.
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
#[test]
fn a_one_line_capture_is_appended_to_the_daily_note_and_the_note_is_created() {
let (vault, _config, mut session, registry) = setup("capture-daily");
let first = capture(&registry, &mut session, json!({ "text": "buy milk" })).expect("capture");
let path = path_of(&first);
assert!(std::path::Path::new(&path).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("md")), "{first}");
assert_eq!(first.get("appended"), Some(&json!(true)));
let second = capture(&registry, &mut session, json!({ "text": "call Ana", "source": "https://e.x" })).expect("capture");
assert_eq!(path_of(&second), path, "the same daily note");
let content = on_disk(vault.path(), &path);
let milk = content.find("buy milk").expect("first item");
let ana = content.find("call Ana").expect("second item");
assert!(milk < ana, "the second capture sits below the first: {content}");
assert!(content.contains("source: https://e.x"), "{content}");
}
#[test]
fn several_lines_become_a_heading_block() {
let (vault, _config, mut session, registry) = setup("capture-block");
let answer = capture(&registry, &mut session, json!({ "text": "one\ntwo", "title": "Idea" })).expect("capture");
let content = on_disk(vault.path(), &path_of(&answer));
assert!(content.contains("### ") && content.contains(" Idea\none\ntwo\n"), "{content}");
}
#[test]
fn the_inbox_gets_a_new_note_with_frontmatter_and_collisions_are_numbered() {
let (vault, _config, mut session, registry) = setup("capture-inbox");
let params = json!({ "text": "an idea", "title": "My Idea!", "target": "inbox", "source": "app" });
let a = path_of(&capture(&registry, &mut session, params.clone()).expect("first"));
let b = path_of(&capture(&registry, &mut session, params).expect("second"));
assert!(a.starts_with("Inbox/") && a.ends_with("my-idea.md"), "{a}");
assert_ne!(a, b, "a second capture in the same second does not overwrite");
let content = on_disk(vault.path(), &a);
assert!(content.starts_with("---\ncreated: ") && content.contains("source: \"app\"") && content.ends_with("an idea\n"), "{content}");
}
#[test]
fn a_folder_target_works_and_hostile_targets_are_refused() {
let (vault, _config, mut session, registry) = setup("capture-folder");
let answer = capture(&registry, &mut session, json!({ "text": "x", "target": "Projects/Atlas" })).expect("capture");
assert!(path_of(&answer).starts_with("Projects/Atlas/"), "{answer}");
assert!(vault.path().join(path_of(&answer)).is_file());
for bad in ["..", "../out", "/abs", "a/../b", ".obsidian", ".obsidian/x", ".notescraft", "a//b"] {
assert!(capture(&registry, &mut session, json!({ "text": "x", "target": bad })).is_err(), "target {bad:?} must be refused");
}
assert!(!vault.path().join("out").exists());
}
#[test]
fn an_attachment_is_saved_and_embedded_and_bad_names_are_refused() {
let (vault, _config, mut session, registry) = setup("capture-attachment");
let answer =
capture(&registry, &mut session, json!({ "text": "look", "target": "inbox", "attachment": { "name": "shot.png", "base64": PNG } })).expect("capture");
let embed = answer.get("embed").and_then(Value::as_str).unwrap_or_default();
assert!(embed.starts_with("![[") && embed.ends_with("]]"), "{answer}");
assert!(on_disk(vault.path(), &path_of(&answer)).contains(embed), "the note embeds the file");
for bad in ["../x.png", "a/b.png", "noext", ".png"] {
let refused = capture(&registry, &mut session, json!({ "attachment": { "name": bad, "base64": PNG } }));
assert!(refused.is_err(), "{bad:?}: {refused:?}");
}
assert!(capture(&registry, &mut session, json!({ "attachment": { "name": "a.png", "base64": "not base64!!" } })).is_err());
}
#[test]
fn size_limits_and_empty_captures_are_refused() {
let (_vault, _config, mut session, registry) = setup("capture-limits");
assert!(capture(&registry, &mut session, json!({})).is_err(), "nothing to capture");
assert!(capture(&registry, &mut session, json!({ "text": " \n" })).is_err(), "blank text");
let huge = capture(&registry, &mut session, json!({ "text": "x".repeat(1024 * 1024 + 1) })).unwrap_err();
assert!(huge.contains("too_large") || huge.contains("1 MiB") || huge.contains("longer"), "{huge}");
assert!(capture(&registry, &mut session, json!({ "text": "x", "title": "t".repeat(201) })).is_err());
assert!(capture(&registry, &mut session, json!({ "text": "x", "source": "s".repeat(2001) })).is_err());
assert!(capture(&registry, &mut session, json!({ "text": 5 })).is_err(), "wrong type");
}
#[test]
fn a_read_only_session_refuses() {
let vault = temp_vault("capture-readonly");
let config = temp_config("capture-readonly-cfg");
let mut session = Session::open_read_only(vault.path()).expect("open read only");
session.use_settings_dir(config.path());
let mut registry = Registry::new();
notescraft_engine::register_core(&mut registry).expect("register");
assert!(capture(&registry, &mut session, json!({ "text": "x" })).is_err());
}
#[test]
fn a_dirty_open_buffer_receives_the_append_and_undo_works() {
let (_vault, _config, mut session, registry) = setup("capture-buffer");
let first = path_of(&capture(&registry, &mut session, json!({ "text": "first" })).expect("capture"));
session.open_buffer(&first).expect("open the buffer");
let before = session.buffer(&first).map(|b| b.text().to_owned()).unwrap_or_default();
capture(&registry, &mut session, json!({ "text": "second" })).expect("capture into the open note");
let after = session.buffer(&first).map(|b| b.text().to_owned()).unwrap_or_default();
assert!(after.contains("second") && after.starts_with(&before), "{after}");
assert!(session.buffer(&first).is_some_and(notescraft_engine::Buffer::is_dirty), "the append stays an unsaved edit");
session.buffer_mut(&first).expect("buffer").undo().expect("undo");
assert_eq!(session.buffer(&first).map(|b| b.text().to_owned()).unwrap_or_default(), before, "undo takes the capture back");
}
#[test]
fn hostile_parameters_never_panic() {
let (_vault, _config, mut session, registry) = setup("capture-hostile");
let nul = "\u{0}".repeat(100_000);
let hostile = [
json!({ "text": nul }),
json!({ "text": "x", "title": nul }),
json!({ "text": "x", "target": nul }),
json!({ "text": "x", "attachment": 5 }),
json!({ "text": "x", "attachment": { "name": nul, "base64": "AAAA" } }),
json!({ "text": "x", "utc_offset_minutes": 99_999_999_999_i64 }),
json!({ "text": ["a"], "target": { "x": 1 } }),
json!([1, 2, 3]),
json!(null),
];
for params in hostile {
let _ = capture(&registry, &mut session, params);
}
}