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:
parent
55e45e32e0
commit
5b3367e7e5
9 changed files with 587 additions and 24 deletions
|
|
@ -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 <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
|
||||
- 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)
|
||||
|
|
|
|||
247
maskinrommet/src/calendar_poller.rs
Normal file
247
maskinrommet/src/calendar_poller.rs
Normal 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(())
|
||||
}
|
||||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
30
migrations/031_calendar_subscriptions.sql
Normal file
30
migrations/031_calendar_subscriptions.sql
Normal 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;
|
||||
3
tasks.md
3
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 <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.
|
||||
> 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
//
|
||||
// Bruk:
|
||||
// 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.
|
||||
// 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<String>,
|
||||
|
||||
/// URL til ICS/CalDAV-kalender (hentes via HTTP GET)
|
||||
#[arg(long)]
|
||||
url: Option<String>,
|
||||
|
||||
/// Samlings-ID (node som hendelsene tilhører)
|
||||
#[arg(long)]
|
||||
collection_id: Option<Uuid>,
|
||||
|
|
@ -41,7 +45,10 @@ struct Cli {
|
|||
|
||||
#[derive(Deserialize)]
|
||||
struct JobPayload {
|
||||
file: String,
|
||||
#[serde(default)]
|
||||
file: Option<String>,
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
collection_id: String,
|
||||
}
|
||||
|
||||
|
|
@ -63,13 +70,19 @@ struct ImportResult {
|
|||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<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 {
|
||||
Created,
|
||||
Updated,
|
||||
|
|
@ -290,14 +369,9 @@ async fn import_event(
|
|||
}
|
||||
}
|
||||
|
||||
/// Parse ICS-fil og returner liste med hendelser.
|
||||
fn parse_ics(path: &str) -> Vec<CalendarEvent> {
|
||||
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<CalendarEvent> {
|
||||
let reader = BufReader::new(data.as_bytes());
|
||||
let parser = ical::IcalParser::new(reader);
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue