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

226 lines
12 KiB
Rust

//! ICS feeds: subscribe, apply what was fetched, work offline and keep secrets out of files.
mod common;
use common::{seed, temp_vault};
use notescraft_engine::{Registry, Session, feed_statuses, register_core};
use serde_json::{Value, json};
const SECRET: &str = "TOKEN1234SECRET";
const FEED: &str = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:a@x\r\nDTSTART:20261020T093000\r\nDTEND:20261020T103000\r\nSUMMARY:Board meeting\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:b@x\r\nDTSTART:20261019T080000\r\nSUMMARY:Weekly sync\r\nRRULE:FREQ=WEEKLY\r\nEND:VEVENT\r\nEND:VCALENDAR\r\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/own.md", "---\ntitle: Own\nstart: 2026-10-21T10:00\n---\n")]);
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 subscribe(&mut self) -> String {
let url = format!("webcal://calendar.example.com/team.ics?token={SECRET}");
self.run("calendar.feed_add", &json!({ "name": "Team", "url": url, "refresh_minutes": 30 }))["id"].as_str().unwrap().to_owned()
}
fn agenda(&mut self) -> Vec<String> {
let out = self.run("calendar.agenda", &json!({ "from": "2026-10-19", "to": "2026-10-27", "sort": "title" }));
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()
}
fn private_files(&self) -> String {
let mut text = String::new();
let mut pending = vec![self.vault.path().join(".notescraft")];
while let Some(dir) = pending.pop() {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
if entry.path().is_dir() {
pending.push(entry.path());
} else if entry.file_name() != "calendar-feeds.json" {
text.push_str(&std::fs::read_to_string(entry.path()).unwrap_or_default());
}
}
}
text
}
}
#[test]
fn subscribing_and_applying_a_fetch_shows_read_only_events() {
let mut world = World::new("feed-apply");
let id = world.subscribe();
assert_eq!(id, "team");
let due = world.run("calendar.sync_due", &json!({ "now": "2026-10-20T09:00" }));
assert_eq!(
(due["count"].clone(), due["feeds"][0]["url"].as_str().map(|u| u.starts_with("https://calendar.example.com"))),
(json!(1), Some(true)),
"webcal is fetched over https"
);
let applied = world.run("calendar.sync_apply", &json!({ "id": id, "text": FEED, "etag": "v1", "now": "2026-10-20T09:00" }));
assert_eq!((applied["events"].clone(), applied["error"].clone()), (json!(2), Value::Null));
let agenda = world.agenda();
assert!(agenda.contains(&"2026-10-20T09:30 Board meeting".to_owned()) && agenda.contains(&"2026-10-21T10:00 Own".to_owned()), "{agenda:?}");
assert_eq!(agenda.iter().filter(|line| line.ends_with("Weekly sync")).count(), 2, "the weekly feed event repeats: 19 and 26 October");
let full = world.run("calendar.agenda", &json!({ "from": "2026-10-20", "to": "2026-10-20" }));
let item = &full["groups"][0]["items"][0];
assert_eq!((item["id"].as_str(), item["source"].as_str(), item["calendar"].as_str()), (Some("feed:team:a@x"), Some("team"), Some("Team")));
assert!(world.run("calendar.sync_due", &json!({ "now": "2026-10-20T09:10" }))["count"] == 0, "not due again within 30 minutes");
let due_again = world.run("calendar.sync_due", &json!({ "now": "2026-10-20T09:30" }));
assert_eq!((due_again["count"].clone(), due_again["feeds"][0]["etag"].clone()), (json!(1), json!("v1")));
assert_eq!(world.run("calendar.sync_due", &json!({ "now": "2026-10-20T09:01", "force": true }))["count"], 1);
world.run("calendar.sync_apply", &json!({ "id": "team", "not_modified": true, "now": "2026-10-20T09:30" }));
assert_eq!(world.agenda().len(), agenda.len(), "not modified keeps the events");
}
#[test]
fn going_offline_keeps_the_events_and_shows_a_flag() {
let mut world = World::new("feed-offline");
let id = world.subscribe();
world.run("calendar.sync_apply", &json!({ "id": id, "text": FEED, "now": "2026-10-20T09:00" }));
let before = world.agenda();
let failed = world.run(
"calendar.sync_apply",
&json!({ "id": id, "error": format!("timed out reaching https://calendar.example.com/team.ics?token={SECRET}"), "now": "2026-10-20T10:00" }),
);
assert!(failed["error"].as_str().is_some_and(|e| e.contains("timed out") && !e.contains(SECRET)), "{failed}");
assert_eq!(world.agenda(), before, "the cached events still show");
let listed = world.run("calendar.feeds", &json!({}));
assert!(listed["feeds"][0]["status"]["error"].is_string(), "{listed}");
assert_eq!(listed["feeds"][0]["status"]["events"], 2);
let flagged = feed_statuses(&world.session);
assert_eq!((flagged[0].0.as_str(), flagged[0].1.error.is_some()), ("Team", true));
assert!(
world.run("calendar.sync_due", &json!({ "now": "2026-10-20T10:14" }))["count"] == 0
&& world.run("calendar.sync_due", &json!({ "now": "2026-10-20T10:15" }))["count"] == 1,
"a failing feed is retried after 15 minutes"
);
world.run("calendar.sync_apply", &json!({ "id": id, "text": "not a calendar", "now": "2026-10-20T10:15" }));
assert_eq!(world.agenda(), before, "a broken answer does not wipe the cache");
}
#[test]
fn no_secret_reaches_a_read_a_cache_or_the_log() {
let mut world = World::new("feed-secret");
let id = world.subscribe();
world.run("calendar.sync_apply", &json!({ "id": id, "text": FEED, "now": "2026-10-20T09:00" }));
world.run(
"calendar.sync_apply",
&json!({ "id": id, "error": format!("GET https://calendar.example.com/team.ics?token={SECRET} refused"), "now": "2026-10-20T10:00" }),
);
let listed = world.run("calendar.feeds", &json!({})).to_string();
let agenda = world.run("calendar.agenda", &json!({ "from": "2026-10-19", "to": "2026-10-27" })).to_string();
let files = world.private_files();
for (name, text) in [("feeds", &listed), ("agenda", &agenda), ("cache and log", &files)] {
assert!(!text.contains(SECRET) && !text.contains("example.com"), "{name} leaked the address: {text}");
}
assert!(!files.lines().any(|line| line.contains("Board meeting") && line.contains("\"ok\"")), "the log holds no event text");
assert!(files.contains("\"feed\":\"Team\""), "the log names the feed");
}
#[test]
fn feeds_can_be_removed_and_bad_input_is_refused() {
let mut world = World::new("feed-remove");
let first = world.subscribe();
let second = world.subscribe();
assert_eq!((first.as_str(), second.as_str()), ("team", "team-2"), "a second feed of the same name gets its own id");
world.run("calendar.sync_apply", &json!({ "id": first, "text": FEED, "now": "2026-10-20T09:00" }));
world.run("calendar.feed_remove", &json!({ "id": first }));
assert!(!world.agenda().iter().any(|line| line.contains("Board meeting")), "its events are gone");
for (id, params) in [
("calendar.feed_add", json!({ "name": "x", "url": "ftp://a.example/x.ics" })),
("calendar.feed_add", json!({ "name": "", "url": "https://a.example/x.ics" })),
("calendar.feed_add", json!({ "name": "x", "url": "https://a.example/x.ics", "refresh_minutes": 1 })),
("calendar.feed_remove", json!({ "id": "nope" })),
("calendar.sync_apply", json!({ "id": "nope", "text": FEED })),
("calendar.sync_apply", json!({ "id": "team-2" })),
("calendar.sync_due", json!({ "now": "later" })),
] {
assert!(world.registry.run(id, &mut world.session, &params).is_err(), "{id} {params}");
}
}
#[test]
fn a_read_only_session_reads_the_cache_but_changes_nothing() {
let mut world = World::new("feed-ro");
let id = world.subscribe();
world.run("calendar.sync_apply", &json!({ "id": id, "text": FEED, "now": "2026-10-20T09:00" }));
let World { registry, session, vault } = world;
drop(session);
let mut ro = Session::open_read_only(vault.path()).expect("open");
let out = registry.run("calendar.agenda", &mut ro, &json!({ "from": "2026-10-20", "to": "2026-10-20" })).unwrap();
assert_eq!(out["count"], 1);
for (id, params) in [
("calendar.feed_add", json!({ "name": "y", "url": "https://a.example/y.ics" })),
("calendar.sync_apply", json!({ "id": "team", "not_modified": true })),
("calendar.feed_remove", json!({ "id": "team" })),
] {
assert!(registry.run(id, &mut ro, &params).is_err(), "{id}");
}
assert_eq!(registry.run("calendar.feeds", &mut ro, &json!({})).unwrap()["count"], 1);
}
const GOOGLE_EVENTS: &str =
r#"{"items":[{"id":"g1","summary":"Google event","start":{"dateTime":"2026-10-20T11:00:00Z"},"end":{"dateTime":"2026-10-20T12:00:00Z"}}]}"#;
#[test]
fn picked_google_calendars_become_feeds_and_show_read_only_events() {
let mut world = World::new("feed-google");
let list = json!({ "calendars": [{ "id": "me@x.com", "name": "Me", "enabled": true }, { "id": "team@group.calendar.google.com", "name": "Team", "enabled": false }] });
assert_eq!(world.run("calendar.google_calendars", &list)["enabled"], 1);
let feeds = world.run("calendar.feeds", &json!({}));
assert_eq!((feeds["count"].clone(), feeds["feeds"][0]["id"].clone()), (json!(1), json!("google-me")));
assert!(!feeds.to_string().contains("google://") && !feeds.to_string().contains("me@x.com"), "a read never shows the address");
let due = world.run("calendar.sync_due", &json!({ "now": "2026-10-20T09:00" }));
assert_eq!(due["feeds"][0]["url"], "google://calendar/me@x.com", "the app sees the id to fetch");
world.run("calendar.sync_apply", &json!({ "id": "google-me", "text": GOOGLE_EVENTS, "now": "2026-10-20T09:00" }));
let agenda = world.agenda();
assert!(agenda.contains(&"2026-10-20T11:00 Google event".to_owned()), "{agenda:?}");
let state = world.run("calendar.google_state", &json!({}));
assert_eq!(state["calendars"].as_array().unwrap().len(), 2);
world.run(
"calendar.google_calendars",
&json!({ "calendars": [{ "id": "me@x.com", "name": "Me" }, { "id": "team@group.calendar.google.com", "name": "Team", "enabled": true }] }),
);
assert_eq!(world.run("calendar.feeds", &json!({}))["count"], 2, "a missing `enabled` keeps what was chosen, and the second calendar is on now");
world.run("calendar.google_calendars", &json!({ "calendars": [{ "id": "team@group.calendar.google.com", "name": "Team" }] }));
assert_eq!(world.run("calendar.feeds", &json!({}))["count"], 1, "a calendar that left the list is dropped");
assert!(!world.agenda().iter().any(|line| line.ends_with("Google event")), "its cached events went with it");
world.run("calendar.google_disconnect", &json!({}));
assert_eq!(world.run("calendar.feeds", &json!({}))["count"], 0);
assert_eq!(world.run("calendar.google_state", &json!({}))["calendars"], json!([]));
for bad in [json!({ "calendars": "x" }), json!({ "calendars": [{ "name": "no id" }] })] {
assert!(world.registry.run("calendar.google_calendars", &mut world.session, &bad).is_err(), "{bad}");
}
let many: Vec<Value> = (0..31).map(|n| json!({ "id": format!("c{n}"), "name": format!("C{n}"), "enabled": true })).collect();
assert!(world.registry.run("calendar.google_calendars", &mut world.session, &json!({ "calendars": many })).is_err(), "too many on at once");
}
#[test]
fn reminder_settings_are_reachable_through_settings_set() {
let mut world = World::new("feed-settings");
world.run("settings.set", &json!({ "key": "session.reminders.quiet_enabled", "value": true }));
world.run("settings.set", &json!({ "key": "session.reminders.default_minutes", "value": [10, 30] }));
assert!(world.session.config().reminders.quiet_enabled);
assert_eq!(world.session.config().reminders.default_minutes, [10, 30]);
assert!(world.registry.run("settings.set", &mut world.session, &json!({ "key": "session.reminders.nope", "value": 1 })).is_err());
}