A show made a feed of its own leaves its list's group (#358)
Unsung's unread counted 2 in the feed list while All Subscriptions' unread list was empty. It had been a show of an OPML that is hidden from All Subscriptions, and became a feed of its own; Db::unmanage set managed = false and left feeds.group_id pointing at the OPML, so shown_in hid it with the list. 20 feeds in production were like it. unmanage now sets the group from the feed's catalogue entry: none for a show promoted when its list dropped it or went away, the list's for a show promoted by editing its settings, which config keeps in the group. A pass at start-up moves those already left behind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A podcast that was once in an OPML and is now a feed of its own is no longer hidden from All Subscriptions when that OPML is, so its unread items are listed there as the feed list counts them (#358).
|
||||||
|
|
||||||
## [1.5.1] - 2026-10-24
|
## [1.5.1] - 2026-10-24
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ pub async fn retire_group(ctx: &Ctx, parent_id: &str, parent: Option<&config::Fe
|
|||||||
if cfg.feeds.contains_key(&m.id) {
|
if cfg.feeds.contains_key(&m.id) {
|
||||||
// Scanned from its config entry and still read. Dropped as derived, its stored
|
// Scanned from its config entry and still read. Dropped as derived, its stored
|
||||||
// entries would go with it: davewiner's 11 were promoted without being unmanaged.
|
// entries would go with it: davewiner's 11 were promoted without being unmanaged.
|
||||||
ctx.db.unmanage(&m.id).await?;
|
ctx.db.unmanage(&m.id, cfg.feeds[&m.id].group.as_deref()).await?;
|
||||||
} else if promote_kept(ctx, &m, parent).await? {
|
} else if promote_kept(ctx, &m, parent).await? {
|
||||||
tracing::info!(feed = %m.id, "its list is gone but someone kept it; a feed of its own now");
|
tracing::info!(feed = %m.id, "its list is gone but someone kept it; a feed of its own now");
|
||||||
} else if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
|
} else if ctx.db.downloaded_count(&m.id).await.unwrap_or(1) > 0 {
|
||||||
@@ -450,8 +450,9 @@ pub(crate) async fn promote_kept(ctx: &Ctx, m: &db::Managed, base: Option<&confi
|
|||||||
// kept show went to the directory (#332). A list no longer known may have been private.
|
// kept show went to the directory (#332). A list no longer known may have been private.
|
||||||
private: base.is_none_or(|p| p.private),
|
private: base.is_none_or(|p| p.private),
|
||||||
});
|
});
|
||||||
|
let group = cfg.feeds[&m.id].group.clone();
|
||||||
ctx.store_cfg(cfg).await?;
|
ctx.store_cfg(cfg).await?;
|
||||||
ctx.db.unmanage(&m.id).await?;
|
ctx.db.unmanage(&m.id, group.as_deref()).await?;
|
||||||
ctx.db.set_orphaned(&m.id, false).await?;
|
ctx.db.set_orphaned(&m.id, false).await?;
|
||||||
for (user, kept) in subs {
|
for (user, kept) in subs {
|
||||||
if !kept {
|
if !kept {
|
||||||
@@ -476,6 +477,22 @@ pub(crate) async fn retire_stranded(ctx: &Ctx) -> Result<usize> {
|
|||||||
Ok(before.len() - ctx.db.managed_feeds().await?.len())
|
Ok(before.len() - ctx.db.managed_feeds().await?.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Puts each feed no longer derived in the group its catalogue entry says, and returns how many
|
||||||
|
/// moved. Made a feed of its own before #358, a show kept its list's group in the database, and
|
||||||
|
/// hiding the list from All Subscriptions hid it too.
|
||||||
|
pub(crate) async fn regroup_unmanaged(ctx: &Ctx) -> Result<usize> {
|
||||||
|
let cfg = ctx.cfg();
|
||||||
|
let mut moved = 0;
|
||||||
|
for (id, group) in ctx.db.unmanaged_in_groups().await? {
|
||||||
|
let Some(f) = cfg.feeds.get(&id) else { continue };
|
||||||
|
if f.group.as_deref() != Some(group.as_str()) {
|
||||||
|
ctx.db.unmanage(&id, f.group.as_deref()).await?;
|
||||||
|
moved += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(moved)
|
||||||
|
}
|
||||||
|
|
||||||
/// A feed that answered from a new address after permanent redirects moves there in the
|
/// A feed that answered from a new address after permanent redirects moves there in the
|
||||||
/// catalogue, so it is read from there and no longer redirected every time. One an OPML lists
|
/// catalogue, so it is read from there and no longer redirected every time. One an OPML lists
|
||||||
/// is left alone, since the OPML would only put the old address back; so is a move onto an
|
/// is left alone, since the OPML would only put the old address back; so is a move onto an
|
||||||
@@ -594,6 +611,42 @@ mod tests {
|
|||||||
assert!(ctx.db.feed_summary("has-file").await.unwrap().orphaned, "and flagged as orphaned");
|
assert!(ctx.db.feed_summary("has-file").await.unwrap().orphaned, "and flagged as orphaned");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_show_made_a_feed_of_its_own_is_not_hidden_with_its_list() {
|
||||||
|
// Unsung: promoted out of a Hacker News OPML hidden from All Subscriptions, its unread
|
||||||
|
// counted in the feed list and missing from All Subscriptions (#358).
|
||||||
|
let mut cfg = config::Config::default();
|
||||||
|
cfg.feeds.insert("opml".into(), feed());
|
||||||
|
let ctx = test_ctx(cfg).await;
|
||||||
|
ctx.db
|
||||||
|
.exec_for_test(
|
||||||
|
"INSERT INTO users (id, name, is_admin) VALUES (1,'a',true);
|
||||||
|
INSERT INTO feeds (id, url) VALUES ('opml','u0');
|
||||||
|
INSERT INTO subscriptions (user_id, feed_id, kept) VALUES (1,'opml',false), (1,'show',true), (1,'old',true);
|
||||||
|
INSERT INTO entries (feed_id, guid, first_seen) VALUES ('show','1',1), ('old','2',2);",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
ctx.db.upsert_managed("show", "http://x/s.xml", "Show", "opml").await.unwrap();
|
||||||
|
ctx.db.upsert_managed("old", "http://x/o.xml", "Old", "opml").await.unwrap();
|
||||||
|
ctx.db.set_hide_from_all(1, "opml", true).await.unwrap();
|
||||||
|
let unread = async || ctx.db.count_of(1, None, db::Filter::Unread, None).await.unwrap();
|
||||||
|
assert_eq!(unread().await, 0, "the list's shows are hidden with it");
|
||||||
|
|
||||||
|
let show = ctx.db.managed_feeds().await.unwrap().into_iter().find(|m| m.id == "show").unwrap();
|
||||||
|
assert!(promote_kept(&ctx, &show, None).await.unwrap());
|
||||||
|
assert_eq!(unread().await, 1, "a feed of its own is in no list");
|
||||||
|
|
||||||
|
// Promoted before the fix: unmanaged, still in the group, and in no group in config.
|
||||||
|
ctx.db.exec_for_test("UPDATE feeds SET managed = false WHERE id = 'old'").await.unwrap();
|
||||||
|
let mut cfg = ctx.edit_cfg().await;
|
||||||
|
cfg.feeds.insert("old".into(), feed());
|
||||||
|
ctx.store_cfg(cfg).await.unwrap();
|
||||||
|
assert_eq!(regroup_unmanaged(&ctx).await.unwrap(), 1);
|
||||||
|
assert_eq!(unread().await, 2);
|
||||||
|
assert_eq!(regroup_unmanaged(&ctx).await.unwrap(), 0, "nothing left to move");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() {
|
async fn a_stranded_group_is_retired_but_a_promoted_feed_keeps_its_entries() {
|
||||||
// davewiner: the OPML left config before retire_group existed, and 11 of its feeds
|
// davewiner: the OPML left config before retire_group existed, and 11 of its feeds
|
||||||
|
|||||||
@@ -90,6 +90,13 @@ pub(crate) async fn daemon(
|
|||||||
Ok(n) => tracing::info!(feeds = n, "retired feeds whose OPML is no longer in config"),
|
Ok(n) => tracing::info!(feeds = n, "retired feeds whose OPML is no longer in config"),
|
||||||
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not retire feeds whose OPML is no longer in config"),
|
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not retire feeds whose OPML is no longer in config"),
|
||||||
}
|
}
|
||||||
|
match catalogue::regroup_unmanaged(&ctx).await {
|
||||||
|
Ok(0) => {}
|
||||||
|
Ok(n) => tracing::info!(feeds = n, "took feeds of their own out of their old list's group"),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "could not take feeds of their own out of their old list's group")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Room for any one request's commands: a web request waits inside itself for a free place,
|
// Room for any one request's commands: a web request waits inside itself for a free place,
|
||||||
// and Download latest sends up to 100. With 64 the rest waited on downloads an hour long, and
|
// and Download latest sends up to 100. With 64 the rest waited on downloads an hour long, and
|
||||||
|
|||||||
@@ -2420,12 +2420,23 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stops treating a feed as derived, because it now has its own config entry.
|
/// Stops treating a feed as derived, because it now has its own config entry, in `group` as
|
||||||
pub async fn unmanage(&self, id: &str) -> Result<()> {
|
/// that entry says. Left in its list's group, a feed of its own was hidden with the list (#358).
|
||||||
self.exec("UPDATE feeds SET managed = false WHERE id = $1", vec![id.into()]).await?;
|
pub async fn unmanage(&self, id: &str, group: Option<&str>) -> Result<()> {
|
||||||
|
self.exec("UPDATE feeds SET managed = false, group_id = $2 WHERE id = $1", vec![id.into(), group.into()])
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Feeds not derived any more that the database still puts in a group, with that group.
|
||||||
|
pub async fn unmanaged_in_groups(&self) -> Result<Vec<(String, String)>> {
|
||||||
|
self.rows("SELECT id, group_id FROM feeds WHERE NOT coalesce(managed, false) AND group_id IS NOT NULL", vec![])
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|r| Ok((r.try_get("", "id")?, r.try_get("", "group_id")?)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Empties a feed of its items, leaving its files alone.
|
/// Empties a feed of its items, leaving its files alone.
|
||||||
pub async fn clear_entries(&self, feed_id: &str) -> Result<()> {
|
pub async fn clear_entries(&self, feed_id: &str) -> Result<()> {
|
||||||
entries::Entity::delete_many().filter(entries::Column::FeedId.eq(feed_id)).exec(&self.orm).await?;
|
entries::Entity::delete_many().filter(entries::Column::FeedId.eq(feed_id)).exec(&self.orm).await?;
|
||||||
|
|||||||
@@ -1884,7 +1884,7 @@ async fn patch_feed(
|
|||||||
let found =
|
let found =
|
||||||
subs.iter().find(|s| s.id == id).ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
|
subs.iter().find(|s| s.id == id).ok_or_else(|| ApiError::bad_request(format!("no feed with id {id:?}")))?;
|
||||||
cfg.feeds.insert(id.clone(), found.cfg.clone());
|
cfg.feeds.insert(id.clone(), found.cfg.clone());
|
||||||
state.ctx.db.unmanage(&id).await?;
|
state.ctx.db.unmanage(&id, found.cfg.group.as_deref()).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let checked = match &body.url {
|
let checked = match &body.url {
|
||||||
|
|||||||
Reference in New Issue
Block a user