15d7f5e873
- 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>
331 lines
19 KiB
Rust
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, ¶ms);
|
|
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, ¶ms).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(®istry, &mut session), 2);
|
|
registry.run("settings.set", &mut session, &json!({ "key": "session.calendar.show_completed", "value": false })).unwrap();
|
|
assert_eq!(count(®istry, &mut session), 1, "done tasks are hidden");
|
|
registry.run("settings.set", &mut session, &json!({ "key": "session.calendar.tasks", "value": false })).unwrap();
|
|
assert_eq!(count(®istry, &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);
|
|
}
|