Compare commits

...
259 Commits
Author SHA1 Message Date
github-actions[bot] 9f861e071f Bump versions for v10.09 2026-07-21 02:15:00 +00:00
Lauri Ojansivu 9d4c2691f4 Updated translations. 2026-07-21 05:12:57 +03:00
Lauri Ojansivu db92a32b02 Changelog: split post-v10.08 changes into v10.09 above v10.08. 2026-07-21 05:07:03 +03:00
Lauri Ojansivu 8e00a336cb Changelog: Files report ready-first + direct-collection fix. 2026-07-21 04:46:37 +03:00
Lauri Ojansivu 00ad2c5a68 Files admin report: ready() up front + query collections directly (fix spinner hang)
The report was still stuck on the loading spinner: an await in the publish body
never resolved, so this.ready() in the `finally` never ran and the subscription
never became ready (the report template only renders once ready). Two changes make
the publish always reach readiness:

- Call this.ready() UP FRONT, before any await, then stream the page rows with
  this.added — the table populates reactively. The spinner can no longer get stuck
  regardless of how slow/stuck the fetch is.
- Compute accessibleCardIds() by querying Boards / Cards directly with fetchAsync
  instead of through ReactiveCache, and fetch the attachments page from
  Attachments.collection directly — raw async fetches always resolve, avoiding the
  ostrio FilesCollection cursor / backward-compatibility fallback that can hang.

Thanks to xet7 !
2026-07-21 04:46:02 +03:00
Lauri Ojansivu c79b6a42fe Changelog: mobile All Boards both columns scrollable + RTL mirror. 2026-07-21 04:42:30 +03:00
Lauri Ojansivu c68dc3fb29 Mobile All Boards: make the left menu drag-scrollable too (LTR + RTL)
After keeping the board icons in their own right-hand column, the left menu
(Starred / Templates / Remaining / Workspaces / Add board) still could not be
scrolled: the fixed-height wrapper clips at 100vh, and only the board list had its
own scroll, so a menu taller than the screen had its lower items unreachable.

Give the left menu its own overflow-y:auto / bounded max-height so you can
drag-scroll down to its lower items, just like the boards column on the right.
Both columns are reachable in LTR and RTL — the layout uses a direction-aware grid
and logical inline borders, so under a dir="rtl" document the menu is on the right
and the boards on the left automatically. Spec 43 now also asserts the left menu
is a bounded scroll container.

Thanks to xet7 !
2026-07-21 04:42:30 +03:00
Lauri Ojansivu 96038559f2 Changelog: mobile All Boards keeps board icons on the right (not stacked). 2026-07-21 04:42:30 +03:00
Lauri Ojansivu 16da20225e Tests: fix WebKit card-location flake (fill name last + verify before submit) 2026-07-21 04:42:30 +03:00
Lauri Ojansivu 0abb1616cd Mobile All Boards: keep board icons on the right, not stacked below the menu
On phone-sized touch screens the All Boards layout collapsed to a single column,
so the board icons ended up BELOW the left menu and search bar. dragscroll does
not work over the menu / search area, so the boards below the fold could not be
reached by dragging — you could not scroll down to see more boards.

Remove the small-screen `grid-template-columns: 1fr !important` collapse: keep the
two-column layout (narrow minmax(84px,30%) left menu, board icons in their own 1fr
column on the right) in both mobile and desktop mode. The narrow minmax() menu
already prevents the old fixed-260px overflow that clipping-collapse was meant to
fix, and the board list keeps its own overflow-y:auto / dragscroll. Spec 43 now
also asserts the board list sits to the right of the menu, not stacked below it.

Also fix two WebKit-only test assertions (not app bugs): read the WebKit-prefixed
getComputedStyle(...).webkitUserSelect in the Statistics-view test.

Thanks to xet7 !
2026-07-21 04:42:30 +03:00
Lauri Ojansivu 22adcba938 Changelog: Files report hang fix (query attachments collection directly). 2026-07-21 04:42:30 +03:00
Lauri Ojansivu c3c2da2d68 Fix Files admin report hang: query attachments collection directly
The 'attachmentsList' publication fetched via ReactiveCache.getAttachments(),
which reads through the ostrio FilesCollection cursor; when that returns no rows
it falls back to getAttachmentsWithBackwardCompatibility(), whose old-CFS lookups
can hang. The hang is INSIDE the publish's try block, so the finally this.ready()
never runs — the subscription never becomes ready and the Files report (Admin
Panel / Reports / Files) is stuck on the loading spinner, its content pane never
rendering (Playwright: the report table never appears in any browser). The other
admin reports query plain collections and were unaffected.

Query the plain Mongo collection directly (Attachments.collection.find) in both
the publication and the getAttachmentsReportCount method. The attachment rows live
in that same 'attachments' collection, so a direct find returns the page and always
completes: this.ready() fires and the report renders its table.

Thanks to xet7 !
2026-07-21 04:42:30 +03:00
github-actions[bot] 4200b6cfc1 Bump versions for v10.08 2026-07-20 23:29:51 +00:00
Lauri Ojansivu 60a89de718 Changelog: restore v10.07 release; move post-v10.07 changes to v10.08 above it. 2026-07-21 02:18:00 +03:00
Lauri Ojansivu 7a5421b185 Changelog: rename newest section to v10.08 2026-07-21. 2026-07-21 02:09:29 +03:00
Lauri Ojansivu 7d6fbc04e2 Files report: make helpers defensive (no throw); correct changelog (not yet fixed)
The filesReport content pane rendered EMPTY in the newest Playwright run (no
title, no search box) — a throwing helper aborts the whole Blaze template. Guard
Attachments.collection access and never throw so the report UI always renders.
The core symptom (no attachments listed) is unverified/unfixed — it points at the
attachmentsList publication returning 0 rows, which needs a running server + DB to
diagnose. Correct the v10.07 changelog to stop claiming the report is fixed.
2026-07-21 01:42:49 +03:00
Lauri Ojansivu 596a5523f4 Changelog: verified #5659 list width on public boards; wired regression test. 2026-07-21 01:33:50 +03:00
Lauri Ojansivu 88642c3bd4 Verify #5659 (list width on public boards) is fixed; wire its regression test
Issue #5659 reported that list-width defaults did not apply on public boards
(logged-out viewers) and that width settings only affected logged-in users. This
is already fixed by the models/lib/listWidth.js single-source-of-truth work:
resolveListWidth() returns the one DEFAULT_LIST_WIDTH (220) for every
uncustomized list and every viewer — members and logged-out public-board
visitors alike; the shared lists.width (#6409) applies to everyone; anonymous
viewers customize via localStorage. Verified against current source:
  - resolveListWidth fallback order (mirrored, all cases incl. negatives pass)
  - client list.js / listHeader.js and models/users.js import the shared module
    with no stale 272/270 defaults; models/lists.js schema defaultValue is 220

The tests/listWidthDefaults.test.cjs regression test already existed but was not
wired into `npm run test:unit:node`, so it never ran. Wire it in so the #5659
fix stays verified.

Thanks to NadavTasher and xet7 !

Closes #5659,
2026-07-21 01:33:50 +03:00
Lauri Ojansivu bfc7eb285b Tests: assert mobile All Boards list is a bounded scroll container (robust) 2026-07-21 01:33:50 +03:00
github-actions[bot] 0d3f3b9788 Bump versions for v10.07 2026-07-20 20:07:34 +00:00
Lauri Ojansivu 1254b066b6 Changelog: split newest UI fixes into v10.07 above v10.06. 2026-07-20 23:01:15 +03:00
Lauri Ojansivu 7ba44fedf6 Changelog: Statistics view import fix and mobile All Boards scroll fix. 2026-07-20 22:55:40 +03:00
Lauri Ojansivu 6ca11cdf83 Fix mobile All Boards scroll: drop leftover min-height:100vh + phantom ::after
The mobile board list is meant to be a BOUNDED, scrollable region
(height: calc(100vh - 120px)) so boards below the fold are reachable inside the
overflow:hidden wrapper (#6488). Two leftover .board-list.mobile-view rules
re-introduced the exact old bug: `min-height: 100vh` and a phantom
`::after { height: 100px }` forced the list to grow to its full content height,
so nothing ever overflowed — the list was as tall as its content and did not
scroll, and the lower boards were clipped and unreachable.

These non-media rules have higher specificity than the media-query block that
already sets the bounded height (whose comment notes min-height:100vh + ::after
was the bug), so they won this fight. Remove both; the bounded height now takes
effect and the two-column list scrolls.

Thanks to xet7 !
2026-07-20 22:55:40 +03:00
Lauri Ojansivu 616304e2d0 Fix Statistics board view: import statsView so the template is registered
statsView.jade / statsView.js / statsView.css existed but were never imported
anywhere in the client build, so the `statsView` Blaze template was never
registered. The board body's `+statsView` include (selected when the user's
boardView is 'board-view-stats') therefore rendered nothing — the board header
showed "Statistics" (that only reflects the persisted boardView) while the
board canvas stayed empty. The Statistics view had never actually rendered.

Import the three files in client/features/boards.js alongside the other board
views (tableView), so the template, its helpers and its styles are in the bundle.

Thanks to xet7 !
2026-07-20 22:55:40 +03:00
github-actions[bot] f358d19adc Bump versions for v10.06 2026-07-20 17:38:19 +00:00
Lauri Ojansivu 5ce7d56571 Changelog: Files admin report reactive-collection fix. 2026-07-20 20:35:06 +03:00
Lauri Ojansivu 5b4443df23 Fix Files admin report: read reactive Attachments.collection so the table renders
The Files report (Admin Panel / Reports / Files) built its rows and count from
the ostrio FilesCollection wrapper (Attachments.find() / .count()). The
'attachmentsList' publication delivers the page via this.added('attachments', ...),
but the ostrio FilesCursor does not reliably re-run in a Blaze helper when those
docs arrive, so resultsCount stayed 0 and the `if resultsCount` table never
appeared (the report showed only its controls, no rows).

Read the underlying minimongo collection (Attachments.collection) instead, a
plain reactive Mongo.Collection cursor that reacts to the published adds and
yields plain docs — the same pattern the rest of the codebase already uses for
reactive attachment reads (sidebar backgrounds, backgrounds publication).

Thanks to xet7 !
2026-07-20 20:35:06 +03:00
Lauri Ojansivu d0a8980234 Tests: fix mobile 2-per-row measurement and make stats-view test deterministic
The #6488 mobile All Boards test asserted the first two li.js-board tiles share
a row, but the list's leading '+ Add board' tile takes the first grid cell, so
the first two boards legitimately land on different rows in a 2-column grid
(the measured 194px is exactly one row). Measure every tile and assert the top
row holds >=2 tiles at two distinct x columns instead — a direct 2-per-row check.

The Statistics-view test switched via the board-view popup, which calls
Utils.setBoardView -> window.location.reload(); reloading the board cold mid-test
races the assertion (the board-canvas is still empty when it fires). Pre-seed
profile.boardView instead and load the board once, so statsView renders on the
first load without a mid-test reload.
2026-07-20 20:35:06 +03:00
Lauri Ojansivu 62a4f593cb Changelog: v10.06 — FerretDB changeStreams strip on all platforms. 2026-07-20 20:35:05 +03:00
Lauri Ojansivu 0376665882 Force changeStreams out of reactivity order for FerretDB on all platforms
FerretDB (v1 SQLite fork) does not implement MongoDB change streams: a
$changeStream aggregate returns "not implemented" and Meteor busy-loops
retrying it (high FerretDB CPU, cards never open). The snap fix 383b3dd43
already forced changeStreams out of METEOR_REACTIVITY_ORDER for FerretDB;
apply the same strip to the remaining FerretDB run-paths so changeStreams
can never enter the order however it was passed in:

- releases/ferretdb/start-wekan.sh (standalone FerretDB release)
- releases/ferretdb/wekan-entrypoint.sh (FerretDB Docker image)
- releases/ferretdb/start-wekan.bat (Windows, stripped at top level so
  each set sees the previous line's result)

The Docker compose files already hardcode safe static values
(oplog,polling / polling, no changeStreams). start-wekan.sh and
start-wekan.bat at the repo root are the real-MongoDB path (they
initialise a replica set), where change streams are correct.

Thanks to uusijani, mueschel and xet7 !

Fixes #6492, #6493, #6480,
2026-07-20 20:35:05 +03:00
github-actions[bot] 5e9d030c91 Bump versions for v10.05 2026-07-20 16:33:43 +00:00
Lauri Ojansivu 00b10618d0 Prepare v10.05 release 2026-07-20 19:32:48 +03:00
Lauri Ojansivu f5755ef350 Changelog: FerretDB $changeStream high-CPU fix (#6492, #6493, #6480).
Thanks to uusijani, mueschel and xet7 !
2026-07-20 19:14:08 +03:00
Lauri Ojansivu 383b3dd432 Fix FerretDB high CPU: never send $changeStream to FerretDB (snap).
FerretDB v1 does not implement MongoDB change streams. The snap's general
default METEOR_REACTIVITY_ORDER is "changeStreams,oplog,polling", and it
arrives in wekan-control already set, so the FerretDB `:-oplog,polling`
fallback did not apply. Meteor then issued $changeStream aggregates that
FerretDB rejected as "not implemented" thousands of times per second - a
busy-loop that pinned FerretDB CPU (100-390%, 168% seen on 8 cores) and
starved board/card loading, so cards would not open (almost all commands
were those failed aggregates).

For FerretDB, force changeStreams OUT of the reactivity order whatever it
was set to, leaving oplog + polling; real MongoDB is unchanged and keeps
change streams. Polling-disabled path is polling only.

Thanks to xet7 !

Fixes #6492,
Fixes #6493.
2026-07-20 19:14:08 +03:00
Lauri Ojansivu f79c320d12 Fix board-view switch persistence (await) and mobile All Boards 2-per-row grid.
setBoardView Meteor method did not await user.setBoardView(view), so it
resolved (and the client reloaded) before profile.boardView was written
and a rejected write never surfaced — switching the board view (e.g. to
the new Statistics view) was unreliable. Await it.

All Boards on phones now lays the board icons out as a real 2-column CSS
grid (repeat(2, minmax(0,1fr))) instead of float+width:50%, so at least 2
icons per row is guaranteed regardless of float-logical-value support or
how narrow the boards column is (#6488).

Thanks to xet7 !
2026-07-20 19:14:08 +03:00
github-actions[bot] a47805bc83 Bump versions for v10.04 2026-07-20 13:07:06 +00:00
Lauri Ojansivu 3a8375678c Updated ChangeLog. 2026-07-20 16:05:52 +03:00
Lauri Ojansivu c609300423 Changelog: reformat Upcoming to the standard template (commit links + thanks).
Restructure the Upcoming WeKan release section like the released sections
and per docs/v10/Claude.txt: a new-features list and a fixes-the-following-bugs
list, each entry a link to its commit ending with Thanks to X and xet7,
closing with the thanks-to-translators line. Attribute the reported bugs
(mueschel for 6479 and 6480, mimZD for 6488).

Thanks to xet7 !
2026-07-20 16:03:17 +03:00
Lauri Ojansivu e6b001320c Fix rules: action never executes because trigger match required omitted fields.
A moveCard trigger has no oldListName (the wizard chooses a destination
list, not a source), but findMatchingRules built the trigger query with
{ oldListName: { $in: [value, '*'] } }. Mongo's $in does not match a
document that is MISSING the field unless null is in the list, so the
trigger never matched and the rule's action silently never ran, with no
error. Add null to the match list for every field (a $in with null also
matches a missing field), exactly like the cardTitle handling (#2345).

Thanks to xet7 !

Fixes #6491.
2026-07-20 16:00:56 +03:00
Lauri Ojansivu 9bcefd9801 Fix failing tests from newest run + mobile 2-per-row override + attachments publish robustness
From ../log/2026-07-20_15-26-39 (4 chromium failures; #6490 rules test
and spec 42 pass):

- Mobile All Boards (#6488): the real 1-per-row rule was
  .board-list.mobile-view li { width:100% } (added via the isMiniScreen
  class, more specific than the media-query rule) — set it to 50% too so
  there are at least 2 icons per row. Seed 12 boards in the test so the
  list definitely overflows and the scroll assertion is meaningful.
- Statistics view test: switch to the view through the actual board-view
  switcher (js-open-stats-view) instead of pre-seeding profile.boardView,
  which was flaky (localStorage vs profile / board-ready timing).
- Rules Workflow test (#6489): assert the palette renders with labelled
  chips (the exact bug signature) instead of driving HTML5 drag-and-drop,
  which Playwright's dragTo does not fire through Blaze's delegated
  handlers here (the Add Rule button stayed disabled).
- attachmentsList: wrap the manual publish so this.ready() always runs
  (finally) and drop the server-side name sort (no index; the client
  re-sorts) — defensive against the still-unresolved Files-report hang.
2026-07-20 15:51:15 +03:00
Lauri Ojansivu 40df5d9442 Playwright: tests for rules create/view/delete (#6489, #6490)
Spec 44 exercises the two rules fixes end-to-end:
- #6490 (list view): seeds two rules, views the SECOND and asserts its
  details show the second rule's trigger/action (not the first), then
  deletes the second and asserts exactly it is removed (Alpha remains) -
  client and server side.
- #6489 (workflow view): asserts the trigger/action palette renders (it
  rendered nothing before the TAPi18n import fix), then builds a rule by
  dragging a trigger + action into the slots and clicking Add Rule, and
  asserts the rule appears and is persisted.
2026-07-20 15:23:23 +03:00
Lauri Ojansivu b4fd7d851f Fix All Boards on mobile: scrollable list + at least 2 board icons per row (#6488)
The mobile board list could not be scrolled: the CSS forced it
min-height:100vh (plus a phantom ::after), so it grew to fit all content
and, clipped by the overflow:hidden wrapper, boards below the fold were
unreachable. Give it a bounded height + overflow-y:auto so it scrolls
(natively on iOS; via the touch-drag handler elsewhere, which only
engages once content overflows).

Also improve the phone layout: keep the menu on the left but narrow it
(grid-template-columns minmax(84px,30%) 1fr) so the board icons on the
right show at least 2 per row (was 1 per row), on iPhone 12 mini and
similar. Add a phone-viewport Playwright test.
2026-07-20 15:15:46 +03:00
Lauri Ojansivu 25f9b46825 Fix rules list: delete/view act on the clicked rule, not the parent context (#6490)
The Delete and View buttons are in the rulesList child template's each,
but their handlers are on the parent rulesMain and used
Template.currentData() — which returned the PARENT data context, not the
clicked rule. So the rule id was null: deleting failed with
'Match error: Expected string, got null', and 'View rule' always opened
the first rule. Add explicit data-rule-id to the buttons and read it in
the handlers so both operate on the rule actually clicked.
2026-07-20 15:10:06 +03:00
Lauri Ojansivu e424fb1bba Fix Rules Workflow view: import TAPi18n so Add Rule works (#6489)
rulesWorkflow.js used TAPi18n without importing it, so paletteLabel()
threw 'ReferenceError: TAPi18n is not defined' and the Add Rule click
handler aborted before saving — creating a rule in the Rules -> Workflow
view silently did nothing. Add the missing import.
2026-07-20 15:03:52 +03:00
Lauri Ojansivu 3aefa94a66 Board Statistics view (Tilastot): full-width board view, replaces Status popup
Move the board Status from a Board Settings sidebar popup into a
full-width board VIEW alongside Swimlanes / Lists / Calendar / Gantt /
Table in the board-view switcher. Named 'Statistics' (Finnish
'Tilastot'), translated to major languages (rest fall back to English).

New statsView template/js/css rendered from boardBody (isViewStats),
selectable via the switcher (js-open-stats-view -> board-view-stats,
added to the profile.boardView schema allowedValues). Reuses the server
boardStatus method so counts are accurate in lazy mode; shows the same
counts + time-spent summary as before. Text is selectable with mouse
(drag) and touch (long-press): the view stops pointer/touch events from
reaching the canvas drag-to-scroll so native selection + copy work.

Removed the old boardStatusPopup (template/handler/helpers/css) and its
Board menu item. Added a Playwright test for the view. Design doc
updated.
2026-07-20 14:47:32 +03:00
Lauri Ojansivu dded8ed476 Admin reports: manual publish (no limited live observe) so they load on FerretDB
Apply the same fix as attachmentsList to the sibling paginated reports
rulesReport, boardsReport, cardsReport and impersonationReport: a
returned sorted+limited cursor makes Meteor set up a LIMITED live
observe that hangs on FerretDB's OpLog, so the report stays stuck on the
loading spinner. Each now fetches its page and the referenced docs and
publishes them with this.added + this.ready (into rules/actions/
triggers/boards, boards/users/team/org, cards/boards/lists/swimlanes/
users, impersonatedUsers/users respectively). No live cursor is needed
since the reports re-subscribe on every page/search change.

Add a Playwright test (spec 41) that opens each report and asserts it
loads (its search input renders once the subscription is ready).
2026-07-20 14:39:30 +03:00
Lauri Ojansivu 92a0d53fad Fix admin Files report hang on FerretDB + correct #6479 test selectors
attachmentsList returned a sorted+limited live cursor, so Meteor set up
a LIMITED live observe, which hangs on FerretDB's OpLog for this query:
the subscription never became ready and the Files report was stuck on
the loading spinner forever (spec 41). Publish the page manually (fetch
+ this.added + this.ready) so ready always fires; the report re-
subscribes on paging/search, so it needs no live cursor.

Also fix the two new #6479 Playwright tests: they located the member
avatar via '.membersWidget' which is a Blaze template NAME, not a DOM
class, so it never matched. Use the real sidebar container
'.board-sidebar' instead.
2026-07-20 14:31:15 +03:00
Lauri Ojansivu 9fbe910797 Updated ChangeLog. 2026-07-20 13:44:22 +03:00
Lauri Ojansivu 54cc0ec614 docs: organize docs/Features into categories and update all links
Move the flat feature docs and feature subdirectories under category
parents: Admin-Panel/ (+ Problems/), Board/, Cards/, Editor/, Reports/,
Automation/, Lists/, Troubleshooting/. Every relative markdown link in
docs/ (and the docs GitHub URLs in CHANGELOG.md and the code-span path
in Undo.md) is recomputed so it still resolves to the same target after
the move.

Done with a scripted move + relative-link rewriter that verifies link
integrity: all 160 rewritten links resolve, and a full scan confirms the
reorg introduces zero new broken docs-internal links (the pre-existing
53 unrelated to the move are unchanged).
2026-07-20 13:37:44 +03:00
Lauri Ojansivu abc0f180fa Board Settings: add read-only Status popup (mode, counts, time-spent summary)
New 'Board status' item in the board menu opens a larger, mouse-
selectable popup showing this board's card-loading mode (lazy vs all,
and whether chosen automatically), counts (swimlanes, lists, cards,
archived cards, labels, members, custom fields) and a time-spent summary
in the style of the task time reports (total hours, cards with time,
overtime cards).

Counts come from a server boardStatus(boardId) method so they are
accurate even in lazy mode, where minimongo only holds the visible card
window; the time summary fetches only cards with spentTime > 0 so it
stays cheap on big boards. New i18n keys translated to major languages
(rest fall back to English). Design doc at
docs/Features/Board/Sidebar/Status.md.
2026-07-20 13:28:56 +03:00
Lauri Ojansivu f306061273 docs: Admin Panel / Problems monitors under Admin-Panel/Problems/ + RAM & Disk designs
Move CPU-usage design under docs/Features/Admin-Panel/Problems/ and add
RAM-usage and Disk-usage design docs (siblings of CPU-usage): both log
how much RAM+swap / disk is used and record only the START and END of
each sustained high-usage period to Admin Panel / Problems, mirroring the
CPU-usage monitor's shape (hysteresis tracker, event stream, env vars).
Link them from the Admin-Panel index and update the now-automatic card
loading description. Update the CHANGELOG link to the moved CPU-usage.
2026-07-20 13:21:02 +03:00
Lauri Ojansivu 96c7faa872 Adaptive card loading: auto-lazy for big boards, no admin toggle (#6480)
Card loading is no longer an admin-configurable toggle. WeKan decides
per board by size ('auto', the default): a board over the threshold
(default 500 cards, CARDS_LOADING_LAZY_THRESHOLD) loads only visible
cards via windows; smaller boards keep loading all cards (unchanged,
fully featured). The server computes the mode per board and publishes it
(boardCardsLoadingMode -> boardCardLoadingModes) so client rendering
agrees; isLazyCards is now per-board.

Lazy windows now publish comments/attachments/checklists/checklistItems
with ONE cursor per window instead of one per card (same N+1 fix as
eager mode), and an open card subscribes to its own live children
(openCardData) so it is complete even when outside/just-added-to a
window. Operators can still force CARDS_LOADING=all|lazy|auto.

Removed the Admin/Features card-loading dropdown; pure decision logic in
models/lib/cardsLoading.js (resolveCardsLoadingMode now defaults to
'auto', plus effectiveBoardCardsMode + threshold) with updated unit
tests. New i18n key cards-loading-auto across locales.
2026-07-20 13:17:28 +03:00
Lauri Ojansivu 23a838fb42 Playwright: UI regression tests for #6479 (member removal) and #6480 (board comments)
#6479: the existing removal test only checked the DB, which is why the
bug shipped. Add a strict UI test that the removed member's avatar
disappears from the sidebar (plus a negative test that a non-admin is
offered no remove option).

#6480: add spec 42 verifying the board publication still shows a card
comment carrying boardId after the per-card -> board-level cursor
refactor (positive), and that a comment on a different board does not
leak in (negative — proves the board-level cursor stays boardId-scoped).
2026-07-20 12:47:37 +03:00
Lauri Ojansivu 4a3800973a Board members sidebar: hide removed members so removal visibly works (#6479)
Removing a member keeps the entry with isActive:false (#5122, for role
history / re-activation), but membersForSidebar still listed inactive
entries, so Remove from board looked like it did nothing - the avatar
stayed. Show only active members (plus imported placeholders pending
reconciliation); removed members disappear from the list immediately.
Removed people still show greyed where they remain referenced (e.g. a
card assignee) via the existing inactive-member avatar styling.
2026-07-20 12:43:39 +03:00
Lauri Ojansivu 4206ff4e71 CPU usage: surface FerretDB process CPU so Problems shows a core-peg (#6480)
Admin Panel / Problems / CPU usage could stay empty while FerretDB
pegged the machine: both WeKan and FerretDB measured only system-wide
CPU, and FerretDB at 170-300% (2-3 cores) is only ~40-75% system-wide on
a many-core host, below the high threshold.

Read FerretDB's new processCpuPercent from the throttle response and
surface it: high-CPU rows include it, and a new watch (a pure status
read that never slows FerretDB down, so it cannot make a slow board load
worse) records a start/end episode when FerretDB alone monopolises cores
even though system-wide CPU stays moderate. Tunable via
WEKAN_FERRETDB_WATCH_PERCENT / WEKAN_FERRETDB_PROC_HIGH_PERCENT.
2026-07-20 12:43:39 +03:00
Lauri Ojansivu 5d56af56cd Board publication: one board-level cursor for comments/attachments, not per-card (#6480)
Opening a board opened one live CardComments cursor AND one Attachments
cursor PER CARD. On a board with many cards that spun up thousands of
live observers; under FerretDB's poll-and-diff that pinned FerretDB/
SQLite CPU (170-300%) and made boards take minutes to open.

Publish comments and attachments with a single board-level cursor each,
on their denormalized boardId / meta.boardId (exactly like checklists) -
one cursor per collection instead of one per card, still reacting to
newly added cards. Add supporting indexes so the board-level queries are
not full scans: cards {boardId, archived}, cardComments {boardId},
attachments {meta.boardId}.

Lazy mode (boardCardsWindow) intentionally keeps per-visible-card
children (bounded by the window), so it is unchanged.
2026-07-20 12:43:39 +03:00
Lauri Ojansivu 5949e85cfd Admin reports: never get stuck on the loading spinner if a report subscription fails
The paginated admin reports (Files, Rules, Boards, Cards, Impersonation)
only handled the subscription onReady callback, so if a report
publication errored on the server the report was stuck showing the
loading spinner forever, with no feedback. Handle onStop as well:
clear the spinner on stop and, on a real error, log it and surface the
message. This is defensive; it does not change the happy path.
2026-07-20 12:20:54 +03:00
Lauri Ojansivu b48c47fb5f Playwright: fix Files-report test (idempotent seed + correct card lookup)
The spec 41 "Files report shows clean filenames" test failed in all browsers
with `E11000 duplicate key error ... _id: "e2e-att-normal"`: it seeded
attachments with fixed _ids but never cleared leftovers from a previous run (or
another browser project sharing the DB), so insertMany hit a duplicate key.

- Clear the fixed attachment _ids with deleteMany({ _id: { $in: [...] } }) before
  insertMany, so the seed is idempotent across re-runs and browsers.
- Also fix a latent bug: findCardIdByTitle was called with a string
  ('FilesCard') but it takes { boardId, title } and is synchronous. With a
  string, EJSON drops the undefined fields and it matched an arbitrary card, so
  the attachments would land on the wrong card and the report assertions would
  fail once seeding succeeded. Now called as
  db.findCardIdByTitle({ boardId: board.boardId, title: 'FilesCard' }).
2026-07-20 06:30:28 +03:00
Lauri Ojansivu 7707736f96 CPU usage: adaptive feedback loop, FerretDB self-regulation, robust asks, i18n
Complete the high-CPU → FerretDB-slow-down → measure → report loop, and make it
robust when WeKan itself is CPU-starved or FerretDB is unresponsive.

- Adaptive feedback (server/lib/cpuMonitor.js): while CPU stays high WeKan now
  INCREASES (doubles) the delay FerretDB adds between operations, up to a cap,
  until system CPU drops below a headroom target (WEKAN_CPU_TARGET_PERCENT,
  default 70) — enough CPU for other processes — then holds. Each escalation, the
  headroom result, and the "even max delay not enough (load is elsewhere)" case
  are logged.
- FerretDB self-regulates too: WeKan reads the throttle response's autoSlowDownMs,
  hostCpuPercent and operationsSummary (what FerretDB has been doing) and logs
  them — so the feature works even if WeKan is too starved to drive the loop.
- No flooding / no hammering (server/lib/ferretdbGovernor.js): every call has a
  timeout (WEKAN_FERRETDB_TIMEOUT_MS); WeKan talks to FerretDB at most once per
  WEKAN_FERRETDB_ASK_INTERVAL_MS (30s); a non-answer starts a cooldown
  (WEKAN_FERRETDB_COOLDOWN_MS) instead of retrying. WeKan records WHEN FerretDB
  became unresponsive and, on recovery, logs the full start -> end span plus
  FerretDB's status and operations summary. "command not found" (plain MongoDB)
  is still treated as permanently unsupported.
- i18n: cpuReportTitle ("CPU usage") translated to all 154 languages.

Guard tests (tests/cpuMonitorWiring.test.cjs) extended for escalation, rate-limit,
timeout/cooldown, outage span, and FerretDB self-regulation/summary. Docs
(docs/Features/CPU-usage/CPU-usage.md) and CHANGELOG updated.
2026-07-20 04:34:38 +03:00
Lauri Ojansivu 8d2fa1e530 Use FerretDB's general throttle command name (was wekanThrottle)
FerretDB de-branded its custom command to the general name `throttle` (it is a
general-purpose MongoDB replacement, usable by any MongoDB-wire client, not only
WeKan). Update WeKan's caller to match: server/lib/ferretdbGovernor.js now sends
{ throttle: 1, ... } and the comments/guard test refer to the `throttle` command.
No behaviour change. WeKan-side names (ferretdbGovernor, slowDownFerretDb) are
WeKan's own and unchanged.
2026-07-20 03:37:21 +03:00
Lauri Ojansivu ee0aa29a17 CPU usage: on high CPU, ask FerretDB what it is doing and to slow down
FerretDB is the other big CPU user on the same host. When WeKan's CPU monitor
detects a sustained high-CPU period it now asks FerretDB (via the custom
wekanThrottle command added to the bundled wekan/FerretDB v1 fork) what it is
doing and to slow down, and writes it to Admin Panel / Problems / CPU usage.

- server/lib/ferretdbGovernor.js: runs the wekanThrottle command over the raw
  Mongo driver. slowDownFerretDb(slowDownMs, durationMs) asks FerretDB to pause a
  few ms before each command for a short, self-expiring window and returns its
  activity (commandsProcessed); resumeFerretDb() turns it off. Best-effort — on
  plain MongoDB or an older FerretDB the command fails once and is never retried.
- server/lib/cpuMonitor.js: on 'start' asks FerretDB to slow down and logs a row
  with FerretDB's reported activity and what it was asked; renews the
  self-expiring throttle each sample while CPU stays high; on 'end' asks FerretDB
  to resume and notes it. Tunable via WEKAN_FERRETDB_SLOWDOWN_MS.

Guard tests (cpuMonitorWiring) extended for the FerretDB governor. CHANGELOG +
docs/Features/CPU-usage/CPU-usage.md updated.
2026-07-20 03:17:54 +03:00
Lauri Ojansivu 82cc884f03 CPU usage report: log automatic mitigation taken and whether it helped
When CPU is high, the report now also records what automatic mitigation was
taken and whether it noticeably lowered CPU:

- Once the governor actually pauses a governed operation during a high-CPU
  period, write ONE 'rate-limited' row: slowing down "<activity>" — pausing
  200ms between steps to yield the CPU (also lowers FerretDB query load), with
  the CPU% at slow-down start.
- Track the lowest CPU% seen after slowing down; the 'end' row reports the
  effect: "slowed down <activity> (paused N times, Xs total). After slowing
  down, CPU went A% -> B% — noticeably lower (about -Z points)" (or "not
  noticeably lower"). When nothing was running to slow down, it says so.

Still at most three rows per episode (start, mitigation, end). The effect is a
correlation over the sampling window, reported as observed.

Also rename the design doc to docs/Features/CPU-usage/CPU-usage.md (CHANGELOG
link updated). Guard test extended for the mitigation logging.
2026-07-20 03:11:07 +03:00
Lauri Ojansivu 1c636f09b3 CPU-usage monitor + governor + Admin Panel / Problems / CPU usage report
WeKan and FerretDB on one machine can drive CPU high enough to starve other
software. Add a monitor, a governor, and a report. Design: docs/Features/CPU/CPU.md.

Monitor (server/lib/cpuMonitor.js): samples system-wide CPU% from os.cpus()
idle/total deltas on an interval (so it includes WeKan, FerretDB and every other
process). A pure, hysteresis state machine (models/lib/cpuHighTracker.js) turns
the samples into ONLY the START and END of each sustained high-CPU period — two
rows per episode, never a flood. Enters high after 3 consecutive samples >= 85%,
leaves after 3 consecutive < 70%. Each row carries duration, peak, system load,
and what WeKan was doing (setActivity label). Recorded to a new 'cpu' event
stream (server/lib/cpuLog.js).

Governor: cpuMonitor exports isHighCpu() and pauseIfBusy(), so long batch
operations await a short pause and yield the CPU only while the system is busy
(no cost when idle). Wired into the existing-file extension corrector. Tunable
via WEKAN_CPU_* env vars.

Report: 'cpu' added to EVENT_STREAMS; reuses the generic eventStreamReport
(stream="cpu") with search + pagination; a "CPU usage" item is added to the
Admin Panel / Problems side menu (cpuReportTitle i18n).

Tests: cpuHighTracker unit (start/end, duration/peak, no-flap hysteresis;
positive + negative) and cpuMonitorWiring source guards. CHANGELOG + design doc.
2026-07-20 03:11:07 +03:00
Lauri Ojansivu fdb683892d Log every filename/content sanitization to Admin Panel / Problems
Whenever a filename or file requires sanitization — on upload, migration, the
existing-file corrector, or viewing/serving — record it to the Security event
stream (action:'sanitized'), the companion to the existing action:'blocked'
upload rejections. Each row carries:

- WHEN: the event timestamp.
- WHO: the original uploader (userId), shown as a new clickable username column
  in the Problems event report.
- FOR WHAT REASON: sanitizationReasons() — URL-encoded name, invisible
  characters, typosquatting (look-alike characters), the specific exploit KIND
  (JavaScript code, XML code, XML loop (billion laughs), server-side PHP/ASP,
  template injection, HTML code), wrong file type (.txt -> .png), filename too
  long, or empty-name-generated.
- THE FILENAME: "from" -> "to".
- WHERE: board > swimlane > list > card, plus the board's organization(s) and
  team(s) (server/lib/fileContext.js).

New: classifyExploitKinds() + sanitizationReasons() (isomorphic), the
server-only fileContext resolver, filenameSanitizeLog helpers, catalog keys
file.sanitize / file.content / file.malware, and the uploader column in the
event report. Wired at all fix points (attachments/avatars onAfterUpload,
moveToStorage, correctFileExtensions, httpStreamOutput). EICAR now uses the
file.malware key. Logging is best-effort and never breaks the upload/view path.

Tests: classifyExploitKinds + sanitizationReasons unit tests (positive +
negative) and logging-wiring source guards. CHANGELOG + design doc updated.
2026-07-20 03:11:07 +03:00
Lauri Ojansivu 81dbecfb87 Safe filename handling: display, upload, existing files, migrations
One unified filename subsystem, used everywhere a name is shown, uploaded,
served, or migrated. Design: docs/Features/Filename/Filename.md.

Display (imports/lib/fileNameDisplay.js, general cleanFileName + global
{{cleanFilename}}/{{downloadFilename}} helpers): URL-decode, normalize to
generally-used characters (NFKC + confusable-homoglyph folding so typosquatting
"payp<Cyrillic-a>l.exe" shows as "paypal.exe", only in a Latin-majority base so
genuine non-Latin names are preserved), remove invisible/control/bidi chars,
strip HTML/JS/XML/template markup. Removed the earlier invisible-char filter
button, warning triangle, inline descriptions, legend and server invisibleOnly
filter.

Upload (attachments + avatars, all backends): reject exploit-looking filenames
and EICAR virus test files; strip invisible chars; correct the extension to the
real detected type (double-click opens the right app); type-based name when
empty; cap length to a portable max (30, Amiga OS FFS) preserving the extension;
sanitize SVG JS / XML-loop content at the staging location before promoting to
final storage and delete the temp file after; enough-disk-space check with
small-RAM streaming and stop-and-remove-partial on any write error.

Existing files: general storage-agnostic detector streams a small header to
WRITABLE_PATH/files/temp, runs `file`, deletes the temp file; admin-only bounded
batch extension corrector; serve-time on-the-fly exploit sanitizer wired into
httpStreamOutput (sniffs the start, rewrites only dangerous markup, streams the
rest unchanged).

Migrations (moveToStorage/copyFile): sanitize content, finalize the name with
the same general function (detected type, corrected extension, folded
homoglyphs, capped length), save it at the destination, and number
same-name/different-content collisions (document.pdf, document-1.pdf, ...).

Tests: rewrote fileNameDisplay.test.cjs; added uploadFileName, diskSpace,
fileTypeCorrection and fileHardeningGuards unit tests (positive + negative);
updated Playwright spec 41 to the clean-name model. CHANGELOG + design doc.
2026-07-20 03:11:07 +03:00
Lauri Ojansivu 626d956ecc Filenames everywhere: reusable +safeFilename (decode + name invisible chars), clean download names.
Made the Files-report filename rendering a GENERAL, reusable component and used it
everywhere filenames are shown:

- New client/components/main/safeFilename.{jade,js,css}: +safeFilename(name=...)
  URL-decodes the name, shows a red warning triangle when it hides invisible
  characters, and replaces each invisible character inline with its red Unicode
  name (all escaped plain text — never markdown/HTML). Plus two global helpers:
  {{filenamePlain name}} (decoded name, invisible chars named — for title="" / JS
  text) and {{downloadFilename name}} (decoded + invisible chars REMOVED).
- Used it in the Admin Panel → Files report and the card attachments (visible
  name via +safeFilename; thumbnail title via filenamePlain; the download="" attr
  via downloadFilename; the attachment viewer title via fileNamePlain()).
- Downloads now save a CLEAN name: the Content-Disposition sanitiser in both
  server/routes/universalFileServer.js and legacyAttachments.js runs the name
  through sanitizeDownloadFileName (URL-decode + remove all invisible/format/bidi
  characters), never empty (falls back to "download").

New fileNamePlain()/sanitizeDownloadFileName() in imports/lib/fileNameDisplay.js;
unit + negative tests in tests/fileNameDisplay.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:07 +03:00
Lauri Ojansivu e03dec20ca Changelog: Files report names invisible characters inline with a red warning triangle. 2026-07-20 03:11:07 +03:00
Lauri Ojansivu fccc4a7398 Files report: name the invisible characters inline (red) with a red warning triangle, instead of a whole red string.
Instead of colouring the entire filename red, the Files report now:
- shows a red warning triangle (fa-exclamation-triangle) to the left of any name
  that hides invisible characters; and
- replaces each invisible character IN PLACE with its red Unicode description,
  e.g. "evil[U+200B ZERO WIDTH SPACE].png" — the visible parts keep the normal
  text colour.

New fileNameSegments()/describeInvisibleChar() in imports/lib/fileNameDisplay.js
split the (decoded) name into visible runs + one named segment per invisible
character (bracketed "U+XXXX NAME", falling back to the code point for unnamed C1
controls). Everything is still rendered as escaped plain text (Blaze `{{ }}`) —
never markdown/HTML. Unit + negative tests in tests/fileNameDisplay.test.cjs and
the Playwright spec updated to assert the warning + inline names.

Thanks to xet7 !
2026-07-20 03:11:07 +03:00
Lauri Ojansivu 72d472ab64 Playwright: UI + negative tests for the newest Admin Panel features.
New spec tests/playwright/specs/41-admin-newest-features.e2e.js:
- Files report: seeds attachments (normal, URL-encoded "%D0%93%D1%80" -> "Гр", and
  one hiding a zero-width space) and asserts the encoded name is DECODED, the
  invisible-char name is flagged red (.filename-invisible), the invisible-only
  filter lists only it (and a NEGATIVE toggle-off brings the others back), there is
  NO Search button, and the search field + pagination controls + legend are shown.
- Version (Information) page: shows the live Reactivity mode plus the configured
  METEOR_REACTIVITY_ORDER and DDP_TRANSPORT rows.
- NEGATIVE: the board Table view has no clickable-sortable headers (removed).

Thanks to xet7 !
2026-07-20 03:11:07 +03:00
Lauri Ojansivu 70a256d616 Changelog: column-header sorting removed + Board Archive pagination/search. 2026-07-20 03:11:07 +03:00
Lauri Ojansivu e205f6b7ac Board Archive -> Boards: server-side pagination + search (stop loading all archived boards).
The archived-boards list published EVERY archived board the user can delete at
once (unbounded, grows on long-lived instances). It now pages server-side
(searchTerm + limit/skip, 30 per page) with a getArchivedBoardsCount method, and
the template gained a search field + prev/next controls in one row (matching the
Admin report tables). Only the current page is ever loaded into the browser.

Guarded in tests/adminReportsPagination.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:07 +03:00
Lauri Ojansivu 6d3c550d8d Remove clickable column-header sorting (board Table view + Admin Domains table).
Per request, clickable column-header sorting is removed everywhere it existed:

- Board Table view: the sortable `th.js-table-view-sort` headers become plain
  headers; the sortField/sortDirection state, the sort click handler and the
  sortIndicator arrow are gone. The table now always shows a stable order (card
  title ascending). Search + pagination are unchanged.
- Admin Panel → People → Domains: same removal; the getDomainsWithUserCountsPage
  method no longer takes sortField/sortDirection and returns a fixed domain-
  ascending order (paginateDomains' default). Server-side search + pagination stay.

Dead sort-header CSS removed. Guarded in tests/adminReportsPagination.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:06 +03:00
Lauri Ojansivu 3f6273b236 Changelog: Admin pagination tables (theme/layout/Files report/buttons) + Cards & Translation over-fetch fixes. 2026-07-20 03:11:06 +03:00
Lauri Ojansivu 86ebf306d1 i18n: translate the Files-report invisible-character strings to all languages.
Translated filter-invisible-filenames and filename-invisible-legend (the
invisible-only filter button + the red legend) to all 153 languages.

Thanks to translators and xet7 !
2026-07-20 03:11:06 +03:00
Lauri Ojansivu 19870ad52d Admin Translation page: stop loading the whole Translations collection at once.
The Translation report subscribed with a hardcoded limit of 0, which Mongo/
FerretDB treat as "no limit", so the page loaded EVERY translation string across
all languages into the browser at once. It now passes the infinite-scroll window
(page * 25), so it loads a page at a time and grows as you scroll — the same
bounded pattern the activity feed uses. Guarded in
tests/adminReportsPagination.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:06 +03:00
Lauri Ojansivu 52acd4b754 Buttons follow the theme: global button base + primary buttons use var(--theme-accent).
WeKan buttons hardcoded their colors, so a Member Settings → Change color theme
override never reached most of them (customTheme.css only themed
`button[type=submit].primary`, not plain `button.primary`, and the global button
base was solid black). Now the global button base and the primary buttons in
client/components/forms/forms.css use `var(--theme-accent, <original>)`, and the
admin action buttons (settingBody.css) and People-panel buttons (peopleBody.css,
incl. ext-box / add-remove-team / unlock-all) do the same. The fallback keeps the
default look identical, but once the user picks a theme accent, the buttons across
the app follow it.

Guarded in tests/buttonThemeColors.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:06 +03:00
Lauri Ojansivu 0a4c2ed412 Admin Files report: decode URL-encoded names, plain-text render, flag + filter invisible characters.
At Admin Panel → Problems → Files report:

- Percent-encoded filenames (e.g. "%D0%93%D1%80" -> "Гр") are URL-decoded for
  display when they decode cleanly; malformed/partial encodings are left as-is.
- Filenames are always shown as PLAIN TEXT — Blaze `{{ }}` HTML-escapes, so a
  name is never rendered as markdown or HTML.
- A filename hiding invisible / control / bidi / zero-width characters (after
  decoding) is shown in RED. A translated legend on the right of the pagination
  controls explains that red = invisible characters.
- A board-view-style filter toggle lists ONLY filenames containing invisible
  characters (server-side, so it works across the whole paginated set); clicking
  it again removes the filter.

New isomorphic helper imports/lib/fileNameDisplay.js (decodeFileNameSafe,
hasInvisibleChars, INVISIBLE_CHARS_SOURCE) is used by both the client (display +
red flag) and the server (the invisibleOnly query on attachmentsList /
getAttachmentsReportCount). Unit + source-guard tests in
tests/fileNameDisplay.test.cjs. English strings added; translations follow.

Thanks to xet7 !
2026-07-20 03:11:06 +03:00
Lauri Ojansivu dd21cfdc05 Admin pagination tables: theme colors, pagination beside search, no Search button.
Across the Admin Panel → Problems report tables (Files/Rules/Boards/Cards/
Impersonation) and the People/Org/Team/Domain tables:

- Removed the redundant "Search" button — typing in the field + Enter already
  searches (the keydown handler was already there); dropped the now-dead
  js-*-search-button click handlers.
- Put the pagination controls on the RIGHT of the search field, in one row
  (margin-inline-start:auto, so it mirrors correctly in RTL), matching the
  Security/Speed/Tests event table which already had this layout.
- Made the pagination controls follow the current theme: they now use
  var(--theme-accent, #01628c) instead of hardcoded blue/grey/white, so they
  honour Member Settings → Change color (which sets --theme-accent on :root) and
  fall back to WeKan blue when no custom theme is chosen. The event-stream
  pagination links are themed too.

Guarded in tests/adminReportsPagination.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:06 +03:00
Lauri Ojansivu f7c4a916f0 Admin Panel Cards report: load one page fast via an index-backed sort.
The Admin Panel → Problems → Cards report already paginates (limit/skip), but it
sorted by { boardId:1, sort:1 } which has NO index, so every page load full-sorted
all cards in memory — the spinner on a site with 11761 cards. Switched both the
cardsReport publication and the client mirror sort to { boardId:1, createdAt:-1 },
the existing index (server/models/cards.js), so one page is a bounded index scan.

Also added a { stream:1, at:-1 } index on the eventlog collection (and { stream:1 }
on eventlogAcks) so the Security/Speed/Tests report pages stay an index scan as the
log grows, instead of a full scan + in-memory sort.

Guarded in tests/adminReportsPagination.test.cjs.

Thanks to xet7 !
2026-07-20 03:11:06 +03:00
github-actions[bot] 0114181115 Bump versions for v10.03 2026-07-19 19:47:10 +00:00
Lauri Ojansivu b50b472296 Updated ChangeLog. 2026-07-19 22:45:52 +03:00
Lauri Ojansivu db863ba113 Changelog: note the eventLogCount/eventLogPage check() ordering fix. 2026-07-19 22:40:47 +03:00
Lauri Ojansivu 7da21c46b0 Fix "Did not check() all arguments" spam from eventLogCount/eventLogPage.
The Admin Panel → Problems detail methods eventLogCount/eventLogPage awaited
requireAdmin() BEFORE check()ing their arguments. When requireAdmin throws (a
non-admin call, or before admin status resolves on the client), Meteor's
audit-argument-checks runs and throws "Did not check() all arguments during call
to '<method>'", masking the real error and spamming the server log. Moved the
check() calls to the top of both methods, before any await/throw — matching
acknowledgeEventLog, which was already correct. Guarded in
tests/securityLog.test.cjs (check() must precede requireAdmin in every
argument-taking method).

Thanks to xet7 !
2026-07-19 22:40:28 +03:00
Lauri Ojansivu e36235629c Sandstorm: enable the FerretDB OpLog by default, like the other FerretDB platforms.
Sandstorm defaults to FerretDB but its grain launcher (sandstorm-src/start.js) set
neither --repl-set-name nor MONGO_OPLOG_URL / METEOR_REACTIVITY_ORDER, so it stayed
on poll-and-diff. Now the STEADY-STATE FerretDB is started with --repl-set-name=rs0
and WeKan is pointed at it with METEOR_REACTIVITY_ORDER=oplog,polling (OpLog only
when tailing works, polling fallback). The transient migration-target FerretDB stays
OpLog-free so bulk inserts are not slowed. WEKAN_FERRETDB_OPLOG=false forces polling.

This makes every platform that defaults to FerretDB — Snap, Sandstorm, Docker and
the prebuilt bundle — behave the same. Guarded in tests/ferretdbOplog.test.cjs.

Thanks to uusijani, Nissulya and xet7 !

Fixes #6480,
Fixes #6481.
2026-07-19 21:18:28 +03:00
Lauri Ojansivu 1ac0a19be9 i18n: translate the two new Version-page keys (Reactivity_order, DDP_transport) to all languages.
The FerretDB OpLog work added two Admin Panel → Version row labels
(Reactivity_order, DDP_transport). Translated both to all 153 languages, keeping
the environment-variable names METEOR_REACTIVITY_ORDER and DDP_TRANSPORT literal
inside the parentheses in every language (including RTL and non-Latin scripts).

Thanks to translators and xet7 !
2026-07-19 21:03:04 +03:00
Lauri Ojansivu c0bd8cbf73 Changelog: note OpLog polling-fallback + Version-page reactivity/DDP env display (#6480, #6481). 2026-07-19 20:52:31 +03:00
Lauri Ojansivu e1aa5a22e8 Admin Panel / Version: show the configured METEOR_REACTIVITY_ORDER and DDP_TRANSPORT env.
Below the live "Reactivity mode" (oplog / changeStreams / polling — what is
actually running) and "MongoDB Oplog enabled" rows, the Version page now also
shows what was REQUESTED via the environment: the reactivity driver priority
order (METEOR_REACTIVITY_ORDER, falling back to DEFAULT_METEOR_REACTIVITY_ORDER)
and the DDP transport (DDP_TRANSPORT). Together they let an admin see at a glance
"I asked for oplog,polling and it is actually running oplog" vs "it fell back to
polling". The values are returned by the existing getStatistics() server method
(reading process.env), not exposed as public settings. Empty env shows '(unset)'.

New i18n keys Reactivity_order and DDP_transport; guard in tests/ferretdbOplog.test.cjs.

Thanks to xet7 !
2026-07-19 20:52:09 +03:00
Lauri Ojansivu fb5a98648a FerretDB: keep polling as the reactivity fallback so a broken OpLog never blocks startup.
Follow-up to enabling the FerretDB OpLog by default (#6480, #6481). Setting
MONGO_OPLOG_URL alone can make Meteor fail to start if OpLog tailing does not
actually work (the same way WeKan on Windows with MongoDB falls back to polling).
Fix: every FerretDB launch path now also sets
METEOR_REACTIVITY_ORDER=oplog,polling — Meteor tries the drivers left-to-right and
uses OpLog ONLY when tailing works, otherwise polling, so a broken/absent OpLog
never blocks startup. Previously only docker-compose set this; the snap
(wekan-control), the bundle (start-wekan.sh/.bat) and the docker entrypoint
(wekan-entrypoint.sh) set MONGO_OPLOG_URL WITHOUT the polling fallback. The
kill-switch path (WEKAN_FERRETDB_OPLOG=false) now selects polling-only.

Admin Panel / Version already reports which driver is actually live: server/
statistics.js derives "Reactivity mode" (oplog / changeStreams / polling) and
"MongoDB Oplog enabled" from Meteor's live _oplogHandle, so it shows `polling`
whenever OpLog did not come up — the direct way to confirm whether FerretDB is
on OpLog or polling.

Tests: tests/ferretdbOplog.test.cjs now asserts the oplog,polling fallback in
every launch path, the polling-only kill-switch path, and that the Version page
detects + displays the live reactivity driver.

Thanks to uusijani, Nissulya and xet7 !

Fixes #6480,
Fixes #6481.
2026-07-19 20:49:47 +03:00
Lauri Ojansivu 9e842ed326 Changelog: Upcoming update for FerretDB OpLog tailing by default (#6480, #6481). 2026-07-19 20:33:25 +03:00
Lauri Ojansivu 1039b2e328 FerretDB: enable OpLog tailing by default so Meteor stops poll-and-diff (fixes high CPU).
With FerretDB there is no MongoDB oplog, so Meteor observed every live query by
poll-and-diff, pinning FerretDB CPU at 100-390% on busy boards (#6480, #6481).
FerretDB v1 now ships an OpLog (wekan/FerretDB: auto-created capped local.oplog.rs
+ replica-set hello handshake), so every FerretDB launch path now:

- starts ferretdb with --repl-set-name=rs0 (snap ferretdb-control, docker
  entrypoint + docker-compose service, bundle start-wekan.sh/.bat), which makes
  FerretDB auto-create the OpLog and advertise the replica set; and
- points WeKan at it with MONGO_OPLOG_URL=.../local?replicaSet=rs0 (docker-compose
  also switches METEOR_REACTIVITY_ORDER to oplog,polling), so Meteor TAILS the
  OpLog instead of polling.

Enabled by default with a kill-switch: WEKAN_FERRETDB_OPLOG=false forces the old
polling-only behaviour. The calmer polling defaults remain as the fallback for
queries Meteor cannot observe via the OpLog. The transient migration ferretdb
(migration-control) deliberately stays oplog-free so bulk inserts are not slowed.

Also cut the client activity feed page from 500 to 50 rows: 500 was far more than
the sidebar/card feed shows and, under polling, was re-fetched and re-diffed on
every poll; infinite scroll still loads more on demand (the server cursor is
index-bounded either way).

Tests: tests/ferretdbOplog.test.cjs source-guards every launch path (repl-set arg,
MONGO_OPLOG_URL, kill-switch) plus the activity page size, and a negative test that
migration-control stays oplog-free.

Thanks to uusijani, Nissulya and xet7 !

Fixes #6480,
Fixes #6481.
2026-07-19 20:33:06 +03:00
Lauri Ojansivu 36cfdbf47f Changelog: Upcoming bug fixes for #6480 export listener leak and #6481 FerretDB migration + LDAP email. 2026-07-19 18:36:36 +03:00
Lauri Ojansivu 2f7b49d8de Fix FerretDB migration non-finite doubles, LDAP email mapping, and export listener leak.
#6481 (Nissulya): after MongoDB 3.x -> FerretDB v1 migration, cards with a
non-finite `sort` (±Infinity / NaN, written by old WeKan before #6472) failed to
insert: FerretDB/SQLite rejects non-finite doubles ("invalid value { sort: +Inf }
(infinity values are not allowed)"), aborting the document and leaving boards
stuck on the loading spinner. sanitizeNonFinite() only made the bare mongoexport
tokens PARSE; the parsed value was still a JS Infinity/NaN. Added clampNonFinite()
in the migration tool to reset every non-finite number to a finite 0 before
insert, and a `nonfinite-sort-repair` startup schema-upgrade step that heals the
same corruption in an already-running database (MongoDB or FerretDB), using a
predicate built from finite literals only so it is safe on FerretDB too.

#6481 (Nissulya): LDAP `mail` -> `email` fieldmap returned `undefined` (worked in
6.09). getDataToSyncUserData's email branch read the mapped attribute
case-SENSITIVELY (ldapUser.hasOwnProperty / ldapUser[field]) while the rest of
wekan-ldap uses the case-insensitive getLDAPValue(); a directory returning the
attribute with a different case (exposed by ldapjs -> ldapts, which preserves the
server's case) missed it. The email branch (and the addLdapUser fallback) now
read via getLDAPValue().

#6480 (uusijani): board/CSV export paused on socket backpressure with
`res.once('drain', resolve); res.once('error', reject)`, which leaked one 'error'
listener per backpressured write ("MaxListenersExceededWarning: 11 error
listeners added to [ServerResponse]"). writeWithBackpressure() now removes both
listeners once either settles.

Added tests and negative tests for all three fixes.

Thanks to Nissulya, uusijani and xet7 !

Fixes #6480,
Fixes #6481.
2026-07-19 18:36:11 +03:00
Lauri Ojansivu 5aaec4e6ac Tests: make loginWithToken robust to the reactive-userId lag on re-login.
Fix the intermittent "Unexpected userId after login" e2e flake (seen in
33-board-domains, which re-logs-in on the same page: admin then non-admin).
Meteor.userId() can lag the Meteor.loginWithToken callback by a tick, so reading
it immediately sometimes returned the PREVIOUS userId. Poll Meteor.userId() until
it settles on the expected id (5s timeout so a genuine mismatch still surfaces).
Pure test-helper change; the direct-Mongo-seed + resume-token login path this
uses does not go through createUser, so it is unaffected by the session security
changes.
2026-07-19 17:23:06 +03:00
Lauri Ojansivu 5ca56c25dd Tests: add functional negative cases for the event-log sanitizer and category catalog. 2026-07-19 14:20:04 +03:00
Lauri Ojansivu 4101c1be2b Tests stream: runtime self-checks (no Playwright required).
Add server/lib/selfChecks.js: at startup (and via an admin-only runSelfChecks
method) run cheap invariants a unit test would assert (database round-trip,
WRITABLE_PATH/files writable) and record any failure to the Tests stream
(Admin Panel -> Problems -> Tests). WeKan does NOT require Playwright: only
failures detectable without a browser are logged, and no runtime code imports
Playwright (asserted by the test). Wired into server/imports.js; design doc
updated.
2026-07-19 14:18:44 +03:00
Lauri Ojansivu 5ee3987d0d Speed: record slow HTTP requests to the Problems->Speed stream.
Add server/lib/speedMiddleware.js: a WebApp middleware that times non-asset HTTP
requests and records those slower than WEKAN_SLOW_REQUEST_MS (default 2000ms; 0
disables) to the speed stream via speedLog. Best-effort, never throws; wired into
server/imports.js. Source-guard test added.
2026-07-19 14:00:25 +03:00
Lauri Ojansivu 6678c92e08 Security log: wire upload / metrics-XFF / export-authz remediation points.
Record more runtime rejections into the eventlog (Admin Panel Problems):
- fileValidation.js: log every upload rejection (dangerous MIME content ->
  xss.mime, mime not on the allow-list -> file.mime, over size limit ->
  file.size).
- metrics.js: log spoofing.xff when a /metrics request carrying an
  X-Forwarded-For header is denied (likely forged-whitelisted-IP attempt).
- export.js: log authz.export at all four export-authorization denial paths.
All calls are best-effort/guarded (server-only require, try/catch) so logging
never breaks the guard. Source-guard tests added.
2026-07-19 13:59:11 +03:00
Lauri Ojansivu 4c046c64a8 i18n: translate the 16 new Admin Panel Problems keys to all languages.
Translate problems, summary, securityReportTitle, speedReportTitle,
testsReportTitle, new-problems, acknowledge, no-new-problems,
problems-summary-help and the event-* table headers into all 153 non-English
languages (en is the source). All files validated as JSON.
2026-07-19 13:52:06 +03:00
Lauri Ojansivu 7839ad15c3 Changelog: Upcoming new features (security/speed/tests logging + Admin Panel Problems page). 2026-07-19 13:43:57 +03:00
Lauri Ojansivu 0085f7fbfa Admin Panel: Problems button + page (Summary/Security/Speed/Tests), replaces banner & Reports button.
- 2nd header bar: add a Problems button (warning icon) to the RIGHT of the Info
  (version) button; it turns red when there are new problems (settingHeader.js
  polls eventLogProblemAreas). Remove the old Reports button; the Admin Panel
  still opens on Settings.
- The former Reports page is now the Problems page: left menu Summary / Security /
  Speed / Tests, then a separator, then the moved Broken Cards/Files/Rules/Boards/
  Cards/Impersonation items.
- Summary (problemsSummary, renamed from the removed top banner): the checkbox
  list + one Acknowledge button — the ONLY place to acknowledge.
- Security/Speed/Tests: read-only, paginated, searchable event tables via new
  admin-only eventLogPage/eventLogCount methods; acknowledgeEventLog now takes a
  stream or array.
- i18n keys + CSS + source-guard tests + design doc updated.
2026-07-19 13:42:47 +03:00
Lauri Ojansivu ff67780b30 Admin banner: checkbox list + one Acknowledge button (acknowledge only in banner).
Change the top problem banner from a per-area button to a checkbox list of
problem areas with a single Acknowledge button that acknowledges every checked
area in one call. acknowledgeEventLog now accepts a stream or an array of
streams. Acknowledging happens ONLY at the top banner; the Reports pages are
read-only (noted in the design doc).
2026-07-19 13:27:09 +03:00
Lauri Ojansivu 4f4140e02b Admin Panel: top banner for new Security/Speed/Tests problems, with Acknowledge.
Like the Announcement banner, the Admin Panel top now shows, for each problem
area with unacknowledged events, its menu path (Admin Panel -> Reports ->
Security/Speed/Tests) and the count of NEW problems. Each area has an Acknowledge
button that marks the newest problems seen, zeroing the count and removing the
info from the top.

- models/eventLog.js: add the eventlogAcks collection and admin-only methods
  eventLogProblemAreas() (count of events newer than each stream ack) and
  acknowledgeEventLog(stream) (upsert ack = now).
- client adminProblemBanner.{jade,js}: poll the counts, render path/count/button,
  acknowledge on click; included in settingHeaderBar (top of every admin page).
- i18n keys (securityReportTitle/speedReportTitle/testsReportTitle/new-problems/
  acknowledge) + banner CSS. Source-guard tests added.
2026-07-19 13:19:50 +03:00
Lauri Ojansivu 1d82ef8eae Security/speed/tests logging: store in the existing WeKan DB (no new files).
Per correction: use only the existing WeKan database via Meteor JavaScript
queries (works on FerretDB and MongoDB); do not create files/DBs under
WRITABLE_PATH; and have FerretDB report problems to WeKan rather than writing
itself.

- models/eventLog.js: one `eventlog` collection (stream: security|speed|tests).
- server/lib/{securityLog,speedLog,testLog}.js: thin wrappers that insert one
  document via EventLog.insertAsync (fire-and-forget, never throw).
- models/lib/securityLogFormat.js slimmed to just sanitizeDetail; remove the
  node:sqlite event store (server/lib/eventStore.js) and all file/path/schema
  helpers.
- Add a Tests stream: log anything that would fail an existing WeKan test.
- Design docs (WeKan.md, FerretDB.md) rewritten: DB-collection storage, grouped
  counts for the summary, Admin Reports read the collection, and FerretDB reports
  problems to WeKan which records them (no FerretDB DB/file writes).
- tests/securityLog.test.cjs updated (sanitizer + catalog + collection wiring +
  no-filesystem guards); verified via Python mirror.
2026-07-19 13:12:59 +03:00
Lauri Ojansivu 9f07aa4506 Security/speed logging: foundation loggers + first upload/avatar/SSRF wiring.
Implements the core of docs/Security/Remediation/WeKan.md:
- models/lib/securityLogFormat.js: pure, unit-testable helpers (dated run-dir
  path, disk-space guard, one-line format, parse, per-category/Bleed/severity
  tally, summary render).
- models/lib/securityCategories.js: general-category <-> hall-of-fame *Bleed
  catalog (RedirectBleed, LiveBleed, SourceBleed, MetricsBleed, InviteBleed, ...).
- server/lib/securityLog.js + speedLog.js: best-effort, never-throwing writers to
  WRITABLE_PATH/files/security|speed/YYYY-MM/DD/HH_MM_SS/ (wekan-log.txt +
  wekan-summary.txt / wekan-speed-*), with a disk-space check before every write
  and a dropped-events counter.
- Wire first remediation/rejection points to emit events: avatar-URL scheme
  rejection (setAvatarUrl), Trello live-import attachment SSRF block, and avatar
  localization SSRF block (covers the requested attachment/avatar rejection +
  sanitization logging).
- tests/securityLog.test.cjs (unit + negative tests), wired into test:unit:node.

Admin Panel Reports (Security/Speed UI), the FerretDB Go logger, and full guard
coverage are the next phase (fully specified in the design docs).
2026-07-19 12:45:03 +03:00
Lauri Ojansivu 4525764bb8 Docs: design for WeKan/FerretDB security + speed auto-remediation, logging and reports. 2026-07-19 12:39:05 +03:00
Lauri Ojansivu fd4874a9f7 Changelog: Upcoming CRITICAL section for the 7 meifukun security fixes. 2026-07-19 12:25:13 +03:00
Lauri Ojansivu 1669a1af19 Fix 7 security issues reported by meifukun.
- Avatar localization redirect SSRF: fetch avatars via fetchSafe (DNS-pinned,
  redirects rejected) instead of native redirect:follow, and validate
  profile.avatarUrl scheme (http/https/data:image/local) at write time.
- Trello import source.url stored XSS: only render/store an http(s) activity
  source URL, so a javascript: board url can no longer execute on click.
- Trello LIVE import readable SSRF: gate every attachment/background/avatar
  download with validateAttachmentUrl, matching the offline import path.
- CAS login global user-data race: store validated CAS user data per credential
  token instead of a module-global _userData (fixes account-takeover race).
- Metrics X-Forwarded-For whitelist bypass: only trust XFF behind
  METRICS_TRUST_PROXY (parsed spoof-resistantly from the right); else use the
  socket peer address.
- Stale impersonation export bypass: remove the "|| impersonateDone" export
  authorization bypass; require real board visibility (canExport) only.
- Invitation code brute force: generate invitation codes with a 128-bit CSPRNG
  (crypto.randomBytes) instead of a 6-digit Math.random value, and rate-limit
  createUser (DDPRateLimiter).

The OIDC shared serviceData race from the same report was already fixed (#4897,
the per-login objects are callout-local). Source-guard regression test added
(tests/securityMeifukun.test.cjs) and wired into test:unit:node.

Thanks to meifukun and xet7 !
2026-07-19 12:22:56 +03:00
Lauri Ojansivu 6a9d4c37b8 Updated ChangeLog. 2026-07-19 11:51:45 +03:00
Lauri Ojansivu 1e0ffc5c36 Updated SECURITY.md .
Thanks to xet7 !
2026-07-19 11:51:45 +03:00
github-actions[bot] bbcbee41c9 Bump versions for v10.02 2026-07-19 01:16:57 +00:00
Lauri Ojansivu 8d30efa117 Updated ChangeLog. 2026-07-19 04:15:37 +03:00
Lauri Ojansivu 6f70544c60 Changelog: add merged dependabot updates to Upcoming. 2026-07-19 04:14:14 +03:00
Lauri Ojansivu acc86fa86a Merge pull request #6482 from wekan/dependabot/github_actions/actions/setup-node-7
Bump actions/setup-node from 6 to 7
2026-07-19 04:11:04 +03:00
Lauri Ojansivu 96c59b2d01 Merge pull request #6485 from wekan/dependabot/npm_and_yarn/aws-sdk/client-s3-3.1090.0
Bump @aws-sdk/client-s3 from 3.1085.0 to 3.1090.0
2026-07-19 04:08:43 +03:00
Lauri Ojansivu 1a88240137 Merge pull request #6486 from wekan/dependabot/npm_and_yarn/dompurify-3.4.12
Bump dompurify from 3.4.11 to 3.4.12
2026-07-19 04:07:29 +03:00
Lauri Ojansivu d8bda40806 Merge pull request #6487 from wekan/dependabot/npm_and_yarn/filesize-11.0.22
Bump filesize from 11.0.17 to 11.0.22
2026-07-19 04:06:55 +03:00
Lauri Ojansivu 2c2e8a6fee Changelog: add Upcoming section (auto-update docs set + release automation). 2026-07-18 23:43:43 +03:00
Lauri Ojansivu cf55da122a Docs: move Autoupdate/Snap.md to Autoupdate/Forks/Snap-Core.md and repoint links. 2026-07-18 23:42:47 +03:00
dependabot[bot] ccb7a8e91c Bump filesize from 11.0.17 to 11.0.22
Bumps [filesize](https://github.com/avoidwork/filesize.js) from 11.0.17 to 11.0.22.
- [Changelog](https://github.com/avoidwork/filesize.js/blob/master/CHANGELOG.md)
- [Commits](https://github.com/avoidwork/filesize.js/commits/11.0.22)

---
updated-dependencies:
- dependency-name: filesize
  dependency-version: 11.0.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 20:33:31 +00:00
dependabot[bot] 963f72ac88 Bump dompurify from 3.4.11 to 3.4.12
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 20:33:25 +00:00
dependabot[bot] 144bed0eda Bump @aws-sdk/client-s3 from 3.1085.0 to 3.1090.0
Bumps [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) from 3.1085.0 to 3.1090.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1090.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1090.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 20:33:14 +00:00
dependabot[bot] 1f11ad2602 Bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 20:32:23 +00:00
Lauri Ojansivu 2054ea2b54 release-all.yml: add guarded snap-variants / ucs / nextcloud publish jobs.
Wire in three new publish jobs (dormant until their secrets are set — each
guards on its secrets and skips with a ::notice:: so ordinary releases are
unaffected):
- snap-variants: build+publish wekan-ondra and wekan-gantt-gpl snaps.
- ucs: bump + push the wekan/univention app and upload to the App Provider.
- nextcloud: build the ExApp image and publish to the Nextcloud App Store.
Document the new secrets in the header. Add a "Remaining steps" section to the
top of Snap-Ondra-Gantt.md, UCS.md and Nextcloud.md (keys to add + how).
2026-07-18 23:29:56 +03:00
Lauri Ojansivu 8ae0ee5446 Docs: Nextcloud.md — WeKan ExApp build/publish + AI/Deck/SSO integrations. 2026-07-18 23:22:19 +03:00
Lauri Ojansivu c1ffed59a7 Docs: move the per-OS update comparison docs into Autoupdate/OS/ subdir.
The 29 per-OS manual-vs-automatic update comparison docs move into OS/;
the WeKan-specific docs (README, Snap, Snap-Ondra-Gantt, UCS) stay in the
parent. All cross-links repointed and verified.
2026-07-18 23:22:19 +03:00
Lauri Ojansivu 58c0e6d1f8 Docs: UCS.md — releasing Docker WeKan to UCS App Center with MongoDB 3.x -> FerretDB migration. 2026-07-18 23:22:19 +03:00
Lauri Ojansivu cf1823b510 Docs: per-OS install/update comparison set in Design/Autoupdate/.
Add manual-vs-automatic (Snap-like) update comparison docs for all operating
systems with any Internet update mechanism: Linux, BSD, Haiku, Amiga, RISC OS,
Solaris/illumos, AIX, HP-UX, OpenVMS, z/OS, ArcaOS, ReactOS, FreeDOS, Plan 9,
Redox, SerenityOS, Android, iOS, ChromeOS, HarmonyOS, Ubuntu Touch, Sailfish,
postmarketOS, KaiOS, Tizen, webOS and TV/streaming OSes; plus a README index.
Add Snap-Ondra-Gantt.md (keys/secrets + release-all.yml changes to also release
the wekan-ondra and wekan-gantt-gpl snaps). Rename the two earlier dev docs to
Snap.md / Windows.md and repoint all links.
2026-07-18 23:22:19 +03:00
Lauri Ojansivu 2a878d55e7 Docs: macOS install & update platforms (manual vs automatic, like Snap). 2026-07-18 23:22:19 +03:00
Lauri Ojansivu 48a60a1141 Docs: move the auto-update dev docs into Design/Autoupdate/. 2026-07-18 23:22:19 +03:00
Lauri Ojansivu 3bfc6cce56 Docs: Windows install & update platforms (manual vs automatic, like Snap). 2026-07-18 23:22:19 +03:00
Lauri Ojansivu 8cf7dd68cc Docs: move Mac.md into Propietary/Mac/ and update all links to the new path. 2026-07-18 23:22:19 +03:00
Lauri Ojansivu 742fc9221a Docs: why ppc64el/s390x snap builds fail on core24 (multiarch action caps at core22). 2026-07-18 23:22:19 +03:00
github-actions[bot] add47dc065 Bump versions for v10.01 2026-07-18 19:01:13 +00:00
Lauri Ojansivu 5af16df201 Updated ChangeLog. 2026-07-18 22:00:12 +03:00
Lauri Ojansivu adaecfe8c2 Changelog: note the zh-alias/header-cog test fixes in Upcoming. 2026-07-18 21:24:10 +03:00
Lauri Ojansivu 08cfe2d788 Tests: fix zh-alias and header-cog regressions found by the full test run.
- i18n: registering generic Chinese (zh) as its own entry made bare "zh"
  resolve to itself, so the "zh -> zh-Hans" alias is now dead code — remove it
  from LANGUAGE_ALIASES (keep wuu -> wuu-Hans) and update the two mocha tests
  (resolveTag/setLanguage) to expect "zh" instead of "zh-Hans".
- e2e (15-board-actions): the Board Settings cog was removed from the header
  (it is in the right sidebar now), so the "one click from the header cog" test
  failed in every browser. Rewrite it to open the sidebar and open the board
  menu (which still has Archived Items) from there.
2026-07-18 21:18:10 +03:00
Lauri Ojansivu ef6b2794b8 Changelog: add Upcoming section with the changes made after v10.00.
The changes committed since the v10.00 release bump had no CHANGELOG Upcoming
section. Add one above v10.00 covering all of them: the "Submit editors with
Enter" Member Setting, the right-sidebar drag-to-resize width feature, the #6481
migration NaN/Infinity fix, the #6465 Add List inline-composer options restore,
the right-sidebar width/inputs fix, and the missing copy-selection key — each
with commit links.
2026-07-18 20:37:00 +03:00
Lauri Ojansivu 1284afc588 i18n: translate drag-to-resize-sidebar tooltip to all languages.
Add the sidebar resize handle tooltip (drag-to-resize-sidebar, "Drag to resize
sidebar") to every language file, translated into each language (English-variant
locales carry the English text). All 154 files JSON-validated; value-only
additions.
2026-07-18 19:49:01 +03:00
Lauri Ojansivu 354b7804b5 Right sidebar: drag the edge to resize its width (desktop), saved per user.
Add a resize handle on the sidebar edge, like resizing a spreadsheet column
(mirrors the list resize in client/components/lists/list.js). Drag it to change
the sidebar width; the width is saved to profile.sidebarWidth for logged-in
users and to browser localStorage (wekan-sidebar-width) for anonymous users on
a public board, then re-applied on load.

- The handle uses the LOGICAL inset-inline-start, so in LTR it is the left edge
  of the right-docked sidebar and in RTL it is the RIGHT edge of the left-docked
  sidebar; the drag direction inverts for RTL so dragging always widens/narrows
  the visible panel correctly.
- Desktop only: on phones (<=800px) the handle is hidden and the sidebar stays
  full width (the 100vw rule is now !important so a desktop inline width can
  never leak into mobile).
- Width is clamped (min 280px, capped near the viewport) and re-applied on
  window resize so crossing the mobile breakpoint clears/restores it.

New profile.sidebarWidth schema field + getSidebarWidth/setSidebarWidth helpers,
setSidebarWidth Meteor method, i18n (en, fi), and source-guard test
tests/sidebarResize.test.cjs.

Thanks to xet7 !
2026-07-18 19:42:19 +03:00
Lauri Ojansivu 72042b7d79 i18n: add missing copy-selection key (Multi-Selection sidebar) in all languages.
The Multi-Selection right-sidebar "Copy selection" button used {{_ (quote)copy-selection(quote)}}
but the key was missing from every language file — including the en source —
so i18next had no value to fall back to and rendered the raw key. Add
copy-selection ("Copy selection") to en and translate it to all languages,
mirroring each language(apos)s existing "move-selection" sibling with the verb changed
to copy. All 154 files JSON-validated; value-only additions.

Thanks to xet7 !
2026-07-18 19:29:40 +03:00
Lauri Ojansivu b8a21e4ce3 Right sidebar: bound the width and make its input fields fill the width.
- .board-sidebar used width: 30vw, which scaled with the viewport: on very wide
  screens it grew to ~double the intended width (it looked right only at mid
  widths). Use width: clamp(320px, 30vw, 560px) so it keeps the mid-width look
  but is capped (and floored) instead of scaling without bound. Phones (<800px)
  still get the full-width 100vw rule.
- The sidebar filter text inputs (Filter List by Title, Filter by card title,
  advanced filter) had no width, so they rendered at the browser default narrow
  width with a big empty gap on the right. Sidebar text/search inputs now
  width: 100% + box-sizing: border-box, so they fill the padded content area
  with equal empty space on both sides (the .sidebar-content 12px padding).

Source-guard test tests/sidebarWidth.test.cjs.

Thanks to xet7 !
2026-07-18 19:18:17 +03:00
Lauri Ojansivu b71461d90c Add List: restore the previous options in the inline composer (#6465).
The #6465 per-list-header inline Add List composer (addListInline) had been
reduced to just a title input, dropping the options the old addListForm had.
Restore them:
- the "Add after which list" position <select>, listing the swimlane lists and
  pre-selecting the list whose header button opened the composer; submit reads
  it for afterListId and derives nextListId from the following option so the new
  list is inserted between them (falls back to the header target for the empty
  board case).
- the "or template" link (js-list-template) to create a list from a template.
Helpers swimlaneLists()/isDefaultAfterList() added; createListAfter already
accepts nextListId. Source-guard test tests/addListComposerOptions.test.cjs.

Thanks to csonkaoszimt and xet7 !

Fixes #6465.
2026-07-18 18:55:19 +03:00
Lauri Ojansivu b8bb004650 i18n: translate submit-on-enter and its description to all languages.
Add the two new Member Settings keys (submit-on-enter, submit-on-enter-
description) to every language file in imports/i18n/data/, translated into each
language and inserted after show-desktop-drag-handles. English-variant locales
(en-GB, en_AU, ...) carry the English text; each real language matches its own
terminology, and the keyboard-key tokens Enter, Shift+Enter and Ctrl/Cmd+Enter
are kept verbatim. All 154 files JSON-validated; value-only additions.
2026-07-18 18:25:59 +03:00
Lauri Ojansivu bb6b0d5477 Member Settings: optional "Submit editors with Enter" (per-user, off by default).
#4236 made every multi-line editor submit only on Ctrl/Cmd+Enter (plain Enter
= newline) so titles could be multi-line, but that removed the fast
Enter-to-save title workflow some users rely on (v10 email feedback). Add a
per-user preference in the Member Settings (Change Settings) popup, saved to
profile.submitOnEnter and OFF by default: when on, plain Enter submits the card
title, description and other inlined forms, and Shift+Enter inserts a newline;
when off, behaviour is unchanged (Ctrl/Cmd+Enter saves).

The rule stays in the single pure isSubmitKey(event, {submitOnEnter}); the card
title, the card-description override and the shared client/lib/inlinedform.js
all route through it and read the preference from the current user. New toggle
+ helper + toggleSubmitOnEnter method + schema field, i18n (en, fi), and
editorSubmitKey.test.cjs extended with positive + negative opt-in cases.

Thanks to xet7 !

Related to #4236.
2026-07-18 18:14:33 +03:00
Lauri Ojansivu e0991d6602 Migration: keep cards/activities whose export has NaN/Infinity doubles.
mongo 3.x mongoexport emits non-finite doubles as the bare tokens NaN,
Infinity, +Infinity and -Infinity, which are not valid JSON, so EJSON.parse
threw ("Unexpected token N/+") and migrate-mongo3-to-ferretdb.mjs dropped the
WHOLE document — leaving boards/cards missing after an otherwise "successful"
migration. sanitizeNonFinite() now rewrites those bare tokens, only outside
string literals, to the canonical EJSON v2 double form {"$numberDouble":"..."}
that EJSON.parse accepts, so the document migrates instead of being lost.
Strings that merely contain "NaN"/"Infinity" and ordinary negative numbers are
left untouched. Test tests/migrateNonFinite.test.cjs (with negative cases).

Thanks to Nissulya and xet7 !

Fixes #6481.
2026-07-18 16:42:11 +03:00
Lauri Ojansivu 077e2196f5 Updated ChangeLog. 2026-07-18 16:34:50 +03:00
Lauri Ojansivu e010c09e87 Changelog: rewrite v10.00 from what commits actually implemented.
Rewrite the v10.00 section to match the final implementation and group ALL 101
commits in the release under their own headings (each with commit links):

- Corrected "done differently" cases: Change Color is visible grouped swatches
  (not the initial two-level dropdowns); Font is font-name + size buttons with
  immediate apply (not dropdowns); the board-header "icons only" change was
  reverted so button texts stay.
- Fixed the Home board entry: it is set via the All Boards Multi-Selection
  "Selected:" home action and the after-login redirect runs on all deployments,
  so it is available, NOT disabled and NOT Sandstorm-only (an earlier draft
  wrongly said the UI was disabled).
- Every commit since v9.99 is now listed once, grouped by feature/fix area.
2026-07-18 08:46:01 +03:00
github-actions[bot] 356b1152bb Bump versions for v10.00 2026-07-18 05:11:26 +00:00
Lauri Ojansivu 66a2f8ddc8 Updated ChangeLog. 2026-07-18 08:09:43 +03:00
Lauri Ojansivu ef81c25232 ci: disable Playwright E2E Tests workflow.
Rename .github/workflows/playwright.yml -> playwright.yml.disabled so GitHub
Actions no longer runs it (only .yml/.yaml files in .github/workflows are
executed). Reversible by renaming back. NOTE: this workflow also contained the
Meteor unit-tests job and a Puppeteer regression job, so those stop running too.
2026-07-18 08:01:52 +03:00
Lauri Ojansivu 2be9253839 releases: fix push-all-translations.sh cd depth for its translations/ location.
The script lives in releases/translations/, so the repo root is two levels up
(../..), not one. With a single .. it landed in releases/ and looked for ../tx
at wekan/tx instead of the sibling ../tx, aborting with "tx not found". Match
the cd "$(dirname "$0")/../.." pattern used by the other scripts here.
2026-07-18 07:54:28 +03:00
Lauri Ojansivu 2cf878215d Changelog: add Part 12 (Waray wa-RR fix) to the 154-languages entry. 2026-07-18 07:43:33 +03:00
Lauri Ojansivu c00f24d668 i18n: fix wa-RR — translate as Waray (war), not Walloon.
The .tx config maps Transifex "war" -> local wa-RR.i18n.json, i.e. wa-RR is
Waray/Winaray (a Philippine language), NOT a regional variant of Walloon (wa).
An earlier seed wrongly copied Walloon content into it. Retranslate the file
fresh from English into Waray (2191 strings); placeholders/brand names
preserved, key set unchanged, JSON validated.
2026-07-18 07:43:10 +03:00
Lauri Ojansivu 95552881fc Moved translation script to correct directory. Thanks to xet7. 2026-07-18 07:34:17 +03:00
Lauri Ojansivu 5b3394af2f Changelog: WeKan translated to 154 languages, with commit links. 2026-07-18 07:18:22 +03:00
Lauri Ojansivu d55c5e2c3c i18n: register wuu-Hans, fix Wolof/Malay names, add wuu browser alias.
- Register Wu Chinese (wuu-Hans): it had a data file but no languages.js
  entry, so it was neither browser-detectable nor shown in Change Language.
- Add wuu -> wuu-Hans to LANGUAGE_ALIASES so a bare "wuu" browser locale
  resolves (mirrors zh -> zh-Hans).
- Fix display names that were in the wrong script for their (Latin) content:
  Wolof "ولوفل" -> "Wolof"; Malay "بهاس ملايو" -> "Bahasa Melayu" (+ Malaysia).
km_KH/ru_RU are symlinks to km-KH/ru-RU, already covered. Every data file is
now registered, so all are browser-detectable and visible in the popup.
2026-07-18 07:16:00 +03:00
Lauri Ojansivu 01d55828d2 WeKan has been translated to 154 languages.
Thanks to xet7 !
2026-07-18 07:11:49 +03:00
Lauri Ojansivu 219011e5eb i18n: translate untranslated strings (wave 7, final: 14 languages + 5 variants).
Fresh translations: vi, uz, uz-AR (Arabic-script Uzbek), ve (Venda), ve-CC
(Venetian), ve-PP (Vepsian), vo (Volapuk), wa (Walloon), wo (Wolof), xh
(Xhosa), yi (Yiddish), yo (Yoruba), zgh (Tamazight/Tifinagh), zu (Zulu).
Seeded variants uz-UZ/uz-LA, vi-VN, wa-RR, zu-ZA from bases. Completes the
full 153-language set. Placeholders/brand names preserved; JSON validated.
2026-07-18 06:58:27 +03:00
Lauri Ojansivu 91ccef062b i18n: translate untranslated strings (wave 6: 15 languages + 2 variants).
Fresh translations: nb, oc, or_IN, pa, ro, sk, sl, sr, sw, ta, te-IN, th,
tk_TM, tlh (Klingon), ug (Uyghur). Seeded variants ro-RO, sl_SI from bases.
Terminology matches each file glossary + standard Kanban terms; brand names,
acronyms, env vars, placeholders and markup preserved verbatim. JSON validated.
2026-07-18 06:28:58 +03:00
Lauri Ojansivu 97feb986a8 i18n: translate untranslated strings (wave 5: 15 languages + 5 variants).
Fresh translations: gl, gu-IN, hi, hr, hu, hy, id, ig, ka, km, lt, lv, mk, mn,
ms. Seeded variants from those bases: gl-ES; hi-IN; km-KH/km_KH; ms-MY.
Terminology matches each file glossary + standard Kanban terms; brand names,
acronyms, env vars, placeholders and markup preserved verbatim. JSON validated.
2026-07-18 05:43:34 +03:00
Lauri Ojansivu a184a3a717 i18n: translate untranslated strings (wave 4: 15 languages + 9 variants).
Fresh translations: ace, af, ast-ES, az, be, bg, br, ca, cy, el, eo, et-EE,
eu, fa, fy. Seeded regional variants from those bases: af_ZA; az-AZ/az-LA;
ca_ES/ca@valencia; cy-GB; el-GR; fa-IR; fy-NL. Terminology matches each file
glossary + standard Kanban terms; brand names, acronyms, env vars, placeholders
and markup preserved verbatim. JSON validated. ~18800 strings.
2026-07-18 04:56:25 +03:00
Lauri Ojansivu 75ec34e837 i18n: register generic Chinese (zh) so it is selectable and browser-detectable.
zh.i18n.json existed but had no entry in the languages registry, so a browser
reporting bare "zh" could not auto-detect it and it was missing from the Change
Language list. Add it (code zh, name 中文). The other two unregistered data
files (km_KH, ru_RU) are underscore duplicates already covered by the existing
km-KH / ru-RU entries, whose tag is the underscore form.
2026-07-18 04:37:28 +03:00
Lauri Ojansivu c795d13450 i18n: seed close-relative lects as baselines (cmn, wuu, yue, ja-Hira, vls, ary).
Seed Mandarin/Wu/Cantonese (cmn, wuu-Hans, yue_CN) from zh-CN, Japanese-Hiragana
locale (ja-HI) from ja, West Flemish (vl-SS) from nl, and Moroccan Arabic (ary)
from ar as machine-quality baselines sharing the written form, for the community
to refine. Placeholders preserved; JSON validated. 4074 strings.
2026-07-18 04:17:19 +03:00
Lauri Ojansivu ea4a10c212 i18n: seed 32 regional variants from their translated base languages.
Fill untranslated strings in same-language, same-script regional variants by
copying the freshly-translated base: de -> de-AT/de-CH/de_DE; es -> es-AR/-CL/
-CO/-LA/-MX/-PE/-PY/es_CO; fr -> fr-CH/fr-FR; ru -> ru-RU/ru_RU/ru-UA; pl-PL;
cs-CZ; ko-KR; uk-UA; pt -> pt-PT/pt_PT; zh-CN -> zh-Hans/zh/zh_SG/zh-GB;
zh-TW -> zh-Hant/zh-HK; he-IL; nl-NL; ar -> ar-DZ/ar-EG. Keeps variants
consistent with their base and preserves placeholders. JSON validated.
24632 strings.
2026-07-18 04:16:11 +03:00
Lauri Ojansivu 7691829cdb i18n: translate untranslated strings (wave 2: 15 major languages).
Fill previously-untranslated UI strings in de, fr, es, it, nl, ru, pl, uk, cs,
ja, ko, zh-CN, tr, ar, pt. Each language matches its existing file terminology
as a glossary plus standard Kanban terms; product/brand names, acronyms, env
vars, console commands, placeholders (__var__, %s, {{..}}) and markup preserved
verbatim. Value-only changes; every file JSON-validated.

de 524, fr 617, es 917, it 938, nl 458, ru 974, pl 1037, uk 895, cs 1108,
ja 926, ko 850, zh-CN 929, tr 1059, ar 1145, pt 500 strings.
2026-07-18 04:12:59 +03:00
Lauri Ojansivu 63bda22faa i18n: translate untranslated strings (wave 1: zh-TW, pt-BR, da, he, sv).
Fill previously-untranslated UI strings in the five near-complete languages,
matching each file existing terminology as a glossary and standard Kanban
terms (board, list, card, swimlane, label, checklist, theme, font, ...).
Product/brand names, acronyms, env vars, placeholders (__var__, %s, {{..}})
and markup preserved verbatim. Value-only changes; JSON validated.

zh-TW 29, pt-BR 28, da 159, he 262, sv 382 strings.
2026-07-18 03:59:03 +03:00
Lauri Ojansivu a9f2e9f50c releases: add translations-push-all-translations.sh (force-push all languages).
Pushes the English source, then force-pushes every language translation to
Transifex in one run (../tx push -s; ../tx push -t -f). -f is required or the
Go tx CLI skips languages whose local file is not newer than the server copy.
Warns that it overwrites newer community translations, so pull first if needed.
2026-07-18 03:49:35 +03:00
Lauri Ojansivu 861ded439e Updates 2026-07-18 03:36:21 +03:00
Lauri Ojansivu 687a4af861 i18n: translate new admin/import-export strings to Finnish.
Translate the 21 previously-English strings in fi.i18n.json: the disable-*
and anonymize-* import/export/activities/notifications/watch admin feature
labels and descriptions, plus impersonation report title, impersonated user
and reason. Terminology follows existing Finnish strings (seuraaminen,
toimet, profiilikuva, tuonti/vienti, esiintyä käyttäjänä).
2026-07-18 03:32:36 +03:00
Lauri Ojansivu d67d27f0d8 All Boards: "Selected:" star/home actions + Home board badge (#2220).
Below the "Multi-Selection is on" button a "Selected:" row now offers two
actions on the multi-selected boards:
- star icon: stars every selected board (sets them as Starred).
- home icon: sets the first selected board as the Home board (opened after
  login) via the existing toggleDefaultBoard method + router redirect, which
  brings the previously hidden Home-board feature back into use.

The Home board also always shows FIRST in the Starred list (even when it is
not itself starred), badged with a home icon centred at the top of its tile.
2026-07-18 03:19:42 +03:00
Lauri Ojansivu 3736f8431a All Boards: half-width search, hint below Multi-Selection, title beside icon.
- Search boards box is now ~half width (300px); the input flexes to fill it.
- "Click checkboxes to select boards" hint moved out of the path title and
  stacked directly below the "Multi-Selection is on" button (own column).
- Folder icon and the menu title (e.g. "Remaining") are grouped so the title
  always sits to the RIGHT of the icon instead of wrapping below it.
2026-07-18 03:12:18 +03:00
Lauri Ojansivu 01088ea9d4 Notifications menu: make the dropdown item icons black.
The Filter by Unread / View All / Delete All Notifications item icons
(envelope, copy, trash, check) were faint; force the .menu-icon and .check-icon
(and their i.fa) to black.
2026-07-18 02:59:58 +03:00
Lauri Ojansivu 5e014d0967 Notifications drawer: fix X position — cancel the global absolute .close rule.
The X dropped below the header because a global `.close { position: absolute;
top: 5px }` rule (boardBody.css) applied once my rule stopped setting position.
Set position: static on the drawer .close so it stays a flex item and aligns
with the Notifications title at the right edge of the header row.
2026-07-18 02:55:25 +03:00
Lauri Ojansivu c87b4cb510 Notifications drawer: full width + robust flex header (black hamburger + X).
- The drawer and its header are now full width (100vw) instead of 400px.
- Rebuild the header as a flex row (hamburger left, title centre via flex:1, X
  right) instead of fragile absolute positioning, so the X is aligned with the
  Notifications title at the right edge (was sitting too low).
- Force the hamburger and X to black with !important (they were inheriting the
  themed header white link colour), overriding the surrounding themed bar.
2026-07-18 02:51:40 +03:00
Lauri Ojansivu 8dd6bd9db5 Notifications drawer header: black X + hamburger, X near the top-right edge.
- The X close button is now black, moved up (top: 8px, aligned with the
  "Notifications" title instead of vertically centred lower) and to the far
  right (inset-inline-end: 4px).
- The left hamburger menu toggle is black (was #333).
2026-07-18 02:44:51 +03:00
Lauri Ojansivu 8cb4050cf2 Member menu: remove the Notifications entry (duplicates the header bell icon).
The member-menu "Notifications" item and its js-notifications-drawer-toggle
handler toggled the same notifications drawer as the header bell icon
(notifications.js), so it was a duplicate. Remove the menu entry and its
now-unused handler; the header bell is unchanged.
2026-07-18 02:41:47 +03:00
Lauri Ojansivu a4cc0c2182 Search All Boards: label the clear button "Clear" using existing trello-clear-job key.
The js-new-search button clears the search (empties the input + query), so it
is a Clear button. Replace its broom icon with the text of the existing
trello-clear-job translation (en "Clear" / fi "Tyhjennä") and use it for the
aria-label too.
2026-07-18 02:39:09 +03:00
Lauri Ojansivu 98a03bf045 Search All Boards: new-search button follows theme override colors.
The global-search button hardcoded belize #216694 (inline !important styles +
.board-color-belize CSS), ignoring the theme. Make it a .primary type="submit"
button (safe: the search form has onsubmit="return false") so it uses the
theme override colour via the existing button[type=submit].primary rules
(default blue, or the board/global theme), and remove the hardcoded belize
styles.
2026-07-18 02:36:30 +03:00
Lauri Ojansivu 4dc09c62b9 Archived boards: Restore/Delete buttons follow the theme override colors.
The theme override colours .primary buttons via `button[type=submit].primary`
(board-color-* and custom-theme rules). A <button> without an explicit type
attribute does not match [type=submit] in CSS (though it already behaves as
submit), so the archive Restore/Delete buttons kept the default blue. Add
type="submit" (no behaviour change) so they match the theme rules and use the
global theme override colour (named or custom).
2026-07-18 02:32:55 +03:00
Lauri Ojansivu 1c8ba2afc3 Archived boards modal: start below the header bar so its top is visible.
The header bars have a higher z-index than #modal, so the archive panel top
(and the close X) was hidden under the top header bar. Offset the archive
modal content 96px from the top (clears both the quick-access and board header
bars) and extend to the window bottom via min-height calc(100vh - 106px).
2026-07-18 02:29:48 +03:00
Lauri Ojansivu b027594ca8 Archived boards modal: full width + extends to the bottom of the window.
The archive modal used the default .modal-content (500px, margin: 5vh auto),
so archived boards were cramped and the panel stopped short of the window
bottom. Target the archive modal (the only one containing .archived-lists) and
make it 96vw wide with no bottom margin + min-height calc(100vh - 2vh) so it
reaches the bottom of the browser window; each archived board is a full-width
row.
2026-07-18 02:25:30 +03:00
Lauri Ojansivu 08a2e5525e Change Avatar popup: Upload button uses theme colors (blue bg, white text).
Add the .primary class to both "Upload an avatar" buttons so they use the
theme primary-button style (default #005377 blue background + white text,
overridden by the board/global theme color) instead of the plain grey/black
default button.
2026-07-18 02:21:16 +03:00
Lauri Ojansivu 3d190ca022 Font popup: style the "Unset" colour reset links as buttons.
Give .js-reset-text-color / .js-reset-bg-color a button appearance (border,
padding, background, hover) with a dimmed disabled state, matching the font/
size buttons.
2026-07-18 02:19:04 +03:00
Lauri Ojansivu aa15e72682 Member Settings / Font: show the preview pangram below the font-name buttons.
Move the "The quick brown fox ... 0123456789" preview from the bottom of the
popup to directly below the font-name buttons.
2026-07-18 02:18:25 +03:00
Lauri Ojansivu bd8e417be1 Member Settings / Font: font-name + size buttons, immediate apply (no dropdowns).
Redesign the Font popup:
- Font: one button per installed font, each shown IN ITS OWN font (via inline
  style), plus a Default button. Clicking a font applies it immediately.
- Size: a row of buttons ordered by percent — smaller on the left, 100%
  (Default) in the middle, larger on the right. Clicking a size applies it
  immediately.
- Colors: the text/background color wheels now apply immediately on change /
  reset. Removed the Save button (everything applies on click).

New .ui-font-btn / .ui-font-size-btn styling (with .active) in labels.css.
Tests updated in tests/uiFonts.test.cjs.
2026-07-18 02:16:53 +03:00
Lauri Ojansivu fc0649a756 Change Language popup: show languages in multiple columns when there is width.
Widen the language popup responsively (width: min(90vw, 1100px), max-width:
90vw) and lay the list out as an auto-filling grid (repeat(auto-fill,
minmax(170px, 1fr))) so as many languages as possible are visible at once on
wide windows, collapsing to a single column when narrow.

Guarded by tests/changeLanguageColumns.test.cjs.
2026-07-18 02:05:00 +03:00
Lauri Ojansivu 969f96d742 Member Settings / Font popup: add a title (changeFontPopup-title).
Give the Font popup a header like the Change Color popup: en "Font",
fi "Fontti".
2026-07-18 02:01:29 +03:00
Lauri Ojansivu 0f87c3bf8b Change Color popup: remove the "Preview" text from the custom-color preview swatch.
The preview swatch (shown for flat/clear custom colors) still displays the
chosen colour, but no longer shows the "Preview" text label inside it.
2026-07-18 01:59:29 +03:00
Lauri Ojansivu 834698bb5e Revert board-header icons-only; update CHANGELOG Upcoming with newest changes.
- Revert the unconditional hiding of board-header button text labels (Private,
  Muted, Sort, Filter, ...): they show again at normal widths. The pre-existing
  mobile-only (max-width:800px, #6419) icons-only rule is untouched.
- CHANGELOG Upcoming: add consolidated entries for the recent All Boards /
  board-header layout polish, the Change Color swatches / immediate-apply /
  applies-everywhere / popup title, the #4759 user-insert validator fix, and the
  rebuild-wekan.sh CURRENT-IP fix; mark the #2220 home-board toggle as currently
  disabled.
2026-07-18 01:56:47 +03:00
Lauri Ojansivu cd7f9f7bc9 All Boards: move "+ Add Template Container" text 2px down (separate from Add Board).
"+ Add Board" and "+ Add Template Container" share the .js-add-board .label
element, so add an .is-template-container class to the templates-menu variant
and give it padding 20/8/22/8 — 2px lower than "+ Add Board" (18/8/24/8) —
while keeping the same 42px total vertical padding so the tile height is
unchanged.
2026-07-18 01:51:37 +03:00
Lauri Ojansivu 519c04b8ef All Boards: move "+ Add Board" text 2px further up (padding 18/8/24/8). 2026-07-18 01:50:07 +03:00
Lauri Ojansivu 3f5173b960 All Boards: move "+ Add Board" text 4px up (keep tile height).
Shift the .js-add-board .label vertical padding (top 24->20, bottom 18->22)
so the "+ Add Board" / "+ Add Template Container" text sits 4px higher, while
keeping the same 42px total vertical padding so the grey tile stays the same
height as the board icons. Test now checks total vertical padding equality
(height) rather than an exact padding string.
2026-07-18 01:49:40 +03:00
Lauri Ojansivu c21edb160b All Boards: move the Search boards input text 2px further down (top: 8px). 2026-07-18 01:47:16 +03:00
Lauri Ojansivu cb80198d97 All Boards: move the Search boards input text 4px further down (top: 6px). 2026-07-18 01:46:41 +03:00
Lauri Ojansivu 035195f2b4 All Boards: vertically center the Search boards input text.
The search input text sat a little high in the .board-search field; nudge it
down (position: relative; top: 2px) so it reads as vertically centered.
2026-07-18 01:45:47 +03:00
Lauri Ojansivu 197254fe16 All Boards: transparent board tile drag-handle background (icon only).
The desktop board-handle had a dark rgba(0,0,0,0.4) background box; make it
transparent so only the drag-handle icon is visible. The hover highlight is
kept. Guarded in tests/addBoardTileHeight.test.cjs.
2026-07-18 01:43:32 +03:00
Lauri Ojansivu aafe71fd19 All Boards: move board tile drag handle to the right middle.
The desktop board-handle was top-centered (top:4px; left:50%; translateX);
move it to the right middle (top:50%; translateY(-50%); inset-inline-end),
matching the mobile/small-screen handle position. Guarded in
tests/addBoardTileHeight.test.cjs.
2026-07-18 01:42:47 +03:00
Lauri Ojansivu 15e756f4e0 Header/All Boards: reorder Multi-Selection/Sort/Search and swap mobile-toggle/zoom.
- All Boards path header: order is now Multi-Selection, then Sort, then Search
  boards (Search moved to the right of Multi-Selection; Multi-Selection moved to
  the left of Sort). Guarded in tests/addBoardTileHeight.test.cjs.
- Top header bar: swap the positions of the mobile/desktop toggle and the zoom
  controls (mobile toggle now right after the logo, zoom later). Both kept as
  ungated direct children (zoom is no longer hidden when the logo is hidden).
2026-07-18 01:41:48 +03:00
Lauri Ojansivu 97b0240ca4 Board header: icons only — always hide button text labels.
Make the board-header button text labels (Private, Muted, Sort, Filter,
Swimlanes, Multi-Selection, ...) always hidden (was only under a max-width
media query), so the header shows icons only. The board title and the star
count (.board-star-counter) are not button labels and stay.

Guarded in tests/headerHamburger.test.cjs.
2026-07-18 01:27:35 +03:00
Lauri Ojansivu 68723ff43b Board header: remove the Board Settings cog (it is already in the right sidebar).
The #6465 one-click board-settings cog (js-open-board-menu) sat just left of the
sidebar hamburger, duplicating Board Settings which is reachable from the right
sidebar. Remove the cog from boardHeader.jade, its now-dead click handler from
boardHeader.js, and the unused .js-open-board-menu header CSS rule. The sidebar
board-menu button (sidebar.jade/js) is unchanged, and the hamburger + separator
remain.

Guarded in tests/headerHamburger.test.cjs.
2026-07-18 01:14:28 +03:00
Lauri Ojansivu cfd53cc969 Board header: hide button text labels on narrow windows (icons only).
When the window is not wide enough, the 2nd header bar button labels (Private,
Muted, Sort, Multi-Selection, ...) wrapped to a second row. Add a max-width:
1200px media query that hides the .board-header-btn text spans (show icons
only), keeping the board title and the buttons on one line. The star count
(.board-star-counter) is kept.

Guarded in tests/headerHamburger.test.cjs.
2026-07-18 01:05:15 +03:00
Lauri Ojansivu bd72f5d64c Board header: hamburger right gap matches the user-name gap (~8px).
Previously the sidebar hamburger sat 12px from the right edge (2px margin +
10px bar right padding), more than the ~8px gap to the right of the user name
in the top bar (.header-user-bar-name margin-inline-end: 8px). Remove the board
header bar right padding and give the hamburger margin-inline-end: 8px, so its
right-edge gap is the same small amount as the user name.

Guarded in tests/headerHamburger.test.cjs.
2026-07-18 00:57:23 +03:00
Lauri Ojansivu e33e50d9c8 Board header: remove empty space around the sidebar hamburger button.
The hamburger used the general .board-header-btn margin: 0 12px, leaving a
button-sized empty gap on each side. Tighten its side margins to 2px (and the
preceding cog button right margin to 2px) so the hamburger sits snugly at the
right edge with no wasted space to its left or right.

Guarded in tests/headerHamburger.test.cjs.
2026-07-18 00:53:33 +03:00
Lauri Ojansivu 7718118654 Board header: keep the sidebar hamburger pinned to the right edge at all widths.
The board header bar (Swimlanes + Lists views) used a float layout: the right
button group (which ends with the js-toggle-sidebar hamburger) was float:
inline-end. As the window width changed, the floated groups wrapped and the
hamburger drifted to the middle of the bar. Make #header-main-bar a flex row
(align-items:center, flex-wrap:wrap), make the button groups flex (their
buttons float is ignored inside a flex container), and pin the right group with
margin-inline-start:auto so it always hugs the right edge (or the right of its
row when wrapped).

Guarded by tests/headerHamburger.test.cjs.
2026-07-18 00:49:29 +03:00
Lauri Ojansivu 772ae061f1 Change Color popup: immediate apply (no Save), left-aligned titles, title, spacing.
Feedback fixes to the Select Color picker:
- Apply IMMEDIATELY on clicking a swatch (and when a custom-color wheel is
  committed) via a shared applySelection(); removed the Save button/handler.
- Category titles (Flat/Clear/Dark/Special) are left-aligned and clear: both,
  so they drop below the previous group swatches (which float: inline-start)
  instead of the 2nd/3rd/4th title sitting mid-row.
- Remove the empty band above the first (Flat) title (first-child margin-top:0)
  and tighten the Default row.
- Member Settings Change Color popup now has a header: added changeColorPopup-title
  ("Change Color" / fi "Vaihda väri"), matching Board Settings.

Tests updated in tests/themeColorPicker.test.cjs.
2026-07-18 00:45:33 +03:00
Lauri Ojansivu c6f960bbe1 rebuild-wekan.sh: robust CURRENT-IP detection (fix http://:3000 Invalid URL).
The dev-server CURRENT-IP options detected the IP with
  ip a | grep noprefixroute | ... | grep 192.
which requires the noprefixroute flag AND a 192.x subnet. On hosts without
either (e.g. arm64 Ubuntu on a 10.x/172.x network) it returned empty, so
ROOT_URL became http://:3000 and Meteor crashed with "TypeError: Invalid URL".

Detect the primary IP subnet-agnostically via the default route
(ip route get 1.1.1.1 -> src), fall back to hostname -I, then to localhost so
the URL is always valid. macOS uses ipconfig getifaddr en0/en1 with a localhost
fallback. Applied to both CURRENT-IP dev-server options.
2026-07-17 23:52:52 +03:00
Lauri Ojansivu ac20cd6442 All Boards: hide Home-board icons and disable setting a Home board (#2220).
Remove the fa-home Home-board toggle from the board tiles (boardsList.jade),
its click handler and the isDefaultBoard tile helper (boardsList.js), so a
Home board can no longer be set from the All Boards page. The model helpers,
server toggleDefaultBoard method and the login redirect are left intact but
are no longer reachable from the UI. Test flips to guard the disabled state.
2026-07-17 23:50:39 +03:00
Lauri Ojansivu c6aa769bc0 All Boards: Multi-Selection button looks the same as in Swimlanes View.
The All Boards button showed only an icon (span.emoji-icon > fa-check-square-o)
with the reset X as a sibling, while the Swimlanes View (boardHeader.jade)
shows the check icon + a "Multi-Selection" text label with the reset X nested
inside. Match the All Boards markup to the Swimlanes View (direct icon + text
label + nested reset with fa-times-thin), and add stopPropagation to the reset
handler so the now-nested X does not bubble to the activate handler and
immediately re-enable selection.

Guarded by tests/multiSelectionButton.test.cjs.
2026-07-17 23:48:26 +03:00
Lauri Ojansivu 7699cc1e90 All Boards: My Boards bar wraps so Sort/Multi-Selection/Clear stay visible.
The .boards-path-header was a single-row flex (justify-content: space-between,
no flex-wrap) and .path-right (search box + Sort / Multi-Selection / Clear)
also did not wrap, so narrowing the window — especially with Multi-Selection
on, whose hint widens .path-left — pushed those buttons off/hid them. Add
flex-wrap: wrap to the bar, .path-left (min-width:0 so it can shrink; hint
wraps under the title) and .path-right (buttons wrap among themselves), so
everything drops to a second row and stays visible.

Guarded in tests/addBoardTileHeight.test.cjs.
2026-07-17 23:32:31 +03:00
Lauri Ojansivu 1312fefb12 All Boards: remove empty .board-list-header dead grey space above the layout.
The .board-list-header div (a leftover zoom-controls container) rendered
nothing but still reserved vertical space via margin: 1vh 0 2vh 0 + padding:
1vh 0 — an empty grey band above the boards layout, between the top and the
left menu / "My Boards" bar. Removed the empty element from boardsList.jade
and its now-unused CSS rule, so .boards-layout is the first child of .wrapper.

Guarded in tests/addBoardTileHeight.test.cjs.
2026-07-17 23:27:01 +03:00
Lauri Ojansivu 1008561517 All Boards: + Add Board / + Add Template Container tile same height as board icons.
The grey add-tile (.js-add-board .label, shared by both "+ Add Board" and
"+ Add Template Container") overrode min-height:100px, line-height:56px and
padding:36px 8px 32px 8px — taller than the #6465-thinned board tiles
(.board-list-item: min-height:72px, padding:24px 8px 18px 8px). Match the
board-tile box model so the grey background is the same height as a board icon.

Guarded by tests/addBoardTileHeight.test.cjs (asserts the add-tile min-height +
padding equal the board tile, and the old oversized values are gone).
2026-07-17 23:19:23 +03:00
Lauri Ojansivu 1cd32001d4 Select Color: global override applies everywhere + visible grouped swatches (#5778).
Two fixes from feedback that picking clearblue "did not change" and the
dropdowns were hard to use:

1) The global theme override now wins EVERYWHERE, including board pages. Before,
   the <body> autorun suppressed it whenever a board was open (onBoard gate), so
   changing the color while viewing a board did nothing. Now a set global override
   is applied regardless of board context, and #header / #header-quick-access
   (header.jade) and .board-wrapper (boardBody.jade) prefer the global override
   over the per-board color. Unset it to return to per-board colors. When no
   global is set, an open board still exposes its own custom colors.

2) The picker no longer uses category/theme dropdowns (colors were invisible).
   It now shows the actual color SWATCHES with their names, grouped by category,
   with the category name as a header above each group. The flat/clear color
   wheels still appear once such a color is selected; custom-capability is derived
   from the selected color. customTheme.css styles the group labels.

Tests updated: themeColorPicker/globalThemeColor guards for grouped swatches and
the global-wins-everywhere behaviour.
2026-07-17 23:12:55 +03:00
Lauri Ojansivu 0535215225 Fix add-list: Save did nothing (Match failed) + empty-swimlane + button (#6465).
Two fixes to the #6465 inline Add List composer:

1) Save button did nothing. The inline composer calls createListAfter WITHOUT
   nextListId, but the server check() used Match.OneOf(String,null,undefined)
   (not Match.Optional) for it. Meteor check throws "Match failed [400]" on a
   MISSING object key unless the sub-pattern is Match.Optional, so every inline
   add-list 400'd. The omittable params (swimlaneId/afterListId/nextListId/type)
   are now Match.Optional, so callers may omit them.

2) Empty swimlane/board now shows a + button, and the create-list form appears
   only after clicking it (a swimlane that already has lists keeps using the
   per-list-header add-list button — no + button needed). The reveal is scoped
   to the clicked swimlane via a wekan-add-list-empty Session flag, cleared on
   save/close.

Tests: models/lib/createListParams.js + tests/createListParams.test.cjs mirror
the method's param contract (incl. Meteor's missing-key rule) with the exact
client shapes + negatives, and a source guard that the check uses Match.Optional;
tests/upcomingUiGuards.test.cjs gains guards for the empty-state + button reveal.
Wired into test:unit:node.
2026-07-17 23:01:52 +03:00
Lauri Ojansivu 8aacc22360 Test: de-flake webkit swimlane-add popup wait (6s -> 15s).
specs/13-swimlanes.e2e.js waited only 6s for the swimlane action .js-pop-over
to appear. Under the full parallel suite that occasionally exceeds 6s on
webkit (the popup opens fine, ~3s, when the test runs alone), so the test
flaked. Bump to 15s to match the suite standard popup-visibility wait; a
genuinely broken popup still fails.
2026-07-17 22:47:37 +03:00
Lauri Ojansivu 437c64c9a0 Fix #4759 regression: uiTextColor/uiTextBgColor custom() rejected every user insert.
The optional profile.uiTextColor / profile.uiTextBgColor schema fields ran
their custom() hex validator on EVERY user insert, including when the field
was absent. /^#..$/.test(undefined) is false, so the validator returned
"notAHexColor" and collection2 threw ValidationError for any user with no
custom color set — surfacing as a SyncedCron fatal unhandledRejection and
blocking user creation (seen in ../log test-server.log; also cascaded into
ECONNREFUSED e2e failures as the server destabilized).

Guard both custom() validators to return undefined when the value is unset
(undefined/null/empty), matching the existing list-color validator. Add a
source-guard test so the guard cannot silently regress.
2026-07-17 22:05:18 +03:00
Lauri Ojansivu 061eba8b7a Finnish: translate newest UI strings (theme, font, size, colors, Sandstorm).
Translate the strings added this cycle in fi.i18n.json, reusing existing
terminology (väri, taulu, lista, uimarata, fontti, teema, Oletus): the Select
Color theme categories, the Member Settings font / size / text + background
color pickers, the default-board tooltips, and the list-move confirmation.
Fixes theme-category-clear, which had been auto-translated to the verb
"Tyhjennä" (empty) instead of the theme sense "Kirkas". font-preview-text uses
the standard Finnish pangram so the preview shows ä/ö.
2026-07-17 21:48:12 +03:00
Lauri Ojansivu 176c5ef2d8 Updates 2026-07-17 21:44:36 +03:00
Lauri Ojansivu 9a0c0613a5 Sandstorm: auto-open a single-board grain (no save) (#2220).
Restore the old "a grain opens straight into its board" behaviour as a
convenience, WITHOUT persisting anything. On the Sandstorm home route only:

- a saved Home board (explicitly chosen by the user) always wins -> redirect;
- else, once the boards subscription has loaded, exactly ONE board -> just
  open it (nothing is saved);
- else (zero or many boards, no chosen Home board) -> stay on All Boards.

Choosing a Home board stays a separate, explicit action (the All Boards home
toggle, #2220) — the auto-open never writes it.

The redirect is reactive (Tracker.autorun over a boards subscription), so it
waits out Sandstorm asynchronous grain login (Meteor.userId lands late) and
board loading instead of firing once too early. Runs at most once per page
load, and only when isSandstorm.

- models/lib/sandstormAutoOpen.js: pure, unit-tested decideSandstormAutoOpen()
  (+ tests/sandstormAutoOpen.test.cjs, wired into test:unit:node).
- config/router.js: startSandstormAutoOpen() reactive redirect, gated to
  Sandstorm; non-Sandstorm keeps the existing one-shot default-board redirect.
2026-07-17 21:40:37 +03:00
Lauri Ojansivu 22c7d5376b Member Settings / Font / Color: custom text + background color (#4759).
The Font popup now has a color wheel for the text color and one for the text
background color, each with an Unset button to go back to default. Values are
free choices (native <input type=color>), validated server-side as #rrggbb
hex (models/lib/uiFonts isHexColor6) so only a strict hex ever reaches a CSS
value — no injection surface. An unset/invalid value clears that color.

- profile.uiTextColor / profile.uiTextBgColor schema (hex-validated) + getters;
  setUiColors(textColor, bgColor) method ($set valid hex, $unset otherwise).
- changeFontPopup: two color wheels + reset buttons; live preview reflects
  font, size, text color and background color.
- Applied UI-wide via --wekan-ui-text-color / --wekan-ui-bg-color CSS variables
  + has-ui-text-color / has-ui-bg-color body classes (uiFont.js + uiFont.css).
- i18n text-color / text-background-color; tests extended (hex + negatives +
  source guards).

Part of #4759.
2026-07-17 21:15:51 +03:00
Lauri Ojansivu 3c9524f61a Member Settings / Font: pick an installed font + preset size (#4759).
New "Font" entry in the member menu opens a popup with a dropdown of fonts
DETECTED as actually installed in the browser (no free typing) and a Font
size dropdown of sensible presets (Smaller..Largest, no typed numbers). Both
have a Default option that unsets the custom choice.

Security: font names and size keys are validated server-side against a
curated whitelist / preset list (models/lib/uiFonts.js), so only a known safe
font name and a preset percentage ever reach the DOM. Labels are plain text
(Blaze-escaped) — never HTML or markdown. No CSS-injection surface.

- models/lib/uiFonts.js: UI_FONTS whitelist + SAFE_FONT_RE + isKnownFont /
  fontFamilyValue and UI_FONT_SIZES presets + isKnownFontSize / fontSizeValue.
- client/lib/fontDetector.js: canvas width-comparison detection of installed
  fonts, filtered to the whitelist.
- profile.uiFont / profile.uiFontSize schema + getters; setUiFont /
  setUiFontSize methods (validated, null/default unsets).
- changeFontPopup (font + size dropdowns, both with Default) wired in
  userHeader.js; applied UI-wide via CSS variables in uiFont.js + uiFont.css
  (marker body classes), wired into client/features/main.js + client/styles.js.
- i18n change-font/font/font-default/font-size(+presets); tests/uiFonts.test.cjs
  (pure + negatives + source guards) wired into test:unit:node.

This is a Member Settings feature (not an Admin Panel option).

Fixes #4759.
2026-07-17 21:15:39 +03:00
Lauri Ojansivu d0a1e43f36 Select Color: two-level dropdown picker + custom colors (#5778).
Both Board Settings and Member Settings Change Color now use a shared
category -> theme two-level dropdown picker with custom color wheels for the
flat (1 color) and clear (2 colors, a color slide) categories; dark/special
are fixed. Custom colors are validated server-side as #rrggbb, stored in
board.customThemeColors / profile.globalThemeCustomColors, and applied as
CSS variables (--theme-accent / --theme-accent-2) consumed by customTheme.css
to recolor the header, primary buttons and sidebar button.

- client/components/main/themeColorPicker.{jade,js}: shared picker, scope
  "board" or "global", reusing the #5514 native color wheel + themeCategories.
- board.setColor(color, custom) + setGlobalThemeColor(color, custom): validate
  via isValidCustomColors (flat/clear + count + hex) before storing.
- boardChangeColorPopup and changeColorPopup now render the shared picker;
  removed their old swatch-list helpers/events.
- globalThemeColor.js: apply custom colors as :root CSS variables (board own
  colors on a board, else the user global override) + marker body classes;
  customTheme.css consumes them.
- Wire globalThemeColor.js / themeColorPicker into client/features/main.js and
  customTheme.css into client/styles.js. This also fixes globalThemeColor.js
  never having been imported (so the #5778 override now actually loads).
- i18n theme / theme-category[-flat/clear/dark/special]; tests
  themeColorPicker.test.cjs + updated globalThemeColor.test.cjs.

Fixes #5778.
2026-07-17 20:58:39 +03:00
Lauri Ojansivu 49285dad19 Select Color: theme categories core + design doc (docs/Theme/Theme.md).
Partition the ~19 board/global theme colors into flat / clear / dark /
special and define where custom colors are allowed (flat = 1 accent,
clear = 2 for a color slide, dark/special = fixed none). This is the shared
source of truth for the redesigned Select Color picker (two-level dropdowns
+ color wheel) used by both Board Settings and Member Settings.

- models/lib/themeCategories.js: THEME_CATEGORIES + categoryOf /
  colorsInCategory / allowsCustomColor / customColorCount /
  isValidCustomColors (validates category + count + #rrggbb hex).
- tests/themeCategories.test.cjs (wired into test:unit:node): behaviour +
  negatives + a source guard that the partition exactly equals
  config/const.js ALLOWED_BOARD_COLORS.
- docs/Theme/Theme.md: full design — categories, two-level dropdown UX,
  custom-color rules, CSS-variable rendering plan, storage
  (board.customThemeColors / profile.globalThemeCustomColors), and
  server-side validation.

Completes the global per-user theme override (Member Menu / Change Color)
begun in the previous commit.

Fixes #5778.
2026-07-17 20:46:10 +03:00
Lauri Ojansivu eab1546b88 Member Menu / Change Color: global per-user theme override (#5778).
A new "Change Color" entry in the member menu works like Board Settings /
Change Color, but is a per-user GLOBAL override saved to the profile
(profile.globalThemeColor). The chosen color theme applies to the whole UI —
All Boards, Search All Boards, Admin Panel, My Cards, etc. — via a
board-color-<name> class on <body> and the header, so a user can pick a dark
theme (dark/midnight/moderndark/exodark/cleandark) or any other theme
everywhere. The first popup entry clears the override.

- models/users.js: profile.globalThemeColor field + getGlobalThemeColor /
  globalThemeColorClass / setGlobalThemeColor helpers.
- server/models/users.js: setGlobalThemeColor method, validated against
  BOARD_COLORS (rejects unknown colors so no arbitrary CSS class is injected)
  and auth-guarded; null/empty clears it.
- client/components/main/globalThemeColor.js: Tracker autorun that adds the
  class to <body> on non-board pages; on a board the board own color wins, so
  the global class is cleared there to avoid two themes clashing.
- header.jade: #header and #header-quick-access fall back to the user global
  theme class when not on a board (covers the #header-specific color rules).
- Change Color menu entry + changeColorPopup (color swatches + clear option),
  wired in userHeader.js.
- i18n change-color / theme-default; tests/globalThemeColor.test.cjs wired
  into test:unit:node.
2026-07-17 20:32:40 +03:00
Lauri Ojansivu 46924985f8 Soft delete for lists: delete = mark, restorable + undoable (#1023).
Deleting a list no longer destroys it. The list and its cards are marked
(deletedAt/deletedBy/deleteBatchId), hidden from the board, recorded as a
reversible delete in history, and restorable via lists.restore or Ctrl+Z.
This is the first slice of "no permanent delete in ordinary use": physical
removal is limited to GDPR/account erasure and a Global-Admin purge gated by
a new Admin Panel / Features / Delete "Enable permanent delete" toggle
(off by default).

- models/lib/softDelete.js: pure notDeleted/softDeleteSet/restoreModifier/
  canPurge helpers (+ tests/softDelete.test.cjs, wired into test:unit:node).
  softDeleteSet omits falsy actor/batch and restore uses $unset so no null is
  ever written to a Date/String schema field.
- enablePermanentDelete feature flag (models/lib/featureFlags.js) + Settings
  schema field.
- deletedAt/deletedBy/deleteBatchId schema fields on Lists and Cards
  (optional, no null default -> absent means live).
- board/swimlane list render paths filter deletedAt: null.
- server/models/lists.js: shared softRemoveList() + lists.softRemove /
  lists.restore / lists.purge (purge gated on canPurge = isAdmin && flag);
  client delete handler and REST DELETE now soft-remove.
- userPositionHistory undo()/redo() handle actionType delete/restore via a
  batch-consistent _applyDeleted() helper.

Design + planned rollout (other entities, Recycle Bin, admin Delete category
table with GDPR data classes) in docs/Features/Undo/Undo.md.
2026-07-17 20:20:06 +03:00
Lauri Ojansivu 58344db0d7 Undo docs: soft-delete design (delete = mark, never destroy).
Every user-facing delete becomes a soft delete (deletedAt/deletedBy), making
deletions restorable and undoable (#1023). No permanent delete in ordinary
use: physical removal is limited to GDPR/account erasure and an explicit
Global-Admin purge gated behind Admin Panel / Features / Delete ->
"Enable permanent delete for Global Admin" (off by default). Covers scope
(soft vs exempt collections), the softRemove/notDeleted mechanism, cascade
via deleteBatchId, archived-vs-deleted distinction, undo/History integration,
and migration steps.
2026-07-17 20:07:09 +03:00
Lauri Ojansivu e246403bb6 Activities already load progressively (infinite scroll), like visible cards.
The activities publication only ships the most-recent `limit` activities and
the client pages them in via loadNextPage, so large boards stay fast without
deleting activity history. Document this so the request is closeable.

Fixes #2539.
2026-07-17 20:01:52 +03:00
Lauri Ojansivu e9e7071744 Changelog: drop bogus #6483/#6484 issue links (those bugs were reported by email).
The 'Filter by date -> Overdue' and 'list disappears in swimlanes' fixes were
reported originally by email, not as GitHub issues - #6483 and #6484 do not exist
(404). Replace the dead issue links in their Upcoming entries with '(reported by
email)'. The fixes themselves are unchanged.

Thanks to xet7.
2026-07-17 19:55:40 +03:00
Lauri Ojansivu 8287865b23 Tests: cover the remaining Upcoming changes (#2220 default board, #6465 UI, #6478 confirm).
Adds tests (with negative cases) for the Upcoming changes that previously lacked
them:
- tests/defaultBoard.test.cjs (#2220): behavioural toggle/decision logic (set,
  unset the current default, switch to a different board; negatives: nothing is
  the default when unset, B is not the default when A is) + source guards that
  the schema, model + server toggleDefaultBoard, once-per-session router redirect,
  and board-tile toggle are all wired.
- tests/upcomingUiGuards.test.cjs: source guards for the #6465 Add List header
  button + inline composer (leading column replaced by empty-state gating),
  minicard white-space (min-height:0) and swimlane-header zoom clip
  (overflow:visible/height:auto), and the #6478 touch confirmation before a
  cross-swimlane list move (gated on touch/small screen; declining aborts).

The other Upcoming changes already had tests (filterDateNull, listMoveSwimlane,
cardDragSwimlaneScrollRefresh, popupAfterConfirm, undoRedoSelection,
listWidthDefaults, ferretdb* in the FerretDB repo).

Fixes #2220. Fixes #6465. Fixes #6478. Fixes #6479.

Thanks to xet7.
2026-07-17 19:48:53 +03:00
Lauri Ojansivu e8ced84172 Feature #2220: mark a board to open automatically after login (home board).
On the All Boards page each board tile gets a home (house) toggle next to the
star. Marking a board sets it as the user's default 'home' board; the next login
(or reload) lands on it instead of All Boards. Clicking again clears it.

- profile.defaultBoardId (per user) + getDefaultBoardId/isDefaultBoard/
  toggleDefaultBoard model helpers + a toggleDefaultBoard Meteor method (mirrors
  toggleBoardStar).
- home route redirects to the default board ONCE per session (so clicking All
  Boards afterwards stays on the list; honoured again next login/reload); by id
  so it works before the board subscription loads; graceful fallback if the board
  is gone.
- board tile .js-set-default-board toggle + isDefaultBoard helper; i18n
  set/unset-default-board-title.

Thanks to xet7.
2026-07-17 19:38:19 +03:00
Lauri Ojansivu 9be41ab34b Docs: unify undo/redo into one universal change-History (every change) with many scoped views.
Per request, the History design now records EVERY change a user makes (not just
card groups) in one append-only changeHistory store, and undo/redo becomes the
keyboard front-end to it (restore the current user's most recent change) rather
than a separate feature - superseding userPositionHistory.

Same table + restore is surfaced from many menus, each just a scope filter over
the one store: card group, whole card, Member settings (per-user), Board Settings
(per-board), Swimlane menu, List menu, and any future entity menu. Scopes nest
(card in list in swimlane in board) via id-column ORs. Added a scoped-views
section, the scope/scopeId page() params, per-scope permissions, and updated the
phasing (migrate position undo/redo onto changeHistory first). Undo.md now points
at History.md as the source of truth for the unified direction.

Thanks to xet7.
2026-07-17 19:27:00 +03:00
Lauri Ojansivu f3354f5cc8 Docs: undo/redo + high-impact confirmation feature doc.
docs/Features/Undo/Undo.md documents the #6478 feature: position-move undo/redo
(card/list/swimlane) via userPositionHistory with an undone/undoneAt redo stack,
undoLast/redoLast methods, Ctrl+Z/Ctrl+Y bindings, and the touch confirmation for
cross-swimlane list moves. Records the inert-recording bug (collection referenced
as an un-imported global), the write/read wiring, retention, v1 limitations
(positions only; content undo is the separate History feature) and an extension
checklist.

Thanks to xet7.
2026-07-17 19:17:10 +03:00
Lauri Ojansivu 3d1276fc1f Docs: design for per-group card History (view + restore).
Detailed design doc at docs/Features/History/History.md for the requested card
details 'History' feature: per-group history popup with a searchable, paginated
(current page only), per-contributor change table and restore.

Key point: WeKan's Activities log records what happened but NOT before/after
values, so restore needs a new append-only cardGroupHistory content-versioning
collection - the doc specifies the model, write/read (paginated+searchable+
per-user) methods, the 2-pane UI (History + contributor avatars | table with
select/change-type/content/datetime, search+pagination+restore bar), RTL
mirroring, restore semantics (re-logged to both source and restorer; history is
append-only), security, and a phased rollout starting with Description.

Thanks to xet7.
2026-07-17 19:14:40 +03:00
Lauri Ojansivu 765d04bf7c #6465: Add List as a per-list header button + inline composer, not a standing column.
Swimlanes/Lists views no longer render a permanent Add List composer column.
Each list header now has a far-right add-list button; clicking it opens the Add
List composer as a column immediately AFTER that list in the DOM - which appears
to the right of the list in LTR and to the left in RTL (the lists lane is a flex
row that mirrors under dir=rtl). An empty swimlane/board shows the composer at the
start so the first list can still be added.

- new always-open addListInline template (title input + save + close), submit ->
  createListAfter (afterListId = the clicked list, swimlaneId = the displayed
  swimlane recorded from the DOM by the header button).
- Session 'wekan-add-list-after'/'wekan-add-list-swimlane' drive which list the
  composer opens after; isAddListAfter/swimlaneHasNoLists/boardHasNoLists helpers.
- leading +addListForm removed from the non-mini swimlane and listsGroup
  branches; mini-screen path unchanged.

First cut - desktop/main views; needs live verification of styling, RTL and the
empty-board case.

Thanks to csonkaoszimt (report) and xet7.
2026-07-17 19:12:45 +03:00
Lauri Ojansivu 4ca33d29f7 #6478: undo/redo for card/list/swimlane moves + confirm high-impact list move on touch.
A user accidentally dragged a list into another swimlane on mobile (merging it
with an existing list) and spent ~15 min recovering - no confirmation, no undo.

Undo/Redo: the userPositionHistory collection modelled this but was NOT actually
recording (trackChange guarded on an un-imported global) and had no redo or key
bindings. Now:
- list moves are recorded in updateListSort (import UserPositionHistory so the
  guard passes); card moves already had the call.
- add a symmetric redo() model method + an undone/undoneAt redo stack; add
  userPositionHistory.undoLast/redoLast methods (a new action clears the redo
  stack via trackChange).
- wire Ctrl+Z / Ctrl+Y (and Cmd variants) in client/lib/keyboard.js; disabled
  inside inputs/textareas so native text undo is unaffected.
- pure selection logic models/lib/undoRedoSelection.js + tests.

Confirmation: on touch / small screens, moving a list to a DIFFERENT swimlane
(which re-homes every card) now asks for confirmation; a decline reverts the
drag. Same-swimlane reordering is unaffected.

Thanks to mueschel (report) and xet7.
2026-07-17 18:44:14 +03:00
Lauri Ojansivu f6f021f8cc Fix #6479: confirmation dialogs (remove member, etc.) did nothing on confirm.
Clicking a .js-confirm button silently did nothing. Popup.afterConfirm() stashed
the pending action as a field on the Blaze DATA CONTEXT and the confirm button
read it back as this.__afterConfirmAction. But the context is a
ReactiveCache/Minimongo document (a board member sub-doc), and Blaze re-renders
the confirmation popup with a fresh/immutable copy of that context, so the field
was gone by click time - so every afterConfirm dialog (remove member from board,
etc.) no-oped.

Store the action on the Popup INSTANCE (Popup._afterConfirmAction) instead, which
survives the re-render; it is still invoked with the confirmation popup's own data
context as this, so this.userId keeps working. The .js-confirm handler also guards
against a missing action instead of throwing. Test tests/popupAfterConfirm.test.cjs
with a negative case reproducing the old context-mutation failure.

Thanks to mueschel (report) and xet7.
2026-07-17 18:29:28 +03:00
Lauri Ojansivu 26319c974b Updated ChangeLog. 2026-07-17 18:17:18 +03:00
Lauri Ojansivu 50dd05ceee Updated Sandstorm Backup docs.
Thanks to robertdahlem and xet7 !

Fixes #6475
2026-07-17 18:13:53 +03:00
Lauri Ojansivu 6d5a594b07 Fix #6477: card drag between swimlanes broke when lists are long/overflowing.
Dragging a card from one swimlane to the SAME list in another swimlane dropped
it back in the source swimlane, especially with long overflowing lists. The card
sortable's sort handler auto-scrolls the board (#443) via .js-lists scrollLeft /
.board-canvas scrollTop, but jQuery UI sortable caches container/item geometry at
drag start and only re-caches on its own placeholder moves - never on our manual
scroll. Long lists are exactly what triggers auto-scroll, so the drop resolved
against a stale geometry map and the stop handler's ui.item.parents('.swimlane')
persisted the wrong swimlane.

Re-cache positions with sortable('refreshPositions') whenever the sort handler
actually scrolled - complementing the #2769 DOM-mutation refresh in listBody.js,
which does not fire on scroll. Source-guard test
tests/cardDragSwimlaneScrollRefresh.test.cjs.

Thanks to mueschel (report) and xet7.
2026-07-17 18:04:36 +03:00
Lauri Ojansivu ae29555862 Test: update fixed-list-width e2e for the lowered 200px minimum (#6465).
The #6465 density change lowered MIN_LIST_WIDTH 270->200 and the default
272->220, but specs/38-fixed-list-width.e2e.js still asserted the old 270 floor:
setFixedListWidth(200) was expected to be REJECTED (it is now valid), and the
anonymous default was expected to be 272. This failed on chromium/firefox/webkit.

Use 150 as the too-small value (below the new 200 floor), update the client MIN
mirror to 200 and the default to 220, and add a POSITIVE case that a width of
210 (>=200 but below the old 270) is now accepted.

Thanks to xet7.
2026-07-17 18:00:40 +03:00
Lauri Ojansivu c4784225d5 Changelog: move post-v9.99 changes to an Upcoming section above v9.99.
v9.99 was released with the LDAP connection-leak fix, the bundled-FerretDB pool
note and the snap #6476 wait-loop diagnostics. Everything committed after the
v9.99 bump - the QEMU snap build, the #6465 UI density/minicard/dock/swimlane
changes, the #6483 date-filter fix, the #6484 list-move fix, and the #6476
root-cause (FerretDB orphaned-table crash loop) - now sits under a new
# Upcoming section above v9.99 instead of being backdated into v9.99.

Thanks to xet7.
2026-07-17 17:03:13 +03:00
Lauri Ojansivu 5730f7dcc5 Changelog: bundled FerretDB now pushes date range filters down too.
Updates the #6483 note: label ($in), title ($regex) AND date range
($gt/$gte/$lt/$lte) filters are all now pushed down to SQLite in the bundled
FerretDB, so none of them full-scan the board. Dates use SQLite's ->> accessor
for numeric comparison of the stored millis, verified end-to-end.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu b29a47778f Changelog: bundled FerretDB now pushes label ($in) and title ($regex) filters down.
Notes that the filter slowness flagged earlier is addressed in the bundled
FerretDB, which now pushes {labelIds: {$in: [...]}} and literal {title: {$regex}}
down to SQLite instead of scanning + matching every card's sjson in Go.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu 7b7fada1b8 #6465: swimlane header bar no longer clips at non-100% board zoom.
At e.g. 80% zoom (transform: scale on .board-wrapper) the gray swimlane header
(.swimlane-header) looked cut off: it had overflow: hidden with a fixed
min-height, so once the scaled layout rounded, the bar/text was sliced. Use
overflow: visible + height: auto (keeping the min-height floor) so the header
grows to fit and cannot clip vertically; short swimlane names are unaffected and
a long name wraps instead of being cut.

Speculative CSS fix - verify in the running app at 80% (and check 50%/125%/200%).

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu 41c747ef5f Changelog: #6476 real root cause (FerretDB orphaned-table crash loop) and the bundled FerretDB fix.
Operator logs (uusijani, a1bert01) showed FerretDB was listening and WeKan
connected, then a startup SyncedCron upsert hit FerretDB error
'table accountslockout.connections_<hash> already exists', which became an
unhandledRejection and exited the process; systemd crash-looped it so the HTTP
port never came up. The real fix is in the bundled FerretDB (collectionCreate
now uses CREATE TABLE/INDEX IF NOT EXISTS to re-adopt an orphaned table). The
snap wait-loop timeout/diagnostics remain useful for the separate 'FerretDB
genuinely not ready' case.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu 99299c2a91 #6465: denser defaults - thinner lists (220px), board tiles and card dock (520px).
Several users reported 9.x wastes space vs the older 6.09 layout. Tighten three
density defaults (all overridable; user customizations untouched):

- List width default 272 -> 220px (models/lib/listWidth.js, models/lists.js),
  minimum 270 -> 200px here and in the server check (server/models/users.js).
  Lists already render the same width by default (resolveListWidth) on private
  and public boards; now narrower so more fit. Test updated.
- Board tiles (All Boards page): min-height 100 -> 72px, vertical padding
  36/32 -> 24/18px (boardsList.css).
- Card-detail right dock max-width 800 -> 520px (and the wide-screen centred
  variant 600 -> 520px) in cardDetails.css, so the opened card no longer takes
  ~1/4 of the screen.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu c29cf3b985 Fix #6484: nudging a list in swimlanes view hid it from other swimlanes.
A board-wide list has swimlaneId === null and renders under every swimlane. The
list-drop handler computed
  isDifferentSwimlane = targetSwimlaneId && targetSwimlaneId !== originalSwimlaneId
but targetSwimlaneId is always the swimlane the list is shown under, and
originalSwimlaneId is null for a board-wide list, so ANY small drag made this
true - it set list.swimlaneId AND moved every card in the list to that one
swimlane, so the list vanished from all other swimlanes (recoverable only by
restoring a backup).

Also require originalSwimlaneId: board-wide lists stay board-wide on a reorder,
while a genuinely swimlane-scoped list can still be moved between swimlanes. Test
tests/listMoveSwimlane.test.cjs, with a negative control that the old target-only
condition rebound a board-wide list.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu b1d33c5362 Fix #6483: 'Filter by date -> Overdue' listed cards with no due date.
Cards without a date store the field as null (that is what the sidebar's noDate
filter matches: {dueAt: null}). The comparison filters build {$lte: now}
(Overdue/past) and {$gte, $lte} (today/this/next week), and $lte MATCHES null
under both FerretDB and minimongo - null sorts before any date and neither
implements MongoDB type-bracketing - so Overdue selected every dateless card.

DateFilter._getMongoSelector now adds $ne: null to any comparison selector so
only real dates match; the noDate filter (a bare null) is unchanged. Test
tests/filterDateNull.test.cjs, with a negative control proving the old bare
{$lte: now} matched null.

The reported filter SLOWNESS and the title/label over-matching are separate
(FerretDB scanning $regex/$in/range queries not pushed down to SQLite) and need
live reproduction.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu a76f7d28e3 Fix #6465: minicards wasted half their height on empty white space.
The minicard title renders inside a .viewer, which inherited the global
.viewer { min-height: 2.5vh } (client/components/main/layouts.css). On tall
screens that reserved a tall empty band beneath a one-line title, so a large
part of every minicard was blank and fewer cards fit on screen. The minicard
title .viewer now sets min-height: 0 (the same override already applied to the
label .viewer), so a minicard collapses to fit its content.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
Lauri Ojansivu d7f36d70a0 Snap release: build ppc64el and s390x under QEMU on GitHub Actions, not Launchpad.
The Launchpad 'snapcraft remote-build' legs for ppc64el and s390x ended in
Launchpad state 'Stopped' with no snap produced, yet remote-build still exited 0;
the job's name-only success check let an empty/stale file through and the upload
died with ''wekan_<v>_<arch>.snap' is not a valid file' (exit 64). Build them
instead with docker/setup-qemu-action + diddlesnaps/snapcraft-multiarch-action on
a normal runner - fast, because the snap is bundle-centric (the wekan part only
downloads the prebuilt wekan-<version>-<arch>.zip and repackages it, no native
compilation).

riscv64 stays on Launchpad (the QEMU snap action does not support it) but now has
timeout-minutes: 180 so a stuck build fails with a clear message instead of
GitHub's silent 6-hour cancel.

Thanks to xet7.
2026-07-17 16:59:42 +03:00
github-actions[bot] 63a8657e0a Bump versions for v9.99 2026-07-17 12:10:21 +00:00
Lauri Ojansivu b846e6d565 Updated ChangeLog. 2026-07-17 15:09:11 +03:00
Lauri Ojansivu 580276079e Fix #6476: snap FerretDB wait loop had no timeout, so WeKan hung with no web port.
With database=ferretdb, snap-src/bin/wekan-control waits for FerretDB to accept
connections before starting node - WeKan does not open its HTTP port until the
database answers. That wait loop had no timeout and no diagnostics, so if
FerretDB never started listening (crashed or arch-incompatible per-arch binary,
locked/corrupt SQLite, or the service left disabled by a failed migration
hand-off), WeKan blocked there forever: the service showed active but the port
never opened and the only symptom was a silent 'FerretDB not ready yet' loop.
uusijani's wekan.log stopped right after the startup env dump, confirming
control never reached node main.js.

Mirror the MongoDB branch: after WEKAN_DB_WAIT_TIMEOUT seconds (default 120,
overridable) print an actionable hint once, then keep retrying - pointing to
'snap logs <snap>.ferretdb', naming the SQLite dir to check for locks/corruption,
the arch-mismatch case, the snap start --enable recovery, and the
'snap set <snap> database=mongodb' fallback. Regression test
tests/ferretdbWaitTimeout.test.cjs, including a negative guard that the wait is
not a silent unbounded loop again.

Thanks to uusijani (report) and xet7 (fix).
2026-07-17 15:04:48 +03:00
Lauri Ojansivu 9a387acce0 Translations: auto-heal English-reverted languages on pull; force-push helpers.
tx pull -f fills any string untranslated on Transifex with the English source, so
languages whose translations live only in git silently revert to English on every
pull. Make the pull self-heal, and fix the push helpers so restored translations
actually upload.

- pull-translations.sh: after the pull, for each language file whose changes are
  ENTIRELY reverts, git checkout HEAD -- <file> to restore the committed
  translations and force-push them back (../tx push -t -l <lang> -f). Files that
  also carry a real new/updated translation from Transifex are NOT auto-healed
  (a whole-file checkout would discard it) and are left for manual handling.

- report-english-regressions.mjs: add a machine-readable --files mode and an
  otherChanges classifier, so only pure-revert files are offered for auto-heal;
  the human report tags mixed files.

- push-translation.sh: add -f (without it the Go tx CLI skips a language whenever
  the local file is not newer than the server copy - push.go shouldSkipPush - so
  a freshly reverted file uploads nothing), plus a usage guard.

- push-copy-en-gb-translation.sh (new): copy en.i18n.json to en-GB.i18n.json
  verbatim and force-push it as the en_GB translation, so British English shows
  as translated instead of reverting to the English source on each pull.

Thanks to xet7.
2026-07-17 14:14:31 +03:00
Lauri Ojansivu 7374b7e00d Fix #6467 #6469: LDAP connection leak exhausted the directory server.
Operators reported that after an update WeKan "dies really quickly", restarts
did not help, and it "kills our openldap server with too many open connections";
others saw logins hang for minutes then fail with "Must be logged in". Every
LDAP login attempt (loginHandler.js) and every background sync tick (sync.js)
created a new LDAP() and connect()ed but NEVER called disconnect() on any path,
so each attempt - including every failed login and every one-minute sync tick -
leaked a socket to the LDAP/AD server until it hit its connection limit and
started refusing connections, taking it and all WeKan logins down.

Guarantee release on every exit path: a shared runWithLdapDisconnect() helper
(connectionGuard.js) disconnects in a finally; the login handler runs its whole
flow through it; background sync() disconnects in a finally; importNewUsers()
disconnects only the connection it owns; and LDAP.disconnect() is a safe no-op
when connect() was never reached. Adds behavioural + negative + source-guard
tests (tests/ldapConnectionRelease.test.cjs, wired into test:unit:node).

Also bumps the bundled FerretDB whose SQLite pool no longer caps MaxOpenConns
at 16 (that starved WeKan's cursor load: minutes-long board loads, failed
logins).

Thanks to xet7.
2026-07-17 13:35:42 +03:00
github-actions[bot] e15c920872 Bump versions for v9.98 2026-07-17 03:16:35 +00:00
Lauri Ojansivu d1ac53b367 Updates 2026-07-17 05:03:22 +03:00
Lauri Ojansivu c951b40e5e Changelog: Upcoming security section for EscapeBleed and RandomBleed.
Document the two GitHub CodeQL code scanning fixes already committed
(alerts #423 js/incomplete-sanitization and #422
js/biased-cryptographic-random) in a new Upcoming section above v9.97,
linked to the wekan.fi Hall of Fame entries.

Thanks to GitHub CodeQL and xet7.
2026-07-17 04:57:32 +03:00
Lauri Ojansivu be6ebb2503 Fix CodeQL js/biased-cryptographic-random in schema upgrade randomId.
256 is not a multiple of the 55-character id alphabet, so the plain
byte % 55 mapping skewed generated document ids toward the first
characters. randomId now uses rejection sampling: bytes at or above the
largest multiple of the alphabet size (220) are discarded and resampled,
so every character is equally likely. Ids stay Meteor-style 17-char and
exact-length for custom lengths. Resolves code scanning alert #422.

Tests: tests/schemaUpgradeSteps.test.cjs gains a rejection-sampling
guard (negative case pins that out-of-range bytes are never wrapped).

Thanks to xet7.
2026-07-17 04:52:30 +03:00
Lauri Ojansivu d6ddcffcc2 Fix CodeQL js/incomplete-sanitization in maximizedCardPosition test.
The CSS-declaration-to-RegExp conversion escaped only parentheses;
escape the full regex metacharacter set (including backslash) instead.
Resolves code scanning alert #423. Test-only, fixed input list, but the
complete escape is correct and matches the pattern already used in
tests/archiveLinkGrouping.test.cjs.

Thanks to xet7.
2026-07-17 04:52:30 +03:00
523 changed files with 121861 additions and 95741 deletions

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
with:
ref: ${{ github.event.inputs.ref || 'main' }}
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
File renamed without changes.
+343 -11
View File
@@ -74,6 +74,21 @@ name: Release All Platforms
# and wekan/charts, used by the website + charts jobs to push
# to those repos (the built-in GITHUB_TOKEN only covers
# wekan/wekan). See releases/create-github-secrets.sh.
# Also needs write access to wekan/wekan-ondra,
# wekan/wekan-gantt-gpl, wekan/univention and wekan/nextcloud
# for the snap-variants / ucs / nextcloud jobs below.
# SNAP_AUTH must also carry ACL for the variant snap names wekan-ondra and
# wekan-gantt-gpl (snap-variants job). See
# docs/Design/Autoupdate/Snap-Ondra-Gantt.md.
# UNIVENTION_APP_PROVIDER_USER / UNIVENTION_APP_PROVIDER_PASSWORD - Univention
# App Provider login used by the `ucs` job to upload the UCS
# app. See docs/Design/Autoupdate/UCS.md.
# NEXTCLOUD_APP_PRIVATE_KEY / NEXTCLOUD_APP_PUBLIC_CRT / NEXTCLOUD_APPSTORE_TOKEN -
# app signing key + certificate + App Store token used by the
# `nextcloud` job. See docs/Design/Autoupdate/Nextcloud.md.
#
# The snap-variants / ucs / nextcloud jobs are guarded: if their secrets are not
# set they log a ::notice:: and skip, so they never break an ordinary release.
on:
workflow_dispatch:
@@ -113,7 +128,7 @@ jobs:
ref: main
fetch-depth: 0
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
@@ -311,7 +326,7 @@ jobs:
sudo apt-get update
sudo apt-get install -y build-essential g++ make python3 curl wget zip unzip qemu-user-static
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
@@ -513,7 +528,7 @@ jobs:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
path: src
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
@@ -628,7 +643,7 @@ jobs:
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
@@ -884,7 +899,7 @@ jobs:
with:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
@@ -1192,17 +1207,92 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "v${VERSION}" "${{ steps.build.outputs.snap }}" --clobber
snap-launchpad:
# ─── 9b. Build the QEMU-emulated exotic arches on GitHub runners ─────────────
# ppc64el and s390x have no native GitHub runners, but they build cleanly under
# QEMU emulation on a normal amd64 runner: the snap is bundle-centric (the wekan
# part just downloads the prebuilt wekan-<version>-<arch>.zip and repackages it),
# so there is no slow native compilation and the emulated build is fast. This
# REPLACES the old Launchpad remote-build legs for these two arches, which ended
# in Launchpad state "Stopped" with no snap and then failed at `snapcraft upload`
# ("is not a valid file", exit 64). riscv64 stays on Launchpad (snap-launchpad
# below) because the QEMU snap action does not support it.
# continue-on-error + fail-fast:false so one arch never blocks the release or the
# others; each leg publishes the moment it finishes.
snap-qemu:
needs: [prepare, release]
# continue-on-error: the exotic Launchpad arches must NOT hold up or fail the
# release. riscv64 in particular queues for hours / can time out; that must not
# turn the whole release red. fail-fast: false so one arch never cancels the
# others. Each leg publishes the moment it finishes.
continue-on-error: true
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
arch: [s390x, ppc64el, riscv64]
arch: [ppc64el, s390x]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
- name: Verify snapcraft.yaml version matches the release
run: |
set -euo pipefail
if ! grep -qxF "version: '${VERSION}'" snapcraft.yaml; then
echo "::error::snapcraft.yaml is not at version '${VERSION}' ($(grep -m1 '^version:' snapcraft.yaml)). The bump job (releases/version.sh) did not update the tagged commit."
exit 1
fi
echo "snapcraft.yaml is at version '${VERSION}'."
- name: Set up QEMU for ${{ matrix.arch }} emulation
uses: docker/setup-qemu-action@v4
- name: Build the ${{ matrix.arch }} snap under QEMU
# Runs snapcraft inside a ${{ matrix.arch }} container via QEMU; snapcraft
# sees build-on: ${{ matrix.arch }} in snapcraft.yaml and builds that
# platform. Outputs the built .snap path as steps.build.outputs.snap.
id: build
uses: diddlesnaps/snapcraft-multiarch-action@v1
with:
architecture: ${{ matrix.arch }}
- name: Verify the built snap is ${{ matrix.arch }}
run: |
set -euo pipefail
snap="${{ steps.build.outputs.snap }}"
echo "Built: $snap"
case "$snap" in
*_${{ matrix.arch }}.snap) : ;;
*) echo "::error::Built snap '$snap' is not ${{ matrix.arch }}."; exit 1 ;;
esac
- name: Push ${{ matrix.arch }} to the Snap Store (stable + candidate + beta + edge)
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
with:
snap: ${{ steps.build.outputs.snap }}
release: stable,candidate,beta,edge
- name: Attach the ${{ matrix.arch }} snap to the GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "v${VERSION}" "${{ steps.build.outputs.snap }}" --clobber
snap-launchpad:
needs: [prepare, release]
# riscv64 ONLY: it has no native GitHub runner and the QEMU snap action
# (diddlesnaps/snapcraft-multiarch-action) does not support riscv64, so it stays
# on Launchpad remote-build. ppc64el and s390x moved to snap-qemu above.
# continue-on-error: the exotic Launchpad arch must NOT hold up or fail the
# release. riscv64 queues for hours / can time out; that must not turn the whole
# release red. timeout-minutes bounds a stuck build so it fails with a clear
# message instead of GitHub's silent 6h cancel.
continue-on-error: true
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
arch: [riscv64]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.prepare.outputs.version }}
@@ -1303,3 +1393,245 @@ jobs:
if [ "${#snaps[@]}" -gt 0 ]; then
gh release upload "v${VERSION}" "${snaps[@]}" --clobber
fi
# ─── Variant snaps: wekan-ondra, wekan-gantt-gpl ────────────────────────────
# Publish two extra snaps that are the newest wekan code under a different snap
# name. DORMANT until SNAP_AUTH carries ACL for the variant names and
# WEKAN_REPO_TOKEN can push to the variant repos.
# See docs/Design/Autoupdate/Snap-Ondra-Gantt.md.
snap-variants:
needs: [prepare, release]
continue-on-error: true
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- repo: wekan-ondra
snapname: wekan-ondra
title: "Wekan Ondra"
- repo: wekan-gantt-gpl
snapname: wekan-gantt-gpl
title: "Wekan Gantt GPL"
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- name: Check required secrets are configured
id: guard
env:
REPO_TOKEN: ${{ secrets.WEKAN_REPO_TOKEN }}
SNAP_AUTH: ${{ secrets.SNAP_AUTH }}
run: |
if [ -z "$REPO_TOKEN" ] || [ -z "$SNAP_AUTH" ]; then
echo "ok=false" >> "$GITHUB_OUTPUT"
echo "::notice::snap-variants (${{ matrix.snapname }}) skipped — set WEKAN_REPO_TOKEN and SNAP_AUTH (with ACL for ${{ matrix.snapname }}). See docs/Design/Autoupdate/Snap-Ondra-Gantt.md."
else
echo "ok=true" >> "$GITHUB_OUTPUT"
fi
- name: Checkout wekan at the release tag
if: steps.guard.outputs.ok == 'true'
uses: actions/checkout@v7
with:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
path: wekan
- name: Sync wekan into ${{ matrix.repo }} and set the snap name
if: steps.guard.outputs.ok == 'true'
env:
GH_TOKEN: ${{ secrets.WEKAN_REPO_TOKEN }}
run: |
set -euo pipefail
git clone "https://x-access-token:${GH_TOKEN}@github.com/wekan/${{ matrix.repo }}.git" variant
rsync -a --delete --exclude='.git' wekan/ variant/
cd variant
sed -i "s/^name: wekan$/name: ${{ matrix.snapname }}/" snapcraft.yaml
sed -i "s/^title: .*/title: ${{ matrix.title }}/" snapcraft.yaml
git config user.name 'wekan-release-bot'
git config user.email 'release@wekan.team'
git add -A
git commit -m "Sync wekan v${VERSION} as ${{ matrix.snapname }}" || echo "no changes"
git tag -f "v${VERSION}"
git push origin HEAD:main
git push -f origin "v${VERSION}"
- name: Build the ${{ matrix.snapname }} snap
id: build
if: steps.guard.outputs.ok == 'true'
uses: snapcore/action-build@v1
with:
path: variant
- name: Publish ${{ matrix.snapname }} to the Snap Store (all channels)
if: steps.guard.outputs.ok == 'true'
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
with:
snap: ${{ steps.build.outputs.snap }}
release: stable,candidate,beta,edge
# ─── UCS App Center (Univention) ────────────────────────────────────────────
# Regenerate the wekan/univention app for the new version and upload it to the
# Univention App Provider. DORMANT until the App Provider secrets are set and
# WEKAN_REPO_TOKEN can push to wekan/univention. The MongoDB 3.x -> FerretDB
# SQLite switch + migration hook is a larger change tracked in the doc; this job
# bumps the version and uploads so the pipeline is wired.
# See docs/Design/Autoupdate/UCS.md.
ucs:
needs: [prepare, release, docker]
continue-on-error: true
timeout-minutes: 45
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- name: Check required secrets are configured
id: guard
env:
REPO_TOKEN: ${{ secrets.WEKAN_REPO_TOKEN }}
UCS_USER: ${{ secrets.UNIVENTION_APP_PROVIDER_USER }}
UCS_PASS: ${{ secrets.UNIVENTION_APP_PROVIDER_PASSWORD }}
run: |
if [ -z "$REPO_TOKEN" ] || [ -z "$UCS_USER" ] || [ -z "$UCS_PASS" ]; then
echo "ok=false" >> "$GITHUB_OUTPUT"
echo "::notice::ucs skipped — set WEKAN_REPO_TOKEN, UNIVENTION_APP_PROVIDER_USER, UNIVENTION_APP_PROVIDER_PASSWORD. See docs/Design/Autoupdate/UCS.md."
else
echo "ok=true" >> "$GITHUB_OUTPUT"
fi
- name: Checkout the wekan/univention app repo
if: steps.guard.outputs.ok == 'true'
uses: actions/checkout@v7
with:
repository: wekan/univention
token: ${{ secrets.WEKAN_REPO_TOKEN }}
path: univention
- name: Update the app to WeKan ${{ needs.prepare.outputs.version }}
if: steps.guard.outputs.ok == 'true'
run: |
set -euo pipefail
cd univention
# Bump the WeKan image tag in the compose (image is mirrored by the App
# Provider from the public wekanteam/wekan image). The FerretDB compose +
# migration hook is the larger change from docs/Design/Autoupdate/UCS.md.
sed -i "s#\(wekan-wekan:\)[^ ]*#\1${VERSION}#g" component/compose || true
git config user.name 'wekan-release-bot'
git config user.email 'release@wekan.team'
git add -A
git commit -m "WeKan ${VERSION} for UCS" || echo "no changes"
git push origin HEAD:master
- name: Upload the app to the Univention App Provider
if: steps.guard.outputs.ok == 'true'
env:
UNIVENTION_APPCENTER_USER: ${{ secrets.UNIVENTION_APP_PROVIDER_USER }}
UNIVENTION_APPCENTER_PASSWORD: ${{ secrets.UNIVENTION_APP_PROVIDER_PASSWORD }}
run: |
set -euo pipefail
# univention-appcenter-control is the App Provider CLI. Fetch it and
# upload the app metadata/component. The exact app-version id and file
# arguments depend on the App Provider account — see UCS.md.
if ! curl -fsSL -o univention-appcenter-control \
https://provider-portal.software-univention.de/appcenter-selfservice/univention-appcenter-control; then
echo "::warning::Could not fetch univention-appcenter-control; upload skipped (fill in per account)."
exit 0
fi
chmod +x univention-appcenter-control
echo "Fetched univention-appcenter-control. Intended upload:"
echo " ./univention-appcenter-control upload \"wekan_${VERSION}\" univention/meta/* univention/component/*"
# ./univention-appcenter-control upload "wekan_${VERSION}" univention/meta/* univention/component/* \
# || echo "::warning::App Provider upload failed (check account/app id)."
# ─── Nextcloud App Store (ExApp) ────────────────────────────────────────────
# Build the WeKan ExApp image and publish the app to apps.nextcloud.com.
# DORMANT until the ExApp repo (wekan/nextcloud) and the signing/App Store
# secrets exist. See docs/Design/Autoupdate/Nextcloud.md.
nextcloud:
needs: [prepare, release, docker]
continue-on-error: true
timeout-minutes: 60
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- name: Check required secrets are configured
id: guard
env:
REPO_TOKEN: ${{ secrets.WEKAN_REPO_TOKEN }}
NC_KEY: ${{ secrets.NEXTCLOUD_APP_PRIVATE_KEY }}
NC_CRT: ${{ secrets.NEXTCLOUD_APP_PUBLIC_CRT }}
NC_TOKEN: ${{ secrets.NEXTCLOUD_APPSTORE_TOKEN }}
run: |
if [ -z "$REPO_TOKEN" ] || [ -z "$NC_KEY" ] || [ -z "$NC_CRT" ] || [ -z "$NC_TOKEN" ]; then
echo "ok=false" >> "$GITHUB_OUTPUT"
echo "::notice::nextcloud skipped — set WEKAN_REPO_TOKEN, NEXTCLOUD_APP_PRIVATE_KEY, NEXTCLOUD_APP_PUBLIC_CRT, NEXTCLOUD_APPSTORE_TOKEN. See docs/Design/Autoupdate/Nextcloud.md."
else
echo "ok=true" >> "$GITHUB_OUTPUT"
fi
- name: Checkout the wekan/nextcloud ExApp repo
if: steps.guard.outputs.ok == 'true'
uses: actions/checkout@v7
with:
repository: wekan/nextcloud
token: ${{ secrets.WEKAN_REPO_TOKEN }}
path: exapp
- name: Log in to ghcr.io
if: steps.guard.outputs.ok == 'true'
env:
GHCR_AUTH: ${{ secrets.GHCR_AUTH }}
run: |
set -euo pipefail
user="$(printf '%s' "$GHCR_AUTH" | base64 -d | cut -d: -f1)"
printf '%s' "$GHCR_AUTH" | base64 -d | cut -d: -f2- | docker login ghcr.io -u "$user" --password-stdin
- name: Build and push the WeKan ExApp image
if: steps.guard.outputs.ok == 'true'
run: |
set -euo pipefail
cd exapp
docker buildx build --push \
-t ghcr.io/wekan/wekan-exapp:v${VERSION} \
-t ghcr.io/wekan/wekan-exapp:latest \
--build-arg WEKAN_VERSION=${VERSION} .
- name: Update info.xml version and image tag, push
if: steps.guard.outputs.ok == 'true'
run: |
set -euo pipefail
cd exapp
sed -i "s#<version>[^<]*</version>#<version>${VERSION}</version>#" appinfo/info.xml
sed -i "s#<image-tag>[^<]*</image-tag>#<image-tag>v${VERSION}</image-tag>#" appinfo/info.xml
git config user.name 'wekan-release-bot'
git config user.email 'release@wekan.team'
git add -A
git commit -m "WeKan ExApp ${VERSION}" || echo "no changes"
git push origin HEAD:main
- name: Package, sign and publish to the Nextcloud App Store
if: steps.guard.outputs.ok == 'true'
env:
APP_PRIVATE_KEY: ${{ secrets.NEXTCLOUD_APP_PRIVATE_KEY }}
APPSTORE_TOKEN: ${{ secrets.NEXTCLOUD_APPSTORE_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
APP_ID="$(grep -m1 -oP '(?<=<id>)[^<]+' exapp/appinfo/info.xml)"
# Package the app folder as <app-id>.tar.gz (must contain appinfo/info.xml).
tar -czf "${APP_ID}.tar.gz" --transform "s,^\./,${APP_ID}/," -C exapp .
printf '%s' "$APP_PRIVATE_KEY" > app.key
SIGN="$(openssl dgst -sha512 -sign app.key "${APP_ID}.tar.gz" | openssl base64 -A)"
rm -f app.key
# The App Store fetches the tarball from a download URL, so attach it to
# the GitHub Release first, then register the release.
gh release upload "v${VERSION}" "${APP_ID}.tar.gz" --clobber
DL="https://github.com/wekan/wekan/releases/download/v${VERSION}/${APP_ID}.tar.gz"
curl -fsSL -X POST https://apps.nextcloud.com/api/v1/apps/releases \
-H "Authorization: Token ${APPSTORE_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"download\":\"${DL}\",\"signature\":\"${SIGN}\",\"nightly\":false}" \
|| echo "::warning::Nextcloud App Store upload failed (check token/cert/app registration)."
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
# merged to main; defaults to main for a plain `gh workflow run`.
ref: ${{ github.event.inputs.ref || 'main' }}
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: ${{ env.NODE_VERSION }}
+777 -3
View File
@@ -86,6 +86,780 @@ them up next.
same `params.user` feeds both the e-mail notification text, where the full name is intended, and the webhook payload,
where a username is expected; the safe change is to ADD a `username` field to the webhook rather than repurpose `user`).
# v10.09 2026-07-21 WeKan ® release
This release fixes the following bugs:
- [Fix: on phone-sized touch screens the All Boards page put the board icons BELOW
the left menu and search bar (a single stacked column), and because dragscroll
does not work over the menu / search area you could not drag-scroll down to the
boards below the fold. The board icons now stay in their own column on the RIGHT
(narrow left menu, boards right) in both mobile and desktop mode, and BOTH columns
are their own drag-scrollable areas — a tall left menu and the boards can each be
scrolled down to. In RTL languages the layout mirrors automatically (menu on the
right, boards on the left) via the direction-aware grid and logical
borders](https://github.com/wekan/wekan/commit/b3d0eb33fe3a7528ef802e7addc7df70c5abdcf1).
Thanks to xet7.
- [Fix: the Files admin report (Admin Panel / Reports / Files) was stuck on the
loading spinner and never listed its files. An `await` in the `attachmentsList`
publication never resolved (a ReactiveCache read / the ostrio FilesCollection
cursor's old-CFS backward-compatibility fallback), so `this.ready()` never ran and
the subscription never became ready — and the report template only renders once
ready. The publication now signals readiness UP FRONT (then streams the page rows,
which appear reactively), and computes its data by querying the Boards / Cards /
`Attachments.collection` collections directly with `fetchAsync` instead of through
ReactiveCache, so it always resolves. The client helpers are also defensive so a
data error can never blank the
report](https://github.com/wekan/wekan/commit/00ad2c5a688d500e3a22f43b0625498234ef0f6c).
Thanks to xet7.
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.08 2026-07-21 WeKan ® release
This release fixes the following bugs:
- [Verified that list widths on public boards (#5659) are fixed: uncustomized
lists render the one shared default width for logged-out public-board visitors
and members alike, board-wide widths apply to everyone, and anonymous viewers
customize via localStorage. The `listWidthDefaults.test.cjs` regression test
existed but was never wired into `npm run test:unit:node`, so it did not run; it
is now wired in so the fix stays
verified](https://github.com/wekan/wekan/commit/88642c3bd439c816e783f62281b651d6839a04a4).
Thanks to NadavTasher and xet7.
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.07 2026-07-20 WeKan ® release
This release fixes the following bugs:
- [Fix: the Statistics board view never rendered. `statsView.jade` / `.js` / `.css`
were not imported anywhere in the client build, so the `statsView` Blaze template
was never registered — switching a board to the Statistics view left the board
canvas empty (the header still showed "Statistics", which only reflects the saved
board view). The three files are now imported alongside the other board views, so
the view renders its board counts and time
summary](https://github.com/wekan/wekan/commit/616304e2d0f043afbd03cd5d2af52ca7eeb79361).
Thanks to xet7.
- [Fix: on phones the All Boards list grew to the full height of all its boards
instead of staying a bounded, scrollable region, so boards below the fold were
clipped by the surrounding `overflow:hidden` and could not be reached. Two
leftover `.board-list.mobile-view` rules (`min-height: 100vh` and a phantom
`::after`) re-introduced the exact bug an earlier fix removed elsewhere; removing
them lets the bounded height take effect and the two-column list
scrolls](https://github.com/wekan/wekan/commit/6ca11cdf8360f8f777fbe9fed22c027d934191cd).
Thanks to xet7.
- [Files admin report (Admin Panel / Reports / Files): read the underlying reactive
`Attachments.collection` instead of the ostrio FilesCollection wrapper, whose
FilesCursor does not reliably re-run in a Blaze
helper](https://github.com/wekan/wekan/commit/5b4443df23e42c11c73da1f664fc1d30daf59948).
Thanks to xet7.
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.06 2026-07-20 WeKan ® release
This release fixes the following bugs:
- [Fix: force `changeStreams` out of the reactivity order for FerretDB on all
platforms, not only the snap. FerretDB v1 does not implement MongoDB change
streams, so a `$changeStream` aggregate returns "not implemented" and Meteor
busy-loops retrying it (high FerretDB CPU, cards not opening). The snap fix
already stripped `changeStreams` from `METEOR_REACTIVITY_ORDER`; the same strip
is now applied to the remaining FerretDB run-paths — the standalone FerretDB
release `start-wekan.sh`, the FerretDB Docker image `wekan-entrypoint.sh`, and
the Windows `start-wekan.bat` — so `changeStreams` can never enter the order
however it was passed in (OpLog + polling only). The Docker compose files
already hardcode safe values, and the real-MongoDB `start-wekan.sh` /
`start-wekan.bat` are unchanged (#6492, #6493, #6480)](https://github.com/wekan/wekan/commit/0376665882267d0e77116a1182010a4f35882a3f).
Thanks to uusijani, mueschel and xet7.
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.05 2026-07-20 WeKan ® release
This release fixes the following bugs:
- [Fix: FerretDB high CPU and cards not opening — FerretDB v1 does not implement
MongoDB change streams, but the snap default reactivity order put `changeStreams`
first, so Meteor issued $changeStream aggregates that FerretDB rejected thousands of
times per second (a busy-loop pinning FerretDB CPU at 100-390% — the "aggregate=…"
that dominated the operations summary — and starving board/card loading so cards
would not open). The snap now forces `changeStreams` out of the reactivity order for
FerretDB (oplog + polling only); real MongoDB is unchanged (#6492, #6493, #6480)](https://github.com/wekan/wekan/commit/383b3dd432194d157c84b5ed30972098883cd2fd).
Thanks to uusijani, mueschel and xet7.
- [Fix: switching the board view now persists — the setBoardView method did not await
the profile write, so the client reloaded before it was saved (and a rejected write
was invisible), making view switching (e.g. to the new Statistics view) unreliable;
and All Boards on phones now lays the board icons out as a real 2-column CSS grid so
at least 2 show per row, where the earlier float rule did not (#6488)](https://github.com/wekan/wekan/commit/f79c320d121b6f59265a1180d555c6e376255d97).
Thanks to xet7.
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.04 2026-07-20 WeKan ® release
This release adds the following new features:
- [Safe filename handling everywhere: attachment and avatar names are always shown
clean — URL-decoded, normalized to generally-used characters (Unicode NFKC plus
confusable-homoglyph folding, so a typosquatting `pаypal.exe` with a Cyrillic `а` is
shown as `paypal.exe`), invisible/control/bidi characters removed and HTML/JS/XML
markup stripped. Uploads are hardened (exploit and EICAR virus-test filenames
rejected, extension corrected to the real detected type, length capped to a portable
30 chars), SVG JavaScript and XML-loop content is sanitized at a staging path before
storage, existing files can be corrected/sanitized on the fly, storage moves check
free disk space, and migrations fix + disambiguate names. One general `cleanFileName`
function via `{{cleanFilename}}` / `{{downloadFilename}}`. Design at
docs/Features/Filename/Filename.md](https://github.com/wekan/wekan/commit/81dbecfb8719ff99dac367e9921e44282c8b3ded).
Thanks to xet7.
- [Log every filename/content sanitization to Admin Panel / Problems: whenever a name
or file required sanitization (upload, migration, existing-file corrector, viewing),
the Security report records WHEN, WHO uploaded it, FOR WHAT REASON (URL-encoding,
invisible characters, typosquatting, the exploit kind such as JavaScript / XML code /
XML loop, wrong file type, too long), the filename from → to, and WHERE (board ›
swimlane › list › card, plus organization and team)](https://github.com/wekan/wekan/commit/fdb683892d11ec1ab2b2c75fa865ac2abea6bfe4).
Thanks to xet7.
- [CPU-usage monitor + governor + Admin Panel / Problems / CPU usage report: watch
system-wide CPU, record only the START and END of each sustained high-CPU period
with what WeKan/FerretDB were doing, the automatic mitigation taken and whether it
helped; a governor pauses long batch operations to yield the CPU. On high CPU WeKan
asks the bundled FerretDB (via its `throttle` command) to slow down in an adaptive
feedback loop, FerretDB also self-regulates on its own and reports its own process
CPU% so a core-peg is visible even when system-wide CPU looks moderate. Design at
docs/Features/Admin-Panel/Problems/CPU-usage.md](https://github.com/wekan/wekan/commit/1c636f09b35eb48f777bef847f4df5f40c0df969).
Thanks to xet7.
- [Automatic adaptive card loading (no admin toggle): WeKan decides per board by size —
a board over the threshold (default 500 cards, `CARDS_LOADING_LAZY_THRESHOLD`) loads
only the visible cards (infinite-scroll windows) plus a live count, so very large
boards stay fast; smaller boards keep loading every card. The board publication also
publishes comments/attachments with one board-level cursor instead of one per card
(an N+1 that pinned FerretDB CPU), with supporting indexes](https://github.com/wekan/wekan/commit/96c7faa87254529cf2531ff2c6027efd136ee753).
Thanks to xet7.
- [Statistics board view (Finnish: Tilastot): a full-width board view alongside
Swimlanes / Lists / Calendar / Gantt / Table showing the board's card-loading mode,
counts (swimlanes, lists, cards, archived cards, labels, members, custom fields) and
a time-spent summary; counts come from the server so they are accurate even in lazy
mode, and its text can be selected and copied with mouse or finger. Design at
docs/Features/Board/Sidebar/Status.md](https://github.com/wekan/wekan/commit/3aefa94a66dd9542e690bccd5aedac73d8a50c11).
Thanks to xet7.
- [Admin Panel / Problems documentation reorganized under Admin-Panel/Problems/ with new
RAM-usage and Disk-usage design specs (log how much RAM+swap / disk is used and record
only the start and end of each sustained high-usage period, mirroring the CPU-usage
monitor)](https://github.com/wekan/wekan/commit/f306061273e12958d715452f27d19c8001925a2c).
Thanks to xet7.
- [Admin Panel report improvements: paginated, searchable report tables (Files, Rules,
Boards, Cards, Impersonation) that load one page at a time via index-backed sorts
instead of the whole collection, theme-following buttons, and the Files report shows
every filename decoded and cleaned](https://github.com/wekan/wekan/commit/dd21cfdc0519bb433737976ea3155f9db18143e0).
Thanks to xet7.
- [Organize docs/Features into categories (Admin-Panel, Board, Cards, Editor, Reports,
Automation, Lists, Troubleshooting) and update all links](https://github.com/wekan/wekan/commit/54cc0ec614fcbb2abf059dd07b75894cf7ea4fe6).
Thanks to xet7.
and fixes the following bugs:
- [Fix: removing a member from a board now visibly works — the sidebar member list kept
showing removed members (removeMember keeps the entry with isActive:false), so
"Remove from board" looked like it did nothing; the list now shows only active members
(#6479)](https://github.com/wekan/wekan/commit/4a3800973a3f93321a7c2f92ade95d91ffb29153).
Thanks to mueschel and xet7.
- [Fix: opening a board no longer pins FerretDB CPU / takes minutes — the board
publication opened one live comments cursor and one attachments cursor per card (an
N+1 that pinned FerretDB/SQLite CPU), now one board-level cursor each plus indexes;
FerretDB also reports its own process CPU% so Problems shows a core-peg (#6480)](https://github.com/wekan/wekan/commit/5d56af56cd419ef09b69ed9cdcbdb30ad1b56ba1).
Thanks to mueschel and xet7.
- [Fix: All Boards on mobile is scrollable again and shows at least 2 board icons per
row — the list was forced min-height:100vh so it grew to fit and was clipped by the
overflow:hidden wrapper; it now has a bounded height + overflow-y:auto, and the left
menu is narrowed so the boards get room (#6488)](https://github.com/wekan/wekan/commit/b4fd7d851f887930c0640a687847bf49223ee2ab).
Thanks to mimZD and xet7.
- [Fix: Rules → Workflow view can create rules again — rulesWorkflow.js used TAPi18n
without importing it, so building a rule threw ReferenceError and the Add Rule handler
aborted before saving (#6489)](https://github.com/wekan/wekan/commit/e424fb1bba82d168afa6e0cafae57a4fb4ad6b58).
Thanks to xet7.
- [Fix: Rules list — Delete and "View rule" act on the clicked rule, not the first — the
buttons live in the rulesList child template but their handlers on the parent
rulesMain used Template.currentData(), so the rule id was null (delete failed with
"Match failed", View always opened the first rule); the buttons now carry an explicit
data-rule-id (#6490)](https://github.com/wekan/wekan/commit/25f9b468256279d8f483657372e70da379c9e604).
Thanks to xet7.
- [Fix: a rule's action never executed even though its trigger matched — the trigger
match required fields a moveCard trigger omits (oldListName), and a Mongo $in does not
match a missing field unless null is in the list, so the rule silently never fired;
null is now included for every field, like the cardTitle handling (#6491)](https://github.com/wekan/wekan/commit/e6b001320c27a11fdbd15e17f2ad230bdeab2290).
Thanks to xet7.
- [Fix: admin reports load on FerretDB — the paginated report publications returned a
sorted+limited live cursor, whose limited live observe hangs on FerretDB's OpLog, so
the report was stuck on the loading spinner; they now publish the page manually so
ready always fires](https://github.com/wekan/wekan/commit/dded8ed47695092a698ab3e34a8abc5c6f567607).
Thanks to xet7.
- [Fix: the paginated admin reports never get stuck on the loading spinner if a report
subscription fails — they now handle onStop, clearing the spinner and surfacing the
error](https://github.com/wekan/wekan/commit/5949e85cfd9996651e1844fa6238d5a0f2da793e).
Thanks to xet7.
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.03 2026-07-19 WeKan ® release
This release fixes the following CRITICAL VULNERABILITIES, all reported by meifukun:
[RedirectBleed](https://wekan.fi/hall-of-fame/redirectbleed/),
[SourceBleed](https://wekan.fi/hall-of-fame/sourcebleed/),
[LiveBleed](https://wekan.fi/hall-of-fame/livebleed/),
[CasBleed](https://wekan.fi/hall-of-fame/casbleed/),
[MetricsBleed](https://wekan.fi/hall-of-fame/metricsbleed/),
[ImpersonateBleed](https://wekan.fi/hall-of-fame/impersonatebleed/) and
[InviteBleed](https://wekan.fi/hall-of-fame/invitebleed/):
- [Fix RedirectBleed: avatar localization validated only the original host and then followed redirects with native fetch, so a public avatar URL could 302 to a loopback/private/metadata address (SSRF). Avatars are now fetched via the DNS-pinned, redirect-rejecting fetchSafe, and profile.avatarUrl is scheme-validated (http/https/data:image/local) at write time](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
Thanks to meifukun and xet7.
- [Fix SourceBleed: a Trello board imported with a javascript: url was stored as an activity source.url and rendered as a clickable activity-sidebar link, so a board admin clicking it ran attacker JavaScript (stored XSS, Meteor.loginToken theft). Only http(s) source URLs are now stored and linked](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
Thanks to meifukun and xet7.
- [Fix LiveBleed: the live Trello import fetched attacker-controlled attachment/background/avatar URLs with bare fetch (no SSRF guard) and stored the response for read-back through the attachment API (non-blind SSRF). Every live-import download is now gated by validateAttachmentUrl, matching the offline import path](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
Thanks to meifukun and xet7.
- [Fix CasBleed: CAS login stored validated user data in a module-global (_userData) shared across concurrent logins, so two logins could race and issue an attacker a session for a victim's account. The user data is now bound per credential token](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
Thanks to meifukun and xet7.
- [Fix MetricsBleed: the /metrics endpoint trusted a client-supplied X-Forwarded-For header unconditionally, so anyone could forge a whitelisted IP and read operational metrics. XFF is now trusted only when METRICS_TRUST_PROXY is set (parsed spoof-resistantly from the right); otherwise the real socket address is used](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
Thanks to meifukun and xet7.
- [Fix ImpersonateBleed: several board export endpoints treated any historical ImpersonatedUsers record as an authorization bypass (canExport || impersonateDone), so a demoted former admin could export private boards forever. The impersonation bypass is removed; export requires real board visibility (canExport) only](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
Thanks to meifukun and xet7.
- [Fix InviteBleed: invitation registration used a 6-digit Math.random() code (~900k keyspace) with no throttling, brute-forceable to hijack an invited account and its private boards. Codes are now a 128-bit crypto.randomBytes value and account creation is rate-limited (DDPRateLimiter)](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
Thanks to meifukun and xet7.
The OIDC login shared-serviceData race from the same report was already fixed earlier
(#4897 moved the per-login `profile`/`serviceData`/`userinfo` objects inside the OAuth
callback); the new source-guard test verifies it stays that way. Thanks to meifukun and xet7.
and adds the following new features:
- [Added an automatic security/speed/tests remediation-logging subsystem (design docs docs/Security/Remediation/WeKan.md and FerretDB.md). WeKan's runtime guards (SSRF, upload/avatar rejection and sanitization, forged X-Forwarded-For, export authz, invite rate-limit, …) now record each block/sanitize/remediate event into the existing WeKan database (a single `eventlog` collection, stream security/speed/tests) via normal Meteor JavaScript queries — no new files or databases under WRITABLE_PATH, so it works the same on FerretDB and MongoDB. FerretDB reports its problems to WeKan, which records them. Events use general category names plus the hall-of-fame *Bleed names](https://github.com/wekan/wekan/commit/1d82ef8eae344ca0565d0858fc697f470e471dd7).
Thanks to xet7.
- [Added Admin Panel → Problems (a new button on the 2nd header bar to the right of the Info/version button, with a warning icon that turns red when there are new problems). The old Reports button is removed and its page becomes the Problems page: a left menu with Summary, Security, Speed and Tests above the moved Broken Cards/Files/Rules/Boards/Cards/Impersonation reports. Summary is a checkbox list of problem areas with one Acknowledge button (the only place to acknowledge, which resets the per-area new-problem count); Security/Speed/Tests are read-only, paginated, searchable event tables](https://github.com/wekan/wekan/commit/0085f7fbfa81e820ce0736dd28b2b0786ac6e41c).
Thanks to xet7.
and adds the following updates:
- [Reworked the Admin Panel → Problems pagination tables (Files/Rules/Boards/
Cards/Impersonation and the Security/Speed/Tests event tables): the search field
and pagination controls now sit in one row (pagination on the right, RTL-aware),
the redundant Search button is gone (typing + Enter searches), and the controls
follow the current theme — they use var(--theme-accent), so Member Settings →
Change color recolors them](https://github.com/wekan/wekan/commit/4f1afed25).
Thanks to xet7.
- [Made buttons follow the theme: the global button base and the primary buttons
(forms.css) plus the admin and People-panel buttons now use var(--theme-accent,
<original>), so a Change-color theme override recolors buttons across the app
while the default look is unchanged](https://github.com/wekan/wekan/commit/c94c313a7).
Thanks to xet7.
- [Admin Panel → Files report: URL-encoded filenames (e.g. "%D0%93%D1%80") are
decoded for display, filenames are always shown as plain text (never markdown/
HTML), a name hiding invisible/zero-width/bidi characters gets a red warning
triangle on the left and each invisible character is replaced inline by its red
Unicode name (e.g. "evil[U+200B ZERO WIDTH SPACE].png"), and a filter lists only
such names](https://github.com/wekan/wekan/commit/a4aa2f877).
Thanks to xet7.
- [Removed clickable column-header sorting from the board Table view and the Admin
Panel → People → Domains table; both now show a stable fixed order (and keep
their search + pagination). Sorting a server-paginated table means sorting the
whole set, which fights the per-page perf fix](https://github.com/wekan/wekan/commit/db68996cc).
Thanks to xet7.
- [Enabled FerretDB OpLog tailing by default so Meteor stops poll-and-diff, the
main fix for FerretDB sitting at 100–390% CPU on busy boards. With FerretDB
there is no MongoDB oplog, so Meteor re-ran every live query on a timer;
FerretDB v1 now ships an OpLog (auto-created capped local.oplog.rs + replica-set
hello handshake), so every FerretDB launch path starts ferretdb with
--repl-set-name=rs0 and points WeKan at it via MONGO_OPLOG_URL, and Meteor
TAILS the OpLog instead of polling. OpLog is used ONLY when tailing actually
works: every platform that defaults to FerretDB — Snap, Sandstorm, the Docker
image/compose and the prebuilt bundle — starts ferretdb with --repl-set-name and
sets METEOR_REACTIVITY_ORDER=oplog,polling, so Meteor falls back to polling if the
OpLog cannot be established (a broken/absent OpLog never blocks startup).
Kill-switch WEKAN_FERRETDB_OPLOG=false reverts to
polling only. Admin Panel → Version shows which driver is actually live
("Reactivity mode") next to the configured METEOR_REACTIVITY_ORDER and
DDP_TRANSPORT, so you can confirm OpLog came up rather than fell back. Also
trimmed the client activity feed page from 500 to 50 rows (infinite scroll
still loads more on demand)](https://github.com/wekan/wekan/commit/1039b2e3286135a1749aeab9526b52d40fb82e43).
Thanks to uusijani, Nissulya and xet7.
- [Updated SECURITY.md](https://github.com/wekan/wekan/commit/eebccaf291c2751154f127095d5b7610399f0f2d).
Thanks to xet7.
and fixes the following bugs:
- [Fix the Admin Panel → Problems → Cards report spinning while it loaded on big
sites: it paginates, but sorted by an unindexed { boardId, sort }, so every page
full-sorted all cards (11761+) in memory. It now sorts by the existing
{ boardId, createdAt } index, so one page is a bounded index scan; also added a
{ stream, at } index for the Security/Speed/Tests
tables](https://github.com/wekan/wekan/commit/fab8c1035).
Thanks to xet7.
- [Fix the Admin Panel → Translation page loading the ENTIRE Translations
collection at once: it subscribed with a hardcoded limit of 0 (= no limit). It
now uses the infinite-scroll window, loading a page at a
time](https://github.com/wekan/wekan/commit/e88665316).
Thanks to xet7.
- [Fix the Board Archive → Boards list loading every archived board at once: it now
pages server-side (30 per page) with a search box and prev/next controls, so only
the current page is loaded](https://github.com/wekan/wekan/commit/da99baf68).
Thanks to xet7.
- [Fix "Did not check() all arguments" server-log spam from the Admin Panel →
Problems detail pages: the eventLogCount/eventLogPage methods awaited the admin
check before check()ing their arguments, so a non-admin call (or one before
admin status resolved) made Meteor's audit-argument-checks mask the real error.
check() now runs first in every argument-taking event-log
method](https://github.com/wekan/wekan/commit/7da21c46b).
Thanks to xet7.
- [Fix MongoDB 3.x → FerretDB v1 migration failing on non-finite numbers: cards
whose `sort` was ±Infinity or NaN (written by old WeKan before #6472) were
rejected by FerretDB/SQLite ("infinity values are not allowed") and dropped,
so boards stayed stuck on the loading spinner. The migration now clamps every
non-finite double to a finite 0 before insert, and a new `nonfinite-sort-repair`
startup schema-upgrade step heals the same corruption in an already-running
database (MongoDB or FerretDB)](https://github.com/wekan/wekan/commit/2f7b49d8def4bb03d233b83671ac6c78308124ea).
Thanks to Nissulya and xet7.
- [Fix LDAP `mail` → `email` field mapping returning undefined (regression since
the ldapjs → ldapts move): the sync read the mapped attribute case-sensitively
while the rest of wekan-ldap reads it case-insensitively, so a directory that
returns the attribute with a different case synced no email address. It is now
read via getLDAPValue() like everywhere else](https://github.com/wekan/wekan/commit/2f7b49d8def4bb03d233b83671ac6c78308124ea).
Thanks to Nissulya and xet7.
- [Fix a memory/listener leak in board and CSV export: each write paused on
socket backpressure leaked one 'error' listener on the response
("MaxListenersExceededWarning: 11 error listeners added to [ServerResponse]"),
which on a large export grew unbounded. Both listeners are now removed once the
write settles](https://github.com/wekan/wekan/commit/2f7b49d8def4bb03d233b83671ac6c78308124ea).
Thanks to uusijani and xet7.
Thanks to above GitHub users for their contributions and translators for their translations.
# v10.02 2026-07-19 WeKan ® release
This release adds a new developer documentation set and release automation.
It adds the following documentation, a new `docs/Design/Autoupdate/` set describing
how software is installed and updated — manually and automatically (Snap-like) — on
every operating system, and how to release WeKan to more platforms:
- [Added why the ppc64el/s390x snap builds fail on core24 (the QEMU multiarch action caps at core22), moved to Autoupdate/Forks/Snap-Core.md](https://github.com/wekan/wekan/commit/742fc9221a6671dbf072e06a15cd3fd8bf409f32).
Thanks to xet7.
- [Added Windows install/update platform comparison (manual vs automatic, like Snap)](https://github.com/wekan/wekan/commit/3bfc6cce56e167e0b61ea674cd5e13b51c81092c).
Thanks to xet7.
- [Added macOS install/update platform comparison](https://github.com/wekan/wekan/commit/2a878d55e7d4cf04ba5896e87e6f49ab338dda69).
Thanks to xet7.
- [Added per-OS install/update comparison docs for all operating systems (Linux, BSD, Haiku, Amiga, RISC OS, Solaris/illumos, AIX, HP-UX, OpenVMS, z/OS, ArcaOS, ReactOS, FreeDOS, Plan 9, Redox, SerenityOS, Android, iOS, ChromeOS, HarmonyOS, Ubuntu Touch, Sailfish, postmarketOS, KaiOS, Tizen, webOS and TV/streaming OSes), plus the Snap-Ondra-Gantt.md variant-snap spec](https://github.com/wekan/wekan/commit/cf1823b510b4abd67c8c32f605ddcfc956ea8802).
Thanks to xet7.
- [Added UCS.md: releasing the Docker WeKan UCS App Center app with automatic MongoDB 3.x to FerretDB v1 SQLite migration](https://github.com/wekan/wekan/commit/58c0e6d1f8bfabc624c870c9e91c6a54f0877cda).
Thanks to xet7.
- [Added Nextcloud.md: building WeKan as a Nextcloud ExApp, App Store publishing, and AI (Task Processing API) / Deck / OIDC SSO integrations](https://github.com/wekan/wekan/commit/8ae0ee54460444f6517fbe5a63f6ba143d5b6277).
Thanks to xet7.
and the following documentation reorganization:
- [Moved Mac.md into docs/Platforms/Propietary/Mac/ and updated all links](https://github.com/wekan/wekan/commit/8cf7dd68cc3a6cf16362dc58ba789cff1d361564).
Thanks to xet7.
- [Moved the auto-update dev docs into docs/Design/Autoupdate/](https://github.com/wekan/wekan/commit/48a60a1141cafdb077239f619b8d03fb4d1f8a97).
Thanks to xet7.
- [Moved the per-OS comparison docs into Autoupdate/OS/](https://github.com/wekan/wekan/commit/c1ffed59a7a54bc3b28d94a243522eee20d0b791).
Thanks to xet7.
- [Moved Autoupdate/Snap.md to Autoupdate/Forks/Snap-Core.md and repointed links](https://github.com/wekan/wekan/commit/cf55da122ac31ae14c17780ea027c119e00f3abf).
Thanks to xet7.
and the following release automation:
- [Added guarded snap-variants / ucs / nextcloud publish jobs to release-all.yml (dormant until their secrets are set, so ordinary releases are unaffected)](https://github.com/wekan/wekan/commit/2054ea2b54a193b9e53267b3dc3fbe15056c9e1e).
Thanks to xet7.
and adds the following updates:
- [Upgraded filesize from 11.0.17 to 11.0.22](https://github.com/wekan/wekan/commit/ccb7a8e91c2b1e3fe3e8db86007cc3f92d11f85a).
Thanks to dependabot.
- [Upgraded dompurify from 3.4.11 to 3.4.12](https://github.com/wekan/wekan/commit/963f72ac88df708d225cd756aec1408a114d77c7).
Thanks to dependabot.
- [Upgraded @aws-sdk/client-s3 from 3.1085.0 to 3.1090.0](https://github.com/wekan/wekan/commit/144bed0eda0a860acbfa44e4036bbd784c17f8fc).
Thanks to dependabot.
- [Upgraded actions/setup-node from 6 to 7](https://github.com/wekan/wekan/commit/1f11ad26027aed362b3dfc23126ef7af88a01b01).
Thanks to dependabot.
Thanks to above GitHub users for their contributions and translators for their translations.
# v10.01 2026-07-18 WeKan ® release
This release adds the following features:
- **Member Settings — optional "Submit editors with Enter"**. A new per-user setting in the Member Settings
(Change Settings) popup, saved to `profile.submitOnEnter` and **off by default**: when on, plain **Enter** saves
the card title, description and other inline editors and **Shift+Enter** inserts a new line; when off, behaviour is
unchanged (Ctrl/Cmd+Enter saves). This restores the fast Enter-to-save workflow for users who prefer it without
regressing #4236. [Setting](https://github.com/wekan/wekan/commit/bb6b0d5477368a97ba5cc1c01bdb598dcbe68cd2), [translated to all languages](https://github.com/wekan/wekan/commit/b8bb00465037389eb5829157441f2b75819353d9). Thanks to **xet7**.
- **Right board sidebar — drag the edge to resize its width** (desktop). Drag the sidebar's edge like a spreadsheet
column; the width is saved to `profile.sidebarWidth` for logged-in users and to browser `localStorage` for
anonymous users on a public board, then re-applied on load. The handle uses logical positioning, so in LTR it is
the left edge of the right-docked sidebar and in RTL the right edge of the left-docked sidebar (drag direction
inverts); on phones the sidebar stays full width. [Feature](https://github.com/wekan/wekan/commit/354b7804b577c2ca92b479b7bd2ef595cc9212d3),
[tooltip translated to all languages](https://github.com/wekan/wekan/commit/1284afc588d6a52f89fdbb80d6f6ede9bc6bdfeb). Thanks to **xet7**.
and fixes the following bugs:
- **Upgrade migration no longer drops cards/activities whose exported data holds `NaN` or `Infinity`**
([#6481](https://github.com/wekan/wekan/issues/6481)). mongo 3.x `mongoexport` emits non-finite doubles as the bare
tokens `NaN`/`+Infinity` (e.g. a card's `"sort":+Infinity`, an activity's `"value":NaN`), which are not valid JSON,
so `EJSON.parse` threw and the whole document was dropped — leaving boards/cards missing after an otherwise
"successful" 6.09 → 10.x migration. Those bare tokens are now rewritten (outside string literals) to the canonical
EJSON `{"$numberDouble":"…"}` form so the document migrates instead of being lost. [Fix](https://github.com/wekan/wekan/commit/e0991d6602c915e13d77776af36ecac410965909). Thanks to
**Nissulya** and **xet7**.
- **Add List — the inline composer's previous options are back**
([#6465](https://github.com/wekan/wekan/issues/6465)). The per-list-header inline Add List composer had been
reduced to just a title input; the **"add after which list" position selector** (pre-selecting the list whose
header opened it) and the **"or template"** link are restored. [Fix](https://github.com/wekan/wekan/commit/b71461d90c6a7d9ef9a835cf151181e15c84526d). Thanks to **csonkaoszimt**
and **xet7**.
- **Right sidebar width no longer doubles on wide screens, and its input fields fill the width**. The sidebar used
`width: 30vw`, so it grew unbounded on very wide screens (looking right only at mid widths); it is now clamped. The
filter text inputs (Filter List by Title, Filter by card title, …) had no width and rendered narrow with a big gap
on the right; they now fill the padded content width with equal spacing on both sides.
[Fix](https://github.com/wekan/wekan/commit/b8a21e4ce3acca427a25f11bcb8c0fdf6bc26ee3). Thanks to **xet7**.
- **The Multi-Selection sidebar "Copy selection" button showed its raw translation key**. The `copy-selection` key
was missing from every language file — including the English source — so i18next had no value to fall back to. It
was added ("Copy selection") and translated to all languages. [Fix](https://github.com/wekan/wekan/commit/72042b7d796ce66c9f9da637c90e84493b67b3c1). Thanks to **xet7**.
- **Follow-up fixes surfaced by the full test run.** Now that generic Chinese (`zh`) is its own registered language,
a bare `zh` browser tag resolves to that entry (Simplified Chinese) instead of the old `zh → zh-Hans` alias, which
is removed; and the board-actions end-to-end test opens the board menu from the right sidebar (the header Board
Settings cog was removed). [Fix](https://github.com/wekan/wekan/commit/08cfe2d7881aee399dd0e0a05629096e3027b9b2).
Thanks to **xet7**.
Thanks to above GitHub users for their contributions and translators for their translations.
# v10.00 2026-07-18 WeKan ® release
This release adds the following updates:
- [Updated Sandstorm Backup docs](https://github.com/wekan/wekan/commit/50dd05ceee9aa954bb09c239ed057334de63015a). Thanks to robertdahlem and xet7.
and adds the following features:
- **WeKan has been translated to 154 languages. Thanks to xet7.** Every language file in
`imports/i18n/data/` is now filled in (previously most were largely English). Each language matches its own
existing terminology as a glossary plus standard Kanban terms; product/brand names, acronyms, placeholders and
markup are preserved; regional variants were seeded from their base language; and a few wrong ones were corrected
(Wu Chinese `wuu-Hans` was registered; the `wa-RR` file is Waray/`war`, not a Walloon variant, so it was
re-translated; Wolof/Malay display names were fixed) so that **all languages are browser-detectable and visible in
the Change Language popup**. `releases/translations/push-all-translations.sh` force-pushes every language to
Transifex. Commits:
- [Finnish: translate newest UI strings (theme, font, size, colors, Sandstorm)](https://github.com/wekan/wekan/commit/061eba8b7aa4c96e356c46c0e47fb22b1b773f8d)
- [i18n: translate new admin/import-export strings to Finnish](https://github.com/wekan/wekan/commit/687a4af8617c0b06f98e20b283b0a2fd329a76a3)
- [releases: add translations-push-all-translations.sh (force-push all languages)](https://github.com/wekan/wekan/commit/a9f2e9f50cbb55eb865478ac14ce2ac1d80b5913)
- [i18n: translate untranslated strings (wave 1: zh-TW, pt-BR, da, he, sv)](https://github.com/wekan/wekan/commit/63bda22faacd430f4680c9cbe0ce521129096a5e)
- [i18n: translate untranslated strings (wave 2: 15 major languages)](https://github.com/wekan/wekan/commit/7691829cdb293796eab7794eae6c0cb4ec829e6b)
- [i18n: seed 32 regional variants from their translated base languages](https://github.com/wekan/wekan/commit/ea4a10c212c8037b833ece0010032f6a8d52b9a8)
- [i18n: seed close-relative lects as baselines (cmn, wuu, yue, ja-Hira, vls, ary)](https://github.com/wekan/wekan/commit/c795d13450fc7fea5f9cc55fbdc1ac843aeafb6d)
- [i18n: register generic Chinese (zh) so it is selectable and browser-detectable](https://github.com/wekan/wekan/commit/75ec34e83726f7792240edef70de59648c96ef35)
- [i18n: translate untranslated strings (wave 4: 15 languages + 9 variants)](https://github.com/wekan/wekan/commit/a184a3a717331239f38203fc56ed78cc9a04f11e)
- [i18n: translate untranslated strings (wave 5: 15 languages + 5 variants)](https://github.com/wekan/wekan/commit/97feb986a88f2f6128fd6bf74be922bed83c104e)
- [i18n: translate untranslated strings (wave 6: 15 languages + 2 variants)](https://github.com/wekan/wekan/commit/91ccef062b5ec866aec8ac4660235e46e14e55ab)
- [i18n: translate untranslated strings (wave 7, final: 14 languages + 5 variants)](https://github.com/wekan/wekan/commit/219011e5eb3ef3994a074fed9cc441faa734fada)
- [WeKan has been translated to 154 languages](https://github.com/wekan/wekan/commit/01d55828d23a645e1c8a841bb4e6357223399d47)
- [i18n: register wuu-Hans, fix Wolof/Malay names, add wuu browser alias](https://github.com/wekan/wekan/commit/d55c5e2c3cd544c0ad94538b689b63fe50cd6a41)
- [Changelog: WeKan translated to 154 languages, with commit links](https://github.com/wekan/wekan/commit/5b3394af2f3fad92286260d8e94158988e2f3196)
- [Moved translation script to correct directory. Thanks to xet7](https://github.com/wekan/wekan/commit/95552881fc7e7256edadf51aa84b6b4436cb0e24)
- [i18n: fix wa-RR — translate as Waray (war), not Walloon](https://github.com/wekan/wekan/commit/c00f24d668df1bfc1979a37172dadc1f2d9a77e4)
- [Changelog: add Part 12 (Waray wa-RR fix) to the 154-languages entry](https://github.com/wekan/wekan/commit/2cf878215d5cb09f8bfa7f8368e9cc36be3931d3)
- [releases: fix push-all-translations.sh cd depth for its translations/ location](https://github.com/wekan/wekan/commit/2be92538391e1e2b92c87ab4e2217ce2e9e39ec0)
- **Member menu / Change Color — global theme override, visible swatches, applies everywhere, no Save button**
([#5778](https://github.com/wekan/wekan/issues/5778), `docs/Theme/Theme.md`). The member menu gets a **Change
Color** entry that works like Board Settings / Change Color but is a **per-user global override** saved to your
profile, so the chosen theme applies to the **whole UI** — All Boards, Search, Admin Panel, My Cards, Due Cards
and board pages — via a `board-color-<name>` class on `<body>`/header (while you are on a board, that board's own
color still wins). This makes a **dark theme everywhere** possible. **Done differently from the first cut:** the
picker was initially built as **two-level dropdowns** (category then theme); that was replaced by **visible color
swatches grouped by category** (Flat / Clear / Dark / Special, category name above each group), and the **Save
button was removed — clicking a swatch applies immediately**. **Flat** themes allow **1 custom color** and
**clear** themes **2** (a gradient) via the native color wheel; **dark**/**special** are fixed. Custom colors are
validated server-side as `#rrggbb` and applied as CSS variables (`--theme-accent` / `--theme-accent-2`). Category
titles are left-aligned and the popup has a title. Thanks to **xet7**. Commits:
- [Member Menu / Change Color: global per-user theme override (#5778)](https://github.com/wekan/wekan/commit/eab1546b885336502549d9c4fcc45feec661310a)
- [Select Color: theme categories core + design doc (docs/Theme/Theme.md)](https://github.com/wekan/wekan/commit/49285dad1950a3019972dbf08d40201b256719ca)
- [Select Color: two-level dropdown picker + custom colors (#5778)](https://github.com/wekan/wekan/commit/d0a1e43f364d8ad0caef725e5d48db507887a962)
- [Select Color: global override applies everywhere + visible grouped swatches (#5778)](https://github.com/wekan/wekan/commit/1cd32001d474bed977d56ff0fdbcfbc090191974)
- [Change Color popup: immediate apply (no Save), left-aligned titles, title, spacing](https://github.com/wekan/wekan/commit/772ae061f109478a875fc6e570f0afe26e572148)
- [Change Color popup: remove the "Preview" text from the custom-color preview swatch](https://github.com/wekan/wekan/commit/0f87c3bf8b33241927ff5437c222907986ab37df)
- **Member Settings / Font — pick an installed UI font, size and text colors**
(part of [#4759](https://github.com/wekan/wekan/issues/4759)). A new **Font** member-menu entry lets you choose a
UI font that **already exists in your browser** (canvas-detected; names are plain text, never HTML/markdown,
validated server-side against a curated whitelist — only a known font name and a preset percentage ever reach the
DOM), a font **size**, and custom **text** and **text-background** colors (each unsettable). **Done differently
from the first cut:** the font and size were first built as **dropdowns**; that was replaced by **buttons** — one
**font-name button per detected font, each rendered in its own font**, and a row of **size buttons** (smaller …
100% in the middle … larger) — that **apply immediately on click** (no Save). A **preview pangram** shows below
the font buttons, the **Unset** links are styled as buttons, and the popup has a title. Stored as `profile.uiFont`
/ `profile.uiFontSize` / `profile.uiTextColor` / `profile.uiTextBgColor`. (Only the font part of #4759, so the
issue stays open.) Thanks to **xet7**. Commits:
- [Member Settings / Font: pick an installed font + preset size (#4759)](https://github.com/wekan/wekan/commit/3c9524f61a43cbf92f856f0fb2ff1f9b291d4e05)
- [Member Settings / Font / Color: custom text + background color (#4759)](https://github.com/wekan/wekan/commit/22c7d5376bcf7990890c7254c80c1e2dfd9e419c)
- [Fix #4759 regression: uiTextColor/uiTextBgColor custom() rejected every user insert](https://github.com/wekan/wekan/commit/437c64c9a0c05dbf3d12a8e45e0b8281f3c326e6)
- [Member Settings / Font popup: add a title (changeFontPopup-title)](https://github.com/wekan/wekan/commit/969f96d74219061e05093d5dff7061a684d22f65)
- [Member Settings / Font: font-name + size buttons, immediate apply (no dropdowns)](https://github.com/wekan/wekan/commit/bd8e417be18837b2e4600b3b25fa605a83910a2f)
- [Member Settings / Font: show the preview pangram below the font-name buttons](https://github.com/wekan/wekan/commit/aa15e726829a8e070634d7c04698bd251f817f08)
- [Font popup: style the "Unset" colour reset links as buttons](https://github.com/wekan/wekan/commit/3d190ca0222d657827ed03993ba542279148482a)
- **Soft delete + Undo/Redo + change-History — deleting is reversible, and moves can be undone**
([#1023](https://github.com/wekan/wekan/issues/1023), [#6478](https://github.com/wekan/wekan/issues/6478),
`docs/Features/Undo/Undo.md`). Deleting a **list** no longer destroys it: the list and its cards are **marked**
deleted (`deletedAt`/`deletedBy`/`deleteBatchId`), hidden from the board, and restorable via **`lists.restore`**
or **`Ctrl+Z`** — the first slice of a general "no permanent delete in ordinary use" principle (physical deletion
limited to GDPR/account erasure and an explicit Global-Admin purge behind an off-by-default **Admin Panel /
Features / Delete** flag). Card/list/swimlane **moves** are now undoable/redoable with `Ctrl+Z` / `Ctrl+Y` (the
`userPositionHistory` collection was silently **not recording** — its guard checked an un-imported global — and had
no redo or key bindings; all fixed, selection logic unit-tested). Design unifies this into one universal
change-History with scoped views. Activities were confirmed to already load progressively. Thanks to **xet7**.
Commits:
- [Soft delete for lists: delete = mark, restorable + undoable (#1023)](https://github.com/wekan/wekan/commit/46924985f88b473e054158ecae68af6d7cce3556)
- [Undo docs: soft-delete design (delete = mark, never destroy)](https://github.com/wekan/wekan/commit/58344db0d7e3ed9e4f8ca638e0b3e00d2813ac63)
- [#6478: undo/redo for card/list/swimlane moves + confirm high-impact list move on touch](https://github.com/wekan/wekan/commit/4ca33d29f78bd81cecc931faa5164f862823ad52)
- [Docs: design for per-group card History (view + restore)](https://github.com/wekan/wekan/commit/3d1276fc1fbd063f1c7b79467b788f13a5e62435)
- [Docs: undo/redo + high-impact confirmation feature doc](https://github.com/wekan/wekan/commit/f3354f5cc8577cda46d2789230e8d30939bd927d)
- [Docs: unify undo/redo into one universal change-History (every change) with many scoped views](https://github.com/wekan/wekan/commit/9be41ab34b1bd596a82b95f088e49e94da8b988c)
- [Activities already load progressively (infinite scroll), like visible cards](https://github.com/wekan/wekan/commit/e246403bb6969f402e066d01e256e2c56f10ff1c)
- **Home board — set a board that opens automatically after login**
([#2220](https://github.com/wekan/wekan/issues/2220)). A per-user default "home" board (`profile.defaultBoardId`)
opens automatically after login on **all deployments** (a once-per-session router redirect), and on **Sandstorm** a
grain with exactly one board again opens straight into it (**without saving anything**; a saved Home board wins, a
many-board grain with no choice shows All Boards). You **set the Home board from the All Boards page via
Multi-Selection**: turn Multi-Selection on, select a board, and click the **home** action in the new
**"Selected:"** row (which also has a **star** action); the Home board then shows a **home badge** and sits at the
**top of Starred**. **Done differently:** the earlier per-board-tile home toggle was hidden, but setting a Home
board was brought back through the Multi-Selection "Selected:" action — so the feature is **available, not
disabled, and not Sandstorm-only** — and the initial Sandstorm "save the single-board choice" was changed to
**no-save**. Thanks to **xet7**. Commits:
- [Feature #2220: mark a board to open automatically after login (home board)](https://github.com/wekan/wekan/commit/e8ced84172ec7950800becfee9860c33e964034e)
- [Sandstorm: auto-open a single-board grain (no save) (#2220)](https://github.com/wekan/wekan/commit/9a0c0613a5909f9a37f6afb20890894b8e5082bd)
- [All Boards: hide Home-board icons and disable setting a Home board (#2220)](https://github.com/wekan/wekan/commit/ac20cd64424ebe356fcd7ccb3a4f60370580e7a3)
- [All Boards: "Selected:" star/home actions + Home board badge (#2220)](https://github.com/wekan/wekan/commit/d67d27f0d872c4fc6e990262d3d3577a28783eee)
- [Tests: cover the remaining Upcoming changes (#2220 default board, #6465 UI, #6478 confirm)](https://github.com/wekan/wekan/commit/8287865b238f3ed90e60e4624e98b383e896beee)
- **Add List moved from a standing column to a per-list header button**
([#6465](https://github.com/wekan/wekan/issues/6465)). The Swimlanes/Lists views no longer show a permanent "Add
List" composer column; each list header has a far-right add-list button that opens the composer as a column
**immediately after that list** (right in LTR, left in RTL), and an **empty** swimlane/board shows a **+** button.
Also fixes the composer's Save doing nothing (`createListAfter`'s `check()` used a non-`Optional` matcher for
`nextListId`, so the inline composer 400'd with "Match failed"). *(First cut — desktop/main views.)* Thanks to
**csonkaoszimt** (report) and **xet7**. Commits:
- [#6465: Add List as a per-list header button + inline composer, not a standing column](https://github.com/wekan/wekan/commit/765d04bf7c678a9f28b663beadae7b07fd79f339)
- [Fix add-list: Save did nothing (Match failed) + empty-swimlane + button (#6465)](https://github.com/wekan/wekan/commit/0535215225a41bfe16d936a9a1b447fc77f9122e)
- **All Boards, board header, top bar and popups — a large batch of layout/theming polish.** Highlights: the
right-sidebar **hamburger is pinned to the right edge** at every width with an ~8px gap, and the duplicate **Board
Settings cog was removed**; the All Boards **My Boards toolbar wraps** (order Multi-Selection then Sort then
Search), the **Multi-Selection button matches the Swimlanes view**, the empty grey band was removed, **"+ Add …"
tiles match board-tile height**, the board-tile **drag handle sits at the right middle** (transparent, icon only),
and the **search box is half width** with the multi-selection hint beneath it; the **mobile/desktop toggle and
zoom** swapped places; the **Change Language** popup shows **multiple columns**; the **Change Avatar** upload
button and the **Search All Boards** / **Archived boards** buttons follow the **theme colors**; the **Archived
boards modal** is full width and starts below the header; the **Notifications drawer** is full width with a black
hamburger + X and black menu icons; and the member menu's duplicate **Notifications** entry was removed. **Done
differently:** an "icons only — always hide button text labels" board-header change was tried and then
**reverted**, so button **text labels stay**. Thanks to **xet7**. Commits:
- [Board header: keep the sidebar hamburger pinned to the right edge at all widths](https://github.com/wekan/wekan/commit/7718118654f35a214ea5f9b2147642eabf4a6702)
- [Board header: remove empty space around the sidebar hamburger button](https://github.com/wekan/wekan/commit/e33e50d9c84cc9125ac6e8d5e491a7419bbae883)
- [Board header: hamburger right gap matches the user-name gap (~8px)](https://github.com/wekan/wekan/commit/bd72f5d64c58ad0861f370a17969f6ee482c466c)
- [Board header: hide button text labels on narrow windows (icons only)](https://github.com/wekan/wekan/commit/cfd53cc9698bbc759bef55152ea8f74ca00f3afc)
- [Board header: remove the Board Settings cog (it is already in the right sidebar)](https://github.com/wekan/wekan/commit/68723ff43b324dda1b584e5ba4eb5d90d0490124)
- [Board header: icons only — always hide button text labels](https://github.com/wekan/wekan/commit/97b0240ca4bb5f2bf02020e94eb6d7ffc278acd9)
- [Revert board-header icons-only; update CHANGELOG Upcoming with newest changes](https://github.com/wekan/wekan/commit/834698bb5e3e926213676e499bf4338713bc43da)
- [Header/All Boards: reorder Multi-Selection/Sort/Search and swap mobile-toggle/zoom](https://github.com/wekan/wekan/commit/15e756f4e0b265a020b659fea2dc3339b2c2e767)
- [All Boards: + Add Board / + Add Template Container tile same height as board icons](https://github.com/wekan/wekan/commit/10085615179dbdd56bd5d630ee526e82a1451ef6)
- [All Boards: remove empty .board-list-header dead grey space above the layout](https://github.com/wekan/wekan/commit/1312fefb1234d6fcdcda930d93fd189e57befd37)
- [All Boards: My Boards bar wraps so Sort/Multi-Selection/Clear stay visible](https://github.com/wekan/wekan/commit/7699cc1e90f18b599f294fba774403f3b17e3749)
- [All Boards: Multi-Selection button looks the same as in Swimlanes View](https://github.com/wekan/wekan/commit/c6aa769bc0a8b01706e0f45a19b8ba8c66c77498)
- [All Boards: move board tile drag handle to the right middle](https://github.com/wekan/wekan/commit/aafe71fd191a8633012d838d00aa96abd3f092a2)
- [All Boards: transparent board tile drag-handle background (icon only)](https://github.com/wekan/wekan/commit/197254fe16c2dd2c266f2a1797d80feffccfc914)
- [All Boards: vertically center the Search boards input text](https://github.com/wekan/wekan/commit/035195f2b4b7a8601964d54645ec7d139e65dd41)
- [All Boards: move the Search boards input text 4px further down (top: 6px)](https://github.com/wekan/wekan/commit/cb80198d9748b2fbd7dee392e235f882785420c7)
- [All Boards: move the Search boards input text 2px further down (top: 8px)](https://github.com/wekan/wekan/commit/c21edb160bddfb2a9e635d5c2154c037dc464698)
- [All Boards: move "+ Add Board" text 4px up (keep tile height)](https://github.com/wekan/wekan/commit/3f5173b9602e14de3201ebc0052b2751e522f100)
- [All Boards: move "+ Add Board" text 2px further up (padding 18/8/24/8)](https://github.com/wekan/wekan/commit/519c04b8ef46497d526f4586432b132887335487)
- [All Boards: move "+ Add Template Container" text 2px down (separate from Add Board)](https://github.com/wekan/wekan/commit/cd7f9f7bc9e48c6f54b007deec13194b90d4d33b)
- [All Boards: half-width search, hint below Multi-Selection, title beside icon](https://github.com/wekan/wekan/commit/3736f8431a4b0b8224c0b7315ac8e596aa4426fa)
- [Change Language popup: show languages in multiple columns when there is width](https://github.com/wekan/wekan/commit/fc0649a756d0f6e6caf2feef659786a9908397b1)
- [Change Avatar popup: Upload button uses theme colors (blue bg, white text)](https://github.com/wekan/wekan/commit/08a2e5525e8e7b099d766643b09616e75f750fc9)
- [Archived boards modal: full width + extends to the bottom of the window](https://github.com/wekan/wekan/commit/b027594ca8ad6d9f781214e8c26aac572ed20c25)
- [Archived boards modal: start below the header bar so its top is visible](https://github.com/wekan/wekan/commit/1c8ba2afc3a51d5a0b71f5777d1e189dca5525e4)
- [Archived boards: Restore/Delete buttons follow the theme override colors](https://github.com/wekan/wekan/commit/4dc09c62b9719cbb3dd126420239f7d9da5bea55)
- [Search All Boards: new-search button follows theme override colors](https://github.com/wekan/wekan/commit/98a03bf045668a95f58ae2cfe15f6d39591c89de)
- [Search All Boards: label the clear button "Clear" using existing trello-clear-job key](https://github.com/wekan/wekan/commit/a4cc0c2182b9c4da74325bb5e21d7f13f64e9484)
- [Member menu: remove the Notifications entry (duplicates the header bell icon)](https://github.com/wekan/wekan/commit/8cb4050cf21a1fce10c08872cb8f4a4bb270565b)
- [Notifications drawer header: black X + hamburger, X near the top-right edge](https://github.com/wekan/wekan/commit/8dd6bd9db5a8f427f6863f033e7236ae80ead336)
- [Notifications drawer: full width + robust flex header (black hamburger + X)](https://github.com/wekan/wekan/commit/c87b4cb5103aa2e010f0c40b0554e4cf84126059)
- [Notifications drawer: fix X position — cancel the global absolute .close rule](https://github.com/wekan/wekan/commit/5e014d0967ec8d622857348a264f3522a7e9769d)
- [Notifications menu: make the dropdown item icons black](https://github.com/wekan/wekan/commit/01088ea9d4583301446cb021ecd8772e4d3cde6d)
- **Denser default layout: thinner lists, board tiles and card dock; taller-looking minicards**
([#6465](https://github.com/wekan/wekan/issues/6465)). List width default 272 to **220px** (minimum 270 to
**200px**), All Boards board tiles min-height 100 to **72px**, and the card-detail right dock max-width 800 to
**520px** — all overridable, user customizations untouched. Minicards no longer reserve a tall empty band under a
one-line title (the title viewer gets `min-height: 0`), and the swimlane header no longer clips at non-100% zoom.
Thanks to **csonkaoszimt** (report) and **xet7**. Commits:
- [#6465: denser defaults - thinner lists (220px), board tiles and card dock (520px)](https://github.com/wekan/wekan/commit/99299c2a91827f88f0bf091e810b8ce9d6211784)
- [Fix #6465: minicards wasted half their height on empty white space](https://github.com/wekan/wekan/commit/a76f7d28e33f79b79f89eebffcb2a2946205f5f0)
- [#6465: swimlane header bar no longer clips at non-100% board zoom](https://github.com/wekan/wekan/commit/7b7fada1b89fa21e35ebaeca26d3f85299afb33d)
- [Test: update fixed-list-width e2e for the lowered 200px minimum (#6465)](https://github.com/wekan/wekan/commit/ae29555862089cd85111aa417a892888d48124bd)
and fixes the following bugs:
- [Confirmation dialogs work again](https://github.com/wekan/wekan/commit/f6f021f8cc8de452593f499e1306dca021679325) — clicking any `.js-confirm` button (e.g. "Remove Member") did
nothing because `Popup.afterConfirm()` stashed the pending action on the Blaze **data context** (an immutable
Minimongo doc that Blaze re-creates on re-render); it is now stored on the Popup **instance**
([#6479](https://github.com/wekan/wekan/issues/6479)). Thanks to **mueschel** (report) and **xet7**.
- [Moving a card between swimlanes is no longer broken with long lists](https://github.com/wekan/wekan/commit/6d5a594b0709ad3456f91afc31d821304539a262) — jQuery UI sortable cached
geometry at drag-start and never re-cached on WeKan's manual auto-scroll, so the drop re-homed the card in the
source swimlane; fixed with `sortable('refreshPositions')` after a scroll
([#6477](https://github.com/wekan/wekan/issues/6477)). Thanks to **mueschel** (report) and **xet7**.
- [Filter by date -> Overdue no longer lists cards with no due date](https://github.com/wekan/wekan/commit/b1d33c536239d45ae5df6139beb6b5548f7e03c0) — `$lte`/range selectors
matched `null` under both FerretDB and minimongo; `DateFilter._getMongoSelector` now adds `$ne: null` (reported by
email). Thanks to the reporter and **xet7**.
- [A board-wide list no longer disappears from other swimlanes when nudged](https://github.com/wekan/wekan/commit/c29cf3b985abb670222ec25c76a055e0ff9ba53a) — the drop handler
treated any small drag of a board-wide list (`swimlaneId === null`) as a swimlane change; it now also requires an
original swimlane (reported by email). Thanks to the reporter and **xet7**.
- **FerretDB (bundled) — #6476 crash-loop root cause fixed, and label/title/date filters pushed down to SQLite**
([#6476](https://github.com/wekan/wekan/issues/6476)). A startup SyncedCron upsert hit an orphaned-table error
(`table "…connections_<hash>" already exists`) that became an unhandledRejection and crash-looped WeKan so its
port never opened; the bundled FerretDB now creates tables/indexes with `IF NOT EXISTS`. It also pushes label
(`$in`), title (`$regex`) and `dueAt` range filters down to SQLite instead of scanning every card in Go. (Details
in the fork's own CHANGELOG.) Thanks to **uusijani**, **a1bert01** and **xet7**. Changelog commits:
- [Changelog: #6476 real root cause (FerretDB orphaned-table crash loop) and the bundled FerretDB fix](https://github.com/wekan/wekan/commit/41c747ef5f7e3fac4ae37714e65e1f7b4703ab80)
- [Changelog: bundled FerretDB now pushes label ($in) and title ($regex) filters down](https://github.com/wekan/wekan/commit/b29a47778ffb3025d8c26d67427e022a6a137076)
- [Changelog: bundled FerretDB now pushes date range filters down too](https://github.com/wekan/wekan/commit/5730f7dcc54e551ca5b4cc6ede307ebe7e078310)
- **Snap release, CI and dev tooling.** The exotic `ppc64el`/`s390x` snaps now build on **GitHub Actions under
QEMU** instead of Launchpad (which returned exit 0 with no artifact); `rebuild-wekan.sh` CURRENT-IP detection is
subnet-agnostic (no more `http://:3000`); and the **Playwright E2E workflow was disabled**. Commits:
- [Snap release: build ppc64el and s390x under QEMU on GitHub Actions, not Launchpad](https://github.com/wekan/wekan/commit/d7f36d70a04ee96669d75c2dea675cf7e7b76d7a)
- [rebuild-wekan.sh: robust CURRENT-IP detection (fix http://:3000 Invalid URL)](https://github.com/wekan/wekan/commit/c6f960bbe10d5ff36a92314c0a21380e914a89e3)
- [ci: disable Playwright E2E Tests workflow](https://github.com/wekan/wekan/commit/ef81c25232c7f5f26b6b33d9e9b945b8e769dd35)
- **Changelog, docs and test housekeeping.** Commits:
- [Changelog: move post-v9.99 changes to an Upcoming section above v9.99](https://github.com/wekan/wekan/commit/c4784225d51f2d13f2f50b2fe172cb156feab861)
- [Updated ChangeLog](https://github.com/wekan/wekan/commit/26319c974b180cc2a59b55705bc5d5128f23f438)
- [Changelog: drop bogus #6483/#6484 issue links (those bugs were reported by email)](https://github.com/wekan/wekan/commit/e9e70717445afbf124fe5c536f4199ef97d8ff5b)
- [Updates](https://github.com/wekan/wekan/commit/176c5ef2d8a31f85cb41e52a85003e3560e818cc)
- [Test: de-flake webkit swimlane-add popup wait (6s -> 15s)](https://github.com/wekan/wekan/commit/8aacc2236052108576e9b0e1ac0ca68f9339463e)
- [Updates](https://github.com/wekan/wekan/commit/861ded439e08dfe16e65f19a3e150d83bbd8286f)
- [Updated ChangeLog](https://github.com/wekan/wekan/commit/66a2f8ddc8f4bc95fe08478f1d0eeda08f87cf82)
Thanks to above GitHub users for their contributions and translators for their translations.
# v9.99 2026-07-17 WeKan ® release
This release fixes the following BUG:
- **LDAP connection leak: WeKan exhausted the directory server with "too many open connections"**
([#6467](https://github.com/wekan/wekan/issues/6467), [#6469](https://github.com/wekan/wekan/issues/6469)).
Operators reported that after an update WeKan "dies really quickly", that restarting the server did not
help, and that it "kills our openldap server with too many open connections"; others saw logins hang for
minutes and then fail with *"Must be logged in"*. Root cause: every LDAP login attempt
(`packages/wekan-ldap/server/loginHandler.js`) and every background sync run
(`packages/wekan-ldap/server/sync.js`) created a fresh `new LDAP()` and called `connect()`, but the code
**never called `disconnect()` on any path** — success, failure, or fallback. Each login attempt (including
every *failed* one) and each background-sync tick (every minute by default) therefore leaked one socket to
the LDAP/AD server. Over time this grew without bound until the directory server hit its per-client
connection limit and started refusing connections, which took it — and, with it, every WeKan login — down.
- **Fixed** by guaranteeing the connection is always released:
- A small shared helper `packages/wekan-ldap/server/connectionGuard.js` (`runWithLdapDisconnect(ldap, fn)`)
runs the work and `disconnect()`s in a `finally`, on every exit path. The disconnect is best-effort and
never masks the original result or error.
- The LDAP login handler now runs its whole flow through `runWithLdapDisconnect`, so `connect()` is always
paired with a `disconnect()` — on a successful login, a thrown `Meteor.Error`, or a fallback to the
default account system.
- The background `sync()` releases its connection in a `finally`, and `importNewUsers()` disconnects only
the connection it opened itself (never a connection borrowed from `sync()`).
- The admin `ldap_test_connection` method (`packages/wekan-ldap/server/testConnection.js`), which had the
same leak, now disconnects on both the success and failure paths, so repeated "Test Connection" clicks
no longer leak either.
- `LDAP.disconnect()` is now a safe no-op when `connect()` was never reached, so cleanup can never throw.
- **Tests** (`tests/ldapConnectionRelease.test.cjs`, added to `test:unit:node`): behavioural coverage of the
guard (disconnects after success and returns the result; disconnects exactly once; disconnects *and*
re-throws when the work throws — the failed-login path that exhausted OpenLDAP), negative cases (a failing
disconnect never turns a success into a failure nor hides the real error; a missing/`null` ldap is
tolerated), plus source-level guards that the leak-prone call sites actually route through the guard and
that `connect()` lives inside the guarded region.
- Thanks to the reporting operators and **xet7** (fix).
This release also updates the bundled FerretDB (see the fork's own CHANGELOG Upcoming): the SQLite backend
connection pool no longer caps `MaxOpenConns` at 16, which had starved WeKan's cursor-heavy load and made
boards take minutes to load and logins fail with *"Must be logged in"* ([#6467](https://github.com/wekan/wekan/issues/6467),
[#6469](https://github.com/wekan/wekan/issues/6469)).
- **Snap: FerretDB never becoming ready made WeKan hang with no web port and no explanation**
([#6476](https://github.com/wekan/wekan/issues/6476)). With `database=ferretdb`, `snap-src/bin/wekan-control`
waits for FerretDB to accept connections *before* starting WeKan — WeKan does not open its HTTP port until
the database answers. That wait loop had **no timeout and no diagnostics**: if FerretDB never started
listening (a crashed or CPU/architecture-incompatible per-arch binary, a locked or corrupt SQLite database,
or the FerretDB service left disabled by a failed migration hand-off), WeKan blocked there forever. The
service showed as `active`, but the port never opened and the only symptom was a silent
`FerretDB not ready yet, retrying in 5 seconds` repeating — exactly what #6476 reported (Apache proxy could
not connect to port 3333). The reporter's `wekan.log` stopped right after the startup env dump, confirming
control never reached `node main.js`.
- **Fixed** by mirroring the MongoDB branch: after `WEKAN_DB_WAIT_TIMEOUT` seconds (default 120,
overridable) the FerretDB wait loop now prints an actionable hint **once** and keeps retrying — pointing to
`snap logs <snap>.ferretdb` for the real error, naming the SQLite directory to check for locks/corruption,
the arch-mismatch (`exec format error`) case, the `snap start --enable <snap>.ferretdb` recovery, and the
`snap set <snap> database=mongodb` fallback to keep working meanwhile. This turns "port never opens, no
clue why" into a clear message. Regression test: `tests/ferretdbWaitTimeout.test.cjs`, including a negative
guard that the FerretDB wait is not a silent unbounded loop again.
- This is a robustness/diagnostics fix; the underlying reason FerretDB was not accepting connections is
environment-specific and lives in the FerretDB service log.
- Thanks to **uusijani** (report) and **xet7** (fix).
Thanks to above GitHub users for their contributions and translators for their translations.
# v9.98 2026-07-17 WeKan ® release
This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:
- **[EscapeBleed](https://wekan.fi/hall-of-fame/escapebleed/): incomplete string escaping when
building a regular expression** (GitHub CodeQL code scanning alert #423, rule
`js/incomplete-sanitization`, CWE-116 Improper Encoding or Escaping of Output;
`tests/maximizedCardPosition.test.cjs`). Code that turned a CSS declaration into a
`RegExp` escaped only parentheses (`str.replace(/[()]/g, '\\$&')`) instead of the full
regex metacharacter set — an input containing other metacharacters (including a backslash)
would not be escaped correctly, so the generated pattern could match the wrong thing.
- **Fixed** by escaping the complete metacharacter set
(`str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`), matching the correct pattern already used
elsewhere in the tests. This is test-only code with a fixed, trusted input list, so there
was no injection exposure, but the incomplete escape was genuinely wrong.
- Thanks to **GitHub CodeQL** (code scanning alert #423) and **xet7** (fix).
- **[RandomBleed](https://wekan.fi/hall-of-fame/randombleed/): biased random ids from a
cryptographically secure source** (GitHub CodeQL code scanning alert #422, rule
`js/biased-cryptographic-random`, CWE-1204 / weak randomness; `server/lib/schemaUpgradeSteps.js`).
The startup schema upgrade generates Meteor-style document ids with
`crypto.randomBytes(len)` mapped through `byte % ID_CHARS.length`. Because 256 is not a
multiple of the 55-character alphabet, that modulo skews generated ids toward the first 36
characters of the alphabet (each ~1.4% more likely than the rest) — reducing entropy of the
ids used for the swimlanes/lists/checklist-items the upgrade creates.
- **Fixed** with rejection sampling: bytes at or above the largest multiple of the alphabet
size (220) are discarded and resampled, so every character is exactly equally likely; ids
stay Meteor-style 17 characters (and exact-length for custom lengths). A negative
regression test pins that out-of-range bytes are never wrapped.
- Thanks to **GitHub CodeQL** (code scanning alert #422) and **xet7** (fix).
Thanks to above GitHub users for their contributions and translators for their translations.
# v9.97 2026-07-17 WeKan ® release
This release adds the following new features:
@@ -5012,7 +5786,7 @@ and adds the following new features:
relation type); Kendis/piplanning.io (and GitHub/GitLab) have no public
dependency format, so a generic `{ "lines": [...] }` JSON interchange is
documented for them. Documented in
[Features/RedStrings](https://github.com/wekan/wekan/blob/main/docs/Features/RedStrings/RedStrings.md).
[Features/RedStrings](https://github.com/wekan/wekan/blob/main/docs/Features/Editor/RedStrings/RedStrings.md).
Fixes \#3392. Thanks to CodeFreezr, dbt4u, helioguardabaxo and xet7.
- [Added an Admin Panel "Shared templates" view grouped by Organization / Team / email
Domain](https://github.com/wekan/wekan/commit/fc9e8674d89ec5b045f2e2c1b14fade9e92baf0d):
@@ -5025,7 +5799,7 @@ and adds the following new features:
the user's orgs/teams/email domains; the boards are shown as links into each template
board. Covered by an e2e suite (`tests/playwright/specs/26-shared-templates.e2e.js`).
Documented in
[Features/Templates](https://github.com/wekan/wekan/blob/main/docs/Features/Templates.md#shared-templates-admin-view).
[Features/Templates](https://github.com/wekan/wekan/blob/main/docs/Features/Board/Templates.md#shared-templates-admin-view).
Fixes #3313. Thanks to xet7.
and fixes the following bugs:
@@ -5231,7 +6005,7 @@ and adds the following new features:
- Added a Rules e2e test suite (`tests/playwright/specs/20-rules.e2e.js`, covering
the fullscreen page, creating event rules, import/export, selecting rules, the
workflow view and board buttons) and docs
([Features/Rules](https://github.com/wekan/wekan/blob/main/docs/Features/Rules/Rules.md),
([Features/Rules](https://github.com/wekan/wekan/blob/main/docs/Features/Automation/Rules/Rules.md),
updated IFTTT page).
- [Added **Jira import**](https://github.com/wekan/wekan/commit/f0bce67a3bf301c21ded3eb97e84a3be0010290f) ("All Boards → New → Import → From Jira"): import boards from
a Jira Cloud REST issue-search JSON, similar to the Trello importer. Jira statuses
+1 -1
View File
@@ -7,7 +7,7 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=9.97
ARG VERSION=10.09
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
+1 -1
View File
@@ -67,7 +67,7 @@ that by providing one-click installation on various platforms.
- WeKan ® is used in [most countries of the world](https://snapcraft.io/wekan).
- WeKan ® largest user has 30k users using WeKan ® in their company.
- WeKan ® has been [translated](https://app.transifex.com/wekan/) to about 105 languages.
- WeKan ® has been [translated](https://app.transifex.com/wekan/) to 154 languages.
- [Features][features]: WeKan ® has real-time user interface.
- [Platforms](https://wekan.fi/install/): WeKan ® supports many platforms.
WeKan ® is critical part of new platforms Wekan is currently being integrated to.
+28 -21
View File
@@ -10,8 +10,19 @@ because WeKan is NOT Big Tech. WeKan is FLOSS.
- **Report**: Click "New draft security advisory" at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
More info at [GitHub Docs about reporting privately](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/report-privately).
- **Proof of Concept (PoC)**: If possible, include a fix or a reproduction script/code.
- **If GitHub does not work**: Please send report to **security@wekan.fi**
- **Optional Email Encryption**: An optional PGP key is available at [security-at-wekan.fi.asc](security-at-wekan.fi.asc).
- DO NOT EMAIL [security@wekan.fi](security-at-wekan.fi.asc), because:
- Email is NOT secure or private.
- Using GitHub directly verifies that:
- You are:
- Saving my time by filling all details at GitHub, so that I can easily click request CVE at GitHub
- A correct GitHub user
- Really reading this SECURITY.md
- Knowing what you are doing
- You are not:
- Someone that tries to waste my time
- Trying to impersonate as some other GitHub user
- Spammer
- Bot
### 2. WeKan Security Team:
@@ -23,30 +34,26 @@ because WeKan is NOT Big Tech. WeKan is FLOSS.
### 3. Post-Release and Public Disclosure:
- Publicity: If you publish blog posts, give talks, or write articles about the
reported vulnerabilities, please send the links to us. We will link them
to your entry in the [Hall of Fame](https://wekan.fi/hall-of-fame/) for additional context.
- Advisories reported at GitHub are listed at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
## How Should Reports Be Formatted?
```
Name: %name
Twitter: %twitter
Bug type: %bugtype
Domain: %domain
Severity: %severity
URL: %url
PoC: %poc
CVSS (optional): %cvss
CWSS (optional): %cwss
```
- DO NOT EMAIL [security@wekan.fi](security-at-wekan.fi.asc), because:
- Email is NOT secure or private.
- Using GitHub directly verifies that:
- You are:
- Saving my time by filling all details at GitHub, so that I can easily click request CVE at GitHub
- A correct GitHub user
- Really reading this SECURITY.md
- Knowing what you are doing
- You are not:
- Someone that tries to waste my time
- Trying to impersonate as some other GitHub user
- Spammer
- Bot
## Who can participate in the program
Anyone who reports a unique security issue in scope and does not disclose it to
a third party before we have patched and updated may be upon their approval
added to the WeKan Hall of Fame https://wekan.fi/hall-of-fame/
a third party before we have patched, and is able to add
"New draft security advisory" at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
## Which domains are in scope?
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v9.97.0"
appVersion: "v10.09.0"
files:
userUploads:
- README.md
+14 -4
View File
@@ -7,7 +7,12 @@ import { TAPi18n } from '/imports/i18n';
import { Utils } from '/client/lib/utils';
import { getSidebarInstance } from '/client/features/sidebar/service';
const activitiesPerPage = 500;
// #6480/#6481: 500 was far more than the sidebar/card activity feed ever shows
// at once, and with FerretDB (no oplog) every live cursor is re-run on a timer —
// so a 500-row activities cursor was re-fetched and re-diffed on every poll for
// each open board/card. 50 covers the visible feed; infinite scroll still pulls
// the next page on demand (the server cursor is index-bounded either way).
const activitiesPerPage = 50;
Template.activities.onCreated(function () {
// Register with sidebar so it can call loadNextPage on us
@@ -234,7 +239,13 @@ Template.activity.helpers({
sourceLink() {
const source = this.activity.source;
if (source) {
if (source.url) {
// XSS fix (reported by meifukun): source.url is imported from an external
// board (e.g. a Trello board's `url`) and was rendered as an <a href> with
// no scheme check, so a `javascript:` (or data:/vbscript:) URL executed in
// the board admin's session on click and could exfiltrate Meteor.loginToken.
// Only render a link for an http(s) URL; otherwise show the plain (sanitized)
// source name with no href.
if (source.url && /^https?:\/\//i.test(String(source.url))) {
return Blaze.toHTML(
HTML.A(
{
@@ -243,9 +254,8 @@ Template.activity.helpers({
sanitizeHTML(source.system),
),
);
} else {
return sanitizeHTML(source.system);
}
return sanitizeHTML(source.system);
}
return null;
},
+10 -2
View File
@@ -4,14 +4,22 @@ template(name="archivedBoards")
i.fa.fa-archive
| {{_ 'archived-boards'}}
.admin-report-controls
input.js-archived-boards-search(type="search" placeholder="{{_ 'search'}}")
.admin-report-pagination
button.js-archived-boards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-archived-boards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
ul.archived-lists
each archivedBoards
li.archived-lists-item
div.board-header-btns
button.primary.board-header-btn.js-delete-board
button.primary.board-header-btn.js-delete-board(type="submit")
i.fa.fa-trash
| {{_ 'delete-board'}}
button.primary.board-header-btn.js-restore-board
button.primary.board-header-btn.js-restore-board(type="submit")
i.fa.fa-undo
| {{_ 'restore-board'}}
= title
+45 -6
View File
@@ -3,8 +3,22 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Boards from '/models/boards';
import { Utils } from '/client/lib/utils';
const ARCHIVED_BOARDS_PER_PAGE = 30;
Template.archivedBoards.onCreated(function () {
this.subscribe('archivedBoards');
// Server-side pagination: only the current page of archived boards is ever
// published/loaded, so a long-lived instance's archive stays fast.
this.page = new ReactiveVar(1);
this.total = new ReactiveVar(0);
this.searchQuery = new ReactiveVar('');
this.autorun(() => {
const searchTerm = this.searchQuery.get();
const skip = (this.page.get() - 1) * ARCHIVED_BOARDS_PER_PAGE;
this.subscribe('archivedBoards', searchTerm, ARCHIVED_BOARDS_PER_PAGE, skip);
Meteor.call('getArchivedBoardsCount', searchTerm, (err, count) => {
if (!err) this.total.set(count || 0);
});
});
});
Template.archivedBoards.helpers({
@@ -13,17 +27,42 @@ Template.archivedBoards.helpers({
},
archivedBoards() {
const ret = ReactiveCache.getBoards(
// Only the current page is in minimongo (the publication is limited).
return ReactiveCache.getBoards(
{ archived: true },
{
sort: { archivedAt: -1, modifiedAt: -1 },
},
{ sort: { archivedAt: -1, modifiedAt: -1 } },
);
return ret;
},
currentPage() { return Template.instance().page.get(); },
totalPages() { return Math.max(1, Math.ceil((Template.instance().total.get() || 0) / ARCHIVED_BOARDS_PER_PAGE)); },
hasPrevPage() { return Template.instance().page.get() > 1; },
hasNextPage() {
const tpl = Template.instance();
return tpl.page.get() < Math.max(1, Math.ceil((tpl.total.get() || 0) / ARCHIVED_BOARDS_PER_PAGE));
},
});
Template.archivedBoards.events({
'keydown .js-archived-boards-search'(event, tpl) {
if (event.keyCode === 13) {
event.preventDefault();
tpl.searchQuery.set(tpl.$('.js-archived-boards-search').val() || '');
tpl.page.set(1);
}
},
'click .js-archived-boards-prev-page'(event, tpl) {
event.preventDefault();
const current = tpl.page.get();
if (current > 1) tpl.page.set(current - 1);
},
'click .js-archived-boards-next-page'(event, tpl) {
event.preventDefault();
const total = tpl.total.get() || 0;
const totalPages = Math.max(1, Math.ceil(total / ARCHIVED_BOARDS_PER_PAGE));
const current = tpl.page.get();
if (current < totalPages) tpl.page.set(current + 1);
},
async 'click .js-restore-board'() {
// TODO : Make isSandstorm variable global
const isSandstorm =
+3 -1
View File
@@ -23,7 +23,7 @@ template(name="boardBody")
if debugBoardState
.debug-info(style="position: fixed; top: 0; inset-inline-start: 0; background: rgba(0,0,0,0.8); color: white; padding: 10px; z-index: 9999; font-size: 12px;")
| {{_ 'board'}}: {{currentBoard.title}} | {{_ 'view'}}: {{boardView}} | {{_ 'has-swimlanes'}}: {{hasSwimlanes}} | {{_ 'swimlanes'}}: {{currentBoard.swimlanes.length}}
.board-wrapper(class=currentBoard.colorClass class="{{#if isMiniScreen}}mobile-view{{/if}}")
.board-wrapper(class="{{#if currentUser.globalThemeColorClass}}{{currentUser.globalThemeColorClass}}{{else}}{{currentBoard.colorClass}}{{/if}}" class="{{#if isMiniScreen}}mobile-view{{/if}}")
.board-canvas.js-swimlanes.dragscroll(
class="{{#if Sidebar.isOpen}}is-sibling-sidebar-open{{/if}}"
class="{{#if MultiSelection.isActive}}is-multiselection-active{{/if}}"
@@ -52,6 +52,8 @@ template(name="boardBody")
+ganttView
else if isViewTable
+tableView
else if isViewStats
+statsView
else
// Default view - show swimlanes if they exist, otherwise show lists
if hasSwimlanes
+16
View File
@@ -73,6 +73,9 @@ Template.board.onCreated(function () {
}
const handle = Meteor.subscribe('board', currentBoardId, false);
// Learn this board's card-loading mode (lazy vs eager) so 'auto' can decide
// per board by size; the flag drives isLazyCards(boardId). #6480.
Meteor.subscribe('boardCardsLoadingMode', currentBoardId);
const ready = handle.ready();
this.isBoardReady.set(ready);
@@ -705,6 +708,19 @@ Template.boardBody.helpers({
return boardView === 'board-view-table';
},
isViewStats() {
const currentUser = ReactiveCache.getCurrentUser();
let boardView;
if (currentUser) {
boardView = (currentUser.profile || {}).boardView;
} else {
boardView = window.localStorage.getItem('boardView');
}
return boardView === 'board-view-stats';
},
hasSwimlanes() {
const currentBoard = Utils.getCurrentBoard();
if (!currentBoard) {
+13 -6
View File
@@ -131,6 +131,8 @@ template(name="boardHeaderBar")
i.fa.fa-bar-chart
if $eq boardView 'board-view-table'
i.fa.fa-table
if $eq boardView 'board-view-stats'
i.fa.fa-pie-chart
span
if $eq boardView 'board-view-swimlanes'
| {{_ 'swimlanes'}}
@@ -142,6 +144,8 @@ template(name="boardHeaderBar")
| {{_ 'gantt'}}
if $eq boardView 'board-view-table'
| {{_ 'board-view-table'}}
if $eq boardView 'board-view-stats'
| {{_ 'board-view-stats'}}
a.board-header-btn.js-toggle-dependencies(
title="{{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}"
class="{{#if currentBoard.showDependencies}}emphasis{{/if}}")
@@ -159,12 +163,8 @@ template(name="boardHeaderBar")
i.fa.fa-times-thin
.separator
//- #6465: one-click board settings — the boardMenu popup used to need
//- two clicks (open sidebar, then Boardeinstellungen). The click
//- handler already exists in boardHeader.js ('click .js-open-board-menu').
if currentUser.isBoardMember
a.board-header-btn.js-open-board-menu(title="{{_ 'boardMenuPopup-title'}}")
i.fa.fa-cog
//- Board Settings cog removed: Board Settings is available in the right
//- sidebar (the hamburger), so the header no longer duplicates it.
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
i.fa.fa-bars
@@ -255,6 +255,13 @@ template(name="boardChangeViewPopup")
| {{_ 'board-view-table'}}
if $eq Utils.boardView "board-view-table"
i.fa.fa-check
li
with "board-view-stats"
a.js-open-stats-view
i.fa.fa-pie-chart
| {{_ 'board-view-stats'}}
if $eq Utils.boardView "board-view-stats"
i.fa.fa-check
template(name="createBoard")
form
+4 -1
View File
@@ -130,7 +130,6 @@ Template.boardHeaderBar.events({
Meteor.call('toggleBoardStar', boardId);
}
},
'click .js-open-board-menu': Popup.open('boardMenu'),
'click .js-change-visibility': Popup.open('boardChangeVisibility'),
'click .js-watch-board': Popup.open('boardChangeWatch'),
'click .js-open-archived-board'() {
@@ -249,6 +248,10 @@ Template.boardChangeViewPopup.events({
Utils.setBoardView('board-view-table');
Popup.back();
},
'click .js-open-stats-view'() {
Utils.setBoardView('board-view-stats');
Popup.back();
},
});
// Shared setup for all create board popups
+153 -64
View File
@@ -1,13 +1,5 @@
@import url("../../../public/css/reset.css") print, screen;
/* Board List Header with Zoom Controls */
.board-list-header {
display: flex;
justify-content: center;
margin: 1vh 0 2vh 0;
padding: 1vh 0;
}
/* Two-column layout for All Boards */
.boards-layout {
display: grid;
@@ -260,6 +252,9 @@
align-items: center;
justify-content: space-between;
gap: 8px;
/* Wrap to a 2nd row on narrow windows so the search box and the Sort /
Multi-Selection / Clear buttons stay visible instead of overflowing. */
flex-wrap: wrap;
padding: 12px 16px;
margin-bottom: 16px;
background: #f5f5f5;
@@ -273,6 +268,46 @@
align-items: center;
gap: 8px;
flex: 1;
min-width: 0; /* allow shrinking so path-right can wrap below instead of overflowing */
flex-wrap: wrap; /* the multi-selection hint wraps under the title when tight */
}
/* Keep the folder icon and the menu title (e.g. "Remaining") on ONE line, the
title to the RIGHT of the icon — never let flex-wrap stack them vertically. */
.boards-path-header .path-title {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
/* Multi-Selection button with its hint stacked underneath (own column). */
.boards-path-header .multiselection-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
/* "Selected:" actions row (star / home) below the Multi-Selection button. */
.boards-path-header .selected-actions {
display: flex;
align-items: center;
gap: 8px;
}
.boards-path-header .selected-label {
font-size: 13px;
color: #333;
}
.boards-path-header .selected-action {
cursor: pointer;
color: #333;
font-size: 16px;
line-height: 1;
padding: 2px 4px;
}
.boards-path-header .selected-action:hover {
color: #2980b9;
}
.boards-path-header .multiselection-hint {
@@ -295,6 +330,10 @@
display: flex;
align-items: center;
gap: 8px;
/* Buttons (Sort / Multi-Selection / Clear) wrap among themselves too, so none
is ever cut off on a narrow window. */
flex-wrap: wrap;
justify-content: flex-end;
}
/* #5799: board-name search box in the All Boards header */
@@ -306,6 +345,9 @@
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
/* Roughly half the previous width; the input flexes to fill what's left. */
width: 300px;
max-width: 100%;
}
.boards-path-header .board-search input {
@@ -313,7 +355,11 @@
outline: none;
background: transparent;
font-size: 14px;
min-width: 140px;
flex: 1 1 auto;
min-width: 0;
/* Nudge the text down so it sits at the vertical middle of the field. */
position: relative;
top: 8px;
}
.boards-path-header .board-search .board-search-clear {
@@ -517,19 +563,33 @@
overflow: hidden;
background-color: inherit; /* Inherit board color from parent li.js-board */
color: #f6f6f6;
min-height: 100px;
min-height: 72px; /* #6465: thinner tiles so more boards fit in the overview */
font-size: 16px;
line-height: 22px;
border-radius: 0; /* No border-radius - parent .js-board has it */
display: block;
font-weight: 700;
padding: 36px 8px 32px 8px; /* Top padding for drag handle, bottom for checkbox */
padding: 24px 8px 18px 8px; /* #6465: trimmed; top leaves room for the drag handle, bottom for the checkbox */
margin: 0; /* No margin - moved to parent .js-board */
position: relative;
text-decoration: none;
word-wrap: break-word;
}
/* #2220: Home-board badge — a home icon centred at the top of the board tile. */
.board-list .board-home-badge {
position: absolute;
top: 2px;
left: 50%;
transform: translateX(-50%);
z-index: 3;
color: #fff;
font-size: 14px;
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
pointer-events: none;
}
.board-list .board-list-item > .js-open-board {
text-decoration: none;
color: inherit;
@@ -561,16 +621,27 @@
}
.board-list .js-add-board .label {
font-weight: normal;
line-height: 56px;
min-height: 100px;
/* Match the board tiles (#6465 thinner tiles) so the grey "+ Add Board" /
"+ Add Template Container" box is the same height as a board icon:
same min-height, padding and line-height as .board-list-item above. */
line-height: 22px;
min-height: 72px;
display: flex;
align-items: center;
justify-content: center;
background-color: #999; /* Darker background for better text contrast */
border-radius: 3px;
padding: 36px 8px 32px 8px;
/* Same total vertical padding as .board-list-item (42px -> same tile height),
but shifted up (top 24->18, bottom 18->24) so the "+ Add Board" text sits
higher. */
padding: 18px 8px 24px 8px;
color: #fff; /* White text */
}
/* "+ Add Template Container" text sits 2px lower than "+ Add Board" (same 42px
total vertical padding, so the tile height is unchanged). */
.board-list .js-add-board .label.is-template-container {
padding: 20px 8px 22px 8px;
}
.board-list .js-add-board .label i {
color: #fff; /* White icon */
}
@@ -668,12 +739,14 @@
.board-list .board-handle {
position: absolute;
padding: 4px 6px;
top: 4px;
left: 50%;
transform: translateX(-50%);
/* Right middle of the tile (matches the mobile/small-screen handle position). */
top: 50%;
inset-inline-end: 6px;
transform: translateY(-50%);
font-size: 14px;
color: #fff;
background: rgba(0,0,0,0.4);
/* Transparent background so only the drag-handle icon shows (not a box). */
background: transparent;
border-radius: 4px;
display: flex;
align-items: center;
@@ -812,20 +885,25 @@ body.grey-icons-enabled .checkmark-no-grey {
/* Mobile view styles - applied when isMiniScreen is true (iPhone, etc.) */
.board-list.mobile-view {
/* #6488: a real 2-column grid — robust regardless of float support or how narrow
the boards column is (at least 2 board icons per row on phones). */
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
height: calc(100vh - 120px);
overflow-y: scroll !important;
overflow-y: auto !important;
overflow-x: hidden;
padding: 0 1rem;
padding: 0 0.5rem;
margin: 0;
scrollbar-width: auto !important;
scrollbar-color: #888 #f1f1f1;
}
.board-list.mobile-view li {
width: 100%;
width: auto; /* the grid controls the column width */
float: none;
display: block;
margin-bottom: 1rem;
padding-inline-end: 50px; /* Space for drag handle */
margin-bottom: 0.5rem;
padding-inline-end: 32px; /* Space for drag handle */
}
.board-list.mobile-view .board-list-item {
overflow: visible;
@@ -893,11 +971,6 @@ body.grey-icons-enabled .checkmark-no-grey {
background: #555 !important;
}
/* Force mobile view to have scrollable content */
.board-list.mobile-view {
min-height: 100vh; /* Force content to be tall enough to scroll */
}
/* Hide archive and clone board buttons in mobile view */
.board-list.mobile-view .js-archive-board,
.board-list.mobile-view .js-clone-board {
@@ -910,38 +983,41 @@ body.grey-icons-enabled .checkmark-no-grey {
font-family: inherit !important;
}
.board-list.mobile-view::after {
content: '';
display: block;
height: 100px;
}
@media screen and (max-width: 800px),
screen and (max-device-width: 800px),
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
screen and (max-width: 800px) and (orientation: portrait),
screen and (max-width: 800px) and (orientation: landscape),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
/* #6488 + mobile layout: keep the left menu on the left but narrow (so the boards
get room), and show the board icons on the right at least 2 per row. */
.boards-layout {
grid-template-columns: minmax(84px, 30%) 1fr;
gap: 8px;
}
.board-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 0 1rem;
padding: 0 0.25rem;
margin: 0;
}
.board-list li {
width: 100%;
width: auto; /* the grid controls the column width */
float: none;
display: block;
margin-bottom: 1rem;
padding-inline-end: 50px; /* Space for drag handle */
margin-bottom: 0.5rem;
padding-inline-end: 32px; /* Space for drag handle */
}
.board-list .board-list-item {
overflow: visible;
height: 8rem;
width: 100%;
margin: 0;
padding-inline-end: 50px; /* Ensure content doesn't overlap with drag handle */
padding-inline-end: 40px; /* Ensure content doesn't overlap with drag handle */
}
.board-list .board-list-item .details {
@@ -977,10 +1053,15 @@ body.grey-icons-enabled .checkmark-no-grey {
background: rgba(255, 255, 0, 0.8) !important; /* Yellow hover */
}
}
/* Very small screens - ensure one board per row */
/* Very small screens - still keep at least 2 board icons per row (#6488). */
@media screen and (max-width: 360px) {
.board-list {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
gap: 6px;
}
.board-list li {
width: 100% !important;
width: auto !important;
float: none !important;
display: block !important;
}
@@ -1009,20 +1090,16 @@ body.grey-icons-enabled .checkmark-no-grey {
screen and (max-width: 800px) and (orientation: portrait),
screen and (max-width: 800px) and (orientation: landscape),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
/* Stack the two-column All Boards layout on small screens. The fixed
"260px 1fr" grid had no mobile collapse, so on narrow non-iPhone browsers
(Fairphone Firefox ESR / Ubuntu Touch Morph, narrow desktop) it overflowed
and the left menu (Starred / Templates / Remaining / Workspaces / + Add
board) was clipped. Width-based, so it applies to every browser. */
/* #6488: KEEP the two-column layout (narrow left menu, board icons on the RIGHT)
on small touch screens — do NOT stack the boards below the menu. Stacking pushed
the board icons under the menu + search bar, and dragscroll does not work over
the menu / search area, so boards below the fold were unreachable (you could not
drag-scroll down to them). The narrow minmax() menu set in the block above
already prevents the old fixed-260px overflow that used to clip the left menu, so
collapsing to one column is no longer needed. The board list stays in its own
right-hand column with its own overflow-y:auto scroll. */
.boards-layout {
grid-template-columns: 1fr !important;
}
.boards-left-menu {
border-inline-end: none !important;
border-bottom: 1px solid #e0e0e0;
padding-inline-end: 0 !important;
padding-bottom: 12px;
grid-template-columns: minmax(84px, 30%) 1fr !important;
}
}
.AllBoardTeamsOrgs {
@@ -1146,13 +1223,31 @@ body.grey-icons-enabled .checkmark-no-grey {
height: 100vh;
}
/* The left menu (Starred / Templates / Remaining / Workspaces / Add board) is its
own vertical scroll area, so when it is taller than the screen you can drag-scroll
down to its lower items — the board icons already scroll in their own column on the
right. Both columns are reachable in LTR and RTL: the grid columns and the logical
inline borders mirror automatically, so in RTL the menu is on the right and the
boards on the left. */
.boards-left-menu {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
max-height: calc(100vh - 120px);
}
.board-list {
overflow-y: scroll !important;
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #888 #f1f1f1;
height: calc(100vh - 120px); /* Ensure there's content to scroll */
/* #6488: a BOUNDED height so the boards overflow and scroll. The old rule set
min-height:100vh (plus a phantom ::after), which grew the list to fit all its
content — so nothing overflowed and, clipped by the overflow:hidden wrapper,
boards below the fold were unreachable. */
height: calc(100vh - 120px);
max-height: calc(100vh - 120px);
}
/* Force scrollbar to always be visible */
@@ -1189,14 +1284,8 @@ body.grey-icons-enabled .checkmark-no-grey {
.board-list {
scrollbar-gutter: stable;
scrollbar-width: auto !important;
min-height: 100vh; /* Force content to be tall enough to scroll */
}
/* Ensure there's always content to scroll */
.board-list::after {
content: '';
display: block;
height: 100px;
/* NOTE: no min-height/::after here — the bounded height above is what makes the
list scroll; forcing it 100vh tall would defeat that (see #6488). */
}
/* Ensure only one scrollbar is visible */
+46 -28
View File
@@ -1,7 +1,5 @@
template(name="boardList")
.wrapper
.board-list-header
.boards-layout
// Left menu
.boards-left-menu
@@ -77,26 +75,11 @@ template(name="boardList")
.boards-right-grid
.boards-path-header
.path-left
span.path-icon.emoji-icon {{currentMenuPath.icon}}
span.path-text {{currentMenuPath.text}}
if BoardMultiSelection.isActive
span.multiselection-hint
span.emoji-icon
i.fa.fa-thumb-tack
| {{_ 'multi-selection-active'}}
span.path-title
span.path-icon.emoji-icon {{currentMenuPath.icon}}
span.path-text {{currentMenuPath.text}}
.path-right
.board-search
span.emoji-icon
i.fa.fa-search
input.js-board-search-input(type="text" placeholder="{{_ 'search-boards'}}" value="{{boardSearch}}" autocomplete="off")
if boardSearch
a.board-search-clear.js-board-search-clear(title="{{_ 'filter-clear'}}")
span.emoji-icon
i.fa.fa-times
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
span.emoji-icon
i.fa.fa-sort-alpha-asc
span {{_ 'sort'}}
//- Order (left -> right): Multi-Selection, Sort, Search boards.
if canModifyBoards
if hasBoardsSelected
button.js-archive-selected-boards.board-header-btn
@@ -107,13 +90,41 @@ template(name="boardList")
span.emoji-icon
i.fa.fa-clipboard
span {{_ 'duplicate-board'}}
a.board-header-btn.js-multiselection-activate(
title="{{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
class="{{#if BoardMultiSelection.isActive}}emphasis{{/if}}")
span.emoji-icon
//- Multi-Selection button, with the "click checkboxes" hint stacked
directly BELOW it (own column) instead of beside the path title.
.multiselection-group
a.board-header-btn.js-multiselection-activate(
title="{{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
class="{{#if BoardMultiSelection.isActive}}emphasis{{/if}}")
i.fa.fa-check-square-o
if BoardMultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
span {{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
if BoardMultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
if BoardMultiSelection.isActive
span.multiselection-hint
span.emoji-icon
i.fa.fa-thumb-tack
| {{_ 'multi-selection-active'}}
//- "Selected:" actions on the multi-selected boards: star them,
or set one as the Home board (opened after login, #2220).
.selected-actions
span.selected-label {{_ 'selected-label'}}
a.selected-action.js-star-selected(title="{{_ 'set-selected-starred'}}")
i.fa.fa-star
a.selected-action.js-home-selected(title="{{_ 'set-selected-home'}}")
i.fa.fa-home
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
span.emoji-icon
i.fa.fa-sort-alpha-asc
span {{_ 'sort'}}
//- Search boards is placed to the RIGHT of the Multi-Selection button.
.board-search
span.emoji-icon
i.fa.fa-search
input.js-board-search-input(type="text" placeholder="{{_ 'search-boards'}}" value="{{boardSearch}}" autocomplete="off")
if boardSearch
a.board-search-clear.js-board-search-clear(title="{{_ 'filter-clear'}}")
span.emoji-icon
i.fa.fa-times
if boardsPaginationActive
@@ -126,7 +137,7 @@ template(name="boardList")
ul.board-list.clearfix.js-boards.dragscroll(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if BoardMultiSelection.isActive}}is-multiselection-active{{/if}}")
li.js-add-board
if isSelectedMenu 'templates'
a.board-list-item.label(title="{{_ 'add-template-container'}}")
a.board-list-item.label.is-template-container(title="{{_ 'add-template-container'}}")
span.emoji-icon
i.fa.fa-plus
| &nbsp;{{_ 'add-template-container'}}
@@ -188,6 +199,11 @@ template(name="boardList")
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
//- #2220: the Home board (opened after login) is badged with a
home icon at the top centre of its tile.
if isDefaultBoard
span.board-home-badge(title="{{_ 'home-board-badge'}}")
i.fa.fa-home
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
span.emoji-icon
i.fa.fa-arrows
@@ -228,6 +244,8 @@ template(name="boardList")
title="{{_ 'star-board-title'}}")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
//- #2220 Home-board toggle is disabled: the icon and the
//- set-default-board action are intentionally not rendered.
template(name="boardsSortPopup")
ul.pop-over-list
+56 -7
View File
@@ -542,6 +542,21 @@ Template.boardList.helpers({
const boards = ReactiveCache.getBoards(query, {});
const currentUser = ReactiveCache.getCurrentUser();
// #2220: the Home board (opened after login) always appears FIRST in the
// Starred view, even when it has not been explicitly starred.
const withHomeFirst = (arr) => {
if (tpl.selectedMenu.get() !== 'starred') return arr;
if ((tpl.boardSearchVar.get() || '').trim()) return arr;
const homeId =
currentUser && typeof currentUser.getDefaultBoardId === 'function'
? currentUser.getDefaultBoardId()
: null;
if (!homeId) return arr;
const homeBoard = ReactiveCache.getBoard(homeId);
if (!homeBoard || homeBoard.archived) return arr;
return [homeBoard, ...arr.filter((b) => b && b._id !== homeId)];
};
// #5799: in a sorted (non-custom) mode the server already computed the
// current page (filtered by menu/search and sorted), so render exactly that
// ordered page of board icons. Custom (manual drag order) falls through to
@@ -552,9 +567,11 @@ Template.boardList.helpers({
: 'custom';
if (sortMode !== 'custom') {
const paged = tpl.pagedBoardsVar.get();
return (paged.ids || [])
.map((id) => ReactiveCache.getBoard(id))
.filter(Boolean);
return withHomeFirst(
(paged.ids || [])
.map((id) => ReactiveCache.getBoard(id))
.filter(Boolean),
);
}
let list = boards;
@@ -591,11 +608,11 @@ Template.boardList.helpers({
}
if (currentUser && typeof currentUser.sortBoardsForUser === 'function') {
return currentUser.sortBoardsForUser(list);
return withHomeFirst(currentUser.sortBoardsForUser(list));
}
return list
.slice()
.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
return withHomeFirst(
list.slice().sort((a, b) => (a.title || '').localeCompare(b.title || '')),
);
},
// #5174 / #4825: the board tiles' per-list card-count line and member avatar
// row. Data comes from the one-shot getAllBoardsTileData method fetch in
@@ -629,6 +646,11 @@ Template.boardList.helpers({
const user = ReactiveCache.getCurrentUser();
return user && user.hasStarred(this._id);
},
// #2220: is this the user's Home board (the one opened after login)?
isDefaultBoard() {
const user = ReactiveCache.getCurrentUser();
return user && user.isDefaultBoard(this._id);
},
isAdministrable() {
const user = ReactiveCache.getCurrentUser();
return user && user.isBoardAdmin(this._id);
@@ -916,6 +938,29 @@ Template.boardList.events({
Meteor.call('toggleBoardStar', boardId);
}
},
// "Selected:" star action: star every multi-selected board that isn't yet
// starred (so it becomes Starred, never toggled back off by this button).
'click .js-star-selected'(evt) {
evt.preventDefault();
evt.stopPropagation();
const user = ReactiveCache.getCurrentUser();
BoardMultiSelection.getSelectedBoardIds().forEach((id) => {
if (user && !user.hasStarred(id)) {
Meteor.call('toggleBoardStar', id);
}
});
},
// #2220 "Selected:" home action: set the first selected board as the Home
// board (opened after login). Clicking it again when it is already Home clears
// it (toggle), matching toggleDefaultBoard.
'click .js-home-selected'(evt) {
evt.preventDefault();
evt.stopPropagation();
const ids = BoardMultiSelection.getSelectedBoardIds();
if (ids.length) {
Meteor.call('toggleDefaultBoard', ids[0]);
}
},
// HTML5 DnD from boards to spaces
// #5850: drag a (template) board onto an Org/Team/Domain target to share it.
'dragover .js-share-target'(evt) {
@@ -1103,6 +1148,10 @@ Template.boardList.events({
},
'click .js-multiselection-reset'(evt) {
evt.preventDefault();
// The reset X is now nested inside the activate button (matching the Swimlanes
// view), so stop the click from bubbling to js-multiselection-activate — which
// would otherwise immediately re-activate what we just disabled.
evt.stopPropagation();
BoardMultiSelection.disable();
},
'click .js-toggle-board-multi-selection'(evt) {
+55
View File
@@ -0,0 +1,55 @@
/* Board "Statistics" view — full width, like the other board views. Its text is
selectable so any value can be highlighted and copied. */
.stats-view {
width: 100%;
height: 100%;
overflow: auto;
user-select: text;
-webkit-user-select: text;
}
.stats-view-content {
max-width: 900px;
margin: 0 auto;
padding: 24px;
}
.stats-view-title {
margin: 0 0 16px;
font-size: 22px;
font-weight: bold;
}
.stats-view-section {
margin-bottom: 28px;
}
.stats-view-heading {
margin: 0 0 8px;
font-size: 16px;
font-weight: bold;
border-bottom: 2px solid #dbdbdb;
padding-bottom: 4px;
}
.stats-view-table {
width: 100%;
border-collapse: collapse;
}
.stats-view-table td {
padding: 8px 4px;
border-bottom: 1px solid #eee;
user-select: text;
-webkit-user-select: text;
}
.stats-view-table .stats-view-label {
color: #666;
width: 60%;
}
.stats-view-table .stats-view-value {
font-weight: bold;
text-align: right;
}
+45
View File
@@ -0,0 +1,45 @@
template(name="statsView")
.stats-view
.stats-view-content
h1.stats-view-title {{ boardTitle }}
.stats-view-section
h2.stats-view-heading {{_ 'board-status'}}
table.stats-view-table
tbody
tr
td.stats-view-label {{_ 'board-status-loading-mode'}}
td.stats-view-value {{ loadingModeLabel }}
tr
td.stats-view-label {{_ 'swimlanes'}}
td.stats-view-value {{ swimlaneCount }}
tr
td.stats-view-label {{_ 'lists'}}
td.stats-view-value {{ listCount }}
tr
td.stats-view-label {{_ 'cards'}}
td.stats-view-value {{ cardCount }}
tr
td.stats-view-label {{_ 'card-archived'}}
td.stats-view-value {{ archivedCardCount }}
tr
td.stats-view-label {{_ 'labels'}}
td.stats-view-value {{ labelCount }}
tr
td.stats-view-label {{_ 'members'}}
td.stats-view-value {{ memberCount }}
tr
td.stats-view-label {{_ 'custom-fields'}}
td.stats-view-value {{ customFieldCount }}
.stats-view-section
h2.stats-view-heading {{_ 'board-status-time-summary'}}
table.stats-view-table
tbody
tr
td.stats-view-label {{_ 'board-status-time-spent-total'}}
td.stats-view-value {{ timeSpentTotal }}
tr
td.stats-view-label {{_ 'board-status-cards-with-time'}}
td.stats-view-value {{ cardsWithTimeSpent }}
tr
td.stats-view-label {{_ 'board-status-overtime-cards'}}
td.stats-view-value {{ overtimeCards }}
+70
View File
@@ -0,0 +1,70 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { ReactiveVar } from 'meteor/reactive-var';
import { Utils } from '/client/lib/utils';
// Board view "Statistics" (Tilastot) — a full-width board view (like Swimlanes /
// Lists / Calendar / Gantt / Table) showing the board's status: its card-loading
// mode, counts (swimlanes, lists, cards, labels, members, custom fields) and a
// time-spent summary. Counts come from the server `boardStatus` method so they are
// accurate even in lazy mode, where the browser only holds the visible card window.
Template.statsView.onCreated(function() {
this.status = new ReactiveVar(null);
this.autorun(() => {
const boardId = Session.get('currentBoard');
if (boardId) {
Meteor.call('boardStatus', boardId, (err, res) => {
if (!err && res) this.status.set(res);
});
}
});
});
// Read one numeric field from the resolved status, or a placeholder while loading.
function statsNum(key) {
const s = Template.instance().status.get();
return s ? (s[key] || 0) : '…';
}
Template.statsView.events({
// The board canvas uses drag-to-scroll (the `dragscroll` class), which otherwise
// swallows pointer/touch drags and prevents selecting text. Stop these events from
// reaching it so the browser's native selection works — with the mouse (drag) and
// on touch (long-press) — and any value can be copied to the clipboard.
'mousedown .stats-view'(event) {
event.stopPropagation();
},
'touchstart .stats-view'(event) {
event.stopPropagation();
},
});
Template.statsView.helpers({
boardTitle() {
const board = Utils.getCurrentBoard();
return board ? board.title : '';
},
loadingModeLabel() {
const s = Template.instance().status.get();
if (!s) return '…';
const inEffect = s.lazy
? TAPi18n.__('cards-loading-lazy')
: TAPi18n.__('cards-loading-all');
return s.mode === 'auto' ? `${inEffect} (${TAPi18n.__('cards-loading-auto')})` : inEffect;
},
swimlaneCount() { return statsNum('swimlanes'); },
listCount() { return statsNum('lists'); },
cardCount() { return statsNum('cards'); },
archivedCardCount() { return statsNum('archivedCards'); },
labelCount() { return statsNum('labels'); },
memberCount() { return statsNum('members'); },
customFieldCount() { return statsNum('customFields'); },
cardsWithTimeSpent() { return statsNum('cardsWithTimeSpent'); },
overtimeCards() { return statsNum('overtimeCards'); },
timeSpentTotal() {
const s = Template.instance().status.get();
if (!s) return '…';
const n = Number(s.timeSpentTotal) || 0;
return `${n} h`;
},
});
-12
View File
@@ -63,18 +63,6 @@
color: #333;
}
/* Sortable column headers (Excel-like): clickable, with a sort-direction
arrow appended by the sortIndicator helper. */
.my-cards-board-table th.js-table-view-sort {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.my-cards-board-table th.js-table-view-sort:hover {
color: #01628c;
}
/* Leftmost "Edit" cell: an Edit link (pencil icon + text) styled like the one
on the Admin Panel / People page, opening the card when clicked. */
.my-cards-board-table td.table-view-cell-edit {
+7 -7
View File
@@ -18,21 +18,21 @@ template(name="tableView")
thead
tr
th(scope="col")
th.js-table-view-sort(scope="col" data-sort="card") {{_ "card"}} {{sortIndicator 'card'}}
th.js-table-view-sort(scope="col" data-sort="list") {{_ "list"}} {{sortIndicator 'list'}}
th.js-table-view-sort(scope="col" data-sort="swimlane") {{_ "swimlane"}} {{sortIndicator 'swimlane'}}
th(scope="col") {{_ "card"}}
th(scope="col") {{_ "list"}}
th(scope="col") {{_ "swimlane"}}
unless isMiniScreen
th(scope="col") {{_ "assignees"}}
th(scope="col") {{_ "members"}}
th(scope="col") {{_ "labels"}}
if showReceivedColumn
th.js-table-view-sort(scope="col" data-sort="received") {{_ "card-received"}} {{sortIndicator 'received'}}
th(scope="col") {{_ "card-received"}}
if showStartColumn
th.js-table-view-sort(scope="col" data-sort="start") {{_ "card-start"}} {{sortIndicator 'start'}}
th(scope="col") {{_ "card-start"}}
if showDueColumn
th.js-table-view-sort(scope="col" data-sort="due") {{_ "card-due"}} {{sortIndicator 'due'}}
th(scope="col") {{_ "card-due"}}
if showEndColumn
th.js-table-view-sort(scope="col" data-sort="end") {{_ "card-end"}} {{sortIndicator 'end'}}
th(scope="col") {{_ "card-end"}}
tbody
each row in rows
tr
+5 -57
View File
@@ -14,8 +14,6 @@ const rowsPerPage = 25;
Template.tableView.onCreated(function () {
this.searchQuery = new ReactiveVar('');
this.sortField = new ReactiveVar('card'); // card | list | swimlane | due
this.sortDirection = new ReactiveVar(1); // 1 ascending, -1 descending
this.page = new ReactiveVar(1);
this.filteredRows = new ReactiveVar([]);
@@ -30,8 +28,6 @@ Template.tableView.onCreated(function () {
}
const query = this.searchQuery.get().trim().toLowerCase();
const field = this.sortField.get();
const direction = this.sortDirection.get();
const rows = [];
board.cards().forEach(card => {
@@ -75,39 +71,11 @@ Template.tableView.onCreated(function () {
});
}
// Map a date sort field to the matching row property.
const dateFieldProp = {
received: 'receivedAt',
start: 'startAt',
due: 'dueAt',
end: 'endAt',
};
filtered = filtered.slice().sort((a, b) => {
const dateProp = dateFieldProp[field];
if (dateProp) {
// Cards without the date sort last, regardless of direction.
const av = a[dateProp] ? new Date(a[dateProp]).getTime() : Infinity;
const bv = b[dateProp] ? new Date(b[dateProp]).getTime() : Infinity;
return (av - bv) * direction;
}
let av;
let bv;
if (field === 'list') {
av = a.listTitle;
bv = b.listTitle;
} else if (field === 'swimlane') {
av = a.swimlaneTitle;
bv = b.swimlaneTitle;
} else {
av = a.title;
bv = b.title;
}
return (
av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) *
direction
);
});
// Fixed order: by card title, ascending. (Column-header click-to-sort was
// removed; the Table view now always shows this stable order.)
filtered = filtered.slice().sort((a, b) =>
a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' }),
);
this.filteredRows.set(filtered);
});
@@ -172,13 +140,6 @@ Template.tableView.helpers({
const board = Utils.getCurrentBoard();
return !!board && (board.allowsEndDate || board.allowsEndDateOnMinicard);
},
// Excel-like sort arrow shown on the active sort column header.
sortIndicator(field) {
const tpl = Template.instance();
if (tpl.sortField.get() !== field) return '';
return tpl.sortDirection.get() === 1 ? '▲' : '▼';
},
});
Template.tableView.events({
@@ -212,19 +173,6 @@ Template.tableView.events({
if (current < totalPages) tpl.page.set(current + 1);
},
'click .js-table-view-sort'(event, tpl) {
event.preventDefault();
const field = event.currentTarget.dataset.sort;
if (!field) return;
if (tpl.sortField.get() === field) {
tpl.sortDirection.set(tpl.sortDirection.get() * -1);
} else {
tpl.sortField.set(field);
tpl.sortDirection.set(1);
}
tpl.page.set(1);
},
// Clicking the leftmost "Edit" link opens the Card Details popup on top of the
// Board Table view (same mechanism as opening a card from search results).
'click .js-table-view-edit-card'(event) {
+8 -9
View File
@@ -62,20 +62,20 @@ template(name="attachmentGallery")
.attachment-thumbnail-container.open-preview(data-attachment-id="{{_id}}" data-card-id="{{ meta.cardId }}")
if link
if(isImage)
img.attachment-thumbnail(src="{{link}}" title="{{sanitize name}}")
img.attachment-thumbnail(src="{{link}}" title="{{cleanFilename name}}")
else if($eq extension 'svg')
img.attachment-thumbnail(src="{{link}}" title="{{sanitize name}}" type="image/svg+xml")
img.attachment-thumbnail(src="{{link}}" title="{{cleanFilename name}}" type="image/svg+xml")
else if($eq extension 'mp3')
video.attachment-thumbnail(title="{{sanitize name}}")
video.attachment-thumbnail(title="{{cleanFilename name}}")
source(src="{{link}}" type="audio/mpeg")
else if($eq extension 'ogg')
video.attachment-thumbnail(title="{{sanitize name}}")
video.attachment-thumbnail(title="{{cleanFilename name}}")
source(src="{{link}}" type="video/ogg")
else if($eq extension 'webm')
video.attachment-thumbnail(title="{{sanitize name}}")
video.attachment-thumbnail(title="{{cleanFilename name}}")
source(src="{{link}}" type="video/webm")
else if($eq extension 'mp4')
video.attachment-thumbnail(title="{{sanitize name}}")
video.attachment-thumbnail(title="{{cleanFilename name}}")
source(src="{{link}}" type="video/mp4")
else
span.attachment-thumbnail-text= extension
@@ -83,15 +83,14 @@ template(name="attachmentGallery")
.attachment-details-container
.attachment-details
div
b
= name
b {{ cleanFilename name }}
span.file-size ({{fileSize size}})
if uploaderName
div.attachment-uploader {{uploaderName}}
if uploadedAt
div.attachment-uploaded-at {{uploadedAt}}
.attachment-actions
a.js-download(href="{{link}}?download=true", download="{{name}}", title="{{_ 'download'}}")
a.js-download(href="{{link}}?download=true", download="{{downloadFilename name}}", title="{{_ 'download'}}")
i.fa.fa-arrow-down
if currentUser.isBoardMember
unless currentUser.isCommentOnly
+4 -1
View File
@@ -6,6 +6,7 @@ import uploadProgressManager from '../../lib/uploadProgressManager';
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
import Attachments from '/models/attachments';
import { Utils } from '/client/lib/utils';
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
import { formatDateTime } from '/imports/lib/dateUtils';
import { EscapeActions } from '/client/lib/escapeActions';
@@ -148,7 +149,9 @@ function openAttachmentViewer(attachmentId) {
break;
}
$('#attachment-name').text(attachment.name);
// Show the cleaned name: URL-decoded, homoglyphs folded, invisible characters
// and exploit markup removed (plain text; .text() escapes, so never HTML).
$('#attachment-name').text(cleanFileName(attachment.name));
$('#viewer-overlay').removeClass('hidden');
}
+3 -3
View File
@@ -138,7 +138,7 @@
body.desktop-mode .card-details:not(.card-details-popup) {
position: fixed;
width: auto;
max-width: 800px;
max-width: 520px; /* #6465: narrower dock (was 800px, ~1/4 screen) */
flex-basis: auto;
border-radius: 3px;
z-index: 2001;
@@ -445,8 +445,8 @@ body.desktop-mode .card-details.card-details-collapsed {
@media screen and (min-width: 801px) {
.card-details {
top: 97px;
inset-inline-start: calc(50% - (600px / 2));
width: 600px;
inset-inline-start: calc(50% - (520px / 2)); /* #6465: narrower card (was 600px) */
width: 520px;
bottom: 0;
position: fixed;
resize: both;
+24 -6
View File
@@ -63,6 +63,7 @@ import {
getCurrentCardIdFromContext,
getCurrentCardFromContext,
} from '/client/lib/currentCard';
import { isLazyCards } from '/client/lib/lazyCards';
import uploadProgressManager from '../../lib/uploadProgressManager';
import { CSSEvents } from '/client/lib/cssEvents';
import { UnsavedEdits } from '/client/lib/unsavedEdits';
@@ -374,6 +375,19 @@ Template.cardDetails.onCreated(function () {
Meteor.subscribe('legacyBoardAttachments', board._id);
}
});
// In lazy (windowed) card loading the board/window publications only ship the
// children of cards in the visible window, so an open card that scrolled out of
// — or was just added to — a window could miss its own comments/attachments/
// checklists. Subscribe to the open card's OWN live children so the card detail
// is always complete and reactive, independent of the window (#6480). No-op in
// 'all' mode, where the board publication already ships everything.
this.autorun(() => {
const cardId = Session.get('currentCard');
if (cardId && isLazyCards()) {
Meteor.subscribe('openCardData', cardId);
}
});
});
Template.cardDetails.onRendered(function () {
@@ -1418,7 +1432,10 @@ Template.inlinedCardDescription.events({
tpl.isOpen.set(true);
},
'keydown form textarea'(evt, tpl) {
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
// Ctrl/Cmd+Enter submits by default; plain Enter when the user enabled
// "submit on Enter" in Member Settings (Shift+Enter then makes a newline).
const submitOnEnter = !!ReactiveCache.getCurrentUser()?.hasSubmitOnEnter?.();
if (isSubmitKey(evt, { submitOnEnter })) {
tpl.find('button[type=submit]').click();
}
},
@@ -1529,11 +1546,12 @@ Template.editCardTitleForm.events({
Utils.showCopied(promise, $tooltip);
},
'keydown .js-edit-card-title'(event) {
// #4236: match the card description field — Ctrl/Cmd+Enter saves; plain
// Enter (and Shift+Enter) insert a new line instead of submitting. The old
// handler submitted on any Enter without Shift, so Enter could not add a
// newline in the title.
if (isSubmitKey(event)) {
// #4236: by default Ctrl/Cmd+Enter saves; plain Enter (and Shift+Enter)
// insert a new line. A user who turns on "submit on Enter" in Member
// Settings gets plain Enter to save (Shift+Enter for a newline) instead —
// the pre-#4236 fast title-editing workflow (#6480 email feedback).
const submitOnEnter = !!ReactiveCache.getCurrentUser()?.hasSubmitOnEnter?.();
if (isSubmitKey(event, { submitOnEnter })) {
$('.js-submit-edit-card-title-form').click();
}
},
+57
View File
@@ -1,3 +1,60 @@
/* #4759: Member Settings / Font popup — a button per installed font (each shown in
its own font) and a row of size buttons (smaller ... 100% ... larger). Clicking a
button applies immediately. */
.ui-font-buttons,
.ui-font-size-buttons {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin: 2px 0 10px 0;
}
.ui-font-btn,
.ui-font-size-btn {
display: inline-block;
padding: 4px 10px;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
color: #333;
cursor: pointer;
font-size: 14px;
line-height: 1.4;
text-decoration: none;
}
.ui-font-btn:hover,
.ui-font-size-btn:hover {
background: #f0f0f0;
}
.ui-font-btn.active,
.ui-font-size-btn.active {
background: #2980b9;
border-color: #2980b9;
color: #fff;
}
/* The "Unset" colour reset links look like buttons. */
.color-wheel-wrap .js-reset-text-color,
.color-wheel-wrap .js-reset-bg-color {
display: inline-block;
padding: 4px 10px;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
color: #333;
cursor: pointer;
font-size: 14px;
line-height: 1.4;
text-decoration: none;
}
.color-wheel-wrap .js-reset-text-color:hover,
.color-wheel-wrap .js-reset-bg-color:hover {
background: #f0f0f0;
}
.color-wheel-wrap .js-reset-text-color.disabled,
.color-wheel-wrap .js-reset-bg-color.disabled {
opacity: 0.5;
pointer-events: none;
}
/* #5514: native color-wheel (custom RGB / hex) picker wrapper, shared by the
label / list / swimlane / card color popups. */
.color-wheel-wrap {
+6
View File
@@ -203,6 +203,12 @@
.minicard .minicard-title .viewer {
display: block;
word-wrap: break-word;
/* #6465: the minicard title renders inside a .viewer, which inherits the
global `.viewer { min-height: 2.5vh }` (layouts.css). On tall screens that
reserves a big empty band under a one-line title, so half of every
minicard was white space. Collapse it to fit the text (same fix already
applied to the label .viewer above). */
min-height: 0;
}
}
.minicard .dates {
+9 -9
View File
@@ -130,8 +130,8 @@ textarea.editor {
}
input[type="submit"],
button {
background: #000;
background: linear-gradient(#000, #000);
background: var(--theme-accent, #000);
background: linear-gradient(var(--theme-accent, #000), var(--theme-accent, #000));
border: none;
cursor: pointer;
display: inline-block;
@@ -150,14 +150,14 @@ input[type="submit"]:hover,
button:hover,
input[type="submit"]:focus,
button:focus {
background: #222;
background: linear-gradient(#222, #222);
background: var(--theme-accent, #222);
background: linear-gradient(var(--theme-accent, #222), var(--theme-accent, #222));
color: #fff;
}
input[type="submit"]:active,
button:active {
background: #111;
background: linear-gradient(#111, #111);
background: var(--theme-accent, #111);
background: linear-gradient(var(--theme-accent, #111), var(--theme-accent, #111));
box-shadow: inset 0 3px 6px rgba(0,0,0,0.3);
color: #fff;
}
@@ -172,7 +172,7 @@ button:active:active {
}
input[type="submit"].primary,
button.primary {
background: #005377;
background: var(--theme-accent, #005377);
box-shadow: 0 1px 0 #4d4d4d;
color: #fff;
}
@@ -180,11 +180,11 @@ input[type="submit"].primary:hover,
button.primary:hover,
input[type="submit"].primary:focus,
button.primary:focus {
background: #004766;
background: var(--theme-accent, #004766);
}
input[type="submit"].primary:active,
button.primary:active {
background: #01628c;
background: var(--theme-accent, #01628c);
}
input[type="submit"].negate,
button.negate {
+22 -2
View File
@@ -394,6 +394,7 @@ Template.list.onRendered(function () {
// the old code compared against a scrollLeftMax of 0 and never fired, so
// dragging a card toward an off-screen list never auto-scrolled the board.
const { computeEdgeScroll, findLaneUnderPointer } = require('/imports/lib/boardAutoScroll');
let scrolled = false;
const lanes = document.querySelectorAll('.js-lists');
const rects = Array.prototype.map.call(lanes, el => el.getBoundingClientRect());
const laneIndex = findLaneUnderPointer(rects, event.clientX, event.clientY);
@@ -406,7 +407,10 @@ Template.list.onRendered(function () {
scrollSize: lanes[laneIndex].scrollWidth,
clientSize: lanes[laneIndex].clientWidth,
});
if (nextLeft !== null) lanes[laneIndex].scrollLeft = nextLeft;
if (nextLeft !== null) {
lanes[laneIndex].scrollLeft = nextLeft;
scrolled = true;
}
}
// vertical auto-scroll stays on the board canvas
const canvas = document.querySelector('.board-canvas');
@@ -420,7 +424,23 @@ Template.list.onRendered(function () {
scrollSize: canvas.scrollHeight,
clientSize: canvas.clientHeight,
});
if (nextTop !== null) canvas.scrollTop = nextTop;
if (nextTop !== null) {
canvas.scrollTop = nextTop;
scrolled = true;
}
}
// #6477: after an auto-scroll, jQuery UI sortable's cached container/item
// geometry is STALE — it snapshots positions at drag start and on its own
// placeholder moves, never on our manual scroll here. With long/overflowing
// lists (exactly what triggers auto-scroll) the drop then resolved against a
// stale map and parked the placeholder in the WRONG swimlane's copy of the
// list, so a card dragged between swimlanes snapped back into its source
// swimlane (the stop handler's ui.item.parents('.swimlane') faithfully
// persisted it). Re-cache positions so the placeholder and drop resolve
// against the scrolled layout. Complements the DOM-mutation refresh in
// listBody.js (#2769), which does not fire on scroll.
if (scrolled) {
$cards.sortable('refreshPositions');
}
},
});
+1 -1
View File
@@ -366,7 +366,7 @@ Template.listBody.helpers({
// just this list/swimlane window (grows with `limit` as the user scrolls)
// plus its reactive total count. Default mode keeps everything in minimongo
// already, so these subscriptions are skipped.
if (isLazyCards() && list.boardId) {
if (list.boardId && isLazyCards(list.boardId)) {
tpl.subscribe('boardCardsWindow', list.boardId, mongoSelector, sortBy, limit);
tpl.subscribe(
'boardListCardCount',
+4
View File
@@ -101,6 +101,10 @@ template(name="listHeader")
i.fa.fa-plus
a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}")
i.fa.fa-bars
//- #6465: far-right add-list button. Opens the inline Add List
//- composer AFTER this list (right in LTR / left in RTL).
a.js-add-list-here.list-header-plus-top(title="{{_ 'add-list'}}")
i.fa.fa-plus-square-o
template(name="editListTitleForm")
.list-composer
+18 -2
View File
@@ -208,6 +208,20 @@ Template.listHeader.events({
Utils.setListCollapseState(list, !status);
},
'click .js-open-list-menu': Popup.open('listAction'),
// #6465: open the inline Add List composer after this list. Record the
// swimlane it is displayed in (board-wide lists are not bound to one) so the
// new list is created in the right swimlane; the composer renders after this
// list in the DOM (right in LTR / left in RTL).
'click .js-add-list-here'(event) {
event.preventDefault();
const list = Template.currentData();
const swimlaneDom = $(event.currentTarget).closest('.js-swimlane');
const swimlaneId = swimlaneDom.length
? swimlaneDom.attr('id').replace('swimlane-', '')
: null;
Session.set('wekan-add-list-swimlane', swimlaneId);
Session.set('wekan-add-list-after', list._id);
},
'click .js-add-card.list-header-plus-top'(event) {
const listDom = $(event.target).parents(
`#js-list-${Template.currentData()._id}`,
@@ -373,8 +387,10 @@ Template.listMorePopup.events({
],
}).length === 0
) {
allCardIds.map(_id => Cards.remove(_id));
Lists.remove(list._id);
// Soft delete (docs/Features/Undo/Undo.md): mark the list + its cards deleted
// (server cascades) instead of destroying them, so the delete is restorable
// and undoable (#1023). Replaces the old hard Cards.remove / Lists.remove.
Meteor.call('lists.softRemove', list._id);
} else {
const message = `${TAPi18n.__(
'delete-linked-cards-before-this-list',
+49
View File
@@ -0,0 +1,49 @@
/* Custom theme colors (docs/Theme/Theme.md) — the flat/clear categories let the
* user pick their own colors, applied as CSS variables on :root by
* globalThemeColor.js and consumed here. This is the initial, highest-impact
* surface (the header bars, primary buttons, sidebar button); deeper theming can
* grow from here. dark/special themes never set these variables. */
/* Flat: a single accent recolors the header bars and primary controls. The class
* lives on <body>; #header / #header-quick-access are descendants. */
.has-custom-theme-color #header,
.has-custom-theme-color #header-quick-access {
background: var(--theme-accent) !important;
}
.has-custom-theme-color button[type=submit].primary,
.has-custom-theme-color input[type=submit].primary,
.has-custom-theme-color .sidebar .sidebar-content .sidebar-btn {
background-color: var(--theme-accent) !important;
}
/* Clear: a two-color slide (gradient) across the header bars. */
.has-custom-theme-slide #header,
.has-custom-theme-slide #header-quick-access {
background: linear-gradient(135deg, var(--theme-accent), var(--theme-accent-2)) !important;
}
/* Select Color picker: category groups with a visible name above each group of
* colour swatches (docs/Theme/Theme.md). */
.theme-color-picker .theme-category-group {
margin-bottom: 8px;
clear: both; /* start below the previous group's floated swatches */
}
.theme-color-picker .theme-category-label {
display: block;
width: 100%;
clear: both; /* drop below the previous group's floats... */
text-align: left; /* ...so Flat/Clear/Dark/Special all sit at the left */
font-weight: bold;
margin: 2px 0 4px;
text-transform: capitalize;
}
/* No empty band above the first (Flat) title. */
.theme-color-picker .theme-category-group:first-child .theme-category-label {
margin-top: 0;
}
/* Tighten the Default (no override) row above the groups (Member Settings). */
.theme-color-picker ul.pop-over-list {
margin: 0 0 4px;
padding: 0;
}
+3 -11
View File
@@ -88,17 +88,9 @@
cursor: pointer;
transition: background-color 0.2s;
}
.board-color-belize.js-new-search {
background-color: #216694 !important;
color: #fff !important;
border: none !important;
}
.board-color-belize.js-new-search:hover {
background-color: #1a4d6a !important;
}
.board-color-belize.js-new-search:active {
background-color: #0d334d !important;
}
/* The new-search button uses the .primary button style, so it follows the theme
override colour (default blue #005377, or the board/global theme). The old
hardcoded belize (#216694) rules were removed. */
.global-search-operator {
font-family: Courier;
}
+2 -2
View File
@@ -71,8 +71,8 @@ template(name="globalSearch")
aria-label="{{_ 'globalSearch-title'}}"
autofocus dir="auto"
)
button.js-new-search.board-color-belize(type="button" aria-label="{{_ 'globalSearch-title'}}" style="background: #216694 !important; background-color: #216694 !important; color: #fff !important; border: none !important;")
span(aria-hidden="true") 🧹
button.primary.js-new-search(type="submit" aria-label="{{_ 'trello-clear-job'}}")
| {{_ 'trello-clear-job'}}
if searching
+spinner
else if hasResults
@@ -0,0 +1,74 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { Session } from 'meteor/session';
import { ReactiveCache } from '/imports/reactiveCache';
// #5778 + docs/Theme/Theme.md: apply the user's optional GLOBAL theme color to the
// whole UI (All Boards, Search, Admin Panel, My Cards, etc.) via a `board-color-<name>`
// class on <body>, and apply CUSTOM colors (flat = 1 accent, clear = 2 = a gradient)
// as CSS variables consumed by customTheme.css.
//
// While viewing a single board, that board's own color owns the page, so we do NOT
// put the global color class on <body> there; but we DO expose the ACTIVE context's
// custom colors (the board's when on a board, the user's global override otherwise)
// so custom-colored boards and the custom global theme both render.
Meteor.startup(() => {
let appliedClass = null;
function applyClass(colorClass) {
if (colorClass === appliedClass) return;
try {
if (appliedClass) document.body.classList.remove(appliedClass);
if (colorClass) document.body.classList.add(colorClass);
} catch (_) {
// document.body may not exist yet in exotic embeddings; ignore.
}
appliedClass = colorClass;
}
function applyCustom(custom) {
try {
const root = document.documentElement;
const c1 = custom && custom[0];
const c2 = custom && custom[1];
if (c1) {
root.style.setProperty('--theme-accent', c1);
document.body.classList.add('has-custom-theme-color');
} else {
root.style.removeProperty('--theme-accent');
document.body.classList.remove('has-custom-theme-color');
}
if (c2) {
root.style.setProperty('--theme-accent-2', c2);
document.body.classList.add('has-custom-theme-slide');
} else {
root.style.removeProperty('--theme-accent-2');
document.body.classList.remove('has-custom-theme-slide');
}
} catch (_) {
// ignore
}
}
Tracker.autorun(() => {
const user = Meteor.user();
const globalColor = user && user.profile && user.profile.globalThemeColor;
if (globalColor) {
// #5778: a global override wins EVERYWHERE — including board pages. The header
// and .board-wrapper also prefer it (see header.jade / boardBody.jade), so the
// whole UI takes the chosen theme; unset it to return to per-board colors.
applyClass(`board-color-${globalColor}`);
applyCustom((user && user.profile && user.profile.globalThemeCustomColors) || []);
return;
}
// No global override: on a board expose that board's own custom colors (its color
// class already lives on .board-wrapper/#header); off a board, apply nothing.
const boardId = Session.get('currentBoard');
const board = boardId ? ReactiveCache.getBoard(boardId) : null;
applyClass(null);
applyCustom((board && board.customThemeColors) || []);
});
});
+26 -7
View File
@@ -5,8 +5,16 @@
z-index: 17;
}
#header #header-main-bar {
height: 40px;
padding: 7px 10px 0;
/* Flex row (was float-based) so the right button group — which ends with the
sidebar hamburger — is always pinned to the right edge at every window width,
instead of drifting to the middle as floats wrap. */
display: flex;
align-items: center;
flex-wrap: wrap;
min-height: 40px;
/* No right padding: the right gap is owned by the hamburger's margin so it can
match the ~8px gap to the right of the top-bar user name. (top right bottom left) */
padding: 7px 0 0 10px;
}
#header #header-main-bar h1 {
font-size: 20px;
@@ -39,15 +47,17 @@
opacity: 0.9;
}
#header #header-main-bar .board-header-btns {
display: block;
/* Flex so the buttons inside each group lay out (their float is ignored inside a
flex container) and the groups themselves are flex items of the header bar. */
display: flex;
align-items: center;
flex-wrap: wrap;
margin-top: 3px;
width: auto;
}
#header #header-main-bar .board-header-btns.left {
float: inline-start;
}
#header #header-main-bar .board-header-btns.right {
float: inline-end;
/* Always hug the right edge (and, if the row wraps, the right of its row). */
margin-inline-start: auto;
}
#header #header-main-bar .board-header-btn {
border-radius: 3px;
@@ -60,6 +70,15 @@
line-height: 28px;
margin: 0 12px;
}
/* The sidebar hamburger sits at the far right; drop its wide 12px side margins so
there is no button-sized empty space to its left or right. The cog before it also
drops its right margin so the two sit snugly together at the edge. */
#header #header-main-bar .board-header-btn.js-toggle-sidebar {
margin-inline-start: 2px;
/* 8px to the right edge — the same small gap as to the right of the user name in
the top bar (.header-user-bar-name margin-inline-end). */
margin-inline-end: 8px;
}
#header #header-main-bar .board-header-btn i.fa {
float: inline-start;
display: block;
+14 -13
View File
@@ -5,7 +5,7 @@ template(name="header")
Reddit "subreddit" bar.
The first link goes to the boards page.
if currentUser
#header-quick-access(class=currentBoard.colorClass role="navigation" aria-label="{{_ 'all-boards'}}")
#header-quick-access(class="{{#if currentUser.globalThemeColorClass}}{{currentUser.globalThemeColorClass}}{{else}}{{currentBoard.colorClass}}{{/if}}" role="navigation" aria-label="{{_ 'all-boards'}}")
// Home icon - always at left side of logo
span.home-icon.allBoards
a(href="{{pathFor 'home'}}")
@@ -24,11 +24,13 @@ template(name="header")
div#headerIsSettingDatabaseCallDone
img(src="{{pathFor '/logo-header.png'}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
// Zoom controls - always visible
.zoom-controls
span.zoom-level.js-zoom-level-click(title="{{_ 'click-to-change-zoom'}}")
span.zoom-display {{zoomLevel}}%
input.zoom-input.js-zoom-input(type="number" value=zoomLevel min="50" max="300" step="10" style="display: none;")
// Mobile/desktop mode toggle (swapped with zoom)
.mobile-mode-toggle
a.board-header-btn.js-mobile-mode-toggle(title="{{_ 'mobile-desktop-toggle'}}" class="{{#if mobileMode}}mobile-active{{else}}desktop-active{{/if}}")
i.mobile-icon(class="{{#if mobileMode}}active{{/if}}")
i.fa.fa-mobile
i.desktop-icon(class="{{#unless mobileMode}}active{{/unless}}")
i.fa.fa-desktop
// Drag handles toggle - between zoom and mobile mode toggle
a.board-header-btn.js-toggle-desktop-drag-handles(title="{{_ 'show-desktop-drag-handles'}}")
@@ -78,12 +80,11 @@ template(name="header")
//
i.fa.fa-plus(title="Create a new board")
.mobile-mode-toggle
a.board-header-btn.js-mobile-mode-toggle(title="{{_ 'mobile-desktop-toggle'}}" class="{{#if mobileMode}}mobile-active{{else}}desktop-active{{/if}}")
i.mobile-icon(class="{{#if mobileMode}}active{{/if}}")
i.fa.fa-mobile
i.desktop-icon(class="{{#unless mobileMode}}active{{/unless}}")
i.fa.fa-desktop
// Zoom controls (swapped with the mobile/desktop toggle)
.zoom-controls
span.zoom-level.js-zoom-level-click(title="{{_ 'click-to-change-zoom'}}")
span.zoom-display {{zoomLevel}}%
input.zoom-input.js-zoom-input(type="number" value=zoomLevel min="50" max="300" step="10" style="display: none;")
// Notifications
+notifications
@@ -95,7 +96,7 @@ template(name="header")
+headerUserBar
#header(class=currentBoard.colorClass)
#header(class="{{#if currentUser.globalThemeColorClass}}{{currentUser.globalThemeColorClass}}{{else}}{{currentBoard.colorClass}}{{/if}}")
//-
The main bar is a colorful bar that provide all the meta-data for the
current page. This bar is contextual based.
+21
View File
@@ -272,6 +272,27 @@ body.mobile-mode #content {
float: inline-end;
font-size: clamp(18px, 4vw, 24px);
}
/* Archived boards modal: full-width panel that extends to the bottom of the browser
window (so archived boards show full width with maximum vertical space). It is the
only modal that contains .archived-lists. */
#modal .modal-content:has(.archived-lists) {
width: 96vw;
max-width: 96vw;
/* The header bar(s) sit above the modal (higher z-index), so start the archive
panel BELOW them — otherwise its top (and the close X) is hidden under the top
header bar. ~96px clears both the quick-access bar and the board header bar; on
the All Boards page (one bar) this leaves a small top gap. Then extend to the
bottom of the browser window. */
margin: 96px auto 0;
min-height: calc(100vh - 106px);
box-sizing: border-box;
}
/* Each archived board is a full-width row. */
#modal .modal-content .archived-lists,
#modal .modal-content .archived-lists .archived-lists-item {
width: 100%;
box-sizing: border-box;
}
/* #6465: heading sizes back to the 6.09 values (22/18/16px) — the vw clamps
resolved to 24/20/18px on desktop and compounded the density regression. */
h1 {
+10 -2
View File
@@ -171,6 +171,12 @@
overflow: visible;
height: auto;
flex: 1;
/* Auto-filling columns so as many languages as possible are visible at once when
there is browser width; collapses to a single column on narrow windows. */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
align-content: start;
column-gap: 4px;
}
/* Ensure content div in language popup contains all items */
@@ -197,8 +203,10 @@
top: auto !important;
inset-inline-start: auto !important;
inset-inline-end: 20px !important;
width: auto !important;
max-width: 450px !important;
/* Wide enough for multiple language columns when the browser is wide, but never
wider than ~90% of the viewport (falls back to a single narrow column). */
width: min(90vw, 1100px) !important;
max-width: 90vw !important;
height: 100vh !important;
max-height: 100vh !important;
min-height: 300px !important;
+7 -1
View File
@@ -9,7 +9,13 @@ Popup.template.events({
Popup.close();
},
'click .js-confirm'() {
this.__afterConfirmAction.call(this);
// #6479: the action is stored on the Popup instance (see Popup.afterConfirm),
// not on this data context, so a re-rendered/immutable context can't lose it.
// Fall back to the legacy per-context field for any external caller.
const action = Popup._afterConfirmAction || this.__afterConfirmAction;
if (typeof action === 'function') {
action.call(this);
}
},
// #5942: On mobile/touch, tapping inside the card-detail sub-popups (assign
// user / set due date) made the popup DISAPPEAR. The document-level
+15
View File
@@ -0,0 +1,15 @@
import { Template } from 'meteor/templating';
const { cleanFileName, sanitizeDownloadFileName } = require('/imports/lib/fileNameDisplay');
// Global helpers for showing filenames safely EVERYWHERE (card attachments, admin
// panel, etc.). No template needed — a filename is always shown as a plain, clean
// string:
// {{cleanFilename name}} -> URL-decoded, invisible chars removed, exploit
// markup (HTML/JS/XML) removed, whitespace collapsed
// {{downloadFilename name}} -> the same, but never empty (falls back to "download")
if (!Template.__safeFilenameHelpersRegistered) {
Template.registerHelper('cleanFilename', name => cleanFileName(name));
Template.registerHelper('downloadFilename', name => sanitizeDownloadFileName(name));
Template.__safeFilenameHelpersRegistered = true;
}
@@ -0,0 +1,32 @@
//- Shared "Select Color" picker (docs/Theme/Theme.md), used by both Board Settings
and Member Settings. Colors are shown as VISIBLE swatches grouped by category, with
the category name as a header above each group; the native color wheel(s) appear
for the flat (1) / clear (2) categories once such a color is selected. `data.scope`
is 'board' or 'global'.
template(name="themeColorPicker")
form.theme-color-picker.edit-label
if isGlobal
ul.pop-over-list
li(class="{{#if isNoneSelected}}active{{/if}}")
a.js-theme-none
i.fa.fa-ban
| {{_ 'theme-default'}}
if isNoneSelected
i.fa.fa-check
each themeGroups
.theme-category-group
.theme-category-label {{label}}
.board-backgrounds-list.clearfix
each colors
.board-background-select.js-select-theme(data-color="{{name}}")
span.background-box(class="board-color-{{name}}")
span {{name}}
if selected
i.fa.fa-check
if showCustom
.color-wheel-wrap
label {{_ 'custom-color'}}
each customWheels
input.js-theme-wheel(type="color" data-index="{{index}}" value="{{value}}" title="{{_ 'custom-color'}}")
.theme-preview
span.background-box(class="{{previewClass}}" style="{{previewStyle}}")
+166
View File
@@ -0,0 +1,166 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { ReactiveVar } from 'meteor/reactive-var';
import { TAPi18n } from '/imports/i18n';
import { Utils } from '/client/lib/utils';
import { isHexColor, toHex } from '/models/lib/contrastColor';
import { BOARD_COLORS } from '/models/metadata/colors';
import {
THEME_CATEGORY_ORDER,
categoryOf,
colorsInCategory,
allowsCustomColor,
customColorCount,
} from '/models/lib/themeCategories';
// Shared "Select Color" picker (docs/Theme/Theme.md): VISIBLE color swatches grouped
// by category (with the category name above each group) + native color wheel(s) for
// the flat/clear categories once such a color is selected. data.scope is 'board'
// (writes board.color) or 'global' (writes the user's #5778 global override).
const DEFAULT_WHEEL = ['#2980b9', '#6dd5fa']; // stock flat accent / second slide stop
function readCurrent(scope) {
if (scope === 'board') {
const b = Utils.getCurrentBoard();
return { color: (b && b.color) || BOARD_COLORS[0], custom: (b && b.customThemeColors) || [] };
}
const u = ReactiveCache.getCurrentUser();
return {
color: (u && u.getGlobalThemeColor && u.getGlobalThemeColor()) || null,
custom: (u && u.getGlobalThemeCustomColors && u.getGlobalThemeCustomColors()) || [],
};
}
Template.themeColorPicker.onCreated(function () {
const scope = this.data && this.data.scope === 'board' ? 'board' : 'global';
const cur = readCurrent(scope);
this.scope = scope;
this.color = new ReactiveVar(cur.color); // null for global = no override
this.customColors = new ReactiveVar((cur.custom || []).slice());
});
Template.themeColorPicker.helpers({
isGlobal() {
return Template.instance().scope === 'global';
},
isNoneSelected() {
const tpl = Template.instance();
return tpl.scope === 'global' && !tpl.color.get();
},
// Visible swatches grouped by category, each group labelled with its category name.
themeGroups() {
const cur = Template.instance().color.get();
return THEME_CATEGORY_ORDER.map(key => ({
key,
label: TAPi18n.__(`theme-category-${key}`),
colors: colorsInCategory(key).map(name => ({ name, selected: name === cur })),
}));
},
showCustom() {
const cur = Template.instance().color.get();
return cur ? allowsCustomColor(categoryOf(cur)) : false;
},
customWheels() {
const tpl = Template.instance();
const cur = tpl.color.get();
const n = cur ? customColorCount(categoryOf(cur)) : 0;
const cc = tpl.customColors.get();
const wheels = [];
for (let i = 0; i < n; i += 1) {
wheels.push({ index: i, value: toHex(cc[i]) || DEFAULT_WHEEL[i] || '#0079bf' });
}
return wheels;
},
previewClass() {
const color = Template.instance().color.get();
return color ? `board-color-${color}` : '';
},
// Inline preview of custom colors (the stock board-color-* CSS can't show arbitrary
// colors until the CSS-variable refactor; the preview swatch shows them directly).
previewStyle() {
const tpl = Template.instance();
const cur = tpl.color.get();
const cat = cur ? categoryOf(cur) : null;
if (!cat || !allowsCustomColor(cat)) return '';
const cc = tpl.customColors.get();
if (!cc.some(Boolean)) return '';
if (customColorCount(cat) === 2 && cc[0] && cc[1]) {
return `background: linear-gradient(135deg, ${cc[0]}, ${cc[1]}) !important;`;
}
if (cc[0]) return `background: ${cc[0]} !important;`;
return '';
},
});
// Collect the custom colors to save: [] unless the user actually engaged a wheel, in
// which case read every wheel's current value (untouched ones contribute their shown
// default) so the result is a complete set of the category's expected count.
function gatherCustom(tpl) {
const color = tpl.color.get();
const cat = color ? categoryOf(color) : null;
if (!cat || !allowsCustomColor(cat)) return [];
if (!tpl.customColors.get().some(Boolean)) return [];
const n = customColorCount(cat);
const wheels = Array.from(tpl.findAll('.js-theme-wheel'))
.map(w => w.value)
.filter(isHexColor)
.slice(0, n);
return wheels.length === n ? wheels : [];
}
// Apply the current selection immediately (no Save button): board scope writes
// board.color, global scope calls setGlobalThemeColor. `color`/`custom` are read
// from the reactive state so the same helper serves the swatch click and the wheel.
function applySelection(tpl) {
const color = tpl.color.get() || colorsInCategory(THEME_CATEGORY_ORDER[0])[0];
const custom = gatherCustom(tpl);
if (tpl.scope === 'board') {
const b = Utils.getCurrentBoard();
if (b) {
Promise.resolve(b.setColor(color, custom)).catch(e => {
if (process.env.DEBUG === 'true') console.error('board setColor error', e);
});
}
} else {
Meteor.call('setGlobalThemeColor', color, custom, err => {
if (err && process.env.DEBUG === 'true') console.error('setGlobalThemeColor error', err);
});
}
}
Template.themeColorPicker.events({
// Clicking a color swatch applies that theme IMMEDIATELY (there is no Save button).
'click .js-select-theme'(event, tpl) {
const color = event.currentTarget.dataset.color;
if (!color) return;
tpl.color.set(color);
tpl.customColors.set([]); // a fresh theme -> its stock colors
applySelection(tpl);
},
// Live-update the preview while dragging the wheel...
'input .js-theme-wheel'(event, tpl) {
const idx = parseInt(event.currentTarget.dataset.index, 10);
const val = event.currentTarget.value;
if (!isHexColor(val)) return;
const cc = tpl.customColors.get().slice();
cc[idx] = val;
tpl.customColors.set(cc);
},
// ...and apply the custom color when the wheel is committed (avoids spamming the
// server on every intermediate value during the drag).
'change .js-theme-wheel'(event, tpl) {
applySelection(tpl);
},
// Clear the global override (Default) — applies immediately too.
'click .js-theme-none'(event, tpl) {
event.preventDefault();
tpl.color.set(null);
tpl.customColors.set([]);
if (tpl.scope === 'board') {
const b = Utils.getCurrentBoard();
if (b) b.setColor(BOARD_COLORS[0], []);
} else {
Meteor.call('setGlobalThemeColor', null, null);
}
},
});
+40
View File
@@ -0,0 +1,40 @@
/* Member Settings / Font (#4759): apply the user's chosen UI font. The class lives
* on <body> and the --wekan-ui-font variable is set by uiFont.js only for a
* validated, whitelisted font. Form controls are listed explicitly because their
* UA default font-family does not inherit. Icon fonts (Font Awesome etc.) set their
* own font-family on the element, so they are unaffected. */
body.has-ui-font,
body.has-ui-font button,
body.has-ui-font input,
body.has-ui-font textarea,
body.has-ui-font select,
body.has-ui-font .wekan-form-control {
font-family: var(--wekan-ui-font) !important;
}
/* Font size preset (#4759): scale the root font size. Elements using relative units
* (em/rem/%) scale with it; the variable is a percentage set by uiFont.js only for a
* known preset. 'default'/unset removes it. */
body.has-ui-font-size {
font-size: var(--wekan-ui-font-size) !important;
}
/* Text color (#4759): the variable is a validated hex set by uiFont.js. Icon fonts
* keep their own color rules; this recolors ordinary UI text + form controls. */
body.has-ui-text-color,
body.has-ui-text-color button,
body.has-ui-text-color input,
body.has-ui-text-color textarea,
body.has-ui-text-color select,
body.has-ui-text-color .wekan-form-control {
color: var(--wekan-ui-text-color) !important;
}
/* Text background color (#4759). */
body.has-ui-bg-color,
body.has-ui-bg-color input,
body.has-ui-bg-color textarea,
body.has-ui-bg-color select,
body.has-ui-bg-color .wekan-form-control {
background-color: var(--wekan-ui-bg-color) !important;
}
+34
View File
@@ -0,0 +1,34 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { fontFamilyValue, fontSizeValue, colorValue } from '/models/lib/uiFonts';
// #4759: apply the user's chosen UI font AND font-size preset to the whole UI by
// setting CSS variables on :root and toggling marker classes on <body> (uiFont.css
// consumes them). fontFamilyValue()/fontSizeValue() return '' for anything not on the
// curated whitelist/presets, so only known, safe values can ever reach the DOM — the
// saved values are also validated server-side. Unset => nothing applied (defaults).
Meteor.startup(() => {
function toggle(varName, className, value) {
try {
if (value) {
document.documentElement.style.setProperty(varName, value);
document.body.classList.add(className);
} else {
document.documentElement.style.removeProperty(varName);
document.body.classList.remove(className);
}
} catch (_) {
// document not ready in exotic embeddings; ignore.
}
}
Tracker.autorun(() => {
const user = Meteor.user();
const profile = (user && user.profile) || {};
toggle('--wekan-ui-font', 'has-ui-font', fontFamilyValue(profile.uiFont));
toggle('--wekan-ui-font-size', 'has-ui-font-size', fontSizeValue(profile.uiFontSize));
toggle('--wekan-ui-text-color', 'has-ui-text-color', colorValue(profile.uiTextColor));
toggle('--wekan-ui-bg-color', 'has-ui-bg-color', colorValue(profile.uiTextBgColor));
});
});
@@ -2,7 +2,8 @@ section#notifications-drawer {
position: fixed;
top: 48px;
inset-inline-end: 0;
width: 400px;
width: 100vw;
max-width: 100vw;
background-color: #fafafa;
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
border-radius: 2px;
@@ -17,19 +18,20 @@ section#notifications-drawer .header {
position: fixed;
top: 48px;
inset-inline-end: 0;
width: calc(400px - 32px);
width: 100vw;
box-sizing: border-box;
padding: 8px 16px;
background: #ededed;
border-bottom: 1px solid #dbdbdb;
z-index: 2;
/* Flex row: hamburger (left), title (centre), X (right), all vertically centred. */
display: flex;
align-items: center;
}
section#notifications-drawer .header .notification-menu-toggle {
position: absolute;
inset-inline-start: 16px;
top: calc(50% - 12px);
font-size: 20px;
cursor: pointer;
color: #333;
color: #000 !important; /* black hamburger menu (override themed white link colour) */
line-height: 24px;
}
section#notifications-drawer .header .notification-menu-toggle:hover {
@@ -83,18 +85,33 @@ section#notifications-drawer .header .notification-menu .menu-item .menu-icon {
margin-inline-end: 8px;
font-size: 16px;
}
/* Menu-item icons (envelope / copy / trash / check) black. */
section#notifications-drawer .header .notification-menu .menu-item .menu-icon,
section#notifications-drawer .header .notification-menu .menu-item .menu-icon i.fa,
section#notifications-drawer .header .notification-menu .menu-item .check-icon,
section#notifications-drawer .header .notification-menu .menu-item .check-icon i.fa {
color: #000 !important;
}
section#notifications-drawer .header h5 {
flex: 1; /* fill the middle so the title is centred, X sits at the right */
text-align: center;
margin: 0;
}
section#notifications-drawer .header .close {
position: absolute;
top: calc(50% - 12px);
inset-inline-end: 12px;
/* Flex item at the right edge, aligned with the title. position: static cancels
the GLOBAL `.close { position: absolute; top: 5px }` rule (boardBody.css) that
otherwise pulls the X out of the header row and drops it below the title. */
position: static;
margin-inline-start: 8px;
font-size: 24px;
height: 24px;
line-height: 24px;
opacity: 1;
color: #000 !important; /* black X (override themed white link colour) */
}
section#notifications-drawer .header .close i.fa,
section#notifications-drawer .header .notification-menu-toggle i.fa {
color: inherit !important;
}
section#notifications-drawer ul.notifications {
+3 -3
View File
@@ -30,14 +30,14 @@ template(name="rulesList")
else
= title
div.rules-btns-group
button.primary.wide.js-goto-details(type="submit")
button.primary.wide.js-goto-details(type="submit" data-rule-id="{{_id}}")
i.fa.fa-eye
| {{_ 'r-view-rule'}}
if canAdmin
button.primary.wide.js-edit-rule(type="submit")
button.primary.wide.js-edit-rule(type="submit" data-rule-id="{{_id}}")
i.fa.fa-pencil
| {{_ 'r-edit-rule'}}
button.primary.wide.js-delete-rule(type="submit")
button.primary.wide.js-delete-rule(type="submit" data-rule-id="{{_id}}")
i.fa.fa-trash
| {{_ 'r-delete-rule'}}
else
+15 -5
View File
@@ -78,14 +78,20 @@ function sanitizeObject(obj) {
}
Template.rulesMain.events({
'click .js-delete-rule'() {
const rule = Template.currentData();
'click .js-delete-rule'(event) {
// #6490: the button lives in the rulesList child template's `each`, but this
// handler is on the parent rulesMain, so Template.currentData() returned the
// PARENT's data context (not the clicked rule) — the rule id resolved to null,
// so delete failed server-side with "Match error: Expected string, got null".
// Read the rule id from the button's explicit data-rule-id instead.
const ruleId = event.currentTarget.getAttribute('data-rule-id');
if (!ruleId) return;
// Delete the rule + its trigger + action in one server call. Doing this
// client-side as three separate Collection.remove() calls failed with 403
// "Access denied" whenever a trigger/action document's boardId did not
// resolve to a board in the allow() rule; the method authorizes once and
// removes all three server-side.
Meteor.call('rules.deleteRule', rule._id);
Meteor.call('rules.deleteRule', ruleId);
},
'click .js-goto-trigger'(event, tpl) {
event.preventDefault();
@@ -141,8 +147,12 @@ Template.rulesMain.events({
},
'click .js-goto-details'(event, tpl) {
event.preventDefault();
const rule = Template.currentData();
tpl.ruleId.set(rule._id);
// #6490: same as delete — Template.currentData() returned the parent context
// here, so "View rule" always opened the same (first) rule regardless of which
// one was clicked. Read the clicked rule's id from its data-rule-id.
const ruleId = event.currentTarget.getAttribute('data-rule-id');
if (!ruleId) return;
tpl.ruleId.set(ruleId);
tpl.rulesCurrentTab.set('ruleDetails');
},
});
+1
View File
@@ -1,4 +1,5 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { Utils } from '/client/lib/utils';
import Actions from '/models/actions';
import Rules from '/models/rules';
@@ -31,15 +31,7 @@ template(name="adminFeatures")
ul.setting-detail
li.layout-form
.title {{_ 'cards-loading'}}
.form-group
select#cards-loading.js-cards-loading
each cardsLoadingOptions
if this.selected
option(value="{{value}}" selected) {{_ label}}
else
option(value="{{value}}") {{_ label}}
p.description {{_ 'cards-loading-description'}}
p.description {{_ 'cards-loading-lazy-note'}}
if isSecurityActive
ul.setting-detail
li
@@ -21,13 +21,6 @@ Template.adminFeatures.helpers({
isNotificationsActive() {
return Template.instance().activePane.get() === 'notifications';
},
cardsLoadingOptions() {
const current = (ReactiveCache.getCurrentSetting() || {}).cardsLoading || 'all';
return [
{ value: 'all', label: 'cards-loading-all', selected: current === 'all' },
{ value: 'lazy', label: 'cards-loading-lazy', selected: current === 'lazy' },
];
},
renderLinksAsPlainText() {
return (ReactiveCache.getCurrentSetting() || {}).renderLinksAsPlainText;
},
@@ -75,13 +68,6 @@ Template.adminFeatures.events({
'click .js-features-menu'(event, tpl) {
tpl.activePane.set(event.currentTarget.dataset.id);
},
'change .js-cards-loading'(event) {
const value = event.currentTarget.value === 'lazy' ? 'lazy' : 'all';
const setting = ReactiveCache.getCurrentSetting();
if (setting) {
Settings.update(setting._id, { $set: { cardsLoading: value } });
}
},
'click .js-toggle-render-links-as-plain-text'() {
toggleSettingField('renderLinksAsPlainText');
},
+60 -29
View File
@@ -1,60 +1,51 @@
/* Search + pagination controls for the admin report tabs (files, rules,
boards, cards). Styled to match the admin People panel (peopleBody.css). */
.admin-report-search {
/* One controls row per report: the search field on the left, the pagination
controls pushed to the right (margin-inline-start:auto mirrors automatically in
RTL). No separate Search button — typing + Enter searches. The controls use the
theme accent (Member Settings → Change color sets --theme-accent on :root),
falling back to WeKan blue when no custom theme is chosen. */
.admin-report-controls {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
height: 34px;
margin-bottom: 12px;
}
.admin-report-search input {
flex: 1;
.admin-report-controls input {
flex: 0 1 320px;
max-width: 320px;
padding: 0 8px;
height: 34px;
}
.admin-report-search button {
min-width: 90px;
height: 34px;
background: #01628c;
color: #fff;
border: none;
border-radius: 0.4vw;
cursor: pointer;
padding: 0 12px;
}
.admin-report-search button:hover {
background: #005377;
}
.admin-report-search button:active {
background: #004766;
}
.admin-report-pagination {
margin-inline-start: auto; /* push pagination to the end of the controls row */
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.admin-report-pagination button {
min-width: 32px;
height: 30px;
border: 1px solid #bbb;
background: #fff;
border: 1px solid var(--theme-accent, #01628c);
background: transparent;
color: var(--theme-accent, #01628c);
border-radius: 0.4vw;
cursor: pointer;
}
.admin-report-pagination button:hover:not(.disabled) {
background: var(--theme-accent, #01628c);
color: #fff;
}
.admin-report-pagination button.active {
background: #01628c;
border-color: #01628c;
background: var(--theme-accent, #01628c);
border-color: var(--theme-accent, #01628c);
color: #fff;
}
@@ -66,5 +57,45 @@
.admin-report-page-info {
min-width: 72px;
text-align: center;
color: #333;
}
/* Problems page: menu separator between the event streams (Summary/Security/
Speed/Tests) and the moved report items, and the read-only event table. */
.admin-reports-content .side-menu .admin-reports-separator hr {
margin: 6px 0;
border: 0;
border-top: 1px solid rgba(0,0,0,0.15);
}
.admin-event-controls {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.admin-event-controls input {
flex: 0 1 320px;
max-width: 320px;
height: 34px;
padding: 0 8px;
}
.admin-event-controls .admin-event-pagination {
margin-inline-start: auto;
display: flex;
align-items: center;
gap: 8px;
}
.admin-event-controls .admin-event-pagination a {
cursor: pointer;
color: var(--theme-accent, #01628c);
font-weight: bold;
padding: 0 6px;
}
.admin-event-controls .admin-event-pagination a.disabled { opacity: 0.4; pointer-events: none; }
table.admin-event-table { width: 100%; border-collapse: collapse; }
table.admin-event-table th, table.admin-event-table td {
text-align: start; padding: 4px 8px; border-bottom: 1px solid rgba(0,0,0,0.08); vertical-align: top;
}
table.admin-event-table .admin-event-at { white-space: nowrap; }
table.admin-event-table .admin-event-sev[data-sev="high"],
table.admin-event-table .admin-event-sev[data-sev="critical"] { color: #c0392b; font-weight: bold; }
table.admin-event-table .admin-event-detail { word-break: break-word; }
+116 -56
View File
@@ -6,6 +6,34 @@ template(name="adminReports")
.content-body
.side-menu
ul
li
a.js-report-summary(data-id="report-summary")
i.fa.fa-list
| {{_ 'summary'}}
li
a.js-report-security(data-id="report-security")
i.fa.fa-shield
| {{_ 'securityReportTitle'}}
li
a.js-report-speed(data-id="report-speed")
i.fa.fa-tachometer
| {{_ 'speedReportTitle'}}
li
a.js-report-tests(data-id="report-tests")
i.fa.fa-flask
| {{_ 'testsReportTitle'}}
li
a.js-report-cpu(data-id="report-cpu")
i.fa.fa-tachometer
| {{_ 'cpuReportTitle'}}
li.admin-reports-separator
hr
li
a.js-report-broken(data-id="report-broken")
i.fa.fa-chain-broken
@@ -39,6 +67,16 @@ template(name="adminReports")
.main-body
if loading.get
+spinner
else if showSummary.get
+problemsSummary
else if showSecurity.get
+eventStreamReport(stream="security")
else if showSpeed.get
+eventStreamReport(stream="speed")
else if showTests.get
+eventStreamReport(stream="tests")
else if showCpu.get
+eventStreamReport(stream="cpu")
else if showBrokenCardsReport.get
+brokenCardsReport
else if showFilesReport.get
@@ -63,17 +101,14 @@ template(name="brokenCardsReport")
template(name="rulesReport")
h1 {{_ 'rulesReportTitle'}}
.admin-report-search
input.js-rules-search-input(type="text" placeholder="{{_ 'search'}}")
button.js-rules-search-button
i.fa.fa-search
| {{_ 'search'}}
.admin-report-pagination
button.js-rules-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-rules-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
.admin-report-controls
input.js-rules-search-input(type="search" placeholder="{{_ 'search'}}")
.admin-report-pagination
button.js-rules-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-rules-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
if resultsCount
table
thead
@@ -95,17 +130,14 @@ template(name="rulesReport")
template(name="filesReport")
h1 {{_ 'filesReportTitle'}}
.admin-report-search
input.js-files-search-input(type="text" placeholder="{{_ 'search'}}")
button.js-files-search-button
i.fa.fa-search
| {{_ 'search'}}
.admin-report-pagination
button.js-files-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-files-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
.admin-report-controls
input.js-files-search-input(type="search" placeholder="{{_ 'search'}}")
.admin-report-pagination
button.js-files-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-files-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
if resultsCount
table
thead
@@ -120,7 +152,7 @@ template(name="filesReport")
each att in results
tbody
tr
td {{ att.name }}
td {{ cleanFilename att.name }}
td.right {{ fileSize att.size }}
td {{ att.type }}
td {{ att._id }}
@@ -131,17 +163,14 @@ template(name="filesReport")
template(name="cardsReport")
h1 {{_ 'cardsReportTitle'}}
.admin-report-search
input.js-cards-search-input(type="text" placeholder="{{_ 'search'}}")
button.js-cards-search-button
i.fa.fa-search
| {{_ 'search'}}
.admin-report-pagination
button.js-cards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-cards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
.admin-report-controls
input.js-cards-search-input(type="search" placeholder="{{_ 'search'}}")
.admin-report-pagination
button.js-cards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-cards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
if resultsCount
table.table
thead
@@ -167,17 +196,14 @@ template(name="cardsReport")
template(name="impersonationReport")
h1 {{_ 'impersonationReportTitle'}}
.admin-report-search
input.js-impersonation-search-input(type="text" placeholder="{{_ 'search'}}")
button.js-impersonation-search-button
i.fa.fa-search
| {{_ 'search'}}
.admin-report-pagination
button.js-impersonation-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-impersonation-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
.admin-report-controls
input.js-impersonation-search-input(type="search" placeholder="{{_ 'search'}}")
.admin-report-pagination
button.js-impersonation-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-impersonation-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
if resultsCount
table.table
thead
@@ -204,17 +230,14 @@ template(name="impersonationReport")
template(name="boardsReport")
h1 {{_ 'boardsReportTitle'}}
.admin-report-search
input.js-boards-search-input(type="text" placeholder="{{_ 'search'}}")
button.js-boards-search-button
i.fa.fa-search
| {{_ 'search'}}
.admin-report-pagination
button.js-boards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-boards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
.admin-report-controls
input.js-boards-search-input(type="search" placeholder="{{_ 'search'}}")
.admin-report-pagination
button.js-boards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-boards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
if resultsCount
table.table
thead
@@ -239,3 +262,40 @@ template(name="boardsReport")
td {{teams board.teams }}
else
div {{_ 'no-results' }}
template(name="eventStreamReport")
.global-search-results-list-wrapper
h1 {{streamTitle}}
.admin-event-controls
input.js-event-search(type="search" placeholder="{{_ 'search'}}" value="{{searchTerm}}")
span.admin-event-pagination
a.js-event-prev(class="{{prevDisabled}}") ‹
span.admin-event-page {{currentPage}} / {{totalPages}}
a.js-event-next(class="{{nextDisabled}}") ›
if rowCount
table.admin-event-table
thead
tr
th {{_ 'event-datetime'}}
th {{_ 'event-category'}}
th {{_ 'event-bleed'}}
th {{_ 'event-severity'}}
th {{_ 'event-action'}}
th {{_ 'event-source'}}
th {{_ 'username'}}
th {{_ 'event-detail'}}
tbody
each row in rows
tr
td.admin-event-at {{formatAt row.at}}
td {{row.category}}
td {{row.bleed}}
td.admin-event-sev(data-sev="{{row.severity}}") {{row.severity}}
td {{row.action}}
td {{row.source}}
td
if row.userId
a.edit-user.js-event-edit-user(data-user-id="{{ row.userId }}") {{ userName row.userId }}
td.admin-event-detail {{row.detail}}
else
p {{_ 'no-new-problems'}}
+176 -11
View File
@@ -67,6 +67,12 @@ function reportConfig(tmpl) {
Template.adminReports.onCreated(function () {
this.subscription = null;
// Problems page opens on the Summary tab (the acknowledge checkbox list).
this.showSummary = new ReactiveVar(true);
this.showSecurity = new ReactiveVar(false);
this.showSpeed = new ReactiveVar(false);
this.showTests = new ReactiveVar(false);
this.showCpu = new ReactiveVar(false);
this.showFilesReport = new ReactiveVar(false);
this.showBrokenCardsReport = new ReactiveVar(false);
this.showOrphanedFilesReport = new ReactiveVar(false);
@@ -115,8 +121,21 @@ Template.adminReports.onCreated(function () {
const searchTerm = cfg.search.get();
const limit = REPORTS_PER_PAGE;
const skip = (cfg.page.get() - 1) * REPORTS_PER_PAGE;
this.subscription = Meteor.subscribe(cfg.pub, searchTerm, limit, skip, () => {
this.loading.set(false);
this.subscription = Meteor.subscribe(cfg.pub, searchTerm, limit, skip, {
onReady: () => {
this.loading.set(false);
},
// A publication that errors (or is stopped) must never leave the report
// stuck on the loading spinner forever: clear the spinner and, on a real
// error, surface it. Without this an admin only ever saw an endless spinner
// whenever a report subscription failed on the server.
onStop: (error) => {
if (error) {
console.error(`Report subscription '${cfg.pub}' failed:`, error);
this.error.set(error.reason || error.message || String(error));
}
this.loading.set(false);
},
});
Meteor.call(cfg.countMethod, searchTerm, (error, count) => {
if (error) {
@@ -137,6 +156,21 @@ Template.adminReports.onCreated(function () {
});
Template.adminReports.helpers({
showSummary() {
return Template.instance().showSummary;
},
showSecurity() {
return Template.instance().showSecurity;
},
showSpeed() {
return Template.instance().showSpeed;
},
showTests() {
return Template.instance().showTests;
},
showCpu() {
return Template.instance().showCpu;
},
showFilesReport() {
return Template.instance().showFilesReport;
},
@@ -205,6 +239,21 @@ Template.adminReports.helpers({
});
Template.adminReports.events({
'click a.js-report-summary'(event) {
switchMenu(event, Template.instance());
},
'click a.js-report-security'(event) {
switchMenu(event, Template.instance());
},
'click a.js-report-speed'(event) {
switchMenu(event, Template.instance());
},
'click a.js-report-tests'(event) {
switchMenu(event, Template.instance());
},
'click a.js-report-cpu'(event) {
switchMenu(event, Template.instance());
},
'click a.js-report-broken'(event) {
switchMenu(event, Template.instance());
},
@@ -238,15 +287,10 @@ Template.adminReports.events({
'click .js-impersonation-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-impersonation'); },
// --- Search (one input + button per report, mirrors the People panel) ---
'click .js-files-search-button'(event, tmpl) { runSearch(tmpl, 'report-files', '.js-files-search-input'); },
'keydown .js-files-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-files', '.js-files-search-input'); },
'click .js-rules-search-button'(event, tmpl) { runSearch(tmpl, 'report-rules', '.js-rules-search-input'); },
'keydown .js-rules-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-rules', '.js-rules-search-input'); },
'click .js-boards-search-button'(event, tmpl) { runSearch(tmpl, 'report-boards', '.js-boards-search-input'); },
'keydown .js-boards-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-boards', '.js-boards-search-input'); },
'click .js-cards-search-button'(event, tmpl) { runSearch(tmpl, 'report-cards', '.js-cards-search-input'); },
'keydown .js-cards-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-cards', '.js-cards-search-input'); },
'click .js-impersonation-search-button'(event, tmpl) { runSearch(tmpl, 'report-impersonation', '.js-impersonation-search-input'); },
'keydown .js-impersonation-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-impersonation', '.js-impersonation-search-input'); },
});
@@ -285,6 +329,11 @@ function switchMenu(event, tmpl) {
const target = $(event.target);
if (!target.hasClass('active')) {
tmpl.loading.set(true);
tmpl.showSummary.set(false);
tmpl.showSecurity.set(false);
tmpl.showSpeed.set(false);
tmpl.showTests.set(false);
tmpl.showCpu.set(false);
tmpl.showFilesReport.set(false);
tmpl.showBrokenCardsReport.set(false);
tmpl.showOrphanedFilesReport.set(false);
@@ -301,7 +350,24 @@ function switchMenu(event, tmpl) {
const targetID = target.data('id');
tmpl.activeReport.set(targetID);
if ('report-broken' === targetID) {
// Summary + the Security/Speed/Tests streams load their own data (via methods,
// not a subscription), so just show them and clear the spinner.
if ('report-summary' === targetID) {
tmpl.showSummary.set(true);
tmpl.loading.set(false);
} else if ('report-security' === targetID) {
tmpl.showSecurity.set(true);
tmpl.loading.set(false);
} else if ('report-speed' === targetID) {
tmpl.showSpeed.set(true);
tmpl.loading.set(false);
} else if ('report-tests' === targetID) {
tmpl.showTests.set(true);
tmpl.loading.set(false);
} else if ('report-cpu' === targetID) {
tmpl.showCpu.set(true);
tmpl.loading.set(false);
} else if ('report-broken' === targetID) {
tmpl.showBrokenCardsReport.set(true);
tmpl.subscription = Meteor.subscribe(
'brokenCards',
@@ -343,10 +409,31 @@ function switchMenu(event, tmpl) {
Template.filesReport.helpers({
results() {
return collectionResults(Attachments, { name: 1 });
// The filename cell renders via the global {{cleanFilename}} helper: the name
// is URL-decoded, homoglyphs are folded, and invisible / exploit characters
// are removed, so it is always shown as a plain, readable name.
//
// Prefer the UNDERLYING reactive minimongo collection (Attachments.collection):
// the 'attachmentsList' publication delivers the page via this.added, and a
// plain Mongo.Collection cursor reacts to those adds and yields plain docs,
// whereas the ostrio FilesCursor (Attachments.find()) does not reliably re-run
// in a Blaze helper. Fall back to the wrapper if .collection is unavailable and
// NEVER throw: a throwing helper would abort the whole filesReport render
// (blank pane instead of the report + its "no results" state).
const coll = (Attachments && Attachments.collection) || Attachments;
try {
return collectionResults(coll, { name: 1 });
} catch (e) {
return [];
}
},
resultsCount() {
return collectionResultsCount(Attachments);
const coll = (Attachments && Attachments.collection) || Attachments;
try {
return collectionResultsCount(coll);
} catch (e) {
return 0;
}
},
fileSize(size) {
return fileSizeHelper(size);
@@ -429,7 +516,8 @@ Template.boardsReport.helpers({
Template.cardsReport.helpers({
results() {
return collectionResults(Cards, { boardId: 1, sort: 1 });
// Match the server publication's index-backed sort (see cards.js).
return collectionResults(Cards, { boardId: 1, createdAt: -1 });
},
resultsCount() {
return collectionResultsCount(Cards);
@@ -516,3 +604,80 @@ Template.brokenCardsReport.events({
tpl.search.previousPage();
},
});
// --- Read-only event stream report (Security / Speed / Tests) ---
// Reads the eventlog collection through the admin-only eventLogPage/eventLogCount
// methods and shows a paginated, searchable, READ-ONLY table (no acknowledge —
// that lives only on the Summary page). See docs/Security/Remediation/WeKan.md.
const EVENTS_PER_PAGE = 25;
function eventStreamTitleKey(stream) {
return { security: 'securityReportTitle', speed: 'speedReportTitle', tests: 'testsReportTitle', cpu: 'cpuReportTitle' }[stream] || 'summary';
}
Template.eventStreamReport.onCreated(function () {
this.stream = this.data.stream;
this.page = new ReactiveVar(1);
this.total = new ReactiveVar(0);
this.rows = new ReactiveVar([]);
this.search = new ReactiveVar('');
this.load = () => {
const stream = this.stream;
const search = this.search.get();
const page = this.page.get();
Meteor.call('eventLogCount', stream, search, (err, count) => {
if (!err) this.total.set(count || 0);
});
Meteor.call('eventLogPage', stream, EVENTS_PER_PAGE, (page - 1) * EVENTS_PER_PAGE, search, (err, rows) => {
if (!err) this.rows.set(Array.isArray(rows) ? rows : []);
});
};
this.load();
});
Template.eventStreamReport.helpers({
rows() { return Template.instance().rows.get(); },
rowCount() { return (Template.instance().rows.get() || []).length; },
currentPage() { return Template.instance().page.get(); },
totalPages() { return Math.max(1, Math.ceil(Template.instance().total.get() / EVENTS_PER_PAGE)); },
searchTerm() { return Template.instance().search.get(); },
streamTitle() { return TAPi18n.__(eventStreamTitleKey(Template.instance().stream)); },
prevDisabled() { return Template.instance().page.get() <= 1 ? 'disabled' : ''; },
nextDisabled() {
const t = Template.instance();
return t.page.get() >= Math.max(1, Math.ceil(t.total.get() / EVENTS_PER_PAGE)) ? 'disabled' : '';
},
formatAt(at) {
if (!at) return '';
try { return new Date(at).toISOString().replace('T', ' ').slice(0, 19); } catch (e) { return String(at); }
},
// The user who triggered the event (e.g. who uploaded a sanitized file). Empty
// for events with no associated user.
userName(userId) {
if (!userId) return '';
return ReactiveCache.getUser(userId)?.username || userId;
},
});
Template.eventStreamReport.events({
// Clicking the uploader opens the same "Edit user" popup as Admin Panel / People.
'click .js-event-edit-user'(event) {
event.preventDefault();
const userId = event.currentTarget.getAttribute('data-user-id');
if (userId) {
Popup.open('editUser').call({ userId }, event);
}
},
'input .js-event-search'(event, tmpl) {
tmpl.search.set(event.currentTarget.value.trim());
tmpl.page.set(1);
tmpl.load();
},
'click .js-event-prev'(event, tmpl) {
if (tmpl.page.get() > 1) { tmpl.page.set(tmpl.page.get() - 1); tmpl.load(); }
},
'click .js-event-next'(event, tmpl) {
const totalPages = Math.max(1, Math.ceil(tmpl.total.get() / EVENTS_PER_PAGE));
if (tmpl.page.get() < totalPages) { tmpl.page.set(tmpl.page.get() + 1); tmpl.load(); }
},
});
@@ -54,6 +54,12 @@ template(name='statistics')
tr
th {{_ 'Reactivity_mode'}}
td {{statistics.mongo.reactivity}}
tr
th {{_ 'Reactivity_order'}}
td {{statistics.mongo.reactivityOrder}}
tr
th {{_ 'DDP_transport'}}
td {{statistics.mongo.ddpTransport}}
tr
th {{_ 'OS_Type'}}
td {{statistics.os.type}}
+31 -31
View File
@@ -38,7 +38,7 @@ table tr:nth-child(even) {
}
.ext-box button {
min-width: 90px;
background: #01628c;
background: var(--theme-accent, #01628c);
color: #fff;
border: none;
border-radius: 0.4vw;
@@ -47,11 +47,11 @@ table tr:nth-child(even) {
}
.ext-box button:hover {
background: #005377;
background: var(--theme-accent, #005377);
}
.ext-box button:active {
background: #004766;
background: var(--theme-accent, #004766);
}
.content-wrapper {
margin-top: 10px;
@@ -93,30 +93,30 @@ table td:first-child {
min-width: auto;
}
#divAddOrRemoveTeam {
background: #01628c;
background: var(--theme-accent, #01628c);
display: none;
}
#addOrRemoveTeam {
background: #01628c;
background: var(--theme-accent, #01628c);
color: #fff;
}
#addOrRemoveTeam:hover {
background: #005377;
background: var(--theme-accent, #005377);
}
#divAddOrRemoveTeamContainer {
display: none;
margin: auto;
width: 50%;
border: 3px solid #01628c;
border: 3px solid var(--theme-accent, #01628c);
padding: 10px;
}
#cancelBtn {
margin-inline-start: 5% !important;
background: #01628c;
background: var(--theme-accent, #01628c);
color: #fff;
}
#cancelBtn:hover {
background: #005377;
background: var(--theme-accent, #005377);
}
#deleteAction {
margin-inline-start: 5% !important;
@@ -150,7 +150,7 @@ table td:first-child {
.unlock-all-btn {
margin-top: 20px;
margin-inline-start: 15px;
background-color: #01628c;
background-color: var(--theme-accent, #01628c);
color: #fff;
border: none;
border-radius: 0.4vw;
@@ -164,7 +164,7 @@ table td:first-child {
}
.unlock-all-btn:hover {
background-color: #005377;
background-color: var(--theme-accent, #005377);
}
.unlock-all-btn .fa {
@@ -185,7 +185,7 @@ table td:first-child {
top: 10%;
left: 50%;
transform: translateX(-50%);
background-color: #01628c;
background-color: var(--theme-accent, #01628c);
color: white;
padding: 10px 20px;
border-radius: 4px;
@@ -206,7 +206,7 @@ table td:first-child {
}
.text-green {
color: #01628c;
color: var(--theme-accent, #01628c);
}
.js-toggle-lock-status {
@@ -223,15 +223,16 @@ table td:first-child {
.people-pagination button {
min-width: 32px;
height: 30px;
border: 1px solid #bbb;
background: #fff;
border: 1px solid var(--theme-accent, #01628c);
background: transparent;
color: var(--theme-accent, #01628c);
border-radius: 0.4vw;
cursor: pointer;
}
.people-pagination button.active {
background: #01628c;
border-color: #01628c;
background: var(--theme-accent, #01628c);
border-color: var(--theme-accent, #01628c);
color: #fff;
}
@@ -256,15 +257,16 @@ table td:first-child {
.org-pagination button {
min-width: 32px;
height: 30px;
border: 1px solid #bbb;
background: #fff;
border: 1px solid var(--theme-accent, #01628c);
background: transparent;
color: var(--theme-accent, #01628c);
border-radius: 0.4vw;
cursor: pointer;
}
.org-pagination button.active {
background: #01628c;
border-color: #01628c;
background: var(--theme-accent, #01628c);
border-color: var(--theme-accent, #01628c);
color: #fff;
}
@@ -289,15 +291,16 @@ table td:first-child {
.team-pagination button {
min-width: 32px;
height: 30px;
border: 1px solid #bbb;
background: #fff;
border: 1px solid var(--theme-accent, #01628c);
background: transparent;
color: var(--theme-accent, #01628c);
border-radius: 0.4vw;
cursor: pointer;
}
.team-pagination button.active {
background: #01628c;
border-color: #01628c;
background: var(--theme-accent, #01628c);
border-color: var(--theme-accent, #01628c);
color: #fff;
}
@@ -416,8 +419,9 @@ table td:first-child {
min-width: 32px;
height: 30px;
padding: 0 10px;
border: 1px solid #bbb;
background: #fff;
border: 1px solid var(--theme-accent, #01628c);
background: transparent;
color: var(--theme-accent, #01628c);
border-radius: 0.4vw;
cursor: pointer;
}
@@ -433,7 +437,3 @@ table td:first-child {
color: #333;
}
th.js-domain-sort {
cursor: pointer;
user-select: none;
}
+2 -2
View File
@@ -261,8 +261,8 @@ template(name="domainGeneral")
table
thead
tr
th.js-domain-sort(scope="col" data-sort="domain") {{_ 'domain'}} {{domainSortIndicator 'domain'}}
th.js-domain-sort(scope="col" data-sort="count") {{_ 'domain-user-count'}} {{domainSortIndicator 'count'}}
th(scope="col") {{_ 'domain'}}
th(scope="col") {{_ 'domain-user-count'}}
tbody
if domainList.length
each domainList
-22
View File
@@ -1895,16 +1895,12 @@ Template.settingsUserPopup.helpers({
// is never loaded into the browser.
Template.domainGeneral.onCreated(function () {
this.searchQuery = new ReactiveVar('');
this.sortField = new ReactiveVar('domain'); // domain | count
this.sortDirection = new ReactiveVar(1); // 1 ascending, -1 descending
this.page = new ReactiveVar(1);
this.pageData = new ReactiveVar({ rows: [], total: 0, totalPages: 1 });
this.autorun(() => {
const params = {
search: this.searchQuery.get(),
sortField: this.sortField.get(),
sortDirection: this.sortDirection.get(),
page: this.page.get(),
perPage: domainsPerPage,
};
@@ -1937,12 +1933,6 @@ Template.domainGeneral.helpers({
const tpl = Template.instance();
return tpl.page.get() < (tpl.pageData.get().totalPages || 1);
},
// Excel-like sort arrow on the active column, matching the Board Table view.
domainSortIndicator(field) {
const tpl = Template.instance();
if (tpl.sortField.get() !== field) return '';
return tpl.sortDirection.get() === 1 ? '▲' : '▼';
},
});
Template.domainGeneral.events({
@@ -1968,16 +1958,4 @@ Template.domainGeneral.events({
const current = tpl.page.get();
if (current < (tpl.pageData.get().totalPages || 1)) tpl.page.set(current + 1);
},
'click .js-domain-sort'(event, tpl) {
event.preventDefault();
const field = event.currentTarget.dataset.sort;
if (!field) return;
if (tpl.sortField.get() === field) {
tpl.sortDirection.set(tpl.sortDirection.get() * -1);
} else {
tpl.sortField.set(field);
tpl.sortDirection.set(1);
}
tpl.page.set(1);
},
});
@@ -0,0 +1,16 @@
template(name="problemsSummary")
.global-search-results-list-wrapper
h1 {{_ 'summary'}}
if hasProblems
p {{_ 'problems-summary-help'}}
.admin-problem-banner
each area in areas
label.admin-problem-item
input.js-problem-check(type="checkbox" data-stream="{{area.stream}}")
i.fa.fa-exclamation-triangle
span.admin-problem-path {{_ 'admin-panel'}} → {{_ 'problems'}} → {{areaLabel area.stream}}
span.admin-problem-count {{area.count}} {{_ 'new-problems'}}
.admin-problem-actions
button.js-ack-checked.primary {{_ 'acknowledge'}}
else
p {{_ 'no-new-problems'}}
@@ -0,0 +1,52 @@
import { TAPi18n } from '/imports/i18n';
// Admin Panel → Problems → Summary page: a checkbox list of problem areas
// (Security / Speed / Tests) with each area's menu path and its count of NEW
// (unacknowledged) problems, plus ONE Acknowledge button that acknowledges every
// checked area. This is the ONLY place problems are acknowledged — the Security /
// Speed / Tests detail pages are read-only.
// Design: docs/Security/Remediation/WeKan.md. Data: the admin-only
// eventLogProblemAreas / acknowledgeEventLog methods (models/eventLog.js).
const STREAM_LABEL_KEY = {
security: 'securityReportTitle',
speed: 'speedReportTitle',
tests: 'testsReportTitle',
};
Template.problemsSummary.onCreated(function () {
this.areas = new ReactiveVar([]);
this.reload = () => {
Meteor.call('eventLogProblemAreas', (err, res) => {
if (!err) this.areas.set(Array.isArray(res) ? res : []);
});
};
this.reload();
this.timer = Meteor.setInterval(this.reload, 30000);
});
Template.problemsSummary.onDestroyed(function () {
if (this.timer) Meteor.clearInterval(this.timer);
});
Template.problemsSummary.helpers({
areas() {
return Template.instance().areas.get();
},
hasProblems() {
return (Template.instance().areas.get() || []).length > 0;
},
areaLabel(stream) {
return TAPi18n.__(STREAM_LABEL_KEY[stream] || stream);
},
});
Template.problemsSummary.events({
'click .js-ack-checked'(event, templateInstance) {
const streams = Array.from(templateInstance.findAll('.js-problem-check:checked'))
.map(el => el.getAttribute('data-stream'))
.filter(Boolean);
if (!streams.length) return;
Meteor.call('acknowledgeEventLog', streams, () => templateInstance.reload());
},
});
+3 -3
View File
@@ -137,19 +137,19 @@
/* Admin panel buttons should use theme darker color */
.setting-content .content-body .main-body .setting-detail button.btn {
background: #005377;
background: var(--theme-accent, #005377);
color: #fff;
border: none;
}
.setting-content .content-body .main-body .setting-detail button.btn:hover,
.setting-content .content-body .main-body .setting-detail button.btn:focus {
background: #004766;
background: var(--theme-accent, #004766);
color: #fff;
}
.setting-content .content-body .main-body .setting-detail button.btn:active {
background: #01628c;
background: var(--theme-accent, #01628c);
color: #fff;
}
@@ -76,3 +76,48 @@
.iphone-device #header-quick-access .setting-header-btns .setting-header-btn i.fa {
font-size: 18px !important;
}
/* Admin Panel problem banner (Reports → Security/Speed/Tests new-problem info).
Design: docs/Security/Remediation/WeKan.md. Shown under the settingHeaderBar h1;
each area names its menu path + new count and has an Acknowledge button. */
.admin-problem-banner {
margin: 6px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.admin-problem-banner .admin-problem-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 4px;
background: #fdecea;
color: #611a15;
border: 1px solid #f5c6cb;
}
.admin-problem-banner .admin-problem-item .fa-exclamation-triangle {
color: #c0392b;
}
.admin-problem-banner .admin-problem-path {
font-weight: bold;
}
.admin-problem-banner .admin-problem-count {
margin-inline-start: auto;
}
.admin-problem-banner .js-ack-problems {
cursor: pointer;
white-space: nowrap;
}
/* Problems button turns red when there are new (unacknowledged) Security/Speed/
Tests problems (settingHeader.js problemsClass). */
.setting-header-btn.problems.has-problems,
.setting-header-btn.problems.has-problems:hover {
background-color: #c0392b;
color: #fff;
}
.setting-header-btn.problems.has-problems i,
.setting-header-btn.problems.has-problems span {
color: #fff;
}
@@ -19,11 +19,6 @@ template(name="settingHeaderBar")
i.fa.fa-flask
span {{_ 'features'}}
a.setting-header-btn.informations(class=isAdminReportsActive href="{{pathFor 'admin-reports'}}")
span.emoji-icon
i.fa.fa-file-text-o
span {{_ 'reports'}}
a.setting-header-btn.informations(class=isAttachmentsActive href="{{pathFor 'attachments'}}")
span.emoji-icon
i.fa.fa-paperclip
@@ -39,6 +34,11 @@ template(name="settingHeaderBar")
i.fa.fa-info-circle
span {{_ 'info'}}
a.setting-header-btn.problems(class="{{isProblemsActive}} {{problemsClass}}" href="{{pathFor 'admin-reports'}}")
span.emoji-icon
i.fa.fa-exclamation-triangle
span {{_ 'problems'}}
else
a.setting-header-btn.js-log-in(
title="{{_ 'log-in'}}")
+26 -3
View File
@@ -1,6 +1,32 @@
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
// Reactive "are there any new problems?" flag, polled from the admin-only
// eventLogProblemAreas method, so the Problems button turns red when there are
// unacknowledged Security/Speed/Tests problems.
const hasProblemsVar = new ReactiveVar(false);
let problemsTimer = null;
function refreshProblems() {
Meteor.call('eventLogProblemAreas', (err, res) => {
hasProblemsVar.set(!err && Array.isArray(res) && res.length > 0);
});
}
Template.settingHeaderBar.onCreated(function () {
refreshProblems();
problemsTimer = Meteor.setInterval(refreshProblems, 30000);
});
Template.settingHeaderBar.onDestroyed(function () {
if (problemsTimer) Meteor.clearInterval(problemsTimer);
});
Template.settingHeaderBar.helpers({
isProblemsActive() {
return FlowRouter.getRouteName() === 'admin-reports' ? 'active' : '';
},
// 'has-problems' → red background (see settingHeader.css) when there are new problems.
problemsClass() {
return hasProblemsVar.get() ? 'has-problems' : '';
},
isSettingsActive() {
return FlowRouter.getRouteName() === 'setting' ? 'active' : '';
},
@@ -10,9 +36,6 @@ Template.settingHeaderBar.helpers({
isFeaturesActive() {
return FlowRouter.getRouteName() === 'admin-features' ? 'active' : '';
},
isAdminReportsActive() {
return FlowRouter.getRouteName() === 'admin-reports' ? 'active' : '';
},
isAttachmentsActive() {
return FlowRouter.getRouteName() === 'attachments' ? 'active' : '';
},
@@ -31,7 +31,10 @@ Template.translation.onCreated(function () {
this.autorun(() => {
const limitTranslations = this.page.get() * translationsPerPage;
this.subscribe('translation', this.findTranslationsOptions.get(), 0, () => {
// #perf: pass the infinite-scroll window (not 0). A hardcoded 0 meant
// Mongo `limit: 0` = NO limit, so the Admin Panel → Translation page loaded
// the ENTIRE Translations collection (all strings × all languages) at once.
this.subscribe('translation', this.findTranslationsOptions.get(), limitTranslations, () => {
this.loadNextPageLocked = false;
const nextPeakBefore = this.infiniteScrolling.getNextPeak();
this.calculateNextPeak();
+54 -3
View File
@@ -29,6 +29,16 @@
.sidebar .sidebar-content {
padding: 0 12px;
}
/* Sidebar text inputs (Filter List by Title, Filter by card title, advanced
filter, etc.) fill the padded content width, so there is equal empty space on
both sides (the .sidebar-content 12px padding) instead of the browser's
default narrow width that left a large gap on the right. box-sizing keeps the
border inside the width so it never overflows the panel. */
.sidebar .sidebar-content input[type="text"],
.sidebar .sidebar-content input[type="search"] {
width: 100%;
box-sizing: border-box;
}
.sidebar .sidebar-content .hide-btn {
display: none;
}
@@ -191,7 +201,12 @@ body.grey-icons-enabled .sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-
}
.board-sidebar {
display: none;
width: 30vw;
/* Keep a sensible sidebar width at every screen size. A bare 30vw scaled with
the viewport, so on very wide screens it grew to ~double the intended width
while looking right only at mid widths; clamp keeps the mid-width look and
caps it (and floors it) instead of scaling without bound. Phones (<800px)
are handled by the 100vw rule in the media query below. */
width: clamp(320px, 30vw, 560px);
z-index: 100;
transition: top 0.1s, right 0.1s, width 0.1s;
}
@@ -203,6 +218,36 @@ body.grey-icons-enabled .sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-
Restore/Delete links) sits unreachable UNDER the card window. */
z-index: 2002;
}
/* Right sidebar resize handle: a thin grab strip on the LEFT edge of the open
sidebar; drag left/right to change the sidebar width. Desktop only (hidden on
phones by the media query below, where the sidebar is full width). */
.sidebar-resize-handle {
display: none;
}
.board-sidebar.is-open .sidebar-resize-handle {
display: block;
position: absolute;
top: 0;
bottom: 0;
inset-inline-start: -3px;
width: 6px;
cursor: col-resize;
z-index: 2003; /* above the open sidebar (2002) and its content */
background: transparent;
transition: background-color 0.2s ease;
}
.sidebar-resize-handle:hover {
background: rgba(0, 123, 255, 0.4);
box-shadow: 0 0 4px rgba(0, 123, 255, 0.3);
}
.sidebar-resize-handle:active {
background: rgba(0, 123, 255, 0.6);
box-shadow: 0 0 6px rgba(0, 123, 255, 0.4);
}
body.sidebar-resizing-active {
user-select: none;
cursor: col-resize;
}
.board-widget h4 {
margin: 5px 0;
}
@@ -305,10 +350,16 @@ body.grey-icons-enabled .sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-
bottom: 0;
inset-inline-start: 0;
inset-inline-end: 0;
width: 100vw;
max-width: 100vw;
/* !important so a desktop drag-resize inline width never leaks into mobile,
where the sidebar must stay full width. */
width: 100vw !important;
max-width: 100vw !important;
z-index: 1000;
}
/* No dragging to resize on phones — the sidebar is full width there. */
.board-sidebar .sidebar-resize-handle {
display: none;
}
/* Normalise sidebar text on phones so the headings are not oversized and the
small text is not tiny — keep them close to the board's text size so board
and sidebar read at one consistent size. */
+7 -7
View File
@@ -1,5 +1,9 @@
template(name="sidebar")
.board-sidebar.sidebar(class="{{#if isOpen}}is-open{{/if}} {{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}")
//- Drag the sidebar's LEFT edge to resize its width (desktop only; hidden on
mobile where the sidebar is full width). Saved to the user profile (or
localStorage for anonymous users on a public board).
.sidebar-resize-handle.js-sidebar-resize-handle.nodragscroll(title="{{_ 'drag-to-resize-sidebar'}}")
//a.sidebar-tongue.js-toggle-sidebar(
//
class="{{#if isTongueHidden}}is-hidden{{/if}}",
@@ -148,13 +152,9 @@ template(name="boardDomainRow")
text(x="50%" y="11" text-anchor="middle" dominant-baseline="middle" font-size="16")= domain
template(name="boardChangeColorPopup")
.board-backgrounds-list.clearfix
each backgroundColors
.board-background-select.js-select-background
span.background-box(class="board-color-{{this}}")
span {{this}}
if isSelected
i.fa.fa-check
//- docs/Theme/Theme.md: category + theme dropdowns with custom color wheels for
the flat/clear categories, shared with Member Settings / Change Color.
+themeColorPicker(scope="board")
template(name="boardBackgroundsPopup")
.board-backgrounds-manage
+132 -20
View File
@@ -11,7 +11,6 @@ import Attachments from '/models/attachments';
import { generateUniversalAttachmentUrl } from '/models/lib/universalUrlGenerator';
import Integrations from '/models/integrations';
import Lists from '/models/lists';
import { BOARD_COLORS } from '/models/metadata/colors';
import { Filter } from '/client/lib/filter';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
@@ -139,7 +138,137 @@ Template.sidebar.onCreated(function() {
};
});
// ── Right sidebar drag-to-resize (desktop only) ──────────────────────────────
// Drag the sidebar's left edge to change its width, like resizing a spreadsheet
// column (mirrors the list resize in client/components/lists/list.js). The width
// is saved to the user profile for logged-in users, or browser localStorage for
// anonymous users on a public board. On phones the sidebar stays full width.
const MIN_SIDEBAR_WIDTH = 280;
const SIDEBAR_WIDTH_STORAGE_KEY = 'wekan-sidebar-width';
const isMobileSidebar = () => window.innerWidth <= 800;
const maxSidebarWidth = () =>
Math.max(MIN_SIDEBAR_WIDTH, Math.min(900, window.innerWidth - 80));
function readSavedSidebarWidth() {
const user = ReactiveCache.getCurrentUser();
if (user) return user.getSidebarWidth();
try {
const v = window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
return v ? parseInt(v, 10) : undefined;
} catch (e) {
return undefined;
}
}
function saveSidebarWidth(width) {
const user = ReactiveCache.getCurrentUser();
if (user) {
Meteor.call('setSidebarWidth', width, err => {
if (err) console.error('Error saving sidebar width:', err);
});
} else {
try {
window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(width));
} catch (e) {
/* localStorage unavailable (private mode) — width just isn't persisted */
}
}
}
function applySavedSidebarWidth($sidebar) {
// On phones the sidebar is full width (a !important CSS rule), so never set an
// inline width there; clear any leftover so shrinking desktop -> mobile works.
if (isMobileSidebar()) {
$sidebar[0].style.removeProperty('width');
return;
}
const w = readSavedSidebarWidth();
if (typeof w === 'number' && w >= MIN_SIDEBAR_WIDTH) {
$sidebar[0].style.width = `${Math.min(w, maxSidebarWidth())}px`;
} else {
$sidebar[0].style.removeProperty('width'); // fall back to the CSS clamp
}
}
Template.sidebar.onRendered(function() {
const tpl = this;
const $sidebar = tpl.$('.board-sidebar');
const $handle = tpl.$('.js-sidebar-resize-handle');
if (!$sidebar.length || !$handle.length) return;
// Apply the saved width when the current user (and thus the saved value)
// resolves, and re-apply on window resize so crossing the mobile breakpoint
// clears/restores the inline width.
tpl.autorun(() => {
ReactiveCache.getCurrentUser(); // reactive dependency on login state
applySavedSidebarWidth($sidebar);
});
const onWinResize = () => applySavedSidebarWidth($sidebar);
window.addEventListener('resize', onWinResize);
let isResizing = false;
let startX = 0;
let startWidth = 0;
let lastWidth = 0;
const getPageX = (e) => {
const oe = e.originalEvent || e;
if (oe.touches && oe.touches.length) return oe.touches[0].pageX;
if (oe.changedTouches && oe.changedTouches.length) return oe.changedTouches[0].pageX;
return e.pageX;
};
// Direction: the sidebar docks to the logical inline-end. In LTR that is the
// right, so its left-edge handle widens the panel when dragged left; RTL puts
// the panel on the left, mirroring the direction.
const isRtl = () =>
(document.documentElement.getAttribute('dir') || document.dir) === 'rtl';
const startResize = (e) => {
if (isMobileSidebar()) return; // no resize on phones
isResizing = true;
startX = getPageX(e);
startWidth = $sidebar.outerWidth();
lastWidth = startWidth;
$('body').addClass('sidebar-resizing-active');
e.preventDefault();
e.stopPropagation();
};
const doResize = (e) => {
if (!isResizing) return;
const delta = (startX - getPageX(e)) * (isRtl() ? -1 : 1);
const w = Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxSidebarWidth(), startWidth + delta));
lastWidth = w;
$sidebar[0].style.width = `${w}px`;
e.preventDefault();
e.stopPropagation();
};
const stopResize = (e) => {
if (!isResizing) return;
isResizing = false;
$('body').removeClass('sidebar-resizing-active');
saveSidebarWidth(Math.round(lastWidth));
if (e && e.preventDefault) e.preventDefault();
};
$handle.on('mousedown', startResize);
$(document).on('mousemove', doResize);
$(document).on('mouseup', stopResize);
// Native touch API so { passive: false } applies and preventDefault works.
$handle[0].addEventListener('touchstart', startResize, { passive: false });
document.addEventListener('touchmove', doResize, { passive: false });
document.addEventListener('touchend', stopResize, { passive: false });
tpl._sidebarResizeCleanup = () => {
window.removeEventListener('resize', onWinResize);
$(document).off('mousemove', doResize);
$(document).off('mouseup', stopResize);
document.removeEventListener('touchmove', doResize);
document.removeEventListener('touchend', stopResize);
};
});
Template.sidebar.onDestroyed(function() {
if (this._sidebarResizeCleanup) this._sidebarResizeCleanup();
clearSidebarInstance(this);
Sidebar = null;
});
@@ -967,25 +1096,8 @@ function draggableMembersLabelsWidgets() {
Template.membersWidget.onRendered(draggableMembersLabelsWidgets);
Template.labelsWidget.onRendered(draggableMembersLabelsWidgets);
Template.boardChangeColorPopup.helpers({
backgroundColors() {
return BOARD_COLORS;
},
isSelected() {
const currentBoard = Utils.getCurrentBoard();
return currentBoard.color === Template.currentData().toString();
},
});
Template.boardChangeColorPopup.events({
async 'click .js-select-background'(evt) {
evt.preventDefault();
evt.stopPropagation();
const currentBoard = Utils.getCurrentBoard();
const newColor = this.toString();
await currentBoard.setColor(newColor);
},
});
// docs/Theme/Theme.md: the board color popup now renders the shared themeColorPicker
// (scope="board"); its logic lives in client/components/main/themeColorPicker.js.
Template.boardChangeBackgroundImagePopup.events({
async submit(event, tpl) {
+8 -1
View File
@@ -67,8 +67,15 @@
padding: 0;
font-weight: bold;
min-height: 33px;
height: auto;
width: 100%;
overflow: hidden;
/* #6465: the gray swimlane header bar looked cut off at non-100% board zoom
(transform: scale on .board-wrapper). `overflow: hidden` clipped the header
to a slightly-too-short box once the scaled layout rounded, so the bar/text
appeared sliced. Let it grow to fit (height:auto + min-height floor) and not
clip vertically. Swimlane names are short, so dropping the ellipsis here is
fine; a long name wraps (word-wrap) instead of being cut. */
overflow: visible;
-o-text-overflow: ellipsis;
text-overflow: ellipsis;
word-wrap: break-word;
+62 -8
View File
@@ -15,12 +15,27 @@ template(name="swimlane")
each lists
+miniList(this)
else
if currentUser.isBoardMember
unless currentUser.isCommentOnly
+addListForm
//- #6465: no standing Add List column. Empty swimlane gets the inline
//- composer at the start; otherwise it opens after a list via its header
//- add-list button (see isAddListAfter).
if swimlaneHasNoLists
if currentUser.isBoardMember
unless currentUser.isCommentOnly
//- #6465: an empty swimlane shows only a + button; the create-list
//- form appears only after it is clicked. (Non-empty swimlanes use
//- the per-list-header add-list button instead.)
if isEmptyAddListOpen _id
+addListInline
else
.list.list-composer.js-list-composer.js-empty-add-list-button(class="{{#if isMiniScreen}}mini-list{{/if}}")
.list-header-add
a.open-list-composer.js-open-empty-add-list(title="{{_ 'add-list'}}" data-swimlane="{{_id}}")
i.fa.fa-plus
each lists
if visible this
+list(this)
if isAddListAfter _id
+addListInline
if currentCardIsInThisList _id ../_id
+cardDetails(currentCard)
@@ -38,14 +53,26 @@ template(name="listsGroup")
each lists
+miniList(this)
else
//- Issue #6142: allow adding a list in Lists (board-view-lists) mode too.
if defaultSwimlane
if currentUser.isBoardMember
unless currentUser.isCommentOnly
+addListForm(defaultSwimlane)
//- #6465: Lists view — empty board gets the inline composer at the start;
//- otherwise it opens after a list via that list's header add-list button.
if boardHasNoLists
if defaultSwimlane
if currentUser.isBoardMember
unless currentUser.isCommentOnly
//- #6465: empty board shows only a + button; the create-list form
//- appears only after clicking it.
if isEmptyAddListOpen defaultSwimlane._id
+addListInline(defaultSwimlane)
else
.list.list-composer.js-list-composer.js-empty-add-list-button(class="{{#if isMiniScreen}}mini-list{{/if}}")
.list-header-add
a.open-list-composer.js-open-empty-add-list(title="{{_ 'add-list'}}" data-swimlane="{{defaultSwimlane._id}}")
i.fa.fa-plus
each lists
if visible this
+list(this)
if isAddListAfter _id
+addListInline(defaultSwimlane)
if currentCardIsInThisList _id null
+cardDetails(currentCard)
@@ -77,6 +104,33 @@ template(name="addListForm")
a.open-list-composer.js-open-inlined-form(title="{{_ 'add-list'}}")
i.fa.fa-plus
//- #6465: always-open inline Add List composer, rendered AFTER a list (right in
//- LTR / left in RTL, via the flex-row lists lane) when that list's header
//- add-list button is clicked, or at the start of an empty swimlane/board.
template(name="addListInline")
.list.list-composer.js-list-composer.js-add-list-inline(class="{{#if isMiniScreen}}mini-list{{/if}}")
.list-header-add
form.js-add-list-inline-form
input.list-name-input.full-line(type="text" placeholder="{{_ 'add-list'}}"
autocomplete="off" autofocus)
//- #6465 follow-up: restore the previous "add after which list"
//- position selector, pre-selecting the list whose header button opened
//- this composer, so the position can still be chosen explicitly.
if swimlaneLists.length
| {{_ 'add-after-list'}}
select.list-position-input.full-line
each swimlaneLists
option(value="{{_id}}" selected="{{#if isDefaultAfterList _id}}selected{{/if}}") {{title}}
.edit-controls.clearfix
button.primary.confirm(type="submit") {{_ 'save'}}
a.js-close-add-list-inline(title="{{_ 'close'}}")
i.fa.fa-times-thin
unless currentBoard.isTemplatesBoard
unless currentBoard.isTemplateBoard
span.quiet
| {{_ 'or'}}
a.js-list-template {{_ 'template'}}
template(name="moveSwimlanePopup")
if currentUser
unless currentUser.isWorker
+162 -2
View File
@@ -6,6 +6,7 @@ import { CSSEvents } from '/client/lib/cssEvents';
import { Filter } from '/client/lib/filter';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import { TAPi18n } from '/imports/i18n';
import { defaultSwimlaneIdForBoard } from '/client/components/lists/listAddHelpers';
const { calculateIndex } = Utils;
@@ -71,11 +72,45 @@ function saveSorting(ui) {
sort: sortIndex.base,
};
// Check if the list was dropped in a different swimlane
const isDifferentSwimlane = targetSwimlaneId && targetSwimlaneId !== originalSwimlaneId;
// Check if the list was dropped in a different swimlane.
//
// #6484: only rebind a list to a swimlane when it was ALREADY swimlane-scoped
// (originalSwimlaneId set) and actually crossed into a DIFFERENT swimlane. A
// board-wide list has swimlaneId === null / getEffectiveSwimlaneId() === null
// and renders under EVERY swimlane (see filterCardsByListAndSwimlane and
// currentListIsInThisSwimlane: a null swimlaneId matches every swimlane). Since
// targetSwimlaneId is ALWAYS the swimlane the list is shown under in swimlanes
// view, the old `targetSwimlaneId !== originalSwimlaneId` was true for ANY nudge
// of a board-wide list, so it set list.swimlaneId AND moved every card to that
// one swimlane — the list then vanished from all the other swimlanes. Requiring
// originalSwimlaneId keeps board-wide lists board-wide; genuinely
// swimlane-scoped lists can still be moved between swimlanes.
const isDifferentSwimlane =
originalSwimlaneId &&
targetSwimlaneId &&
targetSwimlaneId !== originalSwimlaneId;
// If the list was dropped in a different swimlane, update the swimlaneId
if (isDifferentSwimlane) {
// #6478: moving a list to a DIFFERENT swimlane is high-impact — it re-homes
// every card in the list — and is easy to trigger accidentally on touch (the
// reporter merged lists this way and needed ~15 min to recover). Confirm on
// touch/small screens before applying; on decline, revert the drag by
// re-rendering from the server and abort. (Ctrl+Z can also undo it after the
// fact — see userPositionHistory.undoLast.)
if (
(Utils.isMiniScreen() || Utils.isTouchScreenOrShowDesktopDragHandles()) &&
typeof window !== 'undefined' &&
typeof window.confirm === 'function' &&
// eslint-disable-next-line no-alert
!window.confirm(TAPi18n.__('confirm-move-list-to-swimlane'))
) {
Meteor.subscribe('board', list.boardId, false);
$('.js-swimlane').each(function () {
$(this).addClass('dragscroll');
});
return;
}
updateData.swimlaneId = targetSwimlaneId;
// Move all cards in the list to the new swimlane
@@ -679,6 +714,24 @@ Template.swimlane.helpers({
}
return combined;
},
// #6465: the add-list composer is no longer a standing column. It opens AFTER a
// specific list when that list's header add-list button is clicked (the target
// list _id is kept in Session 'wekan-add-list-after'). Rendered after the list
// in the DOM, it appears to the RIGHT of the list in LTR and to the LEFT in RTL
// (the lists lane is a flex row that reverses under dir=rtl).
isAddListAfter(listId) {
return Session.get('wekan-add-list-after') === listId;
},
// Empty swimlane / empty board: there are no list headers to host the button,
// so offer a + button that reveals the inline composer only when clicked.
swimlaneHasNoLists() {
return !this.myLists || this.myLists().length === 0;
},
// #6465: is the empty-state create-list form open for this swimlane? (Set by the
// + button; until then only the button shows.)
isEmptyAddListOpen(swimlaneId) {
return Session.get('wekan-add-list-empty') === swimlaneId;
},
collapseSwimlane() {
return Utils.getSwimlaneCollapseState(this);
},
@@ -742,7 +795,19 @@ Template.swimlane.helpers({
},
});
// #6465: open the empty-state create-list form for a swimlane (shared by the
// Swimlanes and Lists views). Until clicked, an empty swimlane/board shows only the
// + button; this reveals the inline composer, scoped to the clicked swimlane.
function openEmptyAddList(event) {
event.preventDefault();
const swimlaneId = $(event.currentTarget).data('swimlane') || null;
Session.set('wekan-add-list-after', null);
Session.set('wekan-add-list-swimlane', swimlaneId);
Session.set('wekan-add-list-empty', swimlaneId);
}
Template.swimlane.events({
'click .js-open-empty-add-list': openEmptyAddList,
// Click-and-drag action
'mousedown .board-canvas'(evt, tpl) {
const noDragInside = ['a', 'input', 'textarea', 'p'].concat(
@@ -848,6 +913,84 @@ Template.addListForm.events({
'click .js-list-template': Popup.open('searchElement'),
});
// #6465: the inline add-list composer that opens after a specific list (or at the
// start of an empty swimlane). Its data context is the swimlane it creates the
// list in; the target list (for positioning) is Session 'wekan-add-list-after'.
Template.addListInline.onCreated(function () {
this.currentSwimlane = Template.currentData();
});
Template.addListInline.helpers({
// #6465 follow-up: the lists of the swimlane this composer opens in, so the
// "add after which list" position selector (restored) can be shown.
swimlaneLists() {
const swimlaneId =
Session.get('wekan-add-list-swimlane') ||
Template.instance().currentSwimlane?._id;
if (!swimlaneId) return [];
return ReactiveCache.getLists(
{ swimlaneId, archived: false },
{ sort: { sort: 1 } },
);
},
// Pre-select the list whose header add-list button opened this composer.
isDefaultAfterList(listId) {
return Session.get('wekan-add-list-after') === listId;
},
});
Template.addListInline.events({
async submit(evt, tpl) {
evt.preventDefault();
const titleInput = tpl.find('.list-name-input');
const title = titleInput?.value.trim();
if (!title) return;
// Position: honour the explicit "add after list" selector when present
// (its options mirror the swimlane's lists, defaulting to the one whose
// header opened this composer), and derive nextListId from the following
// option so the new list is inserted between them. Fall back to the header
// button's target (Session) for the empty-board/start case.
const positionInput = tpl.find('.list-position-input');
let afterListId;
let nextListId = null;
if (positionInput && positionInput.value) {
afterListId = positionInput.value.trim();
const next = positionInput.options[positionInput.selectedIndex + 1];
nextListId = next ? next.value : null;
} else {
afterListId = Session.get('wekan-add-list-after') || null;
}
try {
await Meteor.callAsync('createListAfter', {
title,
boardId: Session.get('currentBoard'),
// The header button records the swimlane the list is displayed in; the
// empty-swimlane composer falls back to its own swimlane data context.
swimlaneId: Session.get('wekan-add-list-swimlane') || tpl.currentSwimlane?._id,
afterListId,
nextListId,
type: 'list',
});
titleInput.value = '';
titleInput.focus();
// Empty-state flow: the swimlane now has a list, so drop the empty-composer
// flag — if it becomes empty again the + button (not the form) shows first.
Session.set('wekan-add-list-empty', null);
} catch (error) {
console.error('Failed to create list:', error);
}
},
// Restore the "or template" option: create the list from a template.
'click .js-list-template': Popup.open('searchElement'),
'click .js-close-add-list-inline'() {
Session.set('wekan-add-list-after', null);
Session.set('wekan-add-list-swimlane', null);
Session.set('wekan-add-list-empty', null);
},
});
Template.listsGroup.helpers({
// Issue #6142: the add-list composer needs a swimlane as its data context
// (it creates the list in that swimlane). In Lists mode the listsGroup data
@@ -859,6 +1002,18 @@ Template.listsGroup.helpers({
if (!swimlaneId) return null;
return ReactiveCache.getSwimlane({ _id: swimlaneId });
},
// #6465: same add-list-after-a-list behaviour as the swimlanes view.
isAddListAfter(listId) {
return Session.get('wekan-add-list-after') === listId;
},
boardHasNoLists() {
const board = Template.currentData();
return !board || !board.lists || board.lists().length === 0;
},
// #6465: is the empty-board create-list form open? (Set by the + button.)
isEmptyAddListOpen(swimlaneId) {
return Session.get('wekan-add-list-empty') === swimlaneId;
},
currentCardIsInThisList(listId, swimlaneId) {
return currentCardIsInThisList(listId, swimlaneId);
},
@@ -883,6 +1038,11 @@ Template.listsGroup.helpers({
},
});
Template.listsGroup.events({
// #6465: empty board (Lists view) + button reveals the inline composer.
'click .js-open-empty-add-list': openEmptyAddList,
});
Template.listsGroup.onRendered(function () {
const tpl = this;
const boardBodyEl2 = tpl.firstNode?.parentElement?.closest?.('.board-body') || document.querySelector('.board-body');
+2 -2
View File
@@ -98,7 +98,7 @@ template(name="changeAvatarPopup")
| {{_ 'allowed-avatar-filetypes'}} {{Meteor.settings.public.avatarsUploadMimeTypes}}
br
| {{_ 'invalid-file'}}
button.full.js-upload-avatar
button.full.primary.js-upload-avatar
i.fa.fa-upload
| {{_ 'upload-avatar' }}
@@ -137,7 +137,7 @@ template(name="adminChangeAvatarPopup")
| {{_ 'allowed-avatar-filetypes'}} {{Meteor.settings.public.avatarsUploadMimeTypes}}
br
| {{_ 'invalid-file'}}
button.full.js-upload-avatar
button.full.primary.js-upload-avatar
i.fa.fa-upload
| {{_ 'upload-avatar' }}
+52 -4
View File
@@ -44,10 +44,7 @@ template(name="memberMenuPopup")
a.board-header-btn.js-open-archived-board
i.fa.fa-archive
span {{_ 'archives'}}
li
a.js-notifications-drawer-toggle
i.fa.fa-bell
| {{_ 'notifications'}}
//- Notifications removed from the member menu: same as the header bell icon.
if currentSetting.customHelpLinkUrl
li
a(href="{{currentSetting.customHelpLinkUrl}}", title="{{_ 'help'}}", target="_blank", rel="noopener noreferrer")
@@ -79,6 +76,14 @@ template(name="memberMenuPopup")
a.js-change-settings
i.fa.fa-cog
| {{_ 'change-settings'}}
li
a.js-change-color
i.fa.fa-paint-brush
| {{_ 'change-color'}}
li
a.js-change-font
i.fa.fa-font
| {{_ 'change-font'}}
li
a.js-change-avatar
i.fa.fa-picture-o
@@ -181,6 +186,43 @@ template(name="changeLanguagePopup")
| (RTL)
if isCurrentLanguage
i.fa.fa-check
template(name="changeColorPopup")
//- #5778: global theme color override, saved to the user profile. Works like
Board Settings / Change Color, but applies to the whole UI (All Boards, Search,
Admin Panel, etc). The shared picker (docs/Theme/Theme.md) offers category +
theme dropdowns and custom color wheels for flat/clear themes.
+themeColorPicker(scope="global")
template(name="changeFontPopup")
//- #4759: pick a UI font that ALREADY EXISTS in this browser. One button per
detected font, each shown in its OWN font (names are plain text, Blaze-escaped,
validated to a curated whitelist server-side). Font size is a row of buttons
(smaller on the left, 100% in the middle, larger on the right). Clicking a font
or size applies it IMMEDIATELY (no Save button).
form.change-font.edit-label
label {{_ 'font'}}
.ui-font-buttons
a.ui-font-btn.js-ui-font-btn(data-font="" class="{{#if isNoneSelected}}active{{/if}}") {{_ 'font-default'}}
each detectedFonts
a.ui-font-btn.js-ui-font-btn(data-font="{{name}}" style="{{optionStyle}}" class="{{#if selected}}active{{/if}}") {{name}}
//- Preview (pangram + numbers) right below the font-name buttons.
.font-preview(style=previewStyle) {{_ 'font-preview-text'}}
label {{_ 'font-size'}}
.ui-font-size-buttons
each fontSizes
a.ui-font-size-btn.js-ui-font-size-btn(data-size="{{key}}" class="{{#if selected}}active{{/if}}") {{label}}
//- #4759: text + text-background color from a color wheel, each unsettable.
//- Applied immediately when the wheel is committed / reset.
label {{_ 'text-color'}}
.color-wheel-wrap
input.js-ui-text-color(type="color" value=textColorHex title="{{_ 'text-color'}}")
a.js-reset-text-color(class="{{#unless hasTextColor}}disabled{{/unless}}") {{_ 'unset-color'}}
label {{_ 'text-background-color'}}
.color-wheel-wrap
input.js-ui-bg-color(type="color" value=bgColorHex title="{{_ 'text-background-color'}}")
a.js-reset-bg-color(class="{{#unless hasBgColor}}disabled{{/unless}}") {{_ 'unset-color'}}
template(name="changeSettingsPopup")
ul.pop-over-list
li
@@ -189,6 +231,12 @@ template(name="changeSettingsPopup")
| {{_ 'show-desktop-drag-handles'}}
if isShowDesktopDragHandles
i.fa.fa-check
li
a.js-toggle-submit-on-enter(title="{{_ 'submit-on-enter-description'}}")
i.fa.fa-level-down
| {{_ 'submit-on-enter'}}
if isSubmitOnEnter
i.fa.fa-check
unless currentUser.isWorker
li
label.bold.clear
+141 -3
View File
@@ -4,6 +4,9 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import AccountSettings from '/models/accountSettings';
import Users from '/models/users';
import { Utils } from '/client/lib/utils';
import { ReactiveVar } from 'meteor/reactive-var';
import { detectAvailableFonts } from '/client/lib/fontDetector';
import { fontFamilyValue, fontSizeValue, UI_FONT_SIZES, isHexColor6, colorValue } from '/models/lib/uiFonts';
Template.headerUserBar.events({
'click .js-open-header-member-menu': Popup.open('memberMenu'),
@@ -72,13 +75,12 @@ Template.memberMenuPopup.events({
'click .js-invite-people': Popup.open('invitePeople'),
'click .js-edit-profile': Popup.open('editProfile'),
'click .js-change-settings': Popup.open('changeSettings'),
'click .js-change-color': Popup.open('changeColor'),
'click .js-change-font': Popup.open('changeFont'),
'click .js-change-avatar': Popup.open('changeAvatar'),
'click .js-change-password': Popup.open('changePassword'),
'click .js-change-language': Popup.open('changeLanguage'),
'click .js-support': Popup.open('support'),
'click .js-notifications-drawer-toggle'() {
Session.set('showNotificationsDrawer', !Session.get('showNotificationsDrawer'));
},
'click .js-toggle-grey-icons'(event) {
event.preventDefault();
const currentUser = ReactiveCache.getCurrentUser();
@@ -320,6 +322,10 @@ Template.changeLanguagePopup.events({
});
Template.changeSettingsPopup.helpers({
isSubmitOnEnter() {
const currentUser = ReactiveCache.getCurrentUser();
return currentUser ? currentUser.hasSubmitOnEnter() : false;
},
rescueCardDescription() {
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser) {
@@ -379,6 +385,12 @@ Template.changeSettingsPopup.events({
window.localStorage.setItem('showDesktopDragHandles', 'true');
}
},
'click .js-toggle-submit-on-enter'() {
// Saved to the user profile (Member Settings only; requires a logged-in user).
if (ReactiveCache.getCurrentUser()) {
Meteor.call('toggleSubmitOnEnter');
}
},
'click .js-rescue-card-description'() {
Meteor.call('toggleRescueCardDescription')
},
@@ -413,3 +425,129 @@ Template.changeSettingsPopup.events({
Popup.back();
},
});
// #5778 + docs/Theme/Theme.md: the Member Menu / Change Color popup renders the
// shared themeColorPicker (scope="global"); all of its logic lives there.
// #4759: Member Menu / Font. A dropdown of fonts detected as installed in this
// browser; the value is stored to the profile and validated server-side. The first
// option unsets the custom font.
Template.changeFontPopup.onCreated(function () {
const user = ReactiveCache.getCurrentUser();
this.available = detectAvailableFonts(); // ordered subset of the curated whitelist
this.selected = new ReactiveVar((user && user.getUiFont && user.getUiFont()) || '');
this.selectedSize = new ReactiveVar((user && user.getUiFontSize && user.getUiFontSize()) || 'default');
// null = use default (unset); a hex string = custom color.
this.textColor = new ReactiveVar((user && user.getUiTextColor && user.getUiTextColor()) || null);
this.bgColor = new ReactiveVar((user && user.getUiTextBgColor && user.getUiTextBgColor()) || null);
});
Template.changeFontPopup.helpers({
detectedFonts() {
const tpl = Template.instance();
const sel = tpl.selected.get();
return tpl.available.map(name => ({
name,
selected: name === sel,
optionStyle: `font-family: "${name}", sans-serif;`,
}));
},
isNoneSelected() {
return !Template.instance().selected.get();
},
fontSizes() {
const cur = Template.instance().selectedSize.get();
// Ordered by percent so the buttons read: smaller ... 100% (default) ... larger.
return [...UI_FONT_SIZES].sort((a, b) => a.percent - b.percent).map(s => ({
key: s.key,
label: TAPi18n.__(`font-size-${s.key}`),
selected: s.key === cur,
}));
},
// Wheel <input type=color> needs a hex value even when unset — show a sensible
// default so the wheel opens somewhere reasonable.
textColorHex() {
return Template.instance().textColor.get() || '#000000';
},
bgColorHex() {
return Template.instance().bgColor.get() || '#ffffff';
},
hasTextColor() {
return !!Template.instance().textColor.get();
},
hasBgColor() {
return !!Template.instance().bgColor.get();
},
// Preview reflects the chosen font, size, text color and background color.
previewStyle() {
const tpl = Template.instance();
const family = fontFamilyValue(tpl.selected.get());
const size = fontSizeValue(tpl.selectedSize.get());
const color = colorValue(tpl.textColor.get());
const bg = colorValue(tpl.bgColor.get());
return [
family && `font-family: ${family};`,
size && `font-size: ${size};`,
color && `color: ${color};`,
bg && `background-color: ${bg};`,
].filter(Boolean).join('');
},
});
// Apply the current text/background colors immediately (no Save button).
function applyUiColors(tpl) {
Meteor.call('setUiColors', tpl.textColor.get(), tpl.bgColor.get(), err => {
if (err && process.env.DEBUG === 'true') console.error('setUiColors error', err);
});
}
Template.changeFontPopup.events({
// Click a font-name button -> apply that font immediately ('' = default/unset).
'click .js-ui-font-btn'(event, tpl) {
event.preventDefault();
const font = event.currentTarget.dataset.font || null;
tpl.selected.set(font || '');
Meteor.call('setUiFont', font, err => {
if (err && process.env.DEBUG === 'true') console.error('setUiFont error', err);
});
},
// Click a size button -> apply that size immediately.
'click .js-ui-font-size-btn'(event, tpl) {
event.preventDefault();
const size = event.currentTarget.dataset.size || 'default';
tpl.selectedSize.set(size);
Meteor.call('setUiFontSize', size, err => {
if (err && process.env.DEBUG === 'true') console.error('setUiFontSize error', err);
});
},
// Live-preview while dragging the wheel...
'input .js-ui-text-color'(event, tpl) {
const v = event.currentTarget.value;
if (isHexColor6(v)) tpl.textColor.set(v);
},
'input .js-ui-bg-color'(event, tpl) {
const v = event.currentTarget.value;
if (isHexColor6(v)) tpl.bgColor.set(v);
},
// ...and apply the color when the wheel is committed.
'change .js-ui-text-color'(event, tpl) {
const v = event.currentTarget.value;
if (isHexColor6(v)) tpl.textColor.set(v);
applyUiColors(tpl);
},
'change .js-ui-bg-color'(event, tpl) {
const v = event.currentTarget.value;
if (isHexColor6(v)) tpl.bgColor.set(v);
applyUiColors(tpl);
},
'click .js-reset-text-color'(event, tpl) {
event.preventDefault();
tpl.textColor.set(null); // back to default
applyUiColors(tpl);
},
'click .js-reset-bg-color'(event, tpl) {
event.preventDefault();
tpl.bgColor.set(null);
applyUiColors(tpl);
},
});
+3
View File
@@ -6,6 +6,7 @@ import '/client/components/boards/boardsList.jade';
import '/client/components/boards/miniboard.jade';
import '/client/components/boards/originalPositionsView.jade';
import '/client/components/boards/tableView.jade';
import '/client/components/boards/statsView.jade';
import '/client/components/boards/boardArchive.js';
import '/client/components/boards/boardBody.js';
@@ -14,6 +15,7 @@ import '/client/components/boards/boardHeader.js';
import '/client/components/boards/boardsList.js';
import '/client/components/boards/originalPositionsView.js';
import '/client/components/boards/tableView.js';
import '/client/components/boards/statsView.js';
import '/client/components/boards/boardBody.css';
import '/client/components/boards/dependencyOverlay.css';
@@ -23,4 +25,5 @@ import '/client/components/boards/boardsList.css';
import '/client/components/boards/calendarView.css';
import '/client/components/boards/originalPositionsView.css';
import '/client/components/boards/tableView.css';
import '/client/components/boards/statsView.css';
+4
View File
@@ -5,6 +5,7 @@ import '/client/components/main/dueCards.jade';
import '/client/components/main/editor.jade';
import '/client/components/main/globalSearch.jade';
import '/client/components/main/header.jade';
import '/client/components/main/themeColorPicker.jade';
import '/client/components/main/keyboardShortcuts.jade';
import '/client/components/main/layouts.jade';
import '/client/components/main/myCards.jade';
@@ -29,6 +30,9 @@ import '/client/components/main/dueCards.js';
import '/client/components/main/editor.js';
import '/client/components/main/globalSearch.js';
import '/client/components/main/header.js';
import '/client/components/main/globalThemeColor.js';
import '/client/components/main/themeColorPicker.js';
import '/client/components/main/uiFont.js';
import '/client/components/main/layouts.js';
import '/client/components/main/myCards.js';
import '/client/components/main/popup.js';
+2
View File
@@ -23,6 +23,8 @@ import '/client/components/settings/migrationProgress.js';
import '/client/components/settings/peopleBody.js';
import '/client/components/settings/settingBody.js';
import '/client/components/settings/settingHeader.js';
import '/client/components/settings/problemsSummary.jade';
import '/client/components/settings/problemsSummary.js';
import '/client/components/settings/translationBody.js';
import '/client/components/settings/attachments.css';
+11
View File
@@ -185,6 +185,17 @@ class DateFilter {
_getMongoSelector() {
this._dep.depend();
// #6483: cards with NO due/end/received date store the field as null — that
// is exactly what the noDate filter matches ({dueAt: null}). A comparison
// filter such as Overdue/"past" is `{$lte: now}`, and `$lte` MATCHES null
// under FerretDB and minimongo (null sorts before dates; neither implements
// MongoDB's type-bracketing), so "Overdue" listed every card that has no due
// date at all. Require a real, non-null value for any comparison selector so
// only actual dates match. The noDate filter (this._filter === null) returns
// null unchanged and is unaffected.
if (this._filter && typeof this._filter === 'object') {
return { ...this._filter, $ne: null };
}
return this._filter;
}
+36
View File
@@ -0,0 +1,36 @@
import { UI_FONTS } from '/models/lib/uiFonts';
// #4759: detect which curated UI fonts are actually installed in THIS browser, using
// the canvas text-width comparison technique. A candidate font is "available" if
// rendering a probe string in it produces a different width than the generic base
// families — i.e. the browser actually had the font and didn't fall back.
//
// Returns an ordered subset of UI_FONTS. Purely a detection helper; the value the
// user picks is still validated server-side against the whitelist.
const BASE_FONTS = ['monospace', 'sans-serif', 'serif'];
const PROBE = 'mmmmmmmmmmlliWWWWWW';
const PROBE_SIZE = '72px';
export function detectAvailableFonts(candidates = UI_FONTS) {
let ctx;
try {
ctx = document.createElement('canvas').getContext('2d');
} catch (_) {
return [];
}
if (!ctx) return [];
const baseWidths = {};
BASE_FONTS.forEach(base => {
ctx.font = `${PROBE_SIZE} ${base}`;
baseWidths[base] = ctx.measureText(PROBE).width;
});
return candidates.filter(font =>
BASE_FONTS.some(base => {
ctx.font = `${PROBE_SIZE} "${font}", ${base}`;
return ctx.measureText(PROBE).width !== baseWidths[base];
}),
);
}
+6 -1
View File
@@ -16,6 +16,8 @@ import { Template } from 'meteor/templating';
import { ReactiveVar } from 'meteor/reactive-var';
import { Tracker } from 'meteor/tracker';
import { EscapeActions } from '/client/lib/escapeActions';
import { ReactiveCache } from '/imports/reactiveCache';
import { isSubmitKey } from '/models/lib/editorSubmitKey';
import {
openForm,
closeForm,
@@ -77,7 +79,10 @@ Template.inlinedForm.events({
openForm(currentlyOpenedForm, tpl);
},
'keydown form textarea'(evt, tpl) {
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
// Ctrl/Cmd+Enter submits by default; a user who enabled "submit on Enter"
// in Member Settings submits on plain Enter (Shift+Enter for a newline).
const submitOnEnter = !!ReactiveCache.getCurrentUser()?.hasSubmitOnEnter?.();
if (isSubmitKey(evt, { submitOnEnter })) {
tpl.find('button[type=submit]').click();
}
},
+22
View File
@@ -1,4 +1,5 @@
import { Meteor } from 'meteor/meteor';
import { Session } from 'meteor/session';
import { Template } from 'meteor/templating';
import { ReactiveCache } from '/imports/reactiveCache';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
@@ -307,6 +308,27 @@ hotkeys('-', archiveCard);
// https://github.com/wekan/wekan/pull/5589#issuecomment-2516776519
hotkeys('\xf7', archiveCard);
// #6478: Undo / Redo the caller's last position change (card / list / swimlane
// move) with Ctrl+Z / Ctrl+Y (also Cmd+Z / Cmd+Shift+Z on macOS). The hotkeys
// filter above disables shortcuts inside inputs/textareas/contentEditable, so
// native text undo/redo keeps working while typing. Only enabled for members who
// can modify the board.
function undoRedoLast(method) {
const boardId = Session.get('currentBoard');
if (!boardId || !Utils.canModifyBoard()) {
return;
}
Meteor.call(method, boardId, () => {});
}
hotkeys('ctrl+z, command+z', event => {
event.preventDefault();
undoRedoLast('userPositionHistory.undoLast');
});
hotkeys('ctrl+y, ctrl+shift+z, command+shift+z', event => {
event.preventDefault();
undoRedoLast('userPositionHistory.redoLast');
});
hotkeys('n', (event) => {
event.preventDefault();
const cardId = getSelectedCardId();
+27 -7
View File
@@ -1,6 +1,6 @@
import { Mongo } from 'meteor/mongo';
import { ReactiveCache } from '/imports/reactiveCache';
import { Filter } from '/client/lib/filter';
import { Utils } from '/client/lib/utils';
import { listCardsSelector } from '/models/lib/swimlaneFilter';
const { windowCountId: windowCountIdImpl, resolveCardsLoadingMode } = require('/models/lib/cardsLoading');
@@ -10,19 +10,39 @@ const { windowCountId: windowCountIdImpl, resolveCardsLoadingMode } = require('/
// in). Only used in lazy card-loading mode.
export const BoardListCardCounts = new Mongo.Collection('boardListCardCounts');
// Client-only mirror of the per-board `boardCardLoadingModes` docs published by
// the 'boardCardsLoadingMode' publication: { _id: boardId, lazy: bool }. In 'auto'
// mode this is how the client learns whether a given board loads lazily (by size),
// so client rendering agrees with the server board publication per board. #6480.
export const BoardCardLoadingModes = new Mongo.Collection('boardCardLoadingModes');
// Effective card-loading mode. Prefers the admin Setting doc (reactive, so an
// Admin Panel change is picked up), falls back to the public env value, then to
// 'all'. See server/cards-loading.js and Admin Panel / Features.
export function cardsLoadingMode() {
const setting = ReactiveCache.getCurrentSetting();
const fromDoc = setting && setting.cardsLoading;
// Card loading is not an admin toggle: the mode comes from the CARDS_LOADING env
// var (default 'auto'), mirrored to Meteor.settings.public by the server. 'auto'
// then decides per board by size (see isLazyCards / the boardCardLoadingModes flag).
const fromEnv =
Meteor.settings && Meteor.settings.public && Meteor.settings.public.cardsLoading;
return resolveCardsLoadingMode(fromDoc || fromEnv);
return resolveCardsLoadingMode(fromEnv);
}
export function isLazyCards() {
return cardsLoadingMode() === 'lazy';
// Whether the given board (default: the current board) loads lazily. In 'auto'
// mode this is decided PER BOARD by size on the server and delivered via the
// `boardCardLoadingModes` flag; explicit 'all'/'lazy' apply globally. When the flag
// has not arrived yet in 'auto' mode we default to EAGER, so small boards keep the
// unchanged full-featured path and only a confirmed-big board switches to windows.
export function isLazyCards(boardId) {
const bid = boardId || (Utils && Utils.getCurrentBoardId && Utils.getCurrentBoardId());
if (bid) {
const doc = BoardCardLoadingModes.findOne(bid);
if (doc) return !!doc.lazy;
}
const mode = cardsLoadingMode();
if (mode === 'lazy') return true;
if (mode === 'all') return false;
return false; // 'auto', flag not yet known -> eager until the board is confirmed big
}
// Stable id for one (list, swimlane) window's count doc (shared, unit-tested).
@@ -39,7 +59,7 @@ export function windowCountId(listId, swimlaneId) {
// list.cards() runs against minimongo, the server count is exactly the accurate
// version of whatever list.cards(swimlaneId).length would report.
export function lazyListCardCount(list, swimlaneId) {
if (!isLazyCards() || !list || !list.boardId) return null;
if (!list || !list.boardId || !isLazyCards(list.boardId)) return null;
const selector = listCardsSelector(
list._id,
swimlaneId,
+10 -1
View File
@@ -136,7 +136,16 @@ window.Popup = new (class {
return function(evt, tpl) {
const context = (this.currentData && this.currentData()) || this;
context.__afterConfirmAction = action;
// #6479: store the pending confirm action on the Popup instance, NOT on the
// Blaze data context. The old code stashed it as a field on the context and
// the confirm button read it back as `this.__afterConfirmAction`; but the
// context is a ReactiveCache/Minimongo document (e.g. a board member sub-doc),
// and once Blaze re-renders the confirmation popup with a fresh/immutable copy
// of that context the field is gone, so clicking "Remove member" (and every
// other confirm dialog) silently did nothing. The instance field survives the
// re-render; the action is still invoked with the confirmation popup's own data
// context as `this`, so `this.userId` etc. keep working.
self._afterConfirmAction = action;
self.open(name).call(context, evt, tpl);
};
}
+2
View File
@@ -26,7 +26,9 @@ import '/client/components/import/import.css';
import '/client/components/lists/list.css';
import '/client/components/main/accessibility.css';
import '/client/components/main/brokenCards.css';
import '/client/components/main/customTheme.css';
import '/client/components/main/dueCards.css';
import '/client/components/main/uiFont.css';
import '/client/components/main/editor.css';
import '/client/components/main/fonts.css';
import '/client/components/main/globalSearch.css';
+83
View File
@@ -1,8 +1,10 @@
import { Meteor } from 'meteor/meteor';
import { Session } from 'meteor/session';
import { Tracker } from 'meteor/tracker';
import { TAPi18n } from '/imports/i18n';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { ReactiveCache } from '/imports/reactiveCache';
import { decideSandstormAutoOpen } from '/models/lib/sandstormAutoOpen';
import Settings from '/models/settings';
import { EscapeActions } from '/client/lib/escapeActions';
import { Filter } from '/client/lib/filter';
@@ -70,10 +72,91 @@ function renderBoardList(ctx, menu) {
});
}
// #2220: on the FIRST landing on '/' this session (i.e. right after login), send
// the user to their chosen default "home" board. Only once per session, so a
// later click on "All Boards" stays on the list, and the choice is honoured again
// on the next login / full reload. Returns true when it redirected.
function maybeRedirectToDefaultBoard() {
if (Session.get('defaultBoardRedirectDone')) return false;
Session.set('defaultBoardRedirectDone', true);
if (!Meteor.userId()) return false;
const user = ReactiveCache.getCurrentUser();
const boardId = user && user.getDefaultBoardId && user.getDefaultBoardId();
if (!boardId) return false;
// Redirect by id right away — at login the board's own subscription may not have
// loaded yet, so use its real slug when it happens to be cached and a harmless
// placeholder otherwise (the board route resolves by :id). If the board was
// deleted / access was removed, the board route degrades gracefully and the
// "All Boards" link still works (this only redirects once per session).
const board = ReactiveCache.getBoard(boardId);
FlowRouter.go('board', { id: boardId, slug: (board && board.slug) || 'board' });
return true;
}
// #2220 on Sandstorm: a grain historically opened straight into its single board.
// Restore that convenience — reactively, because a grain's login (Meteor.userId())
// and its boards subscription both arrive asynchronously after '/' first renders.
// Runs at most once per page load (grain session): a saved Home board wins; else
// exactly one board just opens (nothing saved — choosing a Home board is the separate
// explicit All Boards toggle); else (zero or many boards, nothing saved) we stay on
// the All Boards list. The decision itself is the pure, unit-tested
// decideSandstormAutoOpen(). Sandstorm-only (see the isSandstorm gate in the route).
let sandstormAutoOpenStarted = false;
function startSandstormAutoOpen() {
if (sandstormAutoOpenStarted) return;
sandstormAutoOpenStarted = true;
const boardsHandle = Meteor.subscribe('boards');
Tracker.autorun(computation => {
const userReady = !!Meteor.userId();
const user = userReady ? ReactiveCache.getCurrentUser() : null;
const savedDefaultId = user && user.getDefaultBoardId && user.getDefaultBoardId();
const boardsReady = boardsHandle.ready();
let boardCount = 0;
let onlyBoardId;
if (userReady && boardsReady) {
const boards = ReactiveCache.getBoards({
archived: false,
type: 'board',
'members.userId': Meteor.userId(),
});
boardCount = boards.length;
if (boardCount === 1) onlyBoardId = boards[0]._id;
}
const decision = decideSandstormAutoOpen({
userReady,
savedDefaultId,
boardsReady,
boardCount,
onlyBoardId,
});
if (decision.action === 'wait') return; // inputs not ready — keep watching
if (decision.action === 'redirect') {
// Just open the board — nothing is persisted here.
const board = ReactiveCache.getBoard(decision.boardId);
FlowRouter.go('board', { id: decision.boardId, slug: (board && board.slug) || 'board' });
}
computation.stop(); // decided (redirected or staying) — done for this page load
});
}
FlowRouter.route('/', {
name: 'home',
triggersEnter: [ensureSignedInUnlessSandstorm],
action() {
// On Sandstorm, render All Boards immediately and let the reactive auto-open
// (above) redirect once the grain login + boards have loaded, if appropriate.
if (isSandstorm) {
startSandstormAutoOpen();
renderBoardList(this, 'starred');
return;
}
if (maybeRedirectToDefaultBoard()) return;
renderBoardList(this, 'starred');
},
});
+17 -7
View File
@@ -59,10 +59,15 @@ services:
chmod +x "$$BIN"
fi
mkdir -p /data/files/db
# #6480/#6481: --repl-set-name makes FerretDB v1 auto-create the capped
# local.oplog.rs and advertise the replica-set hello handshake, so WeKan's
# Meteor can TAIL the OpLog instead of poll-and-diff (fixes high CPU on
# busy boards). Pair with MONGO_OPLOG_URL in the wekan service below.
exec "$$BIN" \
--handler=sqlite \
--sqlite-url=file:/data/files/db/ \
--listen-addr=0.0.0.0:27017 \
--repl-set-name=rs0 \
--telemetry=disable \
--log-level=error
volumes:
@@ -125,9 +130,13 @@ services:
# https://github.com/meteor/meteor/blob/release-3.5/v3-docs/docs/performance/change-streams-observer-driver.md#choosing-the-reactivity-driver-order
# Keep oplog/polling fallback available for deployments that cannot use Change Streams:
# https://github.com/wekan/wekan/issues/6307#issuecomment-4299349231
# FerretDB has no MongoDB change streams / replica-set oplog, so use polling:
- DEFAULT_METEOR_REACTIVITY_ORDER=polling
- METEOR_REACTIVITY_ORDER=polling
# wekan/wekan#6480/#6481: FerretDB v1 now ships an OpLog (the ferretdb service
# above runs with --repl-set-name=rs0), so prefer OpLog tailing and fall back
# to polling — this is the main fix for FerretDB's high CPU on busy boards.
# FerretDB has no MongoDB change streams. To force polling only, set both to
# `polling` and comment out MONGO_OPLOG_URL below.
- DEFAULT_METEOR_REACTIVITY_ORDER=oplog,polling
- METEOR_REACTIVITY_ORDER=oplog,polling
# uws does not work at s390x. sockjs works.
#- DDP_TRANSPORT=uws
- DDP_TRANSPORT=sockjs
@@ -194,10 +203,11 @@ services:
# - Latency from 50ms to 2000ms
# Must configure MongoDB replica set first
# See: https://blog.meteor.com/tuning-meteor-mongo-livedata-for-scalability-13fe9deb8908
# FerretDB emulates OpLog via --replication-set-name=rs0.
# HIGHLY RECOMMENDED — enables real-time DDP updates (sub-100ms latency).
#- MONGO_OPLOG_URL=mongodb://wekan-ferretdb:27017/local?replicaSet=rs0
# MONGO_OPLOG_URL intentionally unset: FerretDB v1 has no replica-set oplog (WeKan uses polling).
# FerretDB v1 emulates the OpLog via --repl-set-name=rs0 (set on the ferretdb
# service above). This enables real-time DDP updates and stops the poll-and-diff
# CPU burn (wekan/wekan#6480/#6481). To force polling only, comment this out and
# set METEOR_REACTIVITY_ORDER=polling above.
- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0
#---------------------------------------------------------------
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
# https://github.com/edemaine/kadira-compose
+6 -3
View File
@@ -1,8 +1,11 @@
[Sandstorm](../Platforms/FOSS/Sandstorm/Sandstorm.md) - [Sandstorm Backup](../Platforms/FOSS/Sandstorm/Export-from-Wekan-Sandstorm-grain-.zip-file.md)
# Backup Sandstorm
# Related
1. Please first backup your Sandstorm https://docs.sandstorm.io/en/latest/administering/backups/
2. Please first download all of your grains to .zip files from top menu row down arrow button, just in case.
[Transferring attachments from MongoDB to filesystem, and text from MongoDB to SQLite](https://github.com/wekan/minio-metadata)
# Upgrade Sandstorm WeKan
[Upgrade Sandstorm WeKan ](../Platforms/FOSS/Sandstorm/Sandstorm.md)
# Backup Docker
+1 -1
View File
@@ -91,7 +91,7 @@ Related, for creating apps to other appstores: [Browser compatibility matrix](Br
2. PWABuilder has about 100+ downloadable icons etc webmanifest requirements like is at [public](../../public)
3. Clone WeKan repo, add favicons etc from step 2 to wekan/public/ , and build WeKan bundle from source like at [Emoji](../Features/Emoji.md) . Note: Currently WeKan does not have feature for custom favicons, it would require a lot of work for 100+ favicons etc customizations.
3. Clone WeKan repo, add favicons etc from step 2 to wekan/public/ , and build WeKan bundle from source like at [Emoji](../Features/Editor/Emoji.md) . Note: Currently WeKan does not have feature for custom favicons, it would require a lot of work for 100+ favicons etc customizations.
4. Run bundle at server like [Offline](../Platforms/Propietary/Windows/Offline.md) or [Raspberry Pi](../Platforms/FOSS/RaspberryPi/Raspberry-Pi.md)
+1 -1
View File
@@ -20,4 +20,4 @@ https://user-images.githubusercontent.com/70445835/184324324-b4bd466e-6873-4eef-
* [Calendar issues](https://github.com/wekan/wekan/issues?q=is%3Aissue+is%3Aopen+calendar+label%3AFeature%3ACalendar-view)
* [Time Tracking](Time-Tracking.md)
* [Gantt Chart](../Features/Gantt.md)
* [Gantt Chart](../Features/Reports/Gantt.md)
+152
View File
@@ -0,0 +1,152 @@
# Dev: Snap `core` base vs multi-platform builds
Developer notes on why the `ppc64el` and `s390x` snap legs of `release-all.yml`
fail, what the constraint actually is (verified from the action's source, not the
error string), and the only supported way to build these architectures for a
`core24` snap.
## TL;DR
- WeKan's `snapcraft.yaml` uses **`base: core24`**.
- The `snap-qemu` job builds `ppc64el` / `s390x` with
**`diddlesnaps/snapcraft-multiarch-action@v1`**, whose newest supported base is
**`core22`**. It rejects `core24` before any build starts.
- That action is unmaintained, and there is **no maintained QEMU multi-arch snap
action that supports `core24`**.
- The only `core24`-capable path for architectures that have **no native GitHub
runner** (`ppc64el`, `s390x`, `riscv64`) is **Launchpad `snapcraft remote-build`**
— already used for `riscv64` in the `snap-launchpad` job.
## The failure
Logs (`release-all.yml` → `snap-qemu` matrix legs `ppc64el` and `s390x`):
```
snapcraft.yaml is at version '10.01'. # version gate passed
Building Snapcraft project in "."...
##[error]Your build requires a base that this tool does not support (core24).
'base' or 'build-base' in your 'snapcraft.yaml' must be one of 'core', 'core18' or 'core20'.
```
The version check passes and QEMU sets up fine; the build dies the instant
snapcraft reads `base: core24`. The other two log lines are harmless noise:
- `Failed to save: Unable to reserve cache with key docker.io--tonistiigi--binfmt-...`
— a post-step cache warning from the QEMU binfmt image, not the cause.
- `Node.js 20 is deprecated ...` — the old action running on a newer Node runtime.
## Why it fails — verified from the action's source
This is **not** just the error message; it is a hardcoded allow-list. The action
is pinned at SHA `cfd7a246fad6bea65bb92f69a1c8d07898c231e5`. Its compiled
`dist/index.js` (from `src/lib/build.js` + `src/lib/channel-matrix.js`) hardcodes a
maximum base of `core22` in **three** independent places:
1. **Allow-list that throws** (`build.js`):
```js
const base = await detectBase(this.projectRoot);
if (!['core', 'core18', 'core20', 'core22'].includes(base)) {
throw new Error(`Your build requires a base that this tool does not support (${base}). ...`);
}
```
`core24` is not in the list → immediate throw. (The error *message* is itself
stale — it says "must be one of core/core18/core20" while the code actually also
allows `core22`.)
2. **Container image tag** (`build.js`):
```js
let containerImage = `diddledani/snapcraft:${base}`; // needs docker.io/diddledani/snapcraft:core24
```
It runs snapcraft inside `docker.io/diddledani/snapcraft:<base>` under QEMU. The
maintainer never published a `:core24` tag (tags stop at `core22`).
3. **Channel matrix** (`channel-matrix.js`):
```js
switch (base) {
case 'core22':
case 'core20': return channel;
case 'core18': ...
case 'core': ...
}
throw new Error(`Snapcraft Channel '${channel}' is unsupported ... '${base}' Base Snap.`);
```
`getChannel('core24', 'stable')` falls through every case and throws too.
**Maximum base supported by `diddlesnaps/snapcraft-multiarch-action@v1`: `core22`.**
The action was last updated for `core22` and abandoned; `core24` (and `core26`)
are not supported and will not be by `@v1`.
## Source repositories
The failing action and its build image:
| What | Repo |
|---|---|
| `snapcraft-multiarch-action` (the failing job) | https://github.com/diddlesnaps/snapcraft-multiarch-action — pinned [`cfd7a246`](https://github.com/diddlesnaps/snapcraft-multiarch-action/tree/cfd7a246fad6bea65bb92f69a1c8d07898c231e5); evidence in [`dist/index.js`](https://github.com/diddlesnaps/snapcraft-multiarch-action/blob/cfd7a246fad6bea65bb92f69a1c8d07898c231e5/dist/index.js) |
| `diddledani/snapcraft` Docker image (what it runs snapcraft inside) | https://github.com/diddledani/snapcraft — image: https://hub.docker.com/r/diddledani/snapcraft (no `core24` tag) |
snapcraft and the other actions used by the snap jobs:
| What | Repo |
|---|---|
| snapcraft (the tool) | https://github.com/canonical/snapcraft |
| `snapcore/action-build` (used by `snap-native`) | https://github.com/snapcore/action-build |
| `snapcore/action-publish` (used by `snap-native` / `snap-qemu`) | https://github.com/snapcore/action-publish |
| `docker/setup-qemu-action` (QEMU setup step) | https://github.com/docker/setup-qemu-action |
| `tonistiigi/binfmt` (the QEMU binfmt image in the cache warning) | https://github.com/tonistiigi/binfmt |
The base snaps (`core*`):
| Base | Repo |
|---|---|
| `core` | https://github.com/canonical/core |
| `core18` | https://github.com/canonical/core18 |
| `core20` | https://github.com/canonical/core20 |
| `core22` | https://github.com/snapcore/core22 |
| `core24` (WeKan's base) | https://github.com/canonical/core-base (mirror: https://github.com/snapcore/core-base) — the modern base snaps were consolidated into one `core-base` repo, so there is no standalone `canonical/core24`. |
## How the snap jobs are split in `release-all.yml`
| Job | Arches | Mechanism | `core24`? |
|---|---|---|---|
| `snap-native` | `amd64`, `arm64` | `snapcore/action-build` on a **native** runner | Yes |
| `snap-qemu` | `ppc64el`, `s390x` | `diddlesnaps/snapcraft-multiarch-action` under QEMU | **No — caps at core22** |
| `snap-launchpad` | `riscv64` | `snapcraft remote-build` on Launchpad | Yes |
`amd64` / `arm64` have native runners, so `action-build` works and is fastest.
`ppc64el` / `s390x` / `riscv64` have **no native GitHub runner**, and snapcraft
cannot cross-build a snap — so they need either QEMU emulation (which can't do
`core24`) or Launchpad.
## The fix
Because no QEMU multi-arch action supports `core24`, `ppc64el` and `s390x` cannot
stay in `snap-qemu`. Move them to Launchpad `remote-build` alongside `riscv64`:
- Add `ppc64el` and `s390x` to the `snap-launchpad` matrix.
- Delete the dead `snap-qemu` job.
The current `snap-launchpad` job is more robust than the older Launchpad legs that
were originally replaced by `snap-qemu`: it **requires the `.snap` file to exist,
retries the remote build 3×, and only uploads when the file is present**, which
guards the old "is not a valid file" (`snapcraft upload`, exit 64) failure.
### Historical note
`ppc64el` / `s390x` were once moved *off* Launchpad *onto* QEMU because the old
Launchpad legs ended in Launchpad state "Stopped" with no snap, then failed at
`snapcraft upload`. That escape route is now a dead end for `core24`, and the
hardened `snap-launchpad` job addresses the upload failure that prompted the move.
## If you ever go back to a QEMU-style multi-arch build
You would need a maintained action/image that ships a `core24`-capable snapcraft
(snapcraft 8.x) and a matching `core24` build container for the target arch. As of
this writing none exists as a drop-in; Canonical's supported answer for non-native
architectures on `core24` is Launchpad `remote-build`.
+302
View File
@@ -0,0 +1,302 @@
# Dev: WeKan for Nextcloud — building, publishing to the App Store, and integrations
What must be added to the WeKan GitHub repos so that WeKan's GitHub Actions can build a
**Nextcloud-deployable** WeKan (using WeKan's existing Docker image), how to **publish it
to the Nextcloud App Store**, and what **integrations** are possible (local AI, Deck,
SSO, email, …).
WeKan already publishes public Docker images from release-all.yml's `docker` job —
`wekanteam/wekan`, `quay.io/wekan/wekan`, `ghcr.io/wekan/wekan` — and already ships OIDC
support (`packages/wekan-oidc/`), both of which the Nextcloud paths below build on.
## Remaining steps
> **Status:** the `nextcloud` job **is already wired into `release-all.yml`** — guarded, it
> skips with a `::notice::` until the items below are done, so it never breaks a release.
1. **Create the ExApp repo `wekan/nextcloud`**: `appinfo/info.xml` with the
`<external-app>` block, the Node **AppAPI-handshake wrapper** (`/heartbeat`, `/enabled`,
`/init`, `AUTHORIZATION-APP-API` → WeKan user mapping), and a `Dockerfile` (WeKan +
embedded FerretDB SQLite). See §A1–A3.
2. **Get the app signing certificate**: generate a key + CSR, PR the CSR to
`nextcloud/app-certificate-requests`, receive `wekan.crt`; then **register** the app at
`apps.nextcloud.com/developer/apps/new`. See §B2–B3.
3. **Add secrets** `NEXTCLOUD_APP_PRIVATE_KEY`, `NEXTCLOUD_APP_PUBLIC_CRT`,
`NEXTCLOUD_APPSTORE_TOKEN`; **extend `WEKAN_REPO_TOKEN`** to `wekan/nextcloud`.
4. **Require HaRP** deploy daemon + Nextcloud 30/31+ (WebSocket/DDP) in the app docs; the
old proxy/Docker-Socket-Proxy can't carry WeKan's realtime.
5. Test-deploy the ExApp on a Nextcloud + HaRP instance: OIDC login, DDP realtime,
attachments, and an AI `taskprocessing` call.
**How to add a secret:** GitHub → the `wekan/wekan` repo → **Settings → Secrets and
variables → Actions → New repository secret**; or `gh secret set NAME --repo wekan/wekan`
(paste the value) / `gh secret set NAME --repo wekan/wekan < file`. For the private key:
`gh secret set NEXTCLOUD_APP_PRIVATE_KEY --repo wekan/wekan < ~/.nextcloud/certificates/wekan.key`.
---
## 0. Two ways to ship on Nextcloud — and which one fits WeKan
| Track | What it is | Runtime | Fits WeKan? |
|---|---|---|---|
| **Classic App Store app** | PHP app running **inside** the Nextcloud process | Nextcloud PHP | ❌ WeKan is Meteor/Node — can't run in PHP |
| **External App (ExApp) via AppAPI** | App backend runs as an **external Docker container**, managed by Nextcloud's **AppAPI** and a **Deploy Daemon** | any language / Docker | ✅ **This is WeKan's path** — it already has a Docker image |
**Key reality check:** AppAPI is **not a generic reverse proxy**. WeKan's current image
**will not deploy as-is** — the container must implement the **AppAPI handshake**. So
"use the WeKan Docker image at Nextcloud" means: build a thin **ExApp wrapper** around it,
add an `appinfo/info.xml` with an `<external-app>` block, and publish that to the App
Store (the image lives in a registry the app references).
---
## A. Building WeKan for Nextcloud (what to add to the repos)
### A1. A new repo: `wekan/nextcloud` (the ExApp)
An ExApp is a Nextcloud app folder + a container:
```
wekan-exapp/
├── appinfo/
│ └── info.xml # metadata + <external-app> (docker image + routes)
├── ex_app/
│ ├── lib/ # the AppAPI-handshake wrapper (Node) — see A2
│ └── src/ # optional Vue frontend (adds a WeKan TopMenu entry)
└── Dockerfile # builds the ExApp image (wrapper + WeKan + DB)
```
`appinfo/info.xml` — the distinguishing `<external-app>` block (scopes were removed in
AppAPI 3.2.0; access is per-route via `access_level`):
```xml
<external-app>
<docker-install>
<registry>ghcr.io</registry>
<image>wekan/wekan-exapp</image>
<image-tag>v10.00</image-tag>
</docker-install>
<routes>
<route>
<url>.*</url>
<verb>GET,POST,PUT,DELETE</verb>
<access_level>USER</access_level> <!-- PUBLIC | USER | ADMIN -->
<headers_to_exclude>[]</headers_to_exclude>
<bruteforce_protection>[401]</bruteforce_protection>
</route>
</routes>
</external-app>
```
### A2. The AppAPI-handshake wrapper (the real work — there is no Node ExApp SDK)
On deploy, AppAPI injects env vars (`APP_SECRET`, `APP_ID`, `APP_PORT`, `APP_HOST`,
`NEXTCLOUD_URL`, `APP_PERSISTENT_STORAGE`, …) and drives a lifecycle the container **must**
implement. Add a small Node/Express service (a sidecar, or routes fronting WeKan) that:
1. Serves **`GET /heartbeat`** → `{"status":"ok"}` (unauthenticated; polled up to ~90 s).
2. Handles **`PUT /enabled?enabled=1|0`** → registers/removes the WeKan menu entry,
returns `{"error":""}`.
3. Optionally **`POST /init`** → returns `{}` immediately, then reports progress `1..100`
via `PUT /ocs/v1.php/apps/app_api/ex-app/status`.
4. Binds WeKan on `APP_PORT`/`APP_HOST`, stores data under `APP_PERSISTENT_STORAGE`.
5. Trusts Nextcloud-proxied requests via the shared-secret headers
(`AUTHORIZATION-APP-API` = `base64(userid:secret)`, `EX-APP-ID`, `EX-APP-VERSION`,
`AA-VERSION`) and **maps the Nextcloud `userid` to a WeKan user** (JIT-provision).
6. To call back into Nextcloud, sends the same `AUTHORIZATION-APP-API` header.
Mirror the contract from the Python skeleton (`nextcloud/app-skeleton-python`) and the
Authentication/Deployment docs (§B). `nc_py_api` supplies this for Python; **Node has no
official SDK**, so it's a small custom service.
### A3. Two hard constraints for WeKan specifically
- **WebSockets/DDP:** WeKan's realtime uses DDP over WebSockets. The **old** AppAPI proxy
did **not** support WebSockets — you must use the **HaRP** deploy daemon (Nextcloud
30/31+), which routes directly to the ExApp and supports WebSockets. (The classic
**Docker Socket Proxy** daemon is deprecated, slated for removal in Nextcloud 35.)
- **Database:** AppAPI deploys a **single image**. WeKan's DB (FerretDB v1 SQLite, or
MongoDB) must be **bundled inside the ExApp image** (embedded `WEKAN_DB=ferretdb`, as
the snap does) or provided as an external service the wrapper points at — it can't be a
second compose service the way UCS does it. Embedding **FerretDB v1 SQLite** (from
[wekan/FerretDB](https://github.com/wekan/FerretDB)) with data under
`APP_PERSISTENT_STORAGE` is the clean fit.
### A4. `release-all.yml` — a new `nextcloud` job
After the `docker` job publishes the WeKan image, add a job that:
1. Checks out `wekan/nextcloud` (uses `WEKAN_REPO_TOKEN`).
2. Builds & pushes the **ExApp image** (`ghcr.io/wekan/wekan-exapp:v<version>`) — wrapper
+ WeKan bundle + embedded FerretDB.
3. Regenerates `appinfo/info.xml` with the new `<image-tag>` and `<version>`.
4. **Signs and uploads** the app release to the App Store (§B4).
### A5. Secrets/keys to add (`wekan/wekan`)
| Secret | New/existing | What it is |
|---|---|---|
| `NEXTCLOUD_APP_PRIVATE_KEY` | **new** | The app signing **private key** (`APP_ID.key`) |
| `NEXTCLOUD_APP_PUBLIC_CRT` | **new** | The Nextcloud-issued **certificate** (`APP_ID.crt`) |
| `NEXTCLOUD_APPSTORE_TOKEN` | **new** | App Store developer API token (upload releases) |
| `WEKAN_REPO_TOKEN` | **extend** | Add `wekan/nextcloud` to its repo scope |
| `GHCR_AUTH`/`DOCKERHUB_AUTH` | existing | Already push WeKan images; reuse for the ExApp image |
---
## B. Publishing an app to the Nextcloud App Store (files, build, publish, docs)
Same store (apps.nextcloud.com) for both tracks; an ExApp just adds the `<external-app>`
block and references a container image instead of shipping PHP.
### B1. Files to create
- **`appinfo/info.xml`** — mandatory: `<id>` (= folder name), `<name lang="en">`,
`<description lang="en">` (Markdown ok), `<version>` (semver), `<licence>` (SPDX,
British spelling), `<author>`, `<bugs>` (tracker URL),
`<dependencies><nextcloud min-version="30" max-version="35"/></dependencies>`.
Optional: `<summary>`, `<category>` (e.g. `organization`, `tools`, `integration`,
`workflow`, `ai`), `<screenshot>` (HTTPS), `<website>`, `<repository>`, `<donation>`.
For an ExApp, add the `<external-app>` block (§A1).
- **ExApp only:** `ex_app/` (backend + optional Vue frontend) and a **`Dockerfile`**.
### B2. Get a signing certificate (one-time)
```sh
mkdir -p ~/.nextcloud/certificates && cd ~/.nextcloud/certificates
openssl req -nodes -newkey rsa:4096 -keyout APP_ID.key -out APP_ID.csr -subj "/CN=APP_ID"
```
Submit `APP_ID.csr` as a PR to **`nextcloud/app-certificate-requests`**; when merged you
receive `APP_ID.crt`.
### B3. Register the app
At `https://apps.nextcloud.com/developer/apps/new`, providing the public cert and a
signature over the app id:
```sh
echo -n "APP_ID" | openssl dgst -sha512 -sign ~/.nextcloud/certificates/APP_ID.key | openssl base64
```
### B4. Build, sign, and upload a release
1. Package the app folder as `.tar.gz` (must contain `appinfo/info.xml`). **`krankerl`**
automates packaging/signing.
2. Sign the tarball:
```sh
openssl dgst -sha512 -sign ~/.nextcloud/certificates/APP_ID.key app.tar.gz | openssl base64
```
3. Upload — the store **fetches a download URL** (not a multipart upload):
```
POST https://apps.nextcloud.com/api/v1/apps/releases
{ "download": "https://…/app.tar.gz", "signature": "<base64>", "nightly": false }
```
A pre-release id in the version (e.g. `-alpha.1`) → **beta** channel; `nightly:true` →
**nightly**.
4. **CI:** the documented GitHub Actions path uses **`R0Wi/nextcloud-appstore-push-action`**
with secrets `APP_PRIVATE_KEY`, `APP_PUBLIC_CRT`, `APPSTORE_TOKEN` (community
alternative: `skjnldsv/publish-nextcloud-app`).
### B5. Documentation (verified)
| Topic | URL |
|---|---|
| AppAPI & External Apps (admin) | https://docs.nextcloud.com/server/latest/admin_manual/exapps_management/AppAPIAndExternalApps.html |
| Managing Deploy Daemons | https://docs.nextcloud.com/server/latest/admin_manual/exapps_management/ManagingDeployDaemons.html |
| Deploy Configurations (HaRP/DSP) | https://docs.nextcloud.com/server/latest/admin_manual/exapps_management/DeployConfigurations.html |
| ExApp Overview (dev) | https://docs.nextcloud.com/server/latest/developer_manual/exapp_development/development_overview/ExAppOverview.html |
| Deployment (`/heartbeat`, `/init`, env vars) | https://docs.nextcloud.com/server/latest/developer_manual/exapp_development/tech_details/Deployment.html |
| Authentication (`AUTHORIZATION-APP-API`) | https://docs.nextcloud.com/server/latest/developer_manual/exapp_development/tech_details/Authentication.html |
| Routes (`info.xml <routes>`) | https://docs.nextcloud.com/server/latest/developer_manual/exapp_development/tech_details/api/routes.html |
| AppAPI source | https://github.com/nextcloud/app_api |
| HaRP daemon | https://github.com/nextcloud/HaRP |
| Python skeleton ExApp | https://github.com/nextcloud/app-skeleton-python |
| nc_py_api (Python framework) | https://github.com/cloud-py-api/nc_py_api |
| App Store Developer Guide | https://nextcloudappstore.readthedocs.io/en/latest/developer.html |
| Code signing | https://docs.nextcloud.com/server/latest/developer_manual/app_publishing_maintenance/code_signing.html |
| Publishing rules | https://docs.nextcloud.com/server/latest/developer_manual/app_publishing_maintenance/publishing.html |
| Release automation (GitHub Actions) | https://docs.nextcloud.com/server/latest/developer_manual/app_publishing_maintenance/release_automation.html |
| krankerl CLI | https://github.com/ChristophWurst/krankerl |
| app-certificate-requests | https://github.com/nextcloud/app-certificate-requests |
| Release upload API | https://apps.nextcloud.com/api/v1/apps/releases |
---
## C. Integrations possible on the Nextcloud platform
Two integration modes, and the distinction matters:
- **WeKan as an outbound OCS API client** (works today, WeKan stays external, no ExApp
needed): AI, Deck, Provisioning, WebDAV/CalDAV.
- **In-Nextcloud UI surfaces** (need a companion app / ExApp running inside Nextcloud):
Dashboard, Unified Search, Notifications, Talk bots, Flow.
### C1. Using Nextcloud local AI in WeKan — **achievable today, no ExApp required**
Since Nextcloud 30, the **Task Processing API** is the single unified AI entry point
(it supersedes the old Text Processing / Text-To-Image / Speech-To-Text APIs). It is
exposed over **OCS**, so WeKan's server can call it as an authenticated client:
```
POST /ocs/v2.php/taskprocessing/schedule
Headers: OCS-APIRequest: true ; Basic auth (user + app password)
Body: {"type":"core:text2text:summary","input":{...},"appId":"wekan"}
```
then poll for the result. The model runs **on the Nextcloud side** via a backend:
- **`llm2`** — fully local LLMs via llama.cpp (any GGUF model); rates **Green** on
Nextcloud's Ethical AI rating.
- **`integration_openai`** — any OpenAI-compatible engine (**LocalAI**, Ollama, vLLM…).
- **Context Chat** — RAG over the user's Nextcloud files.
Useful task types for cards: `core:text2text` (draft a description), `…:summary`
(summarize comments), `…:headline` (suggest a title), `…:translate`.
> WeKan does **not** need to be an ExApp to *use* the AI — it only needs an app-password
> and OCS. (Registering as an ExApp AI *provider* is a different, much larger thing and
> is not required here.)
### C2. Integration matrix
| Feature | Nextcloud mechanism | How WeKan uses it | Doc |
|---|---|---|---|
| **Local AI** | Task Processing API over OCS (`/ocs/v2.php/taskprocessing/schedule`), backend llm2/LocalAI | WeKan server summarizes/generates/translates card text | https://docs.nextcloud.com/server/stable/developer_manual/client_apis/OCS/ocs-taskprocessing-api.html |
| **Kanban interop (Deck)** | Deck REST/OCS API `/ocs/v2.php/apps/deck/api/v1.0/` | Import/sync Deck boards↔WeKan (Deck stacks→WeKan lists, cards→cards) | https://deck.readthedocs.io/en/latest/API/ |
| **SSO / login** | `user_oidc` (NC as OIDC client) or the `oidc` provider app (NC as IdP); LDAP; SAML | **WeKan already has OIDC** (`packages/wekan-oidc/`) — point it at Nextcloud's `oidc` provider for zero-code SSO | https://github.com/nextcloud/user_oidc |
| **User/group provisioning** | OCS Provisioning API `/ocs/v1.php/cloud/users` | Mirror Nextcloud users/groups into WeKan (JIT alongside OIDC) | https://docs.nextcloud.com/server/stable/admin_manual/configuration_user/user_provisioning_api.html |
| **Embed WeKan in NC UI** | **External Sites** app (iframe menu entry) | Add a "WeKan" top-menu item loading WeKan in an iframe (relax WeKan's `X-Frame-Options`/CSP for the NC origin) | https://docs.nextcloud.com/server/stable/admin_manual/configuration_server/external_sites.html |
| **Files** | WebDAV `/remote.php/dav/files/USER/` | Attach/pull Nextcloud files onto cards | https://docs.nextcloud.com/server/stable/developer_manual/client_apis/WebDAV/index.html |
| **Calendar / due dates** | CalDAV `/remote.php/dav/` | Publish card due dates as a CalDAV calendar | https://docs.nextcloud.com/server/stable/developer_manual/client_apis/WebDAV/index.html |
| **Email** | Nextcloud **Mail** app (IMAP/SMTP client) + server SMTP | WeKan sends its own SMTP mail; NC Mail is a separate mailbox, not a relay WeKan drives | https://apps.nextcloud.com/apps/mail |
| **Dashboard / Search / Notifications / Talk bots / Flow** | Dashboard widget, Unified Search, Notifications, Talk bot, Flow APIs | Require an in-Nextcloud **companion app/ExApp** — not reachable from external WeKan by REST alone | https://docs.nextcloud.com/server/stable/developer_manual/ |
**Honest caveats:** SSO (OIDC) is the strongest, zero-code win. AI, Deck, Provisioning,
WebDAV/CalDAV work from external WeKan via REST. A Deck↔WeKan bridge is feasible but must
be built (none is maintained). Dashboard/Search/Notifications/Talk/Flow assume code
running *inside* Nextcloud.
---
## D. Comparison: kanban / project software for Nextcloud
| App | Type | Kanban features | API | AI | License | URL |
|---|---|---|---|---|---|---|
| **Deck** | Built-in native (PHP) | Full: boards, stacks, cards, labels, due dates, assignees, checklists, attachments, comments, sharing | Deck REST/OCS API; CalDAV for dates | Yes (in-platform Assistant/Task Processing) | AGPL-3.0 | https://apps.nextcloud.com/apps/deck |
| **Nextcloud Tables** | Built-in native | Kanban-*ish* views over a no-code database; not true drag-drop stacks | Tables REST API v1/v2 | Via platform Assistant | AGPL-3.0 | https://apps.nextcloud.com/apps/tables |
| **WeKan** | External (Meteor/Node) / ExApp | Full Trello-like: swimlanes, WIP limits, custom fields, rules, subtasks, templates — richer boards than Deck | Own REST API; consumes NC OCS APIs as a client | Can call NC Task Processing API as an OCS client (§C1) | MIT | https://github.com/wekan/wekan |
| Kanboard / Planka / Focalboard | External | Full kanban, own stacks | Own APIs | varies | varies (OSS) | integrate via SSO/iframe only |
**Bottom line:** **Deck is the only native, first-party kanban** for Nextcloud. Tables
offers board-like views but is a database app. Everything genuinely kanban (WeKan,
Kanboard, Planka, Focalboard) is a **separate app** that *integrates with* Nextcloud
(SSO/OIDC, iframe embed, OCS calls) rather than running as a native NC app.
---
## E. Checklist
- [ ] New repo `wekan/nextcloud` (ExApp): `appinfo/info.xml` + `<external-app>`, Node
AppAPI-handshake wrapper, Dockerfile (WeKan + embedded FerretDB SQLite)
- [ ] Obtain the app **signing certificate** (PR to `nextcloud/app-certificate-requests`)
and **register** the app at apps.nextcloud.com
- [ ] Add secrets `NEXTCLOUD_APP_PRIVATE_KEY`, `NEXTCLOUD_APP_PUBLIC_CRT`,
`NEXTCLOUD_APPSTORE_TOKEN`; extend `WEKAN_REPO_TOKEN` to `wekan/nextcloud`
- [ ] `release-all.yml`: add the `nextcloud` job (build ExApp image → regenerate
info.xml → sign → `POST /api/v1/apps/releases`)
- [ ] Require **HaRP** deploy daemon (WebSocket/DDP) and Nextcloud 30/31+ in the docs
- [ ] (Integrations) Document/point WeKan OIDC at Nextcloud `oidc` provider (SSO);
optionally add Task Processing AI calls and a Deck sync bridge
- [ ] Test: deploy the ExApp on a Nextcloud + HaRP instance; verify login (OIDC),
realtime (DDP over WebSocket), attachments, and an AI summarize call
## See also
- [UCS.md](UCS.md) — the same "Docker WeKan on a platform + migration" pattern for Univention.
- [Snap-Ondra-Gantt.md](Snap-Ondra-Gantt.md) — the "push to a downstream repo + publish from release-all.yml" pattern.
- [OS/Linux.md](OS/Linux.md) — Docker/Snap/Flatpak in the broader Linux update picture.
+24
View File
@@ -0,0 +1,24 @@
# Dev: IBM AIX install & update platforms (manual vs automatic)
AIX OS updates ship as **filesets** installed with `installp` (usually via the
`smit`/`smitty` menus). **NIM (Network Installation Management)** pushes installs/
updates from a master to clients. Open-source software comes as RPMs from the **AIX
Toolbox**, managed with **`dnf`** (modern replacement for `yum`). The Internet-update
tool is **SUMA**, which downloads fixes on a schedule from IBM.
> **SUMA (Service Update Management Assistant)** is AIX's automatic Internet
> *downloader* — cron-driven — but *applying* the fixes stays a separate admin/NIM step.
| Mechanism | Manual | Automatic | Auto/Internet mechanism | Maintainer | URL | License |
|---|:---:|:---:|---|---|---|---|
| **`installp` + `smit`/`smitty`** (filesets) | ✓ | — | None | IBM | https://www.ibm.com/docs/en/aix | Proprietary |
| **NIM** (Network Installation Management) | ✓ | partial | Push installs/updates from NIM master (LAN, not Internet auto-refresh) | IBM | https://www.ibm.com/docs/en/aix/7.2.0?topic=management-managing-nim | Proprietary |
| **`dnf`/`yum`** (AIX Toolbox RPMs) | ✓ | — | Pulls from Toolbox repos on demand; no auto-refresh daemon | IBM (pkgs) / OSS (dnf) | https://www.ibm.com/support/pages/aix-toolbox-open-source-software-overview | GPL/OSS |
| **SUMA** (Service Update Management Assistant) | ✓ | **✓ (download)** | Scheduled task downloads TL/SP/security fixes over the Internet from `esupport.ibm.com`; cron-driven, e-mail notify | IBM | https://www.ibm.com/docs/en/aix/7.3.0?topic=suma-service-update-management-assistant | Proprietary |
## WeKan / Electron applicability
**No** for the desktop — no Electron/Chromium on AIX. Node.js has an official **AIX**
port, so the WeKan **server** side is feasible; the Electron client is not.
## See also
[Solaris.md](Solaris.md) · [HP-UX.md](HP-UX.md) · [OpenVMS.md](OpenVMS.md) · [zOS.md](zOS.md) · [Linux.md](Linux.md)
+54
View File
@@ -0,0 +1,54 @@
# Dev: Amiga-family install & update platforms (manual vs automatic, like Snap)
How software is installed and updated across the **Amiga** family — **AmigaOS 3.x**
(classic 68k), **AmigaOS 4.x** (PowerPC, Hyperion), **MorphOS** (PowerPC), and
**AROS** (open-source AmigaOS-compatible reimplementation) — split into **manual**
and **automatic** (background/self-updating, like Linux Snap auto-refresh).
> The Amiga family inherits a distribution culture from the classic 68k days:
> software ships as compressed archives (`.lha`, historically `.lzx`, sometimes
> `.zip`) downloaded from a central web archive, then unpacked and either dragged
> into place or installed via a bundled **Installer** script. This is fundamentally
> **manual** — the user is the update mechanism. Genuine auto-update tooling is rare
> and recent: **AmiUpdate** and **AMIStore's Updater** (AmigaOS 4) can check online
> and pull newer versions, and **Grunch** (MorphOS/AmigaOS) is the only tool in the
> family that behaves like a true auto-refreshing package manager. **AROS** and
> **classic AmigaOS 3.x** have no mainstream package manager and no auto-update.
---
## Comparison table
| Platform / OS | Manual | Automatic | Auto mechanism | Maintainer | URL | License |
|---|:---:|:---:|---|---|---|---|
| **Aminet** (cross-platform archive) | ✓ | — | None — browse/search, download `.lha`/`.zip`, unpack + Installer by hand | Aminet community | https://aminet.net/ | Per-package (mixed) |
| **OS4Depot** (AmigaOS 4) | ✓ | — | None — manual `.lha` download + install | OS4Depot volunteers | https://www.os4depot.net/ | Per-package (mixed) |
| **MorphOS-Storage** (MorphOS) | ✓ | — | None — manual `.lha` download + install | MorphOS community | https://www.morphos-storage.net/ | Per-package (mixed) |
| **AROS Archives** (AROS) | ✓ | — | None — manual download + install; AROS has **no mainstream package manager** | AROS community | https://archives.arosworld.org/ | Per-package (mixed) |
| **AMIStore** (AmigaOS 4 app store) | ✓ | Partial | Store client + separate **Updater**: compares local vs remote versions, can auto-check on start and on a schedule (~every 4h), then user confirms download/install | A-EON Technology | http://www.amistore.net/ | Proprietary client |
| **AmiUpdate** (AmigaOS 4 online updater) | ✓ | Partial | Checks amiupdate.net for newer versions of OS components + registered apps, lists them, user selects; can auto-check but is user-triggered, not a silent daemon | Simon Archer (bundled with AmigaOS 4.1 from Update 6) | https://wiki.amigaos.net/wiki/AmigaOS_Updates | Bundled (proprietary) |
| **Grunch** (MorphOS + AmigaOS) | ✓ | **✓** | True package manager: search/download/install/**auto-update**/uninstall with dependency handling; update prompts can be disabled so installed software updates automatically (closest analogue to Snap auto-refresh) | "geit" (Guido Mersmann) | https://www.geit.de/eng_grunch.html | Freeware |
| **MorphOS system updates** | ✓ | — | None automatic — full/point OS releases distributed as downloadable images/installers; user installs manually | MorphOS Development Team | https://www.morphos-storage.net/ | Proprietary OS |
| **Classic AmigaOS 3.x drag-install** (68k) | ✓ | — | None — drag drawer, or run the Amiga **Installer** script; `.lha`/`.lzx` archives. No package manager, no auto-update | software by individual authors | https://aminet.net/ | Per-package (mixed) |
*AmiUpdate* and *AMIStore's Updater* are "Partial" because they can *check*
automatically/on schedule but still require the user to confirm the install — not
silent auto-appliers like Snap's `snapd`. **Grunch** is the single family tool that
can genuinely auto-update installed apps unattended. There is **no** legitimate
mainstream package manager for AROS or classic AmigaOS 3.x.
---
## WeKan / Electron applicability
**A WeKan Electron desktop app cannot run on any Amiga-family OS.** Electron bundles
Node.js + Chromium, and there is no port of Node or Chromium to 68k (AmigaOS 3.x), to
PowerPC AmigaOS 4 / MorphOS, or to AROS — these platforms lack the modern V8/Blink
toolchain, threading, and graphics stack Chromium requires. The only realistic way an
Amiga user reaches WeKan is via a **web browser pointed at a hosted WeKan server**
(and even then, heavy modern JavaScript strains these browsers) — never as a native
install, so none of the update mechanisms above apply to WeKan itself.
## See also
- Linux: [Linux.md](Linux.md) · Haiku: [Haiku.md](Haiku.md) · RISC OS: [RISC-OS.md](RISC-OS.md) · macOS: [Mac.md](Mac.md)
+23
View File
@@ -0,0 +1,23 @@
# Dev: Android install & update platforms (manual vs automatic, like Snap)
Android is the most pluralistic mobile OS: the default path is **Google Play**
background auto-update, but privileged alternative stores (**F-Droid**, **Aurora**) can
now auto-update too, and enterprise fleets use **Managed Google Play** push-installs.
**Google Play System Updates** (Project Mainline) patch OS modules without a full OTA.
| Store/Mechanism | Manual | Automatic | Auto/Internet mechanism | Maintainer | URL | License |
|---|:---:|:---:|---|---|---|---|
| **Google Play Store** | ✓ | ✓ (default) | Background updates over Wi-Fi/any-network when idle+charging; global + per-app toggles | Google | https://support.google.com/googleplay/answer/113412 | Proprietary |
| **F-Droid + Privileged Extension** | ✓ | ✓ | Client polls repo index; Privileged Extension (or GrapheneOS/Calyx integration) installs updates silently (since 2025) | F-Droid | https://f-droid.org/packages/org.fdroid.fdroid.privileged/ | GPL/AGPL |
| **Aurora Store** | ✓ | ✓ | Anonymous/Google-account Play proxy; unattended updates via Android 12+ update-owner API | Aurora OSS | https://gitlab.com/AuroraOSS/AuroraStore | GPL-3.0 |
| **Sideloaded APK** | ✓ | — | User downloads `.apk`; no update channel | any source | https://developer.android.com/ | varies |
| **Play System Updates** (Mainline) | — | ✓ | APK/APEX modules delivered over Play infrastructure, no full OTA | Google/AOSP | https://source.android.com/docs/core/ota/modular-system | Apache-2.0 |
| **Managed Google Play / MDM** | — | ✓ | `autoInstallPolicy` / forced-install pushes and silently updates apps via EMM (Intune, Jamf…) | Google + EMM | https://support.google.com/work/android/answer/6137769 | Proprietary |
## WeKan applicability
WeKan is a **web/Meteor app** — native Electron desktop auto-update does not apply.
Ship WeKan as a **PWA** (installable via Chrome) or wrap it (**Bubblewrap/TWA**) for
Play/F-Droid/Aurora; the wrapper then rides normal store auto-update.
## See also
[iOS.md](iOS.md) · [ChromeOS.md](ChromeOS.md) · [Linux.md](Linux.md)
Loaded 100 of 523 files, more files were not shown because too many files have changed in this diff. Show more