mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
545eb650ca30f592de5a00ea3cb2b6ed28037314
228 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a416d41cda | feat(packing): templates keep weight, quantity and bag; packed and per-person weights (#1131) | ||
|
|
7acf7f5655 |
feat(admin): a daily limit on Google API calls
Admin > Settings > Google options gets a daily limit with today's count beside it. Every call through the Places and Routes clients is counted per UTC day; once the limit is reached the Google key reads as absent until the next day, so search, details and photos fall back to OpenStreetMap the way a keyless install does, and the admin sees that the day is used up. (#1582) |
||
|
|
237f42a493 |
fix(llm-parse): harden photo reading and finish the receipt scan
A photo is now decoded only when its header says it is within 40 megapixels, and bytes TREK cannot shrink, such as a WebP, are sent only up to 3.5 MB. A scanned PDF page is drawn by its long edge, and pdf.js skips an embedded image above the same pixel cap instead of decoding it. The receipt reading is held to bounds in the shared contract, keeps the minus sign of a discount and leaves such lines out so the Ticket split can save, and its prompt states the JSON shape, so a server without json_schema still answers something the parser reads. The model lookup sends the configured key, the import error names the photo formats, the scanned photo is attached only for someone who may upload files, and the phone settlement reloads after a receipt is saved from the trip sheets. Test ids that clashed after the rebase are renumbered, and the wiki describes the image setting, photo import and receipt scan. |
||
|
|
b1c05bca83 |
feat(llm-parse): read a photo, as a booking or as a receipt in Costs
Whether the AI Parsing model reads images is an admin setting now, and it lands with the two features that send a photo, so it has something to control. The setting is `vision: 'auto' | 'on' | 'off'` on the addon config. On Automatic a local Ollama is asked what the model can do (`/api/show`), and a cloud provider means no; Yes and No are applied as set. The old `multimodal` flag is not read: the admin screens wrote it false on every save, so a stored false meant nothing. The per-user toggle keeps its on/off meaning and is read by the extraction now. The lookup is server-side only: capped read, validated body, never throws, cached per server and model. GET /api/llm/capabilities answers it for the signed-in caller, and both photo pickers below ask it. The booking import takes JPG, PNG and WEBP when the model reads images. The photo goes as an image to every provider (image_url, an Anthropic image block, Ollama's native chat with `images`), capped at 1600px, and skips KDE Itinerary. A scanned PDF, which used to reach the model as empty text, has its first two pages drawn by pdf-parse and sent the same way. The rest is the import as it was: background job, review, the file attached to the booking, the linked cost. On a real qwen3.5:4b a train ticket came back as bare clock times until the router gave the model every type's field meanings, and its price as an object inside a string, which read as 4900; both are handled. Costs gets Scan receipt: a dialog laid out like the booking import's takes one photo, read on the import job queue, then reviewed in the existing expense editor pre-filled with merchant, total, currency, day and lines, the photo waiting in Receipts & Invoices to be uploaded on save. No table, no migration, no category guess, no document-type detection. |
||
|
|
1727732841 |
feat(plugins): plugins can add POI categories to the map and answer them
A new manifest capability poiCategories (up to 4, icon from an allow-list, strict #rrggbb color) and the hook poiCategoryProvider.getPois behind the hook:poi-category-provider permission. GET /api/plugin-pois in the plugin contributions module answers them in the /api/maps/pois shape, with search_plugin_pois as the MCP twin. plugin: ids no longer count as Google place ids. SDK 1.8.0 carries the types, validation and docs. On the map, one hook merges the built-in categories with the ones active plugins declare, and the pill shows the plugin chips after a divider on desktop and phone, scrolling sideways instead of shrinking a chip below its touch target. A plugin chip searches through a repo that refuses offline and parses the answer strictly. Its pins, on Leaflet and GL, use the category's own icon and color, both checked again before they reach any markup, and the hover card lists the plugin's details as escaped text. A plugin POI can be added as a place like any other. The admin panels on desktop and phone show a chip for the new permission and the categories a plugin would add, from one shared module. Addresses discussion #1781. |
||
|
|
650451bdf7 |
feat(notifications): Web Push delivers notifications to every device, even with TREK closed
A fourth built-in channel next to email, webhook and ntfy. Browsers register per device, the server encrypts per RFC 8291 and signs per RFC 8292 with node:crypto and sends only to known push services through the SSRF guard. The event matrix decides per category what arrives by push; admins switch the channel on under Notifications. VAPID keys come from the environment or are generated once, only while neither stored row exists, and kept encrypted. A pair that does not decrypt or match is never overwritten and never swapped for another one: push stays off with an error in the log and every device stays registered until the key is fixed. The subject is VAPID_SUBJECT, then an https APP_URL, then the project URL. Only chat messages collapse into one notification per trip, and a replacement still alerts. A 403 removes a device only after three failures in a row; 404 and 410 remove it at once. Password change, reset and an admin setting a password drop the user's devices with the other credentials. The request log redacts subscriptions and endpoints, boot errors print *** for every secret variable, and logout forgets the device with a plain fetch so an expired session cannot redirect midway. Addresses discussion #894. |
||
|
|
7ce31756a3 |
feat(settings): every date picker starts its week on the day you pick
Settings > General > Language & region gets "Week starts on" (Monday, Sunday or Saturday), and Admin > User Defaults a default for everyone who has not picked one. Monday stays the built-in value, so nothing changes for anyone who leaves it alone. Both calendar implementations follow it: CustomDatePicker, used by trip dates, bookings, costs, to-dos and the admin school holidays on desktop and phone, and the journey date picker, whose weekday headers were hardcoded English and now speak the app language. The grid math and the header row live in one helper (utils/calendarWeek.ts). The day names on the options come from Intl, so only the label and a hint needed translating. Vacay keeps its own per-plan week start. The value is validated like the other regional settings: VALID_VALUES for admin defaults, the display-settings MCP tool, and the managed key list. Addresses discussion #2029. |
||
|
|
ed18c37903 |
fix: the findings of the final 4.3.0 audit
Every file the release touches was read once more, and what survived an independent check against the code is fixed here, with a test for each. Data and credentials - A document sync connection edited to a new address no longer carries the stored token along: a blank form under another origin is refused, the same rule the probe route already had. Dawarich and AirTrail testConnection send the stored key only to the host it was stored for. - The key rotation script now covers document_connections.secrets and trip_document_links.webhook_secret, which it had skipped. - Turning a booked night into a pause in the road trip popup asks first and says that the booking and its expense go with it. Deleting a place says the same when a night is booked there, on the desktop and on the phone. - A moved version marker over unchanged bytes no longer replaces the TREK file and its attachments; a real revision carries the attachments over. Planner - A drop in the day plan names the row it lands ahead of in the full day, so a hidden hotel stop no longer shifts it one slot early; the store sorts before it splices. Via re-anchoring, the booked-night guard and the sidebar's day membership read the stored day, the index space the server uses. - A hotel stop placed mid-day re-pins the road trip vias of that day, and the booking session receives the new stop order too. The Public API and the iCal feed list a booked hotel once. A plugin can no longer write a stay against another trip's day. Road trip and map - The route overview paces its requests to the public routers and retries a rate limit, and it says which legs it could not route instead of quietly reporting a shorter trip. A click on a via handle no longer drops a second via; handles hide when zooming out. Read-only members cannot edit the driving limits. The phone shows a way back when no day is picked and prints the night drive departure in the chosen clock format. Integrations and search - Google transit keeps conventional trains when the train chip is on. TREK_PLACES_ENABLED=false stops the details and enrichment calls too, and accepts the whole boolean family. A caller-supplied description no longer lands in the instance-wide enrichment cache. Nominatim searches carry a timeout. An OSM place id is checked for shape before it reaches Overpass. - Dawarich puts a stay on the trip whose dates hold it, the Atlas hand-off knows what the Atlas already shows, and a host change with a new key clears the old instance's state. - Document sync: a webhook address pasted by hand works on the token alone, deleting a connection unregisters its subscriptions, Nextcloud under a sub-path connects, Synology answers a name collision before uploading and verifies a move into the bin, Paperless titles are capped at 128 characters, a DNS failure reads as unreachable rather than as a policy verdict, videos from the store are accepted like uploads, and remote_missing has a way out. Conflict buttons only for the owner, a refused choice is reported, a refused credential gets a Reconnect button, disconnecting asks first on the desktop as it does on the phone, and the folder picker no longer spins forever. Release - The server holds the release notice back from a bundle that cannot draw it, so the previous shell served from the service worker cache after an update no longer shows bare keys and no longer lets a click use the notice up. - Import into an existing list gets the same body limit as a new list. Chat images say when the limit is reached and the new controls are translated. The transit backend switch reports a failed save. The Helm chart carries the Amap credentials in its Secret; the shipped templates document the Places API and Amap variables and the Overpass default. The wiki follows. |
||
|
|
dc1ac0ae85 |
fix(docsync): give the sync flow its own TREK icon and restore the wordmark
The document-sync flow needed TREK's square app icon next to the provider glyphs, and the PR got it by rewriting the shared TrekMark component. TrekMark is the wordmark with the API pill that the admin Places API card, the mobile admin block and the release notes popup draw, so all three showed the app icon instead, and AdminSettingsTab FE-ADMSET-031 failed on the missing "TREK Places API" image. TrekMark is back to its dev version. The app icon now lives in TrekIcon, which DocSyncFlow and MDocSyncSheet use. AddonManager had also switched Synology Photos to the Synology Drive glyph, which renders as a block and sat off-line from the Immich row under Journey. Photos keeps its own icon again. The new Documents shelf gets tests: its rows and glyphs, staying out of the addon count, hiding while Documents is off, the re-read after Documents is switched off, and a provider toggle that saves without re-reading. |
||
|
|
1430056d24 |
fix(docsync): read the retry limit back out, debounce the webhook, and cover the UI
The attempt counter and the backoff were written on every failure and never consulted when planning the next run: rows were found in a live database at 46 attempts against a limit of 6, so a document a provider refuses — one it reads as corrupt, a type it will not take, a full quota — was re-uploaded on every cron tick forever. planReconcile now skips a row that is shelved or still inside its window, and a manual run clears the counters, which is what "waits for a person" has to mean. The webhook had the same shape of bug: WEBHOOK_NUDGE_DEBOUNCE_SECONDS existed, with a comment explaining it, and nothing read it. Providers fire once per document, so twenty files dropped into a watched folder were twenty runs — the in-flight guard answered `busy` to nineteen of them, throwing away nineteen announcements, and the one run that did start had begun before most of the changes landed. A burst now folds into one trailing run, and the binding is re-read when the timer fires so a sync switched off in between does not get a last run out of a stale row. Four defects the new suites turned up, all fixed: the sync button lost its accessible name below sm, where its label is hidden; the stored address was never shown in the connect dialog although the form kept submitting it; the allow-self-signed switch only read the connection at mount, so a stored "on" showed as off and the next save wrote that back; and a refused folder create escaped as an unhandled rejection with nothing said to anybody. Also here: the phone shell gets the same feature (one sheet, four views, the same hook as the desktop), the marks come from the product's own icon files rather than redrawn approximations, and the 22 non-English locales carry the new keys. ADMIN-SVC-069 asserted that `documents` has no MCP surface. It does now, so the test takes its example from MCP_GATED_ADDON_IDS instead of naming one. |
||
|
|
2d7cb124b3 |
feat(documents): two-way sync with Paperless, Papra, Nextcloud, OpenCloud and Synology
A trip can be bound to a folder, tag or space in a self-hosted document store, and documents then move in both directions: what someone uploads in TREK appears there, and what someone files there appears in TREK. Three decisions shape the rest. The connection belongs to the TRIP, not to a person. Every other integration here is per user, and photos are shared only through an opt-in flag; neither can deliver "everyone on the trip sees the same documents", because visibility would depend on whose credentials fetched a file, and a member without their own Paperless account would see nothing. So the trip owner binds the trip once, the server talks to the provider under that one identity, and TREK's own membership decides who sees what. The provider never learns that TREK has members. When the credential holder leaves the trip the binding goes to `orphaned` rather than quietly carrying on with an ex-member's token. TREK keeps the bytes. A synced document is an ordinary trip_files row, so download, trash, offline cache, PDF export and backup keep working untouched, and a provider outage does not blank out a trip that starts tomorrow. Deletion never crosses the boundary on its own. A document that disappears upstream is recorded as missing and waits for a human, and a run that sees more than 40 percent of its known documents vanish at once refuses the whole listing rather than acting on it: an unmounted share answers exactly like a mass delete. The core reconciles by enumerating both sides, because the weakest provider sets the model. A changed-since query cannot see a deletion, Papra does not move updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and Synology FileStation has no change feed at all. A webhook is therefore only ever "look now", never a source of truth. Echo is caught by comparing content, not timestamps: pushed_sha256 records what TREK itself wrote, so a provider reporting TREK's own upload is not mistaken for a stranger's edit. Provider differences are answered by capability data rather than by branching on an id: stable ids where they exist (Paperless document id, oc:fileid, driveId) and a normalised path where they do not, replace-in-place where the API offers it, and a content hash from the listing only where the provider really supplies one. Admins switch providers on under Admin, Addons, Documents, mirroring how photo providers hang off Journey, including the cascade and the 409 when the parent addon is off. The per-trip setup lives in the file manager, where the person has the context that makes the folder choice obvious. |
||
|
|
5b4a7e82ff |
feat(client): show Dawarich stays, routes and marks where they belong
The trip planner and the journal get a "From Dawarich" list: the stays recorded for those dates, each one a row that can be dismissed or turned into a place or an entry after a review step that shows time, name, note and day. A place that came from a recording carries the Dawarich mark next to its name so its origin stays visible afterwards. The recorded route lies over the trip and journal maps as an overlay, coloured per day and cut along the reader's own timezone rather than UTC. It is fetched for the window on screen and never stored. The Atlas gets its own panel beside the map for the countries and cities the recordings prove, and the wish list is ticked off there, on the day of the visit, undoably. The phone has its own take on all of it, in the mobile shell's colours and with native sheets. |
||
|
|
ac57321840 | merge dev and reconcile roadtrip planning | ||
|
|
228e6a13a9 | fix(admin): say what the local model actually gets | ||
|
|
b58771fd4c |
fix(admin): recommend the local model that also reads images
The one model the addon offers to pull was Qwen3 8B, which is text-only, so a scanned page or a photographed document reached it as no text at all and came back empty. Qwen3.5 4B is multimodal at a third of the size: one download covers every kind of document instead of leaving the operator to work out that the recommended model cannot read half of them. The list stays one entry long. A self-hosted host needs a model, not a menu, and any other id can still be typed in or picked from what Ollama already has. |
||
|
|
41b18f970c | feat(vacay): add manual school holiday calendars | ||
|
|
491cfccc56 | feat(roadtrip): add daily scheduling and configurable day endings | ||
|
|
a9907e2f10 |
feat(plugins): let a plugin answer place searches from its own index
TREK searches its own place index and OpenStreetMap. Neither carries a rating, so "the best rated hotel around here" has never been answerable, and an operator with a better index for their region had no way to reach the search box at all. The searchProvider hook is that way in: a plugin is handed the query, a limit, the language and the coordinate the search is biased toward, and answers with places in the same shape the core search returns, plus a rating. It sits beside the core search rather than inside it. The plugin runtime already imports PlacesModule, which imports MapsModule, so asking the providers from the maps service would close a module cycle — the same reason the other fourteen contribution controllers stand next to what they contribute to. The client asks both at once and merges, at the single point every caller already goes through, so all nine search surfaces get plugin hits without knowing the hook exists. A plugin index may never hold up the list: the client stops waiting at two and a half seconds and the host gives a provider two, which is also why the budget is the shortest of any hook here. With no such plugin installed the route answers without reaching the runtime at all. Every hit is normalized host-side and shared by the REST route and the MCP tool, so a cap that holds for the browser holds for the assistant: coordinates are range-checked, strings capped, the rating clamped to 0..5, a website must be http or https, and ids are namespaced so they can never collide with an OSM one. search_places_via_plugins is the MCP half, because what the user can see the assistant has to be able to see; search_place now points at it. |
||
|
|
77162aa8d5 |
feat(roadtrip): avoid tolls, motorways and ferries, through a second engine
The switch a road tripper keeps asking for has never worked. OSRM's `exclude` is the only
avoidance TREK has, and the two public hosts it ships with answer it HTTP 400 on every
leg: their car profile is built without excludable classes, and their own Lua says why
("no memory for classes on demo server"). So "drive this without the tolls" produced
nothing at all on a default install, and the fallback inside the alternatives bar quietly
gave up after one refusal per host.
FOSSGIS runs a public Valhalla on the whole planet next to that OSRM, keyless, and there
avoidance is a runtime weighting rather than a profile that has to be rebuilt. This adds
it as a second engine asked only the questions the first cannot answer, and puts three
switches under the road trip settings that change the road itself rather than offering an
alternative beside it. Paris to Lyon measured 465.8 km / 297 min with tolls against
492.0 km / 454 min without; Hamburg to Copenhagen comes back ferry-free at 469.8 km,
which OSRM cannot produce at all — where a ferry IS the crossing it answers NoRoute
rather than driving round.
Four things the docs would have got wrong, all measured against the live instance:
- `format=osrm` does not carry `has_toll`. That flag is the entire reason for doing this,
and it exists only in the native answer — which is also 5.7x smaller, because the OSRM
shape drags every intersection along. So the adapter translates instead of borrowing a
familiar envelope.
- Without a `radius` Valhalla refuses a point it cannot bind to a road, where OSRM still
snaps one. Vienna's Stephansdom, in a pedestrian zone, answers error 442 until a search
radius is allowed. TREK is full of such places, so every location carries one.
- Ten locations is a hard cap and a road trip day is bundled into one request. A day over
it is split into runs overlapping by one point, which is exact rather than approximate:
a measured six-stop day came back within two metres and under a second of the same day
asked whole.
- Its drive times run 5.4 % above OSRM's over ten European legs and 36 % above on one, and
those times feed the arrival clocks of a day. So a day is answered by one engine or by
neither, never by both, and a piece that will not answer loses the whole day rather than
half of it — the schedule chains leg times blindly and would otherwise print a complete,
plausible, silently wrong timetable.
Because the weighting can fail, the honest surface is the answer rather than the request.
`use_tolls: 0` still returns a tolled road where there is no untolled crossing, so what
the road actually turned out to be is read back per leg, and a day that could not avoid
what it was asked to avoid says so on its own header instead of wearing a label the road
disproves. The switches disable themselves, with a reason, on an instance pointed at its
own OSRM: that operator chose against public hosts, and a second one they never asked for
would undo the choice.
Two things that would have broken quietly and did not:
- The route cache keys on `profile:unit:coords` and is shared with the day planner, the
studio and the booking geometry. Without the avoidance in the key, flipping a switch
returns the old road out of cache in under a millisecond — and worse, an avoiding answer
stored under the plain key hands those other callers a road nobody drives.
- `snapped` is OSRM-only and carries the dashed kerb-to-door spur from #2273. Valhalla
echoes the coordinates it was given and never says where it bound them, but the ends of
a leg's line are exactly those points: measured against Füssen, its first vertex matches
OSRM's snapped location to the digit.
The alternatives bar gets the same engine, which finally makes its "no motorway" and "no
tolls" offers appear on a default install, plus a ferry-free one. `sameRoad` now compares
geometry rather than numbers there, because two engines' distances for the same road sit
kilometres apart and the old 200 m tolerance called that a discovery.
Desktop only, like the whole road trip mode: nothing under client/src/mobile renders it.
|
||
|
|
9543fdc3f2 |
feat(collab): add shared links, chat images, and plugin fullscreen
Adds a Collab Links panel (add/pin/delete) with translations in every locale so tab and button labels are not raw i18n keys, chat image attachments (up to 4), and native fullscreen on plugin iframes. Includes Zod request DTOs and CollabPanel coverage for the Links tab. |
||
|
|
59a1756529 |
style(admin): lay the admin panel out in two columns and match the banner buttons
The User Defaults and Notifications tabs were one narrow column of cards on a page that has no max width, so the right two thirds stayed empty while the page scrolled for ages. Both now use the two-column grid the Settings tab already has, grouped by subject rather than by height. User Defaults splits into "Default User Settings" (colour mode, units, time format, currency, booking codes) and a "Map" card holding template, keys, routing base, preview and engine. The columns are deliberately uneven — the left one never needs more than its widest option row, the right one holds four fields whose values are 60-character tile URLs. Notifications keeps the three channel switches together on the left, because they all write the same notification_channels list, and puts the operator's own targets on the right. The admin preference matrix moves in with them instead of running full width underneath: it decides which of those channels each admin-only event goes out over. Its label column gets minmax(0,1fr) so it wraps rather than overflowing in the narrower column. The transit provider was the one native <select> left on the Settings tab. The option list of a native select is drawn by the OS, so it followed neither the colour scheme nor the radius nor the user's text-size setting; it now uses the CustomSelect the rest of the app uses, and skips the request when the option already selected is picked again. In the update banner, "View on GitHub" was a size smaller than "How to Update" on every axis — 12px against 14px, a 14px icon against 16px, less padding, and 2px shorter. Two buttons side by side read as one group, so that reads as a mistake rather than as hierarchy. Same box now; the fill, the colour and the weight still mark the primary action. |
||
|
|
239856f35c |
fix(settings): make the self-hosted routing engine an instance setting
The field was rendered in every non-managed user's own Map settings and written to their personal settings row. The CSP is built from something else: bootstrap reads the ADMIN default and puts that origin into `connect-src`. So filling the field in was the one path the product offered, and it could not work — the route calculator happily built its URLs against the typed origin and the browser refused every one of them with a bare TypeError, not a status, so no fallback and no retry could see it. Day-plan routes, transport legs, the mobile day route, Studio's road route and the whole road-trip rail stopped routing, app-wide, with a generic "route could not be calculated". Nothing in the UI could repair it except emptying the field again, and there was no admin field to set the value that would have made the origin allowed. The repo has decided this shape once already: `llm_base_url` is admin-only because it names where the server sends its own requests and only whoever runs the instance can judge what is reachable. This is the same class for a different reason, so it joins the same guard, which is now named for what it actually covers. Clearing stays open to everyone, so a row written before the rule cleans itself up rather than blocking the save. The field moves to the admin's default-user settings, next to the tile URL and the Carto key, where a value takes effect. |
||
|
|
bc10fba389 |
feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning A plugin whose author has not updated its `trek` range for the running TREK can now be installed, updated and activated when the operator sets TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker, post-extraction re-check, sideload, dev-link, activation) warns instead of refusing; a missing range is tolerated the same way; "install latest" takes the newest published version. The plugin-API version gate is not lifted and the Discover compatibility verdict stays truthful. The admin is warned at every step: each bypass is logged, install/upload/link/ update responses carry a `trekRangeBypassed` marker, and both admin panels show a "Version checks off" pill, an "Install anyway" confirm dialog before a registry install, the same notice after a path that could not ask first, and a persistent chip on the row while the plugin runs outside its range. Nine new i18n keys in every locale; wiki pages updated (Environment-Variables, Admin-Plugins, Plugin-Development, Security-Hardening). * refactor(plugins): one useRangeBypass hook behind both admin shells SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass logic had been added to the desktop and phone plugin panels as identical copies. The marker/warning types, the row chip, the "Install anyway" offer, the dialog copy and the confirm-or-notice state now live in useRangeBypass (next to useInstanceSettings, the existing shared-logic precedent); each panel keeps only its own dialog/sheet markup. * docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3% duplication, A ratings, hotspots reviewed) was only discoverable by waiting 20-25 minutes for the workflow. The root CLAUDE.md now states the conditions, the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and how to check a PR with the sonarqube MCP first; client and server CLAUDE.md point at it. shared/CLAUDE.md says every locale needs a real translation — parity cannot tell an English placeholder from a translation. * docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy Verified every concrete claim in the five CLAUDE.md files against the code and fixed the stale ones: the websocket server is the Nest realtime gateway, MCP tools live in <domain>.mcp.ts files, the client "native shell" section described code that was never written, offline maps also prefetch vector basemaps, plus several renamed files and wrong table names. Then trimmed everything that drifts on its own: dates, migration history, counts, and inventories of stores, slices, domains, sync files and CLI commands. What stays is the commands, the invariants with the file that enforces each one, what CI actually runs, the reference implementations to copy, and the rules for new code. Each file now opens with that rule. |
||
|
|
b82962f95b |
Feat/plugin instance actions (#2209)
* feat(shared): scope plugin action descriptors * feat(plugin-sdk): instance-scoped settings actions * feat(plugins): run instance-scoped actions from the admin settings dialog * feat(admin): instance-scoped plugin actions in the settings dialog * fix(plugins): surface instance actions without instance fields and harden the action gate * docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope * fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window * docs(wiki): plugin pages for instance actions and the admin panel changes |
||
|
|
3f49ef8d21 |
fix(plugins): readable save button, honest respawn, scrolling hosts dialog (#2170, #2159)
The instance-settings save button paired bg-accent with a hard text-white and vanished in dark mode; it uses the accent pair token now. A failed activate() after saving no longer reports success over a dead plugin: the config stays, the response says what happened. The allowed-hosts dialog scrolls like the ones
|
||
|
|
4cd25d3047 |
fix(admin): the addons page re-reads after a journey toggle instead of trusting itself (#2156)
Disabling Journey cascades the photo providers off on the server, but the panel kept its local rows, so after off and on the providers showed active while being disabled. The desktop and the mobile panel re-read the list after a journey toggle; the write itself still rolls back a single row on failure. |
||
|
|
1e6ab069cd |
Fix/sdk default (#2199)
* feat(plugin-sdk): settings-field default + unknown-attribute warning
* feat(shared): default on the plugin settings-field contract
* feat(server): persist and serve plugin settings-field defaults
* feat(client): pre-fill plugin settings defaults in the admin and user forms
* feat(server): enforce required plugin settings on both save paths
* feat(client): block plugin-settings save on an empty required field
* feat(server): resolve plugin settings defaults at runtime
A settings-field `default` was stored and served to the form but never
reached the plugin: the child's init envelope, `ctx.settings.get()`, the
channel dispatch's readAll and the OAuth provider config all read the
stored blob only. Fold `stored ?? default` in on every path that hands
config to a plugin (settings-defaults.ts), and let a default satisfy
`required` on both save gates — refusing a save the runtime would have
accepted was a contradiction.
Also: one `isFilled` rule shared by the save gate and `hasRequired`
(checkbox exempt, whitespace empty); the install parser drops a
non-boolean checkbox default or a select default outside its options,
matching what the SDK already refuses; export SETTING_FIELD_KEYS so the
SDK's mirror can be parity-tested.
* feat(client): share the plugin settings form rules across all three forms
The mobile user settings screen was a third consumer of the fields/config
contract that never got `default` pre-fill or the required check, and
the seed/patch loops lived in five hand copies. Extract
seedSettingsValues / findMissingRequired / settingsPatch into one module
and use it from the admin instance form, the desktop user tab and the
mobile screen. The user forms now surface a 4xx `{ error }` from the
server (the required-field refusal) instead of the generic toast.
* feat(plugin-sdk): seed dev fixtures from manifest defaults
`trek-plugin dev` handed the plugin only the fixture values, so a plugin
relying on its manifest defaults read undefined in dev and only in dev.
Seed ctx.config / ctx.settings from the declared defaults (a fixture
value still wins) via a new exported settingDefaults(manifest, scope).
SETTING_FIELD_KEYS is now parity-tested against the host's list.
* docs(plugins): document settings defaults as runtime fallbacks
A `default` is the field's effective value wherever nobody set one, not
just a form pre-fill; `required` is enforced on both ends; unknown
attributes are dropped at install and warned on by validate. Add a
Settings section with the attribute table to the SDK README and update
the ctx type comments, the shared schema, and the wiki guide.
* add missing exports
|
||
|
|
da2c881259 | feat(admin): ai-parsing settings live in the tile shelf, compact | ||
|
|
fef5c39581 | feat(admin): photo providers require the journey addon | ||
|
|
3472d06e62 |
feat(admin): rebuild the addons page as a tile grid with sub-shelves
The three stacked sections become one grid of cards per group, with the type shown on the tile itself. Bag tracking, the collab features and the photo providers hang off their parent addon as compact shelf rows instead of full-width siblings, and each group header counts how many of its addons are on. Along the way: - naver_list_import, airtrail and llm_parsing get catalog entries, so their names and descriptions translate like everyone else - dark detection moves into a shared useIsDark hook that watches the .dark class instead of re-deriving settings + matchMedia locally - the ToggleSwitch knob uses --accent-text so a light accent in dark mode no longer renders white-on-white |
||
|
|
394201cd32 |
feat(plugins): instance-wide settings editor in the admin UI (#2170)
* chore: bump version to 4.1.0 [skip ci] * fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone Three places answered "is this a place the trip already has?", not two: PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and kept its own hand-rolled copy of the pre-#2130 order. Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of it — savePlace calls it directly — so the coordinate fallback for a NAMED candidate was live, not latent: two distinct places sharing an address (a restaurant and the bar upstairs) would report each other as duplicates the moment their coordinates round to the same ~11 m box, regardless of name. It also never read google_place_id/google_ftid/osm_id at all, even though every collection_places row stores them, so a place renamed since it was saved could be saved again instead of being recognised by its provider id. Now walks placeMatchStrategies() from @trek/shared, same as findMatchingPlaceId: provider id, then name, then coordinates only when there is no name. savePlace passes the provider id fields through since they are already on the request body; the two bulk call sites (importablePlaces, saveFromTripPlaces) are unchanged, since their source rows don't carry provider ids to pass. Closes #2137 * fix(collections): ask the same question at all three call sites savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy already carries google_place_id/google_ftid/osm_id into the row it writes but asked without them, and the import picker never selected them at all, so a renamed place was recognised on one path and not on the other two. The picker answering differently from the import is the drift the method exists to prevent: a row shown as new would come back refused. COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker. * fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom specified", and so could the map settings tab, which left no screen from which to pick a different basemap. Leaflet answers getMaxZoom() from the map options first and otherwise from a layer that brought one, and only a GridLayer ever contributes via its beforeAdd hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd refuses an infinite one by throwing, before any clustering, so an empty trip hit it too. That path opened when the default basemap became a MapLibre style. It reaches further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless CARTO template to the app default, which is now a vector style, so every instance still carrying the old CARTO basemap fell into it on upgrade. The ceiling belongs on the map rather than on the base layer, because it has to hold whichever of the three branches renders. 19 matches what the raster and satellite layers already carried, so nothing changes for the maps that worked. SharedTripPage gets the same treatment: it has no cluster to throw, but it draws the same vector basemap and its fitBounds asks for a ceiling. mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library rather than a mock, since a mocked react-leaflet would have kept passing through all of this. * fix(map): put the CARTO key back on the template the settings preview draws The map settings fields hold what the user is editing rather than what useTileUrl already resolved, so the preview received a bare template with no key on it. resolveTileUrl then read it as a keyless CARTO url and did what it is meant to do with one: fall back to the app default. The preview drew OpenFreeMap while the fields said CARTO, whether or not a key was saved. Same reason the tab crashed for anyone on a CARTO basemap even after entering a key, which is the loop that made this hard to escape: the key is entered on the page the fallback had taken down. FE-COMP-MAP-033b pins the template the preview is handed, with and without a key. * fix(admin): put the CARTO key back on the default-settings map preview too Same shape as the user-facing map tab, and reported alongside it: the field holds what the admin is editing, so the preview resolved a keyless CARTO template and drew the app default instead of the basemap being set. * fix(mobile): put the CARTO key back on the phone map settings preview too The third preview with the same shape, and the one that matters most while the crash is live: the phone layout is where someone would go to change the basemap when the desktop tab will not open. * fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar Closes #2136 A stay was always exported as an all-day block across its whole range, and the check-in and check-out markers came on top of it. When the stay records both ends of its clock the markers already say everything the block does, and the block is the half nobody can act on: it takes a week of calendar to repeat what two one-hour events state precisely. Same shape as the parking and rental windows in #2068. Only where the markers actually stand in for the booking, though. They are emitted once per stay and titled from its lowest-id reservation, so a second room on the same stay would otherwise be left with nothing at all. A stay that knows only one end keeps its block too, since nothing else carries the other end's date. CAL-025b covers the drop, -025c the one-sided stay, -025d the second room. * fix(mobile): the phone admin map preview needs the CARTO key too The fourth preview with the same shape, missed when the other three were fixed. Sonar pointed at it: the desktop and phone admin pages are near-identical, so the duplication report named the file the change had not reached. * chore: bump version to 4.1.1 [skip ci] * feat(plugins): admin editor for instance-wide plugin settings The scope:'instance' settings a plugin declares had API endpoints but no UI. - shared: plugins.schema.ts — the settings-field descriptor and the admin instance-config GET/PUT wire contracts, imported by both sides - server: GET /api/admin/plugins/:id/config now returns the declared fields alongside the (masked) values; PUT re-spawns an ACTIVE plugin (its child reads config once, at init) and reports it via 'restarted'; the admin list carries instanceSettingsCount so the UI gates its menu item without a fetch - client: 'Instance settings' row action opening a form modal (desktop) / MSheet (phone) in both admin shells — checkbox/select/secret rendering as in the user settings form; an untouched secret mask is never sent back - i18n: three new admin.plugins.* keys in every locale * refactor(plugins): move the admin body contracts to @trek/shared The remaining plugin DTO schemas (install, link, activate, uninstall, retrust, update, egress-hosts, user-settings update, route) now live in shared/src/plugins/plugins.schema.ts with their inferred types, alongside the instance-config contracts. The journey-contract doctrine notes (#1842) moved with them — each body stays deliberately loose so the handlers keep the rejections and fallbacks they already own, and new spec tests pin that looseness so a well-meant tightening breaks a test that explains itself. plugins.dto.ts is now thin createZodDto wrappers over the shared schemas (same class names, no controller changes), and the client's pluginInstall types its options and body against PluginInstallRequest. * fix(admin): offer Allowed hosts only to plugins that declared operatorEgress The row's egress chip was already gated on the declaration, but the kebab menu (desktop) and row action sheet (phone) offered Allowed hosts on every installed plugin — inviting the admin to widen egress for plugins that never asked for it. Both entries now render only when the plugin declares operatorEgress; the dialog's unsupported-notice fallback stays as the truth-teller for stale list data. * test(admin): close the Sonar gaps on the instance-settings form The PR gate flagged new-code coverage (76.4%) and duplication (10.9%). - The two identical 39-line handler blocks were logic, not markup — they move into one shared useInstanceSettings hook; each shell keeps its own markup (the deliberate desktop/phone divergence stays) - New tests per shell cover the untested branches: every field type renders and round-trips into the save payload (select, checkbox/MToggle, number, hint, no-label key fallback), a failed fetch is a toast, a rejected save shows the server reason and keeps the edits, and the dialog/sheet closes without saving - INS-009 covers the controller's no-body '|| {}' fallback --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: michael-bohr <263459669+michael-bohr@users.noreply.github.com> Co-authored-by: Maurice <mauriceboe@icloud.com> |
||
|
|
e70d7397dd | fix(admin): let the plugin consent, signature and dependency dialogs scroll | ||
|
|
a4f8fabde0 |
fix(admin): put the CARTO key back on the default-settings map preview too
Same shape as the user-facing map tab, and reported alongside it: the field holds what the admin is editing, so the preview resolved a keyless CARTO template and drew the app default instead of the basemap being set. |
||
|
|
937e32924d |
fix(plugins): close six gaps in the MCP tool boundary
Found in review of the branch. Each one is a case where the host told the model or the admin something it did not enforce. Advertised schemas are now enforced. buildToolInputSchema built a loose object that checked only which keys were present, so a tool advertising an integer, an enum and additionalProperties: false accepted a string, an unlisted value and an extra property. .meta() still advertises the declaration verbatim, and the schema underneath it is now built from that same declaration, so the two cannot disagree. Anything the builder cannot enforce (oneOf, unknown formats, non-primitive enum members) is refused by parseManifest rather than advertised, and the failure lands at install time where an admin sees it. Writing the test for that turned up a live bug: z.custom() cannot be represented in JSON Schema, so building enums that way would have thrown inside tools/list for the whole session. Enums and consts are real literal types now. readOnlyHint classification fails closed. The side-effect list spelled out db:write: and missed db:create:trips entirely, letting a plugin that can create trips advertise a tool as read-only and idempotent, which is what clients gate auto-approval on. It is an allowlist of read-only grants now, and mcp-tool-annotations.test.ts walks the whole catalog so a new permission fails a test rather than producing a wrong hint. The result cap covers the whole result. It bounded each text block, so a hundred blocks cost a hundred times the limit: 6.5M characters in a probe, now 65K. Large objects are serialised with a budget instead of stringified whole and then sliced, and the block count is capped. The admin tool list was dead code. The registry preview carries only widget and tripPage, so the section added for it never rendered, and the UI hid the description behind the title when both existed. The preview carries sanitised tools now (previewManifest is exported and tested, which is how the gap survived), and both fields render, description always. plugins:use is out of the DCR default. Adding it to ALL_SCOPES meant a registration that names no scopes got it and had it pre-selected at consent. Defaults come from DEFAULT_CLIENT_SCOPES now; asking for it by name still works. The public ManifestCapabilities gained mcpTools, so a typed manifest can express what the runtime already validates. MCPANN-001..005, MCPENF-001..010, MCPPREV-001..005, MCPTOOLS-020..022, SDKP-015b/c. |
||
|
|
eb7d595203 |
feat(admin): show the MCP tools a plugin will advertise
The manifest declaration only becomes an admin-visible control if the admin can actually read it. Nothing rendered capabilities.mcpTools, so approving mcp:tools meant approving text you would first meet inside a chat. The pre-install dialog now lists the tool names and descriptions, beside the existing access and egress sections, and the grant gets an at-a-glance chip like every other capability. Desktop and mobile both, since the panels are parallel copies. |
||
|
|
8069b88057 |
test(admin): wait for the migration POST instead of the save toast
FE-ADMIN-STOR-036 went red on a loaded runner while every assertion in it passed on the run before. The two storage tests that capture the migration POST body read it immediately after awaiting the "Storage configuration saved" toast - but that toast comes from the PUT, and AdminStoragePanel fires the migration POST from a later effect reacting to the refreshed admin.state. The two are not the same tick, so reading the body straight after the toast only held while that effect happened to flush first. Both now await the body through waitFor. A genuine regression still fails, with the same assertion error, once the wait times out. |
||
|
|
b401da2264 |
fix(maps): default the Leaflet basemap to OpenFreeMap
CARTO started stamping "API KEY REQUIRED" across every keyless basemap tile on 26.08.2026. #2060 added a key field as the immediate answer, but it leaves a self-hosted instance needing an email exchange with CARTO before its maps look right, and every default in the client still pointed at CARTO. Default to OpenFreeMap Positron instead: no key, no account, no request limit, commercial use allowed, attribution required. OpenFreeMap serves vector tiles only, so the Leaflet basemap is a MapLibre style document rather than an XYZ template. maplibre-gl-leaflet hangs a GL canvas into Leaflet's own tile pane, which leaves everything above it alone - markers, GeoJSON, clusters, plugin layers, the custom panes, and the click handling, since Leaflet's CSS already puts pointer-events:none on that layer. maplibre-gl still arrives only through a dynamic import, so no chunk that draws a map grows by a megabyte. Switched: the trip planner, the MapView fallback every preview goes through, the journey map, collections, the atlas, the shared trip page and the offline tile prefetch. OpenFreeMap Positron and Bright join the tile presets in settings, the admin defaults and both mobile screens. A CARTO template that is already saved keeps working when a key backs it - that stays a supported choice. Without a key it is not drawn at all: resolveTileUrl falls back to the default rather than to a watermark, so an instance that upgrades with one in its settings sees the new basemap immediately and needs no migration. The stored value is left as it is, so the Map settings tab still shows what was chosen and the warning underneath it explains why - one sentence appended, in all 23 locales. OpenFreeMap asks for a credit of its own; OpenStreetMap alone under its tiles is wrong and a licence problem. attributionForTile() derives it from the URL rather than from a flag at each call site, and the shared trip map, which carried no attribution at all, now gets one. Offline maps keep working and get cheaper. A vector style is four things instead of one, so sync/glPrefetcher.ts walks the style document, its sprite, the Latin glyph ranges of each font stack it uses, the TileJSON - the planet path is versioned, so guessing it answers 200 with an empty body - and then the .pbf tiles. Over the Berlin bounding box TREK computes, raster z10-16 is 2237 requests and 34.6 MB and gives up above z16; vector z10-14 is 177 requests and 32.1 MB and stays sharp above that, because MapLibre scales the z14 tile rather than asking for another. The Workbox rule for tiles.openfreemap.org moves to CacheFirst and shares the prefetcher's cache, so browsing tops a downloaded region up instead of filling a second copy beside it - a versioned tile path can never be stale. Clearing offline maps clears it too. The atlas loses its second raster layer. It existed to warm the HTTP cache of neighbouring zoom levels, which a vector basemap does not need; a second one would only cost a second WebGL context. OpenFreeMap has no label-free style, so its labels are switched off on the live map instead - they are all symbol layers, while borders and coastlines are line and fill - re-armed on style.load so a theme switch keeps them off. Theme changes call setStyle in place rather than rebuilding the layer: a swap per toggle would leak a WebGL context, and browsers cap those in the low teens. |
||
|
|
922f37ab52 |
fix(storage): ask the hook whether a storage job is running, not a stale closure
Every push to dev has been red since the 4.0.0 bump, always on the same test: FE-MOB-MSTOR-015, where the second queued migration POSTs while the first is still running. It passes on the PR runner and fails on the slower push one. Instrumenting the queue effect logs the whole story: DEQUEUE files stateMigrations [] APPLY refresh ["running"] START RESOLVED files error null DEQUEUE journey stateMigrations [] The refresh had already landed the running migration before the single-flight lock was released, exactly as intended. What fires afterwards is the effect of a commit scheduled BEFORE the POST, so its captured admin.state is the pre-POST world, and it runs late enough that the lock is gone. The lock is a ref and is read live; the "is one already running" test was read off that dead closure. Under load the two drift apart and a second migration starts on top of a running one. So the hook answers the question instead. It keeps its world in a ref as well, written where the response is applied rather than when the render lands, and storageBusy() reads that. The queue effect still WAKES on admin.state, it just no longer DECIDES on it, and the server's one-job-at-a-time rule now lives with the hook that talks to the server rather than twice in two shells. Same shape as the dirtyRef/savingRef the hook already keeps for the same reason. An earlier attempt made the panels refresh again after startMigration, which was both redundant (startMigration has awaited its own refresh since #1949) and no fix at all: the stale read is on the next effect run, not on that promise. |
||
|
|
1f66ef1d32 |
fix(maps): let users and admins enter a CARTO API key (#2060)
CARTO stamps an "API KEY REQUIRED" watermark onto every basemap tile fetched without a key since 26.08.2026, and both CartoDB presets plus the built-in fallback are CARTO. The key is free and needs no account, so add it as a setting rather than dropping the provider. carto_api_key sits next to mapbox_access_token: per user in Settings/Map, instance-wide under Admin/User Defaults, encrypted at rest, injected from CARTO_API_KEY on managed instances. Unlike Mapbox it never switches the map provider, since a raster key says nothing about the renderer. The key is appended as ?key= at render time instead of being stored in the template, so a saved URL stays portable and survives a rotation. That is also what reaches the four maps that pinned CARTO and ignored the user's tile setting: atlas, collections, the public trip share and the public journey. The two share pages read it from their token-gated payload, the same way baseCurrency already travels. Also lifts the release notice above page chrome. Its overlay sat at z-index 50, under the Vacay mode toolbar at 61, so the dialog was neither on top nor modal. Adds a six-step layer scale to index.css and moves the three notice shells onto it. Closes #2054 Closes #2052 |
||
|
|
a3ad53b3bf |
fix: close the backtracking patterns SonarCloud's profile turns up
The move to SonarCloud brought a wider rule set with it, and 23 more regexes that can be made to hang on a crafted near-match. 21 are fixed here, each proven equivalent to the pattern it replaces by brute force rather than by reading. Two of them are reachable across users, not just by yourself. A reservation endpoint name is free text any trip member types, stored and then read back in every other member's browser while the map draws its labels, and the pattern over it takes 2.3 seconds on a 32k paste - so one member could freeze the map for the whole trip. The mobile transport sheet reads the same field. Both now scan instead of backtrack, and both were matched against the desktop twin the earlier sweep had already fixed rather than given a third spelling. The markdown link stripper needed the same treatment as the tag stripper before it: the label class cannot be disambiguated in the pattern, because it matches an opening bracket as well, and narrowing it changes what "[a[b](c)" produces. It is a scan now, with all four of the regex's quirks intact. Also here: the two encoding warnings the scanner reported. A decorative comment line in JourneyDetailPage.test.tsx had picked up a replacement character somewhere along the way, and calendar.service.test.ts asserts on one deliberately - that one is now written as a U+FFFD escape, so the file no longer carries the raw bytes while the test says exactly what it said before. Seven findings were deliberately not changed and are flagged in SonarCloud with the reasoning. Five are false positives - patterns with no overlapping quantifier pair, where the only cure would change how nested brackets parse. Two are marked accepted rather than dismissed, because they are real: the readme check's heading pattern has a proven linear replacement that cannot land until the registry's copy of the same script can move with it, and `export let JWT_SECRET` is what makes an admin's key rotation take effect in-process instead of silently waiting for a restart. |
||
|
|
1ad2e92142 |
fix: make the app keyboard-reachable, and close four DoS-able patterns
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.
**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.
`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.
**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.
Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.
sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
|
||
|
|
4ba407aead |
feat(plugins): install an older version of a plugin (#2035)
Adds a version picker to the plugin admin panel, on desktop and mobile: every published version with its own server-computed compatibility verdict, so the client never re-derives semver range logic. A deliberate non-latest pick sets an update hold on the row, which keeps the update banner and "Update all" from immediately nagging the rollback away. The hold releases when an install lands back on the newest compatible version by any path, or when the admin resumes updates explicitly. An unresolvable registry never sets one: refusing to pin on missing information beats silently muting future updates. |
||
|
|
2c2d008dc2 |
fix: clear every open Sonar security issue, and half the reliability ones (#2037)
* fix(security): mint idempotency keys properly, and resolve the extractor by path Three findings out of a sweep of every open Sonar security issue. The other 49 turned out to be false positives and are documented in sonar-project.properties rather than changed — each entry scoped to a file, never a whole rule, so a future Math.random() on an auth path still fails. The idempotency key is the one that can lose data. Both producers fell back to Math.random when crypto.randomUUID was missing, and randomUUID needs a secure context — TREK's own quickstart is http://localhost:3000 and LAN installs run on http://192.168.x.x, so on the deployments the README documents the fallback is what actually runs. The axios one was `Math.random().toString(36).slice(2)`, which is not length-stable: 0.5 yields a single character and 0 yields the empty string, which the interceptor reads as "no key" and skips deduplication for entirely. Two writes sharing a key collapse into one — the second gets the first one's cached response and the edit is gone with no error anywhere. Both now come from utils/randomId, which has the getRandomValues rung that works in insecure contexts too. The extractor probe stored the bare binary name, so every extraction re-resolved it through whatever PATH held at that moment. The official image sets KITINERARY_EXTRACTOR_PATH and never reaches that branch, but a bare-metal install without it ran an unqualified name from a PATH it does not control. It now walks the search path itself and keeps the absolute path of the file it verified; execFileSync also drops the /bin/sh hop the old probe needed. PATH comes through app-config because process.env is walled off in src/. * fix: four tests and sorts that were not doing what they claimed Sonar reported 43 high-impact reliability issues. Reading all of them, four are real; the rest are cosmetic or would be made worse by the suggested change. budget.test.ts hand-built `paid: true`, but both emitters send `paid: body.paid ? 1 : 0` and the handler stores the value verbatim — the test asserted a payload the server never sends. The line below it already used a number. admin.controller.test.ts had a `.resolves` assertion with no await. The promise was dropped, so that line could not fail the test. The two to-do category lists sorted with a bare .sort(), which is byte order: every accented name files behind the whole ASCII range, so in a German trip "Übernachtung" lands after "Zelt". TREK ships 23 locales, so this is real data. One side effect worth knowing: katColor picks a category's dot colour by its index, so re-sorting can move which category shows which colour — it already moves whenever a category is added, and the colour carries no meaning. Deliberately not changed: the two `| 0` truncations (Math.trunc is not equivalent — `| 0` wraps at 32 bits and these carry millisecond timestamps), the two .map() callbacks whose second parameter is declared and used, and the sha1 in thumbnail.service (its own comment says a swap would orphan every existing thumbnail). * refactor: give every button a type, and prefer the Number statics Two mechanical sweeps, no behaviour change intended in either. A <button> with no type attribute is type="submit" per the HTML spec. Outside a form that does nothing today, but it turns into a surprise the moment the markup is wrapped in one, which is what Sonar's S9011 is warning about. 870 buttons get an explicit type="button". Two things were left alone on purpose: buttons that take a {...spread}, because the spread may carry a type and the merge order decides which wins, and anything inside a <form> — there the answer is a judgement call, and checking all eleven form files found every real submit button already carrying type="submit". The Number sweep covers only the three globals that are exactly equivalent to their Number twin: parseInt, parseFloat and NaN. isNaN is NOT in it — the global coerces its argument, so isNaN('abc') is true where Number.isNaN('abc') is false, and renaming it would change what the code does. It reaches server and plugin-sdk as well as the client; both copies of egress-policy.ts got the identical edit, so the mirror the SDK's parity test guards stays intact. |
||
|
|
e3c524dd02 |
test: give every test case an assertion of its own
SonarQube's S2699 flagged 196 test cases as assertion-less. Almost all of them end on a bare `await screen.findByText(...)`, which does throw when the text never shows up — the test is real, the analyser just cannot see it. Spelling the check out keeps the behaviour identical and makes it readable. Writing them out turned three tests red, each for a good reason: - FE-COMP-NOTES-008 read the heading out of the loading panel, which renders its own copy and swaps the whole node out once the fetch settles. It now waits for the loaded state. - FE-COMP-PHOTOPROVIDERS-014 matched /connected/i, which also matches the "Not connected" the badge starts as — the test passed whether or not the button did anything. Exact text now. - FE-FILES-002 compared an empty list against the empty list it already was. It seeds a file first, so the 500 has something to leave alone. JOURNEY-SVC-066 asserted nothing at all behind a "should not throw" comment; it now says that out loud and checks no journey entry is left orphaned. |
||
|
|
c75e958fc0 |
fix(client,sdk): the smaller silent failures, and a dead SDK test file
The SDK's parity suite had an unescaped apostrophe in an it() title, so the file did not parse and vitest reported it as "no tests". Both guards in it were dead - including the byte-identical egress-policy check the package's own notes call the worst possible bug in that module to lose. No CI job runs the SDK suite, so it landed quietly; the next npm publish would have failed on it, since prepublishOnly runs the tests. Also in the SDK: the dev preview answered trek:geolocation without checking the geolocation:read grant, so a plugin whose manifest is missing the permission works perfectly in dev and is dead once installed. That is the lenient direction, which is the one that ships broken plugins. Client side: the background-task poll never dismissed a 404'd import job and spun forever, "Update All" collapsed its busy flag after the first plugin settled, the lightbox had no cancellation so a slow signed URL rendered the wrong image, the plugin geolocation watch was torn down by any unrelated settings write, manual date entry accepted 31 February, cover previews were never revoked, and check-all issued one request per packing item. |
||
|
|
305b113d5f |
Feat/storage driver (#1949)
* feat(storage): storage key validation, driver contract suite, LocalDriver * feat(storage): MirrorDriver composite backend * feat(server): add TREK_UPLOADS_DIR env var for the uploads root * feat(storage): settings-backed registry, StorageService facade, module wiring * test(storage): pin src/nest/storage coverage thresholds * feat(storage): multer upload options factory * refactor(files): spool trip-file uploads through storage * refactor(collab): spool note attachments through storage; normalize files/ prefix * refactor(journey): spool journey media uploads through storage * refactor(trips): spool trip cover upload through storage * refactor(places): spool place-image and collection uploads through storage * refactor(auth): spool avatar upload through storage * fix(server): consolidate allowed_file_types onto AllowedFileTypesService * fix(storage): default defParamCharset to utf8 in the upload factory * test(db): re-point the trailing-migration guard at the trip_files prefix strip * fix(storage): age-gate the boot spool sweep * test(files): key FILE-P02 residue checks on upload size * test(storage): raise src/nest/storage coverage pin * refactor(platform): serve /uploads routes through storage * refactor(files): serve trip-file downloads through storage * refactor(memories): serve photo bytes through storage * refactor(maps): stream place-photo bytes through storage * refactor(share): stream shared place-photo bytes through storage * refactor(journey): serve public journey assets through storage * fix(share): answer place-photo cache miss with 204 like the maps proxy * test(server): close serving-swap gaps surfaced by the full tier * test(storage): raise src/nest/storage branches pin * refactor(place-photos): route place-photo cache through storage * refactor(places): reclaim place images and collection covers through storage * refactor(trips): trip covers through storage * refactor(auth): avatar reclaim through storage * refactor(collab): note-file deletion through storage * refactor(files): trash deletion through storage * refactor(memories): trek photo cache internals through storage * refactor(memories): thumbnail service category-addressed * refactor(memories): immich upload reads through storage; drop uploads-root * refactor(journey): photo deletion through storage * fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep * feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim * test(server): close slice-4 gaps surfaced by the full tier * test(coverage): raise slice-4 domain pins * refactor(backup): list, existence and delete through storage * refactor(backup): download through storage sendToResponse * refactor(backup): create backup through per-category storage enumeration * refactor(backup): restore and upload-restore through storage * refactor(backup): auto-backup retention through storage * refactor(server): boot dir-creation to driver init * test(server): close slice-5 gaps surfaced by the coverage tier * test(coverage): raise slice-5 backup pin * test(server): resolve async backupFileExists mocks for the tests typecheck * test(server): pin ranged-read stat and zero-byte roundtrip in the storage driver contract * feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule * feat(server): S3 driver core — aws-lite client seam, stat/delete, driver-owned deadlines * refactor(server): type the aws-lite S3 client via s3-types reference * feat(server): S3 driver getStream and list — ranged full-object stat, iterator pagination * feat(server): S3 driver put — bounded-peek routing, Upload inactivity deadline * fix(server): S3 driver Upload monitoring — Transform interposition, stream cleanup * feat(server): env-declared s3-main storage backend; settings-declared s3 rejected * test(server): env-gated S3 driver contract suite against MinIO * fix(server): S3 driver zero-byte put — work around aws4 duplicate Content-Length signing defect * fix(server): scope the zero-byte put workaround to buffered bodies; bind test MinIO to localhost * ci(server): MinIO-backed S3 driver contract job * ci(server): align s3-contract shim comment and MinIO bind with references * docs: S3 storage backend configuration and off-box backup mirroring * docs: restore README formatting, keep S3 additions only * ci(server): make live-MinIO contract runs manual-only (AIStor licensing) MinIO's community line is discontinued entirely; the maintained successor, AIStor, requires a license key that can't safely live in a shared CI runner. The s3-contract CI job is removed; live-provider contract runs against MinIO/AIStor are now a manual, license-gated procedure documented in docker-compose.minio-test.yml's header. * chore(server): final-review polish — stale CI refs, dockerignore key, boundary pins, zero-byte tempfile contract case * expose admin ui * feat(shared): storage backend type registry, wire schemas, category relocation * feat(server): accept settings-declared s3 backends — shared-schema options, decrypt-at-build, registry preview() * feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path * feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact) * chore(server): drop storage-secrets import that only Task 8's probe helper will use * feat(server): seed-once storage-config.json boot import — fail closed on any invalid seed * feat(server): registry snapshot() — effective storage config with provenance * feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits * feat(server): api/admin/storage/test — per-target ephemeral probes, mirror targets probed individually * test(server): e2e coverage for api/admin/storage — guards, managed refusal, envelope, probes * test(server): give e2e harness DBs the app_settings table the storage registry reads at boot * test(coverage): raise pins after the storage admin-config slice * fix(server): final-review polish — encryption-gate wording, all-field sentinel guard, README/e2e notes * test(server): storage slice-1 review follow-ups — mirror s3-replica probe decrypt, all-encrypted seed without key, test-route 401 * feat(i18n): storage admin domain + tab label in every locale * feat(client): adminApi storage endpoints — getStorage, updateStorage, testStorageBackend * feat(client): storage admin draft-document helpers + relative-time util * feat(client): schema-driven storage BackendForm — fields by kind, mask echo, encryption banner * feat(client): admin storage panel — backends list, category map, health strip, one-PUT save * feat(client): storage tab on the admin page * feat(client): storage admin panel and section on the phone shell * fix(client): block renaming a storage backend onto an existing name * feat(server): allow mirrors on any storage category — lift the backups-only rule * feat(i18n): replicas-on-primary mirror strings in every locale * feat(client): mirror fold/synthesize model helpers for replicas-on-primary * feat(client): BackendForm mirror-targets composer; raw mirror type hidden * feat(client): desktop storage panel folds mirrors into their primaries * feat(client): phone storage panel folds mirrors into their primaries * fix(client): strip removed backends from remaining mirror replicas — no phantom refs * feat(storage): retire photos as a configurable category — served-only via the ServedCategory seam * feat(i18n): descriptive storage category names and descriptions in every locale * feat(client): descriptive category names, descriptions and id badges in the storage panel * docs(storage): fix stale nine-category comments after the eight-category shrink * docs: storage configuration via the admin UI and seed file — README + compose * docs(wiki): Admin Storage page and storage cross-links * docs(server): storage domain blueprint entry; TREK_PLACE_PHOTO_DIR tombstone clarifier * refactor(server): drop the storage env-var tombstones — the variables never shipped * docs: storage config is simply new in v4 — drop the removed-variables story * feat(shared): backfill-status and usage wire schemas on the storage admin state * feat(server): MirrorDriver.backfill — prefix-scoped, size-compared, cancellable replica catch-up * feat(server): one-at-a-time storage backfill jobs + the storage events bridge * feat(server): storage usage scan — nightly cron, on-demand service, persisted with computedAt * feat(server): replica_failure admin notifications — events bridge, hourly debounce, channel registration * feat(i18n): replica-failure notification text in every locale * feat(server): storage backfill and stats-refresh admin endpoints * feat(client): adminApi backfill and stats-refresh endpoints * feat(i18n): storage sync and usage strings in every locale * feat(client): desktop storage panel — sync now, usage display, backfill polling * feat(client): phone storage panel — sync now, usage display, backfill polling * fix(client): per-category usage line on the phone storage panel * docs: sync now, usage stats and replica-failure notifications * test(server): admin scope now includes replica_failure, not just version_available Commit |
||
|
|
3a0604b3f5 |
feat(config): put the operator-owned surfaces behind TREK_MANAGED (#1946)
* feat(config): put the operator-owned surfaces behind TREK_MANAGED The switch landed last release with nothing behind it. This is what it turns off, plus the four fixes that came out of walking every one of these surfaces. Nothing here changes anything for an install that does not set the flag. That is not a hope, it is what managed-inert.test.ts asserts: the mode stays off for any unparseable value, no other variable can turn it on, every settings key stays writable, and splitManagedKeys hands back the same object rather than a copy so no write path can quietly diverge on self-hosted installs only. WHAT THE MODE WITHHOLDS Fourteen routes carry @ManagedForbidden: key validation, static MCP tokens, JWT rotation, the OIDC admin pair, restore and auto-backup, plugin sideload and reload, the Ollama model list and pull, and the SMTP test send. Each names the reason it is not the instance admin's, and the reason is mandatory. Eighteen settings keys are filtered instead of refused. SMTP, the OIDC toggles and the WebAuthn pair share one handler with keys the admin does own, and the llm_* family is reachable through per-user settings, the instance defaults and the addon config alike, so a route-level refusal would be both too coarse and incomplete. A locked key is skipped and reported as managed_keys; the rest of the save goes through. Refusing the request would take the registration toggles down with the SMTP block. GET /api/auth/me/settings stops returning the three API keys in the clear. It is the only endpoint that decrypts a stored credential back to the caller, which is fine when the admin pasted it in and wrong when the operator supplied it. Two multipart routes check in the handler rather than the guard: a guard throws before the parser drains the body and the client gets an ECONNRESET instead of the 403 (PROFILE-015). They still carry the marker so the boot gate sees them. WHAT KEEPS IT FROM DRIFTING validate-managed-routes.ts refuses to boot on a marking that is not on the reviewed list, a list entry whose route lost its marking, or a marking with no reason. The third case has no equivalent in the guard gate next door and is the one that matters most: a blank reason destroys the only record of why a control was taken away, and nothing recovers that later. It found two wrong entries in my own list while I was writing this. managed-keys.test.ts asserts that every key in ADMIN_SETTINGS_KEYS and DEFAULTABLE_USER_SETTING_KEYS belongs to exactly one of two sets. A key added to either list belongs to neither until somebody decides, and that is the failure this prevents: the lists grow, the mode does not. PLACES_API_BASE AND PLACES_API_KEY The nine Places endpoints spell out googleapis.com, so an install that wants those calls to leave through something of its own has no way to say so. One variable, substituted where every call already funnels through. A second one supplies the key, and when it is set the users table is not consulted at all — a per-user key would route around whatever the endpoint counts, and those columns are readable by any admin of the instance. FOUR FIXES THAT ARE NOT ABOUT THE MODE An admin setting somebody's password now bumps password_version and clears their MCP tokens. An admin sets a password for one reason, and until now verifyJwtAndLoadUser kept accepting every cookie the intruder already had: the one action taken to lock them out was the one action that did not. The four OIDC provider calls go through the SSRF guard. Three are worse than they look — the token, userinfo and JWKS URLs come out of the discovery response, so they are chosen by the other end, not by an admin. safeFetchLlm is renamed safeFetchAdminConfigured and keeps its old name as an alias, because it is the variant that allows loopback and LAN: a self-hoster with Authentik on localhost:9000 must keep working. The token exchange follows no redirect at all, since one would hand client_secret to a second host. DELETE /api/admin/users/:id/mfa clears another account's TOTP. The passkey half has existed since passkeys landed; the TOTP half never did, which left "somebody on the trip lost their phone" with no answer short of an operator in the database. Never for the caller themselves. TREK_PLUGIN_PERMISSIONS=off refuses to boot when the install is centrally administered. A warning is right for a machine its owner chose to trust; where the person who set the flag and the people whose data is in the database are not the same person, fail closed. TEXTS Four strings lose the phrase "self-hosted" because they describe somebody else's software — an Ollama endpoint, an AirTrail instance, a service a plugin talks to — and read better without it in both modes. The one string that describes TREK itself keeps it, and the mode picks a second key instead, so self-hosters lose nothing. A grep gate in shared/ keeps the canonical locale honest, because the parity check compares the file set and the top-level keys and never looks at a value: re-wording a string is invisible to CI today, and so is putting it back. * feat(maps): supply the map credential with the instance when centrally administered MAPBOX_ACCESS_TOKEN joins PLACES_API_KEY as an operator-supplied value. SettingsService injects it when the settings are read, and picks GL as the provider if the reader is still on Leaflet, since a token is always present. The key moves from the customer set to the locked set. It is the one locked key whose value is public by design — a pk.* token has to reach the browser to be useful — so this is not about secrecy: a per-user save landing on top of the instance token would only break the map for that user. Both settings fields for it go away with it. * fix(auth): count the operator key when reporting whether Google features work has_maps_key only looked at the users table, but getMapsKey prefers the PLACES_API_KEY env over anything stored there. On an install that supplies the key that way and has an empty column, every lookup worked while the client was told there was no key and hid the search, the autocomplete and the place photos. Found by walking the running instance rather than the code: the map came up, the search box did not. * feat(admin): hide the settings a centrally administered install cannot act on A control that answers 403 is worse than no control: the AI section rendered the refusal as "Request failed with status code 403", which tells an admin they broke something rather than that the decision is not theirs. So the surfaces go rather than being disabled in place: - AI parsing and AirTrail leave the addon list, and the AI addon route now refuses outright. The endpoint, the model and what a document costs come with the instance, so there is no instance decision left — not even whether it runs. - Immich and Synology Photos leave it too. Both connect to a server the reader runs at home, and there is no route from here to one. - The SMTP block, the admin webhook and ntfy targets, and the passkey RP ID and origins follow the same rule: they configure something the operator owns, and a wrong RP ID on a shared parent domain reaches past this instance entirely. - The About tab goes. It is about the project — what TREK is, where to file a bug, where to support it — and is aimed at the person running the software. The version and the link to the source move to the sidebar footer, on both the desktop page and the mobile twin, because AGPL 13 wants that offer wherever people reach the software over a network. - The update check stops asking GitHub and reports nothing available. Separately, the version footers lose the "self-hosted" suffix everywhere, not just here. It read as a label on the software rather than on the install, and the version alone says what it needs to. * feat(config): refuse to boot centrally administered without an at-rest key Measured on a running instance rather than assumed: with ENCRYPTION_KEY unset, the at-rest key falls back to a file in the data volume, and backupService bundles that file into every archive it builds. The archive is downloadable by any instance admin, so the encryption ships with its own key in the same zip. The skip in backupService already handles this — it only omits the file when the key came from the env — but nothing made sure the env actually carried one. Provisioning sets it; forgetting it was silent. validateEnvAtBoot now carries a second kind of check next to the schema: cross- field rules that only apply with the flag set, for combinations where every value is individually valid and the result is wrong. Empty without the flag, so an install that never asked to be centrally administered cannot meet it. * feat(client): add an attachment point for install-specific screens Two empty exports on the client, an empty module on the server, and the four places that read them. Nothing here adds a route, a nav entry or an endpoint — the arrays are empty and the module contributes nothing, which managed-ext.test pins so it stays that way. Read unconditionally, never behind a flag. On the server a conditional import would tie the module graph to an environment variable, and the graph is built once per app while the flag is read live; on the client an empty array costs nothing to spread, so every build walks the same path instead of one build walking a path nobody tests. Anything attached here follows the same rules as the rest of src/nest and src/components: a real module with a controller and a provider, bodies through the Zod pipe, routes under the global guards, tests. * test(shared): move the wording gate out of src so shared typechecks again CI installs shared with its own dependencies only (npm ci --workspace shared), so there is no @types/node and the `node:fs` import inside src/ failed to compile. The tests passed the whole time — vitest supplies the module — which is why this only showed up in the typecheck step. The walk moves to scripts/, next to i18n-parity.mjs, and the spec imports it the same way that one does. It also runs on its own now (`node scripts/i18n-selfhost-wording.mjs`), which is what you want when CI names a line you have to go find. * test(server): repair the call sites typecheck:tests caught tests/ sits outside the build tsconfig, so none of this showed up in a green test run — which is exactly the gap `npm run typecheck:tests` exists to close. Three kinds, all mine: - Seven `import type` paths for RuntimeEnvService carried one `../` too many. Type-only imports are erased, so vitest never resolved them and nothing failed. - VersionCheckJob and PluginsController grew a constructor parameter and eleven hand-wired `new` calls kept passing the old count. The argument arrived as undefined and no assertion happened to read it. - ConditionContext grew `managed`, and the shared context object in the conditions suite did not. The condition also had no test of its own, so it has one now: both directions, since the registry uses `is: false` to keep a notice away and a notice that only belongs on a centrally administered install would use `is: true`. |
||
|
|
7d0e90eb47 |
Make the plugin permission facts live in one place
The hook->permission map existed verbatim twice (the host supervisor and the plugin SDK) and the permission list four times (envelope, SDK manifest, and both admin panels in the client). All of them were in sync when measured - 63 entries each, 15 hook entries each - so this changes no behaviour. It removes a quiet outage: a hook whose permission is added on only one side fails silently in production, with the plugin installing, activating and then simply never being called. protocol/envelope.ts is the single source now. METHOD_PERMISSION was typed Record<KnownMethod, string>, so its value side was unconstrained - 'db:read:trps' compiled and made the method unreachable forever. It is `as const satisfies Record<KnownMethod, KnownPermission>` now, which catches a typo'd value, a missing key and an extra key. The derived KnownPermission and MethodPermission<M> are what the @PluginMethod decorator will check its argument against. Deliberately no explicit annotation on METHOD_PERMISSION: with one, typeof is that Record, MethodPermission<M> widens to the whole union, and the check silently evaporates. server/scripts/gen-plugin-facts.ts imports envelope.ts and writes the SDK and shared copies. It does not scrape. The guard it replaces pulled the block out of plugin-supervisor.ts with a regex that goes null on a reformat, and it ran only from prepublishOnly - drift would have surfaced as a broken npm release. The generated files are checked in because publish-plugin-sdk builds from a standalone checkout with no server present. The SDK's re-exported types stay wide. plugin-sdk compiles under strict, and grantGaps indexes HOOK_PERMISSION with a plain string, so an `as const` there is TS7053 and would break the publish. Strictness belongs on the host side. --check also fails on a permission with no admin.plugins.perm.* translation and on one the CLI has no hint for. Both are silent today. plugin-sdk/** was in no workflow path filter, so nothing in CI ever ran its tests. It is in the filter now, alongside a job that runs the drift check. |
||
|
|
1e05501b9d |
refactor(client): finish the React 19 migration (#1826)
Mechanical, and it ends the migration visibly.
45 files imported the whole react-dom default export to reach one function.
They now import createPortal by name, across 54 call sites; six files already
did it this way. main.tsx keeps its react-dom/client import, which is a
different module and already correct.
forwardRef is gone from every component. In React 19 ref is an ordinary prop,
so the wrapper only obscured the signature:
NumericInput — a plain DOM ref, no imperative handle involved
JourneyMap — useImperativeHandle stays, only the wrapping goes
JourneyMapGL — same
JourneyMapAuto — same
Each takes `ref?: Ref<Handle>` in its own Props interface now, so the ref is
part of the declared surface instead of arriving through a generic parameter.
The two journey bindings in glLazy shrink to one line each. They existed as
forwardRef wrappers purely to carry the ref past the lazy boundary; with ref an
ordinary prop it rides through the spread like everything else, which makes all
six bindings in that file identical again.
The five React.FC annotations go. Three were `Record<string, React.FC<{size?:
number}>>` for provider icons in files that never import React at all — those
only typechecked through the UMD global — and are now ComponentType. The two
pages become function declarations.
TranslationContext uses the 19 context shorthand, which leaves its React default
import unused, so that goes too.
Also drops two dead vi.mocks of react-dom/server. Their comment still said "used
in createMarkerIcon", which stopped being true when marker icons moved to the
in-house serializer — nothing under src/ imports react-dom/server any more
except the parity test that exists to compare against it.
|
||
|
|
c89d2bf3a1 |
Add a dayTintProvider plugin hook
Lets a plugin colour-code days in the Plan sidebar. trip-segments can already announce "Kanazawa begins" in the day plan, but it can't make day 12 look like it belongs to Kanazawa. dayScheduleProvider can't cover this: it renders a fixed row, and it's capped at 60 items per provider, so one item per day would tint the first 60 days of a six-month trip and silently stop. This hook is bounded by day count instead. GET /api/day-tints/:tripId returns per-day tones for three regions of a day card - the number badge (also the mobile day chip), the header row, and the expanded activity list. `tone` is a shorthand filling every region a contribution doesn't name; unnamed regions render as they do today. Tones only, never hex, so the host can pick the alpha per theme and per region. One contribution per day, resolved whole: first entry wins within a provider, first granted provider wins across plugins. |