ICS-import: synops-calendar CLI for kalenderimport (oppgave 29.11)
Nytt CLI-verktøy som parser ICS-filer (RFC 5545) og oppretter content-noder med scheduled-edges i Synops. Duplikatdeteksjon via ICS UID i node-metadata — re-import oppdaterer eksisterende noder i stedet for å lage duplikater. Støtter --file/--collection-id og --payload-json for jobbkø. Oppretter belongs_to + scheduled edges per hendelse.
This commit is contained in:
parent
59e34878cc
commit
a77a6ea12f
5 changed files with 2883 additions and 2 deletions
3
tasks.md
3
tasks.md
|
|
@ -412,8 +412,7 @@ noden er det som lever videre.
|
||||||
- [x] 29.10 Tegne-input: enkel canvas-basert tegneflate i input-komponenten. Eksporter som PNG → CAS → media-node. Ikke whiteboard (det er et eget verktøy) — dette er "rask skisse som input", som en post-it.
|
- [x] 29.10 Tegne-input: enkel canvas-basert tegneflate i input-komponenten. Eksporter som PNG → CAS → media-node. Ikke whiteboard (det er et eget verktøy) — dette er "rask skisse som input", som en post-it.
|
||||||
|
|
||||||
### Kalender-import
|
### Kalender-import
|
||||||
- [~] 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.
|
||||||
> Påbegynt: 2026-03-18T22:45
|
|
||||||
- [ ] 29.12 CalDAV-abonnement: abonner på ekstern CalDAV-kalender (Google, Outlook). Poller periodisk, synkroniserer endringer. Som RSS-feed men for kalenderhendelser.
|
- [ ] 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
|
## Fase 30: Podcast-hosting — komplett, uten ekstern avhengighet
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall.
|
||||||
| `synops-health` | Sjekk status for alle tjenester (PG, Caddy, Maskinrommet, LiteLLM, Whisper, LiveKit, Authentik) | Ferdig |
|
| `synops-health` | Sjekk status for alle tjenester (PG, Caddy, Maskinrommet, LiteLLM, Whisper, LiveKit, Authentik) | Ferdig |
|
||||||
| `synops-feed` | Abonner på RSS/Atom-feed, opprett content-noder med deduplisering og paywall-deteksjon | Ferdig |
|
| `synops-feed` | Abonner på RSS/Atom-feed, opprett content-noder med deduplisering og paywall-deteksjon | Ferdig |
|
||||||
| `synops-video` | Video-transcode (H.264), thumbnail-generering, varighet-uttrekk fra CAS-hash | Ferdig |
|
| `synops-video` | Video-transcode (H.264), thumbnail-generering, varighet-uttrekk fra CAS-hash | Ferdig |
|
||||||
|
| `synops-calendar` | ICS-import: parser ICS-fil, oppretter kalendernoder med `scheduled`-edges, duplikatdeteksjon via UID | Ferdig |
|
||||||
|
|
||||||
## Delt bibliotek
|
## Delt bibliotek
|
||||||
|
|
||||||
|
|
|
||||||
2479
tools/synops-calendar/Cargo.lock
generated
Normal file
2479
tools/synops-calendar/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
20
tools/synops-calendar/Cargo.toml
Normal file
20
tools/synops-calendar/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "synops-calendar"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "synops-calendar"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
synops-common = { path = "../synops-common" }
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
|
||||||
|
uuid = { version = "1", features = ["v7", "serde"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
ical = "0.11"
|
||||||
382
tools/synops-calendar/src/main.rs
Normal file
382
tools/synops-calendar/src/main.rs
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
// 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 <uuid>
|
||||||
|
// synops-calendar --payload-json '{"file":"kalender.ics","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::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")]
|
||||||
|
struct Cli {
|
||||||
|
/// Sti til ICS-fil
|
||||||
|
#[arg(long)]
|
||||||
|
file: Option<String>,
|
||||||
|
|
||||||
|
/// Samlings-ID (node som hendelsene tilhører)
|
||||||
|
#[arg(long)]
|
||||||
|
collection_id: Option<Uuid>,
|
||||||
|
|
||||||
|
/// Payload fra jobbkø (JSON). Overstyrer andre argumenter.
|
||||||
|
#[arg(long)]
|
||||||
|
payload_json: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct JobPayload {
|
||||||
|
file: String,
|
||||||
|
collection_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed calendar event fra ICS.
|
||||||
|
struct CalendarEvent {
|
||||||
|
uid: String,
|
||||||
|
summary: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
dtstart: Option<String>,
|
||||||
|
dtend: Option<String>,
|
||||||
|
location: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ImportResult {
|
||||||
|
ok: bool,
|
||||||
|
created: usize,
|
||||||
|
updated: usize,
|
||||||
|
errors: Vec<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 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::<Uuid>().unwrap_or_else(|e| {
|
||||||
|
eprintln!("Ugyldig collection_id i payload: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
(payload.file, cid)
|
||||||
|
} else {
|
||||||
|
let file = cli.file.unwrap_or_else(|| {
|
||||||
|
eprintln!("Mangler --file");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
let cid = cli.collection_id.unwrap_or_else(|| {
|
||||||
|
eprintln!("Mangler --collection-id");
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
(file, cid)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse ICS-filen
|
||||||
|
let events = parse_ics(&file_path);
|
||||||
|
|
||||||
|
if events.is_empty() {
|
||||||
|
let output = ImportResult {
|
||||||
|
ok: true,
|
||||||
|
created: 0,
|
||||||
|
updated: 0,
|
||||||
|
errors: vec!["Ingen VEVENT funnet i ICS-filen".to_string()],
|
||||||
|
};
|
||||||
|
println!("{}", serde_json::to_string_pretty(&output).unwrap());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!(events = events.len(), file = %file_path, "Parsed ICS-fil");
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<EventAction, String> {
|
||||||
|
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<Uuid> = 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-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);
|
||||||
|
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<CalendarEvent> {
|
||||||
|
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<String> {
|
||||||
|
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<DateTime<Utc>> {
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue