CalDAV-abonnement: periodisk polling av eksterne kalendere (oppgave 29.12)

Utvider synops-calendar CLI med --url for å hente ICS fra eksterne URLer
(Google Calendar, Outlook, etc). Ny calendar_poller i maskinrommet poller
samlingers calendar_subscriptions[] med konfigurerbart intervall, etter
samme mønster som feed_poller for RSS-feeds.

Endringer:
- synops-calendar: ny --url parameter + reqwest for HTTP-henting
- calendar_poller.rs: bakgrunnsloop som finner forfalne abonnementer
- calendar_poll jobbtype i dispatcher med CLI-dispatch til synops-calendar
- API: configure_calendar_subscription + remove_calendar_subscription
- Migrasjon 031: indeks + prioritetsregel for calendar_poll-jobber
This commit is contained in:
vegard 2026-03-18 23:04:29 +00:00
parent 55e45e32e0
commit 5b3367e7e5
9 changed files with 587 additions and 24 deletions

View file

@ -6,7 +6,7 @@ Månedsbasert kalendervisning for redaksjonell planlegging. Hendelser er nodes i
## 2. Status ## 2. Status
**Kalendervisning implementert (mars 2026).** Bruker `scheduled`-edges i stedet for **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 ### Implementert
- **Fase 1 (v1, mars 2025):** PG-adapter med `calendars` + `calendar_events` (legacy) - **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 - Lenke fra mottak-siden med hendelsesteller
- Tilgang via `nodeVisibility` (respekterer `node_access`-matrise) - Tilgang via `nodeVisibility` (respekterer `node_access`-matrise)
- Sanntidsoppdatering via WebSocket (PG LISTEN/NOTIFY) - Sanntidsoppdatering via WebSocket (PG LISTEN/NOTIFY)
- **ICS-import (oppgave 29.11):** `synops-calendar` CLI som parser ICS-filer
- Input: `--file <ics>` eller `--url <url>` + `--collection-id <uuid>`
- 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 ### Gjenstår — Fase 2
- Kobling til kanban-kort (vis deadline på kalender) - 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) - Flerdag-hendelser (vises over flere celler)
- Abonnementsmodell (kalender → kalender via graph_edges) - Abonnementsmodell (kalender → kalender via graph_edges)
- Personlige vs. delte kalendere (via samlings-noder) - Personlige vs. delte kalendere (via samlings-noder)
- ICS/CalDAV-eksport - ICS-eksport (offentlige kalendere → `/cal/{id}.ics`)
- Varsler/påminnelser via jobbkøen - Varsler/påminnelser via jobbkøen
## 3. Datamodell (implementert) ## 3. Datamodell (implementert)

View file

@ -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<DateTime<Utc>>,
#[serde(default)]
pub enabled: Option<bool>,
}
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<usize, String> {
let collections: Vec<CollectionWithCalendars> = 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<CalendarSubscription> =
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<i64> = 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<serde_json::Value, String> {
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(())
}

View file

@ -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<AppState>,
user: AuthUser,
Json(req): Json<ConfigureCalendarSubscriptionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
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<crate::calendar_poller::CalendarSubscription> = 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<AppState>,
user: AuthUser,
Json(req): Json<RemoveCalendarSubscriptionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let metadata: Option<serde_json::Value> = 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<crate::calendar_poller::CalendarSubscription> = 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 // Tester
// ============================================================================= // =============================================================================

View file

@ -230,6 +230,10 @@ async fn dispatch(
"feed_poll" => { "feed_poll" => {
crate::feed_poller::handle_feed_poll(job, db).await 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ø. // Orchestration: trigger-evaluering har lagt jobben i kø.
// Kompilatoren parser scriptet og validerer det. // Kompilatoren parser scriptet og validerer det.
// Utførelse av kompilert script kommer i oppgave 24.5. // Utførelse av kompilert script kommer i oppgave 24.5.

View file

@ -29,6 +29,7 @@ pub mod summarize;
pub mod ws; pub mod ws;
pub mod mixer; pub mod mixer;
pub mod feed_poller; pub mod feed_poller;
pub mod calendar_poller;
pub mod orchestration_trigger; pub mod orchestration_trigger;
mod webhook; mod webhook;
mod webhook_admin; mod webhook_admin;
@ -170,6 +171,9 @@ async fn main() {
// Start feed-poller for RSS/Atom-abonnementer (oppgave 29.3) // Start feed-poller for RSS/Atom-abonnementer (oppgave 29.3)
feed_poller::start_feed_poller(db.clone()); 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 dynamic_page_cache = publishing::new_dynamic_page_cache();
let metrics = metrics::MetricsCollector::new(); let metrics = metrics::MetricsCollector::new();
@ -284,6 +288,9 @@ async fn main() {
// Feed-abonnement (oppgave 29.3) // Feed-abonnement (oppgave 29.3)
.route("/intentions/configure_feed_subscription", post(intentions::configure_feed_subscription)) .route("/intentions/configure_feed_subscription", post(intentions::configure_feed_subscription))
.route("/intentions/remove_feed_subscription", post(intentions::remove_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/compile_script", post(intentions::compile_script))
.route("/intentions/test_orchestration", post(intentions::test_orchestration)) .route("/intentions/test_orchestration", post(intentions::test_orchestration))
.route("/intentions/ai_suggest_script", post(intentions::ai_suggest_script)) .route("/intentions/ai_suggest_script", post(intentions::ai_suggest_script))

View file

@ -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;

View file

@ -413,8 +413,7 @@ noden er det som lever videre.
### Kalender-import ### Kalender-import
- [x] 29.11 ICS-import: `synops-calendar` CLI som parser ICS-fil og oppretter noder med `scheduled`-edges. Input: `--file <ics> --collection-id <uuid>`. Duplikatdeteksjon via UID. Oppdatering ved re-import. - [x] 29.11 ICS-import: `synops-calendar` CLI som parser ICS-fil og oppretter noder med `scheduled`-edges. Input: `--file <ics> --collection-id <uuid>`. 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. - [x] 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
## Fase 30: Podcast-hosting — komplett, uten ekstern avhengighet ## Fase 30: Podcast-hosting — komplett, uten ekstern avhengighet

View file

@ -18,3 +18,4 @@ serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tracing = "0.1" tracing = "0.1"
ical = "0.11" ical = "0.11"
reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false }

View file

@ -6,7 +6,8 @@
// //
// Bruk: // Bruk:
// synops-calendar --file kalender.ics --collection-id <uuid> // synops-calendar --file kalender.ics --collection-id <uuid>
// synops-calendar --payload-json '{"file":"kalender.ics","collection_id":"..."}' // synops-calendar --url https://calendar.google.com/...ical --collection-id <uuid>
// synops-calendar --payload-json '{"url":"...","collection_id":"..."}'
// //
// Output: JSON til stdout med antall opprettet/oppdatert/feilet. // Output: JSON til stdout med antall opprettet/oppdatert/feilet.
// Feil: stderr + exit code != 0. // Feil: stderr + exit code != 0.
@ -18,18 +19,21 @@ use clap::Parser;
use ical::parser::ical::component::IcalEvent; use ical::parser::ical::component::IcalEvent;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use std::fs::File;
use std::io::BufReader; use std::io::BufReader;
use uuid::Uuid; use uuid::Uuid;
/// Importer ICS-fil til kalendernoder. /// Importer ICS-fil til kalendernoder.
#[derive(Parser)] #[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 { struct Cli {
/// Sti til ICS-fil /// Sti til ICS-fil
#[arg(long)] #[arg(long)]
file: Option<String>, file: Option<String>,
/// URL til ICS/CalDAV-kalender (hentes via HTTP GET)
#[arg(long)]
url: Option<String>,
/// Samlings-ID (node som hendelsene tilhører) /// Samlings-ID (node som hendelsene tilhører)
#[arg(long)] #[arg(long)]
collection_id: Option<Uuid>, collection_id: Option<Uuid>,
@ -41,7 +45,10 @@ struct Cli {
#[derive(Deserialize)] #[derive(Deserialize)]
struct JobPayload { struct JobPayload {
file: String, #[serde(default)]
file: Option<String>,
#[serde(default)]
url: Option<String>,
collection_id: String, collection_id: String,
} }
@ -63,13 +70,19 @@ struct ImportResult {
errors: Vec<String>, errors: Vec<String>,
} }
/// Kilde for ICS-data.
enum IcsSource {
File(String),
Url(String),
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
synops_common::logging::init("synops_calendar"); synops_common::logging::init("synops_calendar");
let cli = Cli::parse(); 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| { let payload: JobPayload = serde_json::from_str(json_str).unwrap_or_else(|e| {
eprintln!("Ugyldig --payload-json: {e}"); eprintln!("Ugyldig --payload-json: {e}");
std::process::exit(1); std::process::exit(1);
@ -78,34 +91,69 @@ async fn main() {
eprintln!("Ugyldig collection_id i payload: {e}"); eprintln!("Ugyldig collection_id i payload: {e}");
std::process::exit(1); 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 { } else {
let file = cli.file.unwrap_or_else(|| { let file = cli.file.unwrap_or_else(|| {
eprintln!("Mangler --file"); eprintln!("Mangler --file eller --url");
std::process::exit(1); std::process::exit(1);
}); });
let cid = cli.collection_id.unwrap_or_else(|| { let cid = cli.collection_id.unwrap_or_else(|| {
eprintln!("Mangler --collection-id"); eprintln!("Mangler --collection-id");
std::process::exit(1); std::process::exit(1);
}); });
(file, cid) (IcsSource::File(file), cid)
}; };
// Parse ICS-filen // Hent ICS-data
let events = parse_ics(&file_path); 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() { if events.is_empty() {
let output = ImportResult { let output = ImportResult {
ok: true, ok: true,
created: 0, created: 0,
updated: 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()); println!("{}", serde_json::to_string_pretty(&output).unwrap());
return; 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 // Koble til database
let db = synops_common::db::connect().await.unwrap_or_else(|e| { 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<String, String> {
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 { enum EventAction {
Created, Created,
Updated, Updated,
@ -290,14 +369,9 @@ async fn import_event(
} }
} }
/// Parse ICS-fil og returner liste med hendelser. /// Parse ICS-data fra en streng og returner liste med hendelser.
fn parse_ics(path: &str) -> Vec<CalendarEvent> { fn parse_ics_data(data: &str) -> Vec<CalendarEvent> {
let file = File::open(path).unwrap_or_else(|e| { let reader = BufReader::new(data.as_bytes());
eprintln!("Kunne ikke åpne {path}: {e}");
std::process::exit(1);
});
let reader = BufReader::new(file);
let parser = ical::IcalParser::new(reader); let parser = ical::IcalParser::new(reader);
let mut events = Vec::new(); let mut events = Vec::new();