Doc comments on ipx-feed's and ipx-directory's public items (#354)
The style guide asks for doc comments on a library's public API. Every type, function, constant and static the two libraries export now has one, saying what it is for or what it does that its name does not: fetch follows redirects itself so credentials stay on the feed's host, Config::parse refuses repeated token names, Store::due_one is the admin's Refresh. Fields and variants whose names say it all have none, as the guide allows. category_path's comment had slid above categories(), which then had two; each has its own again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,9 +6,11 @@ use anyhow::{Context, Result, bail};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// `directory.toml`. See docs/directory.md.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
/// Where the API and the public pages listen.
|
||||||
#[serde(default = "default_bind")]
|
#[serde(default = "default_bind")]
|
||||||
pub bind: String,
|
pub bind: String,
|
||||||
/// The environment variable holding the database URL, so the file holds no secret.
|
/// The environment variable holding the database URL, so the file holds no secret.
|
||||||
@@ -39,6 +41,7 @@ fn default_database_url_env() -> String {
|
|||||||
"DIRECTORY_DATABASE_URL".into()
|
"DIRECTORY_DATABASE_URL".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A token as `directory.toml` lists it: a name, what it may do, and only the token's hash.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct TokenCfg {
|
pub struct TokenCfg {
|
||||||
@@ -74,11 +77,14 @@ pub struct Token {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
/// Reads and checks `path`; see `parse`.
|
||||||
pub fn load(path: &Path) -> Result<Self> {
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||||
Self::parse(&text).with_context(|| format!("in {}", path.display()))
|
Self::parse(&text).with_context(|| format!("in {}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads a config, refusing tokens with empty or repeated names, which would file two
|
||||||
|
/// instances' snapshots together.
|
||||||
pub fn parse(text: &str) -> Result<Self> {
|
pub fn parse(text: &str) -> Result<Self> {
|
||||||
let cfg: Config = toml::from_str(text)?;
|
let cfg: Config = toml::from_str(text)?;
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ pub struct Item {
|
|||||||
pub guid: String,
|
pub guid: String,
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub link: Option<String>,
|
pub link: Option<String>,
|
||||||
|
/// Unix seconds.
|
||||||
pub published: Option<i64>,
|
pub published: Option<i64>,
|
||||||
|
/// Seconds.
|
||||||
pub duration: Option<i64>,
|
pub duration: Option<i64>,
|
||||||
/// As the feed sent it, unsanitized.
|
/// As the feed sent it, unsanitized.
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
@@ -80,6 +82,7 @@ pub struct SnapshotEntry {
|
|||||||
pub category: Option<String>,
|
pub category: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What became of a snapshot's entries.
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct SnapshotResult {
|
pub struct SnapshotResult {
|
||||||
pub accepted: usize,
|
pub accepted: usize,
|
||||||
@@ -91,6 +94,7 @@ pub struct SnapshotResult {
|
|||||||
pub refused_invalid: usize,
|
pub refused_invalid: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Who a token belongs to and what it may do: `/v1/me`'s answer.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Me {
|
pub struct Me {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -105,11 +109,13 @@ pub struct Instance {
|
|||||||
pub subscribers: i64,
|
pub subscribers: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A feed an admin removed from the directory, which snapshots may not bring back.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Blocked {
|
pub struct Blocked {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub url: Option<String>,
|
pub url: Option<String>,
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
|
/// When it was blocked, Unix seconds.
|
||||||
pub at: i64,
|
pub at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +127,8 @@ pub const MAX_SNAPSHOT: usize = 20_000;
|
|||||||
/// to a feed fetches it itself anyway: every six hours was 3.9 GB of feeds a day (#243).
|
/// to a feed fetches it itself anyway: every six hours was 3.9 GB of feeds a day (#243).
|
||||||
pub const REFRESH_EVERY: i64 = 12 * 3600;
|
pub const REFRESH_EVERY: i64 = 12 * 3600;
|
||||||
|
|
||||||
|
/// The running directory: its database, the tokens it accepts and the client its refresher
|
||||||
|
/// fetches feeds with.
|
||||||
pub struct Directory {
|
pub struct Directory {
|
||||||
pub store: Store,
|
pub store: Store,
|
||||||
tokens: RwLock<Vec<Token>>,
|
tokens: RwLock<Vec<Token>>,
|
||||||
@@ -134,9 +142,12 @@ pub struct Directory {
|
|||||||
/// How long the public pages show the same copy of the list.
|
/// How long the public pages show the same copy of the list.
|
||||||
const PAGES_LIST_FOR: Duration = Duration::from_secs(60);
|
const PAGES_LIST_FOR: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// The directory as every handler and task holds it.
|
||||||
pub type Shared = Arc<Directory>;
|
pub type Shared = Arc<Directory>;
|
||||||
|
|
||||||
impl Directory {
|
impl Directory {
|
||||||
|
/// A directory on `store`, accepting `tokens`, whose refresher may reach the internal
|
||||||
|
/// addresses `fetch_internal` lists and no others.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
store: Store,
|
store: Store,
|
||||||
tokens: Vec<Token>,
|
tokens: Vec<Token>,
|
||||||
@@ -288,6 +299,7 @@ pub async fn reloader(dir: Shared, path: std::path::PathBuf) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every route: the API under `/v1`, behind a token, and the public pages.
|
||||||
pub fn router(dir: Shared) -> Router {
|
pub fn router(dir: Shared) -> Router {
|
||||||
let v1 = Router::new()
|
let v1 = Router::new()
|
||||||
.route("/v1/me", get(me))
|
.route("/v1/me", get(me))
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const FLAG_AFTER_SECS: i64 = 24 * 3600;
|
|||||||
/// The most subscribers one instance may say a feed has.
|
/// The most subscribers one instance may say a feed has.
|
||||||
pub const MAX_SUBSCRIBERS: i64 = 1_000_000;
|
pub const MAX_SUBSCRIBERS: i64 = 1_000_000;
|
||||||
|
|
||||||
|
/// The directory's database: feeds, snapshots, blocks and when each sync ran.
|
||||||
pub struct Store {
|
pub struct Store {
|
||||||
db: DatabaseConnection,
|
db: DatabaseConnection,
|
||||||
}
|
}
|
||||||
@@ -30,11 +31,15 @@ pub struct Due {
|
|||||||
|
|
||||||
/// What a fetch found, to be stored.
|
/// What a fetch found, to be stored.
|
||||||
pub enum Refreshed {
|
pub enum Refreshed {
|
||||||
|
/// Unchanged since the last fetch.
|
||||||
NotModified,
|
NotModified,
|
||||||
|
/// Read anew, with the validators to send next time.
|
||||||
Feed { parsed: ipx_feed::ParsedFeed, etag: Option<String>, last_modified: Option<String> },
|
Feed { parsed: ipx_feed::ParsedFeed, etag: Option<String>, last_modified: Option<String> },
|
||||||
|
/// The fetch failed, and why, in words.
|
||||||
Failed(String),
|
Failed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Unix seconds now.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn now() -> i64 {
|
pub fn now() -> i64 {
|
||||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
|
||||||
@@ -184,6 +189,7 @@ impl Store {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records when the sync `name` (Apple's charts) last ran.
|
||||||
pub async fn set_synced(&self, name: &str, at: i64) -> Result<()> {
|
pub async fn set_synced(&self, name: &str, at: i64) -> Result<()> {
|
||||||
self.exec(
|
self.exec(
|
||||||
"INSERT INTO syncs (name, at) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET at = excluded.at",
|
"INSERT INTO syncs (name, at) VALUES ($1, $2) ON CONFLICT (name) DO UPDATE SET at = excluded.at",
|
||||||
@@ -193,6 +199,7 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the database answers, for the healthcheck.
|
||||||
pub async fn ping(&self) -> Result<()> {
|
pub async fn ping(&self) -> Result<()> {
|
||||||
self.rows("SELECT 1", vec![]).await?;
|
self.rows("SELECT 1", vec![]).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -377,6 +384,7 @@ impl Store {
|
|||||||
Ok(self.exec("DELETE FROM blocked WHERE id = $1", vec![id.into()]).await? > 0)
|
Ok(self.exec("DELETE FROM blocked WHERE id = $1", vec![id.into()]).await? > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Each instance that has sent a snapshot, with how many feeds and people it counted.
|
||||||
pub async fn instances(&self) -> Result<Vec<Instance>> {
|
pub async fn instances(&self) -> Result<Vec<Instance>> {
|
||||||
// Postgres sums a BIGINT as NUMERIC, which does not read as an i64.
|
// Postgres sums a BIGINT as NUMERIC, which does not read as an i64.
|
||||||
self.rows(
|
self.rows(
|
||||||
@@ -396,6 +404,7 @@ impl Store {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The feeds an admin has blocked.
|
||||||
pub async fn blocked(&self) -> Result<Vec<Blocked>> {
|
pub async fn blocked(&self) -> Result<Vec<Blocked>> {
|
||||||
self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![])
|
self.rows("SELECT id, url, title, at FROM blocked ORDER BY at DESC, id", vec![])
|
||||||
.await?
|
.await?
|
||||||
@@ -432,6 +441,7 @@ impl Store {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One feed, due or not, for an admin's Refresh.
|
||||||
pub async fn due_one(&self, id: &str) -> Result<Option<Due>> {
|
pub async fn due_one(&self, id: &str) -> Result<Option<Due>> {
|
||||||
self.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
|
self.rows("SELECT id, url, etag, last_modified FROM feeds WHERE id = $1", vec![id.into()])
|
||||||
.await?
|
.await?
|
||||||
@@ -447,6 +457,7 @@ impl Store {
|
|||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stores what a fetch of feed `id` found, and when.
|
||||||
pub async fn save(&self, id: &str, got: Refreshed) -> Result<()> {
|
pub async fn save(&self, id: &str, got: Refreshed) -> Result<()> {
|
||||||
let t = now();
|
let t = now();
|
||||||
match got {
|
match got {
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
|||||||
pub struct Allowed(Arc<Vec<(IpAddr, u8)>>);
|
pub struct Allowed(Arc<Vec<(IpAddr, u8)>>);
|
||||||
|
|
||||||
impl Allowed {
|
impl Allowed {
|
||||||
|
/// Reads `fetch_internal`'s list, refusing an entry that is neither an address nor a network.
|
||||||
pub fn parse(list: &[String]) -> Result<Self> {
|
pub fn parse(list: &[String]) -> Result<Self> {
|
||||||
list.iter().map(|s| parse_net(s)).collect::<Result<Vec<_>>>().map(|v| Self(Arc::new(v)))
|
list.iter().map(|s| parse_net(s)).collect::<Result<Vec<_>>>().map(|v| Self(Arc::new(v)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a connection to `ip` may go ahead: any public address, and the internal ones listed.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn permits(&self, ip: IpAddr) -> bool {
|
pub fn permits(&self, ip: IpAddr) -> bool {
|
||||||
let ip = canonical(ip);
|
let ip = canonical(ip);
|
||||||
@@ -109,6 +111,7 @@ pub fn is_internal(ip: IpAddr) -> bool {
|
|||||||
pub struct Guard(Allowed);
|
pub struct Guard(Allowed);
|
||||||
|
|
||||||
impl Guard {
|
impl Guard {
|
||||||
|
/// A guard letting through what `allowed` lists, besides the public internet.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(allowed: Allowed) -> Self {
|
pub fn new(allowed: Allowed) -> Self {
|
||||||
Self(allowed)
|
Self(allowed)
|
||||||
@@ -123,6 +126,7 @@ impl<S> tower::Layer<S> for Guard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The connector `Guard` wraps, checking each connection it makes.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Guarded<S> {
|
pub struct Guarded<S> {
|
||||||
inner: S,
|
inner: S,
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ use anyhow::{Context, Result, anyhow};
|
|||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
|
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED};
|
||||||
|
|
||||||
|
/// A feed as `parse` reads it, RSS or Atom alike: the channel's own details and its items.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct ParsedFeed {
|
pub struct ParsedFeed {
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
|
/// RSS's `<ttl>`: how long the publisher asks to be left alone between reads.
|
||||||
pub ttl_mins: Option<u64>,
|
pub ttl_mins: Option<u64>,
|
||||||
|
/// The show's artwork, `<itunes:image>` before the channel's `<image>`.
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
/// The site the feed belongs to, where a favicon can stand in for missing artwork.
|
/// The site the feed belongs to, where a favicon can stand in for missing artwork.
|
||||||
pub site: Option<String>,
|
pub site: Option<String>,
|
||||||
@@ -22,17 +25,22 @@ pub struct ParsedFeed {
|
|||||||
pub entries: Vec<Entry>,
|
pub entries: Vec<Entry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One item of a feed.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct Entry {
|
pub struct Entry {
|
||||||
|
/// What tells this item from the feed's others: its `<guid>`, or failing that its link, its
|
||||||
|
/// enclosure's address or its title (`pick_guid`).
|
||||||
pub guid: String,
|
pub guid: String,
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub link: Option<String>,
|
pub link: Option<String>,
|
||||||
|
/// Unix seconds.
|
||||||
pub published: Option<i64>,
|
pub published: Option<i64>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
/// The item's Markdown, when the feed sends it beside HTML of its own (`<source:markdown>`):
|
/// The item's Markdown, when the feed sends it beside HTML of its own (`<source:markdown>`):
|
||||||
/// the page can show either (#285). With no HTML it is the description instead (#284).
|
/// the page can show either (#285). With no HTML it is the description instead (#284).
|
||||||
pub markdown: Option<String>,
|
pub markdown: Option<String>,
|
||||||
pub categories: Vec<String>,
|
pub categories: Vec<String>,
|
||||||
|
/// `<itunes:explicit>` said yes.
|
||||||
pub explicit: bool,
|
pub explicit: bool,
|
||||||
/// Episode artwork; falls back to the feed's in the UI.
|
/// Episode artwork; falls back to the feed's in the UI.
|
||||||
pub image: Option<String>,
|
pub image: Option<String>,
|
||||||
@@ -43,21 +51,22 @@ pub struct Entry {
|
|||||||
pub enclosures: Vec<Enclosure>,
|
pub enclosures: Vec<Enclosure>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A file an item carries, an episode as a rule.
|
||||||
#[derive(Debug, Default, Clone, PartialEq)]
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
pub struct Enclosure {
|
pub struct Enclosure {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
/// The type the feed declares, which may be missing or wrong.
|
||||||
pub mime: Option<String>,
|
pub mime: Option<String>,
|
||||||
|
/// The size the feed declares, in bytes.
|
||||||
pub length: Option<i64>,
|
pub length: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What `fetch` got.
|
||||||
pub enum Fetched {
|
pub enum Fetched {
|
||||||
/// Server said 304, or returned a body we already have.
|
/// Server said 304, or returned a body we already have.
|
||||||
NotModified,
|
NotModified,
|
||||||
Body {
|
/// A body to read, with the validators to send next time.
|
||||||
bytes: Vec<u8>,
|
Body { bytes: Vec<u8>, etag: Option<String>, last_modified: Option<String> },
|
||||||
etag: Option<String>,
|
|
||||||
last_modified: Option<String>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The longest a feed may take, connecting to the last byte: a scan handles feeds in order, and
|
/// The longest a feed may take, connecting to the last byte: a scan handles feeds in order, and
|
||||||
@@ -76,6 +85,7 @@ pub const FEED_SILENCE: std::time::Duration = std::time::Duration::from_secs(15)
|
|||||||
/// beside memory (#224): a body is read whole, so a night of large feeds is a night of large
|
/// beside memory (#224): a body is read whole, so a night of large feeds is a night of large
|
||||||
/// allocations.
|
/// allocations.
|
||||||
pub static FETCHES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
pub static FETCHES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
/// See `FETCHES`.
|
||||||
pub static FETCHED_BYTES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
pub static FETCHED_BYTES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||||
|
|
||||||
/// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so
|
/// Conditional GET, following redirects itself: `client` must follow none (`feed_client`), so
|
||||||
@@ -135,6 +145,9 @@ async fn read_capped(mut resp: reqwest::Response) -> Result<Vec<u8>> {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads a feed, asking only for what changed since `etag` and `last_modified`. Redirects are
|
||||||
|
/// followed here, up to ten, so `auth` goes to the feed's own host alone. Also returns the new
|
||||||
|
/// address when every hop said the feed moved for good, for the caller to keep instead.
|
||||||
#[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))]
|
#[tracing::instrument(skip_all, fields(url = %redact(feed_url), http.response.body.size))]
|
||||||
pub async fn fetch(
|
pub async fn fetch(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
@@ -258,7 +271,9 @@ fn from_wp_posts(posts: &[serde_json::Value]) -> ParsedFeed {
|
|||||||
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
|
/// A stored `last_error`, translated into plain words for whoever subscribes: whose problem
|
||||||
/// it is, and whether there is a new address to switch to.
|
/// it is, and whether there is a new address to switch to.
|
||||||
pub struct Failure {
|
pub struct Failure {
|
||||||
|
/// What to tell them, in a sentence.
|
||||||
pub reason: &'static str,
|
pub reason: &'static str,
|
||||||
|
/// Where the feed went, when the error says.
|
||||||
pub new_url: Option<String>,
|
pub new_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1309,15 +1324,15 @@ const CATEGORIES: &[(&str, &[&str])] = &[
|
|||||||
const ALIASES: &[(&str, &str)] =
|
const ALIASES: &[(&str, &str)] =
|
||||||
&[("American Football", "Football"), ("Football (Soccer)", "Soccer"), ("Software How-To", "Technology")];
|
&[("American Football", "Football"), ("Football (Soccer)", "Soccer"), ("Software How-To", "Technology")];
|
||||||
|
|
||||||
/// Where a category sits in Apple's list, as (category, subcategory), so the Directory browses a
|
|
||||||
/// category and then its subcategories as Apple's does (#118). A podcast's own is stored as its
|
|
||||||
/// subcategory, and one an admin typed that is not Apple's stands as a category of its own.
|
|
||||||
/// Apple's categories as ipx has them, for the directory to check against Apple's own (#252).
|
/// Apple's categories as ipx has them, for the directory to check against Apple's own (#252).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn categories() -> &'static [(&'static str, &'static [&'static str])] {
|
pub fn categories() -> &'static [(&'static str, &'static [&'static str])] {
|
||||||
CATEGORIES
|
CATEGORIES
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a category sits in Apple's list, as (category, subcategory), so the Directory browses a
|
||||||
|
/// category and then its subcategories as Apple's does (#118). A podcast's own is stored as its
|
||||||
|
/// subcategory, and one an admin typed that is not Apple's stands as a category of its own.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn category_path(name: &str) -> (String, Option<String>) {
|
pub fn category_path(name: &str) -> (String, Option<String>) {
|
||||||
// A feed's own text, which can end in a stray comma: "Soccer," was a category of its own.
|
// A feed's own text, which can end in a stray comma: "Soccer," was a category of its own.
|
||||||
|
|||||||
Reference in New Issue
Block a user