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:
parent
d4b016dc10
commit
b766f063b8
10 changed files with 3080 additions and 9 deletions
|
|
@ -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 |
|
| [Guest Prep Simulator](guest_prep_simulator.md) | Middels | Høy | Kunnskapsgraf, AI Gateway |
|
||||||
| [Debate Club](debate_club.md) | Middels | Middels | Kunnskapsgraf, AI Gateway, jobbkø |
|
| [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 |
|
| [Ghost Host TTS](ghost_host_tts.md) | Stor | Høy | LiveKit, AI Gateway, ny TTS-infra |
|
||||||
| [Tekst-primitiv](tekst_primitiv.md) | Lav–Middels | Middels–Høy | Meldingsboks, view-configs |
|
| [Tekst-primitiv](tekst_primitiv.md) *(realisert)* | Lav–Middels | Middels–Høy | Meldingsboks, view-configs |
|
||||||
| [Editor](editor.md) | Middels–Stor | Høy | Tekst-primitiv, Tiptap/ProseMirror, KaTeX |
|
| [Editor](editor.md) *(delvis implementert)* | Middels–Stor | Høy | Tekst-primitiv, Tiptap/ProseMirror, KaTeX |
|
||||||
| [Artikkel-publisering](artikkel_publisering.md) | Middels–Stor | Høy | Tekst-primitiv, kunnskapsgraf, Caddy, jobbkø |
|
| [Artikkel-publisering](artikkel_publisering.md) *(forfremmet)* | Middels–Stor | Høy | Tekst-primitiv, kunnskapsgraf, Caddy, jobbkø |
|
||||||
| [Sosial publisering](social_posting.md) | Lav–Middels | Høy | Chat, jobbkø, workspace settings |
|
| [Sosial publisering](social_posting.md) | Lav–Middels | Høy | Chat, jobbkø, workspace settings |
|
||||||
| [Komponerbare sider](komponerbare_sider.md) | Lav (Fase 1) | Middels–Høy | Workspace-modell, SvelteKit, alle feature-komponenter |
|
| [Komponerbare sider](komponerbare_sider.md) *(superseded)* | Lav (Fase 1) | Middels–Høy | Workspace-modell, SvelteKit, alle feature-komponenter |
|
||||||
| [Contradiction Detector](contradiction_detector.md) | Middels | Høy | Live AI, kunnskapsgraf, pgvector, segmenter |
|
| [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 |
|
| [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 |
|
| [Audience Voice Memo](audience_voice_memo.md) | Lav | Høy | Den Asynkrone Gjesten, Live transkripsjon, Live AI |
|
||||||
| [Avisvisning](avisvisning.md) | Lav–Middels | Høy | Meldingsboks, kunnskapsgraf, prominens-score |
|
| [Avisvisning](avisvisning.md) | Lav–Middels | Høy | Meldingsboks, kunnskapsgraf, prominens-score |
|
||||||
| [Personlig workspace](personlig_workspace.md) | Lav–Middels | Middels–Høy | Workspace-modell, meldingsboks, tekst-primitiv |
|
| [Personlig workspace](personlig_workspace.md) *(superseded)* | Lav–Middels | Middels–Høy | Workspace-modell, meldingsboks, tekst-primitiv |
|
||||||
| [Kildevern-modus](kildevern_modus.md) | Lav–Middels | Høy | AI Gateway, Ollama/vLLM, Møterommet |
|
| [Kildevern-modus](kildevern_modus.md) | Lav–Middels | Høy | AI Gateway, Ollama/vLLM, Møterommet |
|
||||||
| [Podcasting 2.0](podcasting_2_0.md) | Lav | Høy | Podcastfabrikken, kunnskapsgraf, RSS |
|
| [Podcasting 2.0](podcasting_2_0.md) | Lav | Høy | Podcastfabrikken, kunnskapsgraf, RSS |
|
||||||
| [Web Clipper](web_clipper.md) | Lav–Middels | Høy | Jobbkø, AI Gateway, meldingsboks, kunnskapsgraf |
|
| [Web Clipper](web_clipper.md) | Lav–Middels | 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 |
|
| [Collaborative Cursors](collaborative_cursors.md) | Lav | Middels | SpacetimeDB, Svelte |
|
||||||
| [Card Heat Map](card_heat_map.md) | Lav | Middels | Meldingsboks, kanban/storyboard |
|
| [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.
|
**Lavthengende frukter** (lav innsats, høy wow): Serendipity Roulette, Podcast Time Machine, Meme Generator, Audience Voice Memo, Pinboard Mode, Ghost Cards.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
# Forslag: Artikkel-publisering og publikasjonsmodell
|
# 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é
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
# Forslag: Universell editor
|
# 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é
|
## 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.
|
É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
|
- Bildeoptimalisering (resize, WebP-konvertering) som jobbkø-oppgave ved opplasting
|
||||||
- oEmbed/OG-metadata caches i en enkel tabell for å unngå gjentatte oppslag
|
- 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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
# Forslag: Tekst-primitiv
|
# 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é
|
## 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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
| [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 |
|
| [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. |
|
| [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
|
### Relaterte spesifikasjoner
|
||||||
|
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -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.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.2–17.3).
|
- [x] 21.2 `synops-audio`: FFmpeg-prosessering. Input: `--cas-hash <hash> --edl <json>`. Output: ny CAS-hash. Erstatter `audio.rs`. Inkluder parametervalidering (fase 17.2–17.3).
|
||||||
- [x] 21.3 `synops-render`: Tera HTML-rendering. Input: `--node-id <uuid> --theme <tema>`. Output: CAS-hash for rendret HTML. Erstatter `publishing.rs`.
|
- [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`.
|
- [x] 21.4 `synops-rss`: RSS/Atom-generering. Input: `--collection-id <uuid>`. Output: XML til stdout. Erstatter `rss.rs`.
|
||||||
> Påbegynt: 2026-03-18T09:20
|
|
||||||
- [ ] 21.5 `synops-tts`: Tekst-til-tale. Input: `--text <tekst> --voice <stemme>`. Output: CAS-hash for lydfil. Erstatter `tts.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.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`.
|
- [ ] 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`.
|
||||||
|
|
|
||||||
|
|
@ -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-transcribe` | Whisper-transkribering av lydfil fra CAS | Ferdig |
|
||||||
| `synops-audio` | FFmpeg lydprosessering med EDL (cut, normalize, EQ, m.m.) | Ferdig |
|
| `synops-audio` | FFmpeg lydprosessering med EDL (cut, normalize, EQ, m.m.) | Ferdig |
|
||||||
| `synops-render` | Tera HTML-rendering til CAS (artikler, forsider) | Ferdig |
|
| `synops-render` | Tera HTML-rendering til CAS (artikler, forsider) | Ferdig |
|
||||||
|
| `synops-rss` | RSS/Atom-feed generering for samlinger | Ferdig |
|
||||||
|
|
||||||
## Konvensjoner
|
## Konvensjoner
|
||||||
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)
|
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)
|
||||||
|
|
|
||||||
2433
tools/synops-rss/Cargo.lock
generated
Normal file
2433
tools/synops-rss/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
tools/synops-rss/Cargo.toml
Normal file
19
tools/synops-rss/Cargo.toml
Normal 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"] }
|
||||||
593
tools/synops-rss/src/main.rs
Normal file
593
tools/synops-rss/src/main.rs
Normal 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('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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]),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue