Each person has a list for every feed they read and one per feed. An item whose title or text holds one of the words, matched as whole words so "ai" does not hide everything that "said" anything, is hidden from them and, since the scanner now keeps each subscriber's filters separate, is fetched only if someone else still wants it. Whole-word matching is not something LIKE can do on both SQLite and Postgres, so the matches are worked out in Rust into a `hidden` table whenever a list changes, someone subscribes, or a scan brings in new items, and the queries only look that table up. Both new tables are tables rather than columns because create_missing adds tables but never columns. Hidden counts as read for the reaper and for "others still want this file", since whoever it is hidden from is as done with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
326 lines
12 KiB
Rust
326 lines
12 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>,
|
|
#[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>,
|
|
#[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!();
|
|
}
|
|
|
|
/// 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 {}
|
|
}
|