diff --git a/BACKLOG.md b/BACKLOG.md deleted file mode 100644 index 4d5eda7..0000000 --- a/BACKLOG.md +++ /dev/null @@ -1,420 +0,0 @@ -# FeedTheMonkey v3 — Backlog - -A rewrite of FeedTheMonkey from Qt5/C++/QML to Rust + GTK4 + libadwaita. -The app is a FreshRSS client using the Google Reader compatible API. - -## Design principles - -- Idiomatic libadwaita: follows the GNOME HIG, not a port of the Qt UI -- Dark mode follows the system (AdwStyleManager), no in-app toggle -- Credentials stored in the system keyring (libsecret), not a config file -- App settings stored in GSettings - -## Tech stack - -- Language: Rust (stable) -- UI: GTK4 + libadwaita -- Web content: webkit6 crate (webkitgtk-6.0) -- UI templates: Blueprint (.blp) compiled at build time via blueprint-compiler -- HTTP: reqwest (async, TLS) -- JSON: serde + serde_json -- Async: tokio -- Credentials: libsecret crate -- Settings: GSettings (net.jeena.FeedTheMonkey schema) -- API: FreshRSS Google Reader compatible API - -## API reference - -Base URL: `https://rss.jeena.net/api/greader.php` - -| Purpose | Method | Path | Notes | -|---------------|--------|-----------------------------------------------|--------------------------------------------| -| Login | POST | /accounts/ClientLogin | Body: `Email=&Passwd=` → `Auth=` | -| Write token | GET | /reader/api/0/token | Header: `Authorization: GoogleLogin auth=` | -| Fetch unread | GET | /reader/api/0/stream/contents/reading-list | `?xt=user/-/state/com.google/read&n=200&output=json` | -| Mark read | POST | /reader/api/0/edit-tag | Body: `i=&a=user/-/state/com.google/read&T=` | -| Mark unread | POST | /reader/api/0/edit-tag | Body: `i=&r=user/-/state/com.google/read&T=` | - -### Article JSON → content.html field mapping - -| Google Reader field | JS template field | -|-----------------------|-------------------| -| title | title | -| origin.title | feed_title | -| canonical[0].href | link | -| published (unix ts) | updated | -| summary.content | content | -| author | author | -| unread | categories does NOT contain `user/-/state/com.google/read` | - -## UI structure - -``` -AdwApplicationWindow -└── AdwToastOverlay - └── AdwNavigationSplitView - ├── sidebar: AdwNavigationPage (title="FeedTheMonkey") - │ └── AdwToolbarView - │ ├── top: AdwHeaderBar - │ │ ├── start: GtkButton (view-refresh-symbolic) - │ │ │ → AdwSpinner while loading (via GtkStack) - │ │ └── end: GtkMenuButton (open-menu-symbolic, primary=True) - │ │ └── GMenu: Log Out, Keyboard Shortcuts, About - │ └── content: GtkScrolledWindow → GtkListView (ArticleRow) - │ or AdwStatusPage (empty / loading / error) - └── content: AdwNavigationPage (title = article title, dynamic) - └── AdwToolbarView (top-bar-style=raised) - ├── top: AdwHeaderBar (auto back button on narrow) - │ └── end: GtkMenuButton (view-more-symbolic) - │ └── GMenu: Mark Unread, Open in Browser - └── content: WebKitWebView (loads html/content.html) - or AdwStatusPage ("Select an article") -``` - -## GSettings schema - -Schema ID: `net.jeena.FeedTheMonkey` - -| Key | Type | Default | Notes | -|------------------|--------|---------|----------------------------| -| window-width | int | 900 | | -| window-height | int | 600 | | -| window-maximized | bool | false | | -| sidebar-width | int | 280 | | -| zoom-level | double | 1.0 | WebKitWebView zoom level | - -Credentials (libsecret, not GSettings): -- server-url -- username -- password - ---- - -## Epic 1 — Project scaffold - -Set up a working Rust + GTK4 + libadwaita project that opens a blank -AdwApplicationWindow. No logic, just the skeleton. - -### Stories - -**1.1** Create the `v3` git branch from `master`. - -**1.2** Write `Cargo.toml` with all dependencies: -- gtk4, libadwaita, webkit6, gio, glib -- reqwest (with rustls-tls feature, no native-tls) -- serde, serde_json -- tokio (full features) -- secret (libsecret) -- blueprint-compiler is a build-time tool, not a crate - -**1.3** Write `build.rs` that: -1. Runs `blueprint-compiler batch-compile` on all `.blp` files in `data/ui/` - to produce `.ui` files alongside them -2. Runs `glib-compile-resources` on `data/resources.gresource.xml` - to produce a compiled `.gresource` file, then registers it - -**1.4** Write `data/resources.gresource.xml` bundling: -- Compiled `.ui` files from `data/ui/` -- `html/content.html` and `html/content.css` - -**1.5** Write the GSettings schema `data/net.jeena.FeedTheMonkey.gschema.xml` -with the keys listed above. - -**1.6** Write `data/ui/window.blp` — a minimal `AdwApplicationWindow` -containing `AdwNavigationSplitView` with placeholder sidebar and content pages. - -**1.7** Write `src/main.rs` and `src/app.rs` — `AdwApplication` with -APP_ID `net.jeena.FeedTheMonkey`, activates the main window. - -**1.8** Write `src/window.rs` — `AdwApplicationWindow` GObject subclass -that loads the window Blueprint template and restores window size from GSettings. - -**1.9** Verify the app compiles and opens a blank window. - ---- - -## Epic 2 — Authentication - -Login dialog, credential storage, session management, logout. - -### Stories - -**2.1** Write `data/ui/login_dialog.blp` — an `AdwDialog` with: -- `AdwToolbarView` + `AdwHeaderBar` (title "Log In") -- `AdwClamp` → `AdwPreferencesGroup` containing: - - `AdwEntryRow` — Server URL (placeholder: `https://example.com`) - - `AdwEntryRow` — Username - - `AdwPasswordEntryRow` — Password - - `AdwButtonRow` — "Log In" (style: `.suggested-action`) - -**2.2** Write `src/login_dialog.rs` — `LoginDialog` GObject subclass -using the Blueprint template. Emits a `logged-in` signal on success. - -**2.3** Write `src/settings.rs` — libsecret helpers: -- `store_credentials(server_url, username, password)` -- `load_credentials() -> Option<(server_url, username, password)>` -- `clear_credentials()` - -**2.4** Write `src/api.rs` — `Api` struct with: -- `login(server_url, username, password) -> Result` - Calls `POST /accounts/ClientLogin`, parses `Auth=` line from response -- `fetch_write_token(auth_token) -> Result` - Calls `GET /reader/api/0/token` - -**2.5** Connect login dialog to `Api::login()`. On success: -- Store credentials via libsecret -- Store `AuthToken` and `WriteToken` in application state -- Close the dialog, trigger article fetch (Epic 3) - -**2.6** On app start: call `load_credentials()`. If found, call `Api::login()` -automatically and skip showing the login dialog. - -**2.7** Implement logout: clear libsecret credentials, discard tokens, -show the login dialog again. - -**2.8** Show login errors via `AdwAlertDialog` (title "Login Failed", -body = server error message, single "OK" button). - ---- - -## Epic 3 — Article fetching - -Fetch unread articles from the server and populate the list model. - -### Stories - -**3.1** Write `src/model.rs` — `Article` struct (plain Rust, not GObject): -``` -id: String -title: String -feed_title: String -author: String -link: String -published: i64 ← unix timestamp -content: String -excerpt: String ← plain text, first ~150 chars of content stripped of HTML -unread: bool -``` - -Also a GObject wrapper `ArticleObject` (implementing `glib::Object`) so -it can be stored in a `gio::ListStore`. - -**3.2** Add `Api::fetch_unread(auth_token) -> Result>` that: -- Calls `GET /reader/api/0/stream/contents/reading-list?xt=...&n=200&output=json` -- Deserializes the JSON response -- Maps Google Reader fields to `Article` struct -- Derives `unread` from `categories` not containing `user/-/state/com.google/read` - -**3.3** In `src/window.rs`: after successful login, call `fetch_unread()` in -a background tokio task and populate a `gio::ListStore`. - -**3.4** Show `AdwStatusPage` with `AdwSpinnerPaintable` and title "Loading…" -in the sidebar while fetching is in progress. - -**3.5** Show `AdwStatusPage` with icon `rss-symbolic` and title -"No unread articles" when the list is empty after a successful fetch. - -**3.6** Show `AdwStatusPage` with icon `network-error-symbolic` and title -"Could not load articles" (description = error message) on network/API error. -Include a "Try Again" button that re-triggers the fetch. - -**3.7** Implement reload: the refresh button in the sidebar header bar -re-runs `fetch_unread()`, replaces the list store contents, and scrolls -the list back to the top. - ---- - -## Epic 4 — Sidebar - -Article list view with custom rows, navigation, and split view behavior. - -### Stories - -**4.1** Write `data/ui/article_row.blp` — composite template for `ArticleRow`: -``` -GtkBox (orientation=vertical, margin=12, spacing=4) -├── GtkBox (orientation=horizontal, spacing=4) -│ ├── GtkLabel (id=feed_title, hexpand=True, xalign=0, .dim-label, small font) -│ └── GtkLabel (id=date, xalign=1, .dim-label, small font) -├── GtkLabel (id=title, xalign=0, wrap=True, lines=2) -└── GtkLabel (id=excerpt, xalign=0, .dim-label, small font, ellipsize=end, lines=1) -``` - -**4.2** Write `src/article_row.rs` — `ArticleRow` GObject subclass. -- Bind `ArticleObject` properties to labels -- Apply `.dim-label` style to the title label when article is read -- Format `published` timestamp as relative time ("2 hours ago", "Yesterday", etc.) - -**4.3** Set up `GtkListView` in the sidebar with a `GtkSignalListItemFactory` -that creates and binds `ArticleRow` widgets. - -**4.4** Connect list selection (`GtkSingleSelection`) to show the selected -article in the content pane and call `adw_navigation_split_view_set_show_content(true)` -on narrow screens. - -**4.5** Implement `next_article()` and `previous_article()` functions that -move the selection in the list view (used by keyboard shortcuts). - -**4.6** Persist and restore `sidebar-width` from GSettings using -`AdwNavigationSplitView::sidebar-width-fraction` or equivalent. - ---- - -## Epic 5 — Content pane - -WebKitWebView loading content.html, link handling, empty state. - -### Stories - -**5.1** Update `html/content.html`: -- Change `` from "TTRSS" to "FeedTheMonkey" -- Remove `setFont()` and `setNightmode()` JavaScript functions (no longer needed) -- Remove `"loading"` and `"empty"` string handling from `setArticle()` - (these states are now handled by `AdwStatusPage` in Rust) -- Remove the `"logout"` string handling -- Keep `setArticle(article)` for JSON object input -- Keep `checkKey()` and the keyboard event listener - -**5.2** Update `html/content.css`: -- Replace the `.nightmode` body class block with - `@media (prefers-color-scheme: dark) { ... }` -- Keep all existing styles, just change the selector - -**5.3** Write `src/content_view.rs` — wrapper around `webkit::WebView`: -- Loads `content.html` from GResource on init -- Exposes `set_article(article: &Article)` which calls `window.setArticle()` - via `WebView::evaluate_javascript()` -- Handles `decide-policy` signal: intercept `feedthemonkey:previous`, - `feedthemonkey:next`, `feedthemonkey:open` URIs and emit corresponding - GObject signals; intercept all other link clicks and open with `gtk::show_uri()` -- Exposes `zoom_level` property (get/set), used by Epic 8 - -**5.4** Show `AdwStatusPage` with icon `document-open-symbolic` and -title "No Article Selected" when nothing is selected in the sidebar. - -**5.5** Ensure WebKit loads the local `content.html` from GResource using -a custom URI scheme or `load_html()` with a base URI so that `content.css` -is resolved correctly. - ---- - -## Epic 6 — Read state - -Auto-mark-read, manual mark-unread, API sync. - -### Stories - -**6.1** Add `Api::mark_read(auth_token, write_token, item_id) -> Result<()>` -that POSTs to `/reader/api/0/edit-tag` with -`i=<id>&a=user/-/state/com.google/read&T=<write_token>`. - -**6.2** Add `Api::mark_unread(auth_token, write_token, item_id) -> Result<()>` -that POSTs to `/reader/api/0/edit-tag` with -`i=<id>&r=user/-/state/com.google/read&T=<write_token>`. - -**6.3** When the selected article changes (user navigates to next/previous), -mark the *previously selected* article as read via `Api::mark_read()`. -Update `ArticleObject::unread` to `false` so the row styling updates immediately -(optimistic update — no waiting for the API response). - -**6.4** Implement "Mark Unread" action (key `u`, content pane menu): -- Set `ArticleObject::unread` to `true` (optimistic update) -- Call `Api::mark_unread()` in the background -- Show an `AdwToast` "Marked as unread" in the `AdwToastOverlay` -- Set a guard flag so navigating away does not re-mark the article as read - -**6.5** Handle API errors from mark_read/mark_unread silently -(log to stderr, do not show a dialog — these are background operations). - ---- - -## Epic 7 — Keyboard shortcuts - -Full keyboard navigation matching the feature set of v2. - -### Stories - -**7.1** Register `GtkShortcutController` on the main window for all shortcuts: - -| Key(s) | Action | -|-----------------|-----------------------------------------| -| j, Right | Next article | -| k, Left | Previous article | -| Enter, n | Open current article in browser | -| r | Reload articles | -| u | Mark current article unread | -| Space, Page_Down| Scroll content down one page | -| Page_Up | Scroll content up one page | -| Home | Scroll content to top | -| End | Scroll content to bottom | -| Ctrl+plus | Zoom in | -| Ctrl+minus | Zoom out | -| Ctrl+0 | Reset zoom | -| F11 | Toggle fullscreen | -| Ctrl+W | Close window | -| Ctrl+Q | Quit application | - -**7.2** Write `data/ui/shortcuts.blp` — `AdwShortcutsDialog` documenting -all shortcuts, grouped by category (Navigation, Article, View, Application). - -**7.3** Connect the "Keyboard Shortcuts" menu item to show the shortcuts dialog. -Also register `F1` as an alias. - ---- - -## Epic 8 — Zoom - -WebView zoom with keyboard shortcuts and persistence. - -### Stories - -**8.1** Implement `zoom_in()`, `zoom_out()`, `zoom_reset()` in `content_view.rs`: -- `zoom_in`: multiply `WebView::zoom_level` by 1.1 -- `zoom_out`: divide by 1.1 -- `zoom_reset`: set to 1.0 - -**8.2** On zoom change, save the new zoom level to GSettings (`zoom-level` key). - -**8.3** On app start, read `zoom-level` from GSettings and apply to the WebView. - ---- - -## Epic 9 — Window state persistence - -Save and restore window size and sidebar width. - -### Stories - -**9.1** On window close, save `window-width`, `window-height`, `window-maximized` -to GSettings. - -**9.2** On window open, restore width, height, and maximized state from GSettings. - -**9.3** Save `sidebar-width` to GSettings when the sidebar is resized. -Restore on startup. - ---- - -## Epic 10 — Polish - -Final details for a shippable app. - -### Stories - -**10.1** Write `data/net.jeena.FeedTheMonkey.desktop` — `.desktop` file -with `Name=FeedTheMonkey`, `Exec=feedthemonkey`, `Icon=feedthemonkey`, -`Categories=Network;Feed;`. - -**10.2** Include the existing `misc/feedthemonkey.xpm` icon in the GResource -bundle and reference it in the `.desktop` file. - -**10.3** Implement `AdwAboutDialog` with app name, version, license (GPL-3.0), -website, and developer name. Connect to "About" menu item. - -**10.4** Handle the write token expiry: if an `edit-tag` call returns 401, -fetch a new write token and retry once. - -**10.5** Add `AdwAlertDialog` for logout confirmation ("Are you sure you want -to log out?" with "Log Out" destructive button and "Cancel"). - -**10.6** Graceful shutdown: cancel in-flight API requests when the window closes.