Note: The changes in this PR are entirely AI-authored but manually reviewed and tested
## Summary
Translate the remaining French prose and French-derived identifiers in the main FreshRSS/Minz codebase to English.
Why: helps to understand the code
## Scope
- Minz comments, PHPDoc, and paginator identifiers
- Application comments and related identifiers
- Installer database identifiers
- Theme metadata descriptions and the identified CSS comments, previous discussion: https://github.com/FreshRSS/FreshRSS/pull/8241#discussion_r2553181653
- Related call sites and view helpers
External libraries and intentional i18n/docs content are excluded.
* New hook: `Minz_HookType::AutofillField` for auth views
Currently to be used in the Demo extension, because it overrides both the formLogin and reauth views (for autofilling username/password), it can be broken every time there is a slight change in core.
For example, after https://github.com/FreshRSS/FreshRSS/pull/9358.
In the future the hook can be extended to support more fields.
Demo extension PR: https://github.com/FreshRSS/xExtension-Demo/pull/6
* Suggested alternative without returning HTML code
* Sanitizer: block data: URI scheme, keep data:image for images
FreshRSS_SimplePieCustom only disallowed the `javascript` scheme, so
`data:text/html,...` URIs in feed content survived
sanitization on <a href>, <iframe src>, <audio|video|source|track src>.
The default CSP neutralises these on FreshRSS pages, but clients and
deployments without that CSP (API WebViews, extensions using amendCsp(),
proxies stripping headers) could execute them. Same class as
GHSA-3fw6-j8m4-82vj / #9272.
- Sanitize::is_allowed_uri(): element/attribute-aware check that still
permits inert `data:image/...` on img[src], image[src], video[poster]
while blocking `data:` elsewhere; replace_urls() now uses it.
is_allowed_scheme() is unchanged for backward compatibility.
- SimplePieCustom: disallow_uri_schemes(['javascript','data']).
- Tests: 4 blocked-input cases, a data:image keep-alive test and an
`unsafe:` prefix test. They fail without the patch and pass with it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Sync SimplePie
https://github.com/FreshRSS/simplepie/pull/92
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Contributes to #6139 (export part; the import will follow in another PR)
Exporting favourites was collecting the whole file in memory. With many articles, it fails with the default PHP memory limit.
## Changes
- New `Minz_View::helperToFile()`, similar to `helperToString()`, writing the output of a helper into a file by chunks of 64 KiB instead of keeping it in memory.
- `FreshRSS_Export_Service`: the `generate*()` methods now return the path of a temporary file instead of the content, and `zip()` builds the archive from those files (`ZipArchive::addFile()` instead of `addFromString()`). Temporary files are created in `TMP_PATH` (already required to be writable) and deleted at the end of the script.
- The export is sent with `readfile()`: Web UI, `cli/export-zip-for-user.php`, `cli/export-opml-for-user.php` and the OPML export of the Google Reader API.
- Note: this changes what `FreshRSS_Export_Service` returns (a path instead of the content). All callers in the repository are updated, and I could not find any public extension using this service.
## Measurements
Peak PHP memory (`memory_get_peak_usage(true)`), SQLite database with synthetic starred articles:
| Scenario | Before | After |
|---|---|---|
| Web, export your favourites, 10,000 articles (69 MB JSON) | 136 MB (fails with the default `memory_limit` of 128 MB) | 6 MB |
| Web, ZIP (OPML + favourites + labelled articles + 4 feeds), 10,000 articles | 152 MB | 6 MB |
| CLI `export-zip-for-user.php`, 10,000 articles | 189 MB (fails with 128 MB) | 8 MB |
| Web, export your favourites, 30,000 articles (208 MB JSON) | 404 MB | 10 MB |
Co-authored-by: Alejandro Borrego <210245044+abp002@users.noreply.github.com>
* Fix domain-wide Retry-After
- Fix the domain-wide HTTP `Retry-After` for public servers, which has not applied since #8195 (FreshRSS 1.28.0).
- `getRetryAfterFile()` passed the bare host name to `Minz_Request::serverIsPublic()`. That function expects a URL, so it always returned `false`.
- So a 429 or 503 paused only the URL that got it, despite the log saying "For that domain, will first retry after …", and FreshRSS went on requesting the other feeds of that server. With several Reddit feeds, for instance, each one got its own 429 in turn.
- `serverIsPublic()` now gets the URL. Its answer is kept per host for the rest of the request, because it resolves the host name (`dns_get_record()`), and a refresh may load many feeds of the same host.
- Fix the per-URL `Retry-After` of servers on a local network (#8195), which never applied to feeds whose URL, as stored, differs from the URL as requested.
- Stored URLs are HTML-encoded, e.g. `&` in an RSS-Bridge URL, and may end with `#force_feed`.
- `FreshRSS_Feed::load()` checked the stored URL, while the pause is stored for the URL as requested. It now checks the requested URL, as `loadJson()` and `loadHtmlXpath()` already do.
* Minor final
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Fix muting of feeds gone with HTTP 410
A feed answering HTTP 410 Gone is meant to be muted (#3561), but:
* Since #6037, FreshRSS_Feed_Exception extends Minz_Exception, which
resets any code other than its own error levels to 0, so the check for
410 never matched. FreshRSS_Feed_Exception now keeps the HTTP status
code it is given.
* FreshRSS_FeedDAO::mute() mutes by negating the TTL, which leaves a feed
with the default TTL (0) unmuted. Mute the feed the way the feed
settings and dynamic OPML categories do instead.
* A bit of cleaning
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* auth: rotate session ID on all authenticated transitions (session fixation)
The PHP session ID was regenerated on explicit form login, reauth, logout and
password change, but NOT when access is (re)established from a remember-me
cookie, via HTTP authentication, or on self-registration auto-login (CWE-384),
so a fixed session ID could become authenticated.
Regenerate the session ID at those transitions too: in FreshRSS_Auth::init
when accessControl() first grants access (covers remember-me restore and HTTP
auth; skipped for auth_type 'none', which has no authentication boundary), and
in the self-registration auto-login path, mirroring the existing form-login
idiom. Also enable session.use_strict_mode before session_start as defence in
depth so an uninitialised, attacker-supplied session ID is not adopted.
* auth: avoid duplicate cookie during session rotation
* Code preferences, shorten some comments
---------
Co-authored-by: Inverle <inverle@proton.me>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Closes#9288
Changes proposed in this pull request:
The mark-as-read links contain the resolved state, so the automatic reading preference becomes an explicit `state=2` filter after the redirect. Once the last unread article is marked read, the page stays empty instead of showing all articles.
- Keep the requested state separately from the resolved state used by the action. A redirect state of `0` reapplies the reading preference; explicit filters still survive the redirect as intended by #9007.
- Carry that value through the menu, footer and “Load more” link. This matters with oldest-first pagination, where the last footer supplies the menu button's action.
- Add request-state and rendered-pagination tests, plus a changelog entry.
How to test the feature manually:
1. With both read and unread articles, choose “Show unreads if any, all articles otherwise”. Mark all as read from the footer, then repeat using the top button. The resulting page should show the read articles.
2. Set one article per page and oldest-first sorting. Load the remaining articles before marking all as read; the same fallback should work.
3. Select an explicit unread filter: marking everything read should still leave an empty unread view. Also check a favourite filter with a search term, including “Mark selection unread”; the search and favourite scope should remain intact.
Validation: `make NO_DOCKER=1 fix-all` and `make NO_DOCKER=1 test-all` pass with PHP 8.5.9 (719 PHPUnit tests, 1,378 assertions). Browser checks passed against SQLite in Chromium (desktop and mobile viewport) and Firefox. The original footer fails three of the six new rendered-pagination cases.
* Limit the length and parentheses nesting depth of a search query.
* Use exception
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Ignore security-relevant OPML attributes for dynamic OPML sources
OPML content can come from an untrusted source (a dynamic OPML category re-fetches its remote content on every refresh cycle). Such content must not be able to configure feed cURL parameters (CURLOPT_COOKIE, CURLOPT_PROXY, CURLOPT_POSTFIELDS, etc.) nor create further dynamic OPML categories. importOpml() therefore defaults to untrusted, and the local file import controller explicitly opts in.
* Review
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Log and block invalid challenge formats during login
Fixes some unexpected behaviors during a user login, including strange errors in the log related to JSON.
* Ignore JSON decode errors for URL unserializing
* Document the format a bit
* Only allow http/https URI schemes for enclosure and thumbnail URLs
Feed enclosure URLs were stored and rendered without URI scheme validation:
SimplePie's scheme check only applies to URLs inside content HTML
(Sanitize::replace_urls()), not to enclosure metadata, so a `javascript:`
URL survived the whole pipeline and was served as a clickable link in the
article view, including through the Google Reader compatible API.
Add FreshRSS_http_Util::isAllowedUrlScheme() and apply it:
- at store time, when building enclosure attributes in FreshRSS_Feed
- at render time, in FreshRSS_Entry::content(), so entries already stored
in existing databases are protected as well
https://github.com/FreshRSS/FreshRSS/security/advisories/GHSA-3fw6-j8m4-82vj
* Reuse existing SimplePie is_remote_uri() function
* Use more realistic test examples
* Add tests for protocol relative
And simplify tests further
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* SimplePie: Harden cache deserialization calls
To reduce impact if a maliciously provided cache file is somehow loaded, e.g. from a manipulated backup or some kind of external arbitrary file write vulnerability.
* Sync our SimplePie
https://github.com/FreshRSS/simplepie/pull/90
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
This is done by getting rid of the CSRF check allowlist, and instead of refreshing the CSRF token on every unauthenticated page load, only refreshing the token when needed (on login, logout, account delete, HTTP auth user change)
In addition, the actualize actions are now correctly protected as well.
The most notable part is that the user create action could potentially be exploited by another website for XSS on a given instance, by submitting the register form with known credentials, and then by the attacker logging into the same account forcibly registered by a malicious website, and setting custom User JS code.
Login action was likely not exploitable in any meaningful way, because of the JS nonce.
It's worth noting one risk of merging this PR, some vulnerabilities discovered in the past had reduced impact due to CSRF token refreshing on every page load: https://github.com/FreshRSS/FreshRSS/security/advisories/GHSA-jf4v-f8p2-8xvq#:~:text=It%27s%20important,FreshRSS%5FAuth%3A%3AremoveAccess%28%29%2E
* Fix marking filtered label articles as read in SQLite
Remove the undefined entry alias from SQLite markReadTag filters. Add SQLite regression coverage for search and state filters, label boundaries, the maximum entry ID, and unread cache updates.
Fixes#9214
* Test label read filters across supported databases
Run the entry DAO regression cases against configurable SQLite, PostgreSQL, MySQL, or MariaDB connections using temporary tables. Match compressed content storage on MySQL/MariaDB, cover marking articles unread, and document how to run each backend.
* Keep only fix
Will rework test approach in future work
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Gate greader/json share formats behind Share by OPML
Follow-up to #9246, per @Alkarex's suggestion on GHSA-rpmm-h4hx-5p6p.
The greader and json share formats expose origin.feedUrl (the feed's subscription
URL). Sharing feed URLs is what the "Share by OPML" option explicitly means, whereas
"Share by RSS" is meant to share article content. This moves greader and json in
p/api/query.php from the shareRss gate to the shareOpml gate, so feed URLs are only
served when the owner opted into OPML sharing.
Note: moved json alongside greader too, since both emit feedUrl (same toGReader path).
* sharedUrlGreader: gate on shareOpml
* Move greader share link under Share by OPML group
p/api/query.php serialized entries with mode 'freshrss', putting the full stored
feed URL (including any user:pass@ credentials) into origin.feedUrl for f=json and
f=greader, exposing them to anonymous recipients of a "Share by RSS" link.
Call Feed::url(includeCredentials: false) on this path to strip the credentials,
matching the logging and OPML export paths.
Refs GHSA-rpmm-h4hx-5p6p
* Fix: infinite recursion in sanitizeHTML() when maxLength truncation doesn't converge
Sanitizing can grow a truncated HTML fragment (e.g. an unclosed tag gets
auto-closed), so the previous recursive shrink-and-retry could hit a fixed
point and recurse forever, crashing on a stack overflow. Bound the retries
and fall back to a hard truncation that always terminates.
Add unit tests for FreshRSS_SimplePieCustom::sanitizeHTML(), the XSS
sanitization applied to all untrusted feed content, which had no coverage.
* Update app/Models/SimplePieCustom.php
Co-authored-by: Frans de Jonge <fransdejonge@gmail.com>
* Some refactoring
Avoid two mb_strcut in a row. More cases for fallback. Better fallback. More edge cases.
* Address trailing incomplete tag or entity
* Whitespace reduction
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Co-authored-by: Frans de Jonge <fransdejonge@gmail.com>
* Fix “mark as read older than…” widening the active search
Sibling searches of a `FreshRSS_BooleanSearch` are combined by OR, so
`prepend()` added the maximum publication date used by the “mark as read →
articles older than one day / one week” action as one more OR term instead of
an extra restriction.
With an active search and the stream sorted by publication date, that action
therefore marked as read every article older than the cut-off (ignoring the
search) plus every article matching the search at any date.
`prepend()` now wraps the existing expression first, reusing the logic that
`enforce()` already used for the same purpose.
Co-Authored-By: Claude <noreply@anthropic.com>
* Fix SQL space
* Slight reduction of comments
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Group export feeds by category
## What changed
- Groups feed choices in the import/export page by their existing FreshRSS category.
- Preserves the current export request format and selection behavior.
## Why
The export selector previously mixed feeds from every category, making a category-specific export difficult to assemble.
Closes#5555
* Declare export feed count view data
* Fix select size
---------
Co-authored-by: Gerard Alvear <gerard.alvear@logiqd.me>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Filter global feed list by favorites
Fixes#4564.
The global view now filters its category/feed list when the Favorites filter is active, using the same article state and search criteria used when a feed is opened. Empty categories are omitted rather than showing feeds that would open to no matching articles.
* Fix favorite feed state query typing
* Rework logic, including searches
After reflection, I have revised the logic so that in the global view, the categories and feeds shown are always filtered by the search query (if any) and the states (if consequential):
<img width="2012" height="846" alt="image" src="https://github.com/user-attachments/assets/1fd693fa-9184-4601-85ed-2579e3b7299c" />
This makes the global view more useful and distinct in my opinion (this was otherwise not a view I was using myself, but now it will be).
Note that this behaviour would be challenging to port to the normal view for performance reasons.
* Show the matching counts in the global view UI instead of number of unreads
* Spelling
* Fix case of STATE_ALL
* Minor: more explicit logic
---------
Co-authored-by: Gerard Alvear <gerard.alvear@logiqd.me>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Complete two source TODOs: negative UTC offset date parsing and PostgreSQL tag insert
_noDelimit() stripped '-' and ':' from the whole ISO 8601 string, including a
trailing UTC offset, so a negative offset such as "-05:00" silently lost its
sign and corrupted the parsed date. Extract a trailing offset (or "Z") before
stripping delimiters and re-append it unchanged.
FreshRSS_TagDAOPGSQL::sqlIgnore() returned an empty string as a placeholder,
so re-tagging an already-tagged entry produced a real SQL error on PostgreSQL
instead of being a silent no-op like on MySQL/SQLite. Replace the sqlIgnore()
keyword-injection approach with the sqlIgnoreConflict(string $sql): string
pattern already used by EntryDAO, and implement it for PostgreSQL with
"ON CONFLICT DO NOTHING".
Fixes#7923.
Co-Authored-By: Claude <noreply@anthropic.com>
* Drop the negative-UTC-offset date fix, already covered by #9071#9071 already fixes this exact bug in lib_date.php, so keeping this
PR focused on the PostgreSQL tag-insert fix only.
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Gerard Alvear <gerard.alvear@logiqd.me>
Co-authored-by: Claude <noreply@anthropic.com>
On MySQL and MariaDB (the compressed content_bin code path,
isCompressed() === true), three of the four regex-search constructions
in FreshRSS_EntryDAO::sqlBooleanSearch() generated invalid SQL, so the
query failed and the search silently returned zero articles:
* intext:/regex/ appended a stray closing parenthesis after
sqlRegex(...) (MySQL error 1248);
* -intext:/regex/ had the same stray parenthesis;
* -/regex/ emitted ' ANT NOT ' instead of ' AND NOT '
(MySQL error 1064).
sqlRegex() already returns a self-contained, balanced expression
(REGEXP_LIKE(expr, ?, 'flags') or expr REGEXP ?), so no extra
parenthesis is needed, matching the uncompressed (SQLite / PostgreSQL)
branches.
The existing MySQL / MariaDB unit tests for intext:/regex/ had
captured the buggy unbalanced SQL as their expected strings; they are
corrected, and new cases are added for -intext:/regex/ and -/regex/
on both flavours.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* Add per-feed option to show or hide enclosures
Some feeds attach lots of enclosures (images, audio, video, downloads)
that clutter the article view for readers who only want the text. Add
a per-feed "Show enclosures" setting (Feed configuration > Display),
defaulting to "by default" (i.e. shown, matching current behaviour),
following the same ternary-attribute pattern used by existing
per-feed overrides such as show_unread_count and read_upon_gone.
Also apply the preference to the GReader-compat API content call and
the Fever API entry serialization, so third-party clients like Reeder
and Fever apps respect it too, not just the normal web UI. Renamed the
translation key to display_enclosures to match the underlying feed
attribute name.
Fixes#4999
* i18n: fr
* Fix Fever API
* Remove accessor
* Forgotten fix
---------
Co-authored-by: Gerard Alvear <gerard.alvear@logiqd.me>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Show only one representation per media:group
A <media:group> gathers <media:content> elements that are effectively
the same content, yet different representations (e.g. several
resolutions of the same video), but every representation was stored and
rendered as its own enclosure. For instance, articles from PeerTube
feeds showed the same video five times.
When loading entries, keep only one <media:content> per <media:group>:
the one marked with isDefault="true", or the first one otherwise.
https://www.rssboard.org/media-rss#media-groupFixes#8569
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Update SimplePie to include FreshRSS/simplepie#86
Regenerate the vendored SimplePie from FreshRSS/simplepie@freshrss via
`composer update --no-autoloader`, replacing the hand-patched Item.php
with the merged upstream version.
Includes <https://github.com/FreshRSS/simplepie/pull/86>.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove redundant test
Already covered by https://github.com/FreshRSS/simplepie/pull/86
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Add log search to the logs page
Closes#8471. Adds a search field and filters log level, timestamp, and message before pagination
* Fix log search formatting
* Type the log search view property
* Fix missing i18n
Another option could be `index.menu.search_short`
* Fix form submit
---------
Co-authored-by: Gerard Alvear <gerard.alvear@logiqd.me>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Labels (tags) were sorted by the database's raw byte/ASCII collation
via ORDER BY name, so accented or non-Latin label names sorted after
all ASCII names instead of near their base letter.
Apply the same FreshRSS_Context::localeCompare() sort used for
categories, feeds, and shares (#8985) to TagDAO::listTags(), fixing
the last remaining sub-item of this issue; the others were already
resolved by #6212.
Fixes#6211
Co-authored-by: Gerard Alvear <gerard.alvear@logiqd.me>
## Problem
The feed **tree** (left sidebar) and the **feed-title view** (main body) order feeds differently for names with accents or symbols. As reported in #8300, a Spanish feed starting with `Ú` appears **last** in the tree, after every ASCII-named feed.
The tree is sorted in PHP by `FreshRSS_Category::sortFeeds()` with `strnatcasecmp()`, which compares raw UTF-8 **bytes** — so any name starting with a non-ASCII character (`0xC3…` for `Á`, `Ñ`, `Ú`, …) sorts after `A–Z`. The main view is sorted by the database according to its collation, which orders accented letters near their base letter — hence the mismatch (as @Alkarex noted in the issue).
Closes#8300.
## Change
`sortFeeds()` now compares with a `Collator` (`ext-intl`, already a hard requirement) for the user's language, falling back to the previous `strnatcasecmp()` if a collator cannot be created:
```php
$collator = \Collator::create(FreshRSS_Context::hasUserConf() ? FreshRSS_Context::userConf()->language : '');
```
Sorting a Spanish set, before / after:
```
old: banana, Manzana, nube, Zorro, Ábaco, Ñandú, Úlcera, árbol
new: Ábaco, árbol, banana, Manzana, nube, Ñandú, Úlcera, Zorro
```
This makes the tree order locale-aware and much closer to the main view. Exact parity with the main view still depends on the database collation (which is admin/DB-specific and cannot be reproduced generically in PHP), but the "accented names sort last" problem is gone.
* Feeds tree: sort feed names with locale-aware collation
The feed tree sorted names byte-wise with strnatcasecmp(), which places
names starting with a non-ASCII character (e.g. an accented capital such
as "U-acute") after every ASCII name. Use a Collator for the user's
language so accented and non-ASCII names sort near their base letter,
closer to the database collation used on the main feed-title view.
For https://github.com/FreshRSS/FreshRSS/issues/8300
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Guard Collator with class_exists() so intl stays optional
The intl extension is only a recommended (not required) dependency, but
sortFeeds() called \Collator::create() unconditionally when a user language
was set. On installs without intl the \Collator class does not exist, so the
call raised a fatal "Class \"Collator\" not found" — effectively promoting
intl from recommended to required.
Guard the call with class_exists('Collator'): when intl is absent, fall back
to the previous byte-wise strnatcasecmp() sort. Locale-aware ordering stays a
progressive enhancement and the listed requirements are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Shorten the sortFeeds() collation comment per review
Trim the rationale comment to a concise four lines as suggested in review,
keeping the locale-aware/fallback and class_exists() reasoning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Stylistic preference
* Factorise localeCompare
* Minor code preference
* Preserve natural locale sort behaviour
* Avoid repeated locale lookup in comparator
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
* Add option to keep or reset article sort order when navigating
Adds a new per-user reading option, "Keep the current sort order when
navigating between categories and feeds" (config key: sticky_sort), under
Settings > Reading > "Left navigation: Categories". It defaults to enabled,
which reproduces the current behaviour exactly.
Background (addresses #8953)
----------------------------
Once a sort/order was active in one view, it was propagated to every sidebar
link and to the "mark all as read" redirect. Opening another category then
ignored that category's own defaultSort/defaultOrder, because FreshRSS_Context
only applies those defaults when sort/order are absent from the request. As a
result the current view's sort (whether chosen manually or resolved from that
view's default) "bled" into the next category/feed.
Behaviour of the new option
---------------------------
- ON (default): unchanged. The current sort/order stays sticky while
navigating, exactly as before.
- OFF: sidebar links and the "mark all as read" redirect no longer carry
sort/order, so each target resolves its own order in FreshRSS_Context:
feed/category defaultSort/defaultOrder, falling back to the global Reading
sort criterion when none is configured.
order/sort propagation across the UI (for reference)
----------------------------------------------------
- Categories & feeds (sidebar): now gated by sticky_sort (app/layout/aside_feed.phtml).
- "Mark all as read" -> next category, both the top navigation button and the
stream-footer button: now gated by sticky_sort
(app/Controllers/entryController.php::readAction, non-AJAX redirect).
- Custom user queries: unaffected. A query's link is a self-contained URL built
from that query's own saved parameters, so switching query A -> B never
carried A's sort; no option is needed there.
- Stream transition links when sorting by category/feed name: intentionally
left unchanged (out of scope; the sort is intrinsic to that grouping).
Compatibility
-------------
- Not a breaking change: the default (true) reproduces the previous behaviour.
- No database migration. The setting is a standard user configuration value
with its default declared in config-user.default.php, so existing users keep
the previous behaviour transparently.
- The read/unread state filter still persists across navigation regardless of
this option.
i18n
----
English strings authored; en-US marked as IGNORE. Translations for the other
25 languages were produced with an automated (AI) translation service and
should be reviewed by native speakers.
* Update app/i18n/en/conf.php
Co-authored-by: Frans de Jonge <fransdejonge@gmail.com>
* Comments preference
* Update app/i18n/en/conf.php
Co-authored-by: Frans de Jonge <fransdejonge@gmail.com>
* i18n updates and comment
* Update translations to match reworded sticky_sort strings
Re-applies the 24 non-English/French translations of the "sticky_sort"
reading option after the English and French wording was updated to
"Keep manual sort order during navigation".
* improve Dutch
* i18n DIRTY
* i18n en: custom sort order
---------
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Co-authored-by: Frans de Jonge <fransdejonge@gmail.com>
- Add a new `EntriesRead` / `entries_read` hook for extensions.
- Trigger the hook when `markRead()` changes entry read/unread state for explicit entry IDs.
- Cover both the default DAO path and the SQLite/PGSQL DAO path.
- Add Minz hook tests for the new hook signature and argument passing.
## Why
Extensions can already react to favorite/bookmark changes through `EntriesFavorite`. This adds the matching read/unread hook requested in #4051 for extensions that need to collect stats or react to manual read state changes.
Closes#4051