Implementer synops-rss CLI-verktøy (oppgave 21.4)

Frittstående RSS/Atom-feed generator som erstatter maskinrommet/src/rss.rs.
Følger unix-filosofien: ett verktøy per oppgave, XML til stdout.

Støtter:
- Oppslag via --collection-id (UUID) eller --slug
- RSS 2.0 og Atom 1.0 (konfigurerbart via trait-metadata eller --format)
- Podcast-enclosures via has_media-edges
- --max-items for å begrense antall elementer

Verifisert mot prod-database med Sidelinja-samlingen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 09:24:01 +00:00
parent d4b016dc10
commit b766f063b8
10 changed files with 3080 additions and 9 deletions

View file

@ -26,16 +26,16 @@ Når en idé modnes nok til å bli implementert, skrives en full spec i `docs/fe
| [Guest Prep Simulator](guest_prep_simulator.md) | Middels | Høy | Kunnskapsgraf, AI Gateway |
| [Debate Club](debate_club.md) | Middels | Middels | Kunnskapsgraf, AI Gateway, jobbkø |
| [Ghost Host TTS](ghost_host_tts.md) | Stor | Høy | LiveKit, AI Gateway, ny TTS-infra |
| [Tekst-primitiv](tekst_primitiv.md) | LavMiddels | MiddelsHøy | Meldingsboks, view-configs |
| [Editor](editor.md) | MiddelsStor | Høy | Tekst-primitiv, Tiptap/ProseMirror, KaTeX |
| [Artikkel-publisering](artikkel_publisering.md) | MiddelsStor | Høy | Tekst-primitiv, kunnskapsgraf, Caddy, jobbkø |
| [Tekst-primitiv](tekst_primitiv.md) *(realisert)* | LavMiddels | MiddelsHøy | Meldingsboks, view-configs |
| [Editor](editor.md) *(delvis implementert)* | MiddelsStor | Høy | Tekst-primitiv, Tiptap/ProseMirror, KaTeX |
| [Artikkel-publisering](artikkel_publisering.md) *(forfremmet)* | MiddelsStor | Høy | Tekst-primitiv, kunnskapsgraf, Caddy, jobbkø |
| [Sosial publisering](social_posting.md) | LavMiddels | Høy | Chat, jobbkø, workspace settings |
| [Komponerbare sider](komponerbare_sider.md) | Lav (Fase 1) | MiddelsHøy | Workspace-modell, SvelteKit, alle feature-komponenter |
| [Komponerbare sider](komponerbare_sider.md) *(superseded)* | Lav (Fase 1) | MiddelsHøy | Workspace-modell, SvelteKit, alle feature-komponenter |
| [Contradiction Detector](contradiction_detector.md) | Middels | Høy | Live AI, kunnskapsgraf, pgvector, segmenter |
| [Auto-Highlight Reel](auto_highlight_reel.md) | Middels | Høy | Podcastfabrikken, jobbkø, AI Gateway, Caddy byte-range |
| [Audience Voice Memo](audience_voice_memo.md) | Lav | Høy | Den Asynkrone Gjesten, Live transkripsjon, Live AI |
| [Avisvisning](avisvisning.md) | LavMiddels | Høy | Meldingsboks, kunnskapsgraf, prominens-score |
| [Personlig workspace](personlig_workspace.md) | LavMiddels | MiddelsHøy | Workspace-modell, meldingsboks, tekst-primitiv |
| [Personlig workspace](personlig_workspace.md) *(superseded)* | LavMiddels | MiddelsHøy | Workspace-modell, meldingsboks, tekst-primitiv |
| [Kildevern-modus](kildevern_modus.md) | LavMiddels | Høy | AI Gateway, Ollama/vLLM, Møterommet |
| [Podcasting 2.0](podcasting_2_0.md) | Lav | Høy | Podcastfabrikken, kunnskapsgraf, RSS |
| [Web Clipper](web_clipper.md) | LavMiddels | Høy | Jobbkø, AI Gateway, meldingsboks, kunnskapsgraf |
@ -51,7 +51,9 @@ Når en idé modnes nok til å bli implementert, skrives en full spec i `docs/fe
| [Collaborative Cursors](collaborative_cursors.md) | Lav | Middels | SpacetimeDB, Svelte |
| [Card Heat Map](card_heat_map.md) | Lav | Middels | Meldingsboks, kanban/storyboard |
**Forfremmet til feature:** [Meldingsboks](../features/meldingsboks.md) — universell diskusjonsprimitiv (erstatter separate modeller for chat, kanban-kort, kalenderhendelser, faktoider, notater).
**Forfremmet til feature:** [Meldingsboks](../features/meldingsboks.md) — universell diskusjonsprimitiv. [Artikkel-publisering](artikkel_publisering.md) → Fase 14 / `docs/concepts/publisering.md`. [Tekst-primitiv](tekst_primitiv.md) — realisert i nodearkitekturen.
**Superseded av retninger:** [Komponerbare sider](komponerbare_sider.md) → `docs/retninger/arbeidsflaten.md`. [Personlig workspace](personlig_workspace.md) → `docs/retninger/bruker_ikke_workspace.md`.
**Lavthengende frukter** (lav innsats, høy wow): Serendipity Roulette, Podcast Time Machine, Meme Generator, Audience Voice Memo, Pinboard Mode, Ghost Cards.

View file

@ -1,5 +1,11 @@
# Forslag: Artikkel-publisering og publikasjonsmodell
> **Forfremmet:** Kjernekonseptet er implementert i Fase 14 (publisering)
> med 17 fullførte deloppgaver. Se `docs/concepts/publisering.md` for
> gjeldende spesifikasjon. Detaljer i dette dokumentet (typografi-filosofi,
> kurateringsflyt, medforfatterskap) er fremtidige utvidelser.
> Dokumentet er bevart som historisk referanse.
## Idé
Utvide Sidelinja til en publiseringsplattform der individuelle skribenter og redaksjonelle team kan skrive, samarbeide på, og publisere tekster. Inspirert av Substack (individuell publisering), men med en kollaborativ og kuratorisk dimensjon: en tekst eies av noen, samarbeides med noen, og publiseres av én eller flere.

View file

@ -1,5 +1,11 @@
# Forslag: Universell editor
> **Delvis implementert:** TipTap-editoren er implementert (Fase 3.5, 14,
> 20.8) med kompakt og utvidet modus, mentions, markdown-formatering og
> auto-save. Aspirerende features (collaborative editing/Yjs, LaTeX/KaTeX,
> podcast-embeds, sidemerknad-fotnoter) er fremtidige utvidelser.
> AI-behandling er flyttet til eget verktøy-panel (`docs/features/ai_verktoy.md`).
## Idé
Én editor-komponent som brukes overalt i Sidelinja — chat, notater, artikler, kanban-kort, show notes. Editoren autodetekterer format (plaintext, markdown, LaTeX) og rendrer riktig uten at brukeren velger modus. Avansert funksjonalitet er alltid tilgjengelig, aldri påtvunget.
@ -174,7 +180,13 @@ Editoren håndterer bilder, lenker og eksterne embeds som førsteklasses innhold
- Bildeoptimalisering (resize, WebP-konvertering) som jobbkø-oppgave ved opplasting
- oEmbed/OG-metadata caches i en enkel tabell for å unngå gjentatte oppslag
### AI-behandling — universell knapp
### AI-behandling — eget verktøy-panel
> **Oppdatert:** AI-behandling er flyttet fra en ✨-knapp inne i editoren
> til et **frittstående AI-verktøy** på arbeidsflaten. Se
> `docs/features/ai_verktoy.md` for full spesifikasjon. Drag-and-drop
> mellom AI-verktøyet og tekstnoder erstatter den opprinnelige ✨-knappen.
> Teksten nedenfor er bevart som historisk referanse.
Editoren har en AI-knapp (✨) som behandler innholdet i boksen. Originalteksten bevares alltid som revisjon (`message_revisions`), og AI-resultatet tar over som nytt innhold — klart for videre redigering av brukeren.

View file

@ -1,5 +1,10 @@
# Forslag: Tekst-primitiv
> **Realisert:** Tekst-primitiv-filosofien er nå innebygd i
> nodearkitekturen. Meldingsboksen er forfremmet til feature
> (`docs/features/meldingsboks.md`) og visibility-modellen fra dette
> dokumentet er implementert. Dokumentet er bevart som historisk referanse.
## Idé
Det finnes ingen forskjell mellom en chatmelding og en artikkel — bare ulike stadier av samme ting. Enhver tekst starter som det enkleste ("hei") og kan vokse til hva som helst: få en tittel, bli rik-formatert, dras inn i en kalender, publiseres på web. Alt er samme node, samme primitiv. Brukeren bestemmer aldri "type" på forhånd — de bare skriver, og utvider når det føles naturlig.

View file

@ -28,6 +28,7 @@ andre dokumenter. En retning kan også forkastes eller parkeres.
| [Noder er sentrum](bruker_ikke_workspace.md) | **Besluttet** | Alt er noder (brukere, team, innhold). Edges definerer relasjoner og tilgang. Materialisert tilgangsmatrise for RLS. |
| [Datalaget](datalaget.md) | **Besluttet** | SpacetimeDB holder hele grafen, PG er persistent arkiv, CAS for binærdata, AGE ved behov |
| [Arbeidsflaten](arbeidsflaten.md) | **Besluttet** | Spatial canvas med verktøy-paneler. Drag-and-drop skaper nye noder med edges. |
| [Unix-filosofi](unix_filosofi.md) | **Besluttet** | Maskinrommet orkestrerer, CLI-verktøy gjør jobben. Claude deler verktøykasse. |
### Relaterte spesifikasjoner

View file

@ -244,8 +244,7 @@ kaller dem direkte. Samme verktøy, to brukere.
- [x] 21.1 `synops-transcribe`: Whisper-transkribering. Input: `--cas-hash <hash> --model <model> [--initial-prompt <tekst>]`. Output: JSON med segmenter. Skriver segmenter til PG, oppdaterer node metadata. Erstatter `transcribe.rs`.
- [x] 21.2 `synops-audio`: FFmpeg-prosessering. Input: `--cas-hash <hash> --edl <json>`. Output: ny CAS-hash. Erstatter `audio.rs`. Inkluder parametervalidering (fase 17.217.3).
- [x] 21.3 `synops-render`: Tera HTML-rendering. Input: `--node-id <uuid> --theme <tema>`. Output: CAS-hash for rendret HTML. Erstatter `publishing.rs`.
- [~] 21.4 `synops-rss`: RSS/Atom-generering. Input: `--collection-id <uuid>`. Output: XML til stdout. Erstatter `rss.rs`.
> Påbegynt: 2026-03-18T09:20
- [x] 21.4 `synops-rss`: RSS/Atom-generering. Input: `--collection-id <uuid>`. Output: XML til stdout. Erstatter `rss.rs`.
- [ ] 21.5 `synops-tts`: Tekst-til-tale. Input: `--text <tekst> --voice <stemme>`. Output: CAS-hash for lydfil. Erstatter `tts.rs`.
- [ ] 21.6 `synops-summarize`: AI-oppsummering. Input: `--communication-id <uuid>`. Output: sammendrag som tekst. Erstatter `summarize.rs`.
- [ ] 21.7 `synops-suggest-edges`: AI-foreslåtte edges. Input: `--node-id <uuid>`. Output: JSON med forslag (target, edge_type, confidence). Erstatter `ai_edges.rs`.

View file

@ -10,6 +10,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall.
| `synops-transcribe` | Whisper-transkribering av lydfil fra CAS | Ferdig |
| `synops-audio` | FFmpeg lydprosessering med EDL (cut, normalize, EQ, m.m.) | Ferdig |
| `synops-render` | Tera HTML-rendering til CAS (artikler, forsider) | Ferdig |
| `synops-rss` | RSS/Atom-feed generering for samlinger | Ferdig |
## Konvensjoner
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)

2433
tools/synops-rss/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,19 @@
[package]
name = "synops-rss"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "synops-rss"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v7", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -0,0 +1,593 @@
// synops-rss — RSS/Atom-feed generering.
//
// Genererer RSS 2.0 eller Atom 1.0 feed for samlinger med `rss`-trait.
// Erstatter RSS-logikken i maskinrommet/src/rss.rs som et frittstående
// CLI-verktøy i tråd med unix-filosofien.
//
// Miljøvariabler:
// DATABASE_URL — PostgreSQL-tilkobling (påkrevd)
//
// Ref: docs/retninger/unix_filosofi.md, docs/concepts/publisering.md
use chrono::{DateTime, Utc};
use clap::Parser;
use serde::Deserialize;
use std::process;
use uuid::Uuid;
// =============================================================================
// CLI
// =============================================================================
/// Generer RSS/Atom-feed for en samling.
#[derive(Parser)]
#[command(name = "synops-rss", about = "RSS/Atom-feed generering for samlinger")]
struct Cli {
/// Samlings-ID (UUID)
#[arg(long)]
collection_id: Option<Uuid>,
/// Samlingens slug (alternativ til --collection-id)
#[arg(long)]
slug: Option<String>,
/// Overstyr feed-format: rss eller atom
#[arg(long)]
format: Option<String>,
/// Maks antall elementer i feeden
#[arg(long)]
max_items: Option<i64>,
}
// =============================================================================
// Konfigurasjon fra trait-metadata
// =============================================================================
#[derive(Deserialize, Default)]
struct RssTraitConfig {
format: Option<String>,
title: Option<String>,
description: Option<String>,
max_items: Option<i64>,
language: Option<String>,
}
#[derive(Deserialize, Default)]
struct PublishingTraitConfig {
slug: Option<String>,
custom_domain: Option<String>,
}
// =============================================================================
// Datamodeller
// =============================================================================
struct CollectionInfo {
id: Uuid,
title: Option<String>,
slug: String,
rss_config: RssTraitConfig,
publishing_config: PublishingTraitConfig,
is_podcast: bool,
}
struct FeedItem {
id: Uuid,
title: Option<String>,
content: Option<String>,
created_at: DateTime<Utc>,
publish_at: Option<DateTime<Utc>>,
enclosure_url: Option<String>,
enclosure_mime: Option<String>,
enclosure_size: Option<i64>,
}
// =============================================================================
// Main
// =============================================================================
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_target(false)
.with_writer(std::io::stderr)
.init();
let cli = Cli::parse();
if cli.collection_id.is_none() && cli.slug.is_none() {
eprintln!("Feil: Enten --collection-id eller --slug må oppgis");
process::exit(1);
}
let db_url = match std::env::var("DATABASE_URL") {
Ok(url) => url,
Err(_) => {
eprintln!("Feil: DATABASE_URL er ikke satt");
process::exit(1);
}
};
let db = match sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&db_url)
.await
{
Ok(pool) => pool,
Err(e) => {
eprintln!("Feil: Kunne ikke koble til database: {e}");
process::exit(1);
}
};
let collection = match find_collection(&db, cli.collection_id, cli.slug.as_deref()).await {
Ok(Some(c)) => c,
Ok(None) => {
eprintln!("Feil: Fant ingen samling med rss-trait");
process::exit(1);
}
Err(e) => {
eprintln!("Feil: Database-feil ved oppslag: {e}");
process::exit(1);
}
};
let max_items = cli
.max_items
.or(collection.rss_config.max_items)
.unwrap_or(50);
let items = match fetch_feed_items(&db, collection.id, max_items, collection.is_podcast).await {
Ok(items) => items,
Err(e) => {
eprintln!("Feil: Kunne ikke hente feed-elementer: {e}");
process::exit(1);
}
};
let base_url = collection
.publishing_config
.custom_domain
.as_deref()
.map(|d| format!("https://{d}"))
.unwrap_or_else(|| format!("https://synops.no/pub/{}", collection.slug));
let format = cli
.format
.as_deref()
.or(collection.rss_config.format.as_deref())
.unwrap_or("rss");
let xml = match format {
"atom" => build_atom_feed(&collection, &items, &base_url),
_ => build_rss_feed(&collection, &items, &base_url),
};
tracing::info!(
collection_id = %collection.id,
slug = %collection.slug,
format = format,
items = items.len(),
"Feed generert"
);
print!("{xml}");
}
// =============================================================================
// Database-spørringer
// =============================================================================
/// Finn samling med rss-trait, enten via UUID eller slug.
async fn find_collection(
db: &sqlx::PgPool,
collection_id: Option<Uuid>,
slug: Option<&str>,
) -> Result<Option<CollectionInfo>, sqlx::Error> {
let row: Option<(Uuid, Option<String>, serde_json::Value)> = if let Some(id) = collection_id {
sqlx::query_as(
r#"
SELECT id, title, metadata
FROM nodes
WHERE id = $1
AND node_kind = 'collection'
AND metadata->'traits' ? 'rss'
LIMIT 1
"#,
)
.bind(id)
.fetch_optional(db)
.await?
} else if let Some(slug) = slug {
sqlx::query_as(
r#"
SELECT id, title, metadata
FROM nodes
WHERE node_kind = 'collection'
AND metadata->'traits'->'publishing'->>'slug' = $1
AND metadata->'traits' ? 'rss'
LIMIT 1
"#,
)
.bind(slug)
.fetch_optional(db)
.await?
} else {
return Ok(None);
};
let Some((id, title, metadata)) = row else {
return Ok(None);
};
let traits = metadata
.get("traits")
.cloned()
.unwrap_or(serde_json::Value::Null);
let rss_config: RssTraitConfig = traits
.get("rss")
.cloned()
.map(|v| serde_json::from_value(v).unwrap_or_default())
.unwrap_or_default();
let publishing_config: PublishingTraitConfig = traits
.get("publishing")
.cloned()
.map(|v| serde_json::from_value(v).unwrap_or_default())
.unwrap_or_default();
let slug = publishing_config
.slug
.clone()
.unwrap_or_else(|| id.to_string());
let is_podcast = traits.get("podcast").is_some();
Ok(Some(CollectionInfo {
id,
title,
slug,
rss_config,
publishing_config,
is_podcast,
}))
}
/// Hent publiserte elementer (belongs_to-edges til samlingen).
/// For podcast-samlinger: inkluder enclosure-data via has_media-edges.
async fn fetch_feed_items(
db: &sqlx::PgPool,
collection_id: Uuid,
max_items: i64,
is_podcast: bool,
) -> Result<Vec<FeedItem>, sqlx::Error> {
if is_podcast {
let rows: Vec<(
Uuid,
Option<String>,
Option<String>,
DateTime<Utc>,
Option<serde_json::Value>,
Option<String>,
Option<String>,
Option<i64>,
)> = sqlx::query_as(
r#"
SELECT
n.id,
n.title,
n.content,
n.created_at,
e.metadata,
m.metadata->>'cas_hash' AS cas_hash,
m.metadata->>'mime' AS mime,
(m.metadata->>'size')::bigint AS size
FROM edges e
JOIN nodes n ON n.id = e.source_id
LEFT JOIN edges me ON me.source_id = n.id AND me.edge_type = 'has_media'
LEFT JOIN nodes m ON m.id = me.target_id AND m.node_kind = 'media'
WHERE e.target_id = $1
AND e.edge_type = 'belongs_to'
ORDER BY COALESCE(
(e.metadata->>'publish_at')::timestamptz,
n.created_at
) DESC
LIMIT $2
"#,
)
.bind(collection_id)
.bind(max_items)
.fetch_all(db)
.await?;
Ok(rows
.into_iter()
.map(
|(id, title, content, created_at, edge_meta, cas_hash, mime, size)| {
let publish_at = edge_meta
.as_ref()
.and_then(|m| m.get("publish_at"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<DateTime<Utc>>().ok());
FeedItem {
id,
title,
content,
created_at,
publish_at,
enclosure_url: cas_hash.map(|h| format!("/cas/{h}")),
enclosure_mime: mime,
enclosure_size: size,
}
},
)
.collect())
} else {
let rows: Vec<(
Uuid,
Option<String>,
Option<String>,
DateTime<Utc>,
Option<serde_json::Value>,
)> = sqlx::query_as(
r#"
SELECT
n.id,
n.title,
n.content,
n.created_at,
e.metadata
FROM edges e
JOIN nodes n ON n.id = e.source_id
WHERE e.target_id = $1
AND e.edge_type = 'belongs_to'
ORDER BY COALESCE(
(e.metadata->>'publish_at')::timestamptz,
n.created_at
) DESC
LIMIT $2
"#,
)
.bind(collection_id)
.bind(max_items)
.fetch_all(db)
.await?;
Ok(rows
.into_iter()
.map(|(id, title, content, created_at, edge_meta)| {
let publish_at = edge_meta
.as_ref()
.and_then(|m| m.get("publish_at"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<DateTime<Utc>>().ok());
FeedItem {
id,
title,
content,
created_at,
publish_at,
enclosure_url: None,
enclosure_mime: None,
enclosure_size: None,
}
})
.collect())
}
}
// =============================================================================
// XML-generering
// =============================================================================
/// Bygg RSS 2.0 XML-streng.
fn build_rss_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &str) -> String {
let channel_title = xml_escape(
collection
.rss_config
.title
.as_deref()
.or(collection.title.as_deref())
.unwrap_or("Untitled Feed"),
);
let channel_desc = xml_escape(
collection
.rss_config
.description
.as_deref()
.unwrap_or(""),
);
let language = collection
.rss_config
.language
.as_deref()
.unwrap_or("no");
let feed_url = format!("{base_url}/feed.xml");
let mut xml = String::with_capacity(4096);
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
if collection.is_podcast {
xml.push_str("<rss version=\"2.0\" xmlns:itunes=\"http://www.itunes.com/dtds/podcast-1.0.dtd\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n");
} else {
xml.push_str("<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\">\n");
}
xml.push_str("<channel>\n");
xml.push_str(&format!(" <title>{channel_title}</title>\n"));
xml.push_str(&format!(" <link>{base_url}</link>\n"));
xml.push_str(&format!(" <description>{channel_desc}</description>\n"));
xml.push_str(&format!(" <language>{language}</language>\n"));
xml.push_str(&format!(
" <atom:link href=\"{feed_url}\" rel=\"self\" type=\"application/rss+xml\"/>\n"
));
if let Some(item) = items.first() {
let date = item.publish_at.unwrap_or(item.created_at);
xml.push_str(&format!(
" <lastBuildDate>{}</lastBuildDate>\n",
date.to_rfc2822()
));
}
for item in items {
xml.push_str(" <item>\n");
let title = xml_escape(item.title.as_deref().unwrap_or("Uten tittel"));
xml.push_str(&format!(" <title>{title}</title>\n"));
let item_url = format!("{base_url}/{}", short_id(item.id));
xml.push_str(&format!(" <link>{item_url}</link>\n"));
xml.push_str(&format!(
" <guid isPermaLink=\"false\">{}</guid>\n",
item.id
));
let pub_date = item.publish_at.unwrap_or(item.created_at);
xml.push_str(&format!(
" <pubDate>{}</pubDate>\n",
pub_date.to_rfc2822()
));
if let Some(ref content) = item.content {
let desc = xml_escape(&truncate_description(content, 500));
xml.push_str(&format!(" <description>{desc}</description>\n"));
}
if let Some(ref enc_path) = item.enclosure_url {
let enc_url = format!("{base_url}{enc_path}");
let mime = item.enclosure_mime.as_deref().unwrap_or("audio/mpeg");
let size = item.enclosure_size.unwrap_or(0);
xml.push_str(&format!(
" <enclosure url=\"{enc_url}\" length=\"{size}\" type=\"{mime}\"/>\n"
));
}
xml.push_str(" </item>\n");
}
xml.push_str("</channel>\n");
xml.push_str("</rss>\n");
xml
}
/// Bygg Atom 1.0 XML-streng.
fn build_atom_feed(collection: &CollectionInfo, items: &[FeedItem], base_url: &str) -> String {
let feed_title = xml_escape(
collection
.rss_config
.title
.as_deref()
.or(collection.title.as_deref())
.unwrap_or("Untitled Feed"),
);
let feed_desc = xml_escape(
collection
.rss_config
.description
.as_deref()
.unwrap_or(""),
);
let feed_url = format!("{base_url}/feed.xml");
let updated = items
.first()
.map(|i| i.publish_at.unwrap_or(i.created_at))
.unwrap_or_else(Utc::now);
let mut xml = String::with_capacity(4096);
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n");
xml.push_str(&format!(" <title>{feed_title}</title>\n"));
xml.push_str(&format!(" <subtitle>{feed_desc}</subtitle>\n"));
xml.push_str(&format!(
" <link href=\"{feed_url}\" rel=\"self\" type=\"application/atom+xml\"/>\n"
));
xml.push_str(&format!(
" <link href=\"{base_url}\" rel=\"alternate\"/>\n"
));
xml.push_str(&format!(" <id>{base_url}</id>\n"));
xml.push_str(&format!(
" <updated>{}</updated>\n",
updated.to_rfc3339()
));
for item in items {
xml.push_str(" <entry>\n");
let title = xml_escape(item.title.as_deref().unwrap_or("Uten tittel"));
xml.push_str(&format!(" <title>{title}</title>\n"));
let item_url = format!("{base_url}/{}", short_id(item.id));
xml.push_str(&format!(
" <link href=\"{item_url}\" rel=\"alternate\"/>\n"
));
xml.push_str(&format!(" <id>urn:uuid:{}</id>\n", item.id));
let pub_date = item.publish_at.unwrap_or(item.created_at);
xml.push_str(&format!(
" <updated>{}</updated>\n",
pub_date.to_rfc3339()
));
xml.push_str(&format!(
" <published>{}</published>\n",
pub_date.to_rfc3339()
));
if let Some(ref content) = item.content {
let summary = xml_escape(&truncate_description(content, 500));
xml.push_str(&format!(" <summary>{summary}</summary>\n"));
}
if let Some(ref enc_path) = item.enclosure_url {
let enc_url = format!("{base_url}{enc_path}");
let mime = item.enclosure_mime.as_deref().unwrap_or("audio/mpeg");
let size = item.enclosure_size.unwrap_or(0);
xml.push_str(&format!(
" <link rel=\"enclosure\" href=\"{enc_url}\" type=\"{mime}\" length=\"{size}\"/>\n"
));
}
xml.push_str(" </entry>\n");
}
xml.push_str("</feed>\n");
xml
}
// =============================================================================
// Hjelpefunksjoner
// =============================================================================
/// XML-escape for tekst i elementer.
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// Kort ID fra UUID (første 8 tegn) — for URL-er.
fn short_id(id: Uuid) -> String {
id.to_string()[..8].to_string()
}
/// Trunkér beskrivelse til maks antall tegn, på ordgrense.
fn truncate_description(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
return s.to_string();
}
match s[..max_len].rfind(' ') {
Some(pos) => format!("{}", &s[..pos]),
None => format!("{}", &s[..max_len]),
}
}