Per-user read state and subscriptions

Read, starred and position move to entry_state; subscriptions carry each
person's keywords, auto-download, explicit and per-scan limit. The feed
list and unread counts are per person, and the existing library is
adopted by the admin on first start.

The feed URL, folder and schedule stay shared and admin-only: one file
serves everyone, so they describe the file rather than a preference.
Scanning merges subscribers' wants -- anyone wanting an item is enough --
via merge_policy, which is pure and tested.

Also: the test fixture wiped its data directory from every Playwright
worker, deleting the database out from under the running daemon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
2026-09-11 02:17:16 +00:00
parent 4810bb5cfb
commit d46ec73261
7 changed files with 707 additions and 93 deletions

View File

@@ -348,13 +348,25 @@ struct FeedRow {
unread: i64,
}
async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiError> {
async fn feeds(
State(state): State<WebState>,
user: crate::db::User,
) -> Result<Json<Vec<FeedRow>>, ApiError> {
let cfg = state.ctx.cfg();
// Config entries plus the feeds derived from OPML subscriptions.
// Config entries plus the feeds derived from OPML subscriptions -- the catalogue.
// What comes back is only the part of it this person subscribes to.
let subs = crate::subscriptions(&state.ctx)?;
let mut out = Vec::with_capacity(subs.len());
let mine: std::collections::HashMap<String, crate::db::Sub> = state
.ctx
.db
.subscriptions_for(user.id)?
.into_iter()
.map(|s| (s.feed_id.clone(), s))
.collect();
let mut out = Vec::with_capacity(mine.len());
for sub in &subs {
let (id, feed) = (&sub.id, &sub.cfg);
let Some(mine) = mine.get(id) else { continue };
let s = state.ctx.db.feed_summary(id)?;
let st = state.ctx.db.http_state(id)?;
out.push(FeedRow {
@@ -363,10 +375,13 @@ async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiE
title: s.title,
image: s.image,
folder: feed.folder.clone(),
keywords: feed.keywords.clone(),
allow_explicit: feed.allow_explicit,
auto_download: feed.auto_download,
max_new_per_check: feed.max_new_per_check,
keywords: mine.keywords.clone().unwrap_or_else(|| feed.keywords.clone()),
allow_explicit: mine.allow_explicit.unwrap_or(feed.allow_explicit),
auto_download: mine.auto_download.unwrap_or(feed.auto_download),
max_new_per_check: mine
.max_new_per_check
.map(|n| n as usize)
.or(feed.max_new_per_check),
group: feed.group.clone(),
orphaned: s.orphaned,
// Derived from an OPML and not written to config until you change something.
@@ -384,7 +399,7 @@ async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiE
last_error: s.last_error,
entries: s.entries,
downloaded: s.downloaded,
unread: state.ctx.db.unread_count(id)?,
unread: state.ctx.db.unread_count(user.id, id)?,
});
}
Ok(Json(out))
@@ -548,6 +563,7 @@ struct EntryPage {
async fn entries(
State(state): State<WebState>,
Path(id): Path<String>,
user: crate::db::User,
Query(page): Query<Page>,
) -> Result<Json<EntryPage>, ApiError> {
let filter = crate::db::Filter::parse(page.filter.as_deref().unwrap_or("all"));
@@ -555,14 +571,14 @@ async fn entries(
let mut rows = state
.ctx
.db
.entries(&id, filter, search, page.offset, page.limit.clamp(1, 200))?;
.entries(user.id, &id, filter, search, page.offset, page.limit.clamp(1, 200))?;
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
for row in &mut rows {
if let Some(d) = &row.description {
row.description = Some(ammonia::clean(d));
}
}
let total = state.ctx.db.count_entries(&id, filter, search)?;
let total = state.ctx.db.count_entries(user.id, &id, filter, search)?;
Ok(Json(EntryPage { total, entries: rows }))
}
@@ -577,19 +593,26 @@ struct NewFeed {
async fn add_feed(
State(state): State<WebState>,
user: crate::db::User,
Json(body): Json<NewFeed>,
) -> Result<Json<serde_json::Value>, ApiError> {
let mut cfg = (*state.ctx.cfg()).clone();
// Derived feeds count as subscribed: adding one an OPML already lists would duplicate it.
// Someone else may already have it. Then adding costs nothing: no second fetch, no
// second copy on disk, just another name against the same feed.
if let Some(existing) = crate::subscriptions(&state.ctx)?
.into_iter()
.find(|s| s.cfg.url == body.url)
{
return Ok(Json(serde_json::json!({ "id": existing.id, "existing": true })));
let already = state.ctx.db.subscription(user.id, &existing.id)?.is_some();
state.ctx.db.subscribe(user.id, &existing.id)?;
return Ok(Json(
serde_json::json!({ "id": existing.id, "existing": already }),
));
}
let id = crate::add_one(&state.ctx, &mut cfg, &body.url, body.folder, body.keywords).await?;
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
state.ctx.db.subscribe(user.id, &id)?;
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
}
@@ -626,10 +649,46 @@ async fn patch_feed(
user: crate::db::User,
Json(body): Json<FeedPatch>,
) -> Result<StatusCode, ApiError> {
// How often a feed is polled is the operator's call: it costs bandwidth, it is what
// publishers notice, and one impatient setting affects everyone reading the feed.
if body.schedule.is_some() && !user.is_admin {
return Err(ApiError::forbidden("only an admin sets when feeds are scanned"));
// What one person wants -- which items, whether to fetch them, how many at a time --
// is theirs. It goes on their subscription and nobody else sees the change.
if state.ctx.db.subscription(user.id, &id)?.is_some() {
let mut mine = state
.ctx
.db
.subscription(user.id, &id)?
.unwrap_or_else(|| crate::db::Sub { feed_id: id.clone(), ..Default::default() });
let mut touched = false;
if let Some(v) = body.keywords.clone() {
mine.keywords = Some(v.into_iter().filter(|k| !k.trim().is_empty()).collect());
touched = true;
}
if let Some(v) = body.allow_explicit {
mine.allow_explicit = Some(v);
touched = true;
}
if let Some(v) = body.auto_download {
mine.auto_download = Some(v);
touched = true;
}
if let Some(v) = body.max_new_per_check {
mine.max_new_per_check = v.map(|n| n as i64);
touched = true;
}
if touched {
state.ctx.db.set_subscription(user.id, &mine)?;
}
}
// The rest describes the feed itself -- where its files land, its address, when it is
// polled -- and there is one of those however many people read it.
let feed_level = body.url.is_some() || body.folder.is_some() || body.schedule.is_some();
if !feed_level {
return Ok(StatusCode::NO_CONTENT);
}
if !user.is_admin {
return Err(ApiError::forbidden(
"the feed's address, folder and schedule are the same for everyone, so only an admin changes them",
));
}
let mut cfg = (*state.ctx.cfg()).clone();
@@ -675,18 +734,6 @@ async fn patch_feed(
if let Some(v) = body.folder {
feed.folder = v.filter(|s| !s.trim().is_empty());
}
if let Some(v) = body.keywords {
feed.keywords = v.into_iter().filter(|k| !k.trim().is_empty()).collect();
}
if let Some(v) = body.allow_explicit {
feed.allow_explicit = v;
}
if let Some(v) = body.auto_download {
feed.auto_download = v;
}
if let Some(v) = body.max_new_per_check {
feed.max_new_per_check = v;
}
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
if url_changed {
@@ -700,7 +747,23 @@ async fn patch_feed(
async fn remove_feed(
State(state): State<WebState>,
Path(id): Path<String>,
user: crate::db::User,
) -> Result<StatusCode, ApiError> {
// Unsubscribing is personal: it takes the feed off your list and leaves everyone
// else's alone.
state.ctx.db.unsubscribe(user.id, &id)?;
for child in crate::subscriptions(&state.ctx)?
.iter()
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
{
state.ctx.db.unsubscribe(user.id, &child.id)?;
}
if state.ctx.db.subscriber_count(&id)? > 0 {
return Ok(StatusCode::NO_CONTENT);
}
// Nobody is left: the feed stops being scanned. Its files and history stay, so if
// someone subscribes again they do not pull the back catalogue a second time.
let mut cfg = (*state.ctx.cfg()).clone();
if cfg.feeds.remove(&id).is_none() {
// A derived feed: forget it here, though the OPML will list it again on the next
@@ -708,7 +771,6 @@ async fn remove_feed(
state.ctx.db.drop_managed(&id)?;
return Ok(StatusCode::NO_CONTENT);
}
// Downloads and history stay, so re-adding does not re-pull the back catalogue.
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
Ok(StatusCode::NO_CONTENT)
@@ -723,14 +785,15 @@ struct Flags {
async fn set_flags(
State(state): State<WebState>,
Path((feed_id, guid)): Path<(String, String)>,
user: crate::db::User,
Json(body): Json<Flags>,
) -> Result<StatusCode, ApiError> {
use crate::db::EntryFlag;
if let Some(v) = body.read {
state.ctx.db.set_entry_flag(&feed_id, &guid, EntryFlag::Read, v)?;
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Read, v)?;
}
if let Some(v) = body.flagged {
state.ctx.db.set_entry_flag(&feed_id, &guid, EntryFlag::Flagged, v)?;
state.ctx.db.set_entry_flag(user.id, &feed_id, &guid, EntryFlag::Flagged, v)?;
}
Ok(StatusCode::NO_CONTENT)
}
@@ -838,15 +901,17 @@ struct Position {
async fn set_position(
State(state): State<WebState>,
Path((feed_id, guid)): Path<(String, String)>,
user: crate::db::User,
Json(body): Json<Position>,
) -> Result<StatusCode, ApiError> {
state.ctx.db.set_position(&feed_id, &guid, body.secs)?;
state.ctx.db.set_position(user.id, &feed_id, &guid, body.secs)?;
Ok(StatusCode::NO_CONTENT)
}
async fn read_all(
State(state): State<WebState>,
Path(id): Path<String>,
user: crate::db::User,
) -> Result<Json<serde_json::Value>, ApiError> {
// A subscription's own row has no entries, so marking it read means everything under it.
let mut ids = vec![id.clone()];
@@ -856,7 +921,7 @@ async fn read_all(
.filter(|s| s.cfg.group.as_deref() == Some(id.as_str()))
.map(|s| s.id),
);
let n = state.ctx.db.mark_all_read(&ids)?;
let n = state.ctx.db.mark_all_read(user.id, &ids)?;
Ok(Json(serde_json::json!({ "marked": n })))
}