181aad91e6
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>
153 lines
8.0 KiB
Rust
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, ¶ms).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(®istry, &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(®istry, &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(®istry, &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(®istry, &mut session, params.clone()).expect("first"));
|
|
let b = path_of(&capture(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &mut session, json!({ "attachment": { "name": bad, "base64": PNG } }));
|
|
assert!(refused.is_err(), "{bad:?}: {refused:?}");
|
|
}
|
|
assert!(capture(®istry, &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(®istry, &mut session, json!({})).is_err(), "nothing to capture");
|
|
assert!(capture(®istry, &mut session, json!({ "text": " \n" })).is_err(), "blank text");
|
|
let huge = capture(®istry, &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(®istry, &mut session, json!({ "text": "x", "title": "t".repeat(201) })).is_err());
|
|
assert!(capture(®istry, &mut session, json!({ "text": "x", "source": "s".repeat(2001) })).is_err());
|
|
assert!(capture(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &mut session, params);
|
|
}
|
|
}
|