diff --git a/docs/features/kalender.md b/docs/features/kalender.md index 1aa2909..b617169 100644 --- a/docs/features/kalender.md +++ b/docs/features/kalender.md @@ -6,7 +6,7 @@ Månedsbasert kalendervisning for redaksjonell planlegging. Hendelser er nodes i ## 2. Status **Kalendervisning implementert (mars 2026).** Bruker `scheduled`-edges i stedet for -separat `calendar_events`-tabell. Abonnement og ICS-eksport gjenstår. +separat `calendar_events`-tabell. CalDAV/ICS-abonnement implementert. ICS-eksport gjenstår. ### Implementert - **Fase 1 (v1, mars 2025):** PG-adapter med `calendars` + `calendar_events` (legacy) @@ -23,6 +23,16 @@ separat `calendar_events`-tabell. Abonnement og ICS-eksport gjenstår. - Lenke fra mottak-siden med hendelsesteller - Tilgang via `nodeVisibility` (respekterer `node_access`-matrise) - Sanntidsoppdatering via WebSocket (PG LISTEN/NOTIFY) +- **ICS-import (oppgave 29.11):** `synops-calendar` CLI som parser ICS-filer + - Input: `--file ` eller `--url ` + `--collection-id ` + - Duplikatdeteksjon via `metadata.ics_uid` + - Oppdatering ved re-import +- **CalDAV-abonnement (oppgave 29.12):** Periodisk polling av eksterne kalendere + - Abonnement lagres i `metadata.calendar_subscriptions[]` på samlingsnoder + - `calendar_poller` i maskinrommet sjekker hvert 60. sekund + - Enqueuer `calendar_poll`-jobber som spawner `synops-calendar --url` + - API: `POST /intentions/configure_calendar_subscription` og `remove_calendar_subscription` + - Støtter Google Calendar, Outlook, og andre ICS/CalDAV-URLer ### Gjenstår — Fase 2 - Kobling til kanban-kort (vis deadline på kalender) @@ -32,7 +42,7 @@ separat `calendar_events`-tabell. Abonnement og ICS-eksport gjenstår. - Flerdag-hendelser (vises over flere celler) - Abonnementsmodell (kalender → kalender via graph_edges) - Personlige vs. delte kalendere (via samlings-noder) -- ICS/CalDAV-eksport +- ICS-eksport (offentlige kalendere → `/cal/{id}.ics`) - Varsler/påminnelser via jobbkøen ## 3. Datamodell (implementert) diff --git a/maskinrommet/src/calendar_poller.rs b/maskinrommet/src/calendar_poller.rs new file mode 100644 index 0000000..20d1d03 --- /dev/null +++ b/maskinrommet/src/calendar_poller.rs @@ -0,0 +1,247 @@ +// Calendar-poller — periodisk polling av CalDAV/ICS-kalendere. +// +// Finner samlinger med metadata.calendar_subscriptions og enqueuer +// calendar_poll-jobber for abonnementer som er klare for ny polling +// (basert på intervall og siste poll). +// +// Samlingens metadata-format: +// ```json +// { +// "calendar_subscriptions": [ +// { +// "url": "https://calendar.google.com/calendar/ical/.../basic.ics", +// "interval_minutes": 60, +// "last_polled_at": null, +// "enabled": true +// } +// ] +// } +// ``` +// +// Ref: docs/features/kalender.md, tools/synops-calendar/ + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +/// En enkelt kalender-subscription på en samling. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CalendarSubscription { + pub url: String, + #[serde(default = "default_interval")] + pub interval_minutes: u32, + pub last_polled_at: Option>, + #[serde(default)] + pub enabled: Option, +} + +fn default_interval() -> u32 { + 60 +} + +/// Rad fra spørring: samling med calendar_subscriptions. +#[derive(sqlx::FromRow)] +struct CollectionWithCalendars { + id: Uuid, + created_by: Uuid, + calendar_subscriptions: serde_json::Value, +} + +/// Start periodisk kalender-poller i bakgrunnen. +/// Sjekker hvert 60. sekund for abonnementer som trenger ny polling. +pub fn start_calendar_poller(db: PgPool) { + tokio::spawn(async move { + // Vent 90 sekunder etter oppstart (forskjøvet fra feed-poller) + tokio::time::sleep(std::time::Duration::from_secs(90)).await; + tracing::info!("Calendar-poller startet (intervall: 60s)"); + + loop { + match poll_due_calendars(&db).await { + Ok(count) => { + if count > 0 { + tracing::info!(calendars = count, "Calendar-poller: {} kalendere lagt i kø", count); + } + } + Err(e) => { + tracing::error!(error = %e, "Calendar-poller feilet"); + } + } + + tokio::time::sleep(std::time::Duration::from_secs(60)).await; + } + }); +} + +/// Finn samlinger med calendar_subscriptions og enqueue jobber for forfalne abonnementer. +async fn poll_due_calendars(db: &PgPool) -> Result { + let collections: Vec = sqlx::query_as( + r#" + SELECT id, created_by, metadata->'calendar_subscriptions' as calendar_subscriptions + FROM nodes + WHERE node_kind = 'collection' + AND metadata ? 'calendar_subscriptions' + AND jsonb_array_length(metadata->'calendar_subscriptions') > 0 + "#, + ) + .fetch_all(db) + .await + .map_err(|e| format!("Kunne ikke hente samlinger med calendar_subscriptions: {e}"))?; + + let mut enqueued = 0usize; + let now = Utc::now(); + + for collection in &collections { + let subs: Vec = + serde_json::from_value(collection.calendar_subscriptions.clone()).unwrap_or_default(); + + for (idx, sub) in subs.iter().enumerate() { + if sub.enabled == Some(false) { + continue; + } + + let due = match sub.last_polled_at { + Some(last) => { + let elapsed = now.signed_duration_since(last); + elapsed.num_minutes() >= sub.interval_minutes as i64 + } + None => true, + }; + + if !due { + continue; + } + + // Sjekk at det ikke allerede finnes en kjørende/ventende jobb + let existing: Option = sqlx::query_scalar( + r#" + SELECT COUNT(*) FROM job_queue + WHERE job_type = 'calendar_poll' + AND payload->>'url' = $1 + AND payload->>'collection_id' = $2 + AND status IN ('pending', 'running', 'retry') + "#, + ) + .bind(&sub.url) + .bind(collection.id.to_string()) + .fetch_one(db) + .await + .map_err(|e| format!("Kunne ikke sjekke eksisterende calendar_poll-jobb: {e}"))?; + + if existing.unwrap_or(0) > 0 { + tracing::debug!( + url = %sub.url, + collection_id = %collection.id, + "Calendar-poll allerede i kø, hopper over" + ); + continue; + } + + let payload = serde_json::json!({ + "url": sub.url, + "collection_id": collection.id.to_string(), + "created_by": collection.created_by.to_string(), + "subscription_index": idx, + }); + + crate::jobs::enqueue(db, "calendar_poll", payload, Some(collection.id), 3) + .await + .map_err(|e| format!("Kunne ikke enqueue calendar_poll: {e}"))?; + + tracing::info!( + url = %sub.url, + collection_id = %collection.id, + "Calendar-poll enqueued" + ); + + enqueued += 1; + } + } + + Ok(enqueued) +} + +/// Håndterer calendar_poll-jobb — spawner synops-calendar CLI med --url. +/// +/// Payload: { url, collection_id, created_by, subscription_index } +pub async fn handle_calendar_poll( + job: &crate::jobs::JobRow, + db: &PgPool, +) -> Result { + let url = job.payload.get("url") + .and_then(|v| v.as_str()) + .ok_or("Mangler url i payload")?; + + let collection_id = job.payload.get("collection_id") + .and_then(|v| v.as_str()) + .ok_or("Mangler collection_id i payload")?; + + let subscription_index = job.payload.get("subscription_index") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as usize; + + let collection_uuid: Uuid = collection_id.parse() + .map_err(|e| format!("Ugyldig collection_id: {e}"))?; + + let bin = std::env::var("SYNOPS_CALENDAR_BIN") + .unwrap_or_else(|_| "synops-calendar".to_string()); + let mut cmd = tokio::process::Command::new(&bin); + + cmd.arg("--url").arg(url) + .arg("--collection-id").arg(collection_id); + + crate::cli_dispatch::set_database_url(&mut cmd)?; + + tracing::info!( + url = %url, + collection_id = %collection_id, + "Starter synops-calendar (URL-modus)" + ); + + let result = crate::cli_dispatch::run_cli_tool(&bin, &mut cmd).await?; + + // Oppdater last_polled_at + if let Err(e) = update_last_polled(db, collection_uuid, subscription_index).await { + tracing::warn!(error = %e, "Kunne ikke oppdatere last_polled_at for kalender"); + } + + let created = result["created"].as_u64().unwrap_or(0); + let updated = result["updated"].as_u64().unwrap_or(0); + tracing::info!( + url = %url, + created = created, + updated = updated, + "synops-calendar fullført" + ); + + Ok(result) +} + +/// Oppdater last_polled_at for et spesifikt kalender-abonnement. +async fn update_last_polled( + db: &PgPool, + collection_id: Uuid, + subscription_index: usize, +) -> Result<(), String> { + let now = Utc::now().to_rfc3339(); + + sqlx::query( + r#" + UPDATE nodes + SET metadata = jsonb_set( + metadata, + $2::text[], + to_jsonb($3::text) + ) + WHERE id = $1 + "#, + ) + .bind(collection_id) + .bind(&["calendar_subscriptions".to_string(), subscription_index.to_string(), "last_polled_at".to_string()]) + .bind(&now) + .execute(db) + .await + .map_err(|e| format!("Kunne ikke oppdatere last_polled_at: {e}"))?; + + Ok(()) +} diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index 7862ab9..c697774 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -5006,6 +5006,197 @@ pub async fn remove_feed_subscription( }))) } +// ============================================================================= +// Kalender-abonnement (oppgave 29.12) +// ============================================================================= + +#[derive(Deserialize)] +pub struct ConfigureCalendarSubscriptionRequest { + /// Samlings-ID + pub collection_id: Uuid, + /// Kalender-URL (ICS/CalDAV) + pub url: String, + /// Poll-intervall i minutter (default: 60) + #[serde(default = "default_calendar_interval")] + pub interval_minutes: u32, + /// Aktivert (default: true) + #[serde(default = "default_true")] + pub enabled: bool, +} + +fn default_calendar_interval() -> u32 { 60 } + +#[derive(Deserialize)] +pub struct RemoveCalendarSubscriptionRequest { + /// Samlings-ID + pub collection_id: Uuid, + /// Kalender-URL å fjerne + pub url: String, +} + +/// POST /intentions/configure_calendar_subscription +/// +/// Legger til eller oppdaterer et kalender-abonnement på en samling. +/// Lagres i samlingens `metadata.calendar_subscriptions[]`. +pub async fn configure_calendar_subscription( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + if !req.url.starts_with("http://") && !req.url.starts_with("https://") { + return Err(bad_request("Kalender-URL må starte med http:// eller https://")); + } + if req.interval_minutes < 15 { + return Err(bad_request("Intervall må være minst 15 minutter")); + } + + // Sjekk at samlingen eksisterer og er en samling + let collection: Option<(String, serde_json::Value)> = sqlx::query_as( + "SELECT node_kind, COALESCE(metadata, '{}'::jsonb) FROM nodes WHERE id = $1", + ) + .bind(req.collection_id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "PG-feil ved oppslag av samling"); + internal_error("Kunne ikke slå opp samling") + })?; + + let (kind, metadata) = collection.ok_or_else(|| bad_request("Samling ikke funnet"))?; + if kind != "collection" { + return Err(bad_request("Noden er ikke en samling")); + } + + // Les eksisterende abonnementer + let mut subs: Vec = metadata + .get("calendar_subscriptions") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + let new_sub = crate::calendar_poller::CalendarSubscription { + url: req.url.clone(), + interval_minutes: req.interval_minutes, + last_polled_at: None, + enabled: Some(req.enabled), + }; + + if let Some(existing) = subs.iter_mut().find(|s| s.url == req.url) { + existing.interval_minutes = req.interval_minutes; + existing.enabled = Some(req.enabled); + tracing::info!(url = %req.url, "Kalender-abonnement oppdatert"); + } else { + subs.push(new_sub); + tracing::info!(url = %req.url, "Kalender-abonnement lagt til"); + } + + // Skriv tilbake til metadata + sqlx::query( + r#" + UPDATE nodes + SET metadata = jsonb_set( + COALESCE(metadata, '{}'::jsonb), + '{calendar_subscriptions}', + $2 + ) + WHERE id = $1 + "#, + ) + .bind(req.collection_id) + .bind(serde_json::to_value(&subs).unwrap()) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "Kunne ikke oppdatere calendar_subscriptions"); + internal_error("Kunne ikke lagre kalender-abonnement") + })?; + + tracing::info!( + collection_id = %req.collection_id, + url = %req.url, + user = %user.node_id, + interval = req.interval_minutes, + "Kalender-abonnement konfigurert" + ); + + Ok(Json(serde_json::json!({ + "status": "ok", + "collection_id": req.collection_id, + "url": req.url, + "interval_minutes": req.interval_minutes, + "enabled": req.enabled, + "subscriptions_count": subs.len(), + }))) +} + +/// POST /intentions/remove_calendar_subscription +/// +/// Fjerner et kalender-abonnement fra en samling. +pub async fn remove_calendar_subscription( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let metadata: Option = sqlx::query_scalar( + "SELECT COALESCE(metadata, '{}'::jsonb) FROM nodes WHERE id = $1 AND node_kind = 'collection'", + ) + .bind(req.collection_id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "PG-feil"); + internal_error("Kunne ikke hente samling") + })?; + + let metadata = metadata.ok_or_else(|| bad_request("Samling ikke funnet"))?; + + let mut subs: Vec = metadata + .get("calendar_subscriptions") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + let before = subs.len(); + subs.retain(|s| s.url != req.url); + + if subs.len() == before { + return Err(bad_request("Kalender-abonnement ikke funnet")); + } + + sqlx::query( + r#" + UPDATE nodes + SET metadata = jsonb_set( + COALESCE(metadata, '{}'::jsonb), + '{calendar_subscriptions}', + $2 + ) + WHERE id = $1 + "#, + ) + .bind(req.collection_id) + .bind(serde_json::to_value(&subs).unwrap()) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "Kunne ikke oppdatere calendar_subscriptions"); + internal_error("Kunne ikke fjerne kalender-abonnement") + })?; + + tracing::info!( + collection_id = %req.collection_id, + url = %req.url, + user = %user.node_id, + "Kalender-abonnement fjernet" + ); + + Ok(Json(serde_json::json!({ + "status": "ok", + "collection_id": req.collection_id, + "url": req.url, + "removed": true, + "subscriptions_count": subs.len(), + }))) +} + // ============================================================================= // Tester // ============================================================================= diff --git a/maskinrommet/src/jobs.rs b/maskinrommet/src/jobs.rs index 4bf4991..98a70e6 100644 --- a/maskinrommet/src/jobs.rs +++ b/maskinrommet/src/jobs.rs @@ -230,6 +230,10 @@ async fn dispatch( "feed_poll" => { crate::feed_poller::handle_feed_poll(job, db).await } + // Calendar-polling: periodisk CalDAV/ICS-abonnement (oppgave 29.12) + "calendar_poll" => { + crate::calendar_poller::handle_calendar_poll(job, db).await + } // Orchestration: trigger-evaluering har lagt jobben i kø. // Kompilatoren parser scriptet og validerer det. // Utførelse av kompilert script kommer i oppgave 24.5. diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 7e0e9a0..b15453f 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -29,6 +29,7 @@ pub mod summarize; pub mod ws; pub mod mixer; pub mod feed_poller; +pub mod calendar_poller; pub mod orchestration_trigger; mod webhook; mod webhook_admin; @@ -170,6 +171,9 @@ async fn main() { // Start feed-poller for RSS/Atom-abonnementer (oppgave 29.3) feed_poller::start_feed_poller(db.clone()); + + // Start calendar-poller for CalDAV/ICS-abonnementer (oppgave 29.12) + calendar_poller::start_calendar_poller(db.clone()); let dynamic_page_cache = publishing::new_dynamic_page_cache(); let metrics = metrics::MetricsCollector::new(); @@ -284,6 +288,9 @@ async fn main() { // Feed-abonnement (oppgave 29.3) .route("/intentions/configure_feed_subscription", post(intentions::configure_feed_subscription)) .route("/intentions/remove_feed_subscription", post(intentions::remove_feed_subscription)) + // Kalender-abonnement (oppgave 29.12) + .route("/intentions/configure_calendar_subscription", post(intentions::configure_calendar_subscription)) + .route("/intentions/remove_calendar_subscription", post(intentions::remove_calendar_subscription)) .route("/intentions/compile_script", post(intentions::compile_script)) .route("/intentions/test_orchestration", post(intentions::test_orchestration)) .route("/intentions/ai_suggest_script", post(intentions::ai_suggest_script)) diff --git a/migrations/031_calendar_subscriptions.sql b/migrations/031_calendar_subscriptions.sql new file mode 100644 index 0000000..b3e4a77 --- /dev/null +++ b/migrations/031_calendar_subscriptions.sql @@ -0,0 +1,30 @@ +-- 031_calendar_subscriptions.sql +-- Oppgave 29.12: CalDAV-abonnement — periodisk polling av eksterne kalendere. +-- Kalenderabonnementer lagres i samlingens metadata.calendar_subscriptions[], +-- etter samme mønster som feed_subscriptions (oppgave 29.3). +-- +-- Inneholder: +-- 1. Indeks for rask oppslag av calendar_subscriptions +-- 2. Prioritetsregel for calendar_poll-jobber +-- +-- Ref: docs/features/kalender.md, tools/synops-calendar/ + +BEGIN; + +-- ============================================================================= +-- 1. Indeks for rask oppslag av samlinger med calendar_subscriptions +-- ============================================================================= +CREATE INDEX IF NOT EXISTS idx_nodes_calendar_subscriptions + ON nodes ((metadata->'calendar_subscriptions')) + WHERE node_kind = 'collection' AND metadata ? 'calendar_subscriptions'; + +-- ============================================================================= +-- 2. Prioritetsregel for calendar_poll-jobber +-- ============================================================================= +-- Lav prioritet (3), lav CPU-vekt, maks 2 samtidige, 120s timeout. +-- Kalender-polling er bakgrunnsarbeid som ikke haster. +INSERT INTO job_priority_rules (job_type, base_priority, cpu_weight, max_concurrent, timeout_seconds) +VALUES ('calendar_poll', 3, 1, 2, 120) +ON CONFLICT (job_type) DO NOTHING; + +COMMIT; diff --git a/tasks.md b/tasks.md index 548ab2c..56b57e2 100644 --- a/tasks.md +++ b/tasks.md @@ -413,8 +413,7 @@ noden er det som lever videre. ### Kalender-import - [x] 29.11 ICS-import: `synops-calendar` CLI som parser ICS-fil og oppretter noder med `scheduled`-edges. Input: `--file --collection-id `. Duplikatdeteksjon via UID. Oppdatering ved re-import. -- [~] 29.12 CalDAV-abonnement: abonner på ekstern CalDAV-kalender (Google, Outlook). Poller periodisk, synkroniserer endringer. Som RSS-feed men for kalenderhendelser. - > Påbegynt: 2026-03-18T22:55 +- [x] 29.12 CalDAV-abonnement: abonner på ekstern CalDAV-kalender (Google, Outlook). Poller periodisk, synkroniserer endringer. Som RSS-feed men for kalenderhendelser. ## Fase 30: Podcast-hosting — komplett, uten ekstern avhengighet diff --git a/tools/synops-calendar/Cargo.toml b/tools/synops-calendar/Cargo.toml index 5f8f8d1..129cda4 100644 --- a/tools/synops-calendar/Cargo.toml +++ b/tools/synops-calendar/Cargo.toml @@ -18,3 +18,4 @@ serde_json = "1" tokio = { version = "1", features = ["full"] } tracing = "0.1" ical = "0.11" +reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } diff --git a/tools/synops-calendar/src/main.rs b/tools/synops-calendar/src/main.rs index f77f2ce..18f6742 100644 --- a/tools/synops-calendar/src/main.rs +++ b/tools/synops-calendar/src/main.rs @@ -6,7 +6,8 @@ // // Bruk: // synops-calendar --file kalender.ics --collection-id -// synops-calendar --payload-json '{"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. @@ -18,18 +19,21 @@ use clap::Parser; use ical::parser::ical::component::IcalEvent; use serde::{Deserialize, Serialize}; use sqlx::PgPool; -use std::fs::File; use std::io::BufReader; use uuid::Uuid; /// Importer ICS-fil til kalendernoder. #[derive(Parser)] -#[command(name = "synops-calendar", about = "Importer ICS-fil til kalendernoder i Synops")] +#[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, @@ -41,7 +45,10 @@ struct Cli { #[derive(Deserialize)] struct JobPayload { - file: String, + #[serde(default)] + file: Option, + #[serde(default)] + url: Option, collection_id: String, } @@ -63,13 +70,19 @@ struct ImportResult { 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 (file_path, collection_id) = if let Some(ref json_str) = cli.payload_json { + 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); @@ -78,34 +91,69 @@ async fn main() { eprintln!("Ugyldig collection_id i payload: {e}"); std::process::exit(1); }); - (payload.file, cid) + 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"); + 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); }); - (file, cid) + (IcsSource::File(file), cid) }; - // Parse ICS-filen - let events = parse_ics(&file_path); + // 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-filen".to_string()], + errors: vec!["Ingen VEVENT funnet i ICS-data".to_string()], }; println!("{}", serde_json::to_string_pretty(&output).unwrap()); return; } - tracing::info!(events = events.len(), file = %file_path, "Parsed ICS-fil"); + 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| { @@ -164,6 +212,37 @@ async fn main() { } } +/// 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, @@ -290,14 +369,9 @@ async fn import_event( } } -/// Parse ICS-fil og returner liste med hendelser. -fn parse_ics(path: &str) -> Vec { - let file = File::open(path).unwrap_or_else(|e| { - eprintln!("Kunne ikke åpne {path}: {e}"); - std::process::exit(1); - }); - - let reader = BufReader::new(file); +/// 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();