// synops-calendar — Importer ICS-filer til kalendernoder i Synops. // // Parser en ICS-fil (RFC 5545) og oppretter content-noder med // scheduled-edges for hver VEVENT. Duplikatdeteksjon via ICS UID: // ved re-import oppdateres eksisterende noder. // // Bruk: // synops-calendar --file kalender.ics --collection-id // synops-calendar --url https://calendar.google.com/...ical --collection-id // synops-calendar --payload-json '{"url":"...","collection_id":"..."}' // // Output: JSON til stdout med antall opprettet/oppdatert/feilet. // Feil: stderr + exit code != 0. // // Ref: docs/retninger/unix_filosofi.md, docs/primitiver/edges.md use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc}; use clap::Parser; use ical::parser::ical::component::IcalEvent; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::io::BufReader; use uuid::Uuid; /// Importer ICS-fil til kalendernoder. #[derive(Parser)] #[command(name = "synops-calendar", about = "Importer ICS-fil eller CalDAV-URL til kalendernoder i Synops")] struct Cli { /// Sti til ICS-fil #[arg(long)] file: Option, /// URL til ICS/CalDAV-kalender (hentes via HTTP GET) #[arg(long)] url: Option, /// Samlings-ID (node som hendelsene tilhører) #[arg(long)] collection_id: Option, /// Payload fra jobbkø (JSON). Overstyrer andre argumenter. #[arg(long)] payload_json: Option, } #[derive(Deserialize)] struct JobPayload { #[serde(default)] file: Option, #[serde(default)] url: Option, collection_id: String, } /// Parsed calendar event fra ICS. struct CalendarEvent { uid: String, summary: Option, description: Option, dtstart: Option, dtend: Option, location: Option, } #[derive(Serialize)] struct ImportResult { ok: bool, created: usize, updated: usize, errors: Vec, } /// Kilde for ICS-data. enum IcsSource { File(String), Url(String), } #[tokio::main] async fn main() { synops_common::logging::init("synops_calendar"); let cli = Cli::parse(); let (source, collection_id) = if let Some(ref json_str) = cli.payload_json { let payload: JobPayload = serde_json::from_str(json_str).unwrap_or_else(|e| { eprintln!("Ugyldig --payload-json: {e}"); std::process::exit(1); }); let cid = payload.collection_id.parse::().unwrap_or_else(|e| { eprintln!("Ugyldig collection_id i payload: {e}"); std::process::exit(1); }); let source = if let Some(url) = payload.url { IcsSource::Url(url) } else if let Some(file) = payload.file { IcsSource::File(file) } else { eprintln!("Payload må inneholde 'url' eller 'file'"); std::process::exit(1); }; (source, cid) } else if let Some(url) = cli.url { let cid = cli.collection_id.unwrap_or_else(|| { eprintln!("Mangler --collection-id"); std::process::exit(1); }); (IcsSource::Url(url), cid) } else { let file = cli.file.unwrap_or_else(|| { eprintln!("Mangler --file eller --url"); std::process::exit(1); }); let cid = cli.collection_id.unwrap_or_else(|| { eprintln!("Mangler --collection-id"); std::process::exit(1); }); (IcsSource::File(file), cid) }; // Hent ICS-data let ics_data = match &source { IcsSource::File(path) => { std::fs::read_to_string(path).unwrap_or_else(|e| { eprintln!("Kunne ikke lese {path}: {e}"); std::process::exit(1); }) } IcsSource::Url(url) => { tracing::info!(url = %url, "Henter ICS fra URL"); fetch_ics(url).await.unwrap_or_else(|e| { eprintln!("Kunne ikke hente {url}: {e}"); std::process::exit(1); }) } }; // Parse ICS-data let events = parse_ics_data(&ics_data); if events.is_empty() { let output = ImportResult { ok: true, created: 0, updated: 0, errors: vec!["Ingen VEVENT funnet i ICS-data".to_string()], }; println!("{}", serde_json::to_string_pretty(&output).unwrap()); return; } let source_label = match &source { IcsSource::File(path) => path.clone(), IcsSource::Url(url) => url.clone(), }; tracing::info!(events = events.len(), source = %source_label, "Parsed ICS-data"); // Koble til database let db = synops_common::db::connect().await.unwrap_or_else(|e| { eprintln!("{e}"); std::process::exit(1); }); // Verifiser at collection-noden eksisterer let collection_exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)", ) .bind(collection_id) .fetch_one(&db) .await .unwrap_or_else(|e| { eprintln!("DB-feil ved oppslag av samling: {e}"); std::process::exit(1); }); if !collection_exists { eprintln!("Samlings-node {collection_id} finnes ikke"); std::process::exit(1); } // Importer hendelser let mut created = 0usize; let mut updated = 0usize; let mut errors = Vec::new(); for event in &events { match import_event(&db, event, collection_id).await { Ok(EventAction::Created) => created += 1, Ok(EventAction::Updated) => updated += 1, Err(e) => { let uid = &event.uid; let msg = format!("Feil ved import av {uid}: {e}"); tracing::warn!("{}", msg); errors.push(msg); } } } let all_ok = errors.is_empty(); let output = ImportResult { ok: all_ok, created, updated, errors, }; tracing::info!(created, updated, errors = output.errors.len(), "Import fullført"); println!("{}", serde_json::to_string_pretty(&output).unwrap()); if !all_ok { std::process::exit(1); } } /// Hent ICS-data fra en URL via HTTP GET. async fn fetch_ics(url: &str) -> Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|e| format!("Kunne ikke opprette HTTP-klient: {e}"))?; let response = client .get(url) .header("User-Agent", "synops-calendar/0.1") .send() .await .map_err(|e| format!("HTTP-forespørsel feilet: {e}"))?; if !response.status().is_success() { return Err(format!("HTTP {}: {}", response.status(), url)); } let body = response .text() .await .map_err(|e| format!("Kunne ikke lese respons: {e}"))?; // Enkel validering: sjekk at det ser ut som ICS if !body.contains("BEGIN:VCALENDAR") { return Err(format!("Responsen ser ikke ut som ICS-data (mangler BEGIN:VCALENDAR)")); } Ok(body) } enum EventAction { Created, Updated, } /// Importer én kalender-hendelse. Duplikatdeteksjon via UID i metadata. async fn import_event( db: &PgPool, event: &CalendarEvent, collection_id: Uuid, ) -> Result { let dtstart = event.dtstart.as_deref().ok_or("Mangler DTSTART")?; let at = parse_ics_datetime(dtstart).ok_or_else(|| format!("Kunne ikke parse DTSTART: {dtstart}"))?; let at_str = at.to_rfc3339(); let title = event.summary.clone().unwrap_or_default(); // Bygg metadata let mut meta = serde_json::json!({ "ics_uid": event.uid, }); if let Some(ref loc) = event.location { meta["location"] = serde_json::Value::String(loc.clone()); } if let Some(ref dtend) = event.dtend { if let Some(end) = parse_ics_datetime(dtend) { meta["dtend"] = serde_json::Value::String(end.to_rfc3339()); } } // Sjekk om noden allerede eksisterer (duplikatdeteksjon via ics_uid) let existing: Option = sqlx::query_scalar( r#"SELECT n.id FROM nodes n JOIN edges e ON e.source_id = n.id WHERE e.target_id = $1 AND e.edge_type = 'belongs_to' AND n.metadata->>'ics_uid' = $2"#, ) .bind(collection_id) .bind(&event.uid) .fetch_optional(db) .await .map_err(|e| format!("DB-feil ved duplikatsjekk: {e}"))?; if let Some(node_id) = existing { // Oppdater eksisterende node let mut tx = db.begin().await.map_err(|e| format!("Transaksjon feilet: {e}"))?; sqlx::query( "UPDATE nodes SET title = $1, content = $2, metadata = $3 WHERE id = $4", ) .bind(&title) .bind(event.description.as_deref()) .bind(&meta) .bind(node_id) .execute(&mut *tx) .await .map_err(|e| format!("Kunne ikke oppdatere node: {e}"))?; // Oppdater scheduled-edge metadata let sched_meta = serde_json::json!({ "at": at_str }); sqlx::query( r#"UPDATE edges SET metadata = $1 WHERE source_id = $2 AND target_id = $3 AND edge_type = 'scheduled'"#, ) .bind(&sched_meta) .bind(node_id) .bind(collection_id) .execute(&mut *tx) .await .map_err(|e| format!("Kunne ikke oppdatere scheduled-edge: {e}"))?; tx.commit().await.map_err(|e| format!("Commit feilet: {e}"))?; tracing::debug!(node_id = %node_id, uid = %event.uid, "Oppdatert eksisterende hendelse"); Ok(EventAction::Updated) } else { // Opprett ny node + edges let node_id = Uuid::now_v7(); let mut tx = db.begin().await.map_err(|e| format!("Transaksjon feilet: {e}"))?; sqlx::query( r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata) VALUES ($1, 'content', $2, $3, 'hidden', $4)"#, ) .bind(node_id) .bind(&title) .bind(event.description.as_deref()) .bind(&meta) .execute(&mut *tx) .await .map_err(|e| format!("Kunne ikke opprette node: {e}"))?; // belongs_to-edge: hendelse → samling sqlx::query( r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata) VALUES ($1, $2, $3, 'belongs_to', '{}')"#, ) .bind(Uuid::now_v7()) .bind(node_id) .bind(collection_id) .execute(&mut *tx) .await .map_err(|e| format!("Kunne ikke opprette belongs_to-edge: {e}"))?; // scheduled-edge: hendelse → samling med tidspunkt let sched_meta = serde_json::json!({ "at": at_str }); sqlx::query( r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata) VALUES ($1, $2, $3, 'scheduled', $4)"#, ) .bind(Uuid::now_v7()) .bind(node_id) .bind(collection_id) .bind(&sched_meta) .execute(&mut *tx) .await .map_err(|e| format!("Kunne ikke opprette scheduled-edge: {e}"))?; tx.commit().await.map_err(|e| format!("Commit feilet: {e}"))?; tracing::debug!(node_id = %node_id, uid = %event.uid, "Opprettet ny hendelse"); Ok(EventAction::Created) } } /// Parse ICS-data fra en streng og returner liste med hendelser. fn parse_ics_data(data: &str) -> Vec { let reader = BufReader::new(data.as_bytes()); let parser = ical::IcalParser::new(reader); let mut events = Vec::new(); for calendar in parser { let calendar = match calendar { Ok(c) => c, Err(e) => { eprintln!("Feil ved parsing av ICS: {e}"); continue; } }; for vevent in calendar.events { if let Some(event) = extract_event(&vevent) { events.push(event); } } } events } /// Ekstraher relevant data fra en VEVENT. fn extract_event(vevent: &IcalEvent) -> Option { let uid = get_property(vevent, "UID")?; Some(CalendarEvent { uid, summary: get_property(vevent, "SUMMARY"), description: get_property(vevent, "DESCRIPTION"), dtstart: get_property(vevent, "DTSTART"), dtend: get_property(vevent, "DTEND"), location: get_property(vevent, "LOCATION"), }) } /// Hent en property-verdi fra en VEVENT. fn get_property(vevent: &IcalEvent, name: &str) -> Option { vevent .properties .iter() .find(|p| p.name == name) .and_then(|p| p.value.clone()) } /// Parse ICS datetime-streng til UTC DateTime. /// /// Støtter formater: /// - 20260320T140000Z (UTC) /// - 20260320T140000 (lokal, tolkes som UTC) /// - 20260320 (heldagshendelse) fn parse_ics_datetime(s: &str) -> Option> { let s = s.trim(); // Fjern TZID-prefix om det finnes (noen ICS-filer har dette i verdien) let s = if s.contains(':') { s.rsplit(':').next().unwrap_or(s) } else { s }; // 20260320T140000Z if s.ends_with('Z') { let s = &s[..s.len() - 1]; let naive = NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%S").ok()?; return Some(naive.and_utc()); } // 20260320T140000 if s.contains('T') { let naive = NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%S").ok()?; return Some(naive.and_utc()); } // 20260320 (heldagshendelse → 00:00 UTC) let date = NaiveDate::parse_from_str(s, "%Y%m%d").ok()?; Some( date.and_hms_opt(0, 0, 0)? .and_utc(), ) }