A feed's page in the Directory (#128) had its cover, category and latest items, but not the feed's own description, which Apple's show page leads with: iPX read every item's description and threw the channel's away, and the feeds table had nowhere to keep it. It is parsed now, RSS's <description>, or iTunes' summary when that is empty, or Atom's subtitle, kept in feeds.description (kept when a later read has none, as title and image are), and sent, sanitized, with the items from /api/directory/{id}, which is now an object, not a list. The page shows it as plain text under the header, three lines of it, with More when there is more. A description that only repeats the title is left out. feeds.description is the first column added to a table that already exists. create_missing looks for it with a SELECT and runs the ALTER only when it is missing, since it runs on every open, the healthcheck's included, and an ALTER's lock is what made that time out before. The same once-only step forgets every feed's ETag and Last-Modified: a feed is read whole only when it has changed, so its description would otherwise wait for its next item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
357 lines
13 KiB
Rust
357 lines
13 KiB
Rust
//! The database's tables as SeaORM entities: the one description of the schema, from which
|
|
//! `Db::open` creates what a database is missing, on SQLite or Postgres alike (see
|
|
//! `db::create_missing`). Times are Unix seconds.
|
|
|
|
pub mod feeds {
|
|
use sea_orm::entity::prelude::*;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
#[sea_orm(table_name = "feeds")]
|
|
pub struct Model {
|
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
|
pub id: String,
|
|
#[sea_orm(column_type = "Text")]
|
|
pub url: String,
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub title: Option<String>,
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub image: Option<String>,
|
|
/// The channel's first <itunes:category>, for the Directory.
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub category: Option<String>,
|
|
/// What the feed says it is, as it says it, for its page in the Directory. Added after
|
|
/// the table was, so `create_missing` adds it to a database without it.
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub description: Option<String>,
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub etag: Option<String>,
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub last_modified: Option<String>,
|
|
pub last_checked: Option<i64>,
|
|
pub ttl_mins: Option<i64>,
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub last_error: Option<String>,
|
|
/// When the current run of failures began; NULL while the feed is healthy. Kept through
|
|
/// repeated failures so the UI can tell a blip (macmanx: failed once, fine an hour
|
|
/// later) from a feed that has been down for a day.
|
|
pub error_since: Option<i64>,
|
|
/// Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
|
|
#[sea_orm(default_value = false)]
|
|
pub orphaned: bool,
|
|
/// The OPML subscription this feed came from.
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub group_id: Option<String>,
|
|
/// Derived from an OPML and not written to config.toml. Writing 80-odd generated entries
|
|
/// into a hand-edited file made it unreadable; the OPML is the source of truth, so they
|
|
/// are re-derived instead. Customising one promotes it to config.
|
|
#[sea_orm(default_value = false)]
|
|
pub managed: bool,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
pub enum Relation {}
|
|
|
|
impl ActiveModelBehavior for ActiveModel {}
|
|
}
|
|
|
|
pub mod entries {
|
|
use sea_orm::entity::prelude::*;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
#[sea_orm(table_name = "entries")]
|
|
pub struct Model {
|
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
|
pub feed_id: String,
|
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
|
pub guid: String,
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub title: Option<String>,
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub link: Option<String>,
|
|
pub published: Option<i64>,
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub description: Option<String>,
|
|
pub first_seen: i64,
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub image: Option<String>,
|
|
pub duration: Option<i64>,
|
|
pub episode: Option<i64>,
|
|
pub season: Option<i64>,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
pub enum Relation {}
|
|
|
|
impl ActiveModelBehavior for ActiveModel {}
|
|
}
|
|
|
|
pub mod enclosures {
|
|
use sea_orm::entity::prelude::*;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
#[sea_orm(table_name = "enclosures")]
|
|
pub struct Model {
|
|
#[sea_orm(primary_key)]
|
|
pub id: i64,
|
|
#[sea_orm(column_type = "Text")]
|
|
pub feed_id: String,
|
|
#[sea_orm(column_type = "Text")]
|
|
pub guid: String,
|
|
/// The dedupe key, and the reason one file serves every subscriber. A reaped file keeps
|
|
/// its row with path NULL and state 'reaped', so a purged episode is never fetched again.
|
|
#[sea_orm(unique, column_type = "Text")]
|
|
pub url: String,
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub mime: Option<String>,
|
|
pub length: Option<i64>,
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub path: Option<String>,
|
|
/// 'pending' is queued: a scan downloads it on its own. 'held' is listed but outside the
|
|
/// feed's newest max_new_per_check, or its feed downloads nothing automatically; it can
|
|
/// be downloaded by hand (`Db::hold_back`). 'skipped' is filtered out ('last_error' says
|
|
/// why), then 'downloading', 'done', 'error', 'reaped'.
|
|
#[sea_orm(column_type = "Text")]
|
|
pub state: String,
|
|
#[sea_orm(default_value = 0)]
|
|
pub bytes_done: i64,
|
|
pub downloaded_at: Option<i64>,
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub last_error: Option<String>,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
pub enum Relation {}
|
|
|
|
impl ActiveModelBehavior for ActiveModel {}
|
|
}
|
|
|
|
pub mod users {
|
|
use sea_orm::entity::prelude::*;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
#[sea_orm(table_name = "users")]
|
|
pub struct Model {
|
|
#[sea_orm(primary_key)]
|
|
pub id: i64,
|
|
/// Unique without regard to case: `db::create_missing` adds the index on lower(name), which
|
|
/// works the same on both databases where SQLite's COLLATE NOCASE does not.
|
|
#[sea_orm(column_type = "Text")]
|
|
pub name: String,
|
|
/// NULL for someone who only ever arrives through the proxy: there is no password to
|
|
/// check, and leaving it empty is not the same as leaving it unset.
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub pass_hash: Option<String>,
|
|
#[sea_orm(default_value = false)]
|
|
pub is_admin: bool,
|
|
/// For whoever maintains the server. NULL where it is not known.
|
|
pub created: Option<i64>,
|
|
pub last_login: Option<i64>,
|
|
/// The theme chosen in Settings, and light, dark or auto. NULL until one is chosen.
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub theme: Option<String>,
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub theme_mode: Option<String>,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
pub enum Relation {}
|
|
|
|
impl ActiveModelBehavior for ActiveModel {}
|
|
}
|
|
|
|
/// A table of one person's rows, gone when they are.
|
|
macro_rules! owned_by_user {
|
|
() => {
|
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
pub enum Relation {
|
|
#[sea_orm(
|
|
belongs_to = "super::users::Entity",
|
|
from = "Column::UserId",
|
|
to = "super::users::Column::Id",
|
|
on_delete = "Cascade"
|
|
)]
|
|
User,
|
|
}
|
|
|
|
impl Related<super::users::Entity> for Entity {
|
|
fn to() -> RelationDef {
|
|
Relation::User.def()
|
|
}
|
|
}
|
|
|
|
impl ActiveModelBehavior for ActiveModel {}
|
|
};
|
|
}
|
|
|
|
/// What one person wants from a feed. The feed, its items and its files are shared; this is the
|
|
/// part that is not. NULL in a column means: follow the feed's own setting.
|
|
pub mod subscriptions {
|
|
use sea_orm::entity::prelude::*;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
#[sea_orm(table_name = "subscriptions")]
|
|
pub struct Model {
|
|
#[sea_orm(primary_key, auto_increment = false)]
|
|
pub user_id: i64,
|
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
|
pub feed_id: String,
|
|
/// JSON array of strings; NULL follows the feed.
|
|
#[sea_orm(column_type = "Text", nullable)]
|
|
pub keywords: Option<String>,
|
|
pub auto_download: Option<bool>,
|
|
pub allow_explicit: Option<bool>,
|
|
pub max_new_per_check: Option<i64>,
|
|
/// Pinned to the top of this person's feed list, a feed inside a folder included.
|
|
#[sea_orm(default_value = false)]
|
|
pub pinned: bool,
|
|
}
|
|
|
|
owned_by_user!();
|
|
}
|
|
|
|
/// Read, kept and how far in. One row per person per item, created on first touch; an item
|
|
/// nobody has touched has no row at all, which is what unread means.
|
|
pub mod entry_state {
|
|
use sea_orm::entity::prelude::*;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
#[sea_orm(table_name = "entry_state")]
|
|
pub struct Model {
|
|
#[sea_orm(primary_key, auto_increment = false)]
|
|
pub user_id: i64,
|
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
|
pub feed_id: String,
|
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
|
pub guid: String,
|
|
#[sea_orm(default_value = false)]
|
|
pub read: bool,
|
|
#[sea_orm(default_value = false)]
|
|
pub flagged: bool,
|
|
#[sea_orm(default_value = 0)]
|
|
pub position: i64,
|
|
/// The length this person's player measured, beside the position it is measured against.
|
|
pub duration: Option<i64>,
|
|
}
|
|
|
|
owned_by_user!();
|
|
}
|
|
|
|
/// Words someone never wants to see: an item whose title or text has one is hidden from them and
|
|
/// not downloaded on their account (issue #47). `feed_id` is empty for the list that applies to
|
|
/// every feed they read.
|
|
pub mod blocklists {
|
|
use sea_orm::entity::prelude::*;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
#[sea_orm(table_name = "blocklists")]
|
|
pub struct Model {
|
|
#[sea_orm(primary_key, auto_increment = false)]
|
|
pub user_id: i64,
|
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
|
pub feed_id: String,
|
|
/// JSON array of strings.
|
|
#[sea_orm(column_type = "Text")]
|
|
pub words: String,
|
|
}
|
|
|
|
owned_by_user!();
|
|
}
|
|
|
|
/// The items someone's block lists hide from them, worked out whenever a list or the feed
|
|
/// changes (`Db::rehide`), since SQL on both databases cannot match whole words itself.
|
|
pub mod hidden {
|
|
use sea_orm::entity::prelude::*;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
#[sea_orm(table_name = "hidden")]
|
|
pub struct Model {
|
|
#[sea_orm(primary_key, auto_increment = false)]
|
|
pub user_id: i64,
|
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
|
pub feed_id: String,
|
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
|
pub guid: String,
|
|
}
|
|
|
|
owned_by_user!();
|
|
}
|
|
|
|
pub mod sessions {
|
|
use sea_orm::entity::prelude::*;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
#[sea_orm(table_name = "sessions")]
|
|
pub struct Model {
|
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
|
pub token: String,
|
|
pub user_id: i64,
|
|
pub seen: i64,
|
|
}
|
|
|
|
owned_by_user!();
|
|
}
|
|
|
|
/// A token a script or agent sends as `Authorization: Bearer`, acting as the person who made
|
|
/// it (#123). Kept as its SHA-256, so the table is no use to anyone who reads it.
|
|
pub mod api_tokens {
|
|
use sea_orm::entity::prelude::*;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
#[sea_orm(table_name = "api_tokens")]
|
|
pub struct Model {
|
|
#[sea_orm(primary_key)]
|
|
pub id: i64,
|
|
pub user_id: i64,
|
|
/// What its owner called it, to tell one from another when revoking.
|
|
#[sea_orm(column_type = "Text")]
|
|
pub name: String,
|
|
#[sea_orm(unique, column_type = "Text")]
|
|
pub hash: String,
|
|
pub created: i64,
|
|
pub last_used: Option<i64>,
|
|
}
|
|
|
|
owned_by_user!();
|
|
}
|
|
|
|
/// The catalogue: every feed configured, with its shared settings as `config::Feed` in JSON, so a
|
|
/// new setting on a feed needs no new column. It was config.toml's `[feeds]` (issue #18).
|
|
pub mod catalogue {
|
|
use sea_orm::entity::prelude::*;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
#[sea_orm(table_name = "catalogue")]
|
|
pub struct Model {
|
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
|
pub id: String,
|
|
#[sea_orm(column_type = "Text")]
|
|
pub spec: String,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
pub enum Relation {}
|
|
|
|
impl ActiveModelBehavior for ActiveModel {}
|
|
}
|
|
|
|
/// The server's settings, by name, each a JSON value. `general` is `config::Stored`: what was in
|
|
/// config.toml's `[general]` and the admin page edits. Its row being there is what says the
|
|
/// configuration has moved in (issue #18).
|
|
pub mod settings {
|
|
use sea_orm::entity::prelude::*;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
|
#[sea_orm(table_name = "settings")]
|
|
pub struct Model {
|
|
#[sea_orm(primary_key, auto_increment = false, column_type = "Text")]
|
|
pub name: String,
|
|
#[sea_orm(column_type = "Text")]
|
|
pub value: String,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
|
pub enum Relation {}
|
|
|
|
impl ActiveModelBehavior for ActiveModel {}
|
|
}
|