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>
226 lines
12 KiB
Rust
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, ¶ms).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, ¶ms).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());
|
|
}
|