Files
freshrss-read-more/docs/superpowers/plans/2026-11-06-read-more.md
T
2026-11-06 18:48:10 +01:00

154 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Read More — plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extension FreshRSS qui replie les articles du mode lecture à une hauteur fixe avec un bouton « Lire plus », sans casser le marquage « lu » au scroll.
**Architecture:** Extension `user` sans base de données. `extension.php` injecte un JS et un CSS et passe la hauteur configurée au JS via le hook `js_vars` (`context.extensions.read_more`). Le JS replie les `.flux .text` trop hauts par `max-height` (jamais `display:none`). L'article reste plus haut que 0, donc `onScroll()` de FreshRSS continue de le marquer lu. Le dépôt est directement le dossier de l'extension.
**Tech Stack:** PHP 8.2+ (API Minz de FreshRSS 1.30.4), JS vanilla (pas de build), CSS. Tests : `node:test` + `jsdom` (unitaires DOM), Playwright + Docker Compose (`freshrss/freshrss:1.30.4`) pour le bout-en-bout.
**Spec:** `docs/superpowers/specs/2026-11-06-read-more-design.md`
## Global Constraints
- Cible : **FreshRSS 1.30.4**. L'image Docker de test est épinglée sur `freshrss/freshrss:1.30.4`.
- Mode lecture uniquement : le JS ne fait rien si `context.current_view !== 'reader'`.
- Ne jamais rendre un `.flux` de hauteur 0. Repli = `max-height` + `overflow: hidden` sur `.text`.
- Hauteur repliée par défaut **250 px**, réglable, bornée **100–2000 px** (valeur hors bornes ramenée à la borne).
- Seuil : repli seulement si hauteur du contenu **≥ 1,5 × hauteur repliée**.
- Le bouton ne modifie jamais l'état lu/non lu. Clic : `preventDefault()` + `stopPropagation()`.
- Après dépliage : reste déplié, pas de bouton « Réduire ».
- Libellés : FR « Lire plus », EN « Read more ».
- Aucune dépendance runtime. `node_modules`, `package.json` et `tests/` ne servent qu'au développement.
- Noms fixés : `metadata.json` `name` = `"Read More"`, `entrypoint` = `"ReadMore"`, classe `ReadMoreExtension`, clé de config utilisateur `collapsed_height`.
## Review Focus
1. **Image chargée après coup** : un article d'abord trop court pour être replié et qui grandit quand ses images arrivent doit être replié. Test : `reevaluates short entry after image load` (Task 2).
2. **`freshrss:load-more` émis plusieurs fois** : pas de bouton en double, pas de re-repli d'un article déplié. Tests : `is idempotent` et `does not re-collapse expanded entry` (Task 2).
3. **Valeur de config absurde** (vide, 5, 99999, texte) : bornée, pas d'erreur. Test e2e `config height is clamped` (Task 3).
4. **Extension active hors mode lecture** (vue normale) : aucun bouton, aucun repli. Test e2e `normal view untouched` (Task 3).
5. **Article déplié puis dépassé** : marqué lu une fois son bas dépassé. Test e2e `expanded entry marked read after scrolling past` (Task 3).
---
### Task 1: Squelette de l'extension (PHP, config, i18n, README)
**Files:**
- Create: `metadata.json`, `extension.php`, `configure.phtml`, `i18n/fr/ext.php`, `i18n/en/ext.php`, `README.md`, `LICENSE`, `.gitignore`
- Create (vides pour l'instant, remplis en Task 2) : `static/read-more.js`, `static/read-more.css`
**Interfaces:**
- Produces (pour le JS, Task 2) : `window.context.extensions.read_more = { collapsed_height: int, label: string }`.
- Produces : clé i18n `ext.read_more.button`, `ext.read_more.height.label`, `ext.read_more.height.help`.
- [ ] **Step 1: `metadata.json`**
`{"name": "Read More", "author": "vincentb99", "description": "Replie les articles du mode lecture avec un bouton « Lire plus »", "version": "0.1.0", "entrypoint": "ReadMore", "type": "user"}`
- [ ] **Step 2: `extension.php`** : `final class ReadMoreExtension extends Minz_Extension`, sur le modèle de `xExtension-ReadingTime` (dépôt FreshRSS/Extensions).
- `public const DEFAULT_HEIGHT = 250; MIN_HEIGHT = 100; MAX_HEIGHT = 2000;`
- `init(): void` : `registerTranslates()`. Si `!FreshRSS_Context::hasUserConf()`, arrêter là. Sinon `Minz_View::appendStyle($this->getFileUrl('read-more.css'))`, `Minz_View::appendScript($this->getFileUrl('read-more.js'))`, `registerHook(Minz_HookType::JsVars, $this->jsVars(...))`.
- `public function getCollapsedHeight(): int` : `getUserConfigurationInt('collapsed_height') ?? DEFAULT_HEIGHT`, passé par `clampHeight`.
- `public static function clampHeight(int $h): int` : `max(MIN, min(MAX, $h))`.
- `public function jsVars(array $vars): array` : ajoute `$vars['read_more'] = ['collapsed_height' => getCollapsedHeight(), 'label' => _t('ext.read_more.button')]`.
- `handleConfigureAction(): void` : `registerTranslates()`. En POST, `setUserConfigurationValue('collapsed_height', self::clampHeight(Minz_Request::paramInt('collapsed_height') ?: self::DEFAULT_HEIGHT))`.
- [ ] **Step 3: `configure.phtml`** : même structure que celle de ReadingTime (form POST vers `_url('extension','configure','e',urlencode($this->getName()))`, champ `_csrf`, `form-group`, boutons `gen.action.submit` / `gen.action.cancel`). Un champ `<input type="number" name="collapsed_height" min="100" max="2000" step="10">` avec pour valeur `getCollapsedHeight()`.
- [ ] **Step 4: i18n** : `i18n/fr/ext.php` et `i18n/en/ext.php` retournent `['read_more' => ['button' => …, 'height' => ['label' => …, 'help' => …]]]`.
FR : « Lire plus », « Hauteur repliée (px) », « Hauteur visible d'un article avant le bouton « Lire plus » (100 à 2000). »
EN : "Read more", "Collapsed height (px)", "Visible height of an article before the "Read more" button (100–2000)."
- [ ] **Step 5: README.md** (FR) : rôle de l'extension, prérequis (FreshRSS ≥ 1.30.4, option « marquer comme lu au défilement » activée), installation Docker (`git clone` dans `/var/www/FreshRSS/extensions/`, activation dans Configuration → Extensions), mise à jour (`git pull`), réglage, lancement des tests. `LICENSE` : MIT, copyright vincentb99. `.gitignore` : `node_modules/`, `test-results/`, `playwright-report/`.
- [ ] **Step 6: Lint PHP**
Run: `docker run --rm -v "$PWD":/app -w /app php:8.2-cli sh -c 'for f in extension.php configure.phtml i18n/*/ext.php; do php -l "$f" || exit 1; done'`
Expected : `No syntax errors detected` pour chaque fichier.
- [ ] **Step 7: Commit** : `git add -A && git commit -m "feat: squelette de l'extension Read More (config, i18n)"`
---
### Task 2: Logique de repli (JS + CSS) et tests unitaires
**Files:**
- Create: `package.json` (`"private": true`, `"type": "commonjs"`, devDeps `jsdom`, `@playwright/test`, scripts `"test:unit": "node --test tests/unit/"`, `"test:e2e": "playwright test"`)
- Modify: `static/read-more.js`, `static/read-more.css`
- Test: `tests/unit/read-more.test.js`
**Interfaces:**
- Consumes : `window.context.extensions.read_more` (Task 1), `window.context.current_view`.
- Produces : `static/read-more.js` est un IIFE qui expose `window.ReadMore` et, si `module` existe, `module.exports`, avec :
- `THRESHOLD_RATIO = 1.5`
- `shouldCollapse(contentHeight: number, collapsedHeight: number): boolean`
- `processEntries(root: ParentNode, opts: { collapsedHeight: number, label: string, measure?: (el: Element) => number }): void`. `measure` vaut par défaut `el => el.scrollHeight`.
- `init(win: Window): void` : vérifie la vue, lit la config, lance `processEntries(document)`, écoute `freshrss:load-more` sur `document.body` et les `load` d'images en capture sur `document`. S'exécute au chargement si `window.context.extensions` existe, sinon sur `freshrss:globalContextLoaded`.
- Conventions DOM (utilisées aussi par Task 3) :
- sur `.flux` : `data-read-more` = `"collapsed"` | `"short"` | `"expanded"`
- sur `.text` replié : classe `read-more-collapsed`, style inline `--read-more-height: <n>px`
- bouton : `<button type="button" class="btn read-more-btn">label</button>` inséré juste après `.text`
- [ ] **Step 1: Écrire les tests** dans `tests/unit/read-more.test.js` (jsdom, fixture : deux `.flux` contenant `article.flux_content > div.content > div.text`, `measure` injecté par une map élément → hauteur) :
- `shouldCollapse thresholds` : `(374, 250) → false`, `(375, 250) → true`, `(1000, 250) → true`
- `collapses tall entry` : hauteur 1000, h=250 → `.text` a `read-more-collapsed` et la propriété `--read-more-height` vaut `250px`. Un seul `.read-more-btn`, de texte `Lire plus`, frère suivant de `.text`. `data-read-more="collapsed"`.
- `skips short entry` : hauteur 300 → pas de classe, pas de bouton, `data-read-more="short"`
- `is idempotent` : `processEntries` appelé deux fois → toujours un seul bouton par article
- `click expands` : clic → classe retirée, bouton retiré, `data-read-more="expanded"`, `event.defaultPrevented === true`. Un listener `click` sur le `.flux` parent n'est pas appelé.
- `does not re-collapse expanded entry` : clic, puis `processEntries` à nouveau → toujours déplié, pas de bouton
- `does not touch read state` : un `.flux.not_read` reste `not_read` après repli et clic
- `reevaluates short entry after image load` : article `short`, sa hauteur passe à 1000, puis dispatch `new Event('load')` sur un `<img>` de son `.text` (après `init`) → replié
- `init ignores non-reader view` : `context.current_view = 'normal'` → aucun bouton
- `init handles load-more` : `current_view='reader'`, `init`, puis ajout d'un nouveau `.flux` haut et dispatch de `freshrss:load-more` sur `body` → nouveau bouton
- [ ] **Step 2: Vérifier l'échec** : `npm install && npm run test:unit` → FAIL (fonctions absentes).
- [ ] **Step 3: Implémenter `static/read-more.js`** selon les interfaces ci-dessus. Ne traiter que les `.flux` sans `data-read-more`, ou ceux en `short` lors d'une réévaluation. Pour `init` en jsdom, laisser `measure` injectable via `win.ReadMore._measure` ou un paramètre optionnel, au choix de l'implémenteur, pourvu que les tests l'utilisent.
- [ ] **Step 4: `static/read-more.css`**
- `.flux .text.read-more-collapsed { max-height: var(--read-more-height, 250px); overflow: hidden; position: relative; }`
- `::after` en bas : dégradé de `transparent` vers la couleur de fond du thème (`var(--frss-background-color, #fff)`), hauteur 4em, `pointer-events: none`
- `.read-more-btn { display: block; margin: 0.5em auto 1em; }`
- [ ] **Step 5: Tests verts** : `npm run test:unit` → tous PASS.
- [ ] **Step 6: Commit** : `git add -A && git commit -m "feat: repli des articles en mode lecture avec bouton Lire plus"`
---
### Task 3: Tests bout-en-bout sur FreshRSS 1.30.4
**Files:**
- Create: `tests/e2e/docker-compose.yml`, `tests/e2e/fixtures/feed.xml`, `tests/e2e/fixtures/subscriptions.opml`, `tests/e2e/setup.sh`, `tests/e2e/read-more.spec.js`, `playwright.config.js`
**Interfaces:**
- Consumes : conventions DOM de Task 2, nom `"Read More"` et clé `collapsed_height` de Task 1.
- [ ] **Step 1: Environnement**
- `docker-compose.yml` : service `freshrss` (`freshrss/freshrss:1.30.4`, port `8080:80`, volume `../..:/var/www/FreshRSS/extensions/freshrss-read-more:ro`, `FRESHRSS_INSTALL: --default-user test --auth-type none --db-type sqlite --base-url http://localhost:8080`, `FRESHRSS_USER: --user test --password testtest1`). Service `feeds` (`nginx:alpine`) qui sert `fixtures/` sur le réseau compose.
- `feed.xml` : RSS avec 8 items. Les items 1 à 6 sont longs (≥ 15 paragraphes), le 7 est court (1 paragraphe) et le 8 est long.
- `subscriptions.opml` : un abonnement `http://feeds/feed.xml`.
- `setup.sh` : `docker compose up -d --wait`, puis dans le conteneur (`--user www-data`) : `cli/import-for-user.php --user test --filename …opml`, `cli/actualize-user.php --user test`. Enfin un `php -r` qui modifie `data/users/test/config.php` (`include`, puis `var_export`) : `extensions_enabled['Read More']=true`, `mark_when['scroll']=true`, `view_mode='reader'`. Ces clés sont des tableaux, que `reconfigure-user.php` refuse en 1.30.4. Le script se termine par `docker compose down -v` s'il est appelé avec `--down`.
- [ ] **Step 2: Écrire `tests/e2e/read-more.spec.js`** (viewport 390×844, `baseURL http://localhost:8080`, page `/i/?a=reader&state=2`, c'est-à-dire les non lus). Avant chaque test, remettre tout en non lu et `collapsed_height` à 250 via `php -r` dans le conteneur :
- `long entries collapsed` : chaque article long a `data-read-more="collapsed"` et un `.read-more-btn`. La hauteur de son `.text` est ≤ 250.
- `short entry not collapsed` : l'item 7 est en `data-read-more="short"`, sans bouton
- `collapsed entry marked read after scrolling past` : scroller jusqu'à ce que le bas du 1er `.flux` dépasse le haut de l'écran, attendre 1 s (throttle de 500 ms) → le 1er `.flux` n'a plus `not_read`. Après rechargement de la page des non lus, l'article n'y figure plus.
- `read more expands without changing read state or scroll` : clic sur le bouton du 1er article → `.text` sans `max-height`, l'article est toujours `not_read`, et `window.scrollY` varie de moins de 5 px
- `expanded entry marked read after scrolling past` : déplier le 2e, scroller au-delà de son bas → lu
- `normal view untouched` : `/i/?a=normal` → 0 `.read-more-btn`
- `config height is clamped` : sur la page de config de l'extension, soumettre 5 → le champ affiche 100. Soumettre 99999 → 2000. En mode lecture avec 400, la hauteur de `.text` replié vaut ≤ 400.
- [ ] **Step 3: Lancer** : `bash tests/e2e/setup.sh && npx playwright install chromium && npm run test:e2e` → tous PASS. En cas d'échec sur le marquage lu, vérifier d'abord que `mark_when.scroll` est bien actif (`cli/reconfigure-user.php --user test --list`) avant de soupçonner l'extension.
- [ ] **Step 4: Commit** : `git add -A && git commit -m "test: e2e sur FreshRSS 1.30.4 (repli, marquage lu au scroll)"`
---
### Task 4: Vérification manuelle et publication
- [ ] **Step 1** : avec l'environnement de Task 3, ouvrir `http://localhost:8080` dans le navigateur en mode mobile. Vérifier visuellement le dégradé et le bouton dans les thèmes clair et sombre (Configuration → Affichage).
- [ ] **Step 2** : `bash tests/e2e/setup.sh --down`.
- [ ] **Step 3** : demander confirmation à l'utilisateur avant `git push -u origin main` (dépôt externe).
- [ ] **Step 4** : installation en production (par l'utilisateur, suivant le README) : `git clone` dans le volume extensions, activation, réglage, test sur smartphone.