Prepare repo for GitHub publication
- Add MIT LICENSE file - Expand .gitignore with standard Rust/Linux entries - Remove dangling symlinks (breadmancli, breadpadcli) and dev scratchpad (svgs.txt) from git tracking - Replace unsafe unwrap() calls with expect() in breadman CLI (guarded by prior filter)
This commit is contained in:
parent
feefdb81b9
commit
c4626dd64d
34 changed files with 2825 additions and 771 deletions
|
|
@ -16,9 +16,14 @@ pub struct CalDavEventInfo {
|
|||
|
||||
impl CalDavClient {
|
||||
pub fn new(config: CalendarConfig) -> Self {
|
||||
// `reqwest::Client::builder().build()` can only fail if the TLS backend can't be
|
||||
// initialised; fall back to `Client::new()` semantics rather than panicking.
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("falling back to default HTTP client: {}", e);
|
||||
reqwest::Client::new()
|
||||
});
|
||||
CalDavClient { config, client }
|
||||
}
|
||||
|
||||
|
|
@ -139,28 +144,56 @@ fn event_url(base: &str, uid: &str) -> String {
|
|||
fn build_ical(note: &Note, uid: &str) -> String {
|
||||
let dt = note.time.unwrap_or(note.created);
|
||||
let dtstart = dt.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let summary = escape_ical(¬e.body);
|
||||
let description = escape_ical(&format!("type={}", note.note_type.as_str()));
|
||||
let dtstamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
|
||||
|
||||
let mut ical = format!(
|
||||
"BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//breadpad//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:{uid}\r\n\
|
||||
SUMMARY:{summary}\r\n\
|
||||
DTSTART:{dtstart}\r\n\
|
||||
DTEND:{dtstart}\r\n\
|
||||
DESCRIPTION:{description}\r\n"
|
||||
);
|
||||
let mut lines: Vec<String> = vec![
|
||||
"BEGIN:VCALENDAR".into(),
|
||||
"VERSION:2.0".into(),
|
||||
"PRODID:-//breadpad//EN".into(),
|
||||
"BEGIN:VEVENT".into(),
|
||||
format!("UID:{}", uid),
|
||||
fold_line(&format!("SUMMARY:{}", escape_ical(¬e.body))),
|
||||
format!("DTSTART:{}", dtstart),
|
||||
format!("DTEND:{}", dtstart),
|
||||
format!("DTSTAMP:{}", dtstamp),
|
||||
fold_line(&format!("DESCRIPTION:{}", escape_ical(&format!("type={}", note.note_type.as_str())))),
|
||||
];
|
||||
|
||||
if let Some(rrule) = ¬e.rrule {
|
||||
ical.push_str(rrule.as_str());
|
||||
ical.push_str("\r\n");
|
||||
lines.push(rrule.as_str().to_string());
|
||||
}
|
||||
|
||||
ical.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
|
||||
ical
|
||||
lines.push("END:VEVENT".into());
|
||||
lines.push("END:VCALENDAR".into());
|
||||
|
||||
lines.join("\r\n") + "\r\n"
|
||||
}
|
||||
|
||||
/// Fold an iCal property line per RFC 5545 §3.1: lines longer than 75 octets
|
||||
/// are split with CRLF + a single space continuation character.
|
||||
fn fold_line(line: &str) -> String {
|
||||
let bytes = line.as_bytes();
|
||||
if bytes.len() <= 75 {
|
||||
return line.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(line.len() + line.len() / 75 * 3);
|
||||
let mut pos = 0;
|
||||
let mut first = true;
|
||||
while pos < bytes.len() {
|
||||
if !first {
|
||||
out.push_str("\r\n ");
|
||||
}
|
||||
let limit = if first { 75 } else { 74 }; // continuation lines lose 1 octet to the space
|
||||
let mut end = (pos + limit).min(bytes.len());
|
||||
// Step back if we landed in the middle of a multi-byte UTF-8 sequence.
|
||||
while end > pos && end < bytes.len() && (bytes[end] & 0xC0) == 0x80 {
|
||||
end -= 1;
|
||||
}
|
||||
out.push_str(std::str::from_utf8(&bytes[pos..end]).unwrap_or(""));
|
||||
pos = end;
|
||||
first = false;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn escape_ical(s: &str) -> String {
|
||||
|
|
@ -219,3 +252,241 @@ fn parse_ical_block(data: &str) -> Vec<CalDavEventInfo> {
|
|||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{Note, NoteType, RecurrenceRule};
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
fn reminder(body: &str) -> Note {
|
||||
let mut n = Note::new(body.into(), NoteType::Reminder, None);
|
||||
n.time = Some(Utc::now());
|
||||
n
|
||||
}
|
||||
|
||||
// ---- escape_ical ----
|
||||
|
||||
#[test]
|
||||
fn escape_ical_clean_string_unchanged() {
|
||||
assert_eq!(escape_ical("hello world"), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_empty_string() {
|
||||
assert_eq!(escape_ical(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_escapes_backslash() {
|
||||
assert_eq!(escape_ical("back\\slash"), "back\\\\slash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_escapes_semicolon() {
|
||||
assert_eq!(escape_ical("a;b"), "a\\;b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_escapes_comma() {
|
||||
assert_eq!(escape_ical("apples,oranges"), "apples\\,oranges");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_escapes_newline() {
|
||||
assert_eq!(escape_ical("line1\nline2"), "line1\\nline2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_multiple_special_chars() {
|
||||
assert_eq!(escape_ical("a;b,c\nd"), "a\\;b\\,c\\nd");
|
||||
}
|
||||
|
||||
// ---- caldav_uid ----
|
||||
|
||||
#[test]
|
||||
fn caldav_uid_uses_existing_field() {
|
||||
let mut n = Note::new("test".into(), NoteType::Reminder, None);
|
||||
n.caldav_uid = Some("my-custom-uid".into());
|
||||
assert_eq!(caldav_uid(&n), "my-custom-uid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caldav_uid_falls_back_to_id_at_breadpad() {
|
||||
let n = Note::new("test".into(), NoteType::Reminder, None);
|
||||
assert_eq!(caldav_uid(&n), format!("{}@breadpad", n.id));
|
||||
}
|
||||
|
||||
// ---- event_url ----
|
||||
|
||||
#[test]
|
||||
fn event_url_with_trailing_slash() {
|
||||
let url = event_url("https://cloud.example.com/cal/", "abc@breadpad");
|
||||
assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_url_without_trailing_slash() {
|
||||
let url = event_url("https://cloud.example.com/cal", "abc@breadpad");
|
||||
assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics");
|
||||
}
|
||||
|
||||
// ---- build_ical ----
|
||||
|
||||
#[test]
|
||||
fn build_ical_contains_vcalendar_markers() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("BEGIN:VCALENDAR"), "missing BEGIN:VCALENDAR");
|
||||
assert!(ical.contains("END:VCALENDAR"), "missing END:VCALENDAR");
|
||||
assert!(ical.contains("BEGIN:VEVENT"), "missing BEGIN:VEVENT");
|
||||
assert!(ical.contains("END:VEVENT"), "missing END:VEVENT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_contains_uid() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains(&format!("UID:{}", uid)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_contains_summary() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("SUMMARY:team sync"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_description_contains_type() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("DESCRIPTION:type=reminder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_uses_note_time_for_dtstart() {
|
||||
let mut n = Note::new("dentist".into(), NoteType::Reminder, None);
|
||||
n.time = Some(Utc.with_ymd_and_hms(2026, 6, 15, 14, 30, 0).unwrap());
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("DTSTART:20260615T143000Z"), "ical: {}", &ical[..400]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_falls_back_to_created_when_no_time() {
|
||||
let n = Note::new("no time set".into(), NoteType::Reminder, None);
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("DTSTART:"), "DTSTART should be present");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_includes_rrule_when_set() {
|
||||
let mut n = reminder("standup");
|
||||
n.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0"));
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_no_rrule_when_not_set() {
|
||||
let n = reminder("one-off");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(!ical.contains("RRULE:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_escapes_special_chars_in_summary() {
|
||||
let n = Note::new("dentist; bring card, and ID".into(), NoteType::Reminder, None);
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("SUMMARY:dentist\\; bring card\\, and ID"), "ical: {}", &ical[..400]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_contains_dtstamp() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("DTSTAMP:"), "missing DTSTAMP in:\n{}", ical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_line_short_unchanged() {
|
||||
let line = "SUMMARY:short";
|
||||
assert_eq!(fold_line(line), line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_line_exactly_75_unchanged() {
|
||||
let line = "A".repeat(75);
|
||||
assert_eq!(fold_line(&line), line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_line_76_chars_splits() {
|
||||
let line = "X".repeat(76);
|
||||
let folded = fold_line(&line);
|
||||
assert!(folded.contains("\r\n "), "expected fold in: {:?}", folded);
|
||||
// Reassembled content should equal the original.
|
||||
let rejoined: String = folded.split("\r\n ").collect();
|
||||
assert_eq!(rejoined, line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_long_summary_is_folded() {
|
||||
let long_body = "a".repeat(200);
|
||||
let n = Note::new(long_body.clone(), NoteType::Reminder, None);
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
// Every line (split on CRLF) must be at most 75 octets.
|
||||
for line in ical.split("\r\n") {
|
||||
assert!(
|
||||
line.len() <= 75,
|
||||
"line too long ({} octets): {:?}",
|
||||
line.len(),
|
||||
line
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- parse_report_response ----
|
||||
|
||||
#[test]
|
||||
fn parse_report_response_empty_xml_returns_empty() {
|
||||
let events = parse_report_response("").unwrap();
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_report_response_single_event() {
|
||||
let xml = "\
|
||||
BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:abc123@breadpad\r\n\
|
||||
SUMMARY:team sync\r\n\
|
||||
DTSTART:20260615T140000Z\r\n\
|
||||
DTEND:20260615T140000Z\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n";
|
||||
let events = parse_report_response(xml).unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].uid, "abc123@breadpad");
|
||||
assert_eq!(events[0].summary, "team sync");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_report_response_no_vcalendar_block_returns_empty() {
|
||||
let xml = "<multistatus><response><status>HTTP/1.1 200 OK</status></response></multistatus>";
|
||||
let events = parse_report_response(xml).unwrap();
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue