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

143 lines
7.5 KiB
Rust

//! Google Calendar, read-only: hashing, sign-in URL, JSON parsing and refreshing a Google feed.
#![allow(clippy::unwrap_used)]
use notescraft_calendar::google::{auth_url, base64url, events_url, parse_calendar_list, parse_events, parse_token, percent_encode, pkce_challenge, sha256};
use notescraft_calendar::{Feed, Fetch, Fetched, Status, refresh};
fn hex(bytes: &[u8]) -> String {
bytes.iter().fold(String::new(), |mut out, b| {
use std::fmt::Write;
let _ = write!(out, "{b:02x}");
out
})
}
#[test]
fn sha256_matches_the_standard_vectors() {
assert_eq!(hex(&sha256(b"")), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
assert_eq!(hex(&sha256(b"abc")), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
assert_eq!(hex(&sha256(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq")), "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1");
assert_eq!(hex(&sha256(&vec![b'a'; 1_000_000])), "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
}
#[test]
fn base64url_and_pkce_match_the_rfcs() {
for (input, expected) in [("", ""), ("f", "Zg"), ("fo", "Zm8"), ("foo", "Zm9v"), ("foob", "Zm9vYg"), ("fooba", "Zm9vYmE"), ("foobar", "Zm9vYmFy")] {
assert_eq!(base64url(input.as_bytes()), expected, "{input:?}");
}
assert_eq!(base64url(&[0xfb, 0xff, 0xfe]), "-__-", "the URL alphabet, not + and /");
assert_eq!(pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
}
#[test]
fn the_sign_in_address_asks_for_read_only_access_and_a_loopback_answer() {
let url = auth_url("id 1.apps", "http://127.0.0.1:5555/", "CHAL", "st&te");
for part in [
"client_id=id%201.apps",
"redirect_uri=http%3A%2F%2F127.0.0.1%3A5555%2F",
"scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly",
"code_challenge=CHAL",
"code_challenge_method=S256",
"state=st%26te",
"access_type=offline",
"response_type=code",
] {
assert!(url.contains(part), "{part} in {url}");
}
assert!(url.starts_with("https://accounts.google.com/"));
assert!(!url.contains("calendar%20") && !url.contains(".events"), "no write scope");
assert_eq!(percent_encode("a b/é~"), "a%20b%2F%C3%A9~");
}
#[test]
fn tokens_calendars_and_errors_parse() {
let token = parse_token(r#"{"access_token":"ya29.x","expires_in":3599,"refresh_token":"1//r","token_type":"Bearer"}"#).unwrap();
assert_eq!((token.access_token.as_str(), token.expires_in, token.refresh_token.as_deref()), ("ya29.x", 3599, Some("1//r")));
assert_eq!(parse_token(r#"{"access_token":"t"}"#).unwrap().refresh_token, None);
for bad in [r#"{"error":"invalid_grant","error_description":"Bad Request"}"#, "{}", "nope", r#"{"access_token":""}"#] {
assert!(parse_token(bad).is_err(), "{bad}");
}
let message = parse_token(r#"{"error":"invalid_grant","error_description":"Token has been revoked"}"#).unwrap_err().to_string();
assert!(message.contains("revoked"), "{message}");
let list = parse_calendar_list(
r#"{"items":[{"id":"me@x.com","summary":"Me","primary":true},{"id":"c2","summary":"Team","summaryOverride":"Work"},{"summary":"no id"}]}"#,
)
.unwrap();
assert_eq!(list.iter().map(|c| (c.id.as_str(), c.name.as_str(), c.primary)).collect::<Vec<_>>(), [("me@x.com", "Me", true), ("c2", "Work", false)]);
assert!(parse_calendar_list(r#"{"nothing":1}"#).is_err());
}
const EVENTS: &str = r#"{"items":[
{"id":"e1","status":"confirmed","summary":"Standup","start":{"dateTime":"2026-10-20T09:30:00+01:00","timeZone":"Europe/Lisbon"},"end":{"dateTime":"2026-10-20T09:45:00+01:00"},"hangoutLink":"https://meet.example/x","reminders":{"useDefault":false,"overrides":[{"method":"popup","minutes":10},{"method":"email","minutes":99999999}]}},
{"id":"e2","summary":"Holiday","start":{"date":"2026-12-24"},"end":{"date":"2026-12-27"}},
{"id":"e3","summary":"One day","start":{"date":"2026-10-10"},"end":{"date":"2026-10-11"}},
{"id":"e4","status":"cancelled","start":{"dateTime":"2026-10-21T10:00:00Z"}},
{"id":"e5","status":"tentative","summary":"Maybe","start":{"dateTime":"2026-10-22T10:00:00Z"},"htmlLink":"https://calendar.example/e5"},
{"id":"bad","summary":"Broken","start":{"dateTime":"yesterday"}},
{"id":"none","summary":"No start"}
]}"#;
#[test]
fn events_become_read_only_events_and_broken_ones_are_counted() {
let (events, skipped) = parse_events(EVENTS).unwrap();
assert_eq!((events.len(), skipped), (5, 2));
let standup = &events[0];
assert_eq!(standup.uid.as_deref(), Some("e1"));
let e = &standup.event;
assert_eq!(
(e.start.to_string().as_str(), e.end.unwrap().to_string().as_str(), e.tz.as_deref()),
("2026-10-20T09:30", "2026-10-20T09:45", Some("Europe/Lisbon"))
);
assert_eq!(
(e.link.as_deref(), e.reminders.iter().map(|r| r.minutes_before).collect::<Vec<_>>()),
(Some("https://meet.example/x"), vec![10]),
"an absurd reminder is dropped"
);
let holiday = &events[1].event;
assert_eq!((holiday.start.to_string().as_str(), holiday.end.unwrap().to_string().as_str()), ("2026-12-24", "2026-12-26"));
assert!(events[2].event.end.is_none(), "a one-day all-day event has no end");
assert_eq!((events[3].event.status, events[3].event.title.as_str()), (Some(Status::Cancelled), "(no title)"));
assert_eq!((events[4].event.status, events[4].event.link.as_deref()), (Some(Status::Tentative), Some("https://calendar.example/e5")));
for bad in ["", "nope", "{}", r#"{"items":"x"}"#] {
assert!(parse_events(bad).is_err(), "{bad:?}");
}
assert!(parse_events(&format!(r#"{{"items":[{}]}}"#, vec![r#"{"start":{"date":"2026-01-01"}}"#; 10_001].join(","))).is_err(), "too many events");
}
#[test]
fn pages_of_events_are_asked_for_in_order() {
let first = events_url("a@b.com", "2026-10-01T00:00:00Z", "2027-10-01T00:00:00Z", None);
assert!(first.starts_with("https://www.googleapis.com/calendar/v3/calendars/a%40b.com/events?singleEvents=true"), "{first}");
assert!(first.contains("timeMin=2026-10-01T00%3A00%3A00Z") && !first.contains("pageToken"));
assert!(events_url("c", "a", "b", Some("tok/en")).ends_with("&pageToken=tok%2Fen"));
}
struct Reply(Option<Result<Fetched, String>>);
impl Fetch for Reply {
fn fetch(&mut self, _url: &str, _etag: Option<&str>) -> Result<Fetched, String> {
self.0.take().unwrap()
}
}
#[test]
fn a_google_feed_holds_no_secret_and_refreshes_from_json() {
let feed = Feed::google("Work", "me@x.com", None).unwrap();
assert_eq!((feed.id.as_str(), feed.url.as_str(), feed.google_calendar()), ("google-work", "google://calendar/me@x.com", Some("me@x.com")));
assert!(Feed::new("x", "https://a.example/x.ics", None).unwrap().google_calendar().is_none());
for (name, id) in [("", "c"), ("x", ""), ("x", "a b")] {
assert!(Feed::google(name, id, None).is_err(), "{name:?} {id:?}");
}
let cached = refresh(&feed, None, &mut Reply(Some(Ok(Fetched::Body { text: EVENTS.to_owned(), etag: None }))), "2026-10-20T09:00".parse().unwrap());
assert_eq!((cached.events.len(), cached.status.skipped, cached.status.error.clone()), (5, 2, None));
let broken = refresh(
&feed,
Some(&cached),
&mut Reply(Some(Ok(Fetched::Body { text: "BEGIN:VCALENDAR".to_owned(), etag: None }))),
"2026-10-20T10:00".parse().unwrap(),
);
assert_eq!(broken.events.len(), 5, "a bad answer keeps the cache");
assert!(broken.status.error.is_some());
}