Make the interests table the only source of standing interests (step 3)

The OPML file and the profile's ## Interests section become one-time
import inputs; the prompt groups by the stored category and the OPML
config key is gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9PrjtUS16PAQve8D4bHgc
This commit is contained in:
2026-09-13 05:20:16 +00:00
co-authored by Claude Fable 5.1
parent f0c0927ab8
commit a78e44f56e
14 changed files with 190 additions and 237 deletions
+53 -1
View File
@@ -3,7 +3,7 @@
//! Interest queries stay here so the central database layer remains focused on
//! the pipeline's shared records.
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use anyhow::{Result, bail};
use jiff::Timestamp;
@@ -64,6 +64,35 @@ pub struct Rates {
const INTEREST_COLUMNS: &str = "id, name, category, created_at, categorized_at";
/// Parse an OPML export for the one-time interests importer.
pub fn parse_opml(raw: &str) -> Vec<String> {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
for chunk in raw.split("text=\"").skip(1) {
let Some((value, _)) = chunk.split_once('"') else {
continue;
};
let name = xml_unescape(value).trim().to_string();
if !name.is_empty() && seen.insert(name.to_lowercase()) {
out.push(name);
}
}
out
}
fn xml_unescape(value: &str) -> String {
if !value.contains('&') {
return value.to_string();
}
value
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace("&#39;", "'")
.replace("&amp;", "&")
}
fn interest_from(row: &sqlx::sqlite::SqliteRow) -> Interest {
Interest {
id: row.get("id"),
@@ -326,6 +355,29 @@ mod tests {
(dir, db)
}
#[test]
fn opml_parser_unescapes_trims_and_deduplicates_names() {
let interests = parse_opml(
r#"<opml><body>
<outline text=" Rust "/>
<outline text="E-Ink &amp; RSS"/>
<outline text="rust"/>
<outline text="Quotes &quot;and&quot; apostrophes &apos;x&apos; &#39;y&#39;"/>
<outline text="Markup &lt;tag&gt;"/>
<outline text=""/>
</body></opml>"#,
);
assert_eq!(
interests,
[
"Rust",
"E-Ink & RSS",
"Quotes \"and\" apostrophes 'x' 'y'",
"Markup <tag>",
]
);
}
async fn seed_article(db: &Db, id: ArticleId) {
sqlx::query(
"INSERT INTO articles (id, canonical_url, title, first_seen) VALUES (?, ?, ?, ?)",