Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,51 @@ A desktop widget plugin for [DankMaterialShell](https://github.com/AvengeMedia/D
- Feed source labels per item
- **Optional [Miniflux](https://miniflux.app/) mode** — sync with a self-hosted Miniflux server instead of fetching feeds directly, with bidirectional read/unread and starred sync

## Roadmap

Full designs live in [`docs/plans/`](docs/plans/). Contributors welcome on
anything here — the **Phase 6** items are deliberately self-contained and are
the best place to start.

**In progress**

- Search focus and search-during-selection fixes — [design](docs/plans/2026-09-07-search-fixes-design.md)

**Planned** — [full roadmap design](docs/plans/2026-09-07-roadmap-design.md)

| Phase | Work | Depends on |
|---|---|---|
| 0 | Backend provider interface — replaces the inline `sourceMode` branches | — |
| 1 | Google Reader API backend (FreshRSS, TT-RSS, Inoreader, TheOldReader, BazQux, Miniflux) | 0 |
| 2 | Keyboard navigation (`j`/`k`/`o`/`m`/`s`, `/` to search) | search fixes |
| 3 | Local AI via any OpenAI-compatible runtime (ollama, vLLM, llama.cpp, LM Studio): per-article TL;DR, daily digest, interest ranking | 0 |
| 4 | Notes/export provider: markdown directory, Obsidian, Neovim | — |
| 5 | Reader + annotation app — a standalone window for reading, highlighting and note-taking | 3, 4 |
| 6 | Independent smaller items — see below | — |

**Phase 6 / good first issues**

- Feed autodiscovery (paste a site URL, find its feed)
- OPML **export** (import already exists)
- Categories/folders (Miniflux returns them; we flatten them)
- Per-feed refresh intervals
- Audio enclosures → MPRIS, so podcast feeds play through the DMS media widget
- Rule-based notifications (notify on *interesting* items, not just new ones)
- Mark-read-on-scroll, per-source snooze, oldest-first sort

**Design principle for anything with a vendor in its name:** it gets an
interface with presets, never a hardcoded integration. Feed backends speak the
Google Reader API, AI runtimes speak the OpenAI-compatible chat API, and notes
apps are "write a markdown file to a directory". Adding ollama should not make
vLLM harder, and adding Obsidian should not make Neovim harder.

**Not planned**

- **Fever API** — covers only backends the Google Reader API already reaches,
and is read-only in Miniflux.
- **Evernote export** — its local API was retired; there is no integration
surface left that fits the export interface. Use the markdown provider.

## Miniflux mode

Instead of fetching RSS/Atom URLs directly, the widget can act as a front-end for a
Expand Down Expand Up @@ -76,7 +121,7 @@ backed up, synced, or otherwise readable by other tools.

- At very narrow widget widths (approaching the 100px floor) the filter chips can still crowd each other. Fully solving it would need chip wrapping or eliding, which is not implemented. At normal sizes (the default and above) this is not visible.
- Compact view rows reserve slightly more vertical padding than their margins strictly need. This is a pre-existing cosmetic issue, not introduced or fixed in this release.
- The widget now declares `acceptsKeyboardFocus` (gated to when search is open) so the search field can receive typed input. No other widget in the installed DMS build uses this property, so while it is wired correctly per the documented mechanism, its behavior is unproven across DMS versions and may interact with compositor-specific layer-shell focus policy.
- `acceptsKeyboardFocus` gating search means the search field needs a **second click** before it accepts typing. The DMS wrapper maps this property onto layer-shell `WlrKeyboardFocus.OnDemand` (`Modules/Plugins/DesktopPluginWrapper.qml`), and `on_demand` grants keyboard focus only on a click that lands while the surface is already focus-eligible — which it is not at the instant the search toggle is clicked. Fix designed, see the roadmap.
- The Miniflux settings layout (Connection section, read-only feed list, mode-gated visibility of the RSS-only sections) has not been visually verified in a live DMS session.

## Installation
Expand Down
225 changes: 225 additions & 0 deletions docs/plans/2026-09-07-roadmap-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
# Design: v3 roadmap

Date: 2026-09-07
Status: agreed in principle; per-phase specs still to be written
Scope: architecture for everything after the 2.3.x line

## Guiding principle

Every external thing this widget talks to — feed backend, AI runtime, notes
app — gets an **interface with presets**, never a hardcoded vendor. Where a de
facto standard protocol exists, the interface *is* that protocol and each
"provider" collapses into a base URL plus a label. That keeps the provider count
from becoming a code-path count.

Two protocols make this cheap, and both were verified rather than assumed:

- **Google Reader API** is spoken by FreshRSS, Tiny Tiny RSS (via plugin),
Inoreader, TheOldReader, BazQux *and* Miniflux. One client, six backends.
- **The OpenAI-compatible `/v1/chat/completions` endpoint** is spoken by ollama,
vLLM, llama.cpp's server, LM Studio and LocalAI. Verified locally against this
machine's ollama: `GET http://localhost:11434/v1/models` → `HTTP 200` with an
OpenAI-shaped `{"object":"list","data":[...]}` body. One client, every local
runtime.

Transport for all of it stays what the Miniflux integration already uses:
`Proc.runCommand(null, argv, cb, undefined, timeoutMs)` with curl, argv as an
array, never `sh -c`, secrets always their own argv element
(`DankRssWidget.qml:738-771`).

---

## Phase 0 — Backend provider abstraction (blocks almost everything)

`sourceMode` is a two-valued string branched on inline in roughly ten places
(`DankRssWidget.qml:344, 382, 407, 421, 470, 510, ...`). Two values work as an
if/else. Three is where the branch you forgot to update ships as a bug.

Extract an interface before adding a third mode:

```
fetch(cb) -> items[] // normalised, id-prefixed
markRead(ids) -> void
markUnread(ids) -> void
toggleStar(id) -> void
reconcile(serverEntries) -> {readMap, bookmarkMap}
capabilities -> { serverState, star, subscribe, categories, fullText }
```

`capabilities` is what stops the UI from growing its own `if (sourceMode ===
...)` branches: the star button asks `backend.capabilities.star`, not which
backend it is. Fever, whenever it lands, is the case that proves this — it is
read-only in Miniflux, so it reports `subscribe: false` and the subscribe UI
disappears without a single mode check.

Implementations: `StandardBackend` (direct feed fetching, local state) and
`MinifluxBackend` (lifted verbatim from today's branches — behaviour-preserving,
no fixes smuggled in). Id prefixes stay as they are (`g:`/`l:`/`h:`/`m:`,
`FeedParser.js:395-401`); Google Reader gets its own.

This is a pure refactor. It must land with the existing suite green and no
observable behaviour change, as its own PR, before Phase 1 starts.

## Phase 1 — Google Reader API backend

A third implementation behind Phase 0's interface. ClientLogin token flow,
`/reader/api/0/stream/contents`, `edit-tag` for read/starred. Ships with a
Miniflux-hosted round-trip test since Miniflux speaks it too — the one backend
we can test locally against a server we already run.

**Fever API is explicitly deferred.** It buys FreshRSS + TT-RSS, both of which
Google Reader already covers, and it is read-only in Miniflux. Reconsider only
if a contributor wants it for a backend that speaks nothing else.

## Phase 2 — Keyboard navigation

Depends on the hover/focus fix in the search-fixes design doc, which is what
makes the surface reliably focusable at all.

Accepted constraint: the wrapper gives us `WlrKeyboardFocus.OnDemand`, so focus
arrives only after a click on the widget. Driving it from cold would need
`Exclusive`, which swallows every compositor key. **Click-then-drive is the
intended model, not a limitation to design around.**

Bindings (Miniflux/vim conventions, so muscle memory transfers):

| Key | Action | Key | Action |
|---|---|---|---|
| `j` / `k` | next / prev | `Space` | toggle checkbox |
| `o` / `Enter` | open | `/` | focus search |
| `m` | toggle read | `Esc` | close search, else clear selection |
| `s` / `f` | toggle star | `r` | refresh |
| `g g` / `G` | top / bottom | `A` | mark all read |

Needs: a `currentIndex` on the ListView, a focus ring visually distinct from
hover, and `positionViewAtIndex` so the cursor never leaves the viewport.
`Esc` is deliberately layered — closing search before clearing selection — so it
never destroys a selection the user is mid-way through building.

## Phase 3 — AI provider (`AiProvider.js`)

**Interface:** OpenAI-compatible chat completions. A provider is
`{ label, baseUrl, model, apiKey?, timeoutMs }`. Nothing else.

Presets: `ollama` (`http://localhost:11434/v1`), `vLLM`
(`http://localhost:8000/v1`), `llama.cpp`, `LM Studio`
(`http://localhost:1234/v1`), plus `Custom`. Adding a runtime later is a row in
a presets table, not a code path.

Deliberately **not** using ollama's native `/api/generate`. It would work today
and lock out every other runtime tomorrow, which is the exact thing this phase
exists to avoid.

Features on top, in order:

1. **Per-article TL;DR** — on-demand button, result cached against the item id.
2. **Digest** — "last 24h across all feeds in six bullets", one call over
titles + descriptions.
3. **Interest ranking** — embeddings via `/v1/embeddings`, rank unread by
similarity to starred. Highest risk here: ranking that feels wrong is worse
than none. Ships behind a default-off toggle with a visible "why this ranked
high" and an obvious escape back to reverse-chronological.

Model choice note carried over from Brendon's nvim work: this needs an
**instruct** tag, not a `-base` tag. A base model continues text rather than
following the summarise instruction. That trap already cost a day once.

Degradation: no runtime reachable → AI affordances hide entirely. No error
toasts on a laptop that simply is not running ollama today. The widget must be
completely usable, and completely quiet, with no AI configured.

## Phase 4 — Notes / export provider (`ExportProvider.js`)

**Interface:** write a markdown document somewhere. Verified mechanism —
`Quickshell.Io`'s `FileView` with `setText()`, `blockWrites: true` and
`atomicWrites: true`, exactly as DMS itself writes its caches
(`/usr/share/quickshell/dms/Common/CacheData.qml:282-302`).

```
export(article, annotations) -> path
capabilities -> { openAfterWrite, appendToDaily, tags }
```

Providers:

- **Markdown directory** — the base case. A path, a filename template, YAML
frontmatter. Every markdown tool on earth reads this.
- **Obsidian** — the markdown provider plus vault awareness: vault-relative
paths, wikilink-style tags, optional `obsidian://open?vault=…&file=…` callback
to jump to the note. First-class because it is what Brendon uses.
- **Neovim** — the markdown provider plus an optional `nvim --server … --remote`
to open the file in a running instance.

**Evernote is not planned.** Its local API was retired; the only integration
paths left are email-in and manual import, neither of which fits this interface.
If someone wants it, the markdown provider plus their own sync script is the
honest answer.

The insight worth keeping: Obsidian is not an integration, it is *a directory of
markdown files*. Building the file writer first and treating Obsidian as a
configured instance of it is what makes neovim and everything else nearly free.

## Phase 5 — Reader / annotation app

A standalone Quickshell window (DankCalendar-shaped), launched from the widget.
The widget stays the glanceable list; the app is where reading happens.

- Full-text fetch (already a single API call in Miniflux mode).
- Reading-optimised typography, images, no chrome.
- Highlights and margin notes.
- One action to push the article plus its annotations through Phase 4.

**The hard problem is anchor stability**, not the UI. A highlight must survive
the article being re-fetched with different whitespace, an added subscribe
banner, or a rewritten wrapper. Character offsets will not survive any of that.
Store `{ exactQuote, prefixContext, suffixContext }` and re-locate by fuzzy
match on load — the model the W3C annotation spec settled on for the same
reason. An anchor that fails to relocate degrades to an orphaned note attached
to the article, never a highlight silently landing on the wrong sentence.

Depends on Phase 4. Blocked on the open questions below.

## Phase 6 — Small items, independent of everything above

Any of these can be picked up by a contributor at any time.

- **Feed autodiscovery** — paste `arstechnica.com`, find the feed. Best
value-per-line on the whole roadmap; today you must already know the feed URL.
- **OPML export** — import exists (`DankRssWidgetSettings.qml:886`), export does
not. A tool you cannot leave is a bad look for an open one.
- **Categories/folders** — Miniflux returns them; we flatten them.
- **Per-feed refresh intervals** — hourly feeds should not poll like minute ones.
- **Audio enclosures → MPRIS** — `FeedParser.js` already parses enclosures for
images. Podcasts would appear in the DMS media widget like any other track.
- **Rule-based notifications** — upgrade "notify on new items" to "notify on
*interesting* items". The matcher already exists as the search filter.
- **Mark-read-on-scroll**, **per-source snooze**, **oldest-first sort**.

---

## Sequencing

```
search fixes ──> Phase 2 (keyboard)
│
└────────> Phase 0 (backend interface) ──> Phase 1 (Google Reader)
└──> Phase 3 (AI) ──┐
├──> Phase 5 (reader app)
Phase 4 (export)┘
```

Phase 3 before Phase 5 on purpose: the AI work is a weekend that tells us whether
local-model features feel good at all, and if they do, Phase 5 launches with a
much stronger feature set than it would alone.

Phase 6 runs in parallel throughout — it is the contributor on-ramp.

## Open questions

Listed in the handoff; recorded here so the doc stands alone.

1. Reader app as a second plugin in this repo, or a separate repo?
2. Where do annotations live — plugin state, or markdown files in the vault as
the source of truth?
3. Is Phase 1 worth building without a non-Miniflux server to test against?
4. Does the AI config belong per-widget-instance or global?
Loading
Loading