From b293e6f7ed283613b9dbbe8debe7e29ce6b66299 Mon Sep 17 00:00:00 2001
From: Erik Brinkman
Date: Wed, 21 Oct 2026 08:33:06 -0400
Subject: [PATCH] feat: better filters
---
PLAN.md | 78 +-
.../cc.hafa.reader.data.ReaderDatabase/8.json | 397 +++
.../kotlin/cc/hafa/reader/data/Database.kt | 25 +-
.../cc/hafa/reader/data/RoomReaderStore.kt | 4 +
.../kotlin/cc/hafa/reader/ui/ArticleScreen.kt | 109 +-
.../kotlin/cc/hafa/reader/ui/Components.kt | 53 +-
.../kotlin/cc/hafa/reader/ui/FilterRows.kt | 115 +
.../cc/hafa/reader/ui/SettingsScreen.kt | 32 +-
android/app/src/main/res/values/strings.xml | 5 +-
.../main/kotlin/cc/hafa/reader/core/Feed.kt | 31 +-
.../main/kotlin/cc/hafa/reader/core/Filter.kt | 130 +-
.../main/kotlin/cc/hafa/reader/core/Model.kt | 4 +-
.../main/kotlin/cc/hafa/reader/core/Names.kt | 19 +
.../kotlin/cc/hafa/reader/core/ReaderModel.kt | 32 +-
.../kotlin/cc/hafa/reader/core/ReaderView.kt | 83 +
.../kotlin/cc/hafa/reader/core/Sections.kt | 254 ++
.../cc/hafa/reader/core/sync/Documents.kt | 12 +-
.../kotlin/cc/hafa/reader/core/FeedTest.kt | 9 +-
.../cc/hafa/reader/core/FiltersFixtureTest.kt | 117 +
.../cc/hafa/reader/core/ReaderModelTest.kt | 24 +
.../cc/hafa/reader/core/SharedFixtureTest.kt | 2 +
.../cc/hafa/reader/core/sync/DocumentsTest.kt | 10 +-
firebase/firestore.rules | 11 +-
shared/fixtures/atom.xml | 2 +
shared/fixtures/filters.json | 2563 +++++++++++++++++
shared/fixtures/parse.json | 101 +
shared/fixtures/rss.xml | 3 +
shared/src/feed.test.ts | 10 +-
shared/src/feed.ts | 50 +-
shared/src/filter.ts | 197 +-
shared/src/filters-fixture.test.ts | 96 +
shared/src/names.ts | 28 +
shared/src/sections.ts | 367 +++
web/src/app.css | 36 +
web/src/lib/components/article.svelte | 108 +-
web/src/lib/components/filter-popover.svelte | 68 +
web/src/lib/components/filter-rows.svelte | 85 +
web/src/lib/components/filter-sheet.svelte | 129 +
web/src/lib/components/settings-pane.svelte | 31 +-
web/src/lib/components/topic-chip.svelte | 16 +-
web/src/lib/copy.ts | 11 +-
web/src/lib/db.test.ts | 30 +-
web/src/lib/db.ts | 19 +-
web/src/lib/gestures.ts | 8 +
web/src/lib/reader.svelte.ts | 97 +-
web/src/lib/reader.test.ts | 39 +
web/src/lib/sync/documents.test.ts | 10 +-
web/src/lib/sync/documents.ts | 12 +
web/src/lib/sync/fixtures.ts | 2 +
web/src/lib/sync/merge.ts | 1 +
web/src/lib/types.ts | 4 +
web/src/routes/(policy)/privacy/+page.svelte | 15 +-
52 files changed, 5486 insertions(+), 208 deletions(-)
create mode 100644 android/app/schemas/cc.hafa.reader.data.ReaderDatabase/8.json
create mode 100644 android/app/src/main/kotlin/cc/hafa/reader/ui/FilterRows.kt
create mode 100644 android/core/src/main/kotlin/cc/hafa/reader/core/Names.kt
create mode 100644 android/core/src/main/kotlin/cc/hafa/reader/core/Sections.kt
create mode 100644 android/core/src/test/kotlin/cc/hafa/reader/core/FiltersFixtureTest.kt
create mode 100644 shared/fixtures/filters.json
create mode 100644 shared/src/filters-fixture.test.ts
create mode 100644 shared/src/names.ts
create mode 100644 shared/src/sections.ts
create mode 100644 web/src/lib/components/filter-popover.svelte
create mode 100644 web/src/lib/components/filter-rows.svelte
create mode 100644 web/src/lib/components/filter-sheet.svelte
diff --git a/PLAN.md b/PLAN.md
index a054cf3..97329ac 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -100,12 +100,20 @@ All paths are Firestore. `{uid}` is the Firebase Auth id.
account with the next change the person makes.
- `users/{uid}/entries/{entryId}`: one per article the user has received.
Fields: `feedId`, `guid`, `title`, `published`, `dated`, `url`, `author`,
- `imageUrl`, `excerpt`, `summary`, `categories`, `bodyHash`, `read`, `starred`,
+ `imageUrl`, `excerpt`, `summary`, `categories`, `categorySchemes` (the
+ scheme of each category at the same index, empty for none), `sourceUrl`
+ (RSS ``, empty for none), `bodyHash`, `read`, `starred`,
`addedAt`, `updatedAt`, and `expireAt` only under a finite retention and
- when not starred.
+ when not starred. Older clients write neither `categorySchemes` nor
+ `sourceUrl` (the rules allow them, not require them) and read without them
+ as empty; an entry an older client writes whole loses them, and its
+ section then comes from its link.
`entryId` = sha256(`feedId` + `guid`), so two devices adding the same article
write the same document.
-- `users/{uid}/settings/{viewKey}`: one view's sort, words and topics, with
+- `users/{uid}/settings/{viewKey}`: one view's sort, words, topics and
+ sections (turned-off sections ride in `offTopics` as `section:{place}`, so
+ older clients, which add and remove only topics by name and hide an article
+ only when all its topics are off, keep them and are not affected), with
`updatedAt` and a `deleted` tombstone flag; the id is the URL-encoded view key
(`all`, `starred`, `folder:{name}`, `feed:{id}`), which the document also
holds as `key`.
@@ -296,7 +304,7 @@ decides). Web with the extension closed gets nothing until a device opens.
settings, the same panel everywhere, stored in the local database (`settings`
store) and synced like entries. The panel opens where the article does,
and as a full page on a phone. It holds sort (Newest or Oldest), words and
- topics (Starred has only the sort); the unread-only switch is in the list's
+ filters (Starred has only the sort); the unread-only switch is in the list's
bar, not here. A feed's panel also has its group and Unsubscribe. The panel
also holds the reading font, which is one choice for the whole app (see below).
- **Words and topics** (`shared/src/filter.ts`): type a word or phrase and press
@@ -306,16 +314,58 @@ decides). Web with the extension closed gets nothing until a device opens.
whitespace matches any whitespace, a word boundary is required at an end only
when that end is a word character, and each list is compiled once. Every topic
the feed supplies starts on; tap one to turn it off, and an article whose
- topics are all off is hidden. Every list of topics or words (the panel's
- words and topics, and an article's topics, on Starred too) is ordered by how
- many of the view's articles carry or match each (most first, then by name; an
- article lists each of its topics once), wrapping into up to three rows; when
- they need more, they go in three rows that scroll sideways together, each
- chip in turn at the end of the row that is shortest so far (the upper on a
- tie), so the rows are packed with no columns. Chips never move when toggled. On a phone each topic chip, and
+ topics are all off is hidden. The panel's words, and the topics on each
+ article's card in the list, are ordered by how many of the view's articles
+ carry or match each (most first, then by name; an article lists each of its
+ topics once), wrapping into up to three rows; when they need more, they go
+ in three rows that scroll sideways together, each chip in turn at the end of
+ the row that is shortest so far (the upper on a tie), so the rows are packed
+ with no columns. Chips never move when toggled. On a phone each chip, and
each word's remove button, takes taps 44 pixels tall (48 dp on Android)
- around its smaller outline; both lists use the same chip. A feed's own
- settings also apply in every group and view it appears in.
+ around its smaller outline. A feed's own settings also apply in every group
+ and view it appears in.
+- **Filters** (`shared/src/sections.ts`, `topicGroups` in `shared/src/filter.ts`,
+ checked by `shared/fixtures/filters.json`): the panel's "Filters" area, and
+ an open article's filter button, show rows of chips: the sections first
+ (each led by a link icon), then one row per group of topics; each row is a
+ single line that scrolls sideways, its ends fading where more chips lie. The
+ panel's rows cover the view's articles with counts; an article's cover only
+ it, without counts.
+ - *Topic groups:* categories are grouped by RSS `` / Atom
+ ``; no scheme (and JSON Feed tags) is the plain group, as
+ is a scheme the view's articles use for only one distinct topic. A topic
+ under two schemes appears once, in the group covering more articles (then
+ the later scheme in code-unit order). Groups are unnamed and ordered by how
+ many articles they cover (then by scheme); chips by count, then name. Every
+ topic is kept, one on a single article too.
+ - *Name order:* wherever topics or sections go by name, they follow
+ `compareNames` (`shared/src/names.ts`, ported to Kotlin): accents and
+ capitals ignored, then capitals ignored, then as written, each in UTF-16
+ code-unit order, so every platform orders them alike (a locale's collation
+ orders punctuation such as `7-1` and `7.1` differently from one engine to
+ the next).
+ - *Sections:* an article's address is its `` when the feed gives
+ one, else its link; its site is the host without a leading `www.`. Worked
+ out when shown, per feed from the feed's stored articles in the view: when
+ they span more than one site, each article's only section is its site.
+ Otherwise sections come from the path: the last part, parts with a digit
+ and English month names and abbreviations are dropped (parts are
+ lower-cased and percent-decoded first); a part counts only when two of the
+ feed's articles have it at that place under the same parents; leading
+ parts every article shares are skipped; each level that splits the
+ articles gives a chip (`us`, `us/politics`). In the panel a section is
+ followed by those under it. Turning one off stores its full place
+ (`nytimes.com/us/politics`) in `offTopics` as `section:{place}`, and hides
+ every article whose own place (site, then all its kept path parts) is that
+ place or lies under it. Turning a section off clears stored sections under
+ it; tapping an off section whose parent is off turns the parent back on.
+ - *Article filters:* the article's bar has a filter button (hidden when the
+ article has no chips, and in Starred), with an accent dot while any of its
+ chips is off. On a phone it opens a sheet over the dimmed article (a
+ handle, the filter icon and "Filters", then the rows), closed by dragging
+ it down, tapping outside it, Escape or (Android) Back; on a wide screen a
+ box below the button, closed by clicking outside it or Escape. Toggling
+ there holds the list as toggling a topic from an article does.
- **Managing feeds and groups:** a feed's panel has its name (rename), a group
field (type a name, with the existing groups suggested; empty for no group) and
Unsubscribe; a group's
@@ -496,7 +546,7 @@ decides). Web with the extension closed gets nothing until a device opens.
are checked, still when the system or browser turns motion off; the view's name,
cut short with an ellipsis when long; and a small chevron), unread only, view settings
and mark all as read (dimmed and off, for screen readers too, while the view has
- nothing unread, empty or not; `Shift A` then does nothing); the article has Back, star,
+ nothing unread, empty or not; `Shift A` then does nothing); the article has Back, filters (when it has any), star,
read, share and open the original, which on a phone appear only there; view settings,
add feed and sync have Back and the screen's name. On Android the bars slide away while
scrolling down and come back on the way up, on every new screen or article, and
diff --git a/android/app/schemas/cc.hafa.reader.data.ReaderDatabase/8.json b/android/app/schemas/cc.hafa.reader.data.ReaderDatabase/8.json
new file mode 100644
index 0000000..e261bb5
--- /dev/null
+++ b/android/app/schemas/cc.hafa.reader.data.ReaderDatabase/8.json
@@ -0,0 +1,397 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 8,
+ "identityHash": "ccac3789544cd63fbf5957dee47a35f7",
+ "entities": [
+ {
+ "tableName": "feeds",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `siteUrl` TEXT NOT NULL, `icon` TEXT NOT NULL, `renamed` INTEGER NOT NULL, `folder` TEXT NOT NULL, `error` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `etag` TEXT NOT NULL, `lastModified` TEXT NOT NULL, `alpha` REAL NOT NULL, `beta` REAL NOT NULL, `capacity` INTEGER NOT NULL, `pattern` TEXT, `nextFetch` INTEGER NOT NULL, `lastChecked` INTEGER NOT NULL, `hash` TEXT NOT NULL, `guids` TEXT NOT NULL, `details` TEXT, `failures` INTEGER NOT NULL, `problem` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "siteUrl",
+ "columnName": "siteUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "icon",
+ "columnName": "icon",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "renamed",
+ "columnName": "renamed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "folder",
+ "columnName": "folder",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "error",
+ "columnName": "error",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "etag",
+ "columnName": "etag",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastModified",
+ "columnName": "lastModified",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "alpha",
+ "columnName": "alpha",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "beta",
+ "columnName": "beta",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "capacity",
+ "columnName": "capacity",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pattern",
+ "columnName": "pattern",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "nextFetch",
+ "columnName": "nextFetch",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastChecked",
+ "columnName": "lastChecked",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hash",
+ "columnName": "hash",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "guids",
+ "columnName": "guids",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "details",
+ "columnName": "details",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "failures",
+ "columnName": "failures",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "problem",
+ "columnName": "problem",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "entries",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `feedId` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `url` TEXT NOT NULL, `published` INTEGER NOT NULL, `dated` INTEGER NOT NULL, `author` TEXT NOT NULL, `imageUrl` TEXT NOT NULL, `excerpt` TEXT NOT NULL, `summary` TEXT NOT NULL, `categories` TEXT NOT NULL, `read` INTEGER NOT NULL, `starred` INTEGER NOT NULL, `addedAt` INTEGER NOT NULL, `position` INTEGER NOT NULL DEFAULT 0, `bodyHash` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `categorySchemes` TEXT NOT NULL DEFAULT '[]', `sourceUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "feedId",
+ "columnName": "feedId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "guid",
+ "columnName": "guid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "url",
+ "columnName": "url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "published",
+ "columnName": "published",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dated",
+ "columnName": "dated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "author",
+ "columnName": "author",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "excerpt",
+ "columnName": "excerpt",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "summary",
+ "columnName": "summary",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "categories",
+ "columnName": "categories",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "starred",
+ "columnName": "starred",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "addedAt",
+ "columnName": "addedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "bodyHash",
+ "columnName": "bodyHash",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "categorySchemes",
+ "columnName": "categorySchemes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "sourceUrl",
+ "columnName": "sourceUrl",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_entries_feedId",
+ "unique": false,
+ "columnNames": [
+ "feedId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_entries_feedId` ON `${TABLE_NAME}` (`feedId`)"
+ }
+ ]
+ },
+ {
+ "tableName": "bodies",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`entryId` TEXT NOT NULL, `html` TEXT NOT NULL, PRIMARY KEY(`entryId`))",
+ "fields": [
+ {
+ "fieldPath": "entryId",
+ "columnName": "entryId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "html",
+ "columnName": "html",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "entryId"
+ ]
+ }
+ },
+ {
+ "tableName": "settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `sort` TEXT NOT NULL, `hideWords` TEXT NOT NULL, `onlyWords` TEXT NOT NULL, `offTopics` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+ "fields": [
+ {
+ "fieldPath": "key",
+ "columnName": "key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sort",
+ "columnName": "sort",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hideWords",
+ "columnName": "hideWords",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "onlyWords",
+ "columnName": "onlyWords",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "offTopics",
+ "columnName": "offTopics",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updatedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "key"
+ ]
+ }
+ },
+ {
+ "tableName": "tombstones",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `removedAt` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+ "fields": [
+ {
+ "fieldPath": "key",
+ "columnName": "key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "removedAt",
+ "columnName": "removedAt",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "key"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ccac3789544cd63fbf5957dee47a35f7')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/cc/hafa/reader/data/Database.kt b/android/app/src/main/kotlin/cc/hafa/reader/data/Database.kt
index d7d8359..a5d89c6 100644
--- a/android/app/src/main/kotlin/cc/hafa/reader/data/Database.kt
+++ b/android/app/src/main/kotlin/cc/hafa/reader/data/Database.kt
@@ -15,6 +15,7 @@ import androidx.room.TypeConverter
import androidx.room.TypeConverters
import androidx.room.Upsert
import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
import cc.hafa.reader.core.PostingPattern
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
@@ -110,6 +111,12 @@ data class EntryRecord(
val bodyHash: String,
/** When the read or starred mark last changed. */
val updatedAt: Long,
+ /** The scheme of each topic at the same index, as JSON; empty for none. */
+ @ColumnInfo(defaultValue = "[]")
+ val categorySchemes: List,
+ /** Address of the story's source; empty for none. */
+ @ColumnInfo(defaultValue = "")
+ val sourceUrl: String,
)
/** An article's body, kept apart so lists load without it. */
@@ -304,7 +311,7 @@ interface ReaderDao {
/** The reader's local database. */
@Database(
entities = [FeedRecord::class, EntryRecord::class, BodyRecord::class, SettingsRecord::class, TombstoneRecord::class],
- version = 7,
+ version = 8,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -322,10 +329,18 @@ abstract class ReaderDatabase : RoomDatabase() {
.build()
/**
- * Steps from one database version to the next; none yet. To change the
- * tables, raise `version`, build to export the new schema into
- * `app/schemas`, and add a [Migration] from the old version here.
+ * Steps from one database version to the next. To change the tables,
+ * raise `version`, build to export the new schema into `app/schemas`,
+ * and add a [Migration] from the old version here.
*/
- val MIGRATIONS: Array = emptyArray()
+ val MIGRATIONS: Array = arrayOf(
+ // entries gain their topics' schemes and their source's address
+ object : Migration(7, 8) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE `entries` ADD COLUMN `categorySchemes` TEXT NOT NULL DEFAULT '[]'")
+ db.execSQL("ALTER TABLE `entries` ADD COLUMN `sourceUrl` TEXT NOT NULL DEFAULT ''")
+ }
+ },
+ )
}
}
diff --git a/android/app/src/main/kotlin/cc/hafa/reader/data/RoomReaderStore.kt b/android/app/src/main/kotlin/cc/hafa/reader/data/RoomReaderStore.kt
index 04e0b16..6350894 100644
--- a/android/app/src/main/kotlin/cc/hafa/reader/data/RoomReaderStore.kt
+++ b/android/app/src/main/kotlin/cc/hafa/reader/data/RoomReaderStore.kt
@@ -81,6 +81,8 @@ private fun Entry.toRecord() = EntryRecord(
position = position,
bodyHash = bodyHash,
updatedAt = updatedAt,
+ categorySchemes = categorySchemes,
+ sourceUrl = sourceUrl,
)
private fun EntryRecord.toEntry() = Entry(
@@ -102,6 +104,8 @@ private fun EntryRecord.toEntry() = Entry(
position = position,
bodyHash = bodyHash,
updatedAt = updatedAt,
+ categorySchemes = categorySchemes,
+ sourceUrl = sourceUrl,
)
/** The reader's storage in its Room database. */
diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/ArticleScreen.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/ArticleScreen.kt
index 1ca8c5e..84740b1 100644
--- a/android/app/src/main/kotlin/cc/hafa/reader/ui/ArticleScreen.kt
+++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/ArticleScreen.kt
@@ -35,11 +35,13 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.automirrored.outlined.Undo
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.outlined.Check
+import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.outlined.StarOutline
import androidx.compose.material3.BottomAppBarDefaults
@@ -47,21 +49,26 @@ import androidx.compose.material3.BottomAppBarScrollBehavior
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
-import androidx.compose.runtime.rememberUpdatedState
-import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
@@ -69,6 +76,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
@@ -85,10 +93,11 @@ import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cc.hafa.reader.R
+import cc.hafa.reader.core.ArticleFilters
import cc.hafa.reader.core.Entry
-import cc.hafa.reader.core.Selection
-import cc.hafa.reader.core.articleTopics
+import cc.hafa.reader.core.ViewSettings
import cc.hafa.reader.core.decodeHtmlEntities
+import cc.hafa.reader.core.sectionOff
import cc.hafa.reader.core.showsLeadImage
import cc.hafa.reader.core.topicOff
import coil3.SingletonImageLoader
@@ -169,6 +178,10 @@ fun ArticleScreen(snapshot: Snapshot, viewModel: ReaderViewModel, leaving: Boole
val scrollBehavior = BottomAppBarDefaults.exitAlwaysScrollBehavior()
val pull = rememberEdgeDrag(Orientation.Vertical, fromEdgeOnly = true) { towards -> close(towards) }
val sideways = rememberEdgeDrag(Orientation.Horizontal, fromEdgeOnly = false) { towards -> close(towards) }
+ val filters = remember(entry, view.filters) { entry?.let(view.filters::of) }
+ var filtersOpen by remember { mutableStateOf(false) }
+ // Another article closes the sheet the last one had open.
+ LaunchedEffect(entry?.id) { filtersOpen = false }
// Outside the bar's own handling, so a pull past the end does not count as scrolling that hides the bar.
Box(Modifier.fillMaxSize().nestedScroll(pull)) {
ReaderScaffold(
@@ -177,6 +190,9 @@ fun ArticleScreen(snapshot: Snapshot, viewModel: ReaderViewModel, leaving: Boole
BackButton { close(Offset.Zero) }
Spacer(Modifier.weight(1f))
if (entry != null) {
+ if (filters != null && !filters.isEmpty) {
+ FilterButton(off = filters.anyOff(view.viewSettings), open = filtersOpen) { filtersOpen = true }
+ }
ArticleBarActions(entry, state.shownRead(entry), viewModel)
}
}
@@ -202,6 +218,72 @@ fun ArticleScreen(snapshot: Snapshot, viewModel: ReaderViewModel, leaving: Boole
}
}
}
+ if (filtersOpen && filters != null && !filters.isEmpty) {
+ FilterSheet(filters, view.viewSettings, viewModel) { filtersOpen = false }
+ }
+}
+
+/** The bottom bar's filter button, with a dot while any of the article's chips is off; tinted while its sheet is [open]. */
+@Composable
+private fun FilterButton(off: Boolean, open: Boolean, onClick: () -> Unit) {
+ val colors = ReaderTheme.colors
+ IconButton(
+ onClick = onClick,
+ colors = IconButtonDefaults.iconButtonColors(
+ containerColor = if (open) colors.accentSoft else Color.Transparent,
+ contentColor = if (open) colors.accent else colors.muted,
+ ),
+ ) {
+ WithDueDot(off) {
+ Icon(
+ Icons.Outlined.FilterList,
+ contentDescription = stringResource(if (off) R.string.filters_some_off else R.string.filters),
+ )
+ }
+ }
+}
+
+/**
+ * The open article's filters in a sheet over the dimmed article: its sections, then one row of topics per group.
+ * Dragging it down, tapping outside it or Back closes it through [onDismiss].
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun FilterSheet(filters: ArticleFilters, settings: ViewSettings, viewModel: ReaderViewModel, onDismiss: () -> Unit) {
+ val colors = ReaderTheme.colors
+ val model = viewModel.model
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ containerColor = colors.panel,
+ ) {
+ Column(Modifier.padding(start = 20.dp, end = 20.dp, bottom = 20.dp)) {
+ Row(
+ Modifier.padding(bottom = 8.dp).semantics { heading() },
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Icon(Icons.Outlined.FilterList, contentDescription = null, tint = colors.muted)
+ Text(stringResource(R.string.filters), fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
+ }
+ Column(Modifier.verticalScroll(rememberScrollState())) {
+ FilterRows(
+ sections = filters.sections.map { section ->
+ FilterChipItem(section.place, section.label, null, sectionOff(settings.offTopics, section.place)) {
+ model.toggleSection(section.place, fromArticle = true)
+ }
+ },
+ topics = filters.topics.map { row ->
+ row.map { topic ->
+ FilterChipItem(topic.trim().lowercase(), topic, null, topicOff(settings, topic)) {
+ model.toggleTopic(topic, fromArticle = true)
+ }
+ }
+ },
+ )
+ }
+ }
+ }
}
/**
@@ -368,25 +450,6 @@ private fun ArticlePage(
}
}
items(blocks) { block -> BodyBlock(block) }
- // The actions and topics wait for the body, so they do not show under the title and then jump down.
- if (split != null && entry.categories.isNotEmpty()) {
- item(key = "categories") {
- val topics = remember(entry.categories, view.topicOrder) { articleTopics(entry.categories, view.topicOrder) }
- // Starred has no topic filter, so there the topics are plain labels.
- if (state.selection == Selection.Starred) {
- ChipRows(topics) { topic -> TopicLabel(topic) }
- } else {
- // The chips read the view's settings, which change at once; only the list waits for a refresh.
- ChipRows(topics) { topic ->
- TopicChip(
- topic,
- off = topicOff(view.viewSettings, topic),
- onToggle = { viewModel.model.toggleTopic(topic, fromArticle = true) },
- )
- }
- }
- }
- }
}
}
}
diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/Components.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/Components.kt
index 3cd32d3..c347c05 100644
--- a/android/app/src/main/kotlin/cc/hafa/reader/ui/Components.kt
+++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/Components.kt
@@ -5,48 +5,48 @@ import androidx.compose.animation.core.animate
import androidx.compose.animation.core.spring
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
-import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ExperimentalLayoutApi
-import androidx.compose.foundation.layout.asPaddingValues
-import androidx.compose.foundation.layout.calculateEndPadding
-import androidx.compose.foundation.layout.calculateStartPadding
-import androidx.compose.foundation.layout.isImeVisible
-import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.safeDrawing
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
-import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.isImeVisible
+import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.union
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.union
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsTopHeight
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material.icons.outlined.Link
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.BottomAppBarDefaults
import androidx.compose.material3.BottomAppBarScrollBehavior
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.IconToggleButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
@@ -389,15 +389,20 @@ fun ConfirmButton(label: String, confirmLabel: String, confirming: Boolean, onCl
}
/**
- * A topic as a filter chip, struck through while [off]; [count] shows after it when given. The one chip for topics in
- * the settings and under an article; Material keeps its touch area at least 48dp tall.
+ * A topic or, led by a link icon, a [section] as a filter chip, struck through while [off]; [count] shows after it
+ * when given. The one chip of the filter rows; Material keeps its touch area at least 48dp tall.
*/
@Composable
-fun TopicChip(topic: String, off: Boolean, onToggle: () -> Unit, count: Int? = null) {
+fun TopicChip(topic: String, off: Boolean, onToggle: () -> Unit, count: Int? = null, section: Boolean = false) {
val colors = ReaderTheme.colors
FilterChip(
selected = !off,
onClick = onToggle,
+ leadingIcon = if (section) {
+ { Icon(Icons.Outlined.Link, contentDescription = null, modifier = Modifier.size(14.dp), tint = colors.muted) }
+ } else {
+ null
+ },
label = {
Text(
topic,
@@ -418,18 +423,6 @@ fun TopicChip(topic: String, off: Boolean, onToggle: () -> Unit, count: Int? = n
)
}
-/** A topic in a chip's outline that does nothing when pressed, for views without a topic filter. */
-@Composable
-fun TopicLabel(topic: String) {
- val colors = ReaderTheme.colors
- Box(
- Modifier.heightIn(min = 32.dp).border(1.dp, colors.line, FilterChipDefaults.shape).padding(horizontal = 12.dp),
- contentAlignment = Alignment.Center,
- ) {
- Text(topic, style = MaterialTheme.typography.labelLarge, color = colors.text)
- }
-}
-
/** A row of choices of which exactly one is on, each at least 48dp tall. */
@Composable
fun Segmented(options: List>, value: Value, onChange: (Value) -> Unit, modifier: Modifier = Modifier) {
diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/FilterRows.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/FilterRows.kt
new file mode 100644
index 0000000..293f9cd
--- /dev/null
+++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/FilterRows.kt
@@ -0,0 +1,115 @@
+package cc.hafa.reader.ui
+
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.key
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.CompositingStrategy
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.isTraversalGroup
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import cc.hafa.reader.R
+
+/** One chip of a filter row: what it shows, how many articles it covers when counted, and whether it is off. */
+data class FilterChipItem(
+ /** Stable key telling the chip apart. */
+ val key: String,
+ /** The words it shows. */
+ val label: String,
+ /** Articles it covers; not shown when null. */
+ val count: Int?,
+ /** Whether it is turned off. */
+ val off: Boolean,
+ /** Turns it off or back on. */
+ val onToggle: () -> Unit,
+)
+
+// How far a row's ends fade where more chips lie past them.
+private val FADE = 28.dp
+
+/** Widen what this wraps by [edge] on each side, so it runs to the edges of a box padded that much. */
+private fun Modifier.bleed(edge: Dp): Modifier =
+ layout { measurable, constraints ->
+ val extra = edge.roundToPx() * 2
+ val placeable = measurable.measure(
+ constraints.copy(minWidth = constraints.minWidth + extra, maxWidth = constraints.maxWidth + extra),
+ )
+ layout(placeable.width - extra, placeable.height) { placeable.place(-extra / 2, 0) }
+ }
+
+/** Fade the ends of a sideways-scrolling row where more lies past them. */
+private fun Modifier.edgeFades(scroll: ScrollState): Modifier =
+ graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
+ .drawWithContent {
+ drawContent()
+ val fade = FADE.toPx()
+ if (scroll.value > 0) {
+ drawRect(
+ Brush.horizontalGradient(listOf(Color.Transparent, Color.Black), startX = 0f, endX = fade),
+ blendMode = BlendMode.DstIn,
+ )
+ }
+ if (scroll.value < scroll.maxValue) {
+ drawRect(
+ Brush.horizontalGradient(listOf(Color.Black, Color.Transparent), startX = size.width - fade, endX = size.width),
+ blendMode = BlendMode.DstIn,
+ )
+ }
+ }
+
+@Composable
+private fun FilterRow(label: String, chips: List, section: Boolean, edge: Dp) {
+ val scroll = rememberScrollState()
+ Row(
+ Modifier
+ .bleed(edge)
+ .fillMaxWidth()
+ .semantics {
+ contentDescription = label
+ isTraversalGroup = true
+ }
+ .edgeFades(scroll)
+ .horizontalScroll(scroll)
+ .padding(horizontal = edge),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ for (chip in chips) {
+ key(chip.key) {
+ TopicChip(chip.label, off = chip.off, onToggle = chip.onToggle, count = chip.count, section = section)
+ }
+ }
+ }
+}
+
+/**
+ * The filter chips of an article or a view: a row of [sections], then one row per group of [topics], each a single
+ * line that scrolls sideways and runs [edge] past the sides into its box's padding.
+ */
+@Composable
+fun FilterRows(sections: List, topics: List>, edge: Dp = 20.dp) {
+ Column {
+ if (sections.isNotEmpty()) {
+ FilterRow(stringResource(R.string.sections), sections, section = true, edge = edge)
+ }
+ val topicsLabel = stringResource(R.string.topics)
+ for (row in topics) {
+ FilterRow(topicsLabel, row, section = false, edge = edge)
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/cc/hafa/reader/ui/SettingsScreen.kt b/android/app/src/main/kotlin/cc/hafa/reader/ui/SettingsScreen.kt
index 6cdc367..afb3174 100644
--- a/android/app/src/main/kotlin/cc/hafa/reader/ui/SettingsScreen.kt
+++ b/android/app/src/main/kotlin/cc/hafa/reader/ui/SettingsScreen.kt
@@ -50,6 +50,7 @@ import cc.hafa.reader.core.Selection
import cc.hafa.reader.core.Sort
import cc.hafa.reader.core.byCount
import cc.hafa.reader.core.knownWordCounts
+import cc.hafa.reader.core.sectionOff
import cc.hafa.reader.core.topicOff
import cc.hafa.reader.core.wordCounts
import kotlinx.coroutines.Dispatchers
@@ -58,7 +59,7 @@ import kotlinx.coroutines.withContext
/** Which list of words the field adds to. */
private enum class WordMode { Hide, Only }
-/** The current view's settings: name, sort, words, topics, and the feed's group or the group's removal. */
+/** The current view's settings: name, sort, words, sections and topics, and the feed's group or the group's removal. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(snapshot: Snapshot, viewModel: ReaderViewModel) {
@@ -181,18 +182,25 @@ fun SettingsScreen(snapshot: Snapshot, viewModel: ReaderViewModel) {
}
}
- if (selection != Selection.Starred && view.topicOrder.counts.isNotEmpty()) {
+ val filters = view.filters
+ if (selection != Selection.Starred && (filters.sections.isNotEmpty() || filters.groupOrder.groups.isNotEmpty())) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
- Text(stringResource(R.string.topics), fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium)
- ChipRows(view.topicOrder.counts) { item ->
- TopicChip(
- item.topic,
- off = topicOff(settings, item.topic),
- onToggle = { model.toggleTopic(item.topic, fromArticle = false) },
- count = item.count,
- )
- }
- Text(stringResource(R.string.topics_hint), fontSize = 12.sp, lineHeight = 16.sp, color = colors.muted)
+ Text(stringResource(R.string.filters), fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Medium)
+ FilterRows(
+ sections = filters.sections.map { section ->
+ FilterChipItem(section.place, section.label, section.count, sectionOff(settings.offTopics, section.place)) {
+ model.toggleSection(section.place, fromArticle = false)
+ }
+ },
+ topics = filters.groupOrder.groups.map { group ->
+ group.map { item ->
+ FilterChipItem(item.topic.trim().lowercase(), item.topic, item.count, topicOff(settings, item.topic)) {
+ model.toggleTopic(item.topic, fromArticle = false)
+ }
+ }
+ },
+ )
+ Text(stringResource(R.string.filters_hint), fontSize = 12.sp, lineHeight = 16.sp, color = colors.muted)
}
}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index be2353c..3383ce0 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -49,6 +49,9 @@
Remove %1$s
Words match whole words in the title or summary, ignoring capitals and spacing.
Topics
+ Sections
+ Filters
+ Filters, some turned off
Already use Reader?
Sign in to sync your feeds
Keep synced articles
@@ -60,7 +63,7 @@
Refresh
Some feeds are due for a check
- Select a topic to turn it off. Articles with only turned-off topics are hidden.
+ Select a section or topic to turn it off. Articles in a turned-off section, or whose topics are all turned off, are hidden.
Name
Group
No group
diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/Feed.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/Feed.kt
index cac83da..f5ab381 100644
--- a/android/core/src/main/kotlin/cc/hafa/reader/core/Feed.kt
+++ b/android/core/src/main/kotlin/cc/hafa/reader/core/Feed.kt
@@ -34,6 +34,10 @@ data class FeedEntry(
val categories: List,
/** Article body as HTML, not yet sanitized. */
val html: String,
+ /** The scheme of each of [categories] (RSS `domain`, Atom `scheme`), at the same index; empty for none. */
+ val categorySchemes: List = categories.map { "" },
+ /** Address of the story's source (RSS ``); empty for none. */
+ val sourceUrl: String = "",
)
/** A feed's own details and its current entries. */
@@ -215,17 +219,25 @@ private fun decodeEntities(text: String): String =
.replace(HEX_PATTERN) { match -> codePointOr(match.groupValues[1].toLongOrNull(16), match.value) }
.replace(NAMED_PATTERN) { match -> NAMED_ENTITIES[match.groupValues[1].lowercase()] ?: match.value }
-private fun categoryNames(elements: List): List {
- val names = mutableListOf()
+/** An article's categories and the scheme of each, at the same index. */
+private data class Categories(val names: List, val schemes: List)
+
+/** Category names with their schemes, from the attribute that names the scheme; each pair once. */
+private fun categoryNames(elements: List, schemeAttribute: String): Categories {
+ val pairs = LinkedHashSet>()
for (element in elements) {
val name = displayText(attrOf(element, "term").trim().ifEmpty { textOf(element) })
- if (name.isNotEmpty() && name !in names) {
- names.add(name)
+ if (name.isNotEmpty()) {
+ pairs.add(name to jsTrim(attrOf(element, schemeAttribute)))
}
}
- return names
+ return Categories(pairs.map { (name, _) -> name }, pairs.map { (_, scheme) -> scheme })
}
+/** The address an RSS item's `` names; empty for none. */
+private fun sourceAddress(item: Element, base: String): String =
+ childrenNamed(item, "source").firstOrNull()?.let { source -> resolveWebAddress(jsTrim(attrOf(source, "url")), base) } ?: ""
+
/** The raw `href` of the alternate link among Atom links; empty if none. */
private fun atomHref(links: List): String {
val alternate = links
@@ -298,6 +310,7 @@ private fun parseRssItem(item: Element, base: String): FeedEntry {
item,
listOf("pubDate", "date", "published", "issued", "created", "updated", "modified"),
)
+ val categories = categoryNames(childrenNamed(item, "category"), "domain")
return FeedEntry(
guid = childText(item, "guid").ifEmpty { link }.ifEmpty { title },
title = displayText(title).ifEmpty { link },
@@ -306,8 +319,10 @@ private fun parseRssItem(item: Element, base: String): FeedEntry {
author = displayText(childText(item, "creator").ifEmpty { childText(item, "author") }),
imageUrl = leadImage(mediaImages(item, base), html, base),
summary = plainText(childText(item, "description")).ifEmpty { media.summary },
- categories = categoryNames(childrenNamed(item, "category")),
+ categories = categories.names,
html = html,
+ categorySchemes = categories.schemes,
+ sourceUrl = sourceAddress(item, base),
)
}
@@ -335,6 +350,7 @@ private fun parseAtomEntry(entry: Element, base: String): FeedEntry {
val html = atomContent(entry).ifEmpty { summary }.ifEmpty { media.html }
val published = firstDate(entry, listOf("published", "issued", "created", "updated", "modified"))
val author = childrenNamed(entry, "author").firstOrNull()
+ val categories = categoryNames(childrenNamed(entry, "category"), "scheme")
return FeedEntry(
guid = childText(entry, "id").ifEmpty { link }.ifEmpty { title },
title = displayText(title).ifEmpty { link },
@@ -343,8 +359,9 @@ private fun parseAtomEntry(entry: Element, base: String): FeedEntry {
author = if (author == null) "" else displayText(childText(author, "name")),
imageUrl = leadImage(mediaImages(entry, base), html, base),
summary = plainText(summary).ifEmpty { media.summary },
- categories = categoryNames(childrenNamed(entry, "category")),
+ categories = categories.names,
html = html,
+ categorySchemes = categories.schemes,
)
}
diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/Filter.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/Filter.kt
index 5927aa1..9bfa358 100644
--- a/android/core/src/main/kotlin/cc/hafa/reader/core/Filter.kt
+++ b/android/core/src/main/kotlin/cc/hafa/reader/core/Filter.kt
@@ -1,6 +1,5 @@
package cc.hafa.reader.core
-import java.text.Collator
import java.util.concurrent.ConcurrentHashMap
/** The details of an article that filters look at. */
@@ -13,6 +12,18 @@ interface FilterEntry {
/** Sections, topics and tags the feed lists for the article. */
val categories: List
+
+ /** The scheme of each of [categories] at the same index; empty for none. */
+ val categorySchemes: List
+ get() = emptyList()
+
+ /** Link to the original; sections come from it. */
+ val url: String
+ get() = ""
+
+ /** Address of the story's source, which sections come from instead; empty for none. */
+ val sourceUrl: String
+ get() = ""
}
/** Order of the article list. */
@@ -32,7 +43,11 @@ data class ViewSettings(
val hideWords: List = emptyList(),
/** When not empty, only articles whose title or summary has one of these stay. */
val onlyWords: List = emptyList(),
- /** Topics turned off; an article whose topics are all off is hidden. */
+ /**
+ * Topics turned off; an article whose topics are all off is hidden. Entries
+ * starting with [SECTION_PREFIX] hold turned-off sections' places instead,
+ * which hide every article in them.
+ */
val offTopics: List = emptyList(),
)
@@ -193,22 +208,20 @@ fun topicCounts(entries: List): List {
}
}
}
- val collator = Collator.getInstance()
- return counts.values.sortedWith { first, second -> compareCounted(collator, first.count, first.topic, second.count, second.topic) }
+ return counts.values.sortedWith { first, second -> compareCounted(first.count, first.topic, second.count, second.topic) }
}
-private fun compareCounted(collator: Collator, firstCount: Int, firstName: String, secondCount: Int, secondName: String): Int {
+private fun compareCounted(firstCount: Int, firstName: String, secondCount: Int, secondName: String): Int {
val byCount = secondCount - firstCount
- return if (byCount != 0) byCount else collator.compare(firstName, secondName)
+ return if (byCount != 0) byCount else compareNames(firstName, secondName)
}
/** The [names] with the highest counts first and then by name, the order every list of topics or words is shown in. */
fun byCount(names: List, count: (String) -> Int): List {
- val collator = Collator.getInstance()
return names
.map { name -> name to count(name) }
.sortedWith { (firstName, firstCount), (secondName, secondCount) ->
- compareCounted(collator, firstCount, firstName, secondCount, secondName)
+ compareCounted(firstCount, firstName, secondCount, secondName)
}
.map { (name, _) -> name }
}
@@ -233,6 +246,101 @@ private fun topicKey(topic: String): String = topic.trim().lowercase()
fun articleTopics(categories: List, order: TopicOrder): List =
byCount(categories.distinctBy(::topicKey), order::countOf)
+/** Each topic of an article with its scheme ("" for none), each pair once, blank topics left out. */
+private fun schemedTopics(entry: FilterEntry): List> {
+ val pairs = LinkedHashSet>()
+ for ((index, topic) in entry.categories.withIndex()) {
+ val key = topicKey(topic)
+ if (key.isNotEmpty()) {
+ pairs.add(key to (entry.categorySchemes.getOrNull(index) ?: ""))
+ }
+ }
+ return pairs.toList()
+}
+
+/** Schemes the articles use for more than one distinct topic; the others join the plain group. */
+private fun groupingSchemes(pairs: List>>): Set =
+ pairs.flatten()
+ .groupBy({ (_, scheme) -> scheme }, { (key, _) -> key })
+ .filter { (scheme, keys) -> scheme.isNotEmpty() && keys.toSet().size > 1 }
+ .keys
+
+/** The scheme that covers more articles, then the later in code-unit order. */
+private fun largerScheme(first: String, second: String, articles: Map): String {
+ val difference = (articles[second] ?: 0) - (articles[first] ?: 0)
+ return if (difference > 0 || (difference == 0 && second > first)) second else first
+}
+
+/**
+ * The view's topics in groups, by the scheme the feed gives each (RSS
+ * `domain`, Atom `scheme`): topics with no scheme, and those under a scheme
+ * the articles use for only one topic, form the plain group; a topic under
+ * two schemes goes in the group covering more articles. Groups come in order
+ * of how many articles they cover (then by scheme); topics in each by count,
+ * then name, as [topicCounts].
+ */
+fun topicGroups(entries: List): List> {
+ val pairs = entries.map(::schemedTopics)
+ val grouping = groupingSchemes(pairs)
+ val articles = HashMap()
+ val schemesOf = HashMap>()
+ for (article in pairs) {
+ val grouped = article.map { (key, scheme) -> key to if (scheme in grouping) scheme else "" }
+ for (used in grouped.map { (_, scheme) -> scheme }.toSet()) {
+ articles[used] = (articles[used] ?: 0) + 1
+ }
+ for ((key, scheme) in grouped) {
+ schemesOf.getOrPut(key) { LinkedHashSet() }.add(scheme)
+ }
+ }
+ val groups = LinkedHashMap>()
+ for (counted in topicCounts(entries)) {
+ val schemes = schemesOf[topicKey(counted.topic)] ?: setOf("")
+ val scheme = schemes.reduce { best, other -> largerScheme(best, other, articles) }
+ groups.getOrPut(scheme) { mutableListOf() }.add(counted)
+ }
+ return groups.entries
+ .sortedWith(compareByDescending>> { (scheme, _) -> articles[scheme] ?: 0 }.thenBy { (scheme, _) -> scheme })
+ .map { (_, topics) -> topics.toList() }
+}
+
+/** Each topic's group and place in it, by its trimmed lowercase form, from [topicGroups]; built once per view. */
+class GroupOrder(
+ /** The view's topic groups, the group covering most articles first. */
+ val groups: List>,
+) {
+ private val group = HashMap()
+ private val rank = HashMap()
+
+ init {
+ for ((index, topics) in groups.withIndex()) {
+ for ((place, item) in topics.withIndex()) {
+ group[topicKey(item.topic)] = index
+ rank[topicKey(item.topic)] = place
+ }
+ }
+ }
+
+ /**
+ * An article's topics as rows, one per group of the view it has topics in,
+ * in the view's order; topics the view does not know form a last row, by name.
+ */
+ fun articleRows(categories: List): List> {
+ return categories
+ .filter { topic -> topicKey(topic).isNotEmpty() }
+ .distinctBy(::topicKey)
+ .groupBy { topic -> group[topicKey(topic)] ?: Int.MAX_VALUE }
+ .entries
+ .sortedBy { (index, _) -> index }
+ .map { (_, topics) ->
+ topics.sortedWith { first, second ->
+ val byRank = (rank[topicKey(first)] ?: Int.MAX_VALUE).compareTo(rank[topicKey(second)] ?: Int.MAX_VALUE)
+ if (byRank != 0) byRank else compareNames(first, second)
+ }
+ }
+ }
+}
+
/** Whether an article stays after the settings' filters are applied. */
fun passes(settings: ViewSettings, entry: FilterEntry): Boolean = compiledFilter(settings)(entry)
@@ -248,13 +356,15 @@ private fun compiledFilter(settings: ViewSettings): (FilterEntry) -> Boolean {
filters.clear()
}
filters.getOrPut(settings) {
- val off = settings.offTopics.map { topic -> topic.lowercase() }.toSet()
+ val off = settings.offTopics.filter { topic -> !topic.startsWith(SECTION_PREFIX) }.map { topic -> topic.lowercase() }.toSet()
+ val places = offPlaces(settings.offTopics)
val hide = matcherFor(settings.hideWords)
val only = if (settings.onlyWords.isEmpty()) null else matcherFor(settings.onlyWords)
val filter: (FilterEntry) -> Boolean = { entry ->
val topicsOff = entry.categories.isNotEmpty() &&
entry.categories.all { topic -> topicKey(topic) in off }
- val hidden = hide(entry.title) || hide(entry.summary)
+ val hidden = hide(entry.title) || hide(entry.summary) ||
+ (places.isNotEmpty() && placeHidden(places, articlePlace(entry)))
val kept = only == null || only(entry.title) || only(entry.summary)
!(topicsOff || hidden) && kept
}
diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/Model.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/Model.kt
index 9cb8b72..b3dcba3 100644
--- a/android/core/src/main/kotlin/cc/hafa/reader/core/Model.kt
+++ b/android/core/src/main/kotlin/cc/hafa/reader/core/Model.kt
@@ -58,7 +58,7 @@ data class Entry(
val guid: String,
override val title: String,
/** Link to the original article. */
- val url: String,
+ override val url: String,
/** When the feed says it was posted; the first sighting if the feed gives no date. */
val published: Long,
/** Whether [published] came from the feed. */
@@ -83,6 +83,8 @@ data class Entry(
val bodyHash: String = "",
/** When the read or starred mark last changed. */
val updatedAt: Long = 0,
+ override val categorySchemes: List = emptyList(),
+ override val sourceUrl: String = "",
) : FilterEntry
/** What the article list shows. */
diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/Names.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/Names.kt
new file mode 100644
index 0000000..5613eb6
--- /dev/null
+++ b/android/core/src/main/kotlin/cc/hafa/reader/core/Names.kt
@@ -0,0 +1,19 @@
+package cc.hafa.reader.core
+
+import java.text.Normalizer
+
+private val MARKS = Regex("\\p{M}")
+
+/** A name with accents dropped and capitals ignored, for its first comparison. */
+private fun plainName(name: String): String = Normalizer.normalize(name, Normalizer.Form.NFD).replace(MARKS, "").lowercase()
+
+/**
+ * The order of two topic or section names, the same on every platform: by the names with accents and capitals
+ * ignored, then with capitals ignored, then as written, each in UTF-16 code-unit order. A locale's collation is not
+ * used, since engines order punctuation such as `7-1` and `7.1` apart.
+ */
+fun compareNames(first: String, second: String): Int {
+ val plain = plainName(first).compareTo(plainName(second))
+ val lower = first.lowercase().compareTo(second.lowercase())
+ return if (plain != 0) plain else if (lower != 0) lower else first.compareTo(second)
+}
diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderModel.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderModel.kt
index 45d8511..9e4df22 100644
--- a/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderModel.kt
+++ b/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderModel.kt
@@ -477,6 +477,25 @@ class ReaderModel(
* screen everything takes effect at once.
*/
fun toggleTopic(topic: String, fromArticle: Boolean) {
+ changeOffTopics(fromArticle) { offTopics ->
+ if (topicOff(ViewSettings(offTopics = offTopics), topic)) {
+ offTopics.filter { known -> !known.equals(topic, ignoreCase = true) }
+ } else {
+ offTopics + topic
+ }
+ }
+ }
+
+ /**
+ * Turn a section off, with every section under it, or back on, with every
+ * section above it, in the current view; from an article the list holds
+ * as it does for a topic.
+ */
+ fun toggleSection(place: String, fromArticle: Boolean) {
+ changeOffTopics(fromArticle) { offTopics -> toggledSection(offTopics, place) }
+ }
+
+ private fun changeOffTopics(fromArticle: Boolean, change: (List) -> List) {
val key = viewKey(state.value.selection)
val before = state.value.settings[key] ?: DEFAULT_SETTINGS
update { current ->
@@ -488,16 +507,7 @@ class ReaderModel(
},
)
}
- val wasOff = topicOff(before, topic)
- changeSettings { current ->
- current.copy(
- offTopics = if (wasOff) {
- current.offTopics.filter { known -> !known.equals(topic, ignoreCase = true) }
- } else {
- current.offTopics + topic
- },
- )
- }
+ changeSettings { current -> current.copy(offTopics = change(current.offTopics)) }
}
/**
@@ -1518,6 +1528,8 @@ class ReaderModel(
excerpt = item.summary.ifEmpty { excerpt(item.html) },
summary = item.summary,
categories = item.categories,
+ categorySchemes = item.categorySchemes,
+ sourceUrl = item.sourceUrl,
read = false,
starred = false,
addedAt = now,
diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderView.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderView.kt
index 76641d8..922122f 100644
--- a/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderView.kt
+++ b/android/core/src/main/kotlin/cc/hafa/reader/core/ReaderView.kt
@@ -160,6 +160,8 @@ data class ReaderView(
val visible: List,
/** The topics in the current view, commonest first, kept as the same object while they stay the same. */
val topicOrder: TopicOrder,
+ /** The current view's sections and topic groups, kept as the same object while its articles stay the same. */
+ val filters: ViewFilters,
/** Name of the current view. */
val viewTitle: ViewTitle,
/** Feeds in the current view; empty means every feed. */
@@ -405,6 +407,86 @@ private fun topicOrderOf(entries: List): TopicOrder {
}
}
+/** A view's sections and topic groups, as its filter rows show them. */
+class ViewFilters(
+ /** The view's sections with how many articles each covers, each followed by those under it. */
+ val sections: List,
+ /** Each article's section chips by entry id, from its own feed's articles in the view. */
+ val articleSections: Map>,
+ /** The view's topics in groups, the group covering most articles first. */
+ val groupOrder: GroupOrder,
+ /** Whether the view filters at all; Starred does not. */
+ val applies: Boolean = true,
+) {
+ /** An article's sections and topics as the filter rows show them; none where the view does not filter. */
+ fun of(entry: Entry): ArticleFilters =
+ if (applies) {
+ ArticleFilters(articleSections[entry.id] ?: emptyList(), groupOrder.articleRows(entry.categories))
+ } else {
+ ArticleFilters(emptyList(), emptyList())
+ }
+}
+
+/** An open article's filters: its section chips, then its topics in one row per group. */
+data class ArticleFilters(
+ /** Its sections, outermost first. */
+ val sections: List,
+ /** Its topics, one row per group of the view. */
+ val topics: List>,
+) {
+ /** Whether it has any chip. */
+ val isEmpty: Boolean get() = sections.isEmpty() && topics.isEmpty()
+
+ /** Whether any of its sections or topics is turned off in [settings]. */
+ fun anyOff(settings: ViewSettings): Boolean =
+ sections.any { section -> sectionOff(settings.offTopics, section.place) } ||
+ topics.any { row -> row.any { topic -> topicOff(settings, topic) } }
+}
+
+private val NO_FILTERS = ViewFilters(emptyList(), emptyMap(), GroupOrder(emptyList()), applies = false)
+
+/** The articles and filters of the last [filtersOf], reused while the articles' addresses and topics stay the same. */
+private class CountedFilters(val entries: List, val filters: ViewFilters)
+
+@Volatile
+private var lastFilters: CountedFilters? = null
+
+private fun sameArticles(first: List, second: List): Boolean =
+ first.size == second.size &&
+ first.indices.all { index ->
+ val known = first[index]
+ val other = second[index]
+ known.id == other.id && known.categories == other.categories &&
+ known.url == other.url && known.sourceUrl == other.sourceUrl
+ }
+
+/** Each article's section chips, worked out per feed from all of that feed's articles given. */
+private fun sectionsByEntry(entries: List): Map> {
+ val chips = HashMap>()
+ for (articles in entries.groupBy { entry -> entry.feedId }.values) {
+ for ((index, sections) in feedSections(articles).withIndex()) {
+ if (sections.isNotEmpty()) {
+ chips[articles[index].id] = sections
+ }
+ }
+ }
+ return chips
+}
+
+/** The sections and topic groups of a view's [entries]; none in Starred. */
+private fun filtersOf(selection: Selection, entries: List): ViewFilters {
+ val cached = lastFilters
+ return if (selection == Selection.Starred) {
+ NO_FILTERS
+ } else if (cached != null && sameArticles(cached.entries, entries)) {
+ cached.filters
+ } else {
+ val byEntry = sectionsByEntry(entries)
+ ViewFilters(sectionCounts(byEntry.values), byEntry, GroupOrder(topicGroups(entries)))
+ .also { filters -> lastFilters = CountedFilters(entries, filters) }
+ }
+}
+
/** Work out what the screens show from the reader's state. */
fun deriveView(state: ReaderState): ReaderView {
val selection = state.selection
@@ -449,6 +531,7 @@ fun deriveView(state: ReaderState): ReaderView {
baseEntries = baseEntries,
visible = visible,
topicOrder = topicOrderOf(baseEntries),
+ filters = filtersOf(selection, baseEntries),
viewTitle = viewTitle,
viewFeedIds = viewFeedIds,
current = state.entries.firstOrNull { entry -> entry.id == state.currentId },
diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/Sections.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/Sections.kt
new file mode 100644
index 0000000..c8118fa
--- /dev/null
+++ b/android/core/src/main/kotlin/cc/hafa/reader/core/Sections.kt
@@ -0,0 +1,254 @@
+package cc.hafa.reader.core
+
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import java.util.concurrent.ConcurrentHashMap
+
+/** One section chip: the words it shows and the place turning it off stores. */
+data class SectionChip(
+ /** The folders below those every article of the feed shares, or the site. */
+ val label: String,
+ /** The site and every folder above and at the section, joined by `/`. */
+ val place: String,
+)
+
+/** A section chip with how many articles it covers. */
+data class CountedSection(
+ /** The folders below those every article of the feed shares, or the site. */
+ val label: String,
+ /** The site and every folder above and at the section, joined by `/`. */
+ val place: String,
+ /** Articles of the view in the section. */
+ val count: Int,
+)
+
+/** An entry of `offTopics` that holds a turned-off section's place rather than a topic. */
+const val SECTION_PREFIX = "section:"
+
+private val MONTHS = setOf(
+ "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "sept", "oct", "nov", "dec",
+ "january", "february", "march", "april", "june", "july", "august", "september", "october",
+ "november", "december",
+)
+private val DIGIT = Regex("[0-9]")
+
+/** Where an article sits: its site, then the folders of its address that can name a section. */
+private data class Place(val domain: String, val parts: List)
+
+/** The address sections come from: the story's source when the feed names one, else its link. */
+fun sectionAddress(entry: FilterEntry): String = entry.sourceUrl.ifEmpty { entry.url }
+
+private fun placeOf(address: String): Place {
+ val url = address.toHttpUrlOrNull()
+ return if (url == null) {
+ Place("", emptyList())
+ } else {
+ val segments = url.pathSegments.filter { segment -> segment.isNotEmpty() }
+ Place(
+ url.host.removePrefix("www."),
+ segments
+ .dropLast(1)
+ .map { part -> part.lowercase() }
+ .filter { part -> !(DIGIT.containsMatchIn(part) || part in MONTHS) },
+ )
+ }
+}
+
+private fun joined(place: Place): String = (listOf(place.domain) + place.parts).joinToString("/")
+
+/** Addresses kept before the cache starts over. */
+private const val PLACE_LIMIT = 100_000
+
+/** Places already worked out, by address, since filters test every article. */
+private val places = ConcurrentHashMap()
+
+/**
+ * Where an article sits, as a turned-off section's place is stored: the host
+ * without `www.`, then the folders of its address, leaving out its own name,
+ * any with a digit and month names; empty when the address cannot be read.
+ */
+fun articlePlace(entry: FilterEntry): String {
+ val address = sectionAddress(entry)
+ return places[address] ?: run {
+ val found = placeOf(address)
+ val place = if (found.domain.isEmpty()) "" else joined(found)
+ if (places.size >= PLACE_LIMIT) {
+ places.clear()
+ }
+ places[address] = place
+ place
+ }
+}
+
+/** Whether [place] is [outer] or lies under it. */
+private fun within(place: String, outer: String): Boolean = place == outer || place.startsWith("$outer/")
+
+/** Whether any of the [stored] places holds [place]. */
+fun placeHidden(stored: List, place: String): Boolean =
+ place.isNotEmpty() && stored.any { outer -> within(place, outer) }
+
+/** The places of the turned-off sections among a view's [offTopics]. */
+fun offPlaces(offTopics: List): List =
+ offTopics.filter { topic -> topic.startsWith(SECTION_PREFIX) }.map { topic -> topic.removePrefix(SECTION_PREFIX) }
+
+/** Whether a section is off: it or a section above it was turned off. */
+fun sectionOff(offTopics: List, place: String): Boolean = placeHidden(offPlaces(offTopics), place)
+
+/**
+ * The [offTopics] after tapping a section: an off section comes back on with
+ * every section above it, and turning one off clears the sections below it.
+ */
+fun toggledSection(offTopics: List, place: String): List {
+ val off = sectionOff(offTopics, place)
+ val kept = offTopics.filter { topic ->
+ if (topic.startsWith(SECTION_PREFIX)) {
+ val stored = topic.removePrefix(SECTION_PREFIX)
+ if (off) !within(place, stored) else !within(stored, place)
+ } else {
+ true
+ }
+ }
+ return if (off) kept else kept + "$SECTION_PREFIX$place"
+}
+
+/** One folder in the tree of a feed's article folders. */
+private class FolderNode {
+ val articles = mutableListOf()
+ val children = LinkedHashMap()
+}
+
+private fun folderTree(paths: List>): FolderNode {
+ val root = FolderNode()
+ for ((index, parts) in paths.withIndex()) {
+ root.articles.add(index)
+ var node = root
+ for (part in parts) {
+ node = node.children.getOrPut(part) { FolderNode() }
+ node.articles.add(index)
+ }
+ }
+ return root
+}
+
+/** Each path cut at its first folder fewer than two articles share under the same parents. */
+private fun sharedPaths(paths: List>): List> {
+ val root = folderTree(paths)
+ return paths.map { parts ->
+ val kept = mutableListOf()
+ var node: FolderNode? = root
+ for (part in parts) {
+ node = node?.children?.get(part)
+ if (node == null || node.articles.size < 2) {
+ break
+ }
+ kept.add(part)
+ }
+ kept
+ }
+}
+
+/** How many leading folders every path has in common. */
+private fun commonDepth(paths: List>): Int {
+ var depth = 0
+ while (paths.isNotEmpty() && paths.all { parts -> parts.size > depth } && paths.map { parts -> parts[depth] }.toSet().size == 1) {
+ depth += 1
+ }
+ return depth
+}
+
+/** Section chips by folder, for articles that share one site. */
+private fun folderSections(domain: String, paths: List>): List> {
+ val result = paths.map { mutableListOf() }
+ val common = commonDepth(paths)
+ fun walk(node: FolderNode, depth: Int, trail: List) {
+ val stops = node.articles.any { index -> paths[index].size == depth }
+ val splits = node.children.size > 1 || (stops && node.children.size == 1)
+ for ((part, child) in node.children) {
+ val here = trail + part
+ if (splits && depth >= common) {
+ val chip = SectionChip(here.drop(common).joinToString("/"), (listOf(domain) + here).joinToString("/"))
+ for (index in child.articles) {
+ result[index].add(chip)
+ }
+ }
+ walk(child, depth + 1, here)
+ }
+ }
+ walk(folderTree(paths), 0, emptyList())
+ return result
+}
+
+/**
+ * Each article's section chips, outermost first, worked out from all the
+ * articles of one feed.
+ *
+ * When the articles' addresses ([sectionAddress]) span more than one site,
+ * each article's only section is its site. Otherwise sections come from the
+ * folders of the addresses ([articlePlace]): a folder counts only when two
+ * articles have it under the same parents, folders every article shares are
+ * skipped, and each folder that splits the articles gives a chip, so
+ * `/us/politics/` gives `us` and `us/politics`.
+ */
+fun feedSections(entries: List): List> {
+ val found = entries.map { entry -> placeOf(sectionAddress(entry)) }
+ val domains = found.map { place -> place.domain }.filter { domain -> domain.isNotEmpty() }.toSet()
+ return if (domains.size > 1) {
+ found.map { place -> if (place.domain.isEmpty()) emptyList() else listOf(SectionChip(place.domain, place.domain)) }
+ } else {
+ val domain = domains.firstOrNull() ?: ""
+ val kept = found.withIndex().filter { (_, place) -> place.domain.isNotEmpty() }
+ val chips = folderSections(domain, sharedPaths(kept.map { (_, place) -> place.parts }))
+ val result = MutableList(found.size) { emptyList() }
+ for ((position, item) in kept.withIndex()) {
+ result[item.index] = chips[position]
+ }
+ result
+ }
+}
+
+/** The shorter label, then the first in code-unit order, so every device names a place alike. */
+private fun preferredLabel(first: String, second: String): String =
+ if (second.length < first.length || (second.length == first.length && second < first)) second else first
+
+/** The nearest section above [place] among the [counted] ones. */
+private fun parentPlace(place: String, counted: Map): String? {
+ var outer = place
+ var parent: String? = null
+ while (parent == null && '/' in outer) {
+ outer = outer.substringBeforeLast('/')
+ parent = outer.takeIf { candidate -> candidate in counted }
+ }
+ return parent
+}
+
+/**
+ * A view's section chips with how many of its articles each covers, from
+ * each article's chips ([feedSections]): the most articles first, then by
+ * name, each followed by the sections under it in the same order.
+ */
+fun sectionCounts(articles: Collection>): List {
+ val counted = LinkedHashMap()
+ for (chips in articles) {
+ for (chip in chips.associateBy { chip -> chip.place }.values) {
+ val known = counted[chip.place]
+ counted[chip.place] = CountedSection(
+ if (known == null) chip.label else preferredLabel(known.label, chip.label),
+ chip.place,
+ (known?.count ?: 0) + 1,
+ )
+ }
+ }
+ val children = counted.values.groupBy { section -> parentPlace(section.place, counted) }
+ val ordered = mutableListOf()
+ fun add(parent: String?) {
+ val sorted = (children[parent] ?: emptyList()).sortedWith { first, second ->
+ val byCount = second.count - first.count
+ if (byCount != 0) byCount else compareNames(first.label, second.label)
+ }
+ for (section in sorted) {
+ ordered.add(section)
+ add(section.place)
+ }
+ }
+ add(null)
+ return ordered
+}
diff --git a/android/core/src/main/kotlin/cc/hafa/reader/core/sync/Documents.kt b/android/core/src/main/kotlin/cc/hafa/reader/core/sync/Documents.kt
index eb521c9..65e419b 100644
--- a/android/core/src/main/kotlin/cc/hafa/reader/core/sync/Documents.kt
+++ b/android/core/src/main/kotlin/cc/hafa/reader/core/sync/Documents.kt
@@ -62,6 +62,10 @@ data class EntryDocument(
val summary: String,
/** Topics the feed lists. */
val categories: List,
+ /** The scheme of each topic at the same index; empty for none. Missing from older clients' documents. */
+ val categorySchemes: List = emptyList(),
+ /** Address of the story's source; empty for none. Missing from older clients' documents. */
+ val sourceUrl: String = "",
/** Hash of the shared body; empty when unknown. */
val bodyHash: String,
/** Whether it has been read. */
@@ -329,6 +333,8 @@ fun entryDocument(entry: Entry, retention: RetentionDays = null): EntryDocument
excerpt = cutText(entry.excerpt, FieldLimits.EXCERPT),
summary = cutText(entry.summary, FieldLimits.SUMMARY),
categories = entry.categories.take(FieldLimits.CATEGORIES),
+ categorySchemes = entry.categorySchemes.take(FieldLimits.CATEGORIES).map(::fitAddress),
+ sourceUrl = fitAddress(entry.sourceUrl),
bodyHash = entry.bodyHash,
read = entry.read,
starred = entry.starred,
@@ -352,6 +358,8 @@ fun entryFromDocument(id: String, stored: EntryDocument): Entry =
excerpt = stored.excerpt,
summary = stored.summary,
categories = stored.categories.toList(),
+ categorySchemes = stored.categorySchemes.toList(),
+ sourceUrl = stored.sourceUrl,
read = stored.read,
starred = stored.starred,
addedAt = stored.addedAt,
@@ -441,6 +449,8 @@ fun parseEntry(fields: Map): EntryDocument? =
excerpt = text(fields, "excerpt"),
summary = text(fields, "summary"),
categories = texts(fields, "categories"),
+ categorySchemes = texts(fields, "categorySchemes"),
+ sourceUrl = upgradedAddress(text(fields, "sourceUrl")),
bodyHash = text(fields, "bodyHash"),
read = flag(fields, "read"),
starred = flag(fields, "starred"),
@@ -490,7 +500,7 @@ fun EntryDocument.toFields(): Map {
val fields = mutableMapOf(
"feedId" to feedId, "guid" to guid, "title" to title, "published" to published, "dated" to dated,
"url" to url, "author" to author, "imageUrl" to imageUrl, "excerpt" to excerpt, "summary" to summary,
- "categories" to categories, "read" to read, "starred" to starred, "addedAt" to addedAt, "updatedAt" to updatedAt,
+ "categories" to categories, "categorySchemes" to categorySchemes, "sourceUrl" to sourceUrl, "read" to read, "starred" to starred, "addedAt" to addedAt, "updatedAt" to updatedAt,
)
if (bodyHash.isNotEmpty()) {
fields["bodyHash"] = bodyHash
diff --git a/android/core/src/test/kotlin/cc/hafa/reader/core/FeedTest.kt b/android/core/src/test/kotlin/cc/hafa/reader/core/FeedTest.kt
index e291c62..2bbc63f 100644
--- a/android/core/src/test/kotlin/cc/hafa/reader/core/FeedTest.kt
+++ b/android/core/src/test/kotlin/cc/hafa/reader/core/FeedTest.kt
@@ -26,13 +26,20 @@ class FeedTest {
assertEquals("https://example.com/posts/first", first.url)
assertEquals("Ada", first.author)
assertEquals("Short & sweet summary", first.summary)
- assertEquals(listOf("Elections", "World"), first.categories)
+ // a topic under two schemes is kept with each, and each pair once
+ assertEquals(listOf("Elections", "World", "World"), first.categories)
+ assertEquals(
+ listOf("http://www.nytimes.com/namespaces/keywords/des", "", "http://www.nytimes.com/namespaces/keywords/nyt_geo"),
+ first.categorySchemes,
+ )
+ assertEquals("", first.sourceUrl)
assertEquals(millis("2026-10-06T09:14:00Z"), first.published)
assertContains(first.html, "Full body")
assertEquals("https://example.com/img/a.png", first.imageUrl)
assertEquals("https://example.com/posts/second", second.guid)
assertEquals("https://example.com/thumb.jpg", second.imageUrl)
assertEquals(0L, second.published)
+ assertEquals("https://news.example.net/world/story", second.sourceUrl)
}
@Test
diff --git a/android/core/src/test/kotlin/cc/hafa/reader/core/FiltersFixtureTest.kt b/android/core/src/test/kotlin/cc/hafa/reader/core/FiltersFixtureTest.kt
new file mode 100644
index 0000000..8fd81cb
--- /dev/null
+++ b/android/core/src/test/kotlin/cc/hafa/reader/core/FiltersFixtureTest.kt
@@ -0,0 +1,117 @@
+package cc.hafa.reader.core
+
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.boolean
+import kotlinx.serialization.json.int
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+/** The sections, topic groups, hiding and toggling in `shared/fixtures/filters.json`, which every client's tests read. */
+class FiltersFixtureTest {
+ private val fixture: JsonObject =
+ Json.parseToJsonElement(
+ checkNotNull(javaClass.classLoader.getResource("filters.json")) { "missing fixture filters.json" }.readText(),
+ ).jsonObject
+
+ private data class Article(
+ override val title: String,
+ override val summary: String,
+ override val categories: List,
+ override val categorySchemes: List,
+ override val url: String,
+ override val sourceUrl: String,
+ ) : FilterEntry
+
+ private fun JsonElement.text(name: String): String = jsonObject[name]?.jsonPrimitive?.content ?: ""
+
+ private fun JsonElement.texts(name: String): List =
+ jsonObject[name]?.jsonArray?.map { item -> item.jsonPrimitive.content } ?: emptyList()
+
+ private fun JsonElement.items(name: String): List = jsonObject.getValue(name).jsonArray.toList()
+
+ private fun article(item: JsonElement): Article =
+ Article(
+ item.text("title"),
+ item.text("summary"),
+ item.texts("categories"),
+ item.texts("categorySchemes"),
+ item.text("url"),
+ item.text("sourceUrl"),
+ )
+
+ private fun chip(item: JsonElement): SectionChip = SectionChip(item.text("label"), item.text("place"))
+
+ @Test
+ fun `sections and topic groups match the fixture`() {
+ for (case in fixture.items("feeds")) {
+ val name = case.text("name")
+ val entries = case.items("input").map(::article)
+ val output = case.jsonObject.getValue("output")
+ val sections = feedSections(entries)
+ assertEquals(output.items("sections").map { article -> article.jsonArray.map(::chip) }, sections, name)
+ assertEquals(
+ output.items("counts").map { item ->
+ CountedSection(item.text("label"), item.text("place"), item.jsonObject.getValue("count").jsonPrimitive.int)
+ },
+ sectionCounts(sections),
+ name,
+ )
+ val groups = topicGroups(entries)
+ assertEquals(
+ output.items("groups").map { group ->
+ group.jsonArray.map { item -> TopicCount(item.text("topic"), item.jsonObject.getValue("count").jsonPrimitive.int) }
+ },
+ groups,
+ name,
+ )
+ val order = GroupOrder(groups)
+ assertEquals(
+ output.items("articleTopics").map { rows -> rows.jsonArray.map { row -> row.jsonArray.map { topic -> topic.jsonPrimitive.content } } },
+ entries.map { entry -> order.articleRows(entry.categories) },
+ name,
+ )
+ }
+ }
+
+ @Test
+ fun `names sort as the fixture says`() {
+ val names = fixture.getValue("names")
+ assertEquals(names.texts("output"), names.texts("input").sortedWith(::compareNames))
+ }
+
+ @Test
+ fun `article places match the fixture`() {
+ for (case in fixture.items("places")) {
+ val input = case.jsonObject.getValue("input")
+ val place = articlePlace(Article("", "", emptyList(), emptyList(), input.text("url"), input.text("sourceUrl")))
+ assertEquals(case.text("output"), place, input.text("url"))
+ }
+ }
+
+ @Test
+ fun `turned-off sections and topics hide as the fixture says`() {
+ for (case in fixture.items("hides")) {
+ val input = case.jsonObject.getValue("input")
+ val settings = ViewSettings(offTopics = input.texts("offTopics"))
+ val entry = article(input.jsonObject.getValue("entry"))
+ assertEquals(case.jsonObject.getValue("output").jsonPrimitive.boolean, !passes(settings, entry), settings.toString())
+ }
+ }
+
+ @Test
+ fun `tapping a section changes the stored places as the fixture says`() {
+ for (case in fixture.items("toggles")) {
+ val input = case.jsonObject.getValue("input")
+ val output = case.jsonObject.getValue("output")
+ val offTopics = input.texts("offTopics")
+ val place = input.text("place")
+ assertEquals(output.jsonObject.getValue("off").jsonPrimitive.boolean, sectionOff(offTopics, place), place)
+ assertEquals(output.texts("offTopics"), toggledSection(offTopics, place), place)
+ }
+ }
+}
diff --git a/android/core/src/test/kotlin/cc/hafa/reader/core/ReaderModelTest.kt b/android/core/src/test/kotlin/cc/hafa/reader/core/ReaderModelTest.kt
index 6d983b0..58d6834 100644
--- a/android/core/src/test/kotlin/cc/hafa/reader/core/ReaderModelTest.kt
+++ b/android/core/src/test/kotlin/cc/hafa/reader/core/ReaderModelTest.kt
@@ -1065,6 +1065,30 @@ class ReaderModelTest {
select(Selection.All)
}
+ @Test
+ fun `a section turned off hides its articles, and its parent comes back with a child`() = runTest {
+ val model = model()
+ val items = listOf("us/politics/a", "us/politics/b", "us/c", "world/d", "world/e").joinToString("") { path ->
+ "- $pathhttps://www.s.example/$path.html
"
+ }
+ fetcher.answers["https://s.example/feed"] = FetchOutcome(status = 200, text = "S$items")
+ model.addFeed("https://s.example/feed")
+ model.select(Selection.All)
+ val view = model.view()
+ assertEquals(listOf("us" to 3, "us/politics" to 2, "world" to 2), view.filters.sections.map { section -> section.label to section.count })
+ val politics = view.baseEntries.first { entry -> entry.title == "us/politics/a" }
+ val filters = view.filters.of(politics)
+ assertEquals(listOf("s.example/us", "s.example/us/politics"), filters.sections.map { section -> section.place })
+ model.toggleSection("s.example/us", fromArticle = false)
+ assertEquals(listOf("section:s.example/us"), model.view().viewSettings.offTopics)
+ assertTrue(filters.anyOff(model.view().viewSettings))
+ assertEquals(listOf("world/d", "world/e"), model.titles().sorted())
+ model.toggleSection("s.example/us/politics", fromArticle = false)
+ assertEquals(emptyList(), model.view().viewSettings.offTopics)
+ model.select(Selection.Starred)
+ assertTrue(model.view().filters.of(politics).isEmpty)
+ }
+
@Test
fun `a topic turned off from an article is held until the list is refreshed`() = runTest {
val model = model()
diff --git a/android/core/src/test/kotlin/cc/hafa/reader/core/SharedFixtureTest.kt b/android/core/src/test/kotlin/cc/hafa/reader/core/SharedFixtureTest.kt
index 3407319..5a08523 100644
--- a/android/core/src/test/kotlin/cc/hafa/reader/core/SharedFixtureTest.kt
+++ b/android/core/src/test/kotlin/cc/hafa/reader/core/SharedFixtureTest.kt
@@ -42,6 +42,8 @@ class SharedFixtureTest {
summary = entry.text("summary"),
categories = entry.getValue("categories").jsonArray.map { category -> category.jsonPrimitive.content },
html = entry.text("html"),
+ categorySchemes = entry.getValue("categorySchemes").jsonArray.map { scheme -> scheme.jsonPrimitive.content },
+ sourceUrl = entry.text("sourceUrl"),
)
},
)
diff --git a/android/core/src/test/kotlin/cc/hafa/reader/core/sync/DocumentsTest.kt b/android/core/src/test/kotlin/cc/hafa/reader/core/sync/DocumentsTest.kt
index 63a5784..f3e23d5 100644
--- a/android/core/src/test/kotlin/cc/hafa/reader/core/sync/DocumentsTest.kt
+++ b/android/core/src/test/kotlin/cc/hafa/reader/core/sync/DocumentsTest.kt
@@ -100,7 +100,12 @@ class DocumentsTest {
@Test
fun `entries round trip`() {
- val entry = sampleEntry().copy(bodyHash = "abc", starred = true)
+ val entry = sampleEntry().copy(
+ bodyHash = "abc",
+ starred = true,
+ categorySchemes = listOf("https://example.com/subjects"),
+ sourceUrl = "https://news.example.net/",
+ )
val document = entryDocument(entry)
assertNull(document.expireAt)
assertEquals(entry, entryFromDocument(entry.id, document))
@@ -166,6 +171,9 @@ class DocumentsTest {
mapOf("feedId" to "feed-1", "updatedAt" to 3L, "categories" to listOf("ok", 4), "read" to "yes", "published" to Double.NaN),
)
assertEquals(listOf("ok"), parsed?.categories)
+ // an older client's entry has no schemes or source
+ assertEquals(emptyList(), parsed?.categorySchemes)
+ assertEquals("", parsed?.sourceUrl)
assertEquals(false, parsed?.read)
assertEquals(0L, parsed?.published)
assertEquals(false, parsed?.dated)
diff --git a/firebase/firestore.rules b/firebase/firestore.rules
index 22247d3..63e02bc 100644
--- a/firebase/firestore.rules
+++ b/firebase/firestore.rules
@@ -44,12 +44,13 @@ service cloud.firestore {
}
// Entries are written whole, or merged over a whole one, so every field
- // but bodyHash and expireAt is present after the write.
+ // but bodyHash, expireAt, categorySchemes and sourceUrl is present after
+ // the write; older clients write neither of the last two.
function entry(data) {
return data.keys().hasOnly(['feedId', 'guid', 'title', 'published',
'dated', 'url', 'author', 'imageUrl', 'excerpt', 'summary',
- 'categories', 'bodyHash', 'read', 'starred', 'addedAt', 'updatedAt',
- 'expireAt', 'syncedAt'])
+ 'categories', 'categorySchemes', 'sourceUrl', 'bodyHash', 'read',
+ 'starred', 'addedAt', 'updatedAt', 'expireAt', 'syncedAt'])
&& data.keys().hasAll(['feedId', 'guid', 'title', 'published',
'dated', 'url', 'author', 'imageUrl', 'excerpt', 'summary',
'categories', 'read', 'starred', 'addedAt', 'updatedAt', 'syncedAt'])
@@ -64,6 +65,10 @@ service cloud.firestore {
&& text(data.excerpt, 1000)
&& text(data.summary, 20000)
&& data.categories is list && data.categories.size() <= 100
+ && (!('categorySchemes' in data)
+ || (data.categorySchemes is list
+ && data.categorySchemes.size() <= 100))
+ && (!('sourceUrl' in data) || address(data.sourceUrl))
&& (!('bodyHash' in data) || text(data.bodyHash, 128))
&& data.read is bool
&& data.starred is bool
diff --git a/shared/fixtures/atom.xml b/shared/fixtures/atom.xml
index 58ff2cd..f378e92 100644
--- a/shared/fixtures/atom.xml
+++ b/shared/fixtures/atom.xml
@@ -11,6 +11,8 @@
2026-10-05T08:00:00Z
2026-10-05T09:00:00Z
Grace
+
+
<p>Hello</p>
diff --git a/shared/fixtures/filters.json b/shared/fixtures/filters.json
new file mode 100644
index 0000000..0a5605c
--- /dev/null
+++ b/shared/fixtures/filters.json
@@ -0,0 +1,2563 @@
+{
+ "names": {
+ "input": [
+ "7.1",
+ "7-1",
+ "owa",
+ "Owa",
+ "OWA",
+ "Éclair",
+ "eclair",
+ "Zebra",
+ "zèbre",
+ "_under",
+ "Ångström",
+ "apple",
+ "Apple pie",
+ "apple-pie",
+ "10",
+ "9",
+ "Ünïcode",
+ "unicode"
+ ],
+ "output": [
+ "10",
+ "7-1",
+ "7.1",
+ "9",
+ "_under",
+ "Ångström",
+ "apple",
+ "Apple pie",
+ "apple-pie",
+ "eclair",
+ "Éclair",
+ "OWA",
+ "Owa",
+ "owa",
+ "unicode",
+ "Ünïcode",
+ "Zebra",
+ "zèbre"
+ ]
+ },
+ "feeds": [
+ {
+ "name": "nyt",
+ "input": [
+ {
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/trump-midterm-rally-red-states.html",
+ "sourceUrl": "",
+ "categories": [
+ "Elections",
+ "Midterm Elections (2026)",
+ "Trump, Donald J",
+ "Vance, J D"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/nyt_per",
+ "http://www.nytimes.com/namespaces/keywords/nyt_per"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/video/us/100000011219687/trump-campaigns-for-republicans-as-turmoil-mounts-in-washington.html",
+ "sourceUrl": "",
+ "categories": [
+ "United States Politics and Government",
+ "Midterm Elections (2026)",
+ "Trump, Donald J",
+ "United States"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/nyt_per",
+ "http://www.nytimes.com/namespaces/keywords/nyt_geo"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/trump-midterms-republican-political-ads.html",
+ "sourceUrl": "",
+ "categories": [
+ "United States Politics and Government",
+ "Political Advertising",
+ "Midterm Elections (2026)",
+ "Democratic Party"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/nyt_org"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/briefing/france-imposes-curfews.html",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/world/europe/france-protests-riots.html",
+ "sourceUrl": "",
+ "categories": [
+ "France",
+ "Curfews",
+ "Demonstrations, Protests and Riots",
+ "Politics and Government"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/nyt_geo",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/world/europe/france-protests-violence-police-response.html",
+ "sourceUrl": "",
+ "categories": [
+ "France",
+ "Demonstrations, Protests and Riots",
+ "Police",
+ "Tear Gas"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/nyt_geo",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/article/france-student-protests-why.html",
+ "sourceUrl": "",
+ "categories": [
+ "Education (K-12)",
+ "Demonstrations, Protests and Riots",
+ "Teachers and School Employees",
+ "Shortages"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/trump-washington-golf-course.html",
+ "sourceUrl": "",
+ "categories": [
+ "United States Politics and Government",
+ "Suits and Litigation (Civil)",
+ "Federal Courts (US)",
+ "Courts and the Judiciary"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/supreme-court-mahmoud-khalil-deportation.html",
+ "sourceUrl": "",
+ "categories": [
+ "Pro-Palestinian Campus Protests (2023- )",
+ "Deportation",
+ "Freedom of Speech and Expression",
+ "Immigration Detention"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/us/ice-hospitalization-us-citizen.html",
+ "sourceUrl": "",
+ "categories": [
+ "Immigration Detention",
+ "Deportation",
+ "Immigration and Customs Enforcement (US)",
+ "California"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/nyt_org",
+ "http://www.nytimes.com/namespaces/keywords/nyt_geo"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/us/ice-death-laredo-texas-medicine.html",
+ "sourceUrl": "",
+ "categories": [
+ "Immigration Detention",
+ "Illegal Immigration",
+ "United States Politics and Government",
+ "Immigration and Customs Enforcement (US)"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/nyt_org"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/dining/copenhagen-denmark-bread-bmo.html",
+ "sourceUrl": "",
+ "categories": [
+ "Bakeries and Baked Products",
+ "Cheese",
+ "Copenhagen (Denmark)"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/nyt_geo"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/us/california-climate-change-warning.html",
+ "sourceUrl": "",
+ "categories": [
+ "Wildfires",
+ "Floods",
+ "Global Warming",
+ "Weather"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/climate/tree-pandemics-northeast-forests.html",
+ "sourceUrl": "",
+ "categories": [
+ "Trees and Shrubs",
+ "Invasive Species",
+ "Global Warming",
+ "New York State"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/nyt_geo"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/us/bend-school-shooting-accountability.html",
+ "sourceUrl": "",
+ "categories": [
+ "Silver Rail Elementary, Bend, Ore, Shooting (2026)",
+ "Gun Control",
+ "Law and Legislation",
+ "Bend (Ore)"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/nyt_geo"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/magazine/penelope-cruz-acting-career.html",
+ "sourceUrl": "",
+ "categories": [
+ "Cruz, Penelope",
+ "La Bola Negra (Movie)",
+ "The Invite (Movie)",
+ "Movies"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/nyt_per",
+ "http://www.nytimes.com/namespaces/keywords/nyt_ttl",
+ "http://www.nytimes.com/namespaces/keywords/nyt_ttl",
+ "http://www.nytimes.com/namespaces/keywords/des"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/world/canada/alberta-secession-canada-referendum.html",
+ "sourceUrl": "",
+ "categories": [
+ "Canada",
+ "Alberta (Canada)",
+ "Secession and Independence Movements",
+ "Referendums"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/nyt_geo",
+ "http://www.nytimes.com/namespaces/keywords/nyt_geo",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/world/europe/trump-zelensky-putin-ukraine-war.html",
+ "sourceUrl": "",
+ "categories": [
+ "Russian Invasion of Ukraine (2022)",
+ "United States Politics and Government",
+ "United States International Relations",
+ "Putin, Vladimir V"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/nyt_per"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/science/russia-satellite-kosmos-2612.html",
+ "sourceUrl": "",
+ "categories": [
+ "Satellites",
+ "Space and Astronomy",
+ "Russian Invasion of Ukraine (2022)",
+ "Defense and Military Forces"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/opinion/high-school-new-york-city-application.html",
+ "sourceUrl": "",
+ "categories": [
+ "Education (K-12)",
+ "Parenting",
+ "New York City",
+ "Admissions Standards"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/nyt_geo",
+ "http://www.nytimes.com/namespaces/keywords/des"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/opinion/republican-party-trump-future.html",
+ "sourceUrl": "",
+ "categories": [
+ "United States Politics and Government",
+ "Midterm Elections (2026)",
+ "Conservatism (US Politics)",
+ "Hispanic-Americans"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des"
+ ]
+ },
+ {
+ "url": "https://www.nytimes.com/2026/10/19/world/canada/alberta-referendum-danielle-smith.html",
+ "sourceUrl": "",
+ "categories": [
+ "Referendums",
+ "Secession and Independence Movements",
+ "Smith, Danielle (1971- )",
+ "Alberta (Canada)"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "http://www.nytimes.com/namespaces/keywords/nyt_per",
+ "http://www.nytimes.com/namespaces/keywords/nyt_geo"
+ ]
+ }
+ ],
+ "output": {
+ "sections": [
+ [
+ {
+ "label": "us",
+ "place": "nytimes.com/us"
+ },
+ {
+ "label": "us/politics",
+ "place": "nytimes.com/us/politics"
+ }
+ ],
+ [],
+ [
+ {
+ "label": "us",
+ "place": "nytimes.com/us"
+ },
+ {
+ "label": "us/politics",
+ "place": "nytimes.com/us/politics"
+ }
+ ],
+ [],
+ [
+ {
+ "label": "world",
+ "place": "nytimes.com/world"
+ },
+ {
+ "label": "world/europe",
+ "place": "nytimes.com/world/europe"
+ }
+ ],
+ [
+ {
+ "label": "world",
+ "place": "nytimes.com/world"
+ },
+ {
+ "label": "world/europe",
+ "place": "nytimes.com/world/europe"
+ }
+ ],
+ [],
+ [
+ {
+ "label": "us",
+ "place": "nytimes.com/us"
+ },
+ {
+ "label": "us/politics",
+ "place": "nytimes.com/us/politics"
+ }
+ ],
+ [
+ {
+ "label": "us",
+ "place": "nytimes.com/us"
+ },
+ {
+ "label": "us/politics",
+ "place": "nytimes.com/us/politics"
+ }
+ ],
+ [
+ {
+ "label": "us",
+ "place": "nytimes.com/us"
+ }
+ ],
+ [
+ {
+ "label": "us",
+ "place": "nytimes.com/us"
+ }
+ ],
+ [],
+ [
+ {
+ "label": "us",
+ "place": "nytimes.com/us"
+ }
+ ],
+ [],
+ [
+ {
+ "label": "us",
+ "place": "nytimes.com/us"
+ }
+ ],
+ [],
+ [
+ {
+ "label": "world",
+ "place": "nytimes.com/world"
+ },
+ {
+ "label": "world/canada",
+ "place": "nytimes.com/world/canada"
+ }
+ ],
+ [
+ {
+ "label": "world",
+ "place": "nytimes.com/world"
+ },
+ {
+ "label": "world/europe",
+ "place": "nytimes.com/world/europe"
+ }
+ ],
+ [],
+ [
+ {
+ "label": "opinion",
+ "place": "nytimes.com/opinion"
+ }
+ ],
+ [
+ {
+ "label": "opinion",
+ "place": "nytimes.com/opinion"
+ }
+ ],
+ [
+ {
+ "label": "world",
+ "place": "nytimes.com/world"
+ },
+ {
+ "label": "world/canada",
+ "place": "nytimes.com/world/canada"
+ }
+ ]
+ ],
+ "counts": [
+ {
+ "label": "us",
+ "place": "nytimes.com/us",
+ "count": 8
+ },
+ {
+ "label": "us/politics",
+ "place": "nytimes.com/us/politics",
+ "count": 4
+ },
+ {
+ "label": "world",
+ "place": "nytimes.com/world",
+ "count": 5
+ },
+ {
+ "label": "world/europe",
+ "place": "nytimes.com/world/europe",
+ "count": 3
+ },
+ {
+ "label": "world/canada",
+ "place": "nytimes.com/world/canada",
+ "count": 2
+ },
+ {
+ "label": "opinion",
+ "place": "nytimes.com/opinion",
+ "count": 2
+ }
+ ],
+ "groups": [
+ [
+ {
+ "topic": "United States Politics and Government",
+ "count": 6
+ },
+ {
+ "topic": "Midterm Elections (2026)",
+ "count": 4
+ },
+ {
+ "topic": "Demonstrations, Protests and Riots",
+ "count": 3
+ },
+ {
+ "topic": "Immigration Detention",
+ "count": 3
+ },
+ {
+ "topic": "Deportation",
+ "count": 2
+ },
+ {
+ "topic": "Education (K-12)",
+ "count": 2
+ },
+ {
+ "topic": "Global Warming",
+ "count": 2
+ },
+ {
+ "topic": "Referendums",
+ "count": 2
+ },
+ {
+ "topic": "Russian Invasion of Ukraine (2022)",
+ "count": 2
+ },
+ {
+ "topic": "Secession and Independence Movements",
+ "count": 2
+ },
+ {
+ "topic": "Admissions Standards",
+ "count": 1
+ },
+ {
+ "topic": "Bakeries and Baked Products",
+ "count": 1
+ },
+ {
+ "topic": "Cheese",
+ "count": 1
+ },
+ {
+ "topic": "Conservatism (US Politics)",
+ "count": 1
+ },
+ {
+ "topic": "Courts and the Judiciary",
+ "count": 1
+ },
+ {
+ "topic": "Curfews",
+ "count": 1
+ },
+ {
+ "topic": "Defense and Military Forces",
+ "count": 1
+ },
+ {
+ "topic": "Elections",
+ "count": 1
+ },
+ {
+ "topic": "Federal Courts (US)",
+ "count": 1
+ },
+ {
+ "topic": "Floods",
+ "count": 1
+ },
+ {
+ "topic": "Freedom of Speech and Expression",
+ "count": 1
+ },
+ {
+ "topic": "Gun Control",
+ "count": 1
+ },
+ {
+ "topic": "Hispanic-Americans",
+ "count": 1
+ },
+ {
+ "topic": "Illegal Immigration",
+ "count": 1
+ },
+ {
+ "topic": "Invasive Species",
+ "count": 1
+ },
+ {
+ "topic": "Law and Legislation",
+ "count": 1
+ },
+ {
+ "topic": "Movies",
+ "count": 1
+ },
+ {
+ "topic": "Parenting",
+ "count": 1
+ },
+ {
+ "topic": "Police",
+ "count": 1
+ },
+ {
+ "topic": "Political Advertising",
+ "count": 1
+ },
+ {
+ "topic": "Politics and Government",
+ "count": 1
+ },
+ {
+ "topic": "Pro-Palestinian Campus Protests (2023- )",
+ "count": 1
+ },
+ {
+ "topic": "Satellites",
+ "count": 1
+ },
+ {
+ "topic": "Shortages",
+ "count": 1
+ },
+ {
+ "topic": "Silver Rail Elementary, Bend, Ore, Shooting (2026)",
+ "count": 1
+ },
+ {
+ "topic": "Space and Astronomy",
+ "count": 1
+ },
+ {
+ "topic": "Suits and Litigation (Civil)",
+ "count": 1
+ },
+ {
+ "topic": "Teachers and School Employees",
+ "count": 1
+ },
+ {
+ "topic": "Tear Gas",
+ "count": 1
+ },
+ {
+ "topic": "Trees and Shrubs",
+ "count": 1
+ },
+ {
+ "topic": "United States International Relations",
+ "count": 1
+ },
+ {
+ "topic": "Weather",
+ "count": 1
+ },
+ {
+ "topic": "Wildfires",
+ "count": 1
+ }
+ ],
+ [
+ {
+ "topic": "Alberta (Canada)",
+ "count": 2
+ },
+ {
+ "topic": "France",
+ "count": 2
+ },
+ {
+ "topic": "Bend (Ore)",
+ "count": 1
+ },
+ {
+ "topic": "California",
+ "count": 1
+ },
+ {
+ "topic": "Canada",
+ "count": 1
+ },
+ {
+ "topic": "Copenhagen (Denmark)",
+ "count": 1
+ },
+ {
+ "topic": "New York City",
+ "count": 1
+ },
+ {
+ "topic": "New York State",
+ "count": 1
+ },
+ {
+ "topic": "United States",
+ "count": 1
+ }
+ ],
+ [
+ {
+ "topic": "Trump, Donald J",
+ "count": 2
+ },
+ {
+ "topic": "Cruz, Penelope",
+ "count": 1
+ },
+ {
+ "topic": "Putin, Vladimir V",
+ "count": 1
+ },
+ {
+ "topic": "Smith, Danielle (1971- )",
+ "count": 1
+ },
+ {
+ "topic": "Vance, J D",
+ "count": 1
+ }
+ ],
+ [
+ {
+ "topic": "Immigration and Customs Enforcement (US)",
+ "count": 2
+ },
+ {
+ "topic": "Democratic Party",
+ "count": 1
+ }
+ ],
+ [
+ {
+ "topic": "La Bola Negra (Movie)",
+ "count": 1
+ },
+ {
+ "topic": "The Invite (Movie)",
+ "count": 1
+ }
+ ]
+ ],
+ "articleTopics": [
+ [
+ [
+ "Midterm Elections (2026)",
+ "Elections"
+ ],
+ [
+ "Trump, Donald J",
+ "Vance, J D"
+ ]
+ ],
+ [
+ [
+ "United States Politics and Government",
+ "Midterm Elections (2026)"
+ ],
+ [
+ "United States"
+ ],
+ [
+ "Trump, Donald J"
+ ]
+ ],
+ [
+ [
+ "United States Politics and Government",
+ "Midterm Elections (2026)",
+ "Political Advertising"
+ ],
+ [
+ "Democratic Party"
+ ]
+ ],
+ [],
+ [
+ [
+ "Demonstrations, Protests and Riots",
+ "Curfews",
+ "Politics and Government"
+ ],
+ [
+ "France"
+ ]
+ ],
+ [
+ [
+ "Demonstrations, Protests and Riots",
+ "Police",
+ "Tear Gas"
+ ],
+ [
+ "France"
+ ]
+ ],
+ [
+ [
+ "Demonstrations, Protests and Riots",
+ "Education (K-12)",
+ "Shortages",
+ "Teachers and School Employees"
+ ]
+ ],
+ [
+ [
+ "United States Politics and Government",
+ "Courts and the Judiciary",
+ "Federal Courts (US)",
+ "Suits and Litigation (Civil)"
+ ]
+ ],
+ [
+ [
+ "Immigration Detention",
+ "Deportation",
+ "Freedom of Speech and Expression",
+ "Pro-Palestinian Campus Protests (2023- )"
+ ]
+ ],
+ [
+ [
+ "Immigration Detention",
+ "Deportation"
+ ],
+ [
+ "California"
+ ],
+ [
+ "Immigration and Customs Enforcement (US)"
+ ]
+ ],
+ [
+ [
+ "United States Politics and Government",
+ "Immigration Detention",
+ "Illegal Immigration"
+ ],
+ [
+ "Immigration and Customs Enforcement (US)"
+ ]
+ ],
+ [
+ [
+ "Bakeries and Baked Products",
+ "Cheese"
+ ],
+ [
+ "Copenhagen (Denmark)"
+ ]
+ ],
+ [
+ [
+ "Global Warming",
+ "Floods",
+ "Weather",
+ "Wildfires"
+ ]
+ ],
+ [
+ [
+ "Global Warming",
+ "Invasive Species",
+ "Trees and Shrubs"
+ ],
+ [
+ "New York State"
+ ]
+ ],
+ [
+ [
+ "Gun Control",
+ "Law and Legislation",
+ "Silver Rail Elementary, Bend, Ore, Shooting (2026)"
+ ],
+ [
+ "Bend (Ore)"
+ ]
+ ],
+ [
+ [
+ "Movies"
+ ],
+ [
+ "Cruz, Penelope"
+ ],
+ [
+ "La Bola Negra (Movie)",
+ "The Invite (Movie)"
+ ]
+ ],
+ [
+ [
+ "Referendums",
+ "Secession and Independence Movements"
+ ],
+ [
+ "Alberta (Canada)",
+ "Canada"
+ ]
+ ],
+ [
+ [
+ "United States Politics and Government",
+ "Russian Invasion of Ukraine (2022)",
+ "United States International Relations"
+ ],
+ [
+ "Putin, Vladimir V"
+ ]
+ ],
+ [
+ [
+ "Russian Invasion of Ukraine (2022)",
+ "Defense and Military Forces",
+ "Satellites",
+ "Space and Astronomy"
+ ]
+ ],
+ [
+ [
+ "Education (K-12)",
+ "Admissions Standards",
+ "Parenting"
+ ],
+ [
+ "New York City"
+ ]
+ ],
+ [
+ [
+ "United States Politics and Government",
+ "Midterm Elections (2026)",
+ "Conservatism (US Politics)",
+ "Hispanic-Americans"
+ ]
+ ],
+ [
+ [
+ "Referendums",
+ "Secession and Independence Movements"
+ ],
+ [
+ "Alberta (Canada)"
+ ],
+ [
+ "Smith, Danielle (1971- )"
+ ]
+ ]
+ ]
+ }
+ },
+ {
+ "name": "atlantic",
+ "input": [
+ {
+ "url": "https://www.theatlantic.com/technology/2026/10/americans-not-buying-electric-cars/689071/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.theatlantic.com/newsletters/2026/10/what-trump-france-protest-comments-reveal/689073/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.theatlantic.com/science/2026/10/yosemite-fires/689069/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.theatlantic.com/technology/2026/10/blocking-internet-archive-will-not-stop-ai/689065/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.theatlantic.com/ideas/2026/10/constitution-trump-doj-supreme-court-corruption/689042/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.theatlantic.com/ideas/2026/10/liberalism-fukuyama-shklar-trump/689040/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.theatlantic.com/books/2026/10/sarah-anderson-fallow-ultimate-would-you-rather-novel-book-review/689064/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.theatlantic.com/ideas/2026/10/mamdani-leftist-moderate-new-york/689053/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.theatlantic.com/international/2026/10/canada-military-buildup-defense/689041/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.theatlantic.com/politics/2026/10/susan-collins-maine-debate-troy-jackson/689037/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.theatlantic.com/politics/2026/10/democratic-party-big-tech/689008/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.theatlantic.com/ideas/2026/10/trump-putin/689067/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.theatlantic.com/ideas/2026/10/america-now-ally-russia/689066/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://www.theatlantic.com/books/2026/10/poem-j-mae-barizo-catalog/689060/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ }
+ ],
+ "output": {
+ "sections": [
+ [
+ {
+ "label": "technology",
+ "place": "theatlantic.com/technology"
+ }
+ ],
+ [],
+ [],
+ [
+ {
+ "label": "technology",
+ "place": "theatlantic.com/technology"
+ }
+ ],
+ [
+ {
+ "label": "ideas",
+ "place": "theatlantic.com/ideas"
+ }
+ ],
+ [
+ {
+ "label": "ideas",
+ "place": "theatlantic.com/ideas"
+ }
+ ],
+ [
+ {
+ "label": "books",
+ "place": "theatlantic.com/books"
+ }
+ ],
+ [
+ {
+ "label": "ideas",
+ "place": "theatlantic.com/ideas"
+ }
+ ],
+ [],
+ [
+ {
+ "label": "politics",
+ "place": "theatlantic.com/politics"
+ }
+ ],
+ [
+ {
+ "label": "politics",
+ "place": "theatlantic.com/politics"
+ }
+ ],
+ [
+ {
+ "label": "ideas",
+ "place": "theatlantic.com/ideas"
+ }
+ ],
+ [
+ {
+ "label": "ideas",
+ "place": "theatlantic.com/ideas"
+ }
+ ],
+ [
+ {
+ "label": "books",
+ "place": "theatlantic.com/books"
+ }
+ ]
+ ],
+ "counts": [
+ {
+ "label": "ideas",
+ "place": "theatlantic.com/ideas",
+ "count": 5
+ },
+ {
+ "label": "books",
+ "place": "theatlantic.com/books",
+ "count": 2
+ },
+ {
+ "label": "politics",
+ "place": "theatlantic.com/politics",
+ "count": 2
+ },
+ {
+ "label": "technology",
+ "place": "theatlantic.com/technology",
+ "count": 2
+ }
+ ],
+ "groups": [],
+ "articleTopics": [
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ []
+ ]
+ }
+ },
+ {
+ "name": "hn",
+ "input": [
+ {
+ "url": "https://www.openbsd.org/80.html",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://marc.info/?l=openbsd-misc&m=179245849770356&w=2",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://trial.medpath.com/news/novo-nordisk-and-eli-lilly-report-glp-1-signals-of-slowed-biological-aging-at-ardd",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://emilkowal.ski/ui/developing-taste",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://efe.com/english/latest-news/2026-10-20/lula-supporters-protest-against-meta-to-denounce-its-electoral-interference/",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://ipinfo.io/blog/france-fridges-hack",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://redact.dev/blog/whatsapp-help-pages-hidden-ai-instructions",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://projectdiscovery.io/research/how-abliterated-models-can-get-you-pwned",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://marc.info/?l=openbsd-ports&m=179236125692175",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ }
+ ],
+ "output": {
+ "sections": [
+ [
+ {
+ "label": "openbsd.org",
+ "place": "openbsd.org"
+ }
+ ],
+ [
+ {
+ "label": "marc.info",
+ "place": "marc.info"
+ }
+ ],
+ [
+ {
+ "label": "trial.medpath.com",
+ "place": "trial.medpath.com"
+ }
+ ],
+ [
+ {
+ "label": "emilkowal.ski",
+ "place": "emilkowal.ski"
+ }
+ ],
+ [
+ {
+ "label": "efe.com",
+ "place": "efe.com"
+ }
+ ],
+ [
+ {
+ "label": "ipinfo.io",
+ "place": "ipinfo.io"
+ }
+ ],
+ [
+ {
+ "label": "redact.dev",
+ "place": "redact.dev"
+ }
+ ],
+ [
+ {
+ "label": "projectdiscovery.io",
+ "place": "projectdiscovery.io"
+ }
+ ],
+ [
+ {
+ "label": "marc.info",
+ "place": "marc.info"
+ }
+ ]
+ ],
+ "counts": [
+ {
+ "label": "marc.info",
+ "place": "marc.info",
+ "count": 2
+ },
+ {
+ "label": "efe.com",
+ "place": "efe.com",
+ "count": 1
+ },
+ {
+ "label": "emilkowal.ski",
+ "place": "emilkowal.ski",
+ "count": 1
+ },
+ {
+ "label": "ipinfo.io",
+ "place": "ipinfo.io",
+ "count": 1
+ },
+ {
+ "label": "openbsd.org",
+ "place": "openbsd.org",
+ "count": 1
+ },
+ {
+ "label": "projectdiscovery.io",
+ "place": "projectdiscovery.io",
+ "count": 1
+ },
+ {
+ "label": "redact.dev",
+ "place": "redact.dev",
+ "count": 1
+ },
+ {
+ "label": "trial.medpath.com",
+ "place": "trial.medpath.com",
+ "count": 1
+ }
+ ],
+ "groups": [],
+ "articleTopics": [
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ []
+ ]
+ }
+ },
+ {
+ "name": "gnews",
+ "input": [
+ {
+ "url": "https://news.google.com/rss/articles/story0?oc=5",
+ "sourceUrl": "https://www.bbc.com/",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://news.google.com/rss/articles/story1?oc=5",
+ "sourceUrl": "https://www.npr.org/",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://news.google.com/rss/articles/story2?oc=5",
+ "sourceUrl": "https://www.foxnews.com/",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://news.google.com/rss/articles/story3?oc=5",
+ "sourceUrl": "https://www.axios.com/",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://news.google.com/rss/articles/story4?oc=5",
+ "sourceUrl": "https://apnews.com/",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://news.google.com/rss/articles/story5?oc=5",
+ "sourceUrl": "https://www.reuters.com/",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://news.google.com/rss/articles/story6?oc=5",
+ "sourceUrl": "https://www.cbsnews.com/",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://news.google.com/rss/articles/story7?oc=5",
+ "sourceUrl": "https://www.theguardian.com/",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://news.google.com/rss/articles/story8?oc=5",
+ "sourceUrl": "https://www.foxnews.com/",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://news.google.com/rss/articles/story9?oc=5",
+ "sourceUrl": "https://abc7.com/",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://news.google.com/rss/articles/story10?oc=5",
+ "sourceUrl": "https://www.timesofisrael.com/",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://news.google.com/rss/articles/story11?oc=5",
+ "sourceUrl": "https://www.bbc.com/",
+ "categories": [],
+ "categorySchemes": []
+ }
+ ],
+ "output": {
+ "sections": [
+ [
+ {
+ "label": "bbc.com",
+ "place": "bbc.com"
+ }
+ ],
+ [
+ {
+ "label": "npr.org",
+ "place": "npr.org"
+ }
+ ],
+ [
+ {
+ "label": "foxnews.com",
+ "place": "foxnews.com"
+ }
+ ],
+ [
+ {
+ "label": "axios.com",
+ "place": "axios.com"
+ }
+ ],
+ [
+ {
+ "label": "apnews.com",
+ "place": "apnews.com"
+ }
+ ],
+ [
+ {
+ "label": "reuters.com",
+ "place": "reuters.com"
+ }
+ ],
+ [
+ {
+ "label": "cbsnews.com",
+ "place": "cbsnews.com"
+ }
+ ],
+ [
+ {
+ "label": "theguardian.com",
+ "place": "theguardian.com"
+ }
+ ],
+ [
+ {
+ "label": "foxnews.com",
+ "place": "foxnews.com"
+ }
+ ],
+ [
+ {
+ "label": "abc7.com",
+ "place": "abc7.com"
+ }
+ ],
+ [
+ {
+ "label": "timesofisrael.com",
+ "place": "timesofisrael.com"
+ }
+ ],
+ [
+ {
+ "label": "bbc.com",
+ "place": "bbc.com"
+ }
+ ]
+ ],
+ "counts": [
+ {
+ "label": "bbc.com",
+ "place": "bbc.com",
+ "count": 2
+ },
+ {
+ "label": "foxnews.com",
+ "place": "foxnews.com",
+ "count": 2
+ },
+ {
+ "label": "abc7.com",
+ "place": "abc7.com",
+ "count": 1
+ },
+ {
+ "label": "apnews.com",
+ "place": "apnews.com",
+ "count": 1
+ },
+ {
+ "label": "axios.com",
+ "place": "axios.com",
+ "count": 1
+ },
+ {
+ "label": "cbsnews.com",
+ "place": "cbsnews.com",
+ "count": 1
+ },
+ {
+ "label": "npr.org",
+ "place": "npr.org",
+ "count": 1
+ },
+ {
+ "label": "reuters.com",
+ "place": "reuters.com",
+ "count": 1
+ },
+ {
+ "label": "theguardian.com",
+ "place": "theguardian.com",
+ "count": 1
+ },
+ {
+ "label": "timesofisrael.com",
+ "place": "timesofisrael.com",
+ "count": 1
+ }
+ ],
+ "groups": [],
+ "articleTopics": [
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ []
+ ]
+ }
+ },
+ {
+ "name": "wordpress",
+ "input": [
+ {
+ "url": "https://wordpress.org/news/2026/10/wordpress-7-1-3-maintenance-and-security-release/",
+ "sourceUrl": "",
+ "categories": [
+ "Releases",
+ "Security"
+ ],
+ "categorySchemes": [
+ "",
+ ""
+ ]
+ },
+ {
+ "url": "https://wordpress.org/news/2026/09/wordpress-7-1-2-release/",
+ "sourceUrl": "",
+ "categories": [
+ "Releases",
+ "Security"
+ ],
+ "categorySchemes": [
+ "",
+ ""
+ ]
+ },
+ {
+ "url": "https://wordpress.org/news/2026/09/owa-president/",
+ "sourceUrl": "",
+ "categories": [
+ "General",
+ "Open Website Alliance",
+ "owa"
+ ],
+ "categorySchemes": [
+ "",
+ "",
+ ""
+ ]
+ },
+ {
+ "url": "https://wordpress.org/news/2026/09/wordpress-7-1-1-maintenance-and-security-release/",
+ "sourceUrl": "",
+ "categories": [
+ "Releases",
+ "Security"
+ ],
+ "categorySchemes": [
+ "",
+ ""
+ ]
+ },
+ {
+ "url": "https://wordpress.org/news/2026/09/108-student-websites/",
+ "sourceUrl": "",
+ "categories": [
+ "Education"
+ ],
+ "categorySchemes": [
+ ""
+ ]
+ },
+ {
+ "url": "https://wordpress.org/news/2026/08/open-weight/",
+ "sourceUrl": "",
+ "categories": [
+ "General",
+ "policy"
+ ],
+ "categorySchemes": [
+ "",
+ ""
+ ]
+ },
+ {
+ "url": "https://wordpress.org/news/2026/08/wcus-2026-recap/",
+ "sourceUrl": "",
+ "categories": [
+ "Community",
+ "Events",
+ "WordCamp",
+ "WCUS"
+ ],
+ "categorySchemes": [
+ "",
+ "",
+ "",
+ ""
+ ]
+ },
+ {
+ "url": "https://wordpress.org/news/2026/08/mary-lou/",
+ "sourceUrl": "",
+ "categories": [
+ "Releases",
+ "7-1",
+ "7.1",
+ "releases"
+ ],
+ "categorySchemes": [
+ "",
+ "",
+ "",
+ ""
+ ]
+ },
+ {
+ "url": "https://wordpress.org/news/2026/08/browser-extension/",
+ "sourceUrl": "",
+ "categories": [
+ "Development",
+ "General"
+ ],
+ "categorySchemes": [
+ "",
+ ""
+ ]
+ },
+ {
+ "url": "https://wordpress.org/news/2026/08/wordpress-7-0-4-release/",
+ "sourceUrl": "",
+ "categories": [
+ "Releases",
+ "Security",
+ "minor-releases",
+ "releases"
+ ],
+ "categorySchemes": [
+ "",
+ "",
+ "",
+ ""
+ ]
+ }
+ ],
+ "output": {
+ "sections": [
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ [],
+ []
+ ],
+ "counts": [],
+ "groups": [
+ [
+ {
+ "topic": "Releases",
+ "count": 5
+ },
+ {
+ "topic": "Security",
+ "count": 4
+ },
+ {
+ "topic": "General",
+ "count": 3
+ },
+ {
+ "topic": "7-1",
+ "count": 1
+ },
+ {
+ "topic": "7.1",
+ "count": 1
+ },
+ {
+ "topic": "Community",
+ "count": 1
+ },
+ {
+ "topic": "Development",
+ "count": 1
+ },
+ {
+ "topic": "Education",
+ "count": 1
+ },
+ {
+ "topic": "Events",
+ "count": 1
+ },
+ {
+ "topic": "minor-releases",
+ "count": 1
+ },
+ {
+ "topic": "Open Website Alliance",
+ "count": 1
+ },
+ {
+ "topic": "owa",
+ "count": 1
+ },
+ {
+ "topic": "policy",
+ "count": 1
+ },
+ {
+ "topic": "WCUS",
+ "count": 1
+ },
+ {
+ "topic": "WordCamp",
+ "count": 1
+ }
+ ]
+ ],
+ "articleTopics": [
+ [
+ [
+ "Releases",
+ "Security"
+ ]
+ ],
+ [
+ [
+ "Releases",
+ "Security"
+ ]
+ ],
+ [
+ [
+ "General",
+ "Open Website Alliance",
+ "owa"
+ ]
+ ],
+ [
+ [
+ "Releases",
+ "Security"
+ ]
+ ],
+ [
+ [
+ "Education"
+ ]
+ ],
+ [
+ [
+ "General",
+ "policy"
+ ]
+ ],
+ [
+ [
+ "Community",
+ "Events",
+ "WCUS",
+ "WordCamp"
+ ]
+ ],
+ [
+ [
+ "Releases",
+ "7-1",
+ "7.1"
+ ]
+ ],
+ [
+ [
+ "General",
+ "Development"
+ ]
+ ],
+ [
+ [
+ "Releases",
+ "Security",
+ "minor-releases"
+ ]
+ ]
+ ]
+ }
+ },
+ {
+ "name": "folder with articles of its own",
+ "input": [
+ {
+ "url": "https://example.com/a/one.html",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://example.com/a/two.html",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://example.com/a/b/three.html",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://example.com/a/b/four.html",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ }
+ ],
+ "output": {
+ "sections": [
+ [],
+ [],
+ [
+ {
+ "label": "b",
+ "place": "example.com/a/b"
+ }
+ ],
+ [
+ {
+ "label": "b",
+ "place": "example.com/a/b"
+ }
+ ]
+ ],
+ "counts": [
+ {
+ "label": "b",
+ "place": "example.com/a/b",
+ "count": 2
+ }
+ ],
+ "groups": [],
+ "articleTopics": [
+ [],
+ [],
+ [],
+ []
+ ]
+ }
+ },
+ {
+ "name": "one article",
+ "input": [
+ {
+ "url": "https://example.com/news/world/story",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ }
+ ],
+ "output": {
+ "sections": [
+ []
+ ],
+ "counts": [],
+ "groups": [],
+ "articleTopics": [
+ []
+ ]
+ }
+ },
+ {
+ "name": "unreadable addresses",
+ "input": [
+ {
+ "url": "",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "not an address",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://example.com/x/a",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://example.com/x/b",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://example.com/y/c",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ },
+ {
+ "url": "https://example.com/y/d",
+ "sourceUrl": "",
+ "categories": [],
+ "categorySchemes": []
+ }
+ ],
+ "output": {
+ "sections": [
+ [],
+ [],
+ [
+ {
+ "label": "x",
+ "place": "example.com/x"
+ }
+ ],
+ [
+ {
+ "label": "x",
+ "place": "example.com/x"
+ }
+ ],
+ [
+ {
+ "label": "y",
+ "place": "example.com/y"
+ }
+ ],
+ [
+ {
+ "label": "y",
+ "place": "example.com/y"
+ }
+ ]
+ ],
+ "counts": [
+ {
+ "label": "x",
+ "place": "example.com/x",
+ "count": 2
+ },
+ {
+ "label": "y",
+ "place": "example.com/y",
+ "count": 2
+ }
+ ],
+ "groups": [],
+ "articleTopics": [
+ [],
+ [],
+ [],
+ [],
+ [],
+ []
+ ]
+ }
+ },
+ {
+ "name": "schemes",
+ "input": [
+ {
+ "url": "",
+ "sourceUrl": "",
+ "categories": [
+ "Rain",
+ "Snow",
+ "Paris",
+ "Only"
+ ],
+ "categorySchemes": [
+ "s:weather",
+ "s:weather",
+ "s:place",
+ "s:single"
+ ]
+ },
+ {
+ "url": "",
+ "sourceUrl": "",
+ "categories": [
+ "Rain",
+ "Lyon",
+ "Paris",
+ "Plain"
+ ],
+ "categorySchemes": [
+ "s:weather",
+ "s:place",
+ "s:weather",
+ ""
+ ]
+ },
+ {
+ "url": "",
+ "sourceUrl": "",
+ "categories": [
+ "Lyon",
+ "plain"
+ ],
+ "categorySchemes": [
+ "s:place",
+ ""
+ ]
+ },
+ {
+ "url": "",
+ "sourceUrl": "",
+ "categories": [
+ "Hail"
+ ],
+ "categorySchemes": []
+ }
+ ],
+ "output": {
+ "sections": [
+ [],
+ [],
+ [],
+ []
+ ],
+ "counts": [],
+ "groups": [
+ [
+ {
+ "topic": "plain",
+ "count": 2
+ },
+ {
+ "topic": "Hail",
+ "count": 1
+ },
+ {
+ "topic": "Only",
+ "count": 1
+ }
+ ],
+ [
+ {
+ "topic": "Lyon",
+ "count": 2
+ },
+ {
+ "topic": "Paris",
+ "count": 2
+ }
+ ],
+ [
+ {
+ "topic": "Rain",
+ "count": 2
+ },
+ {
+ "topic": "Snow",
+ "count": 1
+ }
+ ]
+ ],
+ "articleTopics": [
+ [
+ [
+ "Only"
+ ],
+ [
+ "Paris"
+ ],
+ [
+ "Rain",
+ "Snow"
+ ]
+ ],
+ [
+ [
+ "Plain"
+ ],
+ [
+ "Lyon",
+ "Paris"
+ ],
+ [
+ "Rain"
+ ]
+ ],
+ [
+ [
+ "plain"
+ ],
+ [
+ "Lyon"
+ ]
+ ],
+ [
+ [
+ "Hail"
+ ]
+ ]
+ ]
+ }
+ }
+ ],
+ "places": [
+ {
+ "input": {
+ "url": "https://WWW.Example.com/News/Sept/caf%C3%A9/2026/item",
+ "sourceUrl": ""
+ },
+ "output": "example.com/news/café"
+ },
+ {
+ "input": {
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/story.html",
+ "sourceUrl": ""
+ },
+ "output": "nytimes.com/us/politics"
+ },
+ {
+ "input": {
+ "url": "https://example.com/",
+ "sourceUrl": ""
+ },
+ "output": "example.com"
+ },
+ {
+ "input": {
+ "url": "https://example.com/story",
+ "sourceUrl": ""
+ },
+ "output": "example.com"
+ },
+ {
+ "input": {
+ "url": "",
+ "sourceUrl": ""
+ },
+ "output": ""
+ },
+ {
+ "input": {
+ "url": "not an address",
+ "sourceUrl": ""
+ },
+ "output": ""
+ },
+ {
+ "input": {
+ "url": "https://sub.example.org/a/b/c/",
+ "sourceUrl": ""
+ },
+ "output": "sub.example.org/a/b"
+ },
+ {
+ "input": {
+ "url": "https://news.google.com/rss/articles/x",
+ "sourceUrl": "https://www.bbc.com/"
+ },
+ "output": "bbc.com"
+ }
+ ],
+ "hides": [
+ {
+ "input": {
+ "offTopics": [
+ "section:nytimes.com/us"
+ ],
+ "entry": {
+ "title": "t",
+ "summary": "s",
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/story.html",
+ "sourceUrl": "",
+ "categories": [
+ "Elections",
+ "Vance"
+ ],
+ "categorySchemes": []
+ }
+ },
+ "output": true
+ },
+ {
+ "input": {
+ "offTopics": [
+ "section:nytimes.com/us/politics"
+ ],
+ "entry": {
+ "title": "t",
+ "summary": "s",
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/story.html",
+ "sourceUrl": "",
+ "categories": [
+ "Elections",
+ "Vance"
+ ],
+ "categorySchemes": []
+ }
+ },
+ "output": true
+ },
+ {
+ "input": {
+ "offTopics": [
+ "section:nytimes.com/u"
+ ],
+ "entry": {
+ "title": "t",
+ "summary": "s",
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/story.html",
+ "sourceUrl": "",
+ "categories": [
+ "Elections",
+ "Vance"
+ ],
+ "categorySchemes": []
+ }
+ },
+ "output": false
+ },
+ {
+ "input": {
+ "offTopics": [
+ "section:nytimes.com/world"
+ ],
+ "entry": {
+ "title": "t",
+ "summary": "s",
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/story.html",
+ "sourceUrl": "",
+ "categories": [
+ "Elections",
+ "Vance"
+ ],
+ "categorySchemes": []
+ }
+ },
+ "output": false
+ },
+ {
+ "input": {
+ "offTopics": [
+ "section:nytimes.com"
+ ],
+ "entry": {
+ "title": "t",
+ "summary": "s",
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/story.html",
+ "sourceUrl": "",
+ "categories": [
+ "Elections",
+ "Vance"
+ ],
+ "categorySchemes": []
+ }
+ },
+ "output": true
+ },
+ {
+ "input": {
+ "offTopics": [
+ "elections"
+ ],
+ "entry": {
+ "title": "t",
+ "summary": "s",
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/story.html",
+ "sourceUrl": "",
+ "categories": [
+ "Elections",
+ "Vance"
+ ],
+ "categorySchemes": []
+ }
+ },
+ "output": false
+ },
+ {
+ "input": {
+ "offTopics": [
+ "elections",
+ "VANCE"
+ ],
+ "entry": {
+ "title": "t",
+ "summary": "s",
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/story.html",
+ "sourceUrl": "",
+ "categories": [
+ "Elections",
+ "Vance"
+ ],
+ "categorySchemes": []
+ }
+ },
+ "output": true
+ },
+ {
+ "input": {
+ "offTopics": [
+ "section:nytimes.com/world",
+ "elections",
+ "vance"
+ ],
+ "entry": {
+ "title": "t",
+ "summary": "s",
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/story.html",
+ "sourceUrl": "",
+ "categories": [
+ "Elections",
+ "Vance"
+ ],
+ "categorySchemes": []
+ }
+ },
+ "output": true
+ },
+ {
+ "input": {
+ "offTopics": [
+ "Section:nytimes.com/us"
+ ],
+ "entry": {
+ "title": "t",
+ "summary": "s",
+ "url": "https://www.nytimes.com/2026/10/19/us/politics/story.html",
+ "sourceUrl": "",
+ "categories": [
+ "Elections",
+ "Vance"
+ ],
+ "categorySchemes": []
+ }
+ },
+ "output": false
+ },
+ {
+ "input": {
+ "offTopics": [
+ "section:bbc.com"
+ ],
+ "entry": {
+ "title": "t",
+ "summary": "s",
+ "url": "https://news.google.com/rss/articles/x",
+ "sourceUrl": "https://www.bbc.com/",
+ "categories": [],
+ "categorySchemes": []
+ }
+ },
+ "output": true
+ },
+ {
+ "input": {
+ "offTopics": [
+ "section:news.google.com"
+ ],
+ "entry": {
+ "title": "t",
+ "summary": "s",
+ "url": "https://news.google.com/rss/articles/x",
+ "sourceUrl": "https://www.bbc.com/",
+ "categories": [],
+ "categorySchemes": []
+ }
+ },
+ "output": false
+ }
+ ],
+ "toggles": [
+ {
+ "input": {
+ "offTopics": [],
+ "place": "nytimes.com/us"
+ },
+ "output": {
+ "off": false,
+ "offTopics": [
+ "section:nytimes.com/us"
+ ]
+ }
+ },
+ {
+ "input": {
+ "offTopics": [
+ "Elections",
+ "section:nytimes.com/us/politics"
+ ],
+ "place": "nytimes.com/us"
+ },
+ "output": {
+ "off": false,
+ "offTopics": [
+ "Elections",
+ "section:nytimes.com/us"
+ ]
+ }
+ },
+ {
+ "input": {
+ "offTopics": [
+ "section:nytimes.com/us",
+ "section:nytimes.com/world"
+ ],
+ "place": "nytimes.com/us/politics"
+ },
+ "output": {
+ "off": true,
+ "offTopics": [
+ "section:nytimes.com/world"
+ ]
+ }
+ },
+ {
+ "input": {
+ "offTopics": [
+ "section:nytimes.com/us"
+ ],
+ "place": "nytimes.com/us"
+ },
+ "output": {
+ "off": true,
+ "offTopics": []
+ }
+ },
+ {
+ "input": {
+ "offTopics": [
+ "section:nytimes.com/us/politics"
+ ],
+ "place": "nytimes.com/us/politics"
+ },
+ "output": {
+ "off": true,
+ "offTopics": []
+ }
+ },
+ {
+ "input": {
+ "offTopics": [
+ "section:nytimes.com/usa"
+ ],
+ "place": "nytimes.com/us"
+ },
+ "output": {
+ "off": false,
+ "offTopics": [
+ "section:nytimes.com/usa",
+ "section:nytimes.com/us"
+ ]
+ }
+ }
+ ]
+}
diff --git a/shared/fixtures/parse.json b/shared/fixtures/parse.json
index 9a1c5f7..0352c4a 100644
--- a/shared/fixtures/parse.json
+++ b/shared/fixtures/parse.json
@@ -19,6 +19,8 @@
"imageUrl": "https://i1.ytimg.com/vi/abcDEF12345/hqdefault.jpg",
"summary": "Today we build a birdhouse. Tools & simple. Chapters: 0:00 Intro",
"categories": [],
+ "categorySchemes": [],
+ "sourceUrl": "",
"html": "Today we build a birdhouse.
Tools <cheap> & simple.
Chapters:
0:00 Intro"
},
{
@@ -30,6 +32,8 @@
"imageUrl": "https://i2.ytimg.com/vi/zyx98765432/hqdefault.jpg",
"summary": "",
"categories": [],
+ "categorySchemes": [],
+ "sourceUrl": "",
"html": ""
}
]
@@ -54,6 +58,8 @@
"imageUrl": "https://photos.example.org/7.jpg",
"summary": "Boats leaving early.",
"categories": [],
+ "categorySchemes": [],
+ "sourceUrl": "",
"html": "The full story.
"
}
]
@@ -78,6 +84,8 @@
"imageUrl": "https://i.example.com/one-460.jpg",
"summary": "",
"categories": [],
+ "categorySchemes": [],
+ "sourceUrl": "",
"html": ""
},
{
@@ -89,6 +97,8 @@
"imageUrl": "https://i.example.com/two.jpg",
"summary": "",
"categories": [],
+ "categorySchemes": [],
+ "sourceUrl": "",
"html": ""
},
{
@@ -100,6 +110,8 @@
"imageUrl": "https://pictures.example.com/three-1024.jpg",
"summary": "",
"categories": [],
+ "categorySchemes": [],
+ "sourceUrl": "",
"html": "
"
},
{
@@ -111,6 +123,8 @@
"imageUrl": "https://pictures.example.com/four@2x.jpg",
"summary": "",
"categories": [],
+ "categorySchemes": [],
+ "sourceUrl": "",
"html": "
"
},
{
@@ -122,6 +136,8 @@
"imageUrl": "https://cdn.example.com/five.jpg?w=800&q=80",
"summary": "",
"categories": [],
+ "categorySchemes": [],
+ "sourceUrl": "",
"html": "
"
},
{
@@ -133,6 +149,8 @@
"imageUrl": "https://i.example.com/six.jpg",
"summary": "",
"categories": [],
+ "categorySchemes": [],
+ "sourceUrl": "",
"html": "
"
},
{
@@ -144,6 +162,8 @@
"imageUrl": "https://i.example.com/seven-480.jpg",
"summary": "",
"categories": [],
+ "categorySchemes": [],
+ "sourceUrl": "",
"html": ""
},
{
@@ -155,10 +175,91 @@
"imageUrl": "https://pictures.example.com/eight-1600.jpg",
"summary": "",
"categories": [],
+ "categorySchemes": [],
+ "sourceUrl": "",
"html": "
"
}
]
}
+ },
+ {
+ "input": {
+ "file": "rss.xml",
+ "feedUrl": "https://example.com/rss"
+ },
+ "output": {
+ "title": "Example Blog",
+ "siteUrl": "https://example.com/",
+ "iconUrl": "https://example.com/icon.png",
+ "entries": [
+ {
+ "guid": "post-1",
+ "title": "First post",
+ "url": "https://example.com/posts/first",
+ "published": 1791278040000,
+ "author": "Ada",
+ "imageUrl": "https://example.com/img/a.png",
+ "summary": "Short & sweet summary",
+ "categories": [
+ "Elections",
+ "World",
+ "World"
+ ],
+ "categorySchemes": [
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "",
+ "http://www.nytimes.com/namespaces/keywords/nyt_geo"
+ ],
+ "sourceUrl": "",
+ "html": "Full body
"
+ },
+ {
+ "guid": "https://example.com/posts/second",
+ "title": "Second post",
+ "url": "https://example.com/posts/second",
+ "published": 0,
+ "author": "",
+ "imageUrl": "https://example.com/thumb.jpg",
+ "summary": "Only a summary",
+ "categories": [],
+ "categorySchemes": [],
+ "sourceUrl": "https://news.example.net/world/story",
+ "html": "Only a summary"
+ }
+ ]
+ }
+ },
+ {
+ "input": {
+ "file": "atom.xml",
+ "feedUrl": "https://atom.example.org/feed.xml"
+ },
+ "output": {
+ "title": "Atom Example",
+ "siteUrl": "https://atom.example.org/",
+ "iconUrl": "https://atom.example.org/atom.ico",
+ "entries": [
+ {
+ "guid": "tag:atom.example.org,2026:1",
+ "title": "Hello Atom",
+ "url": "https://atom.example.org/entries/1",
+ "published": 1791187200000,
+ "author": "Grace",
+ "imageUrl": "",
+ "summary": "",
+ "categories": [
+ "Science",
+ "Notes"
+ ],
+ "categorySchemes": [
+ "https://atom.example.org/subjects",
+ ""
+ ],
+ "sourceUrl": "",
+ "html": "Hello
"
+ }
+ ]
+ }
}
]
}
diff --git a/shared/fixtures/rss.xml b/shared/fixtures/rss.xml
index 9b56ee7..1710809 100644
--- a/shared/fixtures/rss.xml
+++ b/shared/fixtures/rss.xml
@@ -13,6 +13,8 @@
Short & sweet <b>summary</b>
Elections
World
+ World
+ Elections
Full body
]]>
-
@@ -20,6 +22,7 @@
https://example.com/posts/second
Only a summary
+ Example News
diff --git a/shared/src/feed.test.ts b/shared/src/feed.test.ts
index ab8b7f9..3703eca 100644
--- a/shared/src/feed.test.ts
+++ b/shared/src/feed.test.ts
@@ -16,13 +16,21 @@ test("parses RSS 2.0 with content, media and relative links", async () => {
expect(first?.url).toBe("https://example.com/posts/first");
expect(first?.author).toBe("Ada");
expect(first?.summary).toBe("Short & sweet summary");
- expect(first?.categories).toEqual(["Elections", "World"]);
+ // a topic under two schemes is kept with each, and each pair once
+ expect(first?.categories).toEqual(["Elections", "World", "World"]);
+ expect(first?.categorySchemes).toEqual([
+ "http://www.nytimes.com/namespaces/keywords/des",
+ "",
+ "http://www.nytimes.com/namespaces/keywords/nyt_geo",
+ ]);
+ expect(first?.sourceUrl).toBe("");
expect(first?.published).toBe(Date.parse("2026-10-06T09:14:00Z"));
expect(first?.html).toContain("Full body");
expect(first?.imageUrl).toBe("https://example.com/img/a.png");
expect(second?.guid).toBe("https://example.com/posts/second");
expect(second?.imageUrl).toBe("https://example.com/thumb.jpg");
expect(second?.published).toBe(0);
+ expect(second?.sourceUrl).toBe("https://news.example.net/world/story");
});
test("parses Atom", async () => {
diff --git a/shared/src/feed.ts b/shared/src/feed.ts
index 4c17135..6a9f651 100644
--- a/shared/src/feed.ts
+++ b/shared/src/feed.ts
@@ -25,6 +25,13 @@ export interface FeedEntry {
summary: string;
/** Sections, topics and tags the feed lists for the article. */
categories: string[];
+ /**
+ * The scheme of each of `categories` (RSS `domain`, Atom `scheme`), at the
+ * same index; empty for none. Missing from older extensions.
+ */
+ categorySchemes?: string[];
+ /** Address of the story's source (RSS ``); empty for none. Missing from older extensions. */
+ sourceUrl?: string;
/** Article body as HTML, not yet sanitized. */
html: string;
}
@@ -175,17 +182,35 @@ function plainText(html: string): string {
.trim();
}
-function categoryNames(values: unknown[]): string[] {
- const names: string[] = [];
+/** An article's categories and the scheme of each, at the same index. */
+interface Categories {
+ categories: string[];
+ categorySchemes: string[];
+}
+
+/** Category names with their schemes, from the attribute that names the scheme; each pair once. */
+function categoryNames(values: unknown[], schemeAttribute: string): Categories {
+ const result: Categories = { categories: [], categorySchemes: [] };
+ const seen = new Set();
for (const value of values) {
const name = isNode(value)
? displayText(value["@_term"]) || displayText(value)
: displayText(value);
- if (name && !names.includes(name)) {
- names.push(name);
+ const scheme = isNode(value) ? text(value[schemeAttribute]) : "";
+ const pair = `${scheme}\n${name}`;
+ if (name !== "" && !seen.has(pair)) {
+ seen.add(pair);
+ result.categories.push(name);
+ result.categorySchemes.push(scheme);
}
}
- return names;
+ return result;
+}
+
+/** The address an RSS item's `` names; empty for none. */
+function sourceAddress(item: Node, base: string): string {
+ const source = list(item.source).find(isNode);
+ return upgradedAddress(text(source?.["@_url"]), base);
}
const ZONE_OFFSETS = new Map([
@@ -441,7 +466,8 @@ function parseRssItem(item: Node, base: string): FeedEntry {
author: displayText(item.creator) || displayText(item.author),
imageUrl: leadImage(mediaImages(item, base), html, base),
summary: plainText(text(item.description)) || media.summary,
- categories: categoryNames(list(item.category)),
+ ...categoryNames(list(item.category), "@_domain"),
+ sourceUrl: sourceAddress(item, base),
html,
};
}
@@ -469,7 +495,8 @@ function parseAtomEntry(entry: Node, base: string): FeedEntry {
author: author ? displayText(author.name) : "",
imageUrl: leadImage(mediaImages(entry, base), html, base),
summary: plainText(text(entry.summary)) || media.summary,
- categories: categoryNames(list(entry.category)),
+ ...categoryNames(list(entry.category), "@_scheme"),
+ sourceUrl: "",
html,
};
}
@@ -494,6 +521,9 @@ function parseJsonFeed(source: Node, base: string): ParsedFeed {
const title = text(item.title);
const published = firstDate(item, ["date_published", "date_modified"]);
const authors = list(item.authors).filter(isNode);
+ const tags = list(item.tags)
+ .map((tag) => text(tag))
+ .filter(Boolean);
entries.push({
guid: text(item.id) || url || title,
title: title || url,
@@ -511,9 +541,9 @@ function parseJsonFeed(source: Node, base: string): ParsedFeed {
base,
),
summary: plainText(text(item.summary)),
- categories: list(item.tags)
- .map((tag) => text(tag))
- .filter(Boolean),
+ categories: tags,
+ categorySchemes: tags.map(() => ""),
+ sourceUrl: "",
html,
});
}
diff --git a/shared/src/filter.ts b/shared/src/filter.ts
index 7ca365d..ce5018b 100644
--- a/shared/src/filter.ts
+++ b/shared/src/filter.ts
@@ -1,3 +1,11 @@
+import { compareNames } from "./names.ts";
+import {
+ articlePlace,
+ offPlaces,
+ placeHidden,
+ SECTION_PREFIX,
+} from "./sections.ts";
+
/** The details of an article that filters look at. */
export interface FilterEntry {
/** The article's title. */
@@ -6,6 +14,20 @@ export interface FilterEntry {
summary: string;
/** Sections, topics and tags the feed lists for the article. */
categories: string[];
+ /** The scheme of each of `categories` at the same index; empty or missing for none. */
+ categorySchemes?: string[];
+ /** Link to the original; sections come from it. Missing counts as none. */
+ url?: string;
+ /** Address of the story's source, which sections come from instead; empty or missing for none. */
+ sourceUrl?: string;
+}
+
+/** A topic with how many articles carry it. */
+export interface TopicCount {
+ /** The topic as the last article carrying it spelled it. */
+ topic: string;
+ /** Articles that carry it. */
+ count: number;
}
/** How one feed, group or the main list is shown. */
@@ -16,7 +38,11 @@ export interface ViewSettings {
hideWords: string[];
/** When not empty, only articles whose title or summary has one of these stay. */
onlyWords: string[];
- /** Topics turned off; an article whose topics are all off is hidden. */
+ /**
+ * Topics turned off; an article whose topics are all off is hidden. Entries
+ * starting with `SECTION_PREFIX` hold turned-off sections' places instead,
+ * which hide every article in them.
+ */
offTopics: string[];
}
@@ -130,10 +156,8 @@ function matchesEntry(matcher: Matcher, entry: FilterEntry): boolean {
* The topics the articles carry, with how many articles carry each,
* commonest first and then by name.
*/
-export function topicCounts(
- entries: readonly FilterEntry[],
-): { topic: string; count: number }[] {
- const counts = new Map();
+export function topicCounts(entries: readonly FilterEntry[]): TopicCount[] {
+ const counts = new Map();
for (const entry of entries) {
const seen = new Set();
for (const topic of entry.categories) {
@@ -156,7 +180,7 @@ function compareCounted(
secondCount: number,
secondName: string,
): number {
- return secondCount - firstCount || firstName.localeCompare(secondName);
+ return secondCount - firstCount || compareNames(firstName, secondName);
}
/**
@@ -179,9 +203,7 @@ export function byCount(
* Each topic's place in the view's order, by its trimmed lower-case form,
* from `topicCounts`; built once per view so each article looks its topics up.
*/
-export function topicRanks(
- counts: readonly { topic: string; count: number }[],
-): Map {
+export function topicRanks(counts: readonly TopicCount[]): Map {
const ranks = new Map();
for (const [index, { topic }] of counts.entries()) {
ranks.set(topic.trim().toLowerCase(), index);
@@ -210,7 +232,7 @@ export function articleTopics(
return unique
.sort(
(first, second) =>
- first.rank - second.rank || first.topic.localeCompare(second.topic),
+ first.rank - second.rank || compareNames(first.topic, second.topic),
)
.map(({ topic }) => topic);
}
@@ -222,7 +244,12 @@ export function articleTopics(
export function keeper(
settings: ViewSettings,
): (entry: FilterEntry) => boolean {
- const off = new Set(settings.offTopics.map((topic) => topic.toLowerCase()));
+ const off = new Set(
+ settings.offTopics
+ .filter((topic) => !topic.startsWith(SECTION_PREFIX))
+ .map((topic) => topic.toLowerCase()),
+ );
+ const places = offPlaces(settings.offTopics);
const hide = matcherFor(settings.hideWords);
const only = matcherFor(settings.onlyWords);
const onlySome = settings.onlyWords.length > 0;
@@ -231,12 +258,158 @@ export function keeper(
off.size > 0 &&
entry.categories.length > 0 &&
entry.categories.every((topic) => off.has(topic.trim().toLowerCase()));
- const hidden = matchesEntry(hide, entry);
+ const hidden =
+ matchesEntry(hide, entry) ||
+ (places.length > 0 &&
+ placeHidden(
+ places,
+ articlePlace({ url: entry.url ?? "", sourceUrl: entry.sourceUrl }),
+ ));
const kept = !onlySome || matchesEntry(only, entry);
return !(topicsOff || hidden) && kept;
};
}
+function topicKey(topic: string): string {
+ return topic.trim().toLowerCase();
+}
+
+/** Each topic of an article with its scheme ("" for none), each pair once, blank topics left out. */
+function schemedTopics(entry: FilterEntry): [string, string][] {
+ const pairs = new Map();
+ for (const [index, topic] of entry.categories.entries()) {
+ const key = topicKey(topic);
+ const scheme = entry.categorySchemes?.[index] ?? "";
+ if (key !== "") {
+ pairs.set(`${scheme}\n${key}`, [key, scheme]);
+ }
+ }
+ return [...pairs.values()];
+}
+
+function countUp(counts: Map, key: string): void {
+ counts.set(key, (counts.get(key) ?? 0) + 1);
+}
+
+/** Schemes the articles use for more than one distinct topic; the others join the plain group. */
+function groupingSchemes(pairs: readonly [string, string][][]): Set {
+ const terms = new Map>();
+ for (const [key, scheme] of pairs.flat()) {
+ terms.set(scheme, (terms.get(scheme) ?? new Set()).add(key));
+ }
+ return new Set(
+ [...terms]
+ .filter(([scheme, keys]) => scheme !== "" && keys.size > 1)
+ .map(([scheme]) => scheme),
+ );
+}
+
+/** The scheme that covers more articles, then the later in code-unit order. */
+function largerScheme(
+ first: string,
+ second: string,
+ articles: ReadonlyMap,
+): string {
+ const difference = (articles.get(second) ?? 0) - (articles.get(first) ?? 0);
+ return difference > 0 || (difference === 0 && second > first)
+ ? second
+ : first;
+}
+
+/**
+ * The view's topics in groups, by the scheme the feed gives each (RSS
+ * `domain`, Atom `scheme`): topics with no scheme, and those under a scheme
+ * the articles use for only one topic, form the plain group; a topic under
+ * two schemes goes in the group covering more articles. Groups come in order
+ * of how many articles they cover (then by scheme); topics in each by count,
+ * then name, as `topicCounts`.
+ */
+export function topicGroups(entries: readonly FilterEntry[]): TopicCount[][] {
+ const pairs = entries.map(schemedTopics);
+ const grouping = groupingSchemes(pairs);
+ const articles = new Map();
+ const schemesOf = new Map>();
+ for (const article of pairs) {
+ const grouped = article.map(([key, scheme]): [string, string] => [
+ key,
+ grouping.has(scheme) ? scheme : "",
+ ]);
+ for (const used of new Set(grouped.map(([, scheme]) => scheme))) {
+ countUp(articles, used);
+ }
+ for (const [key, scheme] of grouped) {
+ schemesOf.set(key, (schemesOf.get(key) ?? new Set()).add(scheme));
+ }
+ }
+ const groups = new Map();
+ for (const counted of topicCounts(entries)) {
+ const schemes = [...(schemesOf.get(topicKey(counted.topic)) ?? [""])];
+ const scheme = schemes.reduce((best, other) =>
+ largerScheme(best, other, articles),
+ );
+ groups.set(scheme, [...(groups.get(scheme) ?? []), counted]);
+ }
+ return [...groups]
+ .sort(
+ ([first], [second]) =>
+ (articles.get(second) ?? 0) - (articles.get(first) ?? 0) ||
+ (first < second ? -1 : Number(first > second)),
+ )
+ .map(([, topics]) => topics);
+}
+
+/** Each topic's group and place in it, by its trimmed lower-case form, from `topicGroups`. */
+export interface GroupOrder {
+ /** Index of the topic's group. */
+ group: Map;
+ /** Place of the topic within its group. */
+ rank: Map;
+}
+
+/** Where each topic sits in the view's groups; built once per view. */
+export function groupOrder(groups: readonly TopicCount[][]): GroupOrder {
+ const order: GroupOrder = { group: new Map(), rank: new Map() };
+ for (const [index, topics] of groups.entries()) {
+ for (const [rank, { topic }] of topics.entries()) {
+ order.group.set(topicKey(topic), index);
+ order.rank.set(topicKey(topic), rank);
+ }
+ }
+ return order;
+}
+
+/**
+ * An article's topics as rows, one per group of the view it has topics in,
+ * in the view's order (`groupOrder`); topics the view does not know form a
+ * last row, by name.
+ */
+export function articleTopicGroups(
+ categories: readonly string[],
+ order: GroupOrder,
+): string[][] {
+ const rows = new Map();
+ const seen = new Set();
+ for (const topic of categories) {
+ const key = topicKey(topic);
+ if (key !== "" && !seen.has(key)) {
+ seen.add(key);
+ const group = order.group.get(key) ?? Number.POSITIVE_INFINITY;
+ const rank = order.rank.get(key) ?? Number.POSITIVE_INFINITY;
+ rows.set(group, [...(rows.get(group) ?? []), { topic, rank }]);
+ }
+ }
+ return [...rows]
+ .sort(([first], [second]) => first - second)
+ .map(([, topics]) =>
+ topics
+ .sort(
+ (first, second) =>
+ first.rank - second.rank || compareNames(first.topic, second.topic),
+ )
+ .map(({ topic }) => topic),
+ );
+}
+
/** Whether an article stays after the settings' filters are applied. */
export function passes(settings: ViewSettings, entry: FilterEntry): boolean {
return keeper(settings)(entry);
diff --git a/shared/src/filters-fixture.test.ts b/shared/src/filters-fixture.test.ts
new file mode 100644
index 0000000..8b279fc
--- /dev/null
+++ b/shared/src/filters-fixture.test.ts
@@ -0,0 +1,96 @@
+import { expect, test } from "bun:test";
+import {
+ articleTopicGroups,
+ DEFAULT_SETTINGS,
+ type FilterEntry,
+ groupOrder,
+ passes,
+ type TopicCount,
+ topicGroups,
+} from "./filter.ts";
+import { compareNames } from "./names.ts";
+import {
+ articlePlace,
+ type CountedSection,
+ feedSections,
+ type SectionChip,
+ type SectionSource,
+ sectionCounts,
+ sectionOff,
+ toggledSection,
+} from "./sections.ts";
+
+/** `fixtures/filters.json`: sections, topic groups, hiding and toggling, which every client's tests read. */
+interface FiltersFixture {
+ names: { input: string[]; output: string[] };
+ feeds: {
+ name: string;
+ input: FilterEntry[];
+ output: {
+ sections: SectionChip[][];
+ counts: CountedSection[];
+ groups: TopicCount[][];
+ articleTopics: string[][][];
+ };
+ }[];
+ places: { input: SectionSource; output: string }[];
+ hides: {
+ input: { offTopics: string[]; entry: FilterEntry };
+ output: boolean;
+ }[];
+ toggles: {
+ input: { offTopics: string[]; place: string };
+ output: { off: boolean; offTopics: string[] };
+ }[];
+}
+
+const fixture: FiltersFixture = await Bun.file(
+ new URL("../fixtures/filters.json", import.meta.url),
+).json();
+
+test.each(fixture.feeds.map((item) => [item.name, item] as const))(
+ "sections and topic groups of %s match the fixture",
+ (_name, item) => {
+ const sources = item.input.map((entry) => ({
+ url: entry.url ?? "",
+ sourceUrl: entry.sourceUrl,
+ }));
+ const sections = feedSections(sources);
+ const groups = topicGroups(item.input);
+ const order = groupOrder(groups);
+ expect(sections).toEqual(item.output.sections);
+ expect(sectionCounts(sections)).toEqual(item.output.counts);
+ expect(groups).toEqual(item.output.groups);
+ expect(
+ item.input.map((entry) => articleTopicGroups(entry.categories, order)),
+ ).toEqual(item.output.articleTopics);
+ },
+);
+
+test("names sort as the fixture says", () => {
+ expect([...fixture.names.input].sort(compareNames)).toEqual(
+ fixture.names.output,
+ );
+});
+
+test("article places match the fixture", () => {
+ for (const item of fixture.places) {
+ expect(articlePlace(item.input)).toBe(item.output);
+ }
+});
+
+test("turned-off sections and topics hide as the fixture says", () => {
+ for (const { input, output } of fixture.hides) {
+ const settings = { ...DEFAULT_SETTINGS, offTopics: input.offTopics };
+ expect(!passes(settings, input.entry)).toBe(output);
+ }
+});
+
+test("tapping a section changes the stored places as the fixture says", () => {
+ for (const { input, output } of fixture.toggles) {
+ expect(sectionOff(input.offTopics, input.place)).toBe(output.off);
+ expect(toggledSection(input.offTopics, input.place)).toEqual(
+ output.offTopics,
+ );
+ }
+});
diff --git a/shared/src/names.ts b/shared/src/names.ts
new file mode 100644
index 0000000..a8c4d7f
--- /dev/null
+++ b/shared/src/names.ts
@@ -0,0 +1,28 @@
+const MARKS = /\p{M}/gu;
+
+/** A name with accents dropped and capitals ignored, for its first comparison. */
+function plainName(name: string): string {
+ return name.normalize("NFD").replace(MARKS, "").toLowerCase();
+}
+
+function byCodeUnits(first: string, second: string): number {
+ if (first < second) {
+ return -1;
+ } else {
+ return Number(first > second);
+ }
+}
+
+/**
+ * The order of two topic or section names, the same on every platform: by
+ * the names with accents and capitals ignored, then with capitals ignored,
+ * then as written, each in UTF-16 code-unit order. A locale's collation is
+ * not used, since engines order punctuation such as `7-1` and `7.1` apart.
+ */
+export function compareNames(first: string, second: string): number {
+ return (
+ byCodeUnits(plainName(first), plainName(second)) ||
+ byCodeUnits(first.toLowerCase(), second.toLowerCase()) ||
+ byCodeUnits(first, second)
+ );
+}
diff --git a/shared/src/sections.ts b/shared/src/sections.ts
new file mode 100644
index 0000000..e543807
--- /dev/null
+++ b/shared/src/sections.ts
@@ -0,0 +1,367 @@
+import { compareNames } from "./names.ts";
+
+/** One section chip: the words it shows and the place turning it off stores. */
+export interface SectionChip {
+ /** The folders below those every article of the feed shares, or the site. */
+ label: string;
+ /** The site and every folder above and at the section, joined by `/`. */
+ place: string;
+}
+
+/** A section chip with how many articles it covers. */
+export interface CountedSection extends SectionChip {
+ /** Articles of the view in the section. */
+ count: number;
+}
+
+/** What section chips are worked out from. */
+export interface SectionSource {
+ /** Link to the original article. */
+ url: string;
+ /** Address of the story's source; empty or missing for none. */
+ sourceUrl?: string;
+}
+
+/** Where an article sits: its site, then the folders of its address that can name a section. */
+interface Place {
+ domain: string;
+ parts: string[];
+}
+
+/** An entry of `offTopics` that holds a turned-off section's place rather than a topic. */
+export const SECTION_PREFIX = "section:";
+
+const MONTHS = new Set([
+ "jan",
+ "feb",
+ "mar",
+ "apr",
+ "may",
+ "jun",
+ "jul",
+ "aug",
+ "sep",
+ "sept",
+ "oct",
+ "nov",
+ "dec",
+ "january",
+ "february",
+ "march",
+ "april",
+ "june",
+ "july",
+ "august",
+ "september",
+ "october",
+ "november",
+ "december",
+]);
+const DIGIT = /[0-9]/u;
+const PERCENT_RUN = /(?:%[0-9A-Fa-f]{2})+/gu;
+const WWW = /^www\./u;
+const utf8 = new TextDecoder();
+
+/** Percent escapes decoded as UTF-8, malformed bytes becoming U+FFFD. */
+function decodePart(part: string): string {
+ return part.replace(PERCENT_RUN, (run) =>
+ utf8.decode(
+ Uint8Array.from(run.slice(1).split("%"), (hex) =>
+ Number.parseInt(hex, 16),
+ ),
+ ),
+ );
+}
+
+/** The address sections come from: the story's source when the feed names one, else its link. */
+export function sectionAddress(entry: SectionSource): string {
+ const source = entry.sourceUrl ?? "";
+ return source === "" ? entry.url : source;
+}
+
+function placeOf(address: string): Place {
+ let place: Place = { domain: "", parts: [] };
+ if (URL.canParse(address)) {
+ const url = new URL(address);
+ const segments = url.pathname
+ .split("/")
+ .filter((segment) => segment !== "")
+ .map(decodePart);
+ place = {
+ domain: url.hostname.replace(WWW, ""),
+ parts: segments
+ .slice(0, -1)
+ .map((part) => part.toLowerCase())
+ .filter((part) => !(DIGIT.test(part) || MONTHS.has(part))),
+ };
+ }
+ return place;
+}
+
+function joined(place: Place): string {
+ return [place.domain, ...place.parts].join("/");
+}
+
+/** Places already worked out, by address, since filters test every article. */
+const places = new Map();
+// Addresses kept before the cache starts over.
+const PLACE_LIMIT = 100_000;
+
+/**
+ * Where an article sits, as a turned-off section's place is stored: the host
+ * without `www.`, then the folders of its address, leaving out its own name,
+ * any with a digit and month names; empty when the address cannot be read.
+ */
+export function articlePlace(entry: SectionSource): string {
+ const address = sectionAddress(entry);
+ let place = places.get(address);
+ if (place === undefined) {
+ const found = placeOf(address);
+ place = found.domain === "" ? "" : joined(found);
+ if (places.size >= PLACE_LIMIT) {
+ places.clear();
+ }
+ places.set(address, place);
+ }
+ return place;
+}
+
+/** Whether `place` is `outer` or lies under it. */
+function within(place: string, outer: string): boolean {
+ return place === outer || place.startsWith(`${outer}/`);
+}
+
+/** Whether any of the stored places holds `place`. */
+export function placeHidden(stored: readonly string[], place: string): boolean {
+ return place !== "" && stored.some((outer) => within(place, outer));
+}
+
+/** The places of the turned-off sections among a view's `offTopics`. */
+export function offPlaces(offTopics: readonly string[]): string[] {
+ return offTopics
+ .filter((topic) => topic.startsWith(SECTION_PREFIX))
+ .map((topic) => topic.slice(SECTION_PREFIX.length));
+}
+
+/** Whether a section is off: it or a section above it was turned off. */
+export function sectionOff(
+ offTopics: readonly string[],
+ place: string,
+): boolean {
+ return placeHidden(offPlaces(offTopics), place);
+}
+
+/**
+ * The `offTopics` after tapping a section: an off section comes back on with
+ * every section above it, and turning one off clears the sections below it.
+ */
+export function toggledSection(
+ offTopics: readonly string[],
+ place: string,
+): string[] {
+ const off = sectionOff(offTopics, place);
+ const kept = offTopics.filter((topic) => {
+ const stored = topic.startsWith(SECTION_PREFIX)
+ ? topic.slice(SECTION_PREFIX.length)
+ : undefined;
+ return (
+ stored === undefined ||
+ (off ? !within(place, stored) : !within(stored, place))
+ );
+ });
+ return off ? kept : [...kept, `${SECTION_PREFIX}${place}`];
+}
+
+/** One folder in the tree of a feed's article folders. */
+interface FolderNode {
+ articles: number[];
+ children: Map;
+}
+
+function folderTree(paths: readonly string[][]): FolderNode {
+ const root: FolderNode = { articles: [], children: new Map() };
+ for (const [index, parts] of paths.entries()) {
+ root.articles.push(index);
+ let node = root;
+ for (const part of parts) {
+ let child = node.children.get(part);
+ if (child === undefined) {
+ child = { articles: [], children: new Map() };
+ node.children.set(part, child);
+ }
+ child.articles.push(index);
+ node = child;
+ }
+ }
+ return root;
+}
+
+/** Each path cut at its first folder fewer than two articles share under the same parents. */
+function sharedPaths(paths: readonly string[][]): string[][] {
+ const root = folderTree(paths);
+ return paths.map((parts) => {
+ const kept: string[] = [];
+ let node: FolderNode | undefined = root;
+ for (const part of parts) {
+ node = node?.children.get(part);
+ if (node === undefined || node.articles.length < 2) {
+ break;
+ }
+ kept.push(part);
+ }
+ return kept;
+ });
+}
+
+/** Whether every path has the same folder at `depth`. */
+function sharesFolder(paths: readonly string[][], depth: number): boolean {
+ return (
+ paths.length > 0 &&
+ paths.every((parts) => parts.length > depth) &&
+ new Set(paths.map((parts) => parts[depth])).size === 1
+ );
+}
+
+/** How many leading folders every path has in common. */
+function commonDepth(paths: readonly string[][]): number {
+ let depth = 0;
+ while (sharesFolder(paths, depth)) {
+ depth += 1;
+ }
+ return depth;
+}
+
+/** Section chips by folder, for articles that share one site. */
+function folderSections(domain: string, paths: string[][]): SectionChip[][] {
+ const result: SectionChip[][] = paths.map(() => []);
+ const common = commonDepth(paths);
+ function walk(node: FolderNode, depth: number, trail: string[]): void {
+ const stops = node.articles.some((index) => paths[index]?.length === depth);
+ const splits =
+ node.children.size > 1 || (stops && node.children.size === 1);
+ for (const [part, child] of node.children) {
+ const here = [...trail, part];
+ if (splits && depth >= common) {
+ const chip = {
+ label: here.slice(common).join("/"),
+ place: [domain, ...here].join("/"),
+ };
+ for (const index of child.articles) {
+ result[index]?.push(chip);
+ }
+ }
+ walk(child, depth + 1, here);
+ }
+ }
+ walk(folderTree(paths), 0, []);
+ return result;
+}
+
+/**
+ * Each article's section chips, outermost first, worked out from all the
+ * articles of one feed.
+ *
+ * When the articles' addresses (`sectionAddress`) span more than one site,
+ * each article's only section is its site. Otherwise sections come from the
+ * folders of the addresses (`articlePlace`): a folder counts only when two
+ * articles have it under the same parents, folders every article shares are
+ * skipped, and each folder that splits the articles gives a chip, so
+ * `/us/politics/` gives `us` and `us/politics`.
+ */
+export function feedSections(
+ entries: readonly SectionSource[],
+): SectionChip[][] {
+ const found = entries.map((entry) => placeOf(sectionAddress(entry)));
+ const domains = new Set(
+ found.map(({ domain }) => domain).filter((domain) => domain !== ""),
+ );
+ let result: SectionChip[][];
+ if (domains.size > 1) {
+ result = found.map(({ domain }) =>
+ domain === "" ? [] : [{ label: domain, place: domain }],
+ );
+ } else {
+ const [domain = ""] = domains;
+ const usable = found.map((place, index) => ({ place, index }));
+ const kept = usable.filter(({ place }) => place.domain !== "");
+ const chips = folderSections(
+ domain,
+ sharedPaths(kept.map(({ place }) => place.parts)),
+ );
+ result = found.map(() => []);
+ for (const [position, { index }] of kept.entries()) {
+ result[index] = chips[position] ?? [];
+ }
+ }
+ return result;
+}
+
+function compareSections(
+ first: CountedSection,
+ second: CountedSection,
+): number {
+ return second.count - first.count || compareNames(first.label, second.label);
+}
+
+/** The shorter label, then the first in code-unit order, so every device names a place alike. */
+function preferredLabel(first: string, second: string): string {
+ let preferred = first;
+ if (
+ second.length < first.length ||
+ (second.length === first.length && second < first)
+ ) {
+ preferred = second;
+ }
+ return preferred;
+}
+
+/** The nearest section above `place` among the counted ones. */
+function parentPlace(
+ place: string,
+ counted: ReadonlyMap,
+): string | undefined {
+ let outer = place;
+ let parent: string | undefined;
+ while (parent === undefined && outer.includes("/")) {
+ outer = outer.slice(0, outer.lastIndexOf("/"));
+ parent = counted.has(outer) ? outer : undefined;
+ }
+ return parent;
+}
+
+/**
+ * A view's section chips with how many of its articles each covers, from
+ * each article's chips (`feedSections`): the most articles first, then by
+ * name, each followed by the sections under it in the same order.
+ */
+export function sectionCounts(
+ articles: readonly (readonly SectionChip[])[],
+): CountedSection[] {
+ const counted = new Map();
+ for (const chips of articles) {
+ for (const { label, place } of new Map(
+ chips.map((chip) => [chip.place, chip]),
+ ).values()) {
+ const known = counted.get(place);
+ counted.set(place, {
+ label: known === undefined ? label : preferredLabel(known.label, label),
+ place,
+ count: (known?.count ?? 0) + 1,
+ });
+ }
+ }
+ const children = new Map();
+ for (const section of counted.values()) {
+ const parent = parentPlace(section.place, counted);
+ children.set(parent, [...(children.get(parent) ?? []), section]);
+ }
+ const ordered: CountedSection[] = [];
+ function add(parent: string | undefined): void {
+ for (const section of (children.get(parent) ?? []).sort(compareSections)) {
+ ordered.push(section);
+ add(section.place);
+ }
+ }
+ add(undefined);
+ return ordered;
+}
diff --git a/web/src/app.css b/web/src/app.css
index 317a5a5..da2e319 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -148,3 +148,39 @@ input,
opacity: 0.45;
}
}
+
+/* a row of filter chips scrolls sideways with no scrollbar; filter-rows.svelte marks where more chips lie past its ends */
+/* biome-ignore lint/nursery/noUnusedClasses: set by filter-rows.svelte */
+.chip-row {
+ overscroll-behavior-x: contain;
+}
+
+@supports (scrollbar-width: none) {
+ /* biome-ignore lint/nursery/noUnusedClasses: set by filter-rows.svelte */
+ .chip-row {
+ scrollbar-width: none;
+ }
+}
+
+@supports (mask-image: none) {
+ /* biome-ignore lint/nursery/noUnusedClasses: set by filter-rows.svelte */
+ .chip-row.more-after {
+ mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
+ }
+
+ /* biome-ignore lint/nursery/noUnusedClasses: set by filter-rows.svelte */
+ .chip-row.more-before {
+ mask-image: linear-gradient(to right, transparent, #000 28px);
+ }
+
+ /* biome-ignore lint/nursery/noUnusedClasses: set by filter-rows.svelte */
+ .chip-row.more-before.more-after {
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ #000 28px,
+ #000 calc(100% - 28px),
+ transparent
+ );
+ }
+}
diff --git a/web/src/lib/components/article.svelte b/web/src/lib/components/article.svelte
index 1396c97..5a4f2cb 100644
--- a/web/src/lib/components/article.svelte
+++ b/web/src/lib/components/article.svelte
@@ -2,6 +2,7 @@
import ArrowLeft from "@lucide/svelte/icons/arrow-left";
import Check from "@lucide/svelte/icons/check";
import ExternalLink from "@lucide/svelte/icons/external-link";
+ import ListFilter from "@lucide/svelte/icons/list-filter";
import Share from "@lucide/svelte/icons/share-2";
import Star from "@lucide/svelte/icons/star";
import Undo2 from "@lucide/svelte/icons/undo-2";
@@ -21,10 +22,9 @@
import { isNarrow } from "#lib/layout.ts";
import { logFailure, type Reader } from "#lib/reader.svelte.ts";
import { mentionsUrl } from "#lib/sanitize.ts";
- import { articleTopics } from "$shared/filter.ts";
- import ChipRows from "./chip-rows.svelte";
+ import FilterPopover from "./filter-popover.svelte";
+ import FilterSheet from "./filter-sheet.svelte";
import PullMarker from "./pull-marker.svelte";
- import TopicChip from "./topic-chip.svelte";
const {
reader,
@@ -46,9 +46,16 @@
const entry = $derived(reader.current);
// an unread article shows as read while open, since leaving it marks it read
const shownRead = $derived(entry ? reader.shownRead(entry) : false);
- const topics = $derived(
- entry ? articleTopics(entry.categories, reader.topicOrder) : [],
+ const filters = $derived(
+ entry ? reader.articleFilters(entry) : { sections: [], topics: [] },
);
+ const hasFilters = $derived(
+ filters.sections.length > 0 || filters.topics.length > 0,
+ );
+ const filtersOff = $derived(reader.filtersOff(filters));
+ // the filters open over the article: a sheet on a phone, a box below the button on a wide screen
+ let filtersOpen = $state<"none" | "sheet" | "box">("none");
+ let filterButton: HTMLButtonElement | undefined = $state();
const showImage = $derived(
entry !== undefined &&
entry.imageUrl !== "" &&
@@ -220,6 +227,19 @@
}
});
+ let shownId: string | undefined;
+ $effect(() => {
+ // another article closes what the last one had open
+ if (entry?.id !== shownId || !hasFilters) {
+ shownId = entry?.id;
+ filtersOpen = "none";
+ }
+ });
+
+ function closeFilters(): void {
+ filtersOpen = "none";
+ }
+
async function share(): Promise {
if (entry) {
if (canShare) {
@@ -360,7 +380,7 @@
}
-
+
{#if entry}
+ {#if hasFilters}
+
+ {/if}