Files
Marcos Paulo 15d7f5e873 feat: editor blocks and lint, vault knowledge, calendar plugin, macOS menu
- notescraft-lint (rules, safe fixes, learned style) and notescraft-blocks (block model and edits); engine lint.* and block.* commands with CLI and MCP parity
- Lint tab, underlines, slash menu with preview in Source and Visual, block grip and menu, Settings > Editor and lint, Vim block keys
- Visual view: tables, note embeds, %% comments, wrapping property chips
- Vault knowledge (Related tab, assistant sources, Settings) and calendar plugin
- Sidebar top padding fixed, dot grid follows PaperCraft spacing, right-card tabs no longer cover the collapse button

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 12:36:00 -03:00

331 lines
19 KiB
Rust

//! The calendar write commands: add, edit, move, delete, notes, bulk and saved filters.
mod common;
use common::{on_disk, seed, temp_vault};
use notescraft_engine::{Registry, Session, SessionConfig, register_core};
use serde_json::{Value, json};
const SERIES: &str =
"---\ntitle: Standup\nstart: 2026-10-19T09:30\nend: 2026-10-19T09:45\nrepeat: 'FREQ=WEEKLY;BYDAY=MO'\ncalendar: Work\nmystery: keep me\n---\nBody text.\n";
const ONCE: &str = "---\ntitle: Lunch\nstart: 2026-10-20T12:00\nend: 2026-10-20T13:00\n---\n";
struct World {
registry: Registry<Session>,
session: Session,
vault: common::TempVault,
}
impl World {
fn new(name: &str) -> Self {
let vault = temp_vault(name);
seed(vault.path(), &[("Calendar/standup.md", SERIES), ("Calendar/lunch.md", ONCE)]);
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let session = Session::open(vault.path()).expect("open");
Self { registry, session, vault }
}
fn run(&mut self, id: &str, params: &Value) -> Value {
self.registry.run(id, &mut self.session, params).unwrap_or_else(|error| panic!("{id}: {error:?}"))
}
fn fails(&mut self, id: &str, params: &Value) -> String {
match self.registry.run(id, &mut self.session, params) {
Err(error) => format!("{error:?}"),
Ok(value) => panic!("{id} should fail, got {value}"),
}
}
fn file(&self, path: &str) -> String {
on_disk(self.vault.path(), path)
}
/// The `start` of every agenda item of the window, oldest first.
fn starts(&mut self, from: &str, to: &str) -> Vec<String> {
let out = self.run("calendar.agenda", &json!({ "from": from, "to": to }));
out["groups"]
.as_array()
.unwrap()
.iter()
.flat_map(|g| g["items"].as_array().unwrap().iter().map(|i| format!("{} {}", i["start"].as_str().unwrap(), i["title"].as_str().unwrap())))
.collect()
}
}
#[test]
fn add_from_text_and_from_fields() {
let mut world = World::new("cal-add");
let out = world.run("calendar.add", &json!({ "text": "Dentist 2026-11-03 at 15:00 for 1h #health" }));
let path = out["path"].as_str().unwrap().to_owned();
assert_eq!(path, "Calendar/2026-11-03-dentist.md");
assert!(world.file(&path).contains("start: 2026-11-03T15:00") && world.file(&path).contains("end: 2026-11-03T16:00"));
let again = world.run("calendar.add", &json!({ "text": "Dentist 2026-11-03 at 15:00" }));
assert_eq!(again["path"], "Calendar/2026-11-03-dentist-2.md", "a taken name is numbered");
let fields = world.run("calendar.add", &json!({ "event": { "title": "Trip", "start": "2026-12-01", "end": "2026-12-03", "tags": ["fun"] } }));
assert!(world.file(fields["path"].as_str().unwrap()).contains("end: 2026-12-03"));
for bad in [
json!({}),
json!({ "text": "x", "event": { "title": "y", "start": "2026-01-01" } }),
json!({ "text": "Lunch at 25:00" }),
json!({ "event": { "title": "x" } }),
json!({ "event": { "title": "x", "start": "2026-01-01", "colour": "red" } }),
json!({ "event": { "title": "x", "start": "2026-01-01", "repeat": "sometimes" } }),
] {
world.fails("calendar.add", &bad);
}
assert_eq!(std::fs::read_dir(world.vault.path().join("Calendar")).unwrap().count(), 5, "failed adds write nothing");
}
#[test]
fn editing_a_plain_event_keeps_unknown_keys_and_the_body() {
let mut world = World::new("cal-edit-plain");
world.run("calendar.edit", &json!({ "id": "Calendar/lunch.md", "fields": { "title": "Late lunch", "priority": "high", "end": null } }));
let text = world.file("Calendar/lunch.md");
assert!(text.contains("title: Late lunch") && text.contains("priority: high") && !text.contains("end:"), "{text}");
world.run("calendar.move", &json!({ "id": "Calendar/lunch.md", "start": "2026-10-22T14:00" }));
assert!(world.file("Calendar/lunch.md").contains("start: 2026-10-22T14:00"));
world.fails("calendar.edit", &json!({ "id": "Calendar/lunch.md", "fields": { "colour": "red" } }));
world.fails("calendar.edit", &json!({ "id": "Calendar/lunch.md", "fields": { "start": "nope" } }));
world.fails("calendar.edit", &json!({ "id": "Notes/outside.md", "fields": {} }));
world.fails("calendar.edit", &json!({ "id": "Calendar/missing.md", "fields": {} }));
}
#[test]
fn moving_keeps_the_length() {
let mut world = World::new("cal-move");
world.run("calendar.move", &json!({ "id": "Calendar/lunch.md", "start": "2026-10-21T10:00" }));
assert!(world.file("Calendar/lunch.md").contains("end: 2026-10-21T11:00"));
}
#[test]
fn a_repeating_event_needs_a_scope() {
let mut world = World::new("cal-scope");
for (command, params) in [
("calendar.edit", json!({ "id": "Calendar/standup.md", "fields": { "title": "x" } })),
("calendar.move", json!({ "id": "Calendar/standup.md", "start": "2026-10-20" })),
("calendar.delete", json!({ "id": "Calendar/standup.md" })),
] {
let error = world.fails(command, &params);
assert!(error.contains("this, following or all"), "{command}: {error}");
}
let error = world.fails("calendar.edit", &json!({ "id": "Calendar/standup.md", "fields": {}, "scope": "sometimes" }));
assert!(error.contains("this, following, all"), "{error}");
world.fails("calendar.edit", &json!({ "id": "Calendar/standup.md", "fields": {}, "scope": "this" }));
world.fails("calendar.edit", &json!({ "id": "Calendar/standup.md", "fields": {}, "scope": "this", "occurrence": "2026-10-20" }));
assert!(world.file("Calendar/standup.md").contains("mystery: keep me"), "refused calls change nothing");
}
#[test]
fn editing_this_following_or_all() {
let mut world = World::new("cal-series");
let window = ("2026-10-19", "2026-11-09");
assert_eq!(world.starts(window.0, window.1).len(), 5, "lunch + four Mondays");
let this = world.run("calendar.edit", &json!({ "id": "Calendar/standup.md", "scope": "this", "occurrence": "2026-10-26", "fields": { "title": "Standup (long)", "start": "2026-10-26T10:00" } }));
assert_eq!(this["created"].as_array().unwrap().len(), 1);
assert_eq!(
world.starts(window.0, window.1),
["2026-10-19T09:30 Standup", "2026-10-20T12:00 Lunch", "2026-10-26T10:00 Standup (long)", "2026-11-02T09:30 Standup", "2026-11-09T09:30 Standup"]
);
world.run("calendar.edit", &json!({ "id": "Calendar/standup.md", "scope": "following", "occurrence": "2026-11-02", "fields": { "title": "Sync" } }));
assert_eq!(
world.starts(window.0, window.1),
["2026-10-19T09:30 Standup", "2026-10-20T12:00 Lunch", "2026-10-26T10:00 Standup (long)", "2026-11-02T09:30 Sync", "2026-11-09T09:30 Sync"]
);
let head = world.file("Calendar/standup.md");
assert!(head.contains("UNTIL=2026-11-01") && head.contains("mystery: keep me") && head.ends_with("Body text.\n"), "{head}");
world.run("calendar.edit", &json!({ "id": "Calendar/standup.md", "scope": "all", "fields": { "calendar": "Team" } }));
assert!(world.file("Calendar/standup.md").contains("calendar: Team"));
}
#[test]
fn following_at_the_first_occurrence_is_all() {
let mut world = World::new("cal-first");
let out =
world.run("calendar.edit", &json!({ "id": "Calendar/standup.md", "scope": "following", "occurrence": "2026-10-19", "fields": { "title": "Renamed" } }));
assert_eq!(out["created"], json!([]));
assert!(world.file("Calendar/standup.md").contains("title: Renamed"));
}
#[test]
fn deleting_this_following_or_all() {
let mut world = World::new("cal-delete");
world.run("calendar.delete", &json!({ "id": "Calendar/standup.md", "scope": "this", "occurrence": "2026-10-26" }));
assert_eq!(
world.starts("2026-10-19", "2026-11-09"),
["2026-10-19T09:30 Standup", "2026-10-20T12:00 Lunch", "2026-11-02T09:30 Standup", "2026-11-09T09:30 Standup"]
);
world.run("calendar.delete", &json!({ "id": "Calendar/standup.md", "scope": "following", "occurrence": "2026-11-02" }));
assert_eq!(world.starts("2026-10-19", "2026-11-30"), ["2026-10-19T09:30 Standup", "2026-10-20T12:00 Lunch"]);
world.run("calendar.delete", &json!({ "id": "Calendar/standup.md", "scope": "following", "occurrence": "2026-10-19" }));
assert!(!world.vault.path().join("Calendar/standup.md").exists(), "following from the first one deletes the series");
world.run("calendar.delete", &json!({ "id": "Calendar/lunch.md" }));
assert!(!world.vault.path().join("Calendar/lunch.md").exists());
}
#[test]
fn a_meeting_note_is_linked_both_ways_and_made_once() {
let mut world = World::new("cal-note");
let out = world.run("calendar.note", &json!({ "id": "Calendar/lunch.md" }));
let path = out["path"].as_str().unwrap().to_owned();
assert_eq!((out["created"].clone(), path.as_str()), (json!(true), "Meetings/2026-10-20 Lunch.md"));
assert!(world.file(&path).contains("[[Calendar/lunch]]"));
assert!(world.file("Calendar/lunch.md").contains("note: Meetings/2026-10-20 Lunch.md"));
let backlinks = world.run("links.backlinks", &json!({ "path": "Calendar/lunch.md" }));
assert!(backlinks.to_string().contains("Meetings/2026-10-20 Lunch.md"), "{backlinks}");
let again = world.run("calendar.note", &json!({ "id": "Calendar/lunch.md" }));
assert_eq!((again["created"].clone(), again["path"].as_str()), (json!(false), Some(path.as_str())));
}
#[test]
fn bulk_counts_first_and_applies_on_confirm() {
let mut world = World::new("cal-bulk");
let ids = json!(["Calendar/lunch.md", "Calendar/standup.md", "Calendar/lunch.md"]);
let preview = world.run("calendar.bulk", &json!({ "ids": ids, "action": "move", "days": 2 }));
assert_eq!((preview["applied"].clone(), preview["affected"].clone()), (json!(false), json!(2)));
assert!(world.file("Calendar/lunch.md").contains("start: 2026-10-20T12:00"), "no write without confirm");
world.run("calendar.bulk", &json!({ "ids": ids, "action": "move", "days": 2, "confirm": true }));
assert!(world.file("Calendar/lunch.md").contains("start: 2026-10-22T12:00") && world.file("Calendar/standup.md").contains("start: 2026-10-21T09:30"));
world.run("calendar.bulk", &json!({ "ids": ids, "action": "tag", "tag": "x", "confirm": true }));
assert!(world.file("Calendar/lunch.md").contains("tags: [x]"));
world.run("calendar.bulk", &json!({ "ids": ids, "action": "untag", "tag": "x", "confirm": true }));
assert!(!world.file("Calendar/lunch.md").contains("tags:"));
world.run("calendar.bulk", &json!({ "ids": ids, "action": "calendar", "calendar": "Home", "confirm": true }));
assert!(world.file("Calendar/lunch.md").contains("calendar: Home"));
for bad in [
json!({ "ids": [], "action": "tag", "tag": "x" }),
json!({ "ids": ids, "action": "explode" }),
json!({ "ids": ids, "action": "move" }),
json!({ "ids": ["Calendar/lunch.md", "Calendar/nope.md"], "action": "tag", "tag": "x", "confirm": true }),
] {
world.fails("calendar.bulk", &bad);
}
assert!(!world.file("Calendar/lunch.md").contains("tags:"), "a bad id in the list changes nothing");
let gone = world.run("calendar.bulk_delete", &json!({ "ids": ids }));
assert_eq!(gone["applied"], false);
assert!(world.vault.path().join("Calendar/lunch.md").exists());
world.run("calendar.bulk_delete", &json!({ "ids": ids, "confirm": true }));
assert!(!world.vault.path().join("Calendar/lunch.md").exists() && !world.vault.path().join("Calendar/standup.md").exists());
}
#[test]
fn saved_filters_survive_a_restart() {
let mut world = World::new("cal-filters");
world.run("calendar.filter_save", &json!({ "name": "Work", "query": { "filter": { "calendars": ["Work"] }, "sort": "priority", "group": "tag" } }));
world.run("calendar.filter_save", &json!({ "name": "Work", "query": { "sort": "title" } }));
world.run("calendar.filter_save", &json!({ "name": "Home", "query": {} }));
let World { registry, session, vault } = world;
drop(session);
let mut session = Session::open(vault.path()).expect("reopen");
let listed = registry.run("calendar.filters", &mut session, &json!({})).expect("filters");
let names: Vec<&str> = listed["filters"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Work", "Home"]);
assert_eq!(listed["filters"][0]["query"]["sort"], "title", "the same name replaces");
}
#[test]
fn bad_filters_are_refused_and_deleting_works() {
let mut world = World::new("cal-filters-bad");
world.run("calendar.filter_save", &json!({ "name": "Home", "query": {} }));
world.fails("calendar.filter_save", &json!({ "name": " ", "query": {} }));
world.fails("calendar.filter_save", &json!({ "name": "x", "query": { "sort": "colour" } }));
world.run("calendar.filter_delete", &json!({ "name": "Home" }));
world.fails("calendar.filter_delete", &json!({ "name": "Home" }));
assert_eq!(world.run("calendar.filters", &json!({}))["filters"].as_array().unwrap().len(), 0);
}
#[test]
fn a_read_only_session_refuses_every_write() {
let vault = temp_vault("cal-readonly");
seed(vault.path(), &[("Calendar/lunch.md", ONCE)]);
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let mut session = Session::open_read_only(vault.path()).expect("open");
for (id, params) in [
("calendar.add", json!({ "text": "x" })),
("calendar.edit", json!({ "id": "Calendar/lunch.md", "fields": {} })),
("calendar.move", json!({ "id": "Calendar/lunch.md", "start": "2026-10-21" })),
("calendar.delete", json!({ "id": "Calendar/lunch.md" })),
("calendar.note", json!({ "id": "Calendar/lunch.md" })),
("calendar.bulk", json!({ "ids": ["Calendar/lunch.md"], "action": "tag", "tag": "x", "confirm": true })),
("calendar.bulk_delete", json!({ "ids": ["Calendar/lunch.md"], "confirm": true })),
("calendar.filter_save", json!({ "name": "x", "query": {} })),
("calendar.filter_delete", json!({ "name": "x" })),
] {
assert!(registry.run(id, &mut session, &params).is_err(), "{id}");
}
assert_eq!(on_disk(vault.path(), "Calendar/lunch.md"), ONCE);
let _ = SessionConfig::default();
}
#[test]
fn the_assistant_gate_queues_calendar_writes_and_honours_the_toggle() {
use notescraft_engine::{AssistantGate, AssistantSettings, Decision, GateSource};
let mut world = World::new("cal-gate");
let allow = |write: bool, delete: bool, calendar: Option<bool>| {
let mut settings = AssistantSettings { allow_write: write, allow_delete: delete, review_writes: true, ..AssistantSettings::default() };
if let Some(on) = calendar {
settings.plugin_tools.insert("calendar".to_owned(), on);
}
settings
};
let check = |world: &World, id: &str, params: &Value| AssistantGate::check(GateSource::Assistant, &world.session, id, params);
let add = json!({ "text": "Lunch tomorrow" });
assert!(matches!(check(&world, "calendar.agenda", &json!({})), Decision::Allow), "reads are never gated");
assert!(matches!(check(&world, "calendar.add", &add), Decision::Deny(_)), "writes are off by default");
world.session.set_assistant_settings(allow(true, false, None));
assert!(matches!(check(&world, "calendar.add", &add), Decision::Queue(_)), "a permitted write waits for review");
assert!(matches!(check(&world, "calendar.delete", &json!({ "id": "Calendar/lunch.md" })), Decision::Deny(_)), "deletes need their own flag");
world.session.set_assistant_settings(allow(true, true, None));
assert!(matches!(check(&world, "calendar.bulk_delete", &json!({ "ids": [] })), Decision::Queue(_)));
world.session.set_assistant_settings(allow(true, true, Some(false)));
assert!(matches!(check(&world, "calendar.agenda", &json!({})), Decision::Deny(_)), "the toggle hides reads too");
assert!(matches!(check(&world, "calendar.add", &add), Decision::Deny(_)));
assert!(matches!(AssistantGate::check(GateSource::Cli, &world.session, "calendar.add", &add), Decision::Allow), "the user is never gated");
assert!(world.vault.path().join("Calendar/lunch.md").exists());
}
#[test]
fn switching_the_calendar_plugin_off_is_saved_and_closes_the_assistant_gate() {
use notescraft_engine::{AssistantGate, AssistantSettings, Decision, GateSource};
let mut world = World::new("cal-core-off");
world.session.use_settings_dir(world.vault.path().join(".notescraft/test-config"));
assert!(world.session.core_plugin_enabled("calendar"));
world.session.set_assistant_settings(AssistantSettings { allow_write: true, ..AssistantSettings::default() });
assert!(matches!(AssistantGate::check(GateSource::Assistant, &world.session, "calendar.agenda", &json!({})), Decision::Allow));
world.run("settings.set", &json!({ "key": "plugins.core_disabled", "value": ["calendar"] }));
assert!(!world.session.core_plugin_enabled("calendar"), "the setting reaches the session");
assert!(
matches!(AssistantGate::check(GateSource::Assistant, &world.session, "calendar.agenda", &json!({})), Decision::Deny(_)),
"an off plugin has no assistant tools"
);
let saved = world.run("settings.get", &json!({}));
assert_eq!(saved["settings"]["plugins"]["core_disabled"], json!(["calendar"]), "and it is stored");
world.run("settings.set", &json!({ "key": "plugins.core_disabled", "value": [] }));
assert!(world.session.core_plugin_enabled("calendar"));
}
#[test]
fn calendar_switches_hide_tasks_and_done_tasks() {
let vault = temp_vault("cal-switches");
seed(vault.path(), &[("Notes/plan.md", "- [ ] Open \u{1F4C5} 2026-10-21\n- [x] Done \u{1F4C5} 2026-10-22\n")]);
let mut registry = Registry::new();
register_core(&mut registry).expect("register");
let mut session = Session::open(vault.path()).expect("open");
session.use_settings_dir(vault.path().join(".notescraft/test-config"));
let count = |registry: &Registry<Session>, session: &mut Session| {
registry.run("calendar.agenda", session, &json!({ "from": "2026-10-19", "to": "2026-10-27" })).unwrap()["count"].as_u64().unwrap()
};
assert_eq!(count(&registry, &mut session), 2);
registry.run("settings.set", &mut session, &json!({ "key": "session.calendar.show_completed", "value": false })).unwrap();
assert_eq!(count(&registry, &mut session), 1, "done tasks are hidden");
registry.run("settings.set", &mut session, &json!({ "key": "session.calendar.tasks", "value": false })).unwrap();
assert_eq!(count(&registry, &mut session), 0, "no tasks at all");
registry.run("settings.set", &mut session, &json!({ "key": "session.week_start", "value": "monday" })).unwrap();
assert_eq!(session.config().week_start, notescraft_templates::Weekday::Monday);
}