Compare commits

...
609 Commits
Author SHA1 Message Date
github-actions[bot] 66fb87a3fa Bump versions for v10.35 2026-07-24 18:20:12 +00:00
Lauri Ojansivu 73bd4d7275 Prepare v10.35 release 2026-07-24 21:19:11 +03:00
Lauri Ojansivu 6f8a66d47a Changelog Upcoming: record the real board-copy cause (sync getDefaultSwimline on the server) and fix.
Thanks to xet7 !
2026-07-24 21:12:10 +03:00
Lauri Ojansivu f92b49d1b0 Board copy: use the async default-swimlane getter so a copy no longer throws on the server.
This is the actual cause of the failing "#5592/#5627 copying a board copies its
webhooks and rules" test (all three browsers) — surfaced now that the copy route
returns 500 + logs the stack instead of masking it as 200. The server log shows:

  Error: update is not available on the server. Please use updateAsync() instead.
    at ensureDefaultSwimlaneId (models/boards.js)
    at getDefaultSwimline       (models/boards.js)
    at Swimlanes.copy           (models/swimlanes.js:202)

swimlane.copy() (server, async) called the SYNC getDefaultSwimline() on the source
board to detect its default swimlane. When the board has no pickable swimlane that
getter self-heals via a synchronous Swimlanes.upsert(), which Meteor 3 rejects on
the server, aborting the whole board copy. It now calls the existing
getDefaultSwimlineAsync() (same pick + idempotent upsertAsync self-heal) and awaits
it, preserving the previous null-safety.

The same sync getDefaultSwimline() is still called from other server paths
(models/cards.js, server/models/lists.js, server/rulesHelper.js, swimlanes.js:365);
they only throw when a board has no usable swimlane and are not hit by the current
tests, so they are left for a separate, tested change.

Thanks to xet7 !
2026-07-24 21:11:32 +03:00
Lauri Ojansivu 4e18400faa Changelog Upcoming: note the rebuild-wekan -> build helper rename.
Thanks to xet7 !
2026-07-24 19:32:24 +03:00
Lauri Ojansivu c29f5c7a66 Rename the dev helper rebuild-wekan.sh/.bat -> build.sh/.bat (shorter name).
Renames rebuild-wekan.sh -> build.sh, rebuild-wekan.bat -> build.bat, and the
guard test tests/rebuildWekanDevServer.test.cjs -> tests/buildDevServer.test.cjs,
and updates every reference across the repo: the docs (Build-from-source, Windows,
Sandstorm, Raspberry Pi, virtual-appliance, Debugging, Developer-Documentation,
FerretDB, OpLog, Rclone, Emoji, Multiverse roadmap, Security remediation, Tests),
README.md, CONTRIBUTING.md, CLAUDE.md, package.json (test:unit:node), the release
scripts (release-all.sh, fix-changelog-hashes.sh, node-update.sh), the disabled
playwright workflow, playwright.config.js, the REST spec comment, tools/forge-mirror.js,
and the CHANGELOG. The menu and every option are unchanged; only the filename and
its references. The website has no references.

Thanks to xet7 !
2026-07-24 19:32:24 +03:00
Lauri Ojansivu 742f5cada1 Changelog Upcoming: note the WebKit DMABUF-renderer mitigation and core-dump gitignore.
Thanks to xet7 !
2026-07-24 19:23:04 +03:00
Lauri Ojansivu 0cd2b513bf Playwright: disable WPE WebKit DMABUF renderer to stop headless-ARM renderer crashes; gitignore core dumps.
The last WebKit test run left 8 core dumps in tests/playwright/ — all of WebKit's
WPEWebProcess renderer, one readable dump terminating with SIGTRAP (a deliberate
abort) from the Mesa llvmpipe software-GL stack on this ARM (Apple Silicon / Asahi)
host. A renderer that aborts mid-test makes the next click/navigation time out, so
these surfaced as false WebKit-only failures (05-admin-users, 12-checklists,
20-rules, 39-allboards-sort), not real WeKan bugs.

playwright.config.js now defaults WEBKIT_DISABLE_DMABUF_RENDERER=1 (unless already
set) — the standard fix for WPE WebKit aborting in headless / containerized / VM /
software-GL environments. Playwright passes process.env to the launched browser, so
this covers every run path; it is harmless for Chromium/Firefox, which ignore it.

.gitignore now ignores core dumps (core.<pid>) so a crashing test browser never
leaves untracked multi-MB files that could be committed by accident. The 8 existing
dumps are removed.

Thanks to xet7 !
2026-07-24 19:22:30 +03:00
Lauri Ojansivu 7decac23c5 Changelog Upcoming: note the board-copy 500-on-error + customFields guard fix.
Thanks to xet7 !
2026-07-24 18:50:15 +03:00
Lauri Ojansivu a6d9da4e9f Board copy: stop the REST route masking a failed copy as HTTP 200, and guard customFields.
The Playwright test "#5592/#5627 copying a board copies its webhooks and rules"
regressed (passed 2026-07-23, failed 2026-07-24 in all three browsers): the copy
returned HTTP 200 whose body was an object instead of the new board id string.

Cause of the MASKING: `POST /api/boards/:boardId/copy` did
`catch (error) { sendJsonResult(res, { data: error }) }`, and sendJsonResult
defaults the status to 200 when no `code` is given — so a copy that THREW was
reported as 200 with the error object as the body, looking like a success. The
catch now returns 500 with the message and logs the stack, so a failing copy is
a clean error (and diagnosable in the server log) instead of a wrong 200.

Also hardens board.copy() itself: the custom-field remap did
`card.customFields.map(...)`, which throws for a card whose customFields is
absent (the schema defaults it to [], but a card copied via `.direct` or seeded
raw can lack it), failing the whole copy. It now skips cards with no
customFields, matching the `!Array.isArray(this.customFields)` guard already used
elsewhere in the model.

The copy code and the card/swimlane/list models are unchanged in the regression
window, so the underlying throw comes from an infra change in that window; the
500 + server-side log above make the exact error visible on the next run.

Thanks to xet7 !
2026-07-24 18:49:49 +03:00
Lauri Ojansivu 8f01ecfa11 Docs: schema-migration history — add the Per-Swimlane Lists revert (board-wide lists).
Appends item 11 recording that the Per-Swimlane Lists model was reverted to
board-wide shared lists and its migrations removed (#6521), and that data is
still shown without slow migrations via merge-per-swimlane-lists + repairBoardData.

Thanks to xet7 !
2026-07-24 16:46:35 +03:00
Lauri Ojansivu ffecc49c92 Docs: WeKan lists are board-wide, not per-swimlane (reflect the #6521 revert).
The per-swimlane-lists model was reverted (its migrations were removed in #6521);
lists are board-wide and the same lists appear in every swimlane. Update the docs
to say so:

  * Features/Lists/Lists.md and Features/Board/Swimlanes.md now state plainly that
    lists are shared across swimlanes (a card belongs to one list and one
    swimlane), and that per-swimlane lists were an experiment that was reverted.
  * The docs/Databases/Migrations/ writeups (MIGRATION_SYSTEM_IMPROVEMENTS,
    MIGRATION_SYSTEM_REVIEW_COMPLETE, CODE_CHANGES_SUMMARY, SESSION_SUMMARY,
    verify-migrations.sh) describe the old cron-driven migration system and the
    per-swimlane-lists migration, both removed; each now carries an OBSOLETE banner
    pointing at the current board-wide model and the live repair paths
    (merge-per-swimlane-lists + repairBoardData).

The dated PerUserDataAudit2025-12-23 snapshot is left as-is: it already observed
that lists are board-wide, and it is a point-in-time record.

Thanks to xet7 !
2026-07-24 16:44:56 +03:00
Lauri Ojansivu 7cb3c39a86 Changelog Upcoming: note the rebuild-wekan "Update git" action and shared hash-fix script.
Thanks to xet7 !
2026-07-24 16:32:13 +03:00
Lauri Ojansivu c2ad9dddbb rebuild-wekan: add Setup "Update git" (fetch+rebase+CHANGELOG hash fix+status); share the hash-fix script.
New Setup menu action in both rebuild-wekan.sh and rebuild-wekan.bat that makes
the working copy current in one step: git fetch --all --prune, git pull --rebase
--autostash onto origin/<branch>, then repoint any CHANGELOG commit links the
rebase made stale, then show git status. It never commits; if the hash fix
changed CHANGELOG.md it says so and leaves it for review.

The commit-link repair that used to live inline in releases/release-all.sh is
extracted to releases/fix-changelog-hashes.sh so there is ONE implementation,
called by both release-all.sh (before a release) and the new Update-git option.
Behaviour is unchanged: only the unreleased section is touched, stale links are
repointed to the same-subject rewritten commit, unresolved links warn and never
abort, and it always exits 0.

The Windows .bat calls the shared bash script via Git Bash (bundled with Git for
Windows) and degrades gracefully with instructions when bash is not on PATH.

changelogCommitLinks.test.cjs now guards the shared script plus the fact that
both callers reuse it and release-all.sh keeps no duplicate copy.

Thanks to xet7 !
2026-07-24 16:31:46 +03:00
Lauri Ojansivu fc502520da Changelog Upcoming: repoint the two commit links at their post-rebase hashes.
The v10.34 version bump rebased the post-release work, so the header follow-up
and the #6521 migration-removal bullets linked hashes that no longer exist on the
branch (33ca42a32 -> e28181734, 88a076561 -> 52d68be75). Matched by commit
subject and repointed. Only the not-yet-released Upcoming section is touched.

Thanks to xet7 !
2026-07-24 16:22:52 +03:00
Lauri Ojansivu a854a303ce Changelog v10.34: note the cpu-cores duplicate-key fix that unblocked the Transifex source push.
Thanks to xet7 !
2026-07-24 16:18:24 +03:00
Lauri Ojansivu 32ad565bf7 Changelog: move post-v10.34 work into a fresh Upcoming section.
The v10.34 release commit renamed the Upcoming section to v10.34 while the header
follow-up (display:contents) and the #6521 migration removal were committed just
after it. Their CHANGELOG entries had landed inside the now-released v10.34
section (and had rewritten v10.34's mobile-header bullet). Restore v10.34 to what
it shipped and put both post-release bug fixes under a new "Upcoming WeKan ®
release" section instead.

Thanks to xet7 !
2026-07-24 16:18:24 +03:00
Lauri Ojansivu c9c42deffb Changelog Upcoming: document the #6521 removal of the "Restored Items" migrations.
Thanks to hmeunier95 and xet7 !
2026-07-24 16:18:24 +03:00
Lauri Ojansivu 52d68be755 Remove the per-swimlane-lists-era board migrations that made spurious "Restored Items" columns and empty-looking cards.
#6521: after upgrading toward 10.33 a board grew a new "Restored Items" column
full of cards that looked empty but were not. That column, and the card moves
behind it, came from obsolete board migrations that fight the CURRENT data model:

  * restoreLostCards - created a "Lost Cards" swimlane and a "Restored Items" list
    (i18n lost-cards / lost-cards-list) and moved cards into them. It called a card
    "orphaned" when its list was merely ARCHIVED (archived lists are excluded from
    the set it checks) and "lost" when its swimlaneId was '' - which is NORMAL for a
    board-wide shared card - so it dragged real, fine cards into "Restored Items".
  * comprehensiveBoardMigration / fixMissingListsMigration - converted today's
    board-wide SHARED lists (swimlaneId '') back into per-swimlane DUPLICATE
    columns, i.e. the exact damage the startup schema step merge-per-swimlane-lists
    now UNDOES.
  * restoreAllArchived - un-archived every swimlane/list/card at once.

All four had NO callers left - their admin migration dashboard was already
removed - so they were dead code exposed only as admin-callable Meteor methods, a
footgun that could re-corrupt a board if invoked. They are removed, along with
their imports and the stale commented-out imports in 00checkStartup.js.

The legitimate paths remain and are correct: the board-open self-heal
(repairBoardData) assigns a genuinely missing swimlaneId / relinks orphaned cards
to the board's real first list/swimlane, and the startup schema step
merge-per-swimlane-lists merges any per-swimlane duplicate columns back into one
shared list while cards keep their swimlaneId. It still keys off the era's board
markers (fixMissingListsCompleted / comprehensiveMigrationCompleted) in existing
data, so removing the code that once wrote them changes nothing for already-migrated
boards.

Adds tests/obsoleteBoardMigrationsRemoved.test.cjs so these cannot come back.

Thanks to hmeunier95 and xet7 !

Fixes #6521,
2026-07-24 16:18:24 +03:00
Lauri Ojansivu e28181734e All Boards phone header: icons on one row via display:contents; stop workspace name overlapping its menu.
Two follow-ups to the phone All Boards header.

The section icon still sat alone on row 1 with the Multi-Selection / Sort
buttons stranded on row 2. Those buttons and the search box are wrapped in a
`.path-right` element, so giving that wrapper `flex` only made IT wrap as a
two-line block below the icon. On a phone `.path-right` is now `display:
contents`, which dissolves the wrapper so its buttons and search field become
direct items of the header's flex row; the header then wraps them itself - the
section icon and buttons on row 1, the full-width search box on row 2.

The workspace NAME on the narrow left menu could spill over the "bars" workspace
menu button to its right, so the hamburger looked like it was drawn on top of the
name. The earlier min-width floor on the name caused it: in the narrow menu it
stopped the name shrinking, so its box overflowed onto the menu. The floor is
removed and the name's box (js-select-space) is clipped, so the name truncates
with an ellipsis and can never cover the menu; the menu button and count never
shrink.

Thanks to xet7 !
2026-07-24 16:18:24 +03:00
github-actions[bot] d2a80eacf4 Bump versions for v10.34 2026-07-24 07:20:00 +00:00
Lauri Ojansivu 3ebdfa52a2 Prepare v10.34 release 2026-07-24 10:18:51 +03:00
Lauri Ojansivu 8a4d12266c Updated translations 2026-07-24 10:18:34 +03:00
Lauri Ojansivu 69ecea37fb Changelog Upcoming: document the #6520 Problems board-repair and CPU-count fix.
Thanks to Alishara and xet7 !
2026-07-24 10:14:53 +03:00
Lauri Ojansivu 1fb382f0eb Stop Admin Panel / Problems showing a finished board-repair and info CPU rows.
#6520: the Problems "Status" page showed "Board data-repair — 146/146 boards"
running forever (plus a matching "Migration or repair" login warning), and the
CPU area showed dozens of "new problems" on an idle server.

Board repair stuck at "running": getInProgress() trusted the raw `running` flag.
The startup repair DOES write running:false on completion, but its progress
callback also fired a NON-awaited running:true write when boardsDone ===
boardsTotal; being fire-and-forget, that write could land AFTER the awaited
completion write and pin the doc at "running, 146/146". A process killed
mid-repair leaves running:true too. Both now clear:
  * the startup progress callback no longer writes the terminal boardsDone ===
    boardsTotal update (the completion write owns the final state), closing the
    race at the source;
  * getInProgress() now asks the new pure isStatusActive() instead of reading
    `running` directly - a doc is "in progress" only while running AND not in a
    terminal phase (completed/error/done) AND not already finished by count
    (done >= total) AND updated recently (a running flag with no recent progress
    write is a crashed run). This also heals docs already stuck on an instance,
    and fixes the same class of stale flag for the text migration. Because the
    login "Migration or repair" warning keys off getInProgress(), it clears too.

CPU "57 new problems" on an idle server: the count in eventLogProblemAreas
counted every unacknowledged row in a stream. The CPU monitor writes one
'detected' row when a high-CPU period starts and several severity:'info' rows
('remediated' when it ends, 'rate-limited' for mitigation, FerretDB governor
chatter), so each brief, already-over spike looked like several "new problems".
The count now excludes severity:'info' rows (via the new newProblemsSelector) -
notices that a problem was handled or cleared are not problems. Rows with no
severity are still counted, so nothing unclassified is silently dropped.

Both decisions are pure, unit- and negative-tested (statusActive.test.cjs,
eventLogProblems.test.cjs), and registered in test:unit:node.

Thanks to Alishara and xet7 !

Fixes #6520,
2026-07-24 10:14:29 +03:00
Lauri Ojansivu 8f6f4a126a Fix duplicate i18n key cpu-cores that broke the Transifex source push.
`tx push -s` failed with `parse_error: Duplicate string key` because
en.i18n.json had two `cpu-cores` keys: the existing "CPU Cores" (already on
Transifex with human translations) and a second "cores" added by the CPU-usage
feature. A JS JSON parser silently keeps the last, so the app never noticed, but
Transifex rejects duplicates - and the collision also meant `cpu-cores` resolved
to "cores" everywhere.

The feature's string needs a different value ("N cores"), so it moves to a new
unique key `cpu-cores-suffix`; the pre-existing `cpu-cores` "CPU Cores" is left
untouched, matching every other language file, so no human translation is
disturbed. Only the one template that used it (Admin Panel / Problems / CPU
usage) is repointed.

Thanks to xet7 !
2026-07-24 10:06:58 +03:00
Lauri Ojansivu c44b408b04 Changelog Upcoming: document running the five unrun test suites and dead-file deletion.
This developer-tooling change (registering five never-run mocha suites, two of
them *bleed security regression suites, plus the testsAreRegistered guard, and
deleting responsiveFixes.css and the empty backgrounds tombstones) was committed
after v10.33 but had no CHANGELOG entry. Every other post-v10.33 commit is
already covered, individually or as a consolidated fix series.

Thanks to xet7 !
2026-07-24 10:00:22 +03:00
Lauri Ojansivu 2aee64a336 Changelog Upcoming: document the All Boards mobile header tweaks.
Thanks to xet7 !
2026-07-24 09:51:06 +03:00
Lauri Ojansivu 79c5c278e1 All Boards mobile header: one-line icons above Search, a visible Sort icon, and workspace names.
On a phone the All Boards path header put the section icon alone on a wasted
first line while the Multi-Selection and Sort buttons sat on the next line. The
section icon and those buttons now share ONE row (path-left becomes flex 0 0
auto and stays on the buttons' row), and the Search box takes a full-width row
of its own beneath them (flex-basis 100% forces the wrap).

The blue Sort button showed as an empty blue square: its icon was the wrapped
`fa-sort-alpha-asc`. It now uses the plain `fa-sort` glyph as a direct `i.fa`
child, matching the Multi-Selection button beside it whose icon renders, so the
button always shows a sort icon.

On the narrow left menu each workspace name now keeps a small floor of width
(min-width, with the trailing menu button and count shrinking first) so at least
the first few characters stay visible and a workspace can be told apart while it
is dragged. An unnamed workspace still shows just its icon.

Thanks to xet7 !
2026-07-24 09:51:06 +03:00
Lauri Ojansivu f6a674f633 Changelog Upcoming: document the touch drag-and-drop bridge for board reorder.
Thanks to xet7 !
2026-07-24 09:16:27 +03:00
Lauri Ojansivu 1c2e1e2047 Add touch -> HTML5 drag-and-drop bridge so board icons drag by finger.
HTML5 drag-and-drop never fires from touch, so the board-icon reorder (and the
other HTML5-DnD spots) only worked with a mouse. This adds
client/lib/dragDropTouch.js: a long press on a `draggable="true"` element starts
a synthetic drag that dispatches REAL, bubbling
dragstart/dragenter/dragover/dragleave/drop/dragend DOM events with a
dataTransfer shim - so every existing delegated handler works from a finger with
no change to the reorder logic, the workspace drag-to-share, or the card/rule
drags.

Chosen to be safe alongside the existing touch scrolling:
  * LONG PRESS (250 ms) to start - a quick swipe on a draggable tile still
    scrolls; only a deliberate hold-then-move begins a drag, and preventDefault
    is called ONLY once a drag is under way.
  * It fires a `mousedown` on the pressed element first, so the board reorder's
    handle gate (which records the mousedown target) works from touch too.
  * setDragImage is honoured, so the ghost is centred on the finger exactly as it
    is on the cursor for a mouse.
  * It excludes form fields and jQuery-UI sortables (lists/swimlanes/cards
    already get touch via touch-punch), and coexists with dragscrollTouch.js,
    which already skips the `.nodragscroll` board tiles and handle.

Loaded globally (via pageDragscroll / boardBody, and imported in boardsList).
Adds tests/dragDropTouch.test.cjs pinning the properties above.

This is Option A from the discussion - it keeps every mouse fix and every other
HTML5-DnD feature, rather than rewriting reorder onto jQuery-UI-sortable.

Thanks to xet7 !
2026-07-24 09:13:02 +03:00
Lauri Ojansivu 4f97b8a74a Changelog Upcoming: note centering the drag ghost on the cursor.
Thanks to xet7 !
2026-07-24 08:56:38 +03:00
Lauri Ojansivu 137c68ca74 All Boards: centre the drag ghost on the cursor so the gap opens where you aim.
You had to drag a board icon to the LEFT edge of the target for the gap to open
there. The reason: HTML5 dragover fires for whatever is under the CURSOR, but an
icon is grabbed off-centre (often at the move handle on the right), so the ghost
sat beside the cursor - when the ghost looked like it was over the target, the
cursor was actually over the neighbour, and the gap opened for the wrong icon.

The drag image is now centred on the cursor (setDragImage at the icon's centre),
so "ghost over the target" and "cursor over the target" line up. Combined with the
whole-icon insert-before from before, dropping the ghost onto a target icon now
opens the gap at that icon regardless of where on it you grabbed the dragged one.

Thanks to xet7 !
2026-07-24 08:56:25 +03:00
Lauri Ojansivu 6621f3bc49 Changelog Upcoming: note hiding the dragged board icon during drag.
Thanks to xet7 !
2026-07-24 08:33:02 +03:00
Lauri Ojansivu d03976e402 All Boards: hide the dragged icon while dragging so the gap lands where the icon will.
The dragged board icon stayed put in its slot during the drag, so the grid had
one extra tile and the live gap appeared one slot to the LEFT of where the board
would actually land - dropping "Eikun" onto "juuppa" showed the gap beside juuppa
instead of at it.

The dragged icon is now taken OUT of the grid flow while dragging
(`display: none`, added on a timeout after dragstart so the drag image is already
captured, removed on dragend). The other icons reflow as if it were gone, so the
gap sits exactly where the icon will land and "juuppa" moves aside to show the
drop slot.

The order computation already skips the dragged id, and the "is this the last
tile?" check now ignores the hidden (still-in-DOM) dragged tile, so reordering the
last board still works.

Thanks to xet7 !
2026-07-24 08:32:50 +03:00
Lauri Ojansivu 6eeae34dcf Changelog Upcoming: note whole-icon drop targeting for board reorder.
Thanks to xet7 !
2026-07-24 08:24:40 +03:00
Lauri Ojansivu 8ee11ee0e0 All Boards: drop a board directly onto the slot it should take.
The live gap used a left/right-half threshold on every board icon: the left half
inserted before, the right half after. So to land a board in a given slot you had
to aim the LEFT half of the icon occupying it, which was fiddly.

Now hovering anywhere on a board icon opens the gap AT that icon's slot (insert
before it), so dropping a board onto an icon puts it in that icon's place - drop
where it should stay, no half-aiming. The only exception is the LAST icon, whose
trailing half still inserts after it, so the end of the order remains reachable.

Thanks to xet7 !
2026-07-24 08:24:29 +03:00
Lauri Ojansivu 46d5171b37 Changelog Upcoming: note the header Font Awesome section icon.
Thanks to xet7 !
2026-07-24 08:18:45 +03:00
Lauri Ojansivu cabf50013a All Boards header: use the left-menu Font Awesome icon, not a Unicode emoji.
The section icon left of the Multi-Selection button was a Unicode emoji
(⭐ 📋 📂 🗂️), which does not match the left menu, where Starred / Templates /
Remaining / Workspaces use Font Awesome glyphs (fa-star, fa-clipboard, fa-folder,
fa-folder-open). currentMenuPath now also returns a `faIcon` for the current
section, the header renders `i.fa` with it, and it is coloured #4d4d4d and sized
like the menu icons - so the header icon is the same glyph and colour as the
matching left-menu item.

Thanks to xet7 !
2026-07-24 08:18:34 +03:00
Lauri Ojansivu ebcd27fbe1 Changelog Upcoming: note the drop-on-gap reorder fix.
Thanks to xet7 !
2026-07-24 08:16:17 +03:00
Lauri Ojansivu 3faddbd6c1 Fix board reorder snapping back: the drop landed on the gap, not a tile.
Reordering a board within a workspace (or any view) put it back where it started.
The `drop .js-board` handler only fires when the pointer is released over a BOARD
tile - but the gap the user aims at is the placeholder `<li>`, which is NOT a
`.js-board`, so releasing over it hit no drop handler and the optimistic move
reverted.

The drop now reads the intended order straight from the DOM using the gap's real
position (every board id in list order, with the dragged id taken from its old
slot and put where the placeholder sits), and there are `dragover`/`drop`
handlers on `.js-board-placeholder` so releasing over the gap itself works. Both
the tile-drop and gap-drop paths share one persist function, and it no-ops when
the visible order did not change.

This removes the earlier before/after target math (computeReorderedSortIndex) in
favour of the DOM position, which is exactly what the user sees, so the persisted
order always matches the gap.

The write itself was already permitted - the users allow rule accepts
`profile.*` and the schema field was added in the previous commit - so with the
drop actually firing, the reorder persists.

Thanks to xet7 !
2026-07-24 08:16:06 +03:00
Lauri Ojansivu 9558e9976d Changelog Upcoming: describe the live in-place drop gap.
Thanks to xet7 !
2026-07-24 08:06:25 +03:00
Lauri Ojansivu c0e71705f6 All Boards: a live drop gap that follows the drag, in place, not just at the end.
Replaces the fixed "drop at the end" placeholder with the standard sortable feel:
while a board is dragged, an empty dashed gap opens up before or after the tile
under the cursor, showing exactly where the board will land - anywhere in the
current view (Starred / Templates / Remaining / a workspace), not only at the
end.

How it works:
  * `dragover .js-board` computes which side of the tile the cursor is on
    (inline-centre, RTL-aware) and inserts a placeholder `<li>` there. The
    placeholder is a plain DOM node, inserted next to the Blaze-managed tiles and
    removed on dragend, so it never fights Blaze's reactive list.
  * `drop .js-board` reads that side and reorders with the pure
    computeReorderedIds(..., after), which now inserts on the target's leading OR
    trailing side and returns null for a no-op (including a drop back into the
    board's own slot). Dropping past the last tile reaches the end, so the
    separate end target is gone.

The placeholder is a real grid cell, so the surrounding tiles flow around the gap
as it moves. Unit tests cover the new before/after side and the own-slot no-ops.

Still mouse-only: HTML5 drag-and-drop does not fire from touch (the jQuery-UI-
sortable + touch-punch move noted before is what would add finger dragging).

Thanks to xet7 !
2026-07-24 08:06:14 +03:00
Lauri Ojansivu 5021bd55a1 Changelog Upcoming: note the reorder persistence, end placeholder and header declutter.
Thanks to xet7 !
2026-07-24 07:58:43 +03:00
Lauri Ojansivu e571420c92 All Boards: hide the folder name next to its icon in the header.
The header showed the current folder's icon AND its name ("Starred", "Remaining",
a workspace name) just left of the Multi-Selection / Sort / search controls. The
name was redundant - the folder icon already indicates the section, the left menu
highlights it, and the "My Boards" bar names the page - and it crowded (and on
narrow widths overlapped) those controls.

The `.path-text` is now visually hidden rather than removed, so a screen reader
still announces which folder is shown; the icon alone has no text alternative.

Thanks to xet7 !
2026-07-24 07:58:28 +03:00
Lauri Ojansivu da64c8a0f1 All Boards: add a trailing drop placeholder to move a board to the end.
Dropping one board onto another can only place it BEFORE that board, so the last
slot in the order was unreachable by drag. A dashed "drop here to move to the
end" tile now sits after the last board (only in the manual "custom" sort mode,
where drag-reorder applies) and accepts a drop that moves the dragged board to
the end.

The move-to-end order math is the pure computeReorderedSortIndexToEnd in
models/lib/boardSortReorder.js (a no-op when the board is already last); the
dragover/drop handlers mirror the board-tile ones and light the placeholder with
the same `.board-reorder-over` highlight.

Thanks to xet7 !
2026-07-24 07:57:42 +03:00
Lauri Ojansivu b09510c41a Fix INVALID [400] when drag-reordering boards: add the missing schema field.
Dropping one board onto another to reorder threw INVALID [400]. setBoardSortIndexes
writes `profile.boardSortIndex` from the client via Users.updateAsync, and the
user `profile` object ENUMERATES its subfields in the schema (it is not blackbox)
- but `profile.boardSortIndex` had no schema entry, so SimpleSchema rejected the
update with a 400.

Added the entry next to the analogous `profile.autoWidthBoards` /
`profile.fixedListWidthBoards` (type Object, defaultValue {}, blackbox, optional).
The #6439 drag-reorder now persists instead of erroring.

Thanks to xet7 !
2026-07-24 07:55:23 +03:00
Lauri Ojansivu 8d5d5abd55 Changelog Upcoming: note the All Boards drag and touch-target fixes.
Thanks to xet7 !
2026-07-24 07:53:11 +03:00
Lauri Ojansivu ab1b2dc4e1 All Boards: make "+ Add workspace" a real touch target.
A finger tap on "+ Add workspace" often did nothing while the larger menu rows
(Starred, Remaining, a workspace name, the workspace menu button) worked fine. It
was a ~20px "+" link with `padding: 2px 8px` - well below the 44px minimum touch
target, so a finger usually landed just outside it. On a touch-capable layout it
is now a 44x44 target with the "+" centred. The click handler (opens the
add-workspace popup) is unchanged.

Thanks to xet7 !
2026-07-24 07:52:54 +03:00
Lauri Ojansivu 4f20e0e024 All Boards: fix handle drag - dragstart.target is the li, not the handle.
With drag handles ON, dragging the handle did nothing (mouse or finger). The gate
that restricts the drag to the handle checked
`dragstart.target.closest('.board-handle')` - but in Chrome and Firefox the
dragstart event's `target` is the DRAGGABLE element itself (the whole
`li.js-board`), not the sub-element the pointer was on. So the closest() was
always null, the gate treated every handle drag as "not on the handle", and
cancelled it.

The press location is now recorded on `mousedown .js-board` (which does fire on
the handle), and dragstart reads that flag instead. The flag is reset on dragend
so a later drag that starts elsewhere is not wrongly allowed. Mouse drag from the
handle now reorders; a drag that starts on the rest of the tile is still
cancelled so that area stays free.

Touch is unchanged and still does not reorder: HTML5 drag-and-drop does not fire
from touch at all, which needs the separate jQuery-UI-sortable + touch-punch move
noted before.

Thanks to xet7 !
2026-07-24 07:51:53 +03:00
Lauri Ojansivu a2cea8b5c7 All Boards: let a mouse drag reorder boards (dragscroll and the link stole it).
Dragging a board tile to reorder used the tile's HTML5 drag (draggable="true" +
the dragstart/dragover/drop handlers). Two things on the same tile were stealing
the mouse drag before that native drag could start:

  * the board list <ul> is a `.dragscroll` element, so the mouse dragscroll
    library pans it on mouse-drag - and the tiles were not `.nodragscroll`, so
    grabbing a tile panned the list instead of starting the board drag;
  * `a.js-open-board` is a link, and a link is draggable by default, so grabbing
    the board title dragged the URL rather than the board.

`li.js-board` is now `.nodragscroll` (dragscroll leaves the tile alone) and the
inner link is `draggable="false"` (the tile's own drag starts instead; the link
still navigates on click). Star/unstar and "+ Add Board" are clicks, so they are
unaffected. Vertical scrolling of the list still works natively.

KNOWN, NOT fixed here: reordering by FINGER on a touchscreen. Board reorder uses
HTML5 drag-and-drop, which browsers do not fire from touch at all - so touch
needs a polyfill or a move to jQuery-UI-sortable + the touch-punch this app
already bundles (what lists/swimlanes use). That is a larger change and is called
out separately rather than half-done here.

Thanks to xet7 !
2026-07-24 07:39:55 +03:00
Lauri Ojansivu 52c80c8c4f Changelog Upcoming: note the responsive board tile grid.
Thanks to xet7 !
2026-07-24 07:30:05 +03:00
Lauri Ojansivu 04e658b03c Board tiles: responsive grid so more width means more boards per row.
The board list had two FIXED-column layouts, and both stretched the tiles wider
as the window grew instead of fitting more per row:
  * desktop: `float; width: 20%` - a fixed 5 columns (4 after margins);
  * mobile-view: `grid-template-columns: repeat(2, minmax(0, 1fr))` - always 2.
That is why widening the window alternated "nice square tiles" and "few very
wide tiles" instead of steadily adding columns.

Both are now `repeat(auto-fill, minmax(..., 1fr))`, which fills each row with as
many tiles of a steady size as fit, so more width = more boards per row:
  * desktop `minmax(200px, 1fr)` - ~3 columns at 1000px up to ~11 at 2500px;
  * mobile-view `minmax(min(46%, 200px), 1fr)` - the `min(46%, ...)` keeps ~2
    columns on a narrow phone board-column while a wider one (a laptop in mobile
    mode) gets more, capped at a 200px tile.
The 8px grid `gap` replaces the per-tile 8px margins (now 0), and the float on
the list items is reset since grid places them.

Phones are unaffected: the `max-width: 800px` / device-iPhone media queries still
pin `repeat(2)` there, and on a phone `.board-list.mobile-view` (higher
specificity) resolves to 2 columns anyway.

Thanks to xet7 !
2026-07-24 07:29:50 +03:00
Lauri Ojansivu 8c55ad0fad Changelog Upcoming: note the top-bar trailing-group alignment.
Thanks to xet7 !
2026-07-24 07:22:07 +03:00
Lauri Ojansivu 940437865d Top bar: keep the zoom/bell/avatar group at the end (LTR right, RTL left).
The zoom %, notification bell and user/avatar menu are the last items in the
quick-access bar. When the starred-boards list in the middle is shown it fills
the gap with `flex: 1` and pushes them to the end - but that list is
`display: none` on a phone, so with nothing filling the middle the group bunched
at the START (left in LTR) with empty bar to its right.

`margin-inline-start: auto` on the first item of the group (the zoom control)
pushes the whole group to the end. Being a LOGICAL margin it is the right edge in
LTR and the left edge in RTL automatically, which is the requested behaviour. An
auto margin an earlier small-width rule had put on the user bar is cleared, so
there is exactly ONE auto in the row - two would split the free space and
separate the group. The rule is last in the file, so it wins on source order.

Thanks to xet7 !
2026-07-24 07:21:54 +03:00
Lauri Ojansivu 9c89d4778c Changelog Upcoming: note the desktop board tile merge fix.
Thanks to xet7 !
2026-07-24 07:19:20 +03:00
Lauri Ojansivu b1435dce79 Fix desktop board tiles: scope the mobile equal-tile rules to .mobile-view.
At wider (laptop) widths the board tiles merged - "Just" and "juuppa" ran
together into one blue block with no gap, and the tiles lost their proper width.
The equal-height/equal-width work done earlier for the phone had six rule groups
whose selector lists included a BARE `.board-list ...` variant alongside the
intended `.board-list.mobile-view ...` one, so they also applied on desktop where
`.mobile-view` is absent. Two of them broke the desktop float layout:

  .board-list li.js-board  { margin: 0 !important }   /* killed the 8px gap    */
  .board-list li           { padding-inline-end: 0 }  /* and other geometry    */

removing the spacing that `.board-list li { width: 20%; margin: 8px }` relies on,
so adjacent coloured tiles touched and read as one block.

All six groups are now `.board-list.mobile-view` only, so the desktop float
layout (untouched `width: 20%`, `margin: 8px`) is restored and the phone layout
is unchanged. Verified that no non-media, non-mobile-view rule sets
`margin: 0 !important`, `width: auto` or `height: 4rem` on a board tile.

Thanks to xet7 !
2026-07-24 07:19:09 +03:00
Lauri Ojansivu bc3569dd0c Changelog Upcoming: note the All Boards landscape menu/header fixes.
Thanks to xet7 !
2026-07-24 07:15:12 +03:00
Lauri Ojansivu 6580c445c1 All Boards in iPhone landscape: tame the left menu width and stack the header.
iPhone landscape is ~844px CSS-wide - above the 800px breakpoint but still an
iPhone, so the device-width media query (`max-device-width: 932px and
-webkit-min-device-pixel-ratio: 3`) applies while the plain `max-width: 800px`
rules do not. That split caused all three symptoms:

1. The left menu was `minmax(140px, 42%)`. 42% of 375px is 158px (fine in
   portrait), but 42% of 844px is ~354px - a very wide column, and because each
   menu item is `justify-content: space-between` with a `flex: 1` label, the
   count chip was flung to the far edge, ~250px from its label. The column is now
   `minmax(140px, min(42%, 210px))`: portrait is unchanged (158px is below the
   cap), landscape is capped at 210px.

2 + 3. The count now sits right after its label (mobile only: `justify-content:
   flex-start`, non-growing label, `gap`), so there is no large empty band even
   at 210px, and the desktop 260px menu is left alone.

4. The folder header ("Remaining" + multi-selection buttons + search) stacked
   onto rows only at `max-width: 800px`, so at 844px it stayed a single row and
   the buttons overlapped the "Remaining" title. Its stacking media query now
   carries the same device-width branch as the grid layout, so it stacks in
   landscape too.

Thanks to xet7 !
2026-07-24 07:15:00 +03:00
Lauri Ojansivu 07c8f75f3d Changelog Upcoming: note the body-scroller fix for Search All Boards.
Thanks to xet7 !
2026-07-24 07:12:21 +03:00
Lauri Ojansivu c845f76cae Search All Boards: one body scroller, so a finger drags the content too.
Dragging the header bars scrolled the page but dragging the grey content area did
not. The header sits OUTSIDE #content, so the fact that it scrolls proves the
BODY is the real scroller. The previous commit had made #content its own
`overflow-y: auto` scroller - and in the content area that TRAPPED the touch:
#content caught the gesture and, having nothing of its own to scroll, did not
pass it on to the body, so a finger over the text did nothing while the same drag
over the header still worked.

#content is a pass-through again: `overflow: visible` so it does not catch the
touch, and `flex: 0 0 auto; min-height: auto` so it takes its content's full
natural height instead of being squeezed to the viewport by the default
`flex: 1`. The body's content is then taller than the body, so the body scrolls -
one scroller, reachable by a finger anywhere.

The body keeps `100dvh` (bottom tracks Safari's toolbar, not the large `100vh`),
`overflow-y: auto`, and `overscroll-behavior: contain` (no pull-to-refresh). Still
scoped with `body:has(.wrapper.global-search-wrapper)`, so only this page changes.

Thanks to xet7 !
2026-07-24 07:12:09 +03:00
Lauri Ojansivu 762e501950 Changelog Upcoming: note the #content scroller fix for Search All Boards.
Thanks to xet7 !
2026-07-24 07:07:07 +03:00
Lauri Ojansivu ec6a5b3685 Search All Boards: make #content a real scroller so the whole page is reachable.
Every earlier attempt treated the wrapper; the bottom stayed unreachable in both
orientations because of #content, not the wrapper.

#content is a flex child of the body flex-column, and a flex child defaults to
`min-height: auto` - it refuses to shrink below its content, so it grows to fit
and NEVER scrolls. Content past the fold just overflowed out of reach. `min-height:
0` is what lets it become a real scroller. That was the missing piece the whole
time.

The other half was the iOS toolbar: `body`/#content were `100vh`, which on iOS is
the LARGE viewport (toolbars hidden), so the scroller's bottom sat behind the
toolbar. `100dvh` - the dynamic viewport, which shrinks when the toolbar shows -
keeps the last line above it, in both portrait and landscape.

So on this page #content is the single scroller: `min-height: 0; overflow-y:
auto`, inside a `100dvh` body, with `overscroll-behavior: contain` to stop the
drag chaining out to the document (which fired pull-to-refresh in landscape). The
wrapper goes back to plain content so its full height feeds #content's scroll
range.

All of it is behind `body:has(.wrapper.global-search-wrapper)`, so it applies
only when the search page is shown and no other page's #content is changed. :has,
dvh and overscroll-behavior are all supported on the iOS Safari and Firefox
versions in question.

Thanks to xet7 !
2026-07-24 07:06:55 +03:00
Lauri Ojansivu e78d40ec0e Changelog Upcoming: note the iOS toolbar clearance for Search All Boards.
Thanks to xet7 !
2026-07-24 06:54:03 +03:00
Lauri Ojansivu e7d1046a54 Search All Boards: clear iOS Safari's bottom toolbar so the last line shows.
Scrolling reaches the end now, but the last line still sat under Safari's bottom
toolbar. The page's scroller `#content` is sized `calc(100vh - 48px)`, and on iOS
`100vh` is the LARGE viewport (toolbar hidden), so the scroller's bottom edge is
behind the toolbar when it is shown - the final bullet ("By default archived
cards are not searched.") ends up under it with nothing able to scroll it clear.

The wrapper's bottom padding is raised from 24px to
`calc(120px + env(safe-area-inset-bottom, 0px))`. That empty scrollable space
after the content lets the last real line scroll up above the toolbar; the
safe-area inset covers the home indicator, and the fixed 120px covers the toolbar
itself, which the inset does not include. Empty trailing space is harmless - it
only extends the scroll range.

Thanks to xet7 !
2026-07-24 06:53:52 +03:00
Lauri Ojansivu 1e8835ac69 Changelog Upcoming: note the native-scroller fix for Search All Boards.
Thanks to xet7 !
2026-07-24 06:50:01 +03:00
Lauri Ojansivu 762547b925 Search All Boards: one native scroller, so iOS Safari stops jumping back.
The last fix un-clipped the page by making its wrapper its OWN scroll box
(`min-height: 100vh; overflow-y: auto`). That box is taller than its parent
`#content`, which is itself a scroller in mobile mode - so BOTH scrolled. On iOS
Safari, reaching the bottom of the inner box handed the scroll off to `#content`
and then rubber-banded back to "Differing operators", which is the reported jump.
Fairphone Firefox does not rubber-band, so it just reached the end - the same two
scrollers, only one of them visibly misbehaves.

The clip is now released WITHOUT adding a second scroller: `.wrapper` for this
page is `height: auto; overflow: visible`, so it grows to its real content height
and neither clips nor scrolls. `#content` (the page's existing scroller, and what
a finger scrolls natively on touch) is then the single scroller, and a single
scroller cannot fight itself - no jump, and the last line
("By default archived cards are not searched.") is reachable on both phones.

The override is `.wrapper.global-search-wrapper` (0,2,0), which beats the
boardsList `.wrapper` clip (0,1,0) regardless of stylesheet order.

The `.dragscroll` class is dropped from the wrapper: with `#content` scrolling
natively there is nothing for dragscrollTouch.js to do, and native touch
scrolling already works on a normal vertical page.

Thanks to xet7 !
2026-07-24 06:49:43 +03:00
Lauri Ojansivu d1a4abbe83 Changelog Upcoming: note the single-scroller fix for Search All Boards.
Thanks to xet7 !
2026-07-24 06:44:55 +03:00
Lauri Ojansivu be07356ccf Search All Boards: one page scroller, so the text below "Notes" is reachable.
The previous commit gave each panel its own `max-height: calc(100vh - 120px)`
scroll box. That did not work: the panels start BELOW the search form, so their
bottoms - and their scrollbars - ended up past the bottom of the wrapper, which
is clipped at `height: 100vh; overflow: hidden`. The result was text cut off
mid-character after "Notes" on iPhone, and the list ending at "Differing
operators ... red label." on Fairphone 4, with no way to scroll to the rest.

The page now has exactly ONE scroll container instead of nested ones. This page's
wrapper is tagged `global-search-wrapper` and, at phone widths, is
`height: auto; min-height: 100vh; overflow-y: auto` - at least a screen tall and
growing with its content - while the panels inside go back to plain content with
no height of their own. Nothing can be clipped between two scrollers because
there is only one, and 24px of bottom padding keeps the last line clear of the
browser's bottom bar.

`.dragscroll` moved to that wrapper and was REMOVED from the panels, which is
required rather than tidying: dragscrollTouch.js takes the NEAREST `.dragscroll`
ancestor and gives up if that one cannot scroll - it does not keep walking up. A
`.dragscroll` class left on a panel that no longer scrolls would therefore have
disabled finger dragging on the whole page.

Thanks to xet7 !
2026-07-24 06:44:31 +03:00
Lauri Ojansivu 8d8ebaa464 Changelog Upcoming: note the Search All Boards mobile fixes.
Thanks to xet7 !
2026-07-24 06:40:57 +03:00
Lauri Ojansivu 1d022d3e55 Search All Boards on a phone: fits the width, scrolls down, and drags with a finger.
Three problems, one root cause each.

1. Label Colors ran off the right edge into the invisible area. The palette
   already has `flex-wrap: wrap`; it could not help, because its container
   `.global-search-page` is `width: 40%; min-width: 400px` - sized for a desktop
   column - so on a 375px phone the block itself was already wider than the
   screen and there was nothing to wrap into. At small width the page block is
   simply as wide as the screen, and the wrappers with their own desktop minimums
   are released too so they cannot re-create the overflow one level down.

2. The page could not be scrolled down AT ALL. `.wrapper` - the GLOBAL layout
   element - is given `height: 100vh; overflow: hidden` by the All Boards
   stylesheet at this width. That is fine for the boards page, which provides its
   own scroll container, but it applies to every page, and this one had none, so
   everything below the fold was unreachable. The help panel and the results list
   now have a bounded height with `overflow-y: auto`.

   (The deeper issue is that a page-specific `.wrapper` rule lives in
   boardsList.css and affects every page at this width. Fixing that properly means
   scoping it to the boards page; this commit does not, because every other page
   currently depends on the behaviour one way or another.)

3. Dragscroll did not work with a finger. client/lib/dragscrollTouch.js scrolls
   the nearest `.dragscroll` ancestor that can actually scroll - and no element on
   this page had the class. The help panel and the results list now carry it, and
   with (2) they are scrollable, so both conditions the handler needs are met.

The query form shares the `.global-search-page` class but is a short fixed
header, so it is explicitly excluded from becoming a scroll box of its own.

Thanks to xet7 !
2026-07-24 06:40:45 +03:00
Lauri Ojansivu a36003a74d Changelog Upcoming: note the admin table wrapping on phones.
Thanks to xet7 !
2026-07-24 06:36:04 +03:00
Lauri Ojansivu 33d95e4eb6 Admin tables fit the screen and wrap their text on a phone.
Admin Panel / Version needed a horizontal scroll to read a value that easily fits
on screen: "WeKan Version" sat at the left edge and its version number about a
thousand pixels away.

The cause is deliberate, and applies to EVERY admin table:

  .setting-content .content-body .main-body table    { min-width: 1200px !important }
  .setting-content .content-body .main-body table td { white-space: nowrap }

with the comment "Force tables to always trigger horizontal scrolling". That is
reasonable for the wide data tables, but a two-column table like Version is
pushed to 1200px for no reason, and nowrap cells can never use a second line.

At small width the table is now allowed to be as wide as the screen and no wider,
and cells may use as many lines as they need; long unbroken values (version
strings, paths, URLs) break instead of pushing the table wide again. The label
column keeps a 45% share so it does not collapse to one character per line beside
a long value. Horizontal scrolling still works where a table genuinely cannot
fit - it is simply no longer forced on tables that would fit comfortably.

The override is last in the file and repeats the same selectors, so it wins on
source order at equal specificity.

Thanks to xet7 !
2026-07-24 06:35:48 +03:00
Lauri Ojansivu ae24872db3 Changelog Upcoming: note the add-workspace input field fix.
Thanks to xet7 !
2026-07-24 06:30:55 +03:00
Lauri Ojansivu c5f40aa581 Show a real input field when adding a workspace, instead of window.prompt().
Clicking "+" beside Workspaces called window.prompt(). A browser is free to
refuse that: iOS Safari offers "don't allow further prompts" for a page and then
suppresses every later one, so the button did nothing at all and no input field
ever appeared - which is exactly what was reported.

It now opens a normal WeKan popup with a text input and a submit button, the same
pattern every other create flow here uses (workspaceActionsPopup already has an
identical "add subworkspace" form). Submitting with an empty field keeps the
popup open and refocuses the input rather than closing silently, which is what a
cancelled prompt() used to look like.

Adds the addWorkspacePopup-title i18n key; the field label and button reuse the
existing allboards.add-workspace-prompt / allboards.add-workspace strings.

This was the only window.prompt() left in the client.

Thanks to xet7 !
2026-07-24 06:30:44 +03:00
Lauri Ojansivu d8cd2f5152 Changelog Upcoming: note drag handles working on All Boards.
Thanks to xet7 !
2026-07-24 06:24:17 +03:00
Lauri Ojansivu 66d8709fa0 Make "Show desktop drag handles" work on the All Boards page too.
The setting governed swimlanes, lists and cards on a board, but board TILES on
All Boards always showed their drag handle and were always draggable from
anywhere, so turning handles off did nothing there.

The handle markup is now behind the same helper as everywhere else, in both
places it appears (a normal board tile and a template-container tile).

The drag source follows it as well, which is the half that matters: with handles
ON the handle is the ONLY place a board drag may start, so the rest of the tile
stays free and a finger dragging across it scrolls the board list instead of
picking the board up - the same contract cards and lists follow. With handles OFF
there is no handle to grab, so the whole tile drags, exactly as before.

Boards use HTML5 drag-and-drop rather than jQuery sortable (the sortable code is
commented out in this file), so this could not be done by swapping a `handle`
option. The `li` keeps `draggable="true"` either way - a drag can only start from
a draggable element, and moving the attribute onto the handle would drag the
handle rather than the board - and `dragstart` instead cancels the drag unless it
began inside `.board-handle`.

With no handle rendered, the tile's title also reclaims the 28px that was
reserved for it.

Thanks to xet7 !
2026-07-24 06:24:05 +03:00
Lauri Ojansivu 557bfb1a5a Changelog Upcoming: note the stacked header on phones.
Thanks to xet7 !
2026-07-24 06:18:13 +03:00
Lauri Ojansivu 9d0908e4bf On a phone, put the menu title above the search box instead of beside it.
Forcing "Remaining" and the search onto one row was the wrong half of the fix: on
a 375px phone the title was ellipsised down to "R.." and the search box still had
barely any width, while a wide screen has room for both side by side.

They stack again - title first, search under it - but without what made stacking
look wrong before. The old two-row layout separated them with a tall empty band
because `.path-right` was free to grow; now each part takes exactly one
full-width row (`flex: 1 1 100%`) with a fixed 6px `row-gap`, so the search sits
directly under the title. The search still needs `min-width: 0` to share its row
with the multi-selection buttons: a flex item defaults to `min-width: auto` and
otherwise refuses to shrink below its content width.

Thanks to xet7 !
2026-07-24 06:18:02 +03:00
Lauri Ojansivu d3ad9aa7b0 Changelog Upcoming: note the identical add/board tile sizing.
Thanks to xet7 !
2026-07-24 06:14:35 +03:00
Lauri Ojansivu 8fd2982eca Make "+ Add Board" and board tiles identical in size at small width.
Two things made them differ, in opposite directions.

Width: the previous commit had "+ Add Board" span both grid columns, which was
the earlier request but not this one - identical tiles is what a wide screen does
and what was asked for. It is one cell again, like any board tile.

Margin: `.js-add-board` carries `margin: 8px !important` and `.js-board` carries
`margin: 8px`, both outside any media query. Zeroing only the add tile - which is
what the first attempt at this did - would have made IT the odd one out instead.
Both are now `margin: 0` at small width: the grid already puts an 8px gap between
cells, so a margin on top of that only shrinks the tiles, and unevenly if the two
do not match exactly.

Height was already 4rem for both; that is now also pinned on the tile INSIDE each
li (`width: 100%; height: 4rem`) so nothing within can change it, and the li
itself is left to the grid (`width: auto; min-width: 0; max-width: none`).

Result at 375px: every tile is one 96px x 64px cell, add and board alike.

Thanks to xet7 !
2026-07-24 06:14:24 +03:00
Lauri Ojansivu c8e67329ad Changelog Upcoming: document the All Boards mobile layout fixes.
Thanks to xet7 !
2026-07-24 06:12:04 +03:00
Lauri Ojansivu 1ad93ef67a All Boards on a phone: readable titles, wider Add Board, one-row header, no placeholder.
Four fixes, all so the small-width layout matches what a wide screen already
does.

1. Board titles broke ONE LETTER PER LINE. The drag handle is a single ~26px
   absolutely-positioned circle, but space for it was reserved THREE times on the
   way down: 32px on `li`, 30px in the tile's padding, and 30px again on
   `.details`. On a ~96px tile that is 92px of reservation, leaving about nothing
   - so every character wrapped. An absolutely-positioned handle only needs the
   innermost text container to keep clear of it, so it is reserved once, on
   `.details`, leaving ~52px of text: the title reads left to right again.

2. "+ Add Board" now spans both grid columns, so it is twice the width of a board
   tile and reads as an action rather than as another board.

3. The header wrapped onto two rows on a phone - "Remaining" on one, the search
   box on another, with a tall empty band between - while a wide screen puts them
   side by side. The search box would not shrink, because a flex item's default
   `min-width: auto` refuses to go below its content width; `min-width: 0` plus
   `flex: 1 1 auto` lets it take what is left beside the title, and the row no
   longer wraps.

4. The "Search boards" placeholder is removed. The magnifier beside the field
   already says what it is, and the label filled the whole box on a phone. An
   `aria-label` replaces it, which is what a placeholder should never have been
   standing in for.

Thanks to xet7 !
2026-07-24 06:11:48 +03:00
Lauri Ojansivu 13d7031f34 Changelog Upcoming: note the grid stretch fix and mobile column rebalance.
Thanks to xet7 !
2026-07-24 06:04:42 +03:00
Lauri Ojansivu 534b80f92e Stop the mobile board grid stretching its row, and rebalance the two columns.
Board tiles still towered over "+ Add Board" after both were set to 4rem, for a
reason that had nothing to do with either tile's height: the mobile board list is
a GRID with a fixed height (`height: calc(100vh - 120px)`) and no
`align-content`, so it defaulted to `stretch` and its single row was stretched to
fill the whole column. "+ Add Board" showed a 4rem grey `.label` sitting at the
top of a stretched cell, while a board's `li.js-board` carries the board colour
itself and therefore painted the entire stretched cell blue - so one looked short
and the other several times taller.

`align-content: start` keeps rows at their content height instead of dividing the
leftover space among them, and `align-items: start` / `align-self: start` stop
each tile stretching to its row. The list still scrolls: the fixed height plus
`overflow-y: auto` is what makes it a scroll container, and that is untouched.

Also rebalances the two columns on mobile, in both places that declare them: the
left menu goes from `minmax(84px, 30%)` to `minmax(140px, 42%)`. At 30% of a
375px phone the menu was ~112px, so "Workspaces" and the counts wrapped and it
read as cramped, while the board column had more width than two 4rem tiles need.
42% gives ~158px of menu and ~209px of boards, which still fits two tiles of
~97px side by side.

Thanks to xet7 !
2026-07-24 06:04:30 +03:00
Lauri Ojansivu 1e91c1f5cf Changelog Upcoming: note the mobile-view board tile height fix.
Thanks to xet7 !
2026-07-24 06:01:18 +03:00
Lauri Ojansivu 000a29129e Make board tiles match "+ Add Board" in mobile view, not several times its height.
The previous commit set both to 4rem, but only the add tile actually shrank: a
board tile stayed 8rem, so on the Remaining tab one board icon towered over
"+ Add Board" instead of matching it.

Two selectors decide that height and they are not equally specific:

  .board-list.mobile-view .board-list-item   (0,3,0)  height: 8rem
  .board-list .board-list-item               (0,2,0)  height: 4rem   <- lost

`.mobile-view` is put on the <ul> by the template from Utils.isMiniScreen(), so
it is present on a phone; the add tile escaped only because it is matched by
`.board-list .js-add-board .label`, which is also (0,3,0) and came later in the
file. Hence one tile shrank and the other did not.

Both `.mobile-view` shapes are now spelled out, so the rule ties on specificity
and wins on order.

The 8rem rule is also OUTSIDE any media query, while the 4rem rule was inside
`@media (max-width: 800px)`. Utils.isMiniScreen() weighs mobile MODE and the
device as well as width, so `.mobile-view` can be set on a wide screen, where the
media query does not apply and the mismatch would have come straight back. The
override is therefore repeated at the end of the file, outside any media query,
matching how the rule it overrides is written.

Thanks to xet7 !
2026-07-24 06:01:06 +03:00
Lauri Ojansivu b4ff3ab9ce Changelog Upcoming: document the shorter equal-height mobile board tiles.
Thanks to xet7 !
2026-07-24 05:58:16 +03:00
Lauri Ojansivu c229285b5e Shorter, equal-height board and "+ Add board" tiles on mobile.
On a phone a board tile was 8rem (128px) while the grey "+ Add Board" /
"+ Add Template Container" tile had no mobile height at all: it fell back to the
desktop `min-height: 72px` plus 42px of padding and then grew further around its
wrapped label. So the two were never the same height, and both wasted vertical
space on a screen that has little of it.

Both are now a flat 4rem (64px) with `box-sizing: border-box`, so the number IS
the rendered height rather than a floor that padding is then added to. The board
tile's 24px/18px vertical padding is cut to 6px, which is what made it tall; the
add tile centres its label with flex, so it only needs enough padding not to
touch the edges.

One rule covers every tab of All Boards - Starred, Templates, Remaining and
Workspaces all render the same `.board-list` tiles, and the add tile is itself an
`a.board-list-item.label`, with `.is-template-container` for the Templates tab.

NOTE on the request: "+ Add board at 1/2 and a board icon at 1/4 of current"
cannot also make them equal, since they start at different heights (128px vs
~114px declared, more as rendered). Equal height is the part that is
unambiguous, so both are set to 4rem - exactly half the board tile's previous
height. Say the word if they should be shorter still (2rem = 32px fits one line
of title).

Thanks to xet7 !
2026-07-24 05:58:05 +03:00
Lauri Ojansivu d175e6fa95 Changelog Upcoming: note the avatar row and username fixes.
Thanks to xet7 !
2026-07-24 05:52:56 +03:00
Lauri Ojansivu fff4951274 Keep the avatar on the first row: stop forcing the bar to wrap, and hide the username.
The avatar dropped to a second line even with space left beside the bell, because
a width-based rule forced `flex-wrap: wrap !important` on the quick-access bar -
added deliberately, with the comment "so ~7 items fit in two wrapped rows". An
!important wrap beats any nowrap, so the earlier one-row work never took effect
at small width; and a wrapping flex row breaks BEFORE the last item rather than
shrinking anything, which is why the avatar went down while empty space stayed
behind on row one.

Every flex-wrap rule on that bar is now nowrap (verified by listing all eight),
and the items are made to fit rather than the row made to grow. `overflow:
visible` is kept, so a long translation or a custom logo spills instead of
disappearing.

The username/full name beside the avatar is also hidden at small width, as
reported. The template already hides it `unless isMiniScreen`, but that is a
JS/Session decision - Utils.isMiniScreen() also weighs mobile mode and the device
- so it can be true while the bar is narrow, and a full name is easily 100px+ of
a 375px bar. Same bare-text-node situation as the home link: font-size:0 on the
link collapses the name, and the avatar keeps its own size (with a font-size
restored on the avatar wrapper so initials avatars, which have no size of their
own, do not vanish with it). The name stays in the DOM for screen readers.

Measured budget for an iPhone 12 mini is now 328px of 375px.

Thanks to xet7 !
2026-07-24 05:52:44 +03:00
Lauri Ojansivu d45522ae47 Changelog Upcoming: note the All Boards label and bell spacing fixes.
Thanks to xet7 !
2026-07-24 05:47:00 +03:00
Lauri Ojansivu 76516a1a29 Actually hide "All Boards" on small screens, and trim the bell's margins.
The label was still visible on iPhone and Fairphone 4. The rule that was supposed
to hide it (added in the one-row commit) carried no !important, and ELEVEN other
rules in header.css set a font-size on that same link - most of them !important,
including three scoped to the All Boards page itself via `body:not(.board-view)`
/ `.wrapper ~`, and one setting `16px !important; /* Much larger text */`. Every
one of them beat it, so the label kept showing on exactly the page it was
reported on.

The fix is a block at the END of header.css that uses !important and repeats each
competing selector shape (plain, `.wrapper ~`, `body:not(.board-view)`, and the
three `.iphone-device` variants), so where specificity ties, source order decides
in favour of hiding. Enumerating every font-size rule that touches
`.home-icon a` confirms this one is last: the competing !important rules end at
char 36958 and this one is at 47542.

Keyed on WIDTH (max-width: 800px), which is what was asked for - all smartphones,
small width. The viewport meta is `width=device-width`, so an iPhone 12 mini
reports 375px and a Fairphone 4 about 360px; both match. The separate
`body.mobile-mode` rule still covers the explicit phone/desktop toggle at any
width.

Also trims the notification bell, which sat in 36px of margin around a 28px icon:
10px on each side of the button (notifications.css) plus 0.5rem on each side of
its `#notifications` wrapper from the mobile blocks. More empty space than icon,
and on a 375px bar it is space the other buttons need. Both layers now give 2px a
side, which takes the measured one-row budget from 342px to 320px of 375px.

Thanks to xet7 !
2026-07-24 05:46:48 +03:00
Lauri Ojansivu a85abdf17c Changelog Upcoming: note the mobile-mode 'All Boards' label fix.
Thanks to xet7 !
2026-07-24 05:32:17 +03:00
Lauri Ojansivu b9f178ef55 Hide the "All Boards" label next to the home icon in mobile mode.
The previous commit hid it on a narrow VIEWPORT (a media query) and on
`.iphone-device`. Mobile MODE is a different thing: `body.mobile-mode` is set by
Utils.applyMobileMode() from the phone/desktop toggle in this same bar, it is a
deliberate user choice, and it applies at any width - so on a wide screen in
mobile mode the label was still there. That is what was asked for: mobile mode,
not a viewport width.

`body.mobile-mode #header-quick-access .home-icon a` (1,2,2) beats the base
`#header-quick-access .home-icon a` (1,1,1), so no !important is needed for this
path; the `.iphone-device` path already has its own override, appended after the
two competing `font-size: 1em !important` rules so the later one wins.

The label is a bare text node inside the link, not a span, so it cannot be
selected on its own: font-size:0 on the link collapses it and the icon's own
font-size brings the icon back. The text stays in the DOM, so screen readers and
the accessibility tree still announce "All Boards".

Thanks to xet7 !
2026-07-24 05:32:07 +03:00
Lauri Ojansivu ae679b23b8 Changelog Upcoming: document the one-row mobile top bar.
Thanks to xet7 !
2026-07-24 05:28:51 +03:00
Lauri Ojansivu 21be1e57ba Fit the quick-access top bar on one row on phones.
On an iPhone 12 mini (375px) the bar wrapped to two rows: home + logo +
mobile/desktop toggle on the first, drag-handle toggle + zoom + notifications +
user menu on the second. The mobile CSS chose that on purpose - the comment said
a nowrap row overflowed and `overflow: hidden` clipped the right-hand items - but
it treated the symptom. The row did not fit because of what was IN it:

  * the home link kept its full "All Boards" label, the single widest item in the
    bar and longer still in most languages ("Kaikki taulut");
  * the logo was allowed 120px of a 375px bar;
  * TWO items had `margin-inline-start: auto` (zoom and the user bar). An auto
    margin absorbs all remaining space on its line, so the first one filled row 1
    and pushed everything after it onto row 2 - that alone forced the wrap;
  * items carried 8px gaps and, on the `.iphone-device` path, 0.5rem margins on
    BOTH sides: ~80px of margins across five items.

The row now fits instead of wrapping: the home link is icon-only, the logo caps
at 84x20, there is exactly ONE auto margin (on the user bar, so it still sits at
the end when there is room), gaps are 6px and per-item margins 3px. Measured
against the declared sizes that is 342px of 375px, leaving ~33px of headroom.

The home LABEL is a bare text node, not a span, so it cannot be selected
directly: `font-size: 0` on the link collapses it and the icon's own font-size
brings the icon back. The text stays in the DOM, so screen readers still announce
it.

Both paths had to change together. `.iphone-device` is added by boardBody.js, so
it is present on a board page and ABSENT on All Boards (the screen in the report),
and its rules set font-size and per-item margins with !important - without the
matching override the bar would have stayed two rows on a board page and changed
height when navigating between the two screens.

Thanks to xet7 !
2026-07-24 05:28:37 +03:00
Lauri Ojansivu fcb29f773b Changelog Upcoming: correct the dev-server entry for the public-ROOT_URL fix.
Thanks to xet7 !
2026-07-24 05:18:02 +03:00
Lauri Ojansivu a3dfc2b194 rebuild-wekan.sh: do not append the local port to a public ROOT_URL.
The dev-server option added in the previous commit stripped the scheme off the
answer and always appended the listen port, so "https://wekan.example.com" became
"http://wekan.example.com:4000". Behind a reverse proxy that is wrong twice over:
Caddy serves https://wekan.example.com on 443 and forwards to localhost:4000, so
the public URL has no port and is https, and nothing is listening on
wekan.example.com:4000 at all. Meteor builds absolute URLs from ROOT_URL, so
every e-mail link, OAuth redirect and attachment URL would have pointed there.

The listen port and ROOT_URL are now treated as the different things they are.
The port is appended ONLY when the dev server is browsed directly:

  <empty>                    -> http://localhost:PORT          (port appended)
  wekan                      -> http://wekan.localhost:PORT    (port appended)
  https://wekan.example.com  -> used EXACTLY as given           (no port, https
                                kept, an explicit port kept if there is one)
  wekan.example.com          -> https://wekan.example.com       (no port; a
                                dotted name is assumed to be proxied)

A trailing slash is stripped so built URLs do not double up, and the prompt now
says which answers are local and which are public. WEKAN_DEV_ROOT_URL is the
non-interactive form (WEKAN_DEV_HOST still works).

Verified by sourcing ask_dev_url for all eight cases, including the reported one:
port 4000 + https://wekan.example.com now yields exactly
ROOT_URL=https://wekan.example.com with Meteor listening on 4000.

Thanks to xet7 !
2026-07-24 05:17:47 +03:00
Lauri Ojansivu 9749374aea Changelog Upcoming: document the drag-handle fixes and the dev-server port/host option.
Thanks to xet7 !
2026-07-24 05:09:30 +03:00
Lauri Ojansivu 523122b957 rebuild-wekan.sh: ask for the dev-server port and ROOT_URL host.
The Dev server menu could only run on localhost:3000, on the current IP, or on a
custom IP:PORT. There was no way to run on a different local port with a
different ROOT_URL host without editing the script.

The new "CUSTOM PORT + SUBDOMAIN" entry asks for both and uses them. ROOT_URL is
not cosmetic: Meteor builds absolute URLs from it (e-mail links, OAuth redirects,
attachment URLs), so a subdomain setup has to be told about it or those links
point back at localhost.

The host answer takes either form:
  <empty>            -> localhost
  wekan              -> wekan.localhost   (a BARE label becomes a subdomain of
                        localhost; browsers and systemd-resolved resolve
                        *.localhost to 127.0.0.1 with no /etc/hosts entry)
  wekan.example.com  -> used AS-IS, with a note that it must resolve here

A pasted URL is reduced to its host (scheme, path and any port stripped), a
non-numeric or out-of-range port falls back to 3000 with a message, the chosen
port is freed first with kill_meteor_on_port like the other dev options, and
WEKAN_DEV_PORT / WEKAN_DEV_HOST skip the matching prompt so the option can be
scripted.

Verified by sourcing ask_dev_url against fake input for all seven cases above.

Adds tests/rebuildWekanDevServer.test.cjs as a source guard for this and for the
inotify watch-limit check added earlier in the same script - that it runs before
the menu, never exits, is overridable, and no-ops on macOS/BSD.

Thanks to xet7 !
2026-07-24 05:09:11 +03:00
Lauri Ojansivu 5a1a661e01 Make the drag-handle toggle work on touch screens, and free the card body for panning.
"Show desktop drag handles" did nothing on a touch screen. The rule was

  isTouchScreenOrShowDesktopDragHandles() = isTouchScreen() || preference

so on touch the OR was already true and the toggle could never hide the handles.
Every place that decides whether to render a handle - minicard, list header,
swimlane header - and every sortable handle selector goes through that one
helper, so the setting was inert on exactly the devices that show handles.

The setting is now THREE states, not two: on, off, and never-chosen. An explicit
choice always wins (including OFF on a touch screen); only when the user has
never chosen does the device decide, and then a touch screen still gets handles
by default, because a finger needs something to grab. The rule lives in the pure
models/lib/dragHandles.js so it can be unit-tested, and the toggle now stores a
real `false` instead of removing the setting - that is what lets "turned off" be
told apart from "never chosen". toggleDesktopDragHandles takes the explicit new
state from the client (which alone knows whether this is a touch device) and
still accepts no argument for older clients.

Handles also decide what a DRAG MEANS, and that half was broken too: `.minicard`
and `.list-header` carried `nodragscroll` unconditionally, so with handles ON a
finger dragging across a card neither moved the card (only the handle does) nor
panned the board (dragscroll was opted out) - it did nothing. They now opt out
only when handles are OFF, i.e. only when the element IS the drag source. With
handles on, the card body pans the board, which is the whole point of having
handles.

And the handle has to be reachable with a finger: it was a 20-28px icon in the
bottom-right corner, a mouse-sized target that is close to unusable on a
wall-mounted infoscreen. On a coarse pointer it becomes a 48px full-height strip
down the leading edge (the Material minimum, and the easiest edge to thumb), with
the card content padded to match. A mouse keeps the compact corner handle, since
`pointer: coarse` does not match a mouse.

Tab order: the keyboard card/list move buttons are focusable but `opacity: 0` and
sit at the START of the card content, so tabbing went card (visible focus) ->
invisible -> invisible -> next card and the focus ring kept vanishing and
reappearing where it looked like it had gone backwards. They are now revealed
while focused, the same pattern the skip-to-content link already uses.

Adds tests/dragHandles.test.cjs: the three states, that an explicit OFF hides
handles on touch (the reported bug), that an explicit choice always beats the
device default, and that a corrupt stored value falls back to the device default
rather than leaving a finger unable to drag.

Thanks to xet7 !
2026-07-24 04:56:39 +03:00
Lauri Ojansivu 2af5d8fd93 Changelog Upcoming: document the inotify watch-limit fixes.
Thanks to xet7 !
2026-07-24 04:35:46 +03:00
Lauri Ojansivu 8ed253709d rebuild-wekan.sh: raise the inotify watch limit when it is too low.
Meteor's file watcher takes ONE inotify watch per DIRECTORY, and the limit
(fs.inotify.max_user_watches) is per USER, shared with every other watcher that
user runs - an editor is usually the other big consumer. On a stock kernel it is
8192 or 65536, which a WeKan checkout can reach on its own. When it runs out,
Meteor fails with a message that blames the disk and sends people looking in the
wrong place:

  Failed to start watcher for <repo>: [Error: inotify_add_watch on '<path>'
  failed: No space left on device]

ENOSPC from inotify_add_watch means the WATCH LIMIT is exhausted, not that the
disk is full.

ensure_inotify_watches() now runs on every invocation, before the menu. If the
limit is below 524288 (the value VS Code documents for the same problem) it
raises it with sysctl for this boot and persists it to
/etc/sysctl.d/60-wekan-inotify.conf so the next boot does not go back to failing
builds. It also raises fs.inotify.max_user_instances from the low stock 128 to
1024, since each watcher process needs an instance and editors + Meteor +
chokidar add up. Each watch costs ~1 KB of kernel memory only while in use, so a
high ceiling is a limit, not a reservation.

It never aborts the script and never fails a build:
  * already high enough        -> silent no-op, the conf file is not touched
  * root, or passwordless sudo -> applied without prompting
  * sudo with a TTY            -> prompts once
  * no root and no usable sudo -> prints the exact two commands to run by hand
  * WEKAN_INOTIFY_WATCHES=0    -> skipped entirely (any value overrides 524288)
  * macOS/BSD                  -> returns immediately, there is no inotify there

Verified against a fake /proc + fake sysctl/sudo for all six paths above.

Thanks to xet7 !
2026-07-24 04:35:32 +03:00
Lauri Ojansivu b5e3c89043 .meteorignore: stop watching .tools/ and FerretDB/ (inotify watch exhaustion).
`meteor` failed with

  Failed to start watcher for /home/user/repos/wekan: [Error: inotify_add_watch
  on '.../.tools/node-v24.18.0-linux-arm64/include/node/openssl/archs/
  BSD-x86_64/asm_avx2' failed: No space left on device]

ENOSPC from inotify_add_watch means the per-user inotify WATCH LIMIT is
exhausted, not that the disk is full - the disk is 64% used with 157 GB free.

Meteor's watcher takes one inotify watch per DIRECTORY, and this repo had 41,040
of them against a fs.inotify.max_user_watches of 65536 that is shared with every
other watcher the same user runs (editors included). Two trees that are not app
code accounted for 72% of that:

  .tools/     20,535 dirs, 4.1 GB - a full Node 24 install (headers for every
              openssl arch), a Go toolchain with its module/build caches, build
              logs and repro dirs
  FerretDB/    9,148 dirs, 3.4 GB - the FerretDB v1 fork checkout, a complete Go
              project plus its docs

Both are already in .gitignore, but the watcher only honours .meteorignore, so
Meteor kept walking and watching them. Nothing in WeKan imports from either (the
FerretDB binary is downloaded from its GitHub releases at build time), so there
is no reason to watch or scan them.

Excluding both takes the watched directory count from 41,040 to 11,213, which
also leaves headroom for the editor's own watches.

Thanks to xet7 !
2026-07-24 04:22:15 +03:00
Lauri Ojansivu ef5af6159b Changelog Upcoming: document the cpu-exec wiring for Sandstorm and the snap.
Thanks to xet7 !
2026-07-24 03:56:01 +03:00
Lauri Ojansivu 51116657cf Route every bundled binary through cpu-exec on Sandstorm and in the snap (#6458).
cpu-exec exists so that a binary needing an instruction-set feature the CPU does
not have (AVX masked by a hypervisor - QEMU/KVM/Proxmox, the reported #6458
environment - an old CPU, or ARMv8.0 on a Raspberry Pi) runs through bundled
qemu-user instead of dying instantly with SIGILL. With no features declared it is
a plain exec, so it is safe and free to route EVERY binary through it - which is
why the design is "route everything".

Two platforms were not doing that:

* Sandstorm SHIPPED cpu-exec and qemu-x86_64 (sandstorm-src/build-deps.sh) but
  the grain launcher spawned all eight bundled binaries directly - ferretdb,
  mongod 3.0 (twice), niscud, the legacy mongo CLI (twice) and node for the
  bridge and the importer. So the safety net was in the package and unreachable,
  on the platform with the LEAST control over its hardware: a grain runs on
  whatever CPU the Sandstorm host has.

* The snap routed mongod (mongodb-control, migration-control) but not FerretDB
  (ferretdb-control, both the external-DB and SQLite launches) and not node
  (wekan-control: the main application start and both maintenance-page starts).

Both now route through cpu-exec, each with a direct-exec fallback so an older
deps image or snap revision still works. On Sandstorm the guard also requires
/bin/bash, since cpu-exec is a bash script; without it the binary runs directly
exactly as before. The snap's main start keeps its `ulimit -s 65500` - the limit
is set before the exec and inherited across it (verified by expanding the line).

Docker (releases/ferretdb/wekan-entrypoint.sh) and the bundle launcher
(releases/ferretdb/start-wekan.sh) already routed ferretdb and node, and were
checked for other unrouted launches - there are none.

tests/cpuExecWiring.test.cjs previously asserted only that the Sandstorm build
SHIPS cpu-exec, which is exactly how the gap survived: it now asserts that the
grain launcher routes each of the six bundled binaries, that no bundled binary is
spawned directly any more (/bin/sleep is a grain utility, not bundled, so it is
exempt), and that the snap routes FerretDB and node with their fallbacks intact.

Thanks to xet7 !
2026-07-24 03:55:46 +03:00
Lauri Ojansivu aa356d715d Run the five test suites that existed but were never executed, and delete dead files.
Five mocha suites sat in the tree looking like they were being run, and were not:
`meteor.testModule` points at client/lib/tests/index.js and
server/lib/tests/index.js, those indexes list their suites with explicit imports,
and these five were never added. server/lib/tests had 49 suite files but 48
imports.

Two of them are *bleed security regression suites - checklistbleed (cross-board
checklist/checklist-item move denial) and proxybleed (header-login trusted-source
and request-IP handling) - exactly the kind of test whose silent absence lets a
fixed vulnerability come back unnoticed. The other three are filenameTruncation,
alwaysShowCodeAsText and renderLinksPlainText.

All five are now imported by their index. Every symbol they import was checked to
still exist first: denyCrossBoardMoveByCard / denyCrossBoardMoveByChecklistItem
(server/lib/utils.js), getRequestIp / isTrustedHeaderLoginSource /
findOrCreateHeaderLoginUser (server/lib/headerLoginAuth.js),
truncateFilenameToBytes / sanitizeFilename (models/lib/filenameSanitizer.js),
getSecureDOMPurifyConfig / sanitizeHTML (imports/lib/secureDOMPurify.js) and the
Markdown export of the wekan-markdown package. They have never run in this state,
so they may still fail on their assertions - that is the point of running them.

tests/testsAreRegistered.test.cjs is added as a guard: it fails if any
*.tests.js file in either directory is not imported by its index, and names the
two *bleed suites explicitly so a future tidy-up cannot drop them again quietly.

Also deletes files that nothing loads:
- client/components/main/responsiveFixes.css (162 lines, imported by nothing, so
  never in the bundle - package.json sets meteor.mainModule, which disables
  Meteor's eager loading).
- models/backgrounds.js, models/backgrounds.server.js and
  server/permissions/backgrounds.js - three empty tombstones whose only content
  was a comment saying board backgrounds became board-level Attachments and the
  file is "intentionally empty and no longer imported". The live paths
  (server/boardBackgrounds.js, server/publications/backgrounds.js) are different
  files and are untouched.

Thanks to xet7 !
2026-07-24 03:45:06 +03:00
Lauri Ojansivu 54cdc15af0 Changelog Upcoming: merge the duplicate subsections into one per verb.
The section had grown two 'and fixes the following bugs:' headers and both a
'developer-tooling fixes' and a 'developer-tooling addition' header, because each
new entry was added with its own heading. The subsection headers are meant to
read as ONE flowing sentence with each verb appearing once, so the nine bullets
are now grouped under: adds new features, fixes bugs, removes dead code, has
developer-tooling changes. No bullet was changed and all nine commit links are
still present.

Thanks to xet7 !
2026-07-24 03:30:55 +03:00
Lauri Ojansivu fb9480b67a Changelog Upcoming: document the release-all.sh commit-link repair.
Thanks to xet7 !
2026-07-24 03:29:39 +03:00
Lauri Ojansivu 2f3c2d49a6 release-all.sh: repoint stale CHANGELOG commit links before releasing.
Every CHANGELOG bullet links the commit it describes, and those links are written
BEFORE the release. Anything that rewrites history in between - a rebase onto an
upstream change, an amend, a squash - changes the hashes, so every link in the
not-yet-released section points at a commit that no longer exists and 404s once
pushed. That just happened to all eight links of the current Upcoming section.

release-all.sh now checks them as its first local step, before the Upcoming
heading is renamed and before the release commit is made, so the repair is part
of that commit:

- a hash that is still an ancestor of HEAD is left alone;
- a stale one is resolved by reading the OLD commit's subject (the pre-rewrite
  object is still in the clone, reachable from the reflog) and finding the commit
  on this branch with exactly that subject - the rewritten copy of the same
  commit - then repointing the link to it;
- the substitution is bounded to the section being released, so the same hash
  quoted in an older, already-pushed entry is never touched;
- it works whether or not the "# Upcoming" heading has already been renamed,
  falling back to the newest release heading;
- anything it cannot resolve is reported as a WARNING and left as-is. A broken
  doc link is never a reason to abort a release, so the repair never exits
  non-zero.

Verified by running the function against fixture CHANGELOGs in this repo, using
the real pre-rebase hashes: two stale links were remapped to their rewritten
commits, an already-correct link was left alone, an unknown hash was reported and
kept, the identical stale hash in an already-released section was NOT rewritten,
and the already-renamed-heading path was exercised too. Adds
tests/changelogCommitLinks.test.cjs as a source guard for those properties.

Thanks to xet7 !
2026-07-24 03:29:28 +03:00
Lauri Ojansivu f82c3266a3 Changelog Upcoming: repoint the commit links at the post-rebase hashes.
The eight Upcoming entries still linked the pre-rebase commits, so every link
would have 404ed once pushed. Each hash was remapped by matching the commit
subject against the commits now on this branch.

Thanks to xet7 !
2026-07-24 03:25:02 +03:00
Lauri Ojansivu ca396ee4f8 Changelog Upcoming: document the missing check() argument fixes.
Thanks to xet7 !
2026-07-24 03:23:38 +03:00
Lauri Ojansivu 49e576b3c8 Add the missing check() calls that made methods throw "Did not check() all arguments".
This app runs with the audit-argument-checks package, so any Meteor method that
RECEIVES an argument it never check()s throws
"Did not check() all arguments during call to '<method>'" - the call fails
entirely, no matter what the method would have done.

problemDetailReport(area) had that defect but nothing had ever passed it an
argument, so it went unnoticed until the new Admin Panel / Problems / CPU usage
header started polling problemDetailReport('cpu') every 5 seconds and the server
log filled with the exception.

Auditing every server method for the same defect found five more that ARE called
with arguments from the client, so these features were failing outright:

- unlockUser(userId) - Admin Panel / People, unlocking a locked-out user
- runBackup(opts, storageName) - Admin Panel / Attachments, running a backup
- restoreBackup(zipPath, mode) - restoring a backup
- saveBackupSchedule(schedule) - saving the backup schedule
- migrateTextDatabase(direction) - the MongoDB <-> FerretDB text migration

and one with no current client caller, getServiceConfiguration(service), fixed
for the same reason.

Each now check()s its arguments against the types its call sites actually pass
(the two optional ones use Match.OneOf(String, null, undefined)), and the three
files that had no check import got one.

Thanks to xet7 !
2026-07-24 03:23:37 +03:00
Lauri Ojansivu 867552ee6b Changelog Upcoming: document removing the cron migration subsystem.
Thanks to xet7 !
2026-07-24 03:23:37 +03:00
Lauri Ojansivu a440d44ea4 Remove the cron migration subsystem: it never ran and had no UI.
Nothing scheduled it and nothing rendered it, but every logged-in client still
paid for it:

- initializeCronJobs() (server/cronMigrationManager.js) was NEVER called, so the
  SyncedCron.add() registrations and the 5s job-queue processor never started.
- boardMigrationDetector.start() sat behind a commented-out Meteor.startup hook,
  with the note "Automatic migration detector is disabled - migrations only run
  when opening boards".
- The Admin Panel had no Cron page at all: no route in config/router.js, no
  button in settingHeader.jade, no template ever included, and every helper,
  event and reactive var for it commented out in settingBody.js.
- Yet imports/cronMigrationClient.js WAS imported by client/imports.js, so every
  client subscribed to three publications and polled cron.getMigrationProgress
  every 10 seconds, forever, for a feature with no user interface.

Migrations already run - and are already reported - through the paths that work:
Admin Panel -> Attachments (database migration) and the on-board-open repair,
both driven by the shared migrationProgress overlay dashboard mounted app-wide in
defaultLayout, plus the in-progress list on Admin Panel -> Problems -> Summary.

Removed: cronSettings.jade/.css, imports/cronMigrationClient.js,
models/cronJobStatus.js, server/cronMigrationManager.js (28 admin-gated cron.*
methods that no UI called), server/cronJobStorage.js,
server/boardMigrationDetector.js and the three publications that fed only the
deleted client module (cronJobs, cronMigrationStatus, migrationProgress).

KEPT on purpose: server/cron/syncedCron.js, which is SyncedCron itself and is
used by the backup schedule (server/methods/backup.js) and scheduled rules
(server/scheduledRules.js).

getInProgress() in server/lib/systemStatus.js no longer reads cronJobStatus.
Leaving that check would have been worse than useless: a leftover
status:'running' doc from an older version now has nothing to clear it, so
Admin Panel -> Problems would report "Migration job X running" forever.

The 40 i18n keys that only the deleted template used are left in place - dropping
them from every language file would discard human translations for keys that may
be reused.

Thanks to xet7 !
2026-07-24 03:23:37 +03:00
Lauri Ojansivu 6b35249cd7 Changelog Upcoming: document the #6519 map-to-existing-user search fix.
Thanks to xet7 !
2026-07-24 03:23:37 +03:00
Lauri Ojansivu 642fa8e93f "Map to existing user" now searches every user, not only board members.
After importing a Trello board and choosing to map users later, "Map to existing
user" listed only the admin: the picker offered nothing but the ACTIVE REAL
MEMBERS of the board, and right after an import the real people have no
membership at all - the placeholders do. There was no search box either, so on a
board whose only real member is the importing admin there was literally nobody to
pick.

The picker now has a search box that goes through the same permission-checked
`searchUsers` method the normal add-member typeahead uses, so the same board
membership check and the same #6116 same-org/team restriction apply. With the box
empty it still lists the board's real members (the common case, which changes no
roles at all); typing searches every real user and labels the ones who are not on
this board yet.

Mapping onto a user who is not a board member adds them to the board with the
PLACEHOLDER's OWN role - the role the import recorded for that person - never a
higher one, and only after passing exactly the checks inviting them would: the
Admin Panel "roles allowed to invite" setting, the same-org/team restriction, and
the refusal to add a deactivated account. Adding a member with a chosen role is
something the board admin doing the mapping can already do through the normal
add-member flow, so this grants no capability the caller did not have; an
existing active member is still left completely untouched, entry and role.

Also fixes sanitizeUserForSearch dropping `profile` from every search result: it
read the Mongo-style paths in its allowlist ('profile.fullname') as FLAT keys, so
the lookup was always undefined and both this picker and the add-member typeahead
showed a blank name with only the username in brackets. Dotted paths are now
copied into the same nested shape, still allowlisting exactly the listed leaves.

Adds positive and negative unit tests for the new planner contract, including
that a comment-only placeholder can never yield an admin, that reactivating an
inactive membership does not duplicate the entry, and that the invite policy runs
before any write.

Thanks to AmigaAbattoir and xet7 !

Fixes #6519,
2026-07-24 03:23:37 +03:00
Lauri Ojansivu ed4361433b Changelog Upcoming: document the Admin Panel Problems fixes and the gh security report script.
Thanks to xet7 !
2026-07-24 03:23:37 +03:00
Lauri Ojansivu f04c484518 Show the current CPU usage on Admin Panel / Problems / CPU usage.
The page listed only PAST high-CPU periods (the event rows written by the CPU
monitor), so it could not answer "what is the CPU doing right now?" at all - the
one question an admin opens that page to ask.

A live line now sits between the page title and the search box: the current
system CPU percentage, the core count, the 1/5/15 minute load average and the
coarse "what WeKan is doing" label, refreshed every 5 seconds and outlined in red
while the monitor considers the CPU sustainedly high.

The figure comes from getCurrentCpu() in the CPU monitor, which reads the same
sample the monitor writes its event rows from, so the header and the rows below
it can never disagree. It reads module state only - no sampling of its own - so
polling it is cheap. When the background monitor is disabled
(WEKAN_CPU_MONITOR=false) it would otherwise always read 0%, so in that case the
percentage is measured between calls using a SEPARATE baseline, which cannot
disturb the monitor's own delta, and `monitored: false` is reported alongside.

Thanks to xet7 !
2026-07-24 03:23:37 +03:00
Lauri Ojansivu d168a9d302 Add a Repair button for "Broken cards N" in Admin Panel / Problems / Summary.
The Summary page reported "Broken cards N" and told the admin to "open the board
to auto-repair, or run the repair migration", but there was no button anywhere to
run one - and worse, no repair in the app could actually clear that count.

countBrokenCards() counts every card missing a boardId OR a swimlaneId OR a
listId. repairAllBoards() walks only NON-ARCHIVED boards and only ever sets
swimlaneId, so three kinds of counted card could never be repaired by anything:
cards on an archived board, cards with a missing listId, and cards with no
boardId at all. The count sat there forever with nothing to press.

The new repairBrokenCards method (admin only) runs the standard per-board pass
and then closes those gaps: orphaned-swimlane cards on archived boards, and cards
missing a listId and/or swimlaneId on any board, are assigned to that board's
first usable list/swimlane (creating a default one if the board has none). Each
board resolves its default list/swimlane once rather than once per card. Cards
with no boardId cannot be placed on any board - guessing would drop a user's card
onto an unrelated board - so they are reported back as `unfixable` and left
alone, never deleted, and the result line says so instead of leaving a count that
never reaches zero unexplained.

What counts as broken now lives in one pure module, models/lib/brokenCardsRepair.js,
used by BOTH the count and the repair, so they can no longer drift apart and
leave the admin pressing a button that does not move the number. Progress is
persisted through setBoardRepairStatus, so a running repair shows up in the same
"in progress" list as the startup pass.

Adds unit tests (positive + negative) for the shared rule, including the
regression itself: every card the count reports as broken must be either
repairable or explicitly unfixable, never silently neither.

Thanks to xet7 !
2026-07-24 03:23:37 +03:00
Lauri Ojansivu 668802cd7f Speed up Admin Panel / Problems / Cards report loading and prev/next paging.
The report already paginates server-side (25 rows per page via limit/skip), so it
never loaded all cards into the browser, but two things made it feel like it did:

1. The cardsReport publication sent WHOLE card documents - description,
   customFields, vote/poker sub-documents, every date field - when the table
   renders only six columns. A 25-row page could be hundreds of kilobytes on
   boards with long descriptions. It now projects title, boardId, listId,
   swimlaneId, members and assignees only.

2. Every prev/next click re-ran the report's count method as well as
   re-subscribing. getCardsReportCount counts EVERY card in the database, and the
   total cannot change just because you moved to the next page, so each click
   paid for a full collection count plus a second server round trip. loadReport()
   now takes a `recount` flag: opening a report and searching in it recount,
   plain paging does not.

Both changes apply to the other paginated admin reports too (files, rules,
boards, impersonation, recovery), which share loadReport().

Thanks to xet7 !
2026-07-24 03:23:37 +03:00
Lauri Ojansivu 1fb32b226f Admin Panel report styles were never loaded, so pagination buttons rendered black.
client/components/settings/adminReports.css was not imported anywhere, and
package.json sets meteor.mainModule, which disables Meteor's eager loading - so a
CSS file that nothing imports is simply not in the bundle. None of the Admin
Panel report styling was applied, and the prev/next buttons fell through to the
global rule in forms.css:

  button { background: var(--theme-accent, #000) }

which is BLACK when no custom theme colour is set. Import it from
client/features/settings.js next to the other settings stylesheets.

Also add client/components/main/paginationControls.css: one themed look for
EVERY prev/next control in WeKan (admin reports, people/org/team/domain, All
Boards, board Table view, cron tables). forms.css also styles button:focus
(var(--theme-accent, #222)) and button:active (#111 / #e6e6e6) at a specificity
equal to or higher than a plain `.some-pagination button` rule, so even a loaded
stylesheet lost as soon as the button was clicked: pressing "next" left it with a
dark grey or grey background until it lost focus. Every state is now spelled out
at a specificity that beats the global rules, all colours come from
--theme-accent with WeKan blue as the fallback, disabled is the same themed
outline faded rather than a grey block, and the "3 / 42" page info inherits the
surrounding text colour instead of a hard-coded #333 that vanishes on dark
backgrounds.

Thanks to xet7 !
2026-07-24 03:23:37 +03:00
Lauri Ojansivu f793c4bac1 Add docs/Security/gh script that downloads all GitHub security and code quality reports.
pull-security-reports.sh saves every report GitHub exposes for a repo - open AND
closed - into a timestamped run directory with one subdirectory per category of
the GitHub Security tab (CodeQuality/StandardFindings, CodeQuality/AIFindings,
CodeScanning, Dependabot/Vulnerabilities, Dependabot/Malware, SecretScanning,
Advisories, DependencyGraph), each split into open/ and closed/, as raw JSON plus
readable per-rule / per-file / per-severity text summaries.

Each category is fetched ONCE with no state filter and split locally with jq, so
open + closed are a partition of one snapshot instead of two requests taken at
different moments. A failing endpoint (feature disabled, missing token scope) is
recorded in 00-summary.txt and skipped rather than aborting the run.

Two safety properties: the secret-scanning API returns the live credential in
.secret, which is deleted before anything is written, and the reports root gets a
.gitignore excluding everything - these files describe unfixed vulnerabilities in
a public repo.

Needed because the VSCode Flatpak sandbox has neither gh nor network access to
api.github.com, so the reports have to be pulled on the host and read from disk.

Thanks to xet7 !
2026-07-24 03:23:37 +03:00
github-actions[bot] de1819ff53 Bump versions for v10.33 2026-07-23 19:25:30 +00:00
Lauri Ojansivu 1eeb392a67 Prepare v10.33 release 2026-07-23 22:24:35 +03:00
Lauri Ojansivu 366c475c64 Changelog Upcoming: document the swimlane-scoped list card count fix.
Thanks to xet7 !
2026-07-23 22:08:43 +03:00
Lauri Ojansivu 99e04ec98a List header card count: scope to the rendered swimlane, not the list's own swimlaneId.
A SHARED list (empty swimlaneId) renders under every swimlane. cardsCount()
scoped the count by `list.swimlaneId`, which is '' for a shared list, so every
swimlane showed the whole-list count while the cards rendered below were
swimlane-scoped — e.g. "5 Cards" over an empty second swimlane. The card body
already scopes by the container swimlane via listBody.jade `idOrNull ../../_id`;
cardsCount() now takes that same `../../_id` (passed from listHeader.jade) and
prefers it over the list's own binding, so the header count matches the cards
actually rendered in that swimlane. Still gated to swimlanes view (lists view
keeps the whole-list count), and both list.cards(id) and lazyListCardCount(id)
already scope by the passed swimlane id (incl. first-swimlane shared/orphaned
surfacing), so the count is correct in both eager and lazy (#6480) modes.

tests/cardsCountSwimlaneScope.test.cjs guards the wiring; the per-swimlane card
set is covered by server/lib/tests/selectAllSwimlane.tests.js.

Thanks to xet7 !
2026-07-23 22:08:24 +03:00
Lauri Ojansivu 1cb8da8adc Changelog Upcoming: document the dragscroll re-attach swimlane fix.
Thanks to xet7 !
2026-07-23 21:33:28 +03:00
Lauri Ojansivu 98b160c36c Re-attach mouse dragscroll when swimlanes/lists render or the board switches.
@wekanteam/dragscroll attaches its mouse handlers PER element inside reset(),
so reset() must be re-run after the set of .dragscroll containers changes. In
boardBody.js reset() ran in onRendered (once) and in one autorun whose only
reactive deps were the touch/permission reads, NOT the swimlanes/lists. So
when swimlanes/lists rendered after onRendered — adaptive lazy card loading
(#6480), the on-open data-repair adding a default swimlane (#6484), or a board
switch that reuses this template instance without re-firing onRendered (the
#4978 case) — the new containers never got mouse handlers, so mouse/touchpad
drag-scrolling was dead and lower swimlanes could not be reached by dragging
(touch still worked via the document-delegated dragscrollTouch). The reset
autorun now reads the board view + swimlanes + lists, so it re-runs and
re-attaches whenever the rendered containers change. The reads perform no DB
writes, so there is no autorun write loop.

tests/dragscrollReattach.test.cjs source-guards the reactive reads before
reset(). Runtime UI behaviour still needs confirmation in a browser.

Thanks to xet7 !
2026-07-23 21:32:47 +03:00
Lauri Ojansivu 6c0191c07d Updated ChangeLog. 2026-07-23 21:16:38 +03:00
Lauri Ojansivu 72fe175545 [Added anupamme of OrbisAI Security](https://github.com/wekan/wekan/pull/6517) to [Hall of Shame: Fake AI](https://github.com/wekan/wekan/blob/main/docs/FAQ/Hall-of-Shame.md).
Part 2.

Thanks to anypamme !
2026-07-23 21:16:38 +03:00
Lauri Ojansivu dc9f3e9d3a Added anupamme of OrbisAI Security from https://github.com/wekan/wekan/pull/6517 to Hall of Shame: Fake AI.
Thanks to anypamme !
2026-07-23 21:16:38 +03:00
Lauri Ojansivu 4abbc621fb Changelog TODO Later: OpLog ts index + MariaDB assessment done; only live EXPLAIN remains.
The OpLog ts index is now created best-effort on postgresql/mysql/hana
(with a MariaDB generated-column fallback), and the MariaDB-vs-mysql-backend
assessment is complete. What remains is live-EXPLAIN index-usage
verification on each engine, which the sandbox cannot do.

Thanks to xet7 !
2026-07-23 21:16:38 +03:00
github-actions[bot] 4f0906691f Bump versions for v10.32 2026-07-23 17:23:51 +00:00
Lauri Ojansivu 32ae15e659 Prepare v10.32 release 2026-07-23 20:22:58 +03:00
Lauri Ojansivu 59f002d690 Changelog + CLAUDE.md: flowing subsection headers, and CRITICAL SECURITY ISSUES wording.
Later subsections in a release now start with a lowercase "and ...:" instead
of repeating "This release ...:", so a multi-subsection release reads as one
sentence (matching v10.21's existing style): Upcoming, v10.30, v10.29, v10.22,
v10.14 and v9.99. The v10.23 security header now says "CRITICAL SECURITY
ISSUES" (the wording previous releases use) instead of "CRITICAL
VULNERABILITIES". CLAUDE.md documents both conventions.

Thanks to xet7 !
2026-07-23 20:21:39 +03:00
Lauri Ojansivu 7d9e406c21 Changelog Upcoming: state human translations are always preferred in translation scripts.
Thanks to s2ldan and xet7 !
2026-07-23 20:13:09 +03:00
Lauri Ojansivu 317b8d67f9 Changelog TODO Later: defer filling untranslated strings (bug fixes first).
Records the remaining direct, no-service translation work (142 languages,
12254 strings) as deferred, since bug fixes are more important than
translations. The #6494 tooling that makes the fill safe is already committed.

Thanks to xet7 !
2026-07-23 20:10:43 +03:00
Lauri Ojansivu 612b03f9e1 Changelog Upcoming: document the #6494 no-service translation fix.
Thanks to s2ldan and xet7 !
2026-07-23 20:06:10 +03:00
Lauri Ojansivu 4146c53745 Translations: fill remaining strings directly, no service; keep human translations preferred.
Removes the machine-translation service (machine-translate.mjs) that called an
external endpoint (LibreTranslate/DeepL) and needed WEKAN_MT_URL / WEKAN_MT_API_KEY.
It did not work and could put a machine string where a human translation belonged.

Now the strings still untranslated everywhere (equal to the English source after the
Transifex pull + merge) are translated DIRECTLY — the maintainer/assistant writes the
translation using that language's existing translations and general kanban terminology
as the reference. No external service, API, endpoint, key or password.

- releases/translations/fill-translations.mjs (new): --missing lists the per-language
  count still untranslated; --list <lang> dumps the placeholder keys to translate;
  --apply <lang> file.json writes translations ONLY into placeholder keys, so it can
  never overwrite a human translation, and ignores any value still equal to English.
- releases/translations/pull-translations.sh: drop the WEKAN_MT machine-service hook;
  after the pull+merge it now prints what remains to translate. The pull always fetches
  the newest Transifex translations FIRST (tx pull -a -f) and merge-translations.mjs
  keeps/restores the human ones, so a fetch is never omitted and Transifex human
  translations are never overwritten (the two-way process #6494 asked for).
- releases/translations/verify-human-preference.mjs (new): pure-logic proof (10 cases)
  that the pull-merge keeps/restores human translations and a fill only touches
  placeholders. Filled strings stay local and are NOT pushed to Transifex.
- CLAUDE.md: document the no-service direct-fill workflow and the human-preference
  guarantee in both pull and push directions.

Thanks to s2ldan and xet7 !

Fixes #6494,
2026-07-23 20:04:43 +03:00
Lauri Ojansivu 5f4f3d910c Changelog Upcoming: document every post-v10.31 commit with correct hashes.
Add the missing entries so the Upcoming section details all real changes
since v10.31: the jam:offline/method/pub-sub removal, the six pre-existing
unit-test fixes, and the exact-version rspack pin (its own dependencies
entry). The #6511 ES-module fix now links all three of its commits
(collectionHelpers export {} e62c77575, and the two conversion parts
8d1fae4f7 + d6908cf41). Verified all commit links resolve and are reachable
from HEAD. Housekeeping commits (version bump, changelog edits, the
package-lock rspack re-resolution) get no bullet, per convention.
2026-07-23 19:51:25 +03:00
Lauri Ojansivu a3af94385a Update rspack. 2026-07-23 19:35:40 +03:00
Lauri Ojansivu b9873564a8 Remove unused jam:offline / jam:method / jam:pub-sub packages.
Once the client boots (after the ESM-conversion fix), jam:offline runs its
startup sync and throws
`can't access property "remove", localCollections[name] is undefined`
(jam_offline.js sync -> _keep): the server returns a collection name to
reconcile that the client never registered, because WeKan registers NO
offline collections (no .keep() anywhere) and does not use jam:method /
jam:pub-sub (no createMethod / createPublication / meteor/jam: imports).

These three packages were already removed once (0dd924f14: "not used, and
they cause too much database usage") and re-added in bf5ca3d3e. Remove them
again. The offline warning UI uses Meteor.status(), not jam:offline, so it
is unaffected. Meteor reconciles the transitive deps in .meteor/versions on
the next build.
2026-07-23 19:34:25 +03:00
Lauri Ojansivu 52325228f8 Fix 6 pre-existing unit-test failures (stale source-guards + a cleanup race).
These failed before the ESM conversion and are unrelated to it:

- addBoardTileHeight: the .path-right slice window was 1600 chars but
  js-board-search-input is at offset 2517 after the header grew; widen to 3000.
- listWidthDefaults: isValidListWidth(269) is TRUE (269 >= MIN 200, no upper
  bound); drop 269 from the "invalid widths" list.
- uiDensity: the js-open-board-menu cog moved to sidebar.jade; read that file.
- exporterBackpressure: the leaky-pattern guard matched the pattern quoted in
  an explanatory comment; strip // comments before matching.
- adminReportsPagination: a 6th report section was added; expect 6
  .admin-report-controls, not 5.
- fileTypeCorrection (real fix): streamHeaderToTemp unlinked the partial temp
  file fire-and-forget, racing the async write-stream open, so a caller could
  still see the file after the rejection. Remove it only after the write stream
  has closed, then reject.

Remaining known failure: rulesApiTriggerNormalize (#2674 $in-matching) — left
for a maintainer decision on the intended rule-normalization contract.
2026-07-23 19:16:24 +03:00
Lauri Ojansivu d5a1d9af1b Changelog: #6511/#6514 fixed by converting all client-reachable CJS helpers to ESM.
Update the entry to reflect the complete fix: not just collectionHelpers,
but a transitive conversion of every client-bundled module.exports helper
to export, plus the exact-version rspack pins.
2026-07-23 19:00:18 +03:00
Lauri Ojansivu d6908cf418 Convert client-reachable CJS helpers from CommonJS to ES modules (2/2).
A transitive walk from client/main.js found the remaining CommonJS
(module.exports) helpers that the rspack client build pulls into the ES
module graph and rejects: 3 client/lib helpers (cardDragGeometry,
inlinedFormManager, popupOffset), 4 imports/lib helpers (boardAutoScroll,
datePickerTime, fileNameDisplay, staleWhileRevalidate), imports/simpleSchema
(its meteor/aldeed:simple-schema require becomes a namespace import),
imports/i18n/loadHelpers, and 4 more models/lib helpers (cardLinkRedirect,
sandstormAutoOpen, securityCategories, securityLogFormat). All become
`export { ... }`; their .cjs unit tests use `await import(...js)`.

The only CJS files still reachable in the walk are server-only: the
server/lib/* files (Meteor never bundles server/ to the client) and the
fs-using models/lib file-storage helpers (reached only via the server-side
fileStoreStrategy; they would fail on require("fs") if they were actually in
the client bundle). Those correctly stay CommonJS.

Full tests/*.test.cjs suite: same 7 pre-existing failures, no new ones.
2026-07-23 18:59:30 +03:00
Lauri Ojansivu 8d1fae4f7d Convert client-reachable models/lib helpers from CommonJS to ES modules (1/2).
The rspack client build treats a `module.exports` file imported into the
client bundle as an ES module and throws
"ES Modules may not assign module.exports" at evaluation time, aborting
the whole client module chain (No such template: swimlane / notifications
/ boardButtons, blank board, missing top bar). This is not an rspack
version regression (the lockfile has pinned ~1.7.x since v8.40); it is
that WeKan added CommonJS helpers to the client bundle.

Convert the 42 client-reachable pure-logic models/lib helpers from
`module.exports = { ... }` to `export { ... }`, and calendarFilter's
internal require('./swimlaneFilter') to an import with a .js extension
(node ESM needs it; rspack/reify tolerate it). Server-only helpers that
use node built-ins (fs/path/child_process) stay CommonJS. The plain-Node
.cjs unit tests for these helpers now use `await import(...js)` (wrapped
in an async IIFE); a source-guard in importVirtualMembers was updated
from module.exports to export. Verified: the full tests/*.test.cjs suite
has the SAME 7 pre-existing failures as before this change and no new
ones.
2026-07-23 18:55:30 +03:00
Lauri Ojansivu eca452d674 package.json: pin @rspack/core / @rspack/cli / @meteorjs/rspack to exact versions.
Remove the caret ranges so a fresh npm install cannot silently drift the
bundler to a newer @rspack/core (e.g. ^1.7.11 -> 1.7.12, or the 2.x line)
that changes CJS/ESM handling and breaks the build. This is drift-prevention
hygiene; the lockfile already resolved these exact versions, so it does not
by itself change the current build.

Note: the v10.27+ "ES Modules may not assign module.exports (No such
template)" break is NOT an rspack version regression -- the lockfile has
pinned @rspack/core ~1.7.9-1.7.11 since v8.40, unchanged between the working
v10.17 and the broken v10.27. The cause is WeKan-side: CJS module.exports
files entering the client bundle. Fixed separately per file (collectionHelpers)
and, durably, by moving those client-imported models/lib files to ESM.
2026-07-23 18:38:16 +03:00
Lauri Ojansivu 3cbbd42047 Updated ChangeLog. 2026-07-23 18:25:20 +03:00
Lauri Ojansivu 68441338d3 Changelog: dev-tooling rebuild-wekan.sh ss fallback; note #6514 is the same #6511 bug.
Add the rebuild-wekan.sh developer-tooling bullet (ss fallback so the dev
server can be stopped when fuser/lsof are absent), and expand the #6511
fix entry to also cover #6514: jullbo (plain Docker, no reverse proxy,
ROOT_URL=http://neptun:4001) and AmigaAbattoir confirmed the identical
"ES Modules may not assign module.exports: 43130" -> "No such template:
swimlane / notifications / boardButtons" failure, i.e. the same global
collectionHelpers module-format bug fixed by export {}. jullbo also
confirmed the #6515 listsUnbound fix works.

Close #6514 as a duplicate of #6511 (same root cause, same fix).

Thanks to jullbo, AmigaAbattoir and xet7 !

Close #6514,
2026-07-23 18:12:56 +03:00
Lauri Ojansivu d61eba49f1 rebuild-wekan.sh: free a stuck dev-server port with ss when fuser/lsof are absent.
free_tcp_port() killed the process holding a port only via fuser (psmisc)
or lsof. On a minimal Linux that has neither installed, it did nothing —
yet port_in_use() detects the port via ss, so the stop loop kept
reporting "still in use" and finally "ERROR: Port 3000, 8080 or 3001 is
still in use ... Stop it manually and retry", and the dev server could
not restart. Add an ss fallback: parse the owning pid from
`ss -ltnpH "sport = :PORT"` (works for your own process without root) and
kill it. ss is part of iproute2 and effectively always present on Linux.
2026-07-23 18:02:54 +03:00
Lauri Ojansivu 6f46f8db4c Changelog Upcoming: add the #6512 OAuth-login redirect fix. 2026-07-23 17:56:02 +03:00
Lauri Ojansivu 79410e73d0 Fix #6512 (OAuth): send Google/OAuth logins to All Boards, not the language selector.
The earlier #6512 fix covered only the useraccounts password/register flow
(onSubmitHook). OAuth / OIDC logins use the redirect login style and do not
go through onSubmitHook: after returning from the identity provider the user
is logged in but still on the sign-in route, which renders only the language
selector ("Vaihda kieltä") until a manual page reload.

Add a client Accounts.onLogin handler (fires for every login method after
Meteor.userId() is set) that navigates to All Boards when the current route
is an auth route. Guarded to auth routes so an auto-login on a deep-linked
board URL is not redirected away, and it is a no-op after password login
(onSubmitHook has already navigated).

Thanks to xet7 !

Fixes #6512,
2026-07-23 17:55:39 +03:00
Lauri Ojansivu beaa4ba312 Changelog: Upcoming section with the #6511 collectionHelpers module-format fix.
v10.31 was released, so the #6511 fix (collectionHelpers.js: export {}
instead of module.exports = {}, which stopped the whole client module
chain aborting with "ES Modules may not assign module.exports" -> "No
such template") goes in a fresh Upcoming section above v10.31.
2026-07-23 17:53:36 +03:00
Lauri Ojansivu e62c775752 Fix #6511: stop the collectionHelpers shim aborting the client module chain.
imports/collectionHelpers.js (a side-effect shim that patches
Mongo.Collection.prototype) ended with `module.exports = {}`. It also
references the Meteor pseudo-global `Package` BARE, which the client
rspack build - ProvidePlugin rewrites into an injected ESM `import`,
marking this file an ES module. An ES module that assigns
`module.exports` throws, at evaluation time,
`Uncaught Error: ES Modules may not assign module.exports or exports.*,
Use ESM export syntax, instead`. Because this shim is the FIRST import in
client/main.js, that throw ABORTED the whole client module chain: app
templates (swimlane, notifications, headerUserBar, ...) never registered,
so Blaze threw "No such template: swimlane" and the board stayed on the
maintenance spinner, the top user/settings bar was missing, and the login
form did not render - reproduced on Docker, Sandstorm and the official
boards.wekan.team (root domain), i.e. a global build bug, not infra. The
regression came from converting this shim to pure require()/module.exports.

Replace `module.exports = {}` with `export {}`: a clean ES module with no
CommonJS export, so no assignment throws, and it still works as a
side-effect import on the client and require() on the server. It exports
nothing, and no consumer reads its exports (both callers import/require it
only for its side effects).

Thanks to mueschel, jullbo, akshat-goel and xet7 !

Fixes #6511,
2026-07-23 17:50:06 +03:00
github-actions[bot] ce10cda9a9 Bump versions for v10.31 2026-07-23 13:48:46 +00:00
Lauri Ojansivu edc96d7a77 Prepare v10.31 release 2026-07-23 16:47:43 +03:00
Lauri Ojansivu b2a07b5482 Changelog: move the post-v10.30 fixes into a new Upcoming section.
The #6512 (post-login redirect) and #6515 (per-swimlane list repair) fix
commits post-date the v10.30 release, so they were wrongly listed inside
the tagged v10.30 section. Move them into a new "# Upcoming WeKan ®
release" section above v10.30. v10.30 keeps only what was in Upcoming
before it was cut (the bundled FerretDB OpLog ts index and the #5623
test fix).
2026-07-23 16:43:07 +03:00
Lauri Ojansivu 8eb142e085 Changelog TODO Later: correct #6511/#6512/#6514 diagnosis from the 10.30 feedback.
The official boards.wekan.team (root domain, correct ROOT_URL) reproduces
these, disproving the earlier sub-path/proxy and ROOT_URL-not-set
theories:
- #6511: the "No such template" cascade is a MODULE-FORMAT build error
  ("ES Modules may not assign module.exports: 43130") that aborts module
  evaluation so templates never register — a real global rspack build
  bug xet7 is fixing, not infra.
- #6512: fixed (post-login redirect race), removed from the backlog.
- #6514: reframed — the redirect-race (#6512) and module-format (#6511)
  fixes are the real causes; ROOT_URL-not-set was at most contributory.
2026-07-23 16:39:01 +03:00
Lauri Ojansivu bbdd57cb44 Changelog Upcoming: add the #6512 post-login redirect fix. 2026-07-23 16:38:15 +03:00
Lauri Ojansivu 3c6d2b54e3 Fix #6512: after login, go to All Boards instead of the language selector.
After a successful login/register, onSubmitHook navigated to the home
route immediately, but the home route sign-in guard (renderBoardList in
config/router.js) checks Meteor.userId() NON-reactively and bounces to
the sign-in page when it is still null. Right after login the client
userId lags the onSubmitHook callback by a tick, so the navigation
bounced the user straight back to the login layout — which shows only
the language selector ("Vaihda kieltä") — until a manual page reload.
The same non-reactive guard also stranded a RETURNING user on the
sign-in page during auto-login from a stored token (Meteor.loggingIn()).

- config/accounts.js: onSubmitHook now waits reactively for
  Meteor.userId() before FlowRouter.go("/"), with a 5s timeout fallback
  so a login that never lands userId still navigates.
- config/router.js: renderBoardList no longer bounces to atSignIn while a
  login is in progress (Meteor.loggingIn()); the All Boards list fills in
  reactively once userId lands.

This is the post-login-redirect part of the WeKan 10.30 login feedback;
the separate "No such template" board failure (#6511) is a module-format
build error being fixed separately.

Thanks to akshat-goel and xet7 !

Fixes #6512,
2026-07-23 16:37:29 +03:00
Lauri Ojansivu 437c4b83ad Changelog: repoint the rebase-orphaned #6515 fix link to its v10.30 hash.
The v10.30 release rebased the #6515 per-swimlane-list fix commit; repoint
its CHANGELOG link from the orphaned pre-rebase hash to the reachable one.
2026-07-23 15:05:13 +03:00
Lauri Ojansivu 189212f235 Changelog TODO Later: strengthen #6511 diagnosis with the #6515 notifications correlation.
Two distinct core templates (swimlane #6511, notifications #6515
secondary) reported "No such template" at render time on different
proxy/sub-path deployments, both correctly defined in the main bundle —
so this is a client-bundle delivery / init problem on those deployments,
not a per-template source bug. Recorded so it is not mistaken for a fix
that can be made blind.
2026-07-23 15:03:42 +03:00
Lauri Ojansivu b1151939d6 Changelog Upcoming: add the #6515 per-swimlane-list repair fix.
Record the fix (34f8af006) that stops the startup/board-open repair from
unbinding legitimate per-swimlane lists (swimlaneId nulled on upgrade).
2026-07-23 15:03:42 +03:00
Lauri Ojansivu 337035eb4c Fix #6515: the startup/board-open repair no longer unbinds per-swimlane lists.
repairBoardsOnStartup ran the shared board repair over every board and,
via planBoardRepair, cleared swimlaneId to null on EVERY list that had
one -- treating any swimlane-bound list as #6484 corruption. But a list
with a swimlaneId is a legitimate PER-SWIMLANE list (rendered only in its
swimlane via swimlaneLists()), indistinguishable from a #6484-corrupted
board-wide list at the data level. So on upgrade to v10.26+ the routine
silently unbound every per-swimlane list (the reporter: listsUnbound: 24)
and all lists then rendered in every swimlane. Board open re-ran the same
repair, so even a manual swimlaneId restore in the DB was nulled again.

Remove list-unbind from the automatic plan (planBoardRepair.listsUnbind
is always empty; repairBoardData no longer issues the swimlaneId: null
write). The card repairs (missing-swimlane, orphaned) are kept. The
#6484 CODE bug that could wrongly bind a board-wide list is already
fixed, and an admin can still deliberately un-bind a specific board with
the explicit repairBoardWideLists method. Affected boards keep their
swimlaneIds now; a manual restore from a pre-upgrade backup will stick.

Tests: boardRepair.test.cjs updated to assert per-swimlane lists are
never auto-unbound and the applier no longer clears swimlaneId.

Thanks to jullbo and xet7 !

Fixes #6515,
2026-07-23 15:03:42 +03:00
github-actions[bot] 37b0017c3d Bump versions for v10.30 2026-07-23 11:13:55 +00:00
Lauri Ojansivu 807c7fce00 Prepare v10.30 release 2026-07-23 14:12:49 +03:00
Lauri Ojansivu 679b0f7ae2 Changelog Upcoming: add the #5623 select-all-swimlane test fix bug entry.
Record the stale-test fix (50a7c8fe5) under Upcoming bug fixes so every
post-v10.29 change is reflected. The zh-TW translation update (1e73b1bef)
is covered by the standard "translators for their translations" footer,
per convention (per-language pulls are not individually bulleted).
2026-07-23 14:11:58 +03:00
Lauri Ojansivu 1e73b1befa Updated translations 2026-07-23 14:09:07 +03:00
Lauri Ojansivu 50a7c8fe59 Fix stale #5623 select-all-swimlane unit test after the doubled-cards fix.
The doubled-cards render fix (c90d6eaa4) changed
filterCardsByListAndSwimlane so shared/orphaned cards (swimlaneId
null / '' / missing / a deleted swimlane) surface ONCE, in the FIRST
swimlane, instead of in every swimlane: the first swimlane is signalled
by passing the OTHER swimlanes ids as the 4th otherSwimlaneIds argument
(what allCards() gets from orphanedCardsSwimlaneIds, which returns them
only for the first swimlane); a non-first swimlane gets no 4th argument
and returns only its own cards. That commit did not update this test, so
it still asserted the old behaviour (a bare swimlaneId returning own +
orphaned) and failed: expected [c1,c2,c5,c6,c7], got [c1,c2].

Update the test to the current contract: the FIRST-swimlane case passes
otherSwimlaneIds=[S2] and still expects [c1,c2,c5,c6,c7], and a new
NON-first-swimlane case asserts only [c1,c2]. Verified the assertions
against the exact helper logic.
2026-07-23 14:04:12 +03:00
Lauri Ojansivu fa5c9ff838 Changelog TODO Later: record #6509 and #6500 (this-year open issues, not fixed).
Triaged all open issues created this year; none are confidently
already-fixed, so none are closed. Recorded the two not yet in the
backlog:
- #6509 (fresh v10.27 on FerretDB v2 + PostgreSQL, lists/cards not shown
  until reload, undefined.remove() -- same Blaze ordered-diff class as
  #6511; not closed because the identical-signature #6511 reporter says
  it persists on 10.28).
- #6500 (Docker latest does not load, Connection reset by peer -- xet7 is
  actively fixing the bundled FerretDB Docker startup; needs the running
  Docker stack).
2026-07-23 12:50:21 +03:00
Lauri Ojansivu 8d8f7a3d06 Changelog: Upcoming — bundled FerretDB gains the OpLog ts index on PG/MySQL/HANA.
v10.29 was released, so this post-release note goes in a fresh Upcoming
section (not retroactively into the tagged v10.29): the bundled FerretDB
v1 fork now creates the OpLog ts index on the PostgreSQL, MySQL and SAP
HANA backends (not only SQLite), best-effort with a descriptive log if a
live engine rejects the syntax. See the FerretDB fork changelog.
2026-07-23 12:50:21 +03:00
github-actions[bot] 463be75174 Bump versions for v10.29 2026-07-23 09:36:16 +00:00
Lauri Ojansivu 09ee0e61b5 Prepare v10.29 release 2026-07-23 12:35:04 +03:00
Lauri Ojansivu 235d00355b Changelog: add Upcoming section for the changes made after v10.28.
Collect every real change committed after the v10.28 release into a new
"# Upcoming WeKan ® release" section above it: the snap external
PostgreSQL/MySQL/HANA FerretDB backend and the placeholder-only machine
translation step (new features), and the snap wekan.problems
settings-loading fix that makes ROOT_URL / LDAP checks read the real
config (bug fix, helps #6512 / #6514). Changelog-only housekeeping
commits (link repointing, TODO Later, version bump) are not listed.
2026-07-23 12:32:38 +03:00
Lauri Ojansivu cbfe3600fc Translations: add a machine-translation step that only fills English placeholders.
The per-key merge (merge-translations.mjs) already guarantees a Transifex
pull never loses a human translation: it keeps the newest Transifex
translation, restores a committed human translation the pull reverted to
English, and leaves the English source ONLY on strings untranslated
everywhere. This adds the missing machine-translation step that fills
exactly those English placeholders — so machine translation can never
overwrite a human translation (a human string, value != English, is
never selected), and a language with no translation at all (every key a
placeholder) gets fully filled.

- machine-translate.mjs: placeholder-only selection (value === English
  source, or key missing); pluggable LibreTranslate-compatible provider
  (WEKAN_MT_URL / WEKAN_MT_API_KEY); English + en-* variants skipped;
  --dry-run, --limit N (resume-friendly), --provider-check; preserves
  en key order + 2-space indent + trailing newline. Machine output stays
  local and is NOT pushed to Transifex.
- pull-translations.sh: run the machine step AFTER the merge, opt-in via
  WEKAN_MT=1 or WEKAN_MT_URL, so a plain pull is unchanged.
2026-07-23 12:31:39 +03:00
Lauri Ojansivu 6b33e2c402 Changelog TODO Later: record #6512/#6514 (ROOT_URL not set) + FerretDB index WIP.
#6514 and #6512 (login page shows only the language selector; header
renders empty with no user/settings menu after a 10.28 upgrade): the
reporter wekan.problems output shows ROOT_URL is not set even after
snap set root-url, which is the root cause -- a wrong/absent ROOT_URL
breaks DDP, so useraccounts never supplies the router content template
that userFormsLayout renders and currentUser stays null, while
jam:offline replays cached boards. Operational fix is to set ROOT_URL
and restart; the wekan.problems settings-loading fix in this release
already surfaces the real value. Recorded for live confirmation.

Also recorded the FerretDB v1 backend-parity in-progress work (oplog ts
index + declared-index usability across PostgreSQL/MySQL/HANA) with the
web-searched authoritative index syntax and the live-EXPLAIN
verification boundary, so the next session can resume.
2026-07-23 12:27:21 +03:00
Lauri Ojansivu 0b43b17721 Changelog TODO Later: record #6511 follow-up (No such template: swimlane).
The v10.28 card-sort _id tiebreaker addressed the first reported error
(Bad index in range.removeMember). The reporter now sees a different
console error, "No such template: swimlane", with the board still
showing no cards. That template is a core Blaze template compiled into
the main bundle and statically included as +swimlane in boardBody, so it
is not a lazy chunk that could 404 under a sub-path; Blaze nonetheless
reports it missing at render time. The deployment is on a /wekan
sub-path with wss .../wekan/sockjs/... 400 Bad Request plus wasm and
source-map errors, so the evidence points at the reverse-proxy WebSocket
upgrade / sub-path bundle delivery, not a WeKan source bug. Left for
live reproduction with the reporter proxy setup rather than guessing a
blind code change.
2026-07-23 12:15:47 +03:00
Lauri Ojansivu cbe4e4fcf3 Changelog: repoint rebase-orphaned commit links to their real hashes.
A rebase left 24 CHANGELOG commit links pointing at pre-rebase hashes
that no longer exist. Repointed each to the actual commit, matched by
subject (and, for the full-hash links, an exact issue-number anchor):

- 9 short links: template drag-to-share / GlobalAdmin REST / domain
  sharing / auth-sync (#5850), snap Node.js-download 404 fix, LDAP-OAuth2
  env-var docs, and the rebuild-wekan Playwright-Docker matrix.
- 15 full-hash links: #3185 #3606 #3624 #4055 #4236 #4249 #4394 #4494
  #4653 #4881 #4946 #4978 #5439 #5536 bug-fix entries.

Remaining older full-hash links were left as-is: an orphaned commit is
still reachable by full SHA on GitHub, and the ambiguous ones (generic
"Part 1/2" subjects, no issue anchor) need a human to confirm.
2026-07-23 12:12:48 +03:00
Lauri Ojansivu 2538e24344 snap: run FerretDB against an EXTERNAL PostgreSQL / MySQL / HANA backend (wekan-ferretdb-handler).
Part of bringing the non-SQLite FerretDB v1 backends to Meteor parity: let the snap run
FerretDB with a backend other than the embedded SQLite, against an EXTERNAL database the
operator hosts (the snap bundles no PostgreSQL/MySQL server).

New snap settings (snap-src/bin/config): wekan-ferretdb-handler (sqlite [default] /
postgresql / mysql / hana) and wekan-ferretdb-url (the connection URL for the external
backend). ferretdb-control now branches on the handler: for a non-sqlite handler it
execs FerretDB with --handler=<engine> --<engine>-url=<url> (the fork's binary already
exposes --postgresql-url / --mysql-url / --hana-url) and SKIPS every SQLite-file step
(rotating backup, restore, reset-oplog, corruption pre-open repair), which apply only to
the embedded SQLite file. The same wekan-ferretdb-oplog gate applies: the replica set /
OpLog is created only when opted in, otherwise standalone (polling) — and because the
OpLog/reactivity machinery is backend-agnostic, Meteor tails the OpLog on the external
backend when enabled.

  snap set wekan wekan-ferretdb-handler=postgresql \
           wekan-ferretdb-url="postgres://user:pass@HOST:5432/ferretdb"

Backend query correctness/performance still needs the FerretDB integration suite against
the live engine (see FerretDB ROADMAP.md).

Thanks to xet7 !
2026-07-23 12:03:16 +03:00
Lauri Ojansivu d69c83f949 snap wekan.problems: load snap settings so ROOT_URL / LDAP checks read the real config.
Reported: after `snap set wekan root-url="https://..."`, `snap run wekan.problems`
still showed "ROOT_URL is not set". Cause: the wrapper exec'd wekan-problems.mjs
WITHOUT the snap settings in its environment, so `process.env.ROOT_URL` (and
LDAP_ENABLE) were empty in that process and the login-page checks always reported
ROOT_URL unset — regardless of the snap setting. The DB-backed checks (broken cards,
migrations/repairs in progress) were correct; only the env-derived ones were wrong.

Source `$SNAP/bin/wekan-read-settings` (echo suppressed) before exec so ROOT_URL /
LDAP_ENABLE / DATABASE etc. are exported and the login-page checks see the same
configuration WeKan runs with.

Thanks to xet7 !
2026-07-23 12:03:16 +03:00
github-actions[bot] 05cdf5d4b2 Bump versions for v10.28 2026-07-23 08:04:37 +00:00
Lauri Ojansivu a13be77a02 Prepare v10.28 release 2026-07-23 11:03:40 +03:00
Lauri Ojansivu a327c59c71 Changelog: Upcoming — #6508 follow-up (publish authenticationMethod so in-board Remap User shows).
Thanks to AmigaAbattoir and xet7 !
2026-07-23 11:00:06 +03:00
Lauri Ojansivu 5433dfbc5b Fix #6508: show "Remap User" for an imported board member — publish authenticationMethod with board members.
Follow-up to the #6508 import fixes: in the board view, clicking an imported
(placeholder) board member's avatar showed no "Remap User" action. The board member
popup gates it on isImportedMember(), which checks `authenticationMethod ===
'imported'` — but the board composite published board members' user docs with only
username / profile.fullname / avatarUrl / initials, WITHOUT authenticationMethod
(user-miniprofile, which publishes safeFields incl. authenticationMethod, is only
subscribed on the import screen). So the field was undefined on the client and the
action never appeared.

Publish authenticationMethod with the board members' user fields (it is already in
Users.safeFields, so this is consistent and not newly-exposed). The in-board "Remap
User" -> mapImportedBoardMember flow now works for imported board members.

Test: tests/importVirtualMembers.test.cjs guards the field is published.

Thanks to AmigaAbattoir and xet7 !

Fixes #6508,
2026-07-23 10:59:37 +03:00
Lauri Ojansivu db1f8c8b74 Changelog: Upcoming — #6511 no-cards / range.removeMember fix (card sort _id tiebreaker).
Thanks to mueschel and xet7 !
2026-07-23 10:55:36 +03:00
Lauri Ojansivu 11cb336161 Fix #6511: board shows no cards / "Bad index in range.removeMember" — add a unique _id sort tiebreaker.
The board card list is a Blaze {{#each}} over a LIMITED, ordered reactive cursor
sorted by `{ sort: 1 }`, which has TIES. When several cards share the same `sort`
value (or the same due date, etc.), their order is non-deterministic across
observe/poll cycles, so Meteor's diffQueryOrderedChanges computes an out-of-range
index and Blaze throws `Bad index in range.removeMember: 0`. That aborts the #each,
so the board renders NO cards (and cascades into the `undefined.remove()` teardown
error). An index-backed board card query (a recent fix) can change the tie order and
expose this.

New pure, tested helper models/lib/cardSortTiebreaker.js appends a UNIQUE `_id` key
as the final sort key (object and Meteor array forms; idempotent), making the total
order deterministic so the ordered diff is always well-defined. Applied in
client/components/lists/listBody.js (cardsWithLimit — the crashing #each, before both
the window subscription and the client cursor) and server/publications/cardsWindow.js
(the published limited window, so "first N cards" is stable between polls).

Test: tests/cardSortTiebreaker.test.cjs. Likely also resolves the same
range.removeMember "no cards" reports on other backends.

Thanks to mueschel and xet7 !

Fixes #6511,
2026-07-23 10:55:08 +03:00
Lauri Ojansivu 5936b5ef99 Changelog: repoint post-rebase links (activity-feed, snap wording) to on-branch hashes.
Thanks to xet7 !
2026-07-23 10:50:03 +03:00
Lauri Ojansivu 8b1150364c Changelog: Upcoming — card open no longer ~40s on FerretDB (bundled dotted-path pushdown fix).
Thanks to xet7 !
2026-07-23 10:49:37 +03:00
Lauri Ojansivu e906ada921 Docs/Nginx: troubleshooting note for "wss ... /sockjs/.../websocket failed" (Connection upgrade header).
The example config is correct, but a common mistake is proxying WeKan with
`proxy_set_header Connection "";` (or omitting the `map $http_upgrade
$connection_upgrade` / `proxy_http_version 1.1`), which stops the SockJS WebSocket
upgrade and surfaces as `WebSocket connection to 'wss://HOST/sockjs/.../websocket'
failed`. Add a Troubleshooting section explaining the symptom and the exact three
lines (and the http-context map) needed on every proxy hop.

Thanks to akshat-goel and xet7 !

Fixes #6510,
2026-07-23 10:49:13 +03:00
Lauri Ojansivu 4706eb7c06 Changelog: Upcoming — activity-feed FerretDB pushdown + snap wekan.problems title wording.
Thanks to xet7 !
2026-07-23 10:49:13 +03:00
Lauri Ojansivu 0470adcf3b snap wekan.problems: neutral check titles so a failing check does not read contradictorily.
The login-page checks used affirmative titles ("ROOT_URL is set", "A migration or
repair is in progress"), so a FAILING one printed e.g. "[error] ROOT_URL is set:
ROOT_URL is not set" — confusing. Use neutral subjects ("ROOT_URL", "Migration or
repair") so the "[severity] <title>: <detail>" line reads correctly in every state.

Thanks to xet7 !
2026-07-23 10:49:13 +03:00
Lauri Ojansivu 959cfbb84a Activity feed: scope + activityType at top level (not $and) so it pushes down on FerretDB.
The comment-only feed (showActivities off) selected activities with
`$and: [{activityType:'addComment'}, {boardId/cardId: {$in}}]`. FerretDB v1 (SQLite)
does not push down a top-level $and, so the activities collection was full-scanned on
every poll — slow history on a big board. The two keys are distinct, so merging them
into one top-level object is exactly equivalent and lets both push down to the index
(same fix as the 10.22 card-window flatten).

Thanks to xet7 !
2026-07-23 10:49:13 +03:00
github-actions[bot] 8303c68c53 Bump versions for v10.27 2026-07-22 21:58:15 +00:00
Lauri Ojansivu 01f7522747 Prepare v10.27 release 2026-07-23 00:57:05 +03:00
Lauri Ojansivu afc6efa4bc Changelog: repoint the #6508 Upcoming link to its post-rebase hash (7b62cd4e6 -> 2f07b2933).
Thanks to xet7 !
2026-07-23 00:54:01 +03:00
Lauri Ojansivu 131149abd5 Changelog: Upcoming — #6508 import map-user crashes (search-select + People email).
Thanks to AmigaAbattoir and xet7 !
2026-07-23 00:52:46 +03:00
Lauri Ojansivu 2f07b29336 Fix #6508: two console crashes in the import "map users later" workflow.
A) Import "map members": clicking a searched user threw
   `TypeError: Cannot read properties of undefined (reading '_id')` at
   `click .js-select-import`. The handler read `Template.currentData()._id`, which is
   undefined when the click lands on a CHILD of the anchor (the avatar or the name
   span). Read the WeKan user id from the anchor's `data-id` (event.currentTarget)
   instead, and guard it — robust regardless of which inner node was clicked.

B) Admin Panel / People: saving an IMPORTED (placeholder) user as an active person
   with an email threw `TypeError: Cannot read properties of undefined (reading '0')`.
   The profile submit handler read `user.emails[0].verified` (and `.address`), but an
   imported placeholder user (and some SSO users) have no `emails` array at all. Read
   the primary email defensively (missing OR empty array) via a `primaryEmail` guard.

Tests: tests/importMapUserFixes.test.cjs; tests/importMemberMapping.test.cjs updated
for the data-id mapping.

Thanks to AmigaAbattoir and xet7 !

Fixes #6508,
2026-07-23 00:52:46 +03:00
github-actions[bot] 1697584693 Bump versions for v10.26 2026-07-22 18:55:17 +00:00
Lauri Ojansivu 765a6af052 Prepare v10.26 release 2026-07-22 21:54:27 +03:00
Lauri Ojansivu d01101e3bb Changelog: repoint post-rebase commit links (#6506, #6507) to their on-branch hashes.
The rebase rewrote eb80ee69f -> 0e0391f6c (#6506) and 00ef255b0 -> 72768e05f (#6507);
the old hashes were pre-rebase orphans that were never pushed, so their CHANGELOG
links would 404 on GitHub. Updated to the current on-branch hashes.

Thanks to xet7 !
2026-07-22 21:53:03 +03:00
Lauri Ojansivu b0ba2d950c Changelog: Upcoming — #6507 fresh-install reactive loop / Admin panel fix.
Thanks to AmigaAbattoir and xet7 !
2026-07-22 21:48:29 +03:00
Lauri Ojansivu 72768e05fe Fix #6507: fresh-install reactive loop / Admin panel broken / "Cannot read properties of undefined (reading 'remove')".
On a fresh install (no Settings document yet, and Meteor.user() briefly null right
after the first admin logs in) the Admin panel would not open, the title bar
sometimes failed to load or showed "You are not authorized to view this page", and
the console looped `Uncaught (in promise) TypeError: Cannot read properties of
undefined (reading 'remove')` through a Tracker computation.

Root cause: ReactiveCacheClient.getCurrentSetting() and getCurrentUser() guarded
their DataCache with `!this.__x || !this.__x.get()`. Every other getter creates its
DataCache ONCE (`if (!this.__x)`), but these two ALSO rebuilt it whenever the cached
value was FALSY — so while the setting/user was absent (a fresh install, or any
transient null) a brand-new DataCache, and a brand-new Tracker.autorun, was
constructed on EVERY reactive read. That continuous create/teardown churned
computations across every app-wide template (header, admin gates) and raced Blaze's
view teardown, producing the infinite reactive loop and the removed-DomRange crash
(the same WebKit Blaze class the getBoard staleWhileRevalidate comment guards).

Fixed by creating the DataCache once, keyed on `!this.__currentSetting` /
`!this.__currentUser` only, like every other getter; the autorun already updates
reactively when the Settings document / user appears, so no rebuild is ever needed.
Returning a stable null until they exist is correct and loop-free.

Test: tests/reactiveCacheCurrentGetters.test.cjs.

Thanks to AmigaAbattoir and xet7 !

Fixes #6507,
2026-07-22 21:48:29 +03:00
github-actions[bot] 0c5208cf73 Bump versions for v10.25 2026-07-22 17:55:31 +00:00
Lauri Ojansivu bda23a803b Prepare v10.25 release 2026-07-22 20:54:37 +03:00
Lauri Ojansivu 27691bbcc2 Updated translations. 2026-07-22 20:14:28 +03:00
Lauri Ojansivu 137a863bd0 Changelog: repoint the Upcoming card-loading fix link to its post-rebase hash.
The rebase rewrote the commit hash (0b809bccd -> a51afa5a1); update the Upcoming
link so it resolves on GitHub.

Thanks to xet7 !
2026-07-22 20:06:58 +03:00
Lauri Ojansivu 0c151c954a Changelog: Upcoming — fix boards loading lists but never cards on FerretDB (null-in-$in defeated pushdown).
Thanks to xet7 !
2026-07-22 20:05:32 +03:00
Lauri Ojansivu a51afa5a1b Fix boards loading lists but never cards on FerretDB (10.22): drop the null from the card-scope $in so it stays index-backed.
Every board card query used `{ boardId: { $in: [board._id, board.subtasksDefaultBoardId] } }`,
and subtasksDefaultBoardId defaults to null. On FerretDB v1 (SQLite) a $in that
contains a non-string element (the null) does not push down at all, so the WHERE was
dropped and the whole cards collection was full-scanned + sjson-decoded on every
poll-and-diff cycle, across ~7 card cursors per board open. Lists and swimlanes use a
plain boardId equality (index-backed) and stayed fast — so on a big board the columns
appeared but the cards never did.

New helper models/lib/boardCardScope.js builds the scope WITHOUT null and prefers a
plain equality (index seek) when only the board itself is in scope, else an
all-string $in (which pushes down). Applied to every card-scope site in
server/publications/boards.js (8x), cardsWindow.js and cards.js.

Also flatten the lazy card-window selector (cardsWindow.js): the window was
`{ $and: [clientSelector, { boardId, archived }] }`, and FerretDB does not push down
a top-level $and, so boardId/archived were full-scanned too. Merge the board scope at
the top level (exactly equivalent when the client selector has no boardId/archived
key, which it does not — it is a per-list listId + swimlane selector; a collision
guard keeps $and otherwise), so boardId/archived push down.

The FerretDB fork side is hardened separately so a mixed $in (incl. null) also uses
the index. Test: tests/boardCardScope.test.cjs.

Thanks to xet7 !
2026-07-22 20:05:32 +03:00
github-actions[bot] f47b7cb78a Bump versions for v10.24 2026-07-22 15:37:29 +00:00
Lauri Ojansivu 03630214fc Prepare v10.24 release 2026-07-22 18:36:36 +03:00
Lauri Ojansivu 7ca8882cae Changelog: Upcoming — #6506 import map-members step restored + virtual users for unmapped members.
Thanks to AmigaAbattoir and xet7 !
2026-07-22 18:31:32 +03:00
Lauri Ojansivu 0e0391f6c1 Fix #6506: restore the import "map members" step; unmapped members become virtual users, not the importer.
Regression from c0a1a2784 (v10.12): it dropped `importMapMembers` from the import
wizard's `steps` array and hardcoded an empty membersMapping in finishImport, so the
map-users screen never rendered and every imported card/member/comment was assigned
to the importing user even when the person existed in WeKan.

Client (client/components/import/import.js):
 - restore `steps = ['importTextarea', 'importMapMembers']` so the (auto-suggesting)
   map-members step shows again;
 - finishImport builds membersMapping from the members the user mapped
   (member.wekanId), instead of sending `{}`. The step stays OPTIONAL — the Skip
   button and the textarea "import without mapping" button still bypass it.

Server — unmapped members become virtual users instead of collapsing onto the importer:
 - models/trelloCreator.js: new createPlaceholderUsers(board), run in create() before
   the board is built, so it covers BOTH the Trello JSON import and the Trello API
   (all-boards background) import. For each member not already mapped it maps to an
   existing WeKan user by username / recorded importUsername, else inserts an inert
   virtual user (loginDisabled, isActive:false, authenticationMethod:'imported')
   reusing the Trello member id, so board membership and authorship keep the original
   person;
 - models/wekanCreator.js: createPlaceholderUsers no longer overwrites an explicit UI
   mapping (skip members already in this.members) so a member mapped to a real account
   is not clobbered by the identity placeholder.

A board admin can still map a virtual member to a real user later from the sidebar
(that feature is unchanged). Tests updated: tests/importVirtualMembers.test.cjs and
tests/importMemberMapping.test.cjs.

Thanks to AmigaAbattoir and xet7 !

Fixes #6506,
2026-07-22 18:31:32 +03:00
github-actions[bot] a1f93c8015 Bump versions for v10.23 2026-07-22 15:12:36 +00:00
Lauri Ojansivu 8300772fa6 Prepare v10.23 release 2026-07-22 18:11:32 +03:00
Lauri Ojansivu 25ea36b0bd Changelog: new Upcoming CRITICAL section — ExportBleed (export XSS) and the SpliceBleed #426 follow-up.
v10.22 was cut while these fixes were in flight, so the CRITICAL entries go in a
fresh Upcoming section above it.

Thanks to koyokr, GitHub CodeQL and xet7 !
2026-07-22 18:08:04 +03:00
Lauri Ojansivu e7159d3373 Fix CodeQL js/incomplete-multi-character-sanitization (#426) in fileNameDisplay.
stripExploitPatterns() looped a CHAIN of six replaces to a fixpoint. That is
runtime-safe, but CodeQL (correctly, as a local check) could not attribute the
fixpoint to the individual `<[^>]*>?` tag replace, so it kept flagging it as an
incomplete sanitizer that "may still contain <script".

Restructure to the proven-complete pattern already used by
client/lib/importDependencies.js stripHtml() (which cleared the sibling alert
#421): remove template/PI/CDATA tokens (looped), then strip HTML/XML tags with a
SINGLE `replace(/<[^>]*>/g, '')` looped to a fixed point, then drop any stray
angle bracket so an UNCLOSED tag (no '>', e.g. a trailing "<script") cannot
survive either. Behaviour is unchanged for every existing case.

Regression: tests/fileNameDisplay.test.cjs now asserts completeness for closed,
unclosed and spliced tags (no '<', '>' or '<script' in the output).

Thanks to xet7 !
2026-07-22 18:04:52 +03:00
Lauri Ojansivu 2d32e34621 Fix ExportBleed: stored XSS in HTML board exports via a card-title second parse.
A board member could store entity-encoded markup in a card title. It stays inert
on the live board (Blaze escapes it; the +viewer sanitizer neutralizes handlers
and keeps the entity payload as text), but the exported index.html embedded a
click handler that read the card title/body via .textContent — which DECODES HTML
entities — and then concatenated those values into content.innerHTML. That SECOND
parse revived the tag (e.g. `<img src=x onerror=...>`) and executed it when a
recipient clicked the card in the export, disclosing all data in that document
(including cards added after the attacker's board membership was removed).

The modal is now built with DOM nodes and the card title/body are assigned through
textContent, never innerHTML, so they are inserted as inert text.

Regression: tests/exportHTMLXss.test.cjs (title/body reach only textContent; no
titleText/allText flows into any innerHTML; a textContent assignment does not
revive an entity-decoded <img>).

(CWE-79 stored XSS, GitHub Security Advisory GHSA-8r5p-4q9j-f5jx.)

Thanks to koyokr and xet7 !
2026-07-22 18:04:40 +03:00
github-actions[bot] 8a8ef49c4a Bump versions for v10.22 2026-07-22 14:21:11 +00:00
Lauri Ojansivu 58f67ed7ad Prepare v10.22 release 2026-07-22 17:20:02 +03:00
Lauri Ojansivu 0b614fdec9 Changelog: FerretDB high CPU fix (standalone by default; OpLog opt-in) under Upcoming bug fixes.
Thanks to xet7 !
2026-07-22 16:51:34 +03:00
Lauri Ojansivu fae1ccd176 FerretDB high CPU: run FerretDB standalone (no OpLog) by default; enable the replica set / OpLog only when wekan-ferretdb-oplog=true.
The 10.16-10.21 reports of the ferretdb process pegging ~2 cores for a long time
while node sat idle are the signature of an OpLog tail: Meteor holds a tailable
cursor on local.oplog.rs and FerretDB re-scans it server-side. wekan-control
already defaults to polling-only (it clears MONGO_OPLOG_URL), but ferretdb-control
passed --repl-set-name unconditionally, so FerretDB ALWAYS maintained the capped
OpLog (a second write per mutation, a once-a-minute cleanup, and a tailable cursor
for any stray MONGO_OPLOG_URL to spin on) even when WeKan would not tail it.

Gate --repl-set-name on WEKAN_FERRETDB_OPLOG (default false), matching
wekan-control: in the default polling-only mode FerretDB now runs STANDALONE, with
no OpLog at all; the replica set / OpLog is created only when the admin opts in with
wekan-ferretdb-oplog=true. Opting in is now also cheaper on the FerretDB fork side
(the {ts: {$gt}} tail filter is pushed down and the OpLog Timestamp indexed).

Docs: docs/Features/Admin-Panel/Problems/Migrations.md (FerretDB reactivity mode).
Test: tests/ferretdbOplogGating.test.cjs.

Thanks to xet7 !
2026-07-22 16:51:09 +03:00
Lauri Ojansivu bf08498a81 Changelog TODO Later: drop #6503, confirmed fixed in v10.21.
The reporter confirms OIDC works again after updating to WeKan 10.21. The two
source parts (the raw i18n key `changeLanguagePopup-title` and the `oplog catching
up took too long` stalls) were fixed in v10.16+; the remaining dead OIDC/Register
buttons were a stale dynamic-import bundle cache on the snap after the refresh
(no source regression), which a clean >=10.16 build clears. Removed from the
TODO Later backlog.

Thanks to Alishara and xet7 !

Closes #6503,
2026-07-22 16:30:15 +03:00
Lauri Ojansivu 6b5eaf3a09 Changelog: point the Upcoming commit links at their post-rebase hashes.
The rebase onto origin/main rewrote the local commit hashes, so the Upcoming
section's commit links pointed at pre-rebase hashes that never reached the remote.
Update all six to the pushed hashes (doubled-cards, repairBoardWideLists, board-open
auto-detect, shared board data-repairs, #6504 card-move fix, Problems Status /
snap wekan.problems).

Thanks to xet7 !
2026-07-22 16:25:22 +03:00
Lauri Ojansivu c747293f25 Changelog: Problems Status overview + snap wekan.problems, shared server-side board data-repairs with blue Product-name dashboard, and #6504 card-move fix.
Thanks to ChristianMa97 and xet7 !
2026-07-22 16:22:39 +03:00
Lauri Ojansivu c4a76e0e3d Docs: Admin Panel / Problems — README overview + Snap commands + Repairs / Migrations / Login-Problems pages.
docs/Features/Admin-Panel/Problems/README.md describes what Problems offers and
links the detailed pages (existing CPU/RAM/Disk/Recovery plus the new Repairs,
Migrations, Login-Problems and Snap pages). Snap.md documents `snap run
wekan.problems` and its sub-commands (migrations | login | broken-cards | cpu).
Repairs.md / Migrations.md cover the shared board data-repairs and the MongoDB <->
SQLite migration, when they run (board open, server startup, during migration) and
the progress dashboard. Login-Problems.md explains the "Must be logged in" /
"Loading, please wait" causes and fixes, tying them to a saturated ferretdb.

Thanks to xet7 !
2026-07-22 16:22:39 +03:00
Lauri Ojansivu 5a1c9a61e9 Admin Panel / Problems "Status" overview + snap run wekan.problems: show migrations/repairs in progress and login-page "Must be logged in" causes; run repairs server-side.
Motivated by the 10.16-10.21 reports where, after an upgrade, the login page
showed "Must be logged in" or sat on the logo + "Loading, please wait." spinner,
pages took 5+ minutes, All Boards counts stayed 0 and i18n showed raw keys — the
FerretDB v1 (SQLite) process pegged ~2 cores for a long time under WeKan's poll
load, i.e. a migration/repair or a saturated backend, with no way to see it
without the Admin Panel.

Server-side, independent of any client / progress page:
 - server/startup/repairBoardsOnStartup.js runs the shared board data-repairs once
   (version-gated, background, 30s after boot) so corrupted data is fixed even if
   no user opens a board and nobody watches a progress page.
 - The MongoDB <-> SQLite text migration and the startup repair now PERSIST their
   progress to the text_migration_status collection (models/textMigrationStatus.js),
   so a separate process can see "a migration/repair is in progress" (an in-memory
   object could not).

Status overview (server/lib/systemStatus.js), shown in Admin Panel / Problems ->
Summary and via the method systemStatusReport (admin-gated, server/methods/
systemStatus.js):
 - everything in progress (text migration, board repair, recovery, cron migration,
   attachment migration) aggregated from the persisted status collections;
 - detected problems (broken cards; failing login-page checks);
 - login-page "Must be logged in" / "Loading, please wait" cause checklist
   (migration in progress, ROOT_URL, LDAP, Sandstorm) — models/lib/loginProblems.js;
 - a CPU detail area that calls out a pegged ferretdb process and the mitigation.

Snap: `snap run wekan.problems` (snap-src/bin/wekan-problems[.mjs], snapcraft app
`problems`) prints the same overview as text — problems, migrations/repairs in
progress, login checks — so an admin with only server access can see it WITHOUT
Admin Panel access. Sub-commands: migrations | login | broken-cards | cpu | help.

Pure helpers models/lib/loginProblems.js and models/lib/problemsOverview.js are
unit-tested; tests/problemsStatus.test.cjs (14 checks) also source-guards the
server hub, method, startup pass, migration persistence and snap command.

Thanks to xet7 !
2026-07-22 16:22:39 +03:00
Lauri Ojansivu a4460b3f63 Fix #6504: rule to move a card to another board failing with "newBoard.getNextCardNumber is not a function".
On the server ReactiveCache.getBoard() is async (returns a Promise). The
cross-board branch of Cards.helpers.move() called it without await, so `newBoard`
was a Promise: `newBoard.getNextCardNumber()` threw, and `oldBoard` being a
Promise silently dropped label carry-over. Await both getBoard() calls (same fix
as PR #6505).

Added tests/cardMoveAwaitBoard.test.cjs as a regression guard.

Thanks to ChristianMa97 and xet7 !

Closes #6505,
Fixes #6504,
2026-07-22 16:22:39 +03:00
Lauri Ojansivu 136a14c88a Shared board data-repairs run on board open AND during the MongoDB <-> SQLite migration, shown in one blue, Product-name-branded progress dashboard.
The board-open self-heal is broadened from the #6484 list-unbind alone to a
shared repair set (models/lib/boardRepair.js planner + server/lib/repairBoardData.js):

 - #6484: lists wrongly bound to a swimlane -> cleared back to board-wide, so the
   list stops "disappearing" from the other swimlanes;
 - cards with no swimlaneId (null/''/missing), which never render in the Swimlanes
   view -> assigned to the board's first swimlane;
 - orphaned cards whose swimlaneId points at a DELETED swimlane (invisible cards)
   -> reassigned to the first swimlane. A card on an existing but archived swimlane
   is NOT treated as orphaned.

The same repair set now runs in BOTH places:
 - Board open: boardBody.js maybeRepairBoard -> boardRepairNeeded / repairBoardData
   (server/methods/repairBoardData.js), once per board, admin-gated, fire-and-forget.
 - MongoDB <-> FerretDB v1 (SQLite) text migration: migrateTextDatabase runs
   repairAllBoards() on the live database BEFORE copying, so the migrated copy is
   clean too. It reports a new "repairing" phase (boardsDone/boardsTotal/repaired).

Both flows drive ONE shared progress dashboard (migrationProgress), now:
 - mounted app-wide (defaultLayout) so it shows for an Admin Panel database
   migration as well as a board-open repair;
 - fed from the DB-migration status poll (attachments.js driveMigrationDashboard);
 - styled in WeKan blue (#2980b9) instead of the old purple/green;
 - branded with the configured Product name (from the earlier commit).

Tests: tests/boardRepair.test.cjs (planner + server/migration/dashboard source
guards, 11 checks) and the updated tests/listUnbindRepair.test.cjs pass.

Thanks to xet7 !
2026-07-22 16:22:39 +03:00
Lauri Ojansivu fe3edbe908 Changelog: board-open auto-detect + repair of the #6484 corruption, with a Product-name-branded migration-progress modal.
Thanks to xet7 !
2026-07-22 16:22:39 +03:00
Lauri Ojansivu f559b751b2 #6484: auto-detect the "list disappeared from swimlanes" corruption on board open and repair it with a Product-name-branded progress modal.
When a board opens it now checks (boardListRepairNeeded) whether any list is
wrongly bound to a single swimlane (the #6484 corruption, which makes a list
vanish from the other swimlanes). If so, and the viewer is a board admin, it
runs repairBoardWideLists automatically while showing the shared
migration-progress modal, then completes. The check runs once per board,
fire-and-forget, and never blocks rendering; on error the board is unmarked so a
later open retries.

The migration-progress modal is now branded with the configured Product name
(Admin Panel / Settings / Layout / Product name, Settings.productName), falling
back to WeKan when none is set, via the pure models/lib/productName.js helper —
so the repair (and every migration reusing this modal) shows the admin's Product
name instead of the hard-coded WeKan brand.

Tests: tests/productName.test.cjs (helper + modal-branding source guards) and
the extended tests/listUnbindRepair.test.cjs (detection method + client
detect/repair/progress wiring) pass.

Thanks to xet7 !
2026-07-22 16:22:39 +03:00
Lauri Ojansivu a54a0ab03e Changelog: Upcoming — doubled-cards fix + #6484 list-unbind repair.
Thanks to xet7.
2026-07-22 16:22:39 +03:00
Lauri Ojansivu d35532f0f3 Add repairBoardWideLists: un-bind lists corrupted by the #6484 swimlane bug.
The #6484 bug (nudging a board-wide list in swimlanes view bound it to one swimlane and moved its cards there, so it vanished from the other swimlanes) is fixed in code, but boards already hit keep the wrongly-set list.swimlaneId. New board-admin-gated method repairBoardWideLists(boardId) clears swimlaneId back to null for every swimlane-bound list on that board, restoring them to board-wide (rendered under every swimlane). Idempotent (lists already board-wide are skipped), scoped to the one board, and returns the count repaired. The list-selection is a pure, unit-tested models/lib/listUnbindRepair.js. Note: this repairs the LIST binding only; cards the bug moved into one swimlane are not restored (their intended swimlane is unknown -- recover those from a backup).

Tests: tests/listUnbindRepair.test.cjs (5 checks) covers listsToUnbind (selects bound lists, idempotent, invalid inputs, non-string swimlaneId) and a source guard that the method is board-admin-gated, uses the helper, clears swimlaneId to null, and is scoped to the board.

Thanks to xet7.
2026-07-22 16:22:39 +03:00
Lauri Ojansivu c90d6eaa40 Swimlanes: a shared card no longer appears in every swimlane (doubled cards).
In swimlanes view a card with swimlaneId null/'' (a 'shared' / pre-migration card) was surfaced in EVERY swimlane: swimlaneMembershipSelector used { swimlaneId: { $in: [swimlaneId, null, ''] } } for non-first swimlanes, and filterCardsByListAndSwimlane matched null/''/missing there too. On a board with several swimlanes the same shared card therefore rendered once per swimlane -- the reported 'doubled cards/entries', which also bloats the DOM (contributing to the search hang / can't-scroll on big boards). A non-first swimlane now matches ONLY its own cards ({ swimlaneId }); shared and orphaned cards surface ONCE, in the FIRST swimlane (the $nin branch, unchanged), so a card is never shown in more than one swimlane. Still a single field clause, so the #6441 board-wide label-filter fix is preserved (verified in the tests).

Tests: tests/swimlaneFilter.test.cjs updated -- a non-first swimlane matches only its own cards; shared/orphaned cards appear once in the first swimlane and are still filtered by the active label filter; #6441 board-wide filtering for cards that HAVE a swimlaneId is unchanged. 18 checks pass.

Thanks to xet7.
2026-07-22 16:22:39 +03:00
Lauri Ojansivu 501ed7a377 Changelog TODO Later: record #6503 status (i18n + oplog fixed; dead buttons need repro).
Note that #6503 is partly fixed in v10.16+ (the raw i18n key via static-bundled English + timeout-bounded init, and the 'oplog catching up' stalls via the FerretDB polling-only default), while the remaining dead OIDC/Register buttons are not a source regression (login code identical 10.11->10.13) but a stale dynamic-import bundle cache on the snap, needing the reporter to confirm on a clean >=10.16 install with cleared cache, plus a browser console/Network capture if it persists.

Thanks to Alishara and xet7.
2026-07-22 16:22:39 +03:00
github-actions[bot] 14b54e3066 Bump versions for v10.21 2026-07-22 10:42:59 +00:00
Lauri Ojansivu 1ae677e5c1 Prepare v10.21 release 2026-07-22 13:42:01 +03:00
Lauri Ojansivu d446a84399 Updated ChangeLog. 2026-07-22 13:41:53 +03:00
Lauri Ojansivu 12aedeb24a Changelog TODO Later: defer code-block color highlighting pending a security decision.
Add a 'Deferred pending a security decision' bucket for syntax/color highlighting of code blocks in the card +viewer (also asked in #5149): it is possible via MarkdownIt's highlight option + a highlighter like highlight.js, but the viewer's DOMPurify strips every class/id attribute for CSS-injection safety, so a highlighter's <span class=hljs-...> output would lose its classes and show no colour. Enabling it needs a tight allowlist of hljs-*/language-* classes on spans inside pre>code, a sanitizer relaxation xet7 has not decided on yet.

Thanks to xet7.
2026-07-22 13:36:30 +03:00
Lauri Ojansivu 8f6866f71a Changelog: Upcoming — #4758 card links survive board moves, #5149 copy-code reliable.
Thanks to Robdebert, C0rn3j and xet7 !
2026-07-22 13:28:46 +03:00
Lauri Ojansivu 27201f642f Copy code block: reliable per-viewer button, copies raw text, translated (#5149).
The copy-code-block feature existed but the reporter saw it 'work very sporadically (only a handful of items have it)' and lost after editing. Root causes: it ran ONCE in Template.editor.onRendered with a global document.querySelectorAll('.viewer > pre'), so it decorated only whichever viewers were on the page at that instant and never re-added the button after an edit re-rendered the viewer; and it copied the HTML-escaped innerHTML of the <pre>'s first child node (often the wrong content or undefined) via the deprecated execCommand. Now the decoration lives in Template.viewer.onRendered, so EVERY viewer (card description, checklist item, comment) gets a copy button and it is restored on every re-render; it is de-duplicated (skips a <pre> that already has its button) so re-renders never stack buttons; it copies the RAW code text (pre.textContent) via navigator.clipboard.writeText with an execCommand fallback; and the button title is translated (the existing 'copy-text-to-clipboard' i18n key) instead of a hard-coded English string.

Tests: tests/copyCodeBlock.test.cjs (5 checks) guards that the decoration is in Template.viewer.onRendered scoped to the viewer's <pre>, copies textContent (not innerHTML), de-dups, is translated, and uses the async Clipboard API with a fallback.

Thanks to C0rn3j and xet7 !

Fixes #5149,
2026-07-22 13:27:50 +03:00
Lauri Ojansivu 191f827393 Card links keep working after the card is moved to another board (#4758).
A card URL is /b/:boardId/:slug/:cardId; after the card was moved to another board the link opened the OLD board and found nothing. The card route now resolves the card via the permission-checked 'card' publication (server/publications/cards.js, which only returns the card when the user may see its board) and, if the card's CURRENT board differs from the URL's board, redirects to /b/<currentBoard>/<slug>/<cardId> so the card opens where it now lives -- so a link stays valid across board moves as long as the viewer has access to the destination board (like Trello). When the user cannot see the card's new board (or it is gone) the probe subscription yields nothing and the URL's board is kept, and the redirect is guarded against a redirect loop (target board matches -> stop) and against firing after the user navigated away. The decision is a pure, unit-tested models/lib/cardLinkRedirect.js. (A toast telling the user 'this card was moved to a board you cannot access' in the no-access case is a possible follow-up.)

Tests: tests/cardLinkRedirect.test.cjs (6 checks) covers the moved-card redirect, the same-board no-op, missing/invalid card (stay put), the no-loop invariant, and a router source guard that it drives off the permission-checked 'card' publication.

Thanks to Robdebert and xet7 !

Fixes #4758,
2026-07-22 13:22:15 +03:00
Lauri Ojansivu 2366f78b5e Close #4043: invitation email always carries a valid code (already fixed).
#4043 ('Sometimes user get board invitation by mail without invitation code' — recipients got an email whose code the sign-up form rejected with 'The invitation code doesn't exist') is already fixed and shipped: the pure models/lib/invitationCodeEmail.js rules (normalizeInviteEmail lowercases the stored email so it matches the sign-up form, isInvitationCodeSendable refuses to mail a missing/invalid code, buildReinviteModifier overwrites a stale code with a fresh valid one on re-invite, and shouldRemoveInvitationOnEmailFailure only rolls back a code created by this very invite) are wired into server/models/settings.js sendInvitation and covered by tests/invitationCodeEmail.test.cjs (14 checks, incl. the settings.js wiring). It was mis-filed under 'Feature requests' in the CHANGELOG TODO Later; removed from there.

Thanks to Emile840 and xet7 !

Fixes #4043,
2026-07-22 13:12:28 +03:00
Lauri Ojansivu 113df6c396 Changelog TODO Later: add #6430 (drag flicker, runtime) and #5323 (due-date notify, feature).
#6430 (~1s card flicker dragging on large boards — a drag/reactivity re-render; xet7 already reduced it in 2e7c4ed but it persists, needs a large board + browser to profile) under 'need the running app'; #5323 (due-date notification/webhook with per-board offset — the built-in NOTIFY_DUE reminder already exists and was improved in #3192, so the remainder is a feature) under 'feature requests'.

Thanks to xet7.
2026-07-22 13:10:05 +03:00
Lauri Ojansivu 759fb05744 CLAUDE.md: document the CHANGELOG '# TODO Later' triage-backlog section.
Explain what the '# TODO Later' section at the top of CHANGELOG.md is for (open issues investigated but not fixed here, each with a concrete reason), how it fits the 'Fix open issues' process (fix / close-if-already-fixed / else add to TODO Later with the reason), the category buckets (need infra/running-server, need the running app to reproduce, already-correct, feature/by-design, needs a maintainer decision), that entries use issue links (not commit links) since nothing was committed, and that fixed issues must be REMOVED from the list once their Fixes #NNNN commit closes them.

Thanks to xet7.
2026-07-22 13:07:54 +03:00
github-actions[bot] 9287596a72 Bump versions for v10.20 2026-07-22 10:05:55 +00:00
Lauri Ojansivu de134aa210 Prepare v10.20 release 2026-07-22 13:04:59 +03:00
Lauri Ojansivu bd600c617f Changelog TODO Later: add #1942 (runtime), #1995 (feature), #3001 (proxy env).
Continue triaging open issues that cannot be fixed/verified in this sandbox: #1942 (blank/frozen view opening a linked card the viewer has no rights to — runtime permission + reactive close), #1995 (edit fields on a linked card — feature/by-design, same class as #3748), #3001 (manipulated headers behind an Apache reverse proxy — a proxy-config concern needing the reporter's setup). #1972 and #2306 already carry Fixes commits and close on push.

Thanks to xet7.
2026-07-22 13:02:42 +03:00
Lauri Ojansivu 50d2a33f17 Changelog: Upcoming — close #1158 (list drag disabled for anonymous users).
Thanks to GenericUK and xet7 !
2026-07-22 13:02:42 +03:00
Lauri Ojansivu 8f53000533 Close #1158: guard that list dragging is disabled for anonymous/read-only users.
#1158 (on a public read-only board an anonymous / not-logged-in user could still DRAG lists, which made mobile scrolling almost impossible even though the reorder was denied server-side) is already fixed by #5462: all three list sortables in client/components/swimlanes/swimlanes.js set disabled: !Utils.canModifyBoard(), and canModifyBoard() requires a current user who is an active, non-comment-only, non-read-only board member -- so an anonymous user (no currentUser) can never drag lists. Covered by the Playwright spec 35-list-sort-permissions.e2e.js; this adds a plain-node source-guard so the invariant (every list sortable gated by canModifyBoard, which is falsy for anonymous) cannot silently regress.

Thanks to GenericUK and xet7 !

Fixes #1158,
2026-07-22 13:02:42 +03:00
Lauri Ojansivu 6e99bb592a Changelog TODO Later: drop fixed issues, add newly-triaged ones with reasons.
Remove entries that have since been fixed (they carry Fixes #NNNN and close on push): #4560/#4419/#4158 (LDAP, startup-upgrade batch), #4825/#4897, #4822, #3826, #5282/#5547, #3453/#3199/#3843, #4593 and #3037 — with a short note in the intro pointing at them. Add newly-triaged still-open issues with the reason each is not fixed here: #1094/#2445 (need a running server stack / environment), #1658/#1946/#5052/#5421/#761 (need the running app to reproduce/verify), and #5081/#1213 (feature/design proposals). Drops the now-empty 'LDAP security/config' and 'Incomplete report' buckets.

Thanks to xet7.
2026-07-22 13:02:42 +03:00
github-actions[bot] 586620e329 Bump versions for v10.19 2026-07-22 09:40:18 +00:00
Lauri Ojansivu 92c983f813 Prepare v10.19 release 2026-07-22 12:39:07 +03:00
Lauri Ojansivu b37b37dfb2 Changelog: Upcoming — REST card board-move works + tested (#3037).
Thanks to rptl and xet7 !
2026-07-22 12:37:49 +03:00
Lauri Ojansivu 14dd48bb62 Close #3037: unit-test the REST card board-move params.
#3037 (moving a card between boards via the API bounced back to the old board when only the raw boardId/listId were edited) is already fixed: the edit-card handler (server/models/cards.js) normalizes newBoardId/newSwimlaneId/newListId via normalizeMoveParams, and when all three are present (isBoardMove) it validates the destination list+swimlane belong to the destination board and re-homes the card through cardMove(..., ['boardId','swimlaneId','listId'], ...) instead of a raw field update (Issue #5398/#5399). The pure helpers had only Playwright coverage; this adds a plain-node unit test (dynamic import of the ES helper) covering normalizeMoveParams (all-three => board move, a PARTIAL set is NOT a board move so a card is never half-moved, same-board move, empty/empty-string negatives) and computeTopSort, plus a source guard that the handler re-homes through cardMove. The supported API is newBoardId/newSwimlaneId/newListId (not a raw boardId edit).

Thanks to rptl and xet7 !

Fixes #3037,
2026-07-22 12:37:49 +03:00
github-actions[bot] 45e5401936 Bump versions for v10.18 2026-07-22 09:18:08 +00:00
Lauri Ojansivu ed82d5d966 Prepare v10.18 release 2026-07-22 12:17:18 +03:00
Lauri Ojansivu 3d4ee77d53 Changelog: Upcoming — Propagate Members To Boards flag now works (#4737/#5850).
Thanks to xet7 !
2026-07-22 12:15:56 +03:00
Lauri Ojansivu 5234d09d15 Org/Team: the 'Propagate Members To Boards' flag now actually propagates.
#4737/#5850: the per-org/team orgPropagateMembersToBoards / teamPropagateMembersToBoards flag was stored and shown in the Admin Panel, but the propagateOrgTeamMembersToBoards method that acts on it had NO caller anywhere — dead code, so turning the flag on did nothing. The propagation logic is refactored into exported propagateGroupMembersToBoards(kind, groupId) / propagateAllFlaggedGroupsToBoards() (the Meteor method now delegates to the latter), and the flag setters setTeamPropagateMembersToBoards / setOrgPropagateMembersToBoards now run the propagation for that group when the flag is turned ON. Still strictly add-only (existing board members are never removed/modified) and template boards are skipped; the add-only member computation is extracted to a pure, unit-tested models/lib/propagateMembers.js.

Tests: tests/propagateMembers.test.cjs (9 checks) covers membersToAddToBoard (add-only, de-dup, already-member, invalid inputs) and source guards that both flag-setters propagate on enable, the method delegates to the shared impl, and it stays add-only + board-type-only.

Thanks to xet7 !
2026-07-22 12:15:56 +03:00
Lauri Ojansivu b1236ba2dd Changelog: Upcoming — close #4593 (late-joining team member board membership).
Thanks to mianbaoshumoon and xet7 !
2026-07-22 12:15:56 +03:00
Lauri Ojansivu 16f653108c Close #4593: guard the team-member -> board-membership wiring.
#4593 (a user added to a team AFTER it was assigned to a board never gained membership/authority on that board) is already fixed: server/models/users.js addUserToTeamBoards uses the pure, tested models/lib/teamBoardMemberSync.js decision logic, and is called from BOTH editUser (the admin People-panel path in the reproduction) and the create-user-with-teams path, so a late-joining team member is pushed into board.members like the team's original members. The pure helper was covered by tests/teamBoardMemberSync.test.cjs; this adds source-guard tests that the two server call sites actually invoke it (and that it routes through the helper), so the fix cannot silently regress and reopen #4593.

Thanks to mianbaoshumoon and xet7 !

Fixes #4593,
2026-07-22 12:15:56 +03:00
Lauri Ojansivu ad3e88f11e Changelog: Upcoming — Tab in add-card no longer loses content (#1195).
Thanks to callahad and xet7 !
2026-07-22 12:15:56 +03:00
Lauri Ojansivu 533edca7dc Add-card: pressing Tab no longer loses the typed card text (#1195).
The add-card form's Tab handler (Template.addCardForm pressKey, keyCode === 9) is a deliberate power feature -- Tab jumps to the NEXT column's add-card form so cards can be added across columns quickly. But it opened that next form while abandoning whatever was typed in the current one, so pressing Tab mid-entry silently lost the card text (the card appeared to 'move to the next column' with no content). It now submits the current card first, but only when the textarea has non-whitespace content (so Tab in an empty box does not create a blank card), then jumps to the next column's form -- preserving both the content and the column-jump feature.

Tests: tests/addCardTabSubmit.test.cjs (4 checks) guards the ordering -- the Tab branch reads the textarea, submits on non-empty content BEFORE openForm of the next list, still opens the next column, and does not submit for an empty box.

Thanks to callahad and xet7 !

Fixes #1195,
2026-07-22 12:15:56 +03:00
github-actions[bot] 6be85973b3 Bump versions for v10.17 2026-07-22 08:29:36 +00:00
Lauri Ojansivu d3ad662535 Prepare v10.17 release 2026-07-22 11:28:45 +03:00
Lauri Ojansivu 9bede25197 Changelog: use commit links (not issue links) for recent bug-fix entries.
Convert the Upcoming (#3192) and v10.14/v10.15/v10.16 bug-fix bullets I added from '[#NNNN](issues/NNNN)' to the CLAUDE.md format '[description (#NNNN)](commit-url). Thanks to X and xet7.' -- #3192 (c0bbacaf9), FerretDB polling-only #6503 (b15d89dd0), i18n raw-key #6503 (eb411d9a5), rules remove-all-members #2674 (e1ebe978b), archived-swimlane #1971/#1959 (52bb15594), @member autocomplete (2328bcd0f), #label autocomplete (728cd929c). The CRITICAL/Bleed security entries keep their hall-of-fame links and the Dependabot entries keep their CVE/advisory links, as those are the correct references.

Thanks to xet7.
2026-07-22 11:26:13 +03:00
Lauri Ojansivu 72d7c41e18 Docs: expand Snap-Ondra-Gantt step 4 (WEKAN_REPO_TOKEN) + note snap-variants is disabled.
Detail how to extend WEKAN_REPO_TOKEN to the two variant repos for each token type: a classic 'repo' PAT usually needs nothing changed (just org write access), a fine-grained PAT must add wekan-ondra/wekan-gantt-gpl with Contents: Read/Write, or use per-repo deploy keys -- the stored secret value never changes either way (no gh secret set step), with a throwaway-push verify command. Also correct the now-stale status note: the snap-variants job is currently hard-disabled with 'if: ${{ false }}' (grouped with the failing snap exotic-arch jobs), so publishing variants now needs BOTH steps 1-4 AND removing that 'if' line (new step 5), and update the checklist to match.

Thanks to xet7.
2026-07-22 11:19:39 +03:00
Lauri Ojansivu 03ad7688dd release-all.yml: disable the failing snap extra-arch + variant/store jobs.
The snap builds for the exotic arches fail, so skip them on release: snap-qemu (ppc64el, s390x snaps), snap-launchpad (riscv64 snap), snap-variants, nextcloud and ucs each get 'if: ${{ false }}' (re-enable by removing that line). build-extra-arches is kept ENABLED: it builds the wekan-<version>-{ppc64le,s390x,riscv64}.zip bundles that the docker job bakes into the full multi-arch image (amd64, arm64, ppc64le, s390x, riscv64) -- that path works, only the snap exotic-arch builds fail.

Thanks to xet7.
2026-07-22 11:19:39 +03:00
Lauri Ojansivu 938ef2e59f Changelog: Upcoming — due-date reminder hour 0 + assignee notify (#3192).
Thanks to SirConfigMgr and xet7 !
2026-07-22 11:19:39 +03:00
Lauri Ojansivu c0bbacaf94 Due-date reminders: honour hour 0 (midnight) and notify card assignees (#3192).
Two source bugs kept due-date reminder mail from reaching people. (1) The reminder hour was parsed as parseInt(NOTIFY_DUE_AT_HOUR_OF_DAY, 10) || 8, so a configured hour of 0 (midnight) silently became 8 (0 is falsy) and out-of-range hours were accepted; parseNotifyDueHour now keeps 0 and rejects anything outside 0..23. (2) A due-card activity's notification participants were only the card CREATOR and its MEMBERS (server/models/activities.js) -- a user ASSIGNED a card but not a member was never notified about it, including its due date; card.assignees are now included too (still gated downstream by the board tracking level, so nobody who is not tracking is newly emailed). The NOTIFY_DUE_* parsing is extracted to a pure, unit-tested models/lib/dueNotificationConfig.js. Note: the built-in reminder still requires NOTIFY_DUE_DAYS_BEFORE_AND_AFTER to be set, and members/assignees are emailed per their board watch/tracking level.

Tests: tests/dueNotificationConfig.test.cjs (12 checks) covers the midnight-hour case, valid/invalid/out-of-range hours, the day-offset window parsing and negatives, plus source guards that cards.js uses parseNotifyDueHour and that assignees are in the notification participants.

Thanks to SirConfigMgr and xet7 !

Fixes #3192,
2026-07-22 11:19:39 +03:00
github-actions[bot] 8f2189a66f Bump versions for v10.16 2026-07-22 07:38:52 +00:00
Lauri Ojansivu b53619e20f Prepare v10.16 release 2026-07-22 10:37:18 +03:00
Lauri Ojansivu a2ef7298e2 Updated translations. 2026-07-22 10:37:12 +03:00
Lauri Ojansivu 4c5a63648a Changelog: Upcoming — FerretDB polling-only default (#6503).
Thanks to xet7 !
2026-07-22 10:35:52 +03:00
Lauri Ojansivu 375dc3976f Tests: allow WAL/SHM sidecar cleanup in the ferretdb-control rm guard.
ferretdbOplogReset.test.cjs forbade any rm touching 'wekan.sqlite', but ferretdb-control legitimately removes the transient wekan.sqlite-wal/-shm sidecars before copying a backup over the data file. The guard now forbids only the bare data file / glob (via a (?!-) lookahead) and allows the -wal/-shm/-journal sidecars. Pre-existing test failure, source is correct.

Thanks to xet7.
2026-07-22 10:35:52 +03:00
Lauri Ojansivu b15d89dd0c FerretDB: default to polling-only on all platforms; OpLog is opt-in (#6503).
FerretDB v1 can tail an OpLog, but on the SQLite backend Meteor's tailable+awaitData OpLog tail keeps FerretDB CPU pinned (reporters saw ~190-390% even idle; a 2-core box maxed out) and a struggling tail also surfaces as 'oplog catching up took too long', stalling board and login-page loading. Despite the FerretDB-side mitigations (16 MiB capped oplog, 500ms awaitData poll, SQLite pragmas, CPU self-regulation) the peg persisted on real deployments, and the polling-only path reliably drops CPU to ~10%. So make POLLING the default on every launcher (snap wekan-control, bundle start-wekan.sh, docker wekan-entrypoint.sh + docker-compose.yml, windows start-wekan.bat, sandstorm start.js): WEKAN_FERRETDB_OPLOG now defaults false, docker-compose reactivity is 'polling' with MONGO_OPLOG_URL commented out. The OpLog machinery still exists and is opt-in via WEKAN_FERRETDB_OPLOG=true. The snap exposes it as a setting: 'snap set wekan wekan-ferretdb-oplog=true|false' (registered in snap-src/bin/config, default false). The calmer polling throttle (#6467/#6468) and SQLite pragma tuning keep poll-and-diff cheap.

Tests: tests/ferretdbOplog.test.cjs updated to assert the polling-only default everywhere, the commented-out docker-compose MONGO_OPLOG_URL, the opt-in OpLog machinery still present, and the new wekan-ferretdb-oplog snap setting (default false).

Thanks to xet7 !
2026-07-22 10:35:52 +03:00
Lauri Ojansivu 2cde7bf25c Changelog: Upcoming — i18n raw-key robustness (#6503).
Thanks to Alishara and xet7 !
2026-07-22 10:35:52 +03:00
Lauri Ojansivu eb411d9a58 i18n: never get stuck on raw keys if a dynamic language import fails (#6503).
TAPi18n.init() awaited a dynamic import('./data/en.i18n.json') and only set TAPi18n.ready=true afterwards. If that dynamic chunk hung or rejected -- e.g. a stale snap client bundle after a refresh, the reported #6503 scenario -- ready stayed false forever and the '_' Blaze helper returned every key raw (the untranslated changeLanguagePopup-title), with no recovery. Two guards: (1) the default English is now STATICALLY bundled (import enData from './data/en.i18n.json') and registered first, so the UI is always readable even when dynamic import() is broken; every other language still loads lazily. (2) the normal loadLanguage('en') (which also layers custom DB translations) is bounded by a 10s timeout and its failure is swallowed, so ready.set(true) is reached in EVERY path. index.js also guards the startup so init can never leave an unhandled rejection. The ES-module-namespace unwrap and the timeout guard are extracted to a pure, unit-tested imports/i18n/loadHelpers.js and shared with loadLanguage().

Tests: tests/i18nLoadHelpers.test.cjs (10 checks) covers unwrapI18nModule (real namespace vs the #5756 literal-"default"-key trap, null/plain) and promiseWithTimeout (resolves/rejects in time, and REJECTS instead of hanging when the promise never settles), plus a source guard that init sets ready in every path. Note: this hardens the i18n (raw-key) symptom and makes it recoverable; the dead OIDC/Register buttons need in-browser confirmation of the stale dynamic-import cache (see issue notes).

Thanks to Alishara and xet7 !
2026-07-22 10:35:52 +03:00
github-actions[bot] ad9e7752b8 Bump versions for v10.15 2026-07-22 07:17:28 +00:00
Lauri Ojansivu 06fb83d0b4 Prepare v10.15 release 2026-07-22 10:16:33 +03:00
Lauri Ojansivu 32bf2f83f9 Changelog: Upcoming — #2674 rules 'remove all members' fix.
Thanks to sfahrenholz, mweiss237 and xet7 !
2026-07-22 10:15:50 +03:00
Lauri Ojansivu e1ebe978b9 Rules: 'remove all members' action now removes the card's assignees (#2674).
The rule member actions operate on a card's ASSIGNEES: addMember calls card.assignMember() and removeMember calls card.unassignMember() (both write card.assignees). The 'remove all' branch (username === '*') however iterated card.members -- a different collection -- so it unassigned nobody a rule had assigned and 'remove every member' silently did nothing. Iterate the card's assignees (card.getAssignees()) instead, snapshotting the ids first since unassignMember pulls from that same array. The single-user add/remove were already symmetric (the earlier #2674 fix that made them assign/unassign); this completes it for the wildcard case.

Tests: tests/ruleRemoveMemberAction.test.cjs (6 checks) pins the add/remove symmetry, that the '*' branch reads assignees (not card.members) and still unassigns each, and the defensive skip for an unresolved username.

Thanks to sfahrenholz and xet7 !

Fixes #2674,
2026-07-22 10:15:50 +03:00
Lauri Ojansivu 788e346d01 Changelog: Upcoming — also close #1959 (same swimlane-visibility fix).
Thanks to rangersdo, hnb1 and xet7 !
2026-07-22 10:15:50 +03:00
Lauri Ojansivu da8aa100ff Close #1959: cards no longer vanish when their swimlane is deleted.
Same root cause as #1971, now fixed: new cards and the List-view card drop both resolve the target swimlane through Board.getDefaultSwimline() (client/components/lists/list.js), which now skips archived swimlanes (pickDefaultSwimlane) -- so a card is never assigned to a deleted/archived 'General' swimlane and made invisible in Swimlane view. Cards already orphaned by an earlier swimlane deletion keep surfacing in the first swimlane via the #6443 orphanedCardsSwimlaneIds fallback (covered by swimlaneFilter/ruleMoveAction tests). Adds a wiring guard tying the two together.

Thanks to hnb1 and xet7 !

Fixes #1959,
2026-07-22 10:15:50 +03:00
Lauri Ojansivu a654bef5cb Changelog: Upcoming — #1971 cards no longer added to an archived swimlane.
Thanks to rangersdo and xet7 !
2026-07-22 10:15:50 +03:00
Lauri Ojansivu 8666069c9f Tests: update stale Home-board tile assertion for the #2220 'Selected:' action.
defaultBoard.test.cjs asserted boardsList.js never calls toggleDefaultBoard, but d67d27f0d re-enabled setting the Home board via the 'Selected:' bulk action (js-home-selected), which legitimately calls it. The per-tile toggle stays disabled; the test now checks that instead of forbidding the call outright. Test-only; source is correct.

Thanks to xet7.
2026-07-22 10:15:50 +03:00
Lauri Ojansivu 52bb15594b Cards added in List view no longer land in an archived swimlane.
Board.getDefaultSwimline()/getDefaultSwimlineAsync() picked the first swimlane for the board with no archived filter (ReactiveCache.getSwimlane({ boardId })), so when the first/default swimlane was archived, a card added in List view was assigned to that archived swimlane and became invisible in Swimlane view (visible only in List view until the swimlane was unarchived). Both getters now choose via a pure pickDefaultSwimlane() helper that prefers the first NON-archived swimlane, falling back to the first one only when every swimlane is archived (so callers reading ._id never crash) and returning undefined only when the board has no swimlanes at all -- the single case where the server self-heals a fresh default (#6382/#6429 idempotency preserved).

Tests: tests/defaultSwimlane.test.cjs gains pickDefaultSwimlane cases -- the reported scenario (default archived + active swimlanes -> first active), unchanged behaviour when the first is active, all-archived fallback, empty/null -> undefined, skipping null entries, and a source guard that both getters route through the helper.

Thanks to rangersdo and xet7 !

Fixes #1971,
2026-07-22 10:15:50 +03:00
Lauri Ojansivu 22d1c5505b Changelog: Upcoming — case-insensitive '@' member autocomplete (#5116 follow-up).
Thanks to rjl9 and xet7 !
2026-07-22 10:15:50 +03:00
Lauri Ojansivu 2328bcd0f1 Case-insensitive '@' member autocomplete (shared, null-safe).
Follow-up to the #5116 label fix: the add-card-title '@' member mention in client/components/lists/listBody.js matched with user.username.indexOf(term) === 0 -- case-sensitive, prefix-only, username-only, and it threw when ReactiveCache.getUser() returned null. So '@ann' did not suggest 'Anna', '@Anna' did not match username 'anna', and a full-name search never matched. The card description/comment editor (client/components/main/editor.js) already matched case-insensitively on username OR full name but shared no code and had the same null hazard. Both now route through a pure, unit-tested models/lib/memberAutocomplete.js (memberMatchesTerm / filterMembersByTerm) that lowercases the term and matches username or profile.fullname as a substring, and is null-safe.

Tests: tests/memberAutocompleteMatch.test.cjs (11 checks) covers case-insensitive + substring + full-name matching, empty-term-shows-all, null/undefined user (the old crash), missing profile, order preservation, and source guards that both '@' call sites use the shared matcher.

Thanks to rjl9 and xet7 !
2026-07-22 10:15:50 +03:00
github-actions[bot] 3e1a1f4917 Bump versions for v10.14 2026-07-22 06:23:12 +00:00
Lauri Ojansivu f963e85bdb Prepare v10.14 release 2026-07-22 09:22:00 +03:00
Lauri Ojansivu 9662853ae5 Changelog: Upcoming — #5116 case-insensitive '#' label autocomplete.
Thanks to rjl9 and xet7 !
2026-07-22 09:21:21 +03:00
Lauri Ojansivu 728cd929cd Case-insensitive '#' label autocomplete in the card title.
Typing '#test' in the add-card / edit-card-title field did not suggest a label named 'Testing': the label search matched with a case-SENSITIVE label.name.indexOf(term) (and label.color.indexOf(term)), so only '#Test' matched 'Testing'. The board's '@' member autocomplete already matches case-insensitively; this brings the '#' label autocomplete in line. Extracted the match into a pure, unit-testable models/lib/labelAutocomplete.js (labelMatchesTerm / filterLabelsByTerm) that lowercases both the term and the label name/color, mirroring the other models/lib pure helpers.

Tests: tests/labelAutocompleteMatch.test.cjs covers the exact #5116 scenario (#test -> Testing), color-fallback matching, empty-term-shows-all, order preservation, and negatives (non-substring, missing name/color, null label, non-array), plus a source guard that listBody.js routes through the shared matcher. 11 checks pass.

Thanks to rjl9 and xet7 !

Fixes #5116,
2026-07-22 09:21:21 +03:00
Lauri Ojansivu e6e013bb07 Changelog: Upcoming — rspack dev-toolchain Dependabot fixes (#107/#113/#114).
Document the webpack-dev-server 5.2.6 and http-proxy-middleware 2.0.10 overrides in the Upcoming security section, why elliptic (#55) has no fix, and why the @rspack/cli 1->2 and @babel/parser 7->8 major bumps are deferred.

Thanks to GitHub Dependabot and xet7.
2026-07-22 09:21:21 +03:00
Lauri Ojansivu 1f75d492cb Deps: override webpack-dev-server 5.2.6 + http-proxy-middleware 2.0.10 (security).
Resolve three Dependabot alerts in the rspack build-toolchain dev dependencies without touching the rspack major version, so builds are unaffected. Both packages enter only via @rspack/dev-server (the 'rspack serve' dev server) and are not used by 'meteor build'. webpack-dev-server 5.2.5 -> 5.2.6 fixes CVE-2026-14631 (DoS via malformed Host/Origin header, alert #113) and CVE-2026-14620 (CSRF via internal /webpack-dev-server endpoints, alert #114). http-proxy-middleware 2.0.9 -> 2.0.10 fixes CVE-2026-55602 (host+path router substring-match backend bypass, alert #107). Both are patch releases within the same major line, so the lock resolves cleanly (only those two entries change) and the rspack CLI/core stay at 1.7.11. elliptic (alert #55, CVE-2025-14505) has no fixed version published upstream (latest is still 6.6.1), so it is left pinned; the @rspack/cli 1->2 (PR #6497) and @babel/parser 7->8 (PR #6484, a runtime dependency) major bumps are deferred as they need build/runtime testing first.

Thanks to GitHub Dependabot and xet7.
2026-07-22 09:21:21 +03:00
Lauri Ojansivu 18175abef5 Docs: 'Fix open issues' process, sandbox build+test, #6500 startup note.
CLAUDE.md gains the 'Fix open issues' process (research newest maintained dependencies via Meteor 3 docs/issues/forums, atmosphere/packosphere/npm, add tests+negative+UI tests, commit format). docs/Security/Sandboxes/vscode/README.md documents a version-matched from-scratch toolchain in .tools/ (Node v24.18.0, npm, Meteor 3.5, newest Go for FerretDB) to build the WeKan bundle + FerretDB and run all tests, and notes Docker/podman is unavailable in the Flatpak sandbox. server/00waitForMongo.js keeps the Meteor.startup wait but documents the #6500 finding: accounts-base creates the users index before app hooks, so a top-level await here deadlocks — the reliable fix is the docker-compose healthcheck.

Thanks to xet7.
2026-07-22 09:21:21 +03:00
Lauri Ojansivu e6ced014c5 Tests: make three .cjs suites runnable in a from-scratch node sandbox.
ldapConnectionRelease: strip comments before the source-guard search so a comment mentioning ldap.connect() (before the guarded call) no longer trips the 'connect inside the guard' assertion. htmljsArrayContent: provide a minimal Package global before requiring the vendored htmljs.js, which reads Package.meteor/Package.tracker at load before creating Package itself. ferretdbOplog: accept the hardcoded polling launcher form as well as the ${VAR:-polling} form. Source code unchanged; these are test-harness fixes surfaced by running the suite under a clean Node install.

Thanks to xet7.
2026-07-22 09:21:21 +03:00
Lauri Ojansivu 6467f3c227 Changelog: Upcoming security section for SpliceBleed and IdentityBleed.
Document the two GitHub CodeQL code scanning fixes already committed (alerts #425 js/incomplete-multi-character-sanitization in imports/lib/fileNameDisplay.js and #424 js/identity-replacement in tests/securityLog.test.cjs) in a new Upcoming section above v10.13, linked to the wekan.fi Hall of Fame entries.

Thanks to GitHub CodeQL and xet7.
2026-07-22 09:21:21 +03:00
Lauri Ojansivu c0e6231703 Fix CodeQL js/identity-replacement in securityLog test.
A menu-id regex was built with id.replace('report-', 'report-') — replacing a substring with itself, a no-op CodeQL flags because it is almost always a mistake for a real transformation. Match 'js-' + id directly instead. Resolves code scanning alert #424. Test-only, no runtime exposure.

Thanks to xet7.
2026-07-22 09:21:21 +03:00
Lauri Ojansivu 9e5d9dc019 Fix CodeQL js/incomplete-multi-character-sanitization in fileNameDisplay.
stripExploitPatterns() stripped HTML/script/template markup in a single pass, so nested or interleaved fragments — e.g. <scr<x>ipt> or <scr{{y}}ipt> — could have the inner part removed and the two surviving outer fragments spliced into a fresh <script> token that the single pass no longer re-examined. Apply the removals repeatedly until the string stops changing (a fixpoint); each pass only deletes text, so the string strictly shrinks and the loop always terminates, and any dangerous token an earlier removal reveals is removed too. Resolves code scanning alert #425. Blaze {{ }} already HTML-escapes every rendered filename, so this is defence-in-depth on displayed text, but the single-pass strip was genuinely incomplete.

Thanks to xet7.
2026-07-22 09:21:21 +03:00
Lauri Ojansivu fd1bad4917 CLAUDE.md: test-log location and the VSCode Flatpak sandbox
Document that 'check newest test logs' means the newest ../log/<datetime>/
directory, and that VSCode runs in the Flatpak sandbox from
docs/Security/Sandboxes/vscode/vscode-sandbox.sh (details in that README.md).

Thanks to xet7 !
2026-07-22 09:21:21 +03:00
Lauri Ojansivu a78843330c Translations: never overwrite human translations; add CLAUDE.md instructions
pull-translations.sh ran tx pull -f (which fills untranslated strings with the
English source) and then only auto-healed files whose changes were ENTIRELY
reverts -- a file that also got a real new Transifex translation was skipped, so
its reverted strings were lost back to English. Replace that whole-file heal with
a per-KEY merge (releases/translations/merge-translations.mjs): for every language
file and key, keep Transifex's newest human translation, restore a committed human
translation the pull reverted to English (even in files that also received real
new translations), and leave only genuinely-missing keys (untranslated on Transifex
AND never committed) as the English placeholder for machine translation. So a pull
always brings the newest Transifex translations and never overwrites a human
translation with English/machine; only missing-everywhere strings are machine
translated. Restored languages are pushed back to Transifex.

Add CLAUDE.md at the repo root (read first by Claude) documenting: the translation
policy above; that direct-commit / commit-as-Lauri-Ojansivu / all release steps are
MAINTAINER ONLY (only when git identity is Lauri Ojansivu <x@xet7.org>) while other
contributors use pull requests; plus the general practices, CHANGELOG structure,
no-argument release flow and test conventions from ../log/v10/Claude.txt.

Thanks to xet7 !
2026-07-22 09:21:21 +03:00
github-actions[bot] f32e389cef Bump versions for v10.13 2026-07-21 21:05:02 +00:00
Lauri Ojansivu 988ec1bffa Prepare v10.13 release 2026-07-22 00:04:10 +03:00
Lauri Ojansivu c5bc8cb1fc Changelog: Upcoming — fast-uri (#6502) and linkify-it (#6501) bumps.
Thanks to dependabot and xet7 !
2026-07-22 00:02:31 +03:00
Lauri Ojansivu 7806367421 Merge pull request #6502 from wekan/dependabot/npm_and_yarn/fast-uri-3.1.4
Bump fast-uri from 3.1.2 to 3.1.4
2026-07-21 23:59:08 +03:00
Lauri Ojansivu 08fa72faf1 Merge pull request #6501 from wekan/dependabot/npm_and_yarn/linkify-it-5.0.2
Bump linkify-it from 5.0.1 to 5.0.2
2026-07-21 23:58:33 +03:00
dependabot[bot] 8e35252185 Bump fast-uri from 3.1.2 to 3.1.4
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 20:46:12 +00:00
dependabot[bot] ca5105e62b Bump linkify-it from 5.0.1 to 5.0.2
Bumps [linkify-it](https://github.com/markdown-it/linkify-it) from 5.0.1 to 5.0.2.
- [Changelog](https://github.com/markdown-it/linkify-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/linkify-it/compare/5.0.1...5.0.2)

---
updated-dependencies:
- dependency-name: linkify-it
  dependency-version: 5.0.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 20:46:03 +00:00
github-actions[bot] af57799765 Bump versions for v10.12 2026-07-21 20:44:52 +00:00
Lauri Ojansivu e131a0de82 Prepare v10.12 release 2026-07-21 23:43:59 +03:00
Lauri Ojansivu 1075690927 Changelog: Docker start healthcheck (#6500) + Files report FerretDB fix.
Thanks to youhajjioui and xet7 !
2026-07-21 23:15:31 +03:00
Lauri Ojansivu 930da57fd1 Fix Docker fresh start: wait for FerretDB to be ready before starting WeKan
On a fresh `docker compose up`, the browser got "Connection reset by peer" and
WeKan never loaded (#6500). The compose's ferretdb service downloads its binary at
first run (apt-get + curl), so it is not listening for a while after its container
starts. WeKan's depends_on used `condition: service_started`, which only waits for
the ferretdb CONTAINER to start, not for the database to accept connections -- so
WeKan started against a not-yet-ready database and failed. The comment above
depends_on already said WeKan should wait for the DB to be HEALTHY; the config just
did not implement it.

Add a healthcheck to the ferretdb service (a dependency-free bash /dev/tcp probe of
127.0.0.1:27017, healthy only once FerretDB is actually listening, with a
start_period that covers a slow first download) and change WeKan's depends_on to
`condition: service_healthy`, so WeKan starts only after FerretDB is ready.

Thanks to youhajjioui and xet7 !

Fixes #6500,
2026-07-21 23:15:03 +03:00
Lauri Ojansivu 80e87f27b8 Fix admin Files report empty on FerretDB: avoid $elemMatch in accessibleCardIds
The admin Files report showed "No results" on FerretDB (spec 41 failed in every
Playwright run). Root cause, reproduced against a real FerretDB with the Go Mongo
driver: older FerretDB v1 builds reject {members:{$elemMatch:{userId,isActive:true}}}
with "(BadValue) unknown operator: userId". accessibleCardIds used exactly that
query, and the attachmentsList publication's catch swallowed the error, so the
report silently returned nothing. Current FerretDB handles $elemMatch; the build in
the test harness does not -- which is why board-load tests using a different query
still passed.

accessibleCardIds now matches board membership by the dotted path
{'members.userId': userId} (works on every FerretDB build) and confirms in JS that
the user's own member entry is active, preserving the exact per-element semantics
$elemMatch gives (a userId appears in at most one member entry). Verified with the
Mongo driver against both FerretDB v1.24.2 (was erroring) and v1.37.0: the full
pipeline now returns the 5 seeded attachments on both.

Thanks to xet7 !
2026-07-21 23:08:25 +03:00
Lauri Ojansivu c6b2377598 Files report: surface the hidden publish error + self-localize the e2e test
The admin Files report has shown "No results" in every Playwright run (spec 41),
with nothing in the server log — because the attachmentsList publication swallowed
any error from its query in a catch that only logged when DEBUG=true. Stop hiding
it: the catch now always logs the error (with stack), and under DEBUG the
publication logs whether attachmentsReportQuery returned null (the user has no
accessible cards) versus a query that matched 0 attachments, so the two failure
modes are distinguishable.

Spec 41 now localizes the failure itself: it asserts the seed produced a real
cardId (so a null cardId, which would make meta.cardId never match, fails with a
clear message), and it probes getAttachmentsReportCount directly after opening the
report — that method runs the SAME accessibleCardIds + meta.cardId query the
publication uses, so if the server count is >= 5 but the table is missing it is a
client-render problem, and if it is 0 or errors it is the server query — instead of
just "table not visible".

No runtime is available in this environment to execute Playwright, so this makes
the next run point straight at the failing layer.

Thanks to xet7 !
2026-07-21 22:57:31 +03:00
Lauri Ojansivu 9c8f88872c Updated translations 2026-07-21 20:57:31 +03:00
Lauri Ojansivu b99a81b47e releases/release-all.sh: release with no version argument
Auto-detect the version instead of requiring one: if there is a
'# Upcoming WeKan ® release' section, rename it to the next version (the same
increment as the last release, e.g. 10.11 -> 10.12) dated today; otherwise use
the newest release heading (already renamed), sanity-checked as the +1 increment
and referenced by a recent commit so an old entry is never re-released. Explicit
'PREVIOUS NEW' args still override.

Thanks to xet7 !
2026-07-21 20:35:18 +03:00
Lauri Ojansivu 81150ee7bd Changelog: import virtual users + sidebar mapping + hang mitigation.
Thanks to xet7 !
2026-07-21 19:44:18 +03:00
Lauri Ojansivu c0a1a27847 Import: always create virtual users, map later from the sidebar; bound imports
Board import no longer asks for member mapping up front. Every imported member is
brought in as a virtual (placeholder) user carrying its avatar, username and full
name (the existing createPlaceholderUsers path), added to the board inactive with
no permissions. The client import flow is now a single step that imports
immediately with an empty mapping -- no map-members screen, nothing to get stuck
on.

Mapping a virtual member to an existing user is a deliberate, later action done by
a board admin from the board sidebar member-avatar popup ("Map to existing user"),
and CANNOT be used to gain privileges: the new board-scoped method
mapImportedBoardMember only maps a virtual member onto an existing ACTIVE, REAL
member of the SAME board and never changes that member's role (so no new
membership is created and nothing is escalated); authorization and this guarantee
live in the pure planBoardMemberMapping helper. The mapping reassigns the virtual
member's cards/comments/activities ON THAT BOARD to the target and removes the
placeholder from the board.

Automatic hang mitigation so an import can never spin forever: a client watchdog
surfaces an import-timeout and clears the spinner if importBoard does not answer in
WEKAN_IMPORT_TIMEOUT_MS (default 2 min), and the server bounds creator.create with
the same deadline via a new generic withDeadline() wrapper.

Covered by tests/boardMemberMapPlan.test.cjs (no-escalation + auth matrix),
tests/withDeadline.test.cjs (deadline wrapper) and tests/importVirtualMembers.test.cjs
(wiring).

Thanks to xet7 !
2026-07-21 19:43:48 +03:00
Lauri Ojansivu 2659780f4a Changelog: import assign-members selection fix.
Thanks to xet7 !
2026-07-21 18:51:38 +03:00
Lauri Ojansivu 8db9c183a7 Fix import "Assign members": suggestions could not be selected
In the board/JSON import "Select member" popup, typed suggestions appeared but
neither clicking nor Enter assigned the user, so mapping a migrated board's
members to existing WeKan users was impossible. The click handler mapped
Template.currentData().__originalId -- a field that is never set on the search
results (they are plain WeKan user documents with _id) -- so it mapped undefined
and silently did nothing.

Map the clicked result by its _id, and add an Enter handler that selects the first
(highlighted) result so a name can be assigned by keyboard. Member mapping stays
optional: the step-1 "import without mapping" and the in-step "skip mapping"
buttons still import immediately (unmapped members default to the current user).
Guarded by tests/importMemberMapping.test.cjs.

Thanks to xet7 !
2026-07-21 18:51:22 +03:00
Lauri Ojansivu 738e0777b6 Changelog: move #6493 cleanFilename fix to Upcoming (post-v10.11).
Thanks to mueschel, brlin-tw and xet7 !
2026-07-21 18:46:00 +03:00
Lauri Ojansivu 8d0c00db12 Changelog: cards-not-opening cleanFilename fix (#6493).
Thanks to mueschel, brlin-tw and xet7 !
2026-07-21 18:39:09 +03:00
Lauri Ojansivu a82bb351e4 Fix cards not opening: register the cleanFilename client helper
client/components/main/safeFilename.js registers the global Blaze helpers
cleanFilename and downloadFilename, but it was never imported on the client, so
the helpers were never registered. Every template that uses {{cleanFilename name}}
-- card attachment thumbnails (client/components/cards/attachments.jade) and the
admin Files report -- therefore threw "No such function: cleanFilename" during
render. On a card with attachments this aborted the card's Tracker recompute, so
clicking a card did nothing (the card would not open).

Import safeFilename.js from client/features/main.js alongside the other
client/components/main modules so the helpers are registered at startup. Guarded
by tests/fileNameDisplay.test.cjs.

Thanks to mueschel, brlin-tw and xet7 !

Fixes #6493,
2026-07-21 18:39:09 +03:00
Lauri Ojansivu 6014608b9d Merge pull request #6495 from wekan/dependabot/npm_and_yarn/shell-quote-1.10.0
Bump shell-quote from 1.8.4 to 1.10.0
2026-07-21 18:38:13 +03:00
Lauri Ojansivu 76c1818780 Merge pull request #6496 from wekan/dependabot/npm_and_yarn/brace-expansion-5.0.7
Bump brace-expansion from 5.0.6 to 5.0.7
2026-07-21 18:37:36 +03:00
github-actions[bot] 5b8dae9b3f Bump versions for v10.11 2026-07-21 10:05:13 +00:00
Lauri Ojansivu c948d5bba5 Changelog: split newest changes into v10.11 above v10.10.
Thanks to xet7 !
2026-07-21 12:43:35 +03:00
Lauri Ojansivu 0fcd3dbf12 Updated translations. 2026-07-21 11:04:49 +03:00
Lauri Ojansivu b4853e1135 Changelog: FerretDB idle-CPU OpLog tail fix (#6498).
Thanks to Alishara, bluetopaz1204, mueschel and xet7 !
2026-07-21 10:57:32 +03:00
Lauri Ojansivu a4c57cf775 FerretDB polling mode: unset MONGO_OPLOG_URL so Meteor never tails the OpLog
Merely having MONGO_OPLOG_URL set makes Meteor start an OpLog tail at boot (a
tailable+awaitData cursor on local.oplog.rs) regardless of the reactivity order,
and on FerretDB that tail polls the database continuously -- pinning CPU even
with no clients connected. The polling-only branch of each launcher set
METEOR_REACTIVITY_ORDER=polling but left an already-set MONGO_OPLOG_URL in place,
so "polling only" still tailed the OpLog.

Every FerretDB launcher (snap wekan-control, bundled start-wekan.sh, Docker
wekan-entrypoint.sh, Sandstorm start.js) now actively clears MONGO_OPLOG_URL in
its polling-only branch, so choosing polling truly does no OpLog tailing. The
OpLog-enabled (default) path is unchanged. Covered by tests/ferretdbOplog.test.cjs.

This complements the FerretDB fork fix that stops awaitData from busy-polling the
tail in the default OpLog mode (wekan/FerretDB, wekan/wekan#6498).

Thanks to Alishara, bluetopaz1204, mueschel and xet7 !

Fixes #6498,
2026-07-21 10:56:41 +03:00
Lauri Ojansivu b12a589ccd Changelog: Sandstorm grain migration embedded-page fix.
Thanks to xet7 !
2026-07-21 10:06:41 +03:00
Lauri Ojansivu 3d7ef849ba Sandstorm: fix "cannot open embedded page" error during grain migration
On a grain's first launch after upgrade, sandstorm-src/start.js migrates the old
MongoDB data to FerretDB v1 (SQLite) before starting WeKan. While nothing was
listening on the grain's app port (during migration and the handoff to WeKan),
sandstorm-http-bridge got connection-refused and the browser showed "This page
can not be displayed embedded in another page"; the user had to close the grain
and open it again.

start.js now runs migration-bridge.js as a child process on the app port that
answers every URL with a small auto-refreshing "please wait" page (HTTP 503), so
the grain stays framed until WeKan is up. It is a separate process on purpose:
start.js runs the migration with blocking spawnSync, so it could neither service
an in-process server nor release its listening socket before the importer/Meteor
bind the port -- killing a child is a definitive socket close. The bridge starts
before migration, YIELDS the port around the importer (which serves its own
progress dashboard there), resumes for the handoff, and is released just before
Meteor binds the port. Every use is best-effort (guarded by existsSync, error
handlers, killed on grain exit) so it can never break grain startup.
build-deps.sh ships migration-bridge.js next to start.js in the deps payload.

Thanks to xet7 !
2026-07-21 10:06:41 +03:00
Lauri Ojansivu 324fb0bc2d Changelog: cross-platform recovery maintenance page (#6492).
Thanks to bluetopaz1204, mueschel and xet7 !
2026-07-21 10:06:41 +03:00
Lauri Ojansivu 8e4d57c21d Show a recovery maintenance page on all FerretDB v1 platforms
During a #6492 data recovery, users should never see a bare connection error.
Two layers now cover the whole window on every FerretDB v1 launch path (snap,
bundled release, Docker):

- In-app spinner (already present): the server clears the RECOVERY_IN_PROGRESS
  marker only after a real health probe, so the full-screen overlay behaves
  identically everywhere.
- Static bridge page (new, cross-platform): for the brief window while a
  just-restored FerretDB comes back up and before Meteor can serve the client,
  the launch scripts serve a tiny standalone "recovering your data" page (HTTP
  503) on the web port. The snap reuses wekan-maintenance-page.mjs with a
  recovery wording; release/Docker serve the portable recovery-bridge.mjs. The
  bridge is time-bounded (WEKAN_RECOVERY_BRIDGE_SECONDS, default 20s) so it can
  never block WeKan from starting, hands straight over to the in-app spinner, and
  is skipped if its page file or the marker is absent.

The launch scripts do NOT delete the marker; only the server does, after
verifying the database serves reads. Tests enforce the bridge is bounded, is
guarded by the page file, and that no script deletes the marker.

Thanks to bluetopaz1204, mueschel and xet7 !

Fixes #6492,
2026-07-21 10:06:41 +03:00
Lauri Ojansivu 306d79ec0a Changelog: recovery maintenance spinner (#6492). 2026-07-21 10:06:41 +03:00
Lauri Ojansivu 9ea7f11d27 Show a full-page maintenance spinner while data recovery is in progress
#6492: while the SQLite text data is being recovered/remediated, users should see a
maintenance spinner instead of errors or half-loaded data.

- A published singleton status (models/recoveryStatus.js) carries an "active" flag +
  message. The publication (server/publications/recoveryMaintenance.js) is PUBLIC so
  even logged-out users on the sign-in page see it; the toggle method
  (setRecoveryMaintenance) is admin-only.
- The client overlay (client/components/main/recoveryMaintenance.*) shows a full-screen
  spinner + message when active; it is subscribed globally and rendered in both the app
  (defaultLayout) and sign-in (userFormsLayout) layouts, above modals/popups.
- The startup scripts write a RECOVERY_IN_PROGRESS marker when they restore/re-migrate.
  On startup the WeKan server (server/recovery.js) shows the spinner while the marker is
  present, verifies the database serves reads (probe), then clears the marker and the
  spinner — or, if it still cannot read, keeps the spinner and records that manual
  recovery is required. Admins can also toggle maintenance for a server-initiated
  re-migration.

Tests: tests/recoveryMaintenance.test.cjs (wired into npm run test:unit:node) — the
publication is public while the toggle is admin-gated, the overlay is subscribed and
rendered in both layouts above everything, the server shows the spinner from the marker
and clears it only after a health probe, and the scripts write the marker. Mirror-
validated (no Node runtime here); shell/brace/JSON all checked.

Thanks to bluetopaz1204, mueschel and xet7 !

Fixes #6492,
2026-07-21 10:06:41 +03:00
Lauri Ojansivu ec033cbf30 Changelog: automatic recovery + Admin Panel Recovery report (#6492). 2026-07-21 10:06:40 +03:00
Lauri Ojansivu 63c77a27f4 Automatic recovery/remediation for the SQLite text data + Admin Panel Recovery report
Completes the #6492 data-safety system on the WeKan side (FerretDB detection/repair/cap
landed in the fork):

- Decision brain: models/lib/recoveryPlan.js decideRecovery() picks the least-invasive
  recovery when the database is KNOWN corrupt (latest good backup -> previous backup ->
  re-migrate from MongoDB -> manual). It never acts on a healthy/unknown database and
  never restores a known-bad backup.
- Startup restore/re-migrate: every FerretDB launch path (snap, bundled release, Docker)
  can restore the text data BEFORE FerretDB opens the files, on request
  (WEKAN_FORCE_RESTORE env or a RESTORE_REQUESTED marker). It copies a known-good backup
  INTO the live database (dropping stale WAL side-files); backups are never deleted and
  the main wekan.sqlite is only overwritten, never removed. remigrate is delegated to
  migration-control. Attachments/avatars on the filesystem are untouched.
- Recovery events: the scripts append JSON lines to recovery-events.jsonl; the server
  (server/recovery.js) imports them into the recoveryEvents collection on startup, plus
  an admin-only recordRecoveryEvent method.
- Admin Panel / Problems / Recovery: a new admin-only report
  (server/publications/recoveryReport.js, wired in adminReports.js/.jade + i18n) lists
  the remediation history newest-first, readies up front and fetches directly (no hang).
- Docs: docs/Features/Admin-Panel/Problems/Recovery.md.

Tests (wired into npm run test:unit:node), verified by mirror since there is no Node
runtime here to execute them:
  - recoveryPlan.test.cjs (decision logic, positive + negatives)
  - recoveryEventsJsonl.test.cjs (parser: skips junk, normalizes severity, never throws)
  - recoveryReportQuery.test.cjs (search selector escapes regex metacharacters)
  - recoveryReportWiring.test.cjs (report wired; publication/count/method admin-gated)
  - ferretdbTextDataBackup.test.cjs extended: restore copies from a backup, is
    request-gated, and NEVER deletes the live text data or a backup copy

Thanks to bluetopaz1204, mueschel and xet7 !

Fixes #6492,
2026-07-21 10:06:40 +03:00
Lauri Ojansivu 9ec0a829f6 Changelog: SQLite corruption/bloat safety measures (#6492). 2026-07-21 10:06:40 +03:00
Lauri Ojansivu 10b7487d9b Keep a rotating backup of the text-data database, ready to restore on corruption
Safety measure for #6492: before FerretDB opens its SQLite files (at rest), every
FerretDB launch path now keeps a rotating backup of the TEXT-DATA database
(wekan.sqlite*) in a "backup" subfolder of the same data dir, so a known copy is ready
to restore if the live database is ever detected corrupt (FerretDB's own quick_check
logs corruption on open). Attachments and avatars live on the filesystem and are not
copied.

The critical safety property: the backup only ever COPIES from the live database — it
never moves or deletes the live wekan.sqlite, and never wipes the data dir. The
previous generation is kept under backup/prev so a bad copy cannot overwrite the last
good one on its own. Disable with WEKAN_SQLITE_BACKUP=false.

- snap-src/bin/ferretdb-control
- releases/ferretdb/start-wekan.sh
- releases/ferretdb/wekan-entrypoint.sh

tests/ferretdbTextDataBackup.test.cjs verifies each path copies wekan.sqlite* into the
backup dir, keeps a previous generation, is guarded, and — the critical negative —
NEVER moves/deletes the live text data, never wipes the data dir, and never copies
attachments/avatars.

Thanks to bluetopaz1204, mueschel and xet7 !

Fixes #6492,
2026-07-21 10:06:40 +03:00
Lauri Ojansivu 28c6cf1942 Changelog: FerretDB OpLog reset high-CPU fix (#6492). 2026-07-21 10:06:40 +03:00
Lauri Ojansivu 54b1e9cd17 Reset FerretDB simulated OpLog on start to prevent bloat/corruption high CPU
Per the #6492 investigation, FerretDB v1's SQLite simulated OpLog (local.oplog.rs,
stored in the `local` database = local.sqlite) is not reliably capped, so over time
it bloats or corrupts; Meteor then busy-polls it and FerretDB CPU pegs at 300%+ even
when idle. A reporter confirmed that deleting local.sqlite* drops CPU straight back
to ~10%.

The `local` database is transient SYSTEM data — the OpLog + replica-set metadata,
NOT user data: boards, cards and attachments live in wekan.sqlite. So every FerretDB
launch path now resets ONLY the `local` database files before starting FerretDB,
which recreates a fresh, correctly-capped OpLog from --repl-set-name. This prevents a
corrupt/bloated OpLog from ever persisting across a restart:

- snap-src/bin/ferretdb-control
- releases/ferretdb/start-wekan.sh (bundled release, before each restart-loop start)
- releases/ferretdb/wekan-entrypoint.sh (FerretDB Docker image)

Guarded by WEKAN_FERRETDB_RESET_OPLOG (default true; set false to keep the OpLog).
tests/ferretdbOplogReset.test.cjs verifies each path resets local.sqlite, is guarded,
and — the critical safety property — NEVER deletes wekan.sqlite or the whole data dir.

Thanks to bluetopaz1204, mueschel and xet7 !

Fixes #6492,
2026-07-21 10:06:40 +03:00
github-actions[bot] d5ffad31ac Bump versions for v10.10 2026-07-21 03:35:20 +00:00
Lauri Ojansivu 949b7e76d6 Changelog: split post-v10.09 change into v10.10 above v10.09. 2026-07-21 06:30:37 +03:00
Lauri Ojansivu 5efd8a4fe4 Changelog: mobile All Boards text-cut-off (sizes) fix. 2026-07-21 06:26:28 +03:00
Lauri Ojansivu 576f44ff59 Mobile All Boards: fix board title / menu text cut off (sizes)
On phones the two-column All Boards layout cut off text:
- The board icons column (the 1fr grid track) is a grid item, which defaults to
  min-width:auto and would not shrink below its content, so the board tiles pushed
  the column wider than the viewport and the board titles were clipped off the right
  edge. Add min-width:0 to .boards-right-grid so the column shrinks to its track and
  the tiles (and their titles) fit.
- The mobile board tiles reserved 50px on the right for the drag handle, which on a
  narrow 2-per-row tile hid most of the title. Shrink the handle (40->26px) and the
  reserved padding (50->30px) so more of the title shows.
- Workspace names in the narrow left menu were hard-cut mid-word ("Ro"); let the
  flex children shrink (min-width:0) and truncate the name with an ellipsis so more
  of it is readable ("Roadmap…").

Thanks to xet7 !
2026-07-21 06:26:28 +03:00
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
dependabot[bot] a403afe892 Bump brace-expansion from 5.0.6 to 5.0.7
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.6 to 5.0.7.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.7)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 22:35:09 +00:00
dependabot[bot] e757dc7d92 Bump shell-quote from 1.8.4 to 1.10.0
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.4 to 1.10.0.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.4...v1.10.0)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.10.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 22:35:00 +00: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
792 changed files with 140791 additions and 104405 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 }}
@@ -58,7 +58,7 @@ jobs:
# TEST_WATCH=0 turns ON watch mode and the process never exits — that is
# what made this job hang until the 45-minute timeout. With TEST_WATCH
# unset, `meteor test --once` runs the server tests and exits with the
# correct status (matching the local rebuild-wekan.sh run).
# correct status (matching the local build.sh run).
run: meteor test --once --driver-package meteortesting:mocha
e2e-tests:
@@ -142,7 +142,7 @@ jobs:
# WeKan's server/00checkStartup.js refuses to start unless WRITABLE_PATH
# (used for attachment/file storage) is set and writable.
mkdir -p "$PWD/wekan-writable"
# Match the env the dev runner (rebuild-wekan.sh) and the specs expect:
# Match the env the dev runner (build.sh) and the specs expect:
# WITH_API enables the REST API (specs 17/23/24/28), comment-editor
# flag matches dev.
MONGO_URL=mongodb://localhost:27017/wekan_test \
+353 -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,96 @@ 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:
# DISABLED per maintainer request. Re-enable by removing this `if`.
if: ${{ false }}
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:
# DISABLED per maintainer request. Re-enable by removing this `if`.
if: ${{ false }}
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 +1397,251 @@ 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:
# DISABLED per maintainer request. Re-enable by removing this `if`.
if: ${{ false }}
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:
# DISABLED per maintainer request. Re-enable by removing this `if`.
if: ${{ false }}
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:
# DISABLED per maintainer request. Re-enable by removing this `if`.
if: ${{ false }}
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 }}
+5
View File
@@ -84,3 +84,8 @@ _build-local-test
/FerretDB/
/mongo-tools/
/mongosh/
# Linux core dumps (core.<pid>), e.g. from a crashing Playwright/WebKit browser
# process. Written into the crashing process CWD (tests/playwright/) by
# kernel.core_pattern; never source files.
core.[0-9]*
-3
View File
@@ -60,6 +60,3 @@ ejson@1.2.0
logging@1.3.6
wekan-fullcalendar
wekan-fontawesome
jam:offline
jam:method
jam:pub-sub
-3
View File
@@ -45,9 +45,6 @@ html-tools@2.0.1
htmljs@2.0.2
id-map@1.2.0
inter-process-messaging@0.1.2
jam:method@1.9.1
jam:offline@0.4.1
jam:pub-sub@0.5.1
jquery@3.0.2
lai:collection-extensions@1.0.0
localstorage@1.2.1
+18
View File
@@ -7,3 +7,21 @@ npm-packages/
# package.json). A naked "tests/" would match a dir named tests at any depth and
# would hide those suites, making `meteor test` report "0 passing".
/tests/
# Build/tool scratch space: a full Node 24 install (headers for EVERY openssl arch),
# a Go toolchain with its module/build caches, build logs and repro dirs. 20,535
# DIRECTORIES, none of it app code. Meteor's watcher takes one inotify watch per
# directory, and the per-user limit (fs.inotify.max_user_watches, 65536 here) is
# shared with every other watcher the user runs - editors included. Watching this
# tree is what made `meteor` fail with
# inotify_add_watch on '.tools/node-.../openssl/archs/BSD-x86_64/asm_avx2'
# failed: No space left on device
# ENOSPC from inotify_add_watch means "watch limit reached", NOT a full disk.
/.tools/
# The FerretDB v1 fork checkout: a complete Go project + its docs, 9,148 more
# directories. Nothing in WeKan imports from it - the FerretDB binary is
# downloaded from its GitHub releases at build time - so Meteor has no reason to
# watch or scan it. Both are already in .gitignore, but the watcher only honours
# .meteorignore.
/FerretDB/
+2580 -116
View File
File diff suppressed because it is too large. Load diff
+247
View File
@@ -0,0 +1,247 @@
# CLAUDE.md — instructions Claude reads first
Claude Code reads this file at the repo root before doing work here. Follow it.
## First: are you the maintainer or a contributor?
Check the current git identity before committing or releasing:
```
git config user.name && git config user.email
```
- **Maintainer mode** — ONLY when the identity is exactly
`Lauri Ojansivu <x@xet7.org>` (name `Lauri Ojansivu`, email `x@xet7.org`). Then, and
only then: commit **directly to the current branch** as `Lauri Ojansivu <x@xet7.org>`
with no AI trailer and no pull request, and the **publishing / release steps** below
are available. (Per the standing rule you still **commit only; do not push** unless
explicitly asked.)
- **Contributor mode** — any other git identity. Then: do **not** commit directly to
the branch and do **not** run any release/publishing step. Make changes on a branch
and open a **pull request** for the maintainer to review. The "commit as Lauri
Ojansivu", "commit directly", and all release instructions below are **maintainer-only
and do not apply to you**.
Everything below marked as maintainer-specific (committing directly, the exact commit
author, and the entire "Making a release" / publishing flow) applies only in maintainer
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
good practice for everyone.
## Process: "Fix open issues"
When asked to fix an open issue (one issue at a time):
1. **Read the whole issue** — the description AND all comments (e.g.
`gh issue view <n> --comments`, or the GitHub API `.../issues/<n>/comments`).
2. **If it is a bug, first check whether it is already fixed** in the current WeKan
AND FerretDB source code (read the actual code, not just changelogs). It may have
been fixed already.
3. **If it is NOT fixed yet, fix it**, using the newest documentation and issues of
the dependencies involved, checking where the newest MAINTAINED dependency lives and
reading its source and issues:
- Meteor 3 docs — https://docs.meteor.com/
- Meteor 3 source + issues — https://github.com/meteor/meteor/issues
- other Meteor software docs/code (e.g. Rocket.Chat) for how they solved it
- Meteor forums — https://forums.meteor.com
- AtmosphereJS — https://atmospherejs.com/ ; Packosphere — https://packosphere.com/
- Meteor Community Packages — https://github.com/meteor-community-packages
- Meteor-Files — https://github.com/veliovgroup/Meteor-Files
- npm — https://www.npmjs.com
4. **After fixing, add tests** — unit tests, negative tests, and UI tests where
appropriate — and **run the new tests to verify they pass** (see Tests / the sandbox
build+test instructions in `docs/Security/Sandboxes/vscode/README.md`).
5. **Commit** (maintainer only — when the current git user is `Lauri Ojansivu
<x@xet7.org>`), with no "Co-Authored" / AI trailer, a message body ending:
```
Thanks to (GitHub nickname of the issue creator) and xet7 !
Fixes #1234,
```
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
whose message ends `Fixes #1234,`), noting where/when it was fixed.
Fix from source and test — do not guess. If the environment cannot run the relevant
test (e.g. Docker is unavailable in this sandbox), reproduce as closely as possible
from source and say clearly what was and was not verified.
## Translations (Transifex ↔ direct LLM fill, no external service)
WeKan translations live in `imports/i18n/data/<lang>.i18n.json` (flat
`key -> string`, 2-space indent, key order matches `en.i18n.json`). Transifex holds
the human translations. **The policy is: never overwrite a human translation with a
filled (or English) one, but always take the newest translations from Transifex.**
- Pull with `releases/translations/pull-translations.sh`. It runs `tx pull -a -f`
(which fills every string that is UNtranslated on Transifex with the English source)
and then a **per-key merge** (`releases/translations/merge-translations.mjs`) that,
for every language file and every key:
- Transifex has a real translation (pulled value differs from English) → **keep it**
(the newest human translation always wins);
- the pull reverted it to English but a human translation is committed in git →
**restore the committed translation** (a pull never reverts a human translation,
even in files that also received real new Transifex translations);
- **no translation anywhere** (untranslated on Transifex AND never committed) → leave
the English source as a placeholder. **This is the only case a non-human value is
used.** A separate fill step may fill *only* these English placeholders, so a filled
string can never overwrite a human translation.
- Restored languages are pushed back to Transifex so they stop reverting.
### Filling the remaining untranslated strings — directly, no translation service
The strings still equal to the English source after the merge are the ones untranslated
**everywhere** (Transifex + git). Translate these **directly** — the maintainer or the
assistant (an LLM) writes the translation itself, using that language's **existing
translations** and general **kanban terminology** for the language as the reference.
**Do NOT wire up any external translation service, API, endpoint, key or password** — an
earlier `machine-translate.mjs` that called LibreTranslate/DeepL is removed on purpose
(it did not work and needed a password). There is **no `WEKAN_MT*` env var** anymore.
- `node releases/translations/fill-translations.mjs --missing` — per-language count of
strings still needing translation (English + `en-*` variants are skipped: English by
design). Also printed at the end of `pull-translations.sh`.
- `node releases/translations/fill-translations.mjs --list <lang> [--limit N]` — dump the
untranslated keys of a language as `{ key: englishSource }` for the translator to fill.
- `node releases/translations/fill-translations.mjs --apply <lang> <translated.json>` —
merge the translations back. It writes **only** into placeholder keys, so it can
**never** overwrite a human translation, and a value still equal to English/empty is
ignored. Key order and 2-space indent are preserved.
**Both directions are safe, and it is verified:**
`node releases/translations/verify-human-preference.mjs` proves (pure-logic, no network)
that the pull-merge keeps/restores human translations and that a fill only touches
placeholders. Filled strings stay **local** — they are **NOT** pushed to Transifex (only
the merge-restored human languages are pushed), so a filled string can never masquerade
as human there. So: **only missing strings are ever filled, only when missing
everywhere, human strings are always preferred and merged**, and nothing you fill is
pushed to Transifex as if it were human.
## General practices (from ../log/v10/Claude.txt)
- **[maintainer only]** Commit as `Lauri Ojansivu <x@xet7.org>`, with **no**
"Co-Authored-By" or any other AI trailer, directly to the `main` branch of WeKan and
the `main-v1` branch of the FerretDB fork. **Do not make pull requests.** (Contributors
do the opposite: work on a branch and open a pull request — see the top section.)
- **[maintainer only]** **Commit only. Do not push** (unless explicitly asked).
- Lauri Ojansivu (xet7) maintains WeKan (https://wekan.fi) and the FerretDB v1 fork.
- Directory structure:
- `wekan` — this repo (https://github.com/wekan/wekan); see
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG.md` at root.
- `../w/wekan.fi` — the WeKan website.
- `FerretDB` subdirectory (when present) — https://github.com/wekan/FerretDB, its own
`CHANGELOG.md`. FerretDB `.go` files must contain **no** application-specific names
(say "the client" / "a Meteor 3 driver" / a bare `#NNNN`); its `CHANGELOG.md` may
use `wekan/wekan#NNNN`.
- `sandstorm` subdirectory (when present) — https://github.com/sandstorm-io/sandstorm.
### CHANGELOG
- During development, add entries under a new `# Upcoming WeKan ® release` section above
the newest release (FerretDB uses `## Upcoming FerretDB release`). Do **not** hand-edit
`package.json` or any other version reference — the release workflow bumps those.
- WeKan Upcoming structure — CRITICAL SECURITY ISSUES first, then new features, then
updates (`Thanks to dependabot.`), then bug fixes; each bullet is
`- [desc](https://github.com/wekan/wekan/commit/...).` followed by
`Thanks to (issue creator's GitHub nickname) and xet7.`, and the section ends with
`Thanks to above GitHub users for their contributions and translators for their
translations.`
- **Subsection headers read as ONE flowing sentence.** The FIRST subsection of a release
starts with `This release ` (e.g. `This release fixes the following bugs:`); every LATER
subsection in the SAME release starts with a lowercase `and ` instead of repeating
`This release ` — e.g.
`This release fixes the following bugs:` … `and updates the following dependencies:` …
`and has the following developer-tooling fix:` … `and improves the translation workflow:`
… then the closing `Thanks to above GitHub users …` line. A release with only one
subsection just keeps its single `This release …:` header. Use `and adds the following
new features:`, `and fixes the following bugs:`, `and updates the following
dependencies:`, etc., matching the verb to the subsection.
- **CRITICAL security header — match the previous releases' wording.** A security release
leads with `This release fixes the following CRITICAL SECURITY ISSUE of
[Name](https://wekan.fi/hall-of-fame/namebleed/):` for a single named *Bleed, or
`This release fixes the following CRITICAL SECURITY ISSUES:` for several — NOT
`CRITICAL VULNERABILITIES`. Each security bullet still links its own
`https://wekan.fi/hall-of-fame/...bleed/`. CodeQL-only releases use the established
`This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:`.
Because CRITICAL comes first, it keeps the `This release ` prefix; a following non-security
subsection becomes `and …` per the rule above.
- **`# TODO Later` section** — a triage backlog near the TOP of `CHANGELOG.md` (above the
version sections), for open issues that were **investigated but not fixed here**, each
recorded with a concrete REASON so whoever picks it up next knows why. Use it when working
through open issues (the "Fix open issues" process): for each issue, either **fix it** (commit
ending `Fixes #NNNN,`), **close it** if already fixed in current code (commit `Close #NNNN` /
`Fixes #NNNN,`), or — when it can't be fixed/verified in this environment — **add it to `TODO
Later` under the matching category with the reason**. Group issues by category bullet, e.g.:
*Need specific infrastructure / a running server stack* (LDAP/WebHooks/Sandstorm/proxy —
environment owners), *Need the running app to reproduce/verify* (runtime UI / publication /
mergebox / router state, not unit-testable), *Already correct in the current code* (verified by
reading; could not reproduce), *Feature requests / behaviour-by-design rather than bugs*, and
*Needs a maintainer decision on the intended contract*. Each entry is a normal issue link
`[#NNNN](https://github.com/wekan/wekan/issues/NNNN) (one-line reason)` — issue links here, NOT
commit links (nothing was committed). **Keep it current:** when an issue in `TODO Later` gets
fixed, REMOVE it from the list (its fix commit's `Fixes #NNNN` closes it); do not leave fixed
issues in the backlog. This is the only CHANGELOG place that uses issue links and lives above
the releases; everything else uses the per-release commit-link bullets above.
- FerretDB Upcoming structure — `### New Features 🎉`, `### Fixed 🐛`, `### Other Changes
🤖`; entries end `... by @xet7. Thanks to xet7.`
- Word-wrap both CHANGELOGs at ~80 chars, but never break a long link across lines (a
link line may be longer).
### Commit message structure
```
Do something.
Thanks to (original creator of issue) and xet7 !
Fixes #1234,
Fixes #1235.
```
### Making a release — no version number needed **[maintainer only]**
All publishing / release steps below are maintainer-only. Contributors never run them.
- WeKan: run `./releases/release-all.sh` (no arguments). It renames
`# Upcoming WeKan ® release` to the next version (same increment as the last release;
9.99 → 10.00) dated today, commits + pushes, and triggers
`.github/workflows/release-all.yml` (its `bump` job bumps `package.json` and every
version reference, then the release jobs tag `v<new>` and publish the GitHub Release).
An explicit `oldversion newversion` pair still overrides. Adding entries under Upcoming
is the only hand step.
- FerretDB: run `./build.sh release-ferretdb` from the `FerretDB` subdirectory (no
version). It renames `## Upcoming FerretDB release` to the next version with the
correct git-tag link, commits + tags + pushes, then triggers `release-all.yml` (which
in turn triggers `docker.yml` for the multi-arch image). It refuses to re-release an
already-tagged version.
### Security issues
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
entries, and update `../w/wekan.fi/hall-of-fame/index.html` and the vuln-name
subdirectory `index.html` like previous security issues.
### Tests
- `build.sh` / `build.bat` have a menu to install dependencies, build
WeKan and run tests.
- Add tests, negative tests and UI tests for all new features and fixes that do not yet
have tests. When adding a test, run or validate it and fix it until it works.
- **"Check newest test logs":** test logs are written outside this repo, in
`../log/<datetime>/` (one directory per run, e.g. `../log/2026-07-21_20-58-09/`). The
newest datetime directory is the latest run. Each holds the Playwright per-browser
logs (`wekan-alltests-chromium.log`, `-firefox.log`, `-webkit.log`), the mocha/unit
log (`wekan-alltests-mocha.log`), the e2e/import logs, and `wekan-test-server.log`
(the WeKan test server + database output). "Check the newest test logs" means: open
the most recent `../log/<datetime>/` and read those.
## Environment
- The editor (VSCode) runs inside a **Flatpak sandbox**, launched by
`docs/Security/Sandboxes/vscode/vscode-sandbox.sh`. What the sandbox allows/blocks and
how it is set up is documented in `docs/Security/Sandboxes/vscode/README.md` in that
same directory — read it when something behaves differently than a normal host (file
access, network, running services).
### Always validate from the actual code
- When doing anything, check how it actually works in the code first.
+1 -1
View File
@@ -34,7 +34,7 @@ and click send PR.
## Contributing code
Use rebuild-wekan.sh to run all tests to verify you don't break any features, before sending PR.
Use build.sh to run all tests to verify you don't break any features, before sending PR.
Only send one feature or fix at one PR. Do not add many unrelated changes.
+4 -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.35
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
@@ -272,6 +272,9 @@ EOR
# /build/.ferretdb-default marker (present only on MongoDB-less arches). See
# releases/ferretdb/wekan-entrypoint.sh.
COPY --chmod=755 releases/ferretdb/wekan-entrypoint.sh /build/wekan-entrypoint.sh
# #6492: standalone "recovering data" page the entrypoint serves as a brief bridge on
# the web port while a just-restored FerretDB comes back up during a data recovery.
COPY --chmod=644 releases/ferretdb/recovery-bridge.mjs /build/recovery-bridge.mjs
USER wekan
ENV PORT=8080
+7 -7
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.
@@ -106,7 +106,7 @@ By working directly with WeKan ® you get the benefit of active maintenance and
The main branch uses Meteor 3.5 with Node.js 24.x.
See [CHANGELOG.md](https://github.com/wekan/wekan/blob/main/CHANGELOG.md) for the latest runtime updates.
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./rebuild-wekan.sh` (or `./rebuild-wekan.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-install-dependencies-build-wekan-and-run-the-dev-server). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./build.sh` (or `./build.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-install-dependencies-build-wekan-and-run-the-dev-server). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
Please refer to the [developer documentation](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Developer-Documentation.md) for more information.
@@ -138,7 +138,7 @@ curl https://install.meteor.com/ | sh
### Building WeKan
The `rebuild-wekan.sh` script shows a two-level menu. The top level groups options into categories:
The `build.sh` script shows a two-level menu. The top level groups options into categories:
```
1) Setup 2) Dev server 3) Tests 4) Docker 5) Tools 6) Quit
@@ -156,18 +156,18 @@ git clone git@github.com:YOUR_USERNAME/wekan.git
cd wekan
# Make the script executable
chmod +x rebuild-wekan.sh
chmod +x build.sh
# Step 1: Install dependencies (Setup -> Install dependencies)
./rebuild-wekan.sh
./build.sh
# Press 1 (Setup) and Enter, then 1 (Install dependencies) and Enter
# Step 2: Build WeKan (Setup -> Build WeKan, after dependencies complete)
./rebuild-wekan.sh
./build.sh
# Press 1 (Setup) and Enter, then 2 (Build WeKan) and Enter
# Step 3: Run WeKan in development mode (Dev server -> localhost:3000)
./rebuild-wekan.sh
./build.sh
# Press 2 (Dev server) and Enter, then 1 (localhost:3000) and Enter
```
+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.35.0"
files:
userUploads:
- README.md
+52 -6
View File
@@ -3,7 +3,7 @@ SETLOCAL EnableDelayedExpansion
REM ============================================================================
REM WeKan rebuild / run / test helper for Windows.
REM Mirrors the menu of rebuild-wekan.sh so building, running and testing
REM Mirrors the menu of build.sh so building, running and testing
REM WeKan works on Windows too.
REM
REM Notes:
@@ -14,7 +14,7 @@ REM - 'curl' ships with Windows 10 1803+ and is used for server readiness.
REM - Playwright runs Chromium, Firefox AND WebKit natively on Windows
REM (unlike Linux arm64, no Docker is needed here).
REM - If Meteor does not run well natively on your Windows, WSL2 + Ubuntu
REM with rebuild-wekan.sh is the recommended alternative.
REM with build.sh is the recommended alternative.
REM ============================================================================
REM --- Repo root = folder of this script (strip trailing backslash) ---
@@ -72,10 +72,12 @@ echo.
echo -- Setup -- ^(0 = Back^)
echo 1^) Install dependencies
echo 2^) Build WeKan
echo 3^) Update git ^(fetch + rebase onto origin, fix CHANGELOG hashes, status^)
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" goto install
if "%choice%"=="2" goto build
if "%choice%"=="3" goto updategit
if "%choice%"=="0" goto menu
goto menu_setup
@@ -230,6 +232,50 @@ call meteor build .build --directory
echo Done.
goto end
REM ===========================================================================
REM Make the working copy current in one step: fetch + rebase the current branch
REM onto its upstream, repoint any CHANGELOG commit links the rebase made stale
REM (the same shared releases\fix-changelog-hashes.sh release-all.sh uses), and
REM show the status. Parity with build.sh's Setup -> "Update git ...".
:updategit
echo Updating git: fetch + rebase onto origin, fix CHANGELOG hashes, show status.
git rev-parse --git-dir >nul 2>&1
if errorlevel 1 ( echo Not a git repository. & goto end )
set "BRANCH="
for /f "delims=" %%b in ('git rev-parse --abbrev-ref HEAD 2^>nul') do set "BRANCH=%%b"
echo Branch: %BRANCH%
echo (A dirty tree is auto-stashed for the rebase and re-applied after.)
echo --- git fetch --all --prune ---
git fetch --all --prune
git rev-parse --verify --quiet "origin/%BRANCH%" >nul 2>&1
if errorlevel 1 (
echo No upstream origin/%BRANCH% - skipping rebase.
) else (
echo --- git pull --rebase --autostash origin %BRANCH% ---
git pull --rebase --autostash origin "%BRANCH%"
if errorlevel 1 (
echo.
echo Rebase stopped ^(conflicts^). Resolve them, then:
echo git rebase --continue ^(or: git rebase --abort^)
echo and run this option again to finish the hash fix + status.
goto end
)
)
echo --- Fixing CHANGELOG commit links ^(releases\fix-changelog-hashes.sh^) ---
where bash >nul 2>&1
if errorlevel 1 (
echo bash not found - the CHANGELOG hash fix needs Git Bash, bundled with Git for Windows.
echo Skipping it. Run it yourself in Git Bash: bash releases/fix-changelog-hashes.sh
) else (
bash releases/fix-changelog-hashes.sh
)
echo --- git status ---
git status
echo.
echo NOTE: if CHANGELOG.md was changed above, review 'git diff CHANGELOG.md' and
echo commit it - this script never commits for you.
goto end
REM ===========================================================================
:dev_local
call :ensure_dirs
@@ -898,7 +944,7 @@ if "!SERVER_READY!"=="1" echo ==^> WeKan test server is ready on http://localhos
exit /b 0
:start_bundle_server
REM Parity with rebuild-wekan.sh: run the :3000 test server from the PRECOMPILED
REM Parity with build.sh: run the :3000 test server from the PRECOMPILED
REM .build\bundle (NOT `meteor run`), so Node E2E + Playwright reuse the WeKan you
REM already built with `meteor build .build --directory` - no recompile. The bundle is
REM a plain Node server, so it needs its own MongoDB (Meteor's bundled mongod on :3001,
@@ -997,13 +1043,13 @@ exit /b 0
REM ===========================================================================
:count_tests
REM Print a "by category" summary table for all four test categories that
REM rebuild-wekan runs, then the detailed Playwright per-spec table.
REM Counting rules (kept identical to rebuild-wekan.sh):
REM build runs, then the detailed Playwright per-spec table.
REM Counting rules (kept identical to build.sh):
REM Mocha it( lines across client/lib/tests + server/lib/tests + imports/i18n
REM Import regression ^function test lines in tests/wekanCreator.import.test.js
REM Node E2E logStep('Testing lines in tests/e2e/list-regressions.js
REM Playwright test( / test.only( / test.skip( / test.fixme( lines per spec
REM Uses node (always present here) so parsing matches rebuild-wekan.sh exactly;
REM Uses node (always present here) so parsing matches build.sh exactly;
REM findstr's limited regex engine cannot reproduce these expressions.
node -e "const fs=require('fs'),p=require('path');function rd(f){try{return fs.readFileSync(f,'utf8');}catch(e){return null;}}function cnt(f,re){const s=rd(f);if(s===null)return null;return s.split(/\r?\n/).filter(l=>re.test(l)).length;}function ls(d,suf){try{return fs.readdirSync(d).filter(x=>x.endsWith(suf)).map(x=>p.join(d,x));}catch(e){return [];}}let mocha=0;const mfiles=[].concat(ls('client/lib/tests','.tests.js'),ls('server/lib/tests','.tests.js'),['imports/i18n/i18n.test.js']);for(const f of mfiles){const c=cnt(f,/(^|[^A-Za-z.])it\s*\(/);if(c!==null)mocha+=c;}let imp=cnt('tests/wekanCreator.import.test.js',/^function test/);if(imp===null)imp=0;let ne=cnt('tests/e2e/list-regressions.js',/logStep\('Testing/);if(ne===null)ne=0;const d='tests/playwright/specs';let files=[];try{files=fs.readdirSync(d).filter(f=>f.endsWith('.e2e.js')).sort();}catch(e){}let pw=0;const rows=[];for(const f of files){const m=f.match(/^([0-9]+)/);const spec=m?m[1]:'';let area=f.replace(/^[0-9]+[-_]?/,'').replace(/\.e2e\.js$/,'').replace(/[-_]+/g,' ');area=area.charAt(0).toUpperCase()+area.slice(1);const src=fs.readFileSync(p.join(d,f),'utf8');const c=src.split(/\r?\n/).filter(l=>/(^|[^a-zA-Z.])test(\.(only|skip|fixme))?\s*\(/.test(l)).length;rows.push('| '+spec+' | '+area+' | '+c+' |');pw+=c;}const gt=mocha+imp+ne+pw;console.log('| Category | Tests |');console.log('|----------|-------|');console.log('| Mocha (server + client, meteortesting:mocha) | '+mocha+' |');console.log('| Import regression (tests/wekanCreator.import.test.js) | '+imp+' |');console.log('| Node E2E regressions (tests/e2e/list-regressions.js) | '+ne+' |');console.log('| Playwright e2e specs (tests/playwright/specs/*.e2e.js) | '+pw+' |');console.log('| **Total** | **'+gt+'** |');console.log('');console.log('| Spec | Area | Tests |');console.log('|------|------|-------|');for(const r of rows)console.log(r);console.log('');console.log('**Total: '+pw+' tests**');"
goto end
+260 -7
View File
@@ -78,7 +78,11 @@ function port_in_use(){
# Kill whatever is LISTENING on TCP port $1. Optional $2 = signal name (default
# TERM; pass KILL to force). fuser is preferred on Linux (kills by port with no
# PID parsing); lsof -nP is the macOS fallback.
# PID parsing); lsof -nP is the macOS fallback; ss is the last resort so this still
# works on a minimal Linux that has neither fuser (psmisc) nor lsof installed — which
# is exactly when the old code silently did nothing and the port never freed, even
# though port_in_use (which DOES use ss) kept reporting it in use. ss -p shows the pid
# of the owning process for the current user without root.
function free_tcp_port(){
local p="$1" sig="${2:-TERM}" pids
if command -v fuser >/dev/null 2>&1; then
@@ -86,6 +90,9 @@ function free_tcp_port(){
elif command -v lsof >/dev/null 2>&1; then
pids="$(lsof -nP -iTCP:"$p" -sTCP:LISTEN -t 2>/dev/null)"
[ -n "$pids" ] && kill -"$sig" $pids 2>/dev/null
elif command -v ss >/dev/null 2>&1; then
pids="$(ss -ltnpH "sport = :$p" 2>/dev/null | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u)"
[ -n "$pids" ] && kill -"$sig" $pids 2>/dev/null
fi
return 0
}
@@ -335,7 +342,7 @@ function install_playwright_browsers(){
echo " Install Docker, or set WEKAN_PLAYWRIGHT_DOCKER=0 to run all browsers natively."
fi
fi
echo "Done. Run a browser suite from the menu, or: WEKAN_PLAYWRIGHT_DOCKER=1 ./rebuild-wekan.sh"
echo "Done. Run a browser suite from the menu, or: WEKAN_PLAYWRIGHT_DOCKER=1 ./build.sh"
}
# Reset test-results/ ownership when an older WebKit-in-Docker run left it owned
@@ -1053,9 +1060,234 @@ wekan_docker_build_image() {
return 0
}
# ── inotify watch limit ──────────────────────────────────────────────────────
# Meteor's file watcher takes ONE inotify watch per DIRECTORY it watches, and the
# limit (fs.inotify.max_user_watches) is per USER, shared with every other
# watcher that user runs - VS Code and other editors are usually the biggest
# other consumer. On a stock kernel it is 8192 or 65536, and a WeKan checkout
# (node_modules, .meteor/local, plus whatever else lives under the repo) can sit
# close to that on its own. When it runs out, Meteor fails with a message that
# blames the DISK and sends people looking in the wrong place:
#
# Failed to start watcher for <repo>: [Error: inotify_add_watch on '<path>'
# failed: No space left on device]
#
# ENOSPC from inotify_add_watch means "watch limit reached", NOT a full disk.
#
# So: check the limit on every run and raise it when it is too low. Each watch
# costs ~1 KB of kernel memory ONLY while in use, so a high ceiling is not a
# reservation - 524288 is the value VS Code documents for the same problem.
# Override with WEKAN_INOTIFY_WATCHES=<n> (or 0 to skip this entirely).
#
# This never aborts the script: if it cannot raise the limit (no sudo, no TTY to
# prompt on, a read-only /etc) it prints the one command to run by hand and
# carries on. Nothing here applies on macOS/BSD, which have no inotify.
function ensure_inotify_watches(){
[ "$(uname -s)" = "Linux" ] || return 0
local want="${WEKAN_INOTIFY_WATCHES:-524288}"
[ "$want" = "0" ] && return 0
local proc=/proc/sys/fs/inotify/max_user_watches
[ -r "$proc" ] || return 0
local have; have="$(cat "$proc" 2>/dev/null)"
case "$have" in ''|*[!0-9]*) return 0 ;; esac # unreadable/odd: leave it alone
[ "$have" -ge "$want" ] && return 0
echo
echo "==> inotify watch limit is $have, which is too low for Meteor's file watcher."
echo " Raising fs.inotify.max_user_watches to $want (this is what makes"
echo " 'inotify_add_watch ... No space left on device' go away - that error"
echo " means the WATCH LIMIT is exhausted, not the disk)."
# How to run a privileged command: already root, sudo without a password, or
# sudo with a prompt when there is a terminal to prompt on.
local -a SUDO=()
if [ "$(id -u)" = "0" ]; then
SUDO=()
elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then
SUDO=(sudo)
elif command -v sudo >/dev/null 2>&1 && [ -t 0 ]; then
echo " (sudo will ask for your password)"
SUDO=(sudo)
else
echo " Cannot raise it automatically (no root and no usable sudo). Run:"
echo " echo 'fs.inotify.max_user_watches=$want' | sudo tee /etc/sysctl.d/60-wekan-inotify.conf"
echo " sudo sysctl --system"
return 0
fi
# Apply now, for this boot.
if ! "${SUDO[@]}" sysctl -q -w "fs.inotify.max_user_watches=$want" 2>/dev/null; then
echo " Could not apply it (sysctl refused). Run this by hand:"
echo " sudo sysctl -w fs.inotify.max_user_watches=$want"
return 0
fi
# Persist it, so the next boot does not go back to failing builds.
local conf=/etc/sysctl.d/60-wekan-inotify.conf
if ! "${SUDO[@]}" sh -c "printf '%s\n' '# Raised by WeKan build.sh: Meteor watches one inotify watch per directory.' 'fs.inotify.max_user_watches=$want' > '$conf'" 2>/dev/null; then
echo " Applied for this boot only - could not write $conf."
fi
# max_user_instances is the other half of the same limit family: each watcher
# process needs an instance, and editors + Meteor + chokidar add up. Only
# raised when it is at the low stock default, and never fatal.
local iproc=/proc/sys/fs/inotify/max_user_instances
if [ -r "$iproc" ]; then
local ihave; ihave="$(cat "$iproc" 2>/dev/null)"
case "$ihave" in ''|*[!0-9]*) ihave="" ;; esac
if [ -n "$ihave" ] && [ "$ihave" -lt 1024 ]; then
"${SUDO[@]}" sysctl -q -w fs.inotify.max_user_instances=1024 2>/dev/null \
&& "${SUDO[@]}" sh -c "printf '%s\n' 'fs.inotify.max_user_instances=1024' >> '$conf'" 2>/dev/null \
&& echo " Also raised fs.inotify.max_user_instances from $ihave to 1024."
fi
fi
echo " inotify watch limit is now $(cat "$proc" 2>/dev/null)."
echo
return 0
}
# ── Dev server URL ───────────────────────────────────────────────────────────
# Ask for the port Meteor LISTENS on and for ROOT_URL, so a dev server can run
# somewhere other than http://localhost:3000 without editing this script. Sets
# DEV_PORT and DEV_ROOT_URL for the caller.
#
# The two are NOT the same thing, and that is the point:
# * DEV_PORT is local - what `meteor run --port` binds on this machine.
# * DEV_ROOT_URL is the address a BROWSER uses. Meteor builds absolute URLs
# from it (e-mail links, OAuth redirects, attachment URLs), so it has to be
# the public address, not the local socket.
#
# Behind a reverse proxy those differ completely: Caddy terminates
# https://wekan.example.com on 443 and forwards to localhost:PORT, so ROOT_URL
# must be exactly https://wekan.example.com — appending the local port would
# produce https://wekan.example.com:4000, which nothing serves.
#
# So the port is appended ONLY when you browse the dev server directly:
# <empty> -> http://localhost:PORT (port appended)
# wekan -> http://wekan.localhost:PORT (port appended)
# (browsers and systemd-resolved resolve
# *.localhost to 127.0.0.1, no /etc/hosts entry)
# https://wekan.example.com -> used EXACTLY as given (no port)
# wekan.example.com -> https://wekan.example.com (no port; a
# dotted name is assumed to be proxied)
#
# Non-interactive: WEKAN_DEV_PORT / WEKAN_DEV_ROOT_URL (or WEKAN_DEV_HOST, the
# same answer as the prompt) skip the matching prompt.
function ask_dev_url(){
local port answer url
port="${WEKAN_DEV_PORT:-}"
if [ -z "$port" ]; then
read -r -p "Port for the dev server to listen on [3000]: " port
fi
port="${port:-3000}"
case "$port" in
''|*[!0-9]*) echo "Not a number: '$port' - using 3000."; port=3000 ;;
*) if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then
echo "Port out of range: $port - using 3000."; port=3000
fi ;;
esac
answer="${WEKAN_DEV_ROOT_URL:-${WEKAN_DEV_HOST:-}}"
if [ -z "$answer" ]; then
echo "ROOT_URL: empty or a bare name = local (the port is added);"
echo " a full URL or a dotted name = public/proxied (the port is NOT added)."
read -r -p "ROOT_URL [http://localhost:$port]: " answer
fi
answer="${answer%/}" # a trailing slash would double up in built URLs
case "$answer" in
'')
url="http://localhost:$port" ;;
*://*)
# A complete URL: the user has said exactly what the browser uses.
# Never touch it - not the scheme, not the port, not a port they left
# off on purpose because a proxy serves 80/443.
url="$answer" ;;
*.*)
# A dotted host with no scheme: a public name, so assume it is proxied
# (that is what a public name is for) and do NOT append the local port.
url="https://$answer" ;;
*)
# A bare label: a subdomain of localhost, browsed directly.
url="http://$answer.localhost:$port" ;;
esac
DEV_PORT="$port"
DEV_ROOT_URL="$url"
echo "==> Meteor listens on port $port"
echo "==> ROOT_URL=$DEV_ROOT_URL"
case "$url" in
*localhost*) : ;;
*)
echo " (no port appended: a public ROOT_URL is expected to be served by a"
echo " reverse proxy - e.g. Caddy forwarding it to localhost:$port)" ;;
esac
}
# ── Update git: make the working copy current in one step ────────────────────
# Fetch + rebase the current branch onto its upstream, then repoint any CHANGELOG
# commit links the rebase made stale (same shared script release-all.sh uses), and
# show the resulting status. One menu action instead of the fetch/pull/rebase/
# hash-fix/status dance by hand.
function update_git(){
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 0; }
local branch upstream here
here="$(cd "$(dirname "$0")" && pwd)"
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
echo "== Update git — branch: ${branch:-?} =="
# Do not surprise anyone: a dirty tree is stashed by --autostash below, but say
# so first and let the user opt out.
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
echo "Working tree has uncommitted changes:"
git status --short
printf "They will be auto-stashed for the rebase and re-applied after. Continue? [y/N] "
read -r ans
case "$ans" in y|Y|yes|YES) : ;; *) echo "Aborted (nothing changed)."; return 0 ;; esac
fi
echo "--- git fetch --all --prune ---"
git fetch --all --prune || { echo "fetch failed - check network/remote and retry."; return 0; }
upstream="origin/$branch"
if git rev-parse --verify --quiet "$upstream" >/dev/null 2>&1; then
echo "--- git pull --rebase --autostash origin $branch ---"
if ! git pull --rebase --autostash origin "$branch"; then
echo
echo "Rebase stopped (conflicts or autostash conflict). Resolve them, then:"
echo " git rebase --continue # or: git rebase --abort"
echo "and run this option again to finish the hash fix + status."
return 0
fi
else
echo "No upstream '$upstream' - skipping rebase (nothing to pull for this branch)."
fi
# The rebase may have rewritten commits the CHANGELOG links to; repoint the
# stale links in the unreleased section (shared with releases/release-all.sh).
echo "--- Fixing CHANGELOG commit links (releases/fix-changelog-hashes.sh) ---"
bash "$here/releases/fix-changelog-hashes.sh" || true
echo "--- git status ---"
git status
if [ -n "$(git status --porcelain -- CHANGELOG.md 2>/dev/null)" ]; then
echo
echo "NOTE: CHANGELOG.md was updated by the hash fix above."
echo " Review 'git diff CHANGELOG.md' and commit it (this script never commits for you)."
fi
}
echo
PS3='Please enter your choice: '
# Checked on every run: a too-low inotify limit breaks `meteor run` with a
# misleading "No space left on device".
ensure_inotify_watches
# ── Menu: pick a category, then an action (the handlers below are unchanged) ──
# choose <title> <"short|full"...>: show the short labels, set $opt to the chosen
# leaf's full label (matching a case handler below), or "" when Back is chosen.
@@ -1109,7 +1341,8 @@ while [ -z "$opt" ]; do
"Setup")
choose "Setup" \
"Install dependencies|Install WeKan dependencies" \
"Build WeKan|Build WeKan" ;;
"Build WeKan|Build WeKan" \
"Update git|Update git: fetch + rebase onto origin, fix CHANGELOG hashes, show status" ;;
"Dev server")
choose "Dev server" \
"localhost:3000|Run Meteor for dev on http://localhost:3000" \
@@ -1118,6 +1351,7 @@ while [ -z "$opt" ]; do
"CURRENT-IP:3000|Run Meteor for dev on http://CURRENT-IP-ADDRESS:3000" \
"CURRENT-IP:3000 + MONGO_URL 27019|Run Meteor for dev on http://CURRENT-IP-ADDRESS:3000 with MONGO_URL=mongodb://127.0.0.1:27019/wekan" \
"CUSTOM-IP:PORT|Run Meteor for dev on http://CUSTOM-IP-ADDRESS:PORT" \
"CUSTOM PORT + SUBDOMAIN|Run Meteor for dev on a custom port and ROOT_URL host (asks)" \
"Kill all dev servers|Kill all dev servers (free ports 3000/3001/3100/3101/4000/4001/8080)" ;;
"Tests")
choose "Tests" \
@@ -1211,6 +1445,11 @@ for _once in 1; do
break
;;
"Update git: fetch + rebase onto origin, fix CHANGELOG hashes, show status")
update_git
break
;;
"Run Meteor for dev on http://localhost:3000")
ensure_rspack_public_dirs
kill_meteor_on_port 3000 || break
@@ -1251,9 +1490,9 @@ for _once in 1; do
ensure_rspack_public_dirs
kill_meteor_on_port 3000 || break
if [[ "$OSTYPE" == "darwin"* ]]; then
IPADDRESS=$(ifconfig | grep broadcast | grep 'inet ' | cut -d: -f2 | awk '{ print $2}' | cut -d '/' -f 1 | grep '192.')
IPADDRESS=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo localhost)
else
IPADDRESS=$(ip a | grep 'noprefixroute' | grep 'inet ' | cut -d: -f2 | awk '{ print $2}' | cut -d '/' -f 1 | grep '192.')
IPADDRESS=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[0-9.]+' | head -1 | grep . || hostname -I 2>/dev/null | awk '{print $1}' | grep . || echo localhost)
fi
echo "Your IP address is $IPADDRESS"
#---------------------------------------------------------------------
@@ -1270,9 +1509,9 @@ for _once in 1; do
ensure_rspack_public_dirs
kill_meteor_on_port 3000 || break
if [[ "$OSTYPE" == "darwin"* ]]; then
IPADDRESS=$(ifconfig | grep broadcast | grep 'inet ' | cut -d: -f2 | awk '{ print $2}' | cut -d '/' -f 1 | grep '192.')
IPADDRESS=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || echo localhost)
else
IPADDRESS=$(ip a | grep 'noprefixroute' | grep 'inet ' | cut -d: -f2 | awk '{ print $2}' | cut -d '/' -f 1 | grep '192.')
IPADDRESS=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[0-9.]+' | head -1 | grep . || hostname -I 2>/dev/null | awk '{print $1}' | grep . || echo localhost)
fi
echo "Your IP address is $IPADDRESS"
#---------------------------------------------------------------------
@@ -1285,6 +1524,20 @@ for _once in 1; do
break
;;
"Run Meteor for dev on a custom port and ROOT_URL host (asks)")
ensure_rspack_public_dirs
ask_dev_url
kill_meteor_on_port "$DEV_PORT" || break
#---------------------------------------------------------------------
# Same environment as the plain localhost:3000 option; only the port and
# ROOT_URL differ. Logging of terminal output to console and to
# ../log/wekan-log.log at the end of the line: 2>&1 | tee ../log/wekan-log.log
#---------------------------------------------------------------------
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=uws DEBUG=true WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL="$DEV_ROOT_URL" meteor run --port "$DEV_PORT" 2>&1 | tee ../log/wekan-log.log
#---------------------------------------------------------------------
break
;;
"Run Meteor for dev on http://CUSTOM-IP-ADDRESS:PORT")
ensure_rspack_public_dirs
ip address
-2
View File
@@ -68,8 +68,6 @@ import { Utils } from '/client/lib/utils';
// import '/client/lib/attachmentMigrationManager';
// import '/client/components/settings/migrationProgress';
// Import cron settings - COMMENTED OUT
// import '/client/components/settings/cronSettings';
// Custom head tags
// Mirror Meteor login token into a cookie for server-side file route auth
+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
+81 -1
View File
@@ -3,6 +3,7 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { TAPi18n } from '/imports/i18n';
import dragscroll from '@wekanteam/dragscroll';
import '/client/lib/dragscrollTouch';
import '/client/lib/dragDropTouch';
import { boardConverter } from '/client/lib/boardConverter';
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
import { toFullCalendarFirstDay } from '/client/lib/calendarFirstDay';
@@ -13,6 +14,7 @@ import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import { Filter } from '/client/lib/filter';
import { migrationProgressManager } from '/client/components/settings/migrationProgress';
// SubsManager removed for Meteor 3 migration
const { calculateIndex } = Utils;
@@ -26,6 +28,48 @@ Template.board.onCreated(function () {
// Kept for the template/jade which still references it; conversion is a no-op.
this.isConverting = new ReactiveVar(false);
this._swimlaneCreated = new Set(); // boards where a default swimlane was ensured
this._listRepairChecked = new Set(); // boards whose data-repair was checked
// When a board opens, detect whether it needs the shared data-repairs (the same
// set run during the MongoDB <-> SQLite migration): #6484 lists wrongly bound to
// a swimlane (a list DISAPPEARS from the other swimlanes), cards with no
// swimlane, and orphaned cards whose swimlane was deleted (invisible cards). If
// anything needs fixing and the viewer is a board admin, repair it — showing the
// shared blue, Product-name-branded migration-progress dashboard. Checked once
// per board, fire-and-forget so it never blocks rendering.
this.maybeRepairBoard = (boardId) => {
if (!boardId || this._listRepairChecked.has(boardId)) {
return;
}
this._listRepairChecked.add(boardId);
Meteor.call('boardRepairNeeded', boardId, (err, res) => {
if (err || !res || !res.total || !res.canRepair) {
return; // nothing to fix, or the viewer is not a board admin
}
migrationProgressManager.startMigration();
migrationProgressManager.updateProgress({
overallProgress: 20,
currentStep: 1,
totalSteps: 1,
stepName: 'repair_board_data',
stepProgress: 20,
stepStatus: 'running',
stepDetails: {
lists: res.listsUnbound,
cards: res.cardsAssigned + res.cardsRescued,
},
boardId,
});
Meteor.call('repairBoardData', boardId, (err2) => {
if (err2) {
this._listRepairChecked.delete(boardId); // allow a retry
migrationProgressManager.failMigration(err2);
return;
}
migrationProgressManager.completeMigration();
});
});
};
// Ensure a board has at least one swimlane. Guarded to run once per board and
// fire-and-forget so it never blocks rendering. The board is marked
@@ -73,6 +117,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);
@@ -81,6 +128,8 @@ Template.board.onCreated(function () {
// id and the subscription's ready state — not on the swimlane data that
// ensureDefaultSwimlane reads — which would otherwise cause extra re-runs.
Tracker.nonreactive(() => this.ensureDefaultSwimlane(currentBoardId));
// Also detect + run the shared board data-repairs once the board is ready.
Tracker.nonreactive(() => this.maybeRepairBoard(currentBoardId));
}
});
});
@@ -556,7 +605,25 @@ Template.boardBody.onRendered(function () {
});
this.autorun(() => {
// Always reset dragscroll on view switch
// Re-attach mouse dragscroll whenever the set of rendered `.dragscroll`
// containers can change. The @wekanteam/dragscroll mouse handler is attached
// PER element inside reset(), so it must be re-run after the DOM changes;
// only dragscrollTouch (delegated on `document`) covers reactively-added
// nodes, and that is touch-only. Previously this autorun's only reactive deps
// were the touch/permission reads below, so mouse drag-scrolling stayed dead
// when swimlanes/lists rendered AFTER onRendered — adaptive lazy card loading
// (#6480), the on-open data-repair adding a default swimlane (#6484), or a
// board switch that reuses this template instance without re-firing onRendered
// (see the #4978 note below) — leaving lower swimlanes unreachable by drag on
// touchpad/mouse. Reading the board view + swimlanes + lists here establishes
// those dependencies so reset() re-runs when the containers change. None of
// these reads write to the DB, so there is no autorun write loop.
const dragscrollBoard = Utils.getCurrentBoard();
if (dragscrollBoard) {
Utils.boardView();
dragscrollBoard.swimlanes();
dragscrollBoard.draggableLists();
}
dragscroll.reset();
if ($swimlanesDom.data('uiSortable') || $swimlanesDom.data('sortable')) {
@@ -705,6 +772,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
+529 -78
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;
@@ -139,6 +131,7 @@
border-radius: 4px;
cursor: pointer;
flex: 1;
min-width: 0; /* allow the workspace name inside to shrink + ellipsis */
text-decoration: none;
}
@@ -164,6 +157,12 @@
.workspace-node .workspace-name {
flex: 1;
/* Let the name shrink inside the narrow left menu and truncate with an ellipsis
("Roadmap…") instead of being hard-cut mid-word ("Ro"). */
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workspace-node .workspace-count {
@@ -253,6 +252,12 @@
.boards-right-grid {
min-height: 200px;
/* The right column is a 1fr grid item of .boards-layout; grid items default to
min-width:auto and will NOT shrink below their content's intrinsic width, so on
narrow (phone) screens the board tiles pushed this column wider than the viewport
and the board titles were cut off on the right. min-width:0 lets the 1fr column
shrink to its track so the board list fits and the titles stay visible. */
min-width: 0;
}
.boards-path-header {
@@ -260,6 +265,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 +281,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 +343,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 +358,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 +368,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 {
@@ -356,9 +415,30 @@
.boards-path-header .path-icon {
font-size: 18px;
}
/* #6524: the header section icon is a Font Awesome glyph matching the left menu.
The left-menu icons inherit the default text colour (#4d4d4d), so this one is
set to the same, and sized like a menu icon rather than the old emoji. */
.boards-path-header .path-icon i.fa {
color: #4d4d4d;
font-size: 16px;
}
/* #6524: hide the current-folder NAME next to its icon in the header. The folder
icon already shows which section this is, the left menu highlights it, and the
"My Boards" bar names the page - so the repeated "Starred"/"Remaining"/workspace
name was redundant and crowded the Multi-Selection / Sort / search controls
beside it. Visually hidden rather than removed, so a screen reader still
announces which folder is shown (the icon alone has no text alternative). */
.boards-path-header .path-text {
color: #333;
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.boards-path-header .board-header-btn {
@@ -481,12 +561,25 @@
color: #000;
}
/* #6523: a responsive grid instead of `float: width 20%`. The float layout was
a FIXED 5 columns (wrapping to 4 with margins), so as the window widened the
tiles just stretched wider instead of more fitting per row. `auto-fill` +
`minmax(200px, 1fr)` fills each row with as many ~200px tiles as fit, so more
width means more boards per row and the tiles stay a steady size. The 8px
`gap` replaces the per-tile margins (set to 0 below). */
.board-list {
margin: 0 8px;
padding: 0;
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
align-content: start;
}
.board-list li {
float: inline-start;
width: 20%;
float: none;
width: auto;
margin: 0;
box-sizing: border-box;
position: relative;
}
@@ -517,19 +610,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 +668,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 +786,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,31 +932,40 @@ body.grey-icons-enabled .checkmark-no-grey {
/* Mobile view styles - applied when isMiniScreen is true (iPhone, etc.) */
.board-list.mobile-view {
/* #6488/#6523: a responsive grid. `min(46%, 200px)` keeps ~2 columns on a narrow
phone board-column (46% of it) but caps the tile at 200px, so a wider screen
(e.g. a laptop in mobile mode) gets more columns instead of two stretched ones. */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(46%, 200px), 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;
height: 8rem;
width: 100%;
margin: 0;
padding-inline-end: 50px; /* Ensure content doesn't overlap with drag handle */
/* Keep clear of the drag handle, but only as much as the (smaller) handle needs —
on a ~2-per-row phone column the tile is narrow, so an oversized reserve hid most
of the board title. */
padding-inline-end: 30px;
}
.board-list.mobile-view .board-list-item .details {
padding-inline-end: 50px; /* Extra space for drag handle */
padding-inline-end: 30px; /* room for the drag handle */
width: 100%;
box-sizing: border-box;
}
@@ -847,16 +976,16 @@ body.grey-icons-enabled .checkmark-no-grey {
}
.board-list.mobile-view .board-handle {
position: absolute;
padding: 7px;
padding: 5px;
top: 50%;
transform: translateY(-50%);
inset-inline-end: 10px;
font-size: 24px;
inset-inline-end: 6px;
font-size: 16px;
color: #fff;
background: rgba(0,0,0,0.3);
border-radius: 50%;
width: 40px;
height: 40px;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
@@ -893,11 +1022,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 +1034,84 @@ 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 {
/* #6523: a wider left menu and a narrower board column. At 30% of a 375px
phone the menu was ~112px, so "Workspaces" and the counts wrapped and the
menu read as cramped, while the board column had more width than two 4rem
tiles need. 42% gives the menu ~157px - enough for the longest label on one
line - and still leaves ~200px for the tiles. */
grid-template-columns: minmax(140px, min(42%, 210px)) 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 {
/* #6523: shorter tiles on mobile, and the SAME height for a board tile and the
grey "+ Add Board" / "+ Add Template Container" tile.
They were neither: a board tile was 8rem (128px) here, while the add tile got
no mobile height at all and fell back to the desktop `min-height: 72px` plus
42px of padding - then grew further around its wrapped label, so the two
never lined up and both wasted vertical space on a phone.
Both are now a flat 4rem (64px), half the board tile's previous height, with
the padding folded into that box (box-sizing: border-box) so the number IS
the rendered height rather than a floor to add padding to. This covers every
tab of All Boards - Starred, Templates, Remaining and Workspaces all render
the same `.board-list` tiles. */
/* `.board-list.mobile-view .board-list-item` (set by isMiniScreen on the <ul>,
outside any media query) also declares `height: 8rem` and is MORE specific
(0,3,0) than `.board-list .board-list-item` (0,2,0), so it won on board tiles
while the add tile - matched by `.board-list .js-add-board .label`, also
(0,3,0) but later in the file - shrank. That is why one board icon ended up
several times the height of "+ Add Board" instead of equal to it. Both
`.mobile-view` shapes are therefore spelled out here. */
.board-list .board-list-item,
.board-list.mobile-view .board-list-item,
.board-list .js-add-board .label,
.board-list.mobile-view .js-add-board .label,
.board-list .js-add-board .label.is-template-container,
.board-list.mobile-view .js-add-board .label.is-template-container {
box-sizing: border-box;
height: 4rem;
min-height: 4rem;
}
.board-list .board-list-item,
.board-list.mobile-view .board-list-item {
overflow: visible;
height: 8rem;
width: 100%;
margin: 0;
padding-inline-end: 50px; /* Ensure content doesn't overlap with drag handle */
padding: 6px 30px 6px 8px; /* was 24px/18px vertical: too tall for a 4rem tile */
}
/* The add tile centres its label, so it only needs enough padding not to touch
the edges; the flex centring already positions the text. */
.board-list .js-add-board .label,
.board-list.mobile-view .js-add-board .label,
.board-list .js-add-board .label.is-template-container,
.board-list.mobile-view .js-add-board .label.is-template-container {
padding: 6px 8px;
}
.board-list .board-list-item .details {
@@ -977,10 +1147,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 +1184,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(140px, min(42%, 210px)) 1fr !important; /* #6523: wider menu, narrower boards; capped so landscape (~844px) does not balloon it */
}
}
.AllBoardTeamsOrgs {
@@ -1075,7 +1246,7 @@ body.grey-icons-enabled .checkmark-no-grey {
background-color: #999; /* Default gray background if no color class is applied */
border-radius: 3px; /* Rounded corners for board items */
overflow: hidden; /* Ensure children respect rounded corners */
margin: 8px; /* Space between board items */
margin: 0; /* grid gap spaces the tiles now (#6523) */
}
/* #6439: highlight the board a dragged board will be dropped before, when
@@ -1088,7 +1259,7 @@ body.grey-icons-enabled .checkmark-no-grey {
/* Reset background for add-board button */
.js-add-board {
background-color: transparent !important;
margin: 8px !important; /* Keep margin for add-board */
margin: 0 !important; /* grid gap spaces the tiles now (#6523) */
}
/* Apply board colors to li.js-board parent instead of just the link */
@@ -1146,13 +1317,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 +1378,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 */
@@ -1244,3 +1427,271 @@ body.grey-icons-enabled .checkmark-no-grey {
border-radius: inherit;
z-index: 0;
}
/* ── Equal, short tiles whenever the list renders in mobile view ─────────────
`.mobile-view` is put on the <ul> by the template from Utils.isMiniScreen(),
which also weighs mobile MODE and the device - so it can be set on a wide
screen, where a `max-width: 800px` media query does not apply. The 8rem rule
this overrides (`.board-list.mobile-view .board-list-item`, further up) is
itself outside any media query, so the override has to be too, or a board tile
stays 8rem while "+ Add Board" is 4rem and the two do not match.
Last in the file so that, at equal specificity, source order decides. */
.board-list.mobile-view .board-list-item,
.board-list.mobile-view .js-add-board .label,
.board-list.mobile-view .js-add-board .label.is-template-container {
box-sizing: border-box;
height: 4rem;
min-height: 4rem;
}
.board-list.mobile-view .board-list-item {
padding: 6px 30px 6px 8px;
}
.board-list.mobile-view .js-add-board .label,
.board-list.mobile-view .js-add-board .label.is-template-container {
padding: 6px 8px;
}
/* The mobile board list is a GRID with a fixed height
(`height: calc(100vh - 120px)`) and no `align-content`, so it defaults to
`stretch`: with one row of tiles, that row is stretched to fill the whole
column. The tiles then differ for a reason that has nothing to do with their
own height - "+ Add Board" shows a 4rem grey `.label` sitting at the top of a
stretched cell, while a board's `li.js-board` carries the board colour itself
and so paints the entire stretched cell blue. That is why one board icon
looked several times taller than "+ Add Board" even after both were set to
4rem.
`align-content: start` keeps rows at their content height instead of dividing
the leftover space between them, and `align-items: start` stops each tile from
stretching to its row. The list still scrolls, because the fixed height plus
`overflow-y: auto` is what makes it a scroll container. */
.board-list.mobile-view {
align-content: start;
align-items: start;
}
/* The tile fills its grid cell horizontally but not vertically. */
.board-list.mobile-view li,
.board-list.mobile-view li.js-board {
align-self: start;
height: auto;
}
/* ── Mobile tile text and "+ Add Board" width (#6523) ────────────────────────
Board titles were breaking ONE LETTER PER LINE. The drag handle is a single
~26px absolutely-positioned circle, but space for it was reserved THREE times
over on the way down: 32px on `li`, 30px on the tile's padding, and 30px again
on `.details`. On a ~96px tile that is 92px of reservation, leaving about
nothing for the text, so every character wrapped onto its own line.
The handle is absolutely positioned, so only the innermost text container has
to keep clear of it. Reserve it once, on `.details`, and the title reads
left-to-right again with roughly eight characters per line. */
.board-list.mobile-view li {
padding-inline-end: 0;
}
.board-list.mobile-view .board-list-item {
padding: 6px 8px;
}
.board-list.mobile-view .board-list-item .details {
padding-inline-end: 28px; /* the one reservation the handle actually needs */
}
/* Keep long titles on horizontal lines that clip, rather than breaking mid-word
into a vertical stack. `break-word` only splits a word that cannot fit, which
is what a starved container made of every character. */
.board-list.mobile-view .board-list-item-name {
overflow-wrap: break-word;
word-break: normal;
hyphens: none;
}
/* "+ Add Board" is one grid cell like any board tile, so the two are the same
width as well as the same height. `.js-add-board` carries `margin: 8px
!important` globally while board `li`s have only a bottom margin, which made
its box narrower than a board tile inside an identically sized cell - so the
margin is matched here too. (An earlier version made this tile span both
columns; identical tiles is what the wide-screen layout does, and what was
asked for.) */
.board-list.mobile-view li.js-add-board,
.board-list.mobile-view li.js-board {
grid-column: auto;
/* BOTH get the same margin. `.js-add-board` has `margin: 8px !important` and
`.js-board` has `margin: 8px`, so zeroing only one of them would break the
match in the other direction. The grid's own 8px gap already separates the
tiles, so a margin on top of it only shrinks them unevenly. */
margin: 0 !important;
}
/* Every tile - board or add - is one cell wide and 4rem tall, with nothing
inside it able to change that. */
.board-list.mobile-view li,
.board-list.mobile-view li.js-board,
.board-list.mobile-view li.js-add-board {
width: auto;
min-width: 0;
max-width: none;
}
.board-list.mobile-view li.js-add-board .board-list-item,
.board-list.mobile-view li.js-add-board .label,
.board-list.mobile-view li.js-board .board-list-item {
width: 100%;
box-sizing: border-box;
height: 4rem;
min-height: 4rem;
}
/* ── Header on a phone: menu title ABOVE the search box ─────────────────────
A wide screen has room to put "Remaining" and the search side by side. A phone
does not: squeezed onto one row the title was ellipsised down to "R.." and the
search box still had barely any width. So on a phone the row stacks - title
first, search under it - each taking the full width of the card.
The earlier problem with stacking was not the stacking itself but the gap: the
two rows were separated by a tall empty band. That came from `.path-right`
being free to grow; pinning `row-gap` and letting each part take exactly one
full-width row puts them directly above one another. */
@media screen and (max-width: 800px),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
.boards-path-header {
flex-wrap: wrap;
gap: 6px;
row-gap: 6px;
justify-content: flex-start; /* pack the icons at the start of row 1 */
}
.boards-path-header .path-left {
flex: 0 0 auto; /* just the section icon - stays at the start of row 1 */
min-width: 0;
overflow: hidden;
}
/* Flatten path-right so its Multi-Selection / Sort buttons and the search box
become direct items of the header's OWN flex row. Then the header controls
the wrap directly and deterministically: the section icon and the buttons sit
together on row 1, and the full-width search box drops to row 2 - instead of
path-right wrapping as a two-line block BELOW the section icon (which left the
icon alone on row 1 and the buttons on row 2). */
.boards-path-header .path-right {
display: contents;
}
.boards-path-header .multiselection-group,
.boards-path-header .js-open-boards-sort,
.boards-path-header .board-header-btn {
flex: 0 0 auto;
}
/* Search takes the whole header width, so it wraps onto its own row under the
icons. `min-width: 0` lets it then shrink to fit (a flex item defaults to
`min-width: auto` and refuses to go below its content width). */
.boards-path-header .board-search {
flex: 1 1 100%;
min-width: 0;
}
}
/* With drag handles OFF there is no handle on a board tile, so the title should
use the width that was reserved for it. `:has()` keeps this to one rule
instead of a second class on the tile; browsers without it simply keep the
reservation, which is the old behaviour and harmless. */
.board-list .board-list-item:not(:has(.board-handle)) .details,
.board-list.mobile-view .board-list-item:not(:has(.board-handle)) .details {
padding-inline-end: 0;
}
/* #6523: on a phone (either orientation) the count chip sits right after its
label, not pushed to the far edge. The menu item is `justify-content:
space-between` with a `flex: 1` label, which spreads the label and the count
apart across the whole column - fine on a narrow portrait menu, but a wide gap
on a landscape iPhone where the column is wider. Packing them left keeps the
number beside the text at any menu width. Scoped to the mobile layout so the
desktop 260px menu is untouched. */
@media screen and (max-width: 800px),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
.boards-left-menu .menu-item a {
justify-content: flex-start;
gap: 8px;
}
.boards-left-menu .menu-item .menu-label {
flex: 0 1 auto;
}
/* No extra left margin now that a flex `gap` sets the spacing. */
.boards-left-menu .menu-item .menu-count {
margin-inline-start: 0;
}
/* The workspace row packs a drag handle, folder icon, the NAME, a "bars" menu
button and a count chip. On the narrow phone menu the name must give way to
the fixed items around it: it takes the remaining space and truncates with an
ellipsis, while the name's own box is CLIPPED so it can never spill over the
"bars" menu button on its right (the overlap that made the hamburger look like
it sat on top of the name). The menu button and count never shrink. */
.workspace-node .js-select-space {
flex: 1 1 auto;
min-width: 0;
overflow: hidden; /* clip the name to this box; it cannot cover the menu */
}
.workspace-node .workspace-name {
flex: 1 1 auto;
min-width: 0; /* shrink + ellipsis; no fixed floor that would force overlap */
}
.workspace-node .js-open-workspace-menu,
.workspace-node .workspace-count {
flex: 0 0 auto;
}
}
/* #6524: "+ Add workspace" (and the per-node "+ add subspace") were ~20px "+"
links - far below the 44px minimum touch target, so a finger tap usually
missed them while the larger menu rows registered fine. On a touch-capable
layout they become proper 44px targets with the "+" centred. */
@media screen and (max-width: 800px),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
.workspaces-header .js-add-workspace {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 8px;
font-size: 20px;
line-height: 1;
}
}
/* #6439: the LIVE drop gap. While a board is dragged, this empty dashed cell is
inserted before/after the tile under the cursor to show where the board will
land. It is a grid cell like the board tiles, so it opens a real gap the other
tiles flow around. Not Blaze-managed - inserted and removed by the drag
handlers in boardsList.js. */
.board-list .js-board-placeholder.board-drop-placeholder {
min-height: 4rem;
border: 2px dashed #3498db;
border-radius: 3px;
background: rgba(52, 152, 219, 0.08);
box-sizing: border-box;
list-style: none;
margin: 0;
}
/* #6439: while a board icon is being dragged, take it out of the grid flow so
the other icons reflow as if it were gone and the live gap sits exactly where
the icon will land. Added on a timeout after dragstart (hiding it immediately
cancels the drag), removed on dragend. */
.board-list .js-board.board-dragging-hidden {
display: none !important;
}
/* The touch-drag ghost (client/lib/dragDropTouch.js) is a clone that follows the
finger while dragging a board icon. Keep it above everything and let it fully
colour a board tile clone rather than show a dashed placeholder. */
.touch-drag-ghost {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
border-radius: 3px;
}
+87 -37
View File
@@ -1,7 +1,5 @@
template(name="boardList")
.wrapper
.board-list-header
.boards-layout
// Left menu
.boards-left-menu
@@ -77,26 +75,14 @@ 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
//- #6524: the current section's Font Awesome icon (same glyph and
colour as the matching left-menu item), not a Unicode emoji.
span.path-icon
i.fa(class="{{currentMenuPath.faIcon}}")
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 +93,47 @@ 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}}")
//- Icon as a direct `i.fa` child (like the Multi-Selection button
//- beside it), not wrapped in span.emoji-icon, and the plain
//- fa-sort glyph so the blue Sort button always shows a sort icon.
i.fa.fa-sort
span {{_ 'sort'}}
//- Search boards is placed to the RIGHT of the Multi-Selection button.
.board-search
span.emoji-icon
i.fa.fa-search
//- No placeholder text: the magnifier icon beside the field already
says what it is, and the label took the whole width of the box on a
phone. `aria-label` keeps it named for screen readers, which a
placeholder is not a substitute for anyway.
input.js-board-search-input(type="text" placeholder="" aria-label="{{_ '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 +146,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'}}
@@ -136,7 +156,7 @@ template(name="boardList")
i.fa.fa-plus
| &nbsp;{{_ 'add-board'}}
each boards
li.js-board(class="{{_id}} {{#if isStarred}}starred{{/if}} {{colorClass}} {{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}", draggable="true")
li.js-board.nodragscroll(class="{{_id}} {{#if isStarred}}starred{{/if}} {{colorClass}} {{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}", draggable="true")
if isInvited
.board-list-item
if BoardMultiSelection.isActive
@@ -158,11 +178,17 @@ template(name="boardList")
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
span.emoji-icon
i.fa.fa-arrows
//- The drag handle obeys the same "Show desktop drag handles"
setting as swimlanes, lists and cards. With handles ON it is
the only place a board drag may start, so the rest of the
tile is free to dragscroll; with handles OFF there is no
handle and the whole tile is the drag source.
if isTouchScreenOrShowDesktopDragHandles
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
span.emoji-icon
i.fa.fa-arrows
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}")
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}" draggable="false")
span.details
span.board-list-item-name(title="{{_ 'template-container'}}")
+viewer
@@ -188,11 +214,22 @@ template(name="boardList")
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
span.emoji-icon
i.fa.fa-arrows
//- #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
//- The drag handle obeys the same "Show desktop drag handles"
setting as swimlanes, lists and cards. With handles ON it is
the only place a board drag may start, so the rest of the
tile is free to dragscroll; with handles OFF there is no
handle and the whole tile is the drag source.
if isTouchScreenOrShowDesktopDragHandles
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
span.emoji-icon
i.fa.fa-arrows
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}")
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}" draggable="false")
span.details
span.board-list-item-name(title="{{boardOpenAndMoveHint}}")
+viewer
@@ -228,6 +265,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
@@ -291,6 +330,17 @@ template(name="workspaceTree")
if children
+workspaceTree(nodes=children selectedWorkspaceId=selectedWorkspaceId)
//- #6524: creating a workspace used window.prompt(), which a browser may refuse
to show at all - iOS Safari lets the user suppress further prompts for a page,
and then the "+" beside Workspaces did nothing visible. This is a normal popup
with a real input, like every other create flow in WeKan.
template(name="addWorkspacePopup")
form.js-add-workspace-form
label
| {{_ 'allboards.add-workspace-prompt'}}
input.js-new-workspace-name(type="text" autocomplete="off" autofocus required)
input.primary.wide(type="submit" value="{{_ 'allboards.add-workspace'}}")
template(name="workspaceActionsPopup")
form.workspace-actions-form.js-workspace-actions-form(data-id="{{id}}")
label
+249 -56
View File
@@ -6,9 +6,10 @@ import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { BoardMultiSelection } from '/client/lib/boardMultiSelection';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import '/client/lib/dragDropTouch'; // touch -> HTML5 DnD so board icons drag by finger
import {
isDragReorderEnabled,
computeReorderedSortIndex,
computeSortIndexMapping,
} from '/models/lib/boardSortReorder';
// SubsManager removed for Meteor 3 migration
@@ -423,7 +424,7 @@ Template.boardList.helpers({
const tpl = Template.instance();
const selectedMenuVar = tpl.selectedMenu;
if (!selectedMenuVar || typeof selectedMenuVar.get !== 'function') {
return { icon: '🗂️', text: 'Workspaces' };
return { faIcon: 'fa-folder-open', icon: '🗂️', text: 'Workspaces' };
}
const sel = selectedMenuVar.get();
const currentUser = ReactiveCache.getCurrentUser();
@@ -456,30 +457,31 @@ Template.boardList.helpers({
};
if (sel === 'starred') {
return { icon: '⭐', text: safeTranslate('allboards.starred', 'Starred') };
return { faIcon: 'fa-star', icon: '⭐', text: safeTranslate('allboards.starred', 'Starred') };
} else if (sel === 'templates') {
return { icon: '📋', text: safeTranslate('allboards.templates', 'Templates') };
return { faIcon: 'fa-clipboard', icon: '📋', text: safeTranslate('allboards.templates', 'Templates') };
} else if (sel === 'remaining') {
return { icon: '📂', text: safeTranslate('allboards.remaining', 'Remaining') };
return { faIcon: 'fa-folder', icon: '📂', text: safeTranslate('allboards.remaining', 'Remaining') };
} else {
// sel is a workspaceId, build path
if (!tpl.workspacesTreeVar || typeof tpl.workspacesTreeVar.get !== 'function') {
return { icon: '🗂️', text: safeTranslate('allboards.workspaces', 'Workspaces') };
return { faIcon: 'fa-folder-open', icon: '🗂️', text: safeTranslate('allboards.workspaces', 'Workspaces') };
}
const tree = tpl.workspacesTreeVar.get();
const spacePath = findSpaceById(tree, sel);
if (spacePath && spacePath.length > 0) {
const pathText = spacePath.map((s) => s.name).join(' / ');
return {
faIcon: 'fa-folder-open',
icon: '🗂️',
text: `${safeTranslate('allboards.workspaces', 'Workspaces')} / ${pathText}`,
};
}
return { icon: '🗂️', text: safeTranslate('allboards.workspaces', 'Workspaces') };
return { faIcon: 'fa-folder-open', icon: '🗂️', text: safeTranslate('allboards.workspaces', 'Workspaces') };
}
} catch (error) {
console.error('Error in currentMenuPath:', error);
return { icon: '🗂️', text: 'Workspaces' };
return { faIcon: 'fa-folder-open', icon: '🗂️', text: 'Workspaces' };
}
},
boards() {
@@ -542,6 +544,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 +569,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 +610,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 +648,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);
@@ -828,7 +852,83 @@ Template.workspaceTree.helpers({
},
});
// #6521: HTML5 dragstart's `target` is the DRAGGABLE element (the whole
// `li.js-board`) in Chrome/Firefox, NOT the sub-element the pointer was on - so
// checking `dragstart.target.closest('.board-handle')` is always null and the
// handle-only drag gate cancelled EVERY handle drag. Record instead where the
// mousedown began (which does fire on the handle), and read that in dragstart.
let boardPressStartedOnHandle = false;
// The live drop-gap element (a plain div, not Blaze-managed) and which side of
// its reference tile it currently sits on.
let boardPlaceholderEl = null;
function ensureBoardPlaceholder() {
if (!boardPlaceholderEl) {
boardPlaceholderEl = document.createElement('li');
boardPlaceholderEl.className = 'js-board-placeholder board-drop-placeholder';
}
return boardPlaceholderEl;
}
function showBoardPlaceholder(tile, after) {
const ph = ensureBoardPlaceholder();
const ref = after ? tile.nextSibling : tile;
// Do not insert the gap immediately next to the dragged tile's own slot.
if (ref === ph) return;
tile.parentNode.insertBefore(ph, ref);
}
function removeBoardPlaceholder() {
if (boardPlaceholderEl && boardPlaceholderEl.parentNode) {
boardPlaceholderEl.parentNode.removeChild(boardPlaceholderEl);
}
}
// Read the intended order from the DOM: every board id in list order, with the
// dragged id taken from its old slot and placed where the gap currently sits.
// Using the gap's real position is what makes releasing OVER the gap work - the
// drop does not have to land on a specific tile.
function orderFromDomWithPlaceholder(tpl, draggedId) {
const nodes = Array.from(tpl.findAll('.js-board, .js-board-placeholder'));
const ids = [];
let placed = false;
for (const el of nodes) {
if (el.classList.contains('js-board-placeholder')) {
ids.push(draggedId);
placed = true;
} else {
const id = Blaze.getData(el) && Blaze.getData(el)._id;
if (id && id !== draggedId) ids.push(id);
}
}
// No gap shown (e.g. a drop with no prior dragover): leave the order untouched.
return placed ? ids : null;
}
function persistBoardOrderFromDom(evt, tpl) {
if (!isDragReorderEnabled(currentAllBoardsSortBy())) return;
const dt = evt.originalEvent.dataTransfer;
if (dt && dt.getData('application/x-board-multi') === 'true') return;
const draggedId = dt ? dt.getData('text/plain') : '';
if (!draggedId) return;
evt.preventDefault();
evt.stopPropagation();
const order = orderFromDomWithPlaceholder(tpl, draggedId);
removeBoardPlaceholder();
if (!order) return;
const before = (ReactiveCache.getCurrentUser()?.profile || {}).boardSortIndex || {};
const mapping = computeSortIndexMapping(order);
// No-op if the visible order did not actually change.
const unchanged = order.every((id, i) => before[id] === i) &&
Object.keys(mapping).length === order.length;
if (unchanged) return;
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser && typeof currentUser.setBoardSortIndexes === 'function') {
currentUser.setBoardSortIndexes(mapping);
}
}
Template.boardList.events({
'mousedown .js-board'(evt) {
boardPressStartedOnHandle = !!(evt.target && evt.target.closest &&
evt.target.closest('.board-handle'));
},
'click .js-select-menu'(evt, tpl) {
const type = evt.currentTarget.getAttribute('data-type');
tpl.selectedWorkspaceIdVar.set(null);
@@ -840,21 +940,11 @@ Template.boardList.events({
tpl.selectedMenu.set(id);
},
'click .js-open-workspace-menu': Popup.open('workspaceActions'),
'click .js-add-workspace'(evt, tpl) {
evt.preventDefault();
const name = prompt(
TAPi18n.__('allboards.add-workspace-prompt') || 'New Space name',
);
if (name && name.trim()) {
Meteor.call(
'createWorkspace',
{ parentId: null, name: name.trim() },
(err, res) => {
if (err) console.error(err);
},
);
}
},
// #6524: opens a popup with a real input. This used to call window.prompt(),
// which a browser may simply refuse to show - iOS Safari offers "don't allow
// further prompts" and then suppresses every later one for that page, so the
// "+" beside Workspaces did nothing at all and no input field ever appeared.
'click .js-add-workspace': Popup.open('addWorkspace'),
'click .js-add-board'(evt, tpl) {
// Store the currently selected workspace/menu for board creation
const selectedWorkspaceId = tpl.selectedWorkspaceIdVar.get();
@@ -916,6 +1006,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) {
@@ -957,6 +1070,26 @@ Template.boardList.events({
'dragstart .js-board'(evt) {
const boardId = this._id;
// Honour the "Show desktop drag handles" setting here too. With handles ON
// the handle is the ONLY drag source - the rest of the tile stays free, so a
// finger dragging across it scrolls the board list instead of picking the
// board up. That is the same contract swimlanes, lists and cards follow on a
// board. With handles OFF there is no handle to grab, so the whole tile
// drags, exactly as before.
//
// The `li` keeps `draggable="true"` either way: HTML5 drag-and-drop can only
// start from a draggable element, and moving that attribute onto the handle
// would drag the handle rather than the board. Instead the drag is cancelled
// unless it began on the handle.
// With handles ON, only a drag that BEGAN on the handle may reorder. The
// mousedown handler above recorded that (dragstart.target is unreliable - it
// is the whole li, not the handle). A drag that started elsewhere on the tile
// is cancelled, leaving the rest of the tile free to scroll.
if (Utils.showDragHandles() && !boardPressStartedOnHandle) {
evt.preventDefault();
return;
}
// Support multi-drag
if (
BoardMultiSelection.isActive() &&
@@ -977,6 +1110,25 @@ Template.boardList.events({
try {
evt.originalEvent.dataTransfer.setData('text/plain', boardId);
} catch (e) {}
if (isDragReorderEnabled(currentAllBoardsSortBy())) {
const el = evt.currentTarget;
// Centre the drag ghost on the cursor. dragover fires for whatever is
// under the CURSOR, but the icon was grabbed off-centre (often at the
// handle), so the ghost sat beside the cursor and the gap opened for the
// wrong icon - you had to aim the target's left edge. With the ghost
// centred on the cursor, the gap opens for the icon the ghost is over.
try {
const rect = el.getBoundingClientRect();
evt.originalEvent.dataTransfer.setDragImage(
el, rect.width / 2, rect.height / 2,
);
} catch (e) {}
// Take the dragged icon OUT of the grid flow while dragging, so the other
// icons reflow as if it were gone and the gap sits exactly where the icon
// will land. On a timeout so the drag image is already captured (hiding it
// synchronously in dragstart cancels the drag).
setTimeout(() => el.classList.add('board-dragging-hidden'), 0);
}
}
// Highlight valid drop targets in the sidebar so users know where to drop
document.querySelectorAll('.workspace-node').forEach((el) => {
@@ -988,7 +1140,14 @@ Template.boardList.events({
}
});
},
'dragend .js-board'() {
'dragend .js-board'(evt) {
boardPressStartedOnHandle = false;
removeBoardPlaceholder();
if (evt && evt.currentTarget) {
evt.currentTarget.classList.remove('board-dragging-hidden');
}
document.querySelectorAll('.js-board.board-dragging-hidden').forEach((el) =>
el.classList.remove('board-dragging-hidden'));
document.querySelectorAll('.workspace-node.board-drag-hint, .js-select-menu.board-drag-hint').forEach((el) => {
el.classList.remove('board-drag-hint');
});
@@ -1002,45 +1161,57 @@ Template.boardList.events({
// target (which is why, before this, the browser showed the not-allowed cursor
// and nothing persisted). The reorder decision/index math lives in the pure,
// unit-tested models/lib/boardSortReorder.js.
// #6439: live insertion placeholder. While a board is dragged, an empty gap
// opens up before/after the tile under the cursor, showing exactly where the
// board will land - the standard sortable feel - instead of only highlighting
// a target. The gap is a plain DOM node inserted next to Blaze-managed tiles
// and removed on dragend, so it never fights Blaze's reactive list.
//
// `after` (cursor past the tile's inline-centre) lets a board drop on either
// side of a tile, so the end of the order is reachable by dropping past the
// last tile - no separate end target needed.
'dragover .js-board'(evt, tpl) {
if (!isDragReorderEnabled(currentAllBoardsSortBy())) return;
// A multi-board drag is a share/assign gesture, not a reorder.
const dt = evt.originalEvent.dataTransfer;
if (dt && dt.getData('application/x-board-multi') === 'true') return;
evt.preventDefault();
evt.stopPropagation();
if (dt) dt.dropEffect = 'move';
evt.currentTarget.classList.add('board-reorder-over');
},
'dragleave .js-board'(evt) {
evt.currentTarget.classList.remove('board-reorder-over');
const tile = evt.currentTarget;
// Hovering a tile opens the gap AT that tile's slot (insert before it), so a
// board dropped anywhere on a card lands in that card's place - no need to
// aim the card's left half. The only exception is the LAST tile: its trailing
// half inserts AFTER it, so the end of the order stays reachable.
// Exclude the hidden dragged tile: it is still in the DOM (display:none)
// but not visually present, so it must not count as the last tile.
const allTiles = Array.from(tile.parentNode.querySelectorAll('.js-board'))
.filter((t) => !t.classList.contains('board-dragging-hidden'));
const isLast = allTiles[allTiles.length - 1] === tile;
let after = false;
if (isLast) {
const rect = tile.getBoundingClientRect();
const ltr = getComputedStyle(tile).direction !== 'rtl';
const mid = rect.left + rect.width / 2;
const x = evt.originalEvent.clientX;
after = ltr ? x > mid : x < mid;
}
showBoardPlaceholder(tile, after);
},
'drop .js-board'(evt, tpl) {
persistBoardOrderFromDom(evt, tpl);
},
// Releasing OVER the gap must also drop: the placeholder is not a `.js-board`,
// so without these the drop landed on nothing and the board snapped back.
'dragover .js-board-placeholder'(evt) {
if (!isDragReorderEnabled(currentAllBoardsSortBy())) return;
const dt = evt.originalEvent.dataTransfer;
if (dt && dt.getData('application/x-board-multi') === 'true') return;
const draggedId = dt ? dt.getData('text/plain') : '';
if (!draggedId) return;
evt.preventDefault();
evt.stopPropagation();
evt.currentTarget.classList.remove('board-reorder-over');
const targetId = this._id;
// #6442: current display order of the board grid. Read each board's _id from
// its Blaze data context (the same source dragstart uses via `this._id`) —
// NOT el.classList[0]. On `li.js-board(class="{{_id}} …")` Jade emits the
// literal `js-board` class FIRST, so classList[0] is the string "js-board"
// for every board; the old code therefore produced ['js-board','js-board',…],
// computeReorderedSortIndex could not find the dragged/target ids among them,
// returned null, and the drop silently did nothing (board snapped back).
const orderedIds = Array.from(tpl.findAll('.js-board'))
.map((el) => Blaze.getData(el)?._id)
.filter(Boolean);
const mapping = computeReorderedSortIndex(orderedIds, draggedId, targetId);
if (!mapping) return;
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser && typeof currentUser.setBoardSortIndexes === 'function') {
currentUser.setBoardSortIndexes(mapping);
}
if (dt) dt.dropEffect = 'move';
},
'drop .js-board-placeholder'(evt, tpl) {
persistBoardOrderFromDom(evt, tpl);
},
'click .js-clone-board'(evt) {
if (confirm(TAPi18n.__('duplicate-board-confirm'))) {
@@ -1103,6 +1274,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) {
@@ -1402,6 +1577,24 @@ Template.workspaceActionsPopup.helpers({
},
});
Template.addWorkspacePopup.events({
'submit .js-add-workspace-form'(evt) {
evt.preventDefault();
const input = evt.currentTarget.querySelector('.js-new-workspace-name');
const name = (input && input.value ? input.value : '').trim();
if (!name) {
// Nothing typed: keep the popup open and the field focused rather than
// silently closing, which is what a cancelled prompt() used to look like.
if (input) input.focus();
return;
}
Meteor.call('createWorkspace', { parentId: null, name }, err => {
if (err) console.error(err);
});
Popup.back();
},
});
Template.workspaceActionsPopup.events({
'submit .js-workspace-actions-form'(evt) {
evt.preventDefault();
+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 {
+41
View File
@@ -174,6 +174,41 @@
display: block;
}
}
/* #6521: on a touch device the handle is the ONLY way to drag a card - the rest
of the minicard deliberately pans the board instead - so it has to be a target
a finger can actually hit. A 20-28px icon in the bottom-right corner is a
mouse-sized target; on a wall-mounted infoscreen it is close to unusable.
On a coarse pointer it becomes a full-height strip down the leading (left in
LTR) edge: ~48px wide, the Material minimum, and the easiest edge to reach
with a thumb. The icon stays centred in it. Mouse users keep the compact
corner handle, since `pointer: coarse` does not match a mouse. */
@media (pointer: coarse) {
.minicard .handle {
width: 48px;
height: 100%;
top: 0;
inset-inline-end: auto;
inset-inline-start: 0;
display: flex;
align-items: center;
justify-content: center;
/* Faint tint so the strip is discoverable without shouting. */
background: rgba(127, 127, 127, 0.08);
border-start-start-radius: inherit;
border-end-start-radius: inherit;
}
/* The card's own content must not sit under the strip. Keyed off the class,
not a sibling selector: the details menu and the dependency handle are
absolutely positioned, and a margin would move them. */
.minicard.minicard-with-handle {
padding-inline-start: 48px;
}
.minicard .handle .drag-handle,
.minicard .handle .fa {
font-size: 22px;
}
}
.minicard .handle .drag-handle {
font-size: clamp(16px, 3vw, 20px);
color: #ccc;
@@ -203,6 +238,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 -1
View File
@@ -1,5 +1,13 @@
template(name="minicard")
.minicard.nodragscroll(
//- `nodragscroll` ONLY when the minicard itself is the card's drag source, i.e.
//- when drag handles are OFF (list.js then sets the sortable handle to
//- `.minicard`). That is the whole point of drag handles: with handles ON the
//- card body is free, so a finger dragging across it PANS the board
//- (dragscroll) instead of doing nothing. It used to be unconditional, so with
//- handles on, a drag starting on a card neither panned nor moved the card.
.minicard(
class="{{#unless isTouchScreenOrShowDesktopDragHandles}}nodragscroll{{/unless}}"
class="{{#if showMinicardHandle}}minicard-with-handle{{/if}}"
class="{{#if isLinkedCard}}linked-card{{/if}}"
class="{{#if isLinkedBoard}}linked-board{{/if}}"
class="{{agingClass}}"
+8
View File
@@ -29,6 +29,14 @@ function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
// });
Template.minicard.helpers({
// True exactly when the drag handle is rendered (see minicard.jade): the user
// may move the card AND drag handles are on. On a coarse pointer the handle is
// a 48px strip down the leading edge, so the card's own content needs matching
// padding - keyed off this so a card the user cannot move never gets a gutter
// for a handle that is not there.
showMinicardHandle() {
return Utils.canMoveCard() && Utils.showDragHandles();
},
// #3392: show a drag-to-connect handle on the minicard when the board's
// dependency overlay is on and the user can edit the board. Dragging it onto
// another card creates a dependency (handled in dependencyOverlay.js).
+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 {
+61 -13
View File
@@ -173,6 +173,12 @@ function assignBoardToNamedWorkspace(boardId, wsName, parentId = null) {
Template.import.onCreated(function () {
this.error = new ReactiveVar('');
// #6506: import shows the "map members" step so imported members can be mapped to
// EXISTING WeKan users (auto-suggested by username). It is OPTIONAL — a Skip button
// (and the textarea "import without mapping" button) bypasses it. Whatever is not
// mapped is brought in as a virtual (placeholder) user carrying its username / full
// name — NOT collapsed onto the importing user — and a board admin can still map a
// virtual member to an existing user later from the sidebar member-avatar popup.
this.steps = ['importTextarea', 'importMapMembers'];
this._currentStepIndex = new ReactiveVar(0);
this.importedData = new ReactiveVar();
@@ -311,15 +317,17 @@ Template.import.onCreated(function () {
};
this.finishImport = async () => {
const membersMapping = this.membersToMap.get();
// #6506: build the member mapping from the (optional) map-members step —
// { importedMemberId: existingWekanUserId } for members the user mapped (or that
// auto-matched by username). Members left unmapped, or skipped entirely, are sent
// WITHOUT an entry: the server creator brings them in as virtual (placeholder)
// users instead of collapsing them onto the importing user.
const mappingById = {};
if (membersMapping) {
membersMapping.forEach(member => {
if (member.wekanId) {
mappingById[member.id] = member.wekanId;
}
});
}
(this.membersToMap.get() || []).forEach(member => {
if (member && member.id && member.wekanId) {
mappingById[member.id] = member.wekanId;
}
});
const importedData = this.importedData.get();
// Trello: import over HTTP (see postTrelloImport) so the realtime DDP
@@ -353,8 +361,18 @@ Template.import.onCreated(function () {
return;
}
// wekan / csv: unchanged DDP import.
// wekan / csv: DDP import, guarded by a client-side watchdog so a stalled or
// hung import can never leave the spinner running forever — if the method has not
// answered in WEKAN_IMPORT_TIMEOUT_MS (default 2 min) we surface a timeout error
// and clear the spinner. The server bounds the import itself too (see importBoard).
this.membersToMap.set([]);
let settled = false;
const timeoutMs = parseInt(window.WEKAN_IMPORT_TIMEOUT_MS, 10) || 120000;
const watchdog = Meteor.setTimeout(() => {
if (settled) return;
settled = true;
this.setError('import-timeout');
}, timeoutMs);
Meteor.call(
'importBoard',
importedData,
@@ -362,6 +380,9 @@ Template.import.onCreated(function () {
this.importSource,
Session.get('fromBoard'),
(err, res) => {
if (settled) return; // already timed out
settled = true;
Meteor.clearTimeout(watchdog);
if (err) {
this.setError(err.error);
} else {
@@ -652,14 +673,41 @@ function importPerformSearch(tpl, query) {
}
}
// Map the currently-selected imported member to a WeKan user and close the popup.
function importMapToUser(wekanId) {
if (wekanId && _importMapMembersTpl) {
_importMapMembersTpl.mapSelectedMember(wekanId);
}
Popup.back();
}
Template.importMapMembersAddPopup.events({
'click .js-select-import'(event, tpl) {
if (_importMapMembersTpl) {
_importMapMembersTpl.mapSelectedMember(Template.currentData().__originalId);
'click .js-select-import'(event) {
// Map to the WeKan user _id carried on the .js-select-import anchor's data-id.
// Reading it from the anchor (event.currentTarget) is robust to the click
// landing on a child node (the avatar or the name span), where
// Template.currentData() is undefined and `.­_id` threw "Cannot read properties
// of undefined (reading '_id')" (#6508). (`data-id` is set to {{_id}} in the
// template; the earlier fix from `__originalId` to `_id` was correct but read
// the wrong context.)
const id = event.currentTarget.getAttribute('data-id');
if (id) importMapToUser(id);
},
// Enter selects the first (highlighted) search result, so a name can be assigned
// by keyboard without a mouse click.
'keydown .js-search-member-input'(event, tpl) {
if (event.keyCode === 13) {
event.preventDefault();
const results = tpl.searchResults.get();
if (results && results.length) {
importMapToUser(results[0]._id);
}
}
Popup.back();
},
'keyup .js-search-member-input'(event, tpl) {
if (event.keyCode === 13) {
return; // handled on keydown
}
const query = event.target.value.trim();
if (tpl.searchTimeout) {
+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');
}
},
});
+29 -6
View File
@@ -11,7 +11,10 @@ import { MultiSelection } from '/client/lib/multiSelection';
import { Utils } from '/client/lib/utils';
import { isLinkableCardTarget } from '/models/lib/linkedCardTarget';
import { listCardsSelector } from '/models/lib/swimlaneFilter';
import { sortWithIdTiebreaker } from '/models/lib/cardSortTiebreaker';
import { sortCardsByTitle } from '/models/lib/sortCardsByTitle';
import { labelMatchesTerm } from '/models/lib/labelAutocomplete';
import { memberMatchesTerm } from '/models/lib/memberAutocomplete';
import { isLazyCards, BoardListCardCounts, windowCountId } from '/client/lib/lazyCards';
import {
mutationsChangeDragGeometry,
@@ -346,6 +349,13 @@ Template.listBody.helpers({
if (!sortBy) {
sortBy = defaultSort;
}
// #6511: append a UNIQUE `_id` tiebreaker so the LIMITED, ordered card cursor
// never has ambiguous ties. Equal `sort` (or equal due-date, etc.) values would
// otherwise order non-deterministically across observe cycles, and Blaze's #each
// ordered diff throws "Bad index in range.removeMember" — leaving the board with
// no cards. Applied before BOTH the server window subscription and the client
// cursor below, so they agree on a deterministic order.
sortBy = sortWithIdTiebreaker(sortBy);
// #6441: build the swimlane-membership fallback as a single `swimlaneId:
// { $in: [...] }` clause (via the shared, unit-tested helper) instead of a
// bare top-level `$or`, so it never competes with the board Filter's own
@@ -366,7 +376,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',
@@ -579,6 +589,15 @@ Template.addCardForm.onCreated(function () {
} else if (evt.keyCode === 9) {
// Prevent custom focus movement on Tab key for accessibility
// evt.preventDefault();
// #1195: Tab opens the NEXT column's add-card form, but it used to abandon
// whatever was typed in THIS one — silently losing the card's content. Submit
// the current card first (when the textarea has non-whitespace content) so it
// is saved to this list, then move on to the next column's form.
const $currentForm = $(evt.currentTarget).closest('form');
const typed = $currentForm.find('textarea').val();
if (typeof typed === 'string' && typed.trim() !== '') {
$currentForm.find('button[type=submit]').click();
}
const isReverse = evt.shiftKey;
const list = $(`#js-list-${Template.currentData().listId}`);
const listSelector = '.js-list:not(.js-list-composer)';
@@ -640,7 +659,11 @@ Template.addCardForm.onRendered(function () {
callback(
$.map(currentBoard.activeMembers(), member => {
const user = ReactiveCache.getUser(member.userId);
return user.username.indexOf(term) === 0 ? user : null;
// #5116 follow-up: match case-INSENSITIVELY as a substring of the
// username OR full name (was user.username.indexOf(term) === 0,
// which was case-sensitive, prefix-only, username-only and threw
// when getUser() returned null). Shared with the editor '@' mention.
return memberMatchesTerm(user, term) ? user : null;
}),
);
},
@@ -667,10 +690,10 @@ Template.addCardForm.onRendered(function () {
if (label.name == undefined) {
label.name = "";
}
if (
label.name.indexOf(term) > -1 ||
label.color.indexOf(term) > -1
) {
// #5116: match case-INSENSITIVELY so "#test" suggests "Testing"
// (was label.name.indexOf(term), which never matched a differently
// cased label). Matches name or color, like the shared helper.
if (labelMatchesTerm(label, term)) {
return label;
}
return null;
+11 -4
View File
@@ -1,5 +1,8 @@
template(name="listHeader")
.list-header.js-list-header.nodragscroll(
//- Same rule as the minicard: the list header is the LIST's drag source only
//- when drag handles are OFF, so only then must it opt out of dragscroll.
.list-header.js-list-header(
class="{{#unless isTouchScreenOrShowDesktopDragHandles}}nodragscroll{{/unless}}"
class="{{#if limitToShowCardsCount}}list-header-card-count{{/if}}"
class=colorClass
style=colorStyle)
@@ -18,7 +21,7 @@ template(name="listHeader")
if collapsed
if showCardsCountForList cardsCountWhole
br
span.cardCount {{cardsCount}}
span.cardCount {{cardsCount ../../_id}}
if isMiniScreen
h2.list-header-name(
title="{{ displayDate modifiedAt 'LLL' }}"
@@ -30,7 +33,7 @@ template(name="listHeader")
span(class="{{#if exceededWipLimit}}highlight{{/if}}") {{cardsCountWhole}}
|/#{wipLimit.value})
if showCardsCountForList cardsCountWhole
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cardsCountWhole}}
span.cardCount {{cardsCount ../../_id}} {{cardsCountForListIsOne cardsCountWhole}}
if hasNumberFieldsSum
| &nbsp;
span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}}
@@ -52,7 +55,7 @@ template(name="listHeader")
|/#{wipLimit.value})
unless collapsed
if showCardsCountForList cardsCountWhole
span.cardCount {{cardsCount}} {{cardsCountForListIsOne cardsCountWhole}}
span.cardCount {{cardsCount ../../_id}} {{cardsCountForListIsOne cardsCountWhole}}
if hasNumberFieldsSum
| &nbsp;
span.list-sum-badge(title="{{_ 'sum-of-number-fields'}}") ∑ {{numberFieldsSum}}
@@ -101,6 +104,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
+31 -4
View File
@@ -61,11 +61,22 @@ Template.listHeader.helpers({
}
},
cardsCount() {
cardsCount(containerSwimlaneId) {
const list = Template.currentData();
let swimlaneId = '';
if (Utils.boardView() === 'board-view-swimlanes') {
swimlaneId = list.swimlaneId || '';
// Scope the count to the swimlane this header is rendered IN — the SAME id
// the card body uses (listBody.jade `idOrNull ../../_id`) — passed from
// listHeader.jade as `../../_id`. A SHARED list (empty swimlaneId) renders
// under every swimlane, so scoping by the list's OWN swimlaneId (== '' for a
// shared list) reported the whole-list count in every swimlane while the
// cards rendered below were swimlane-scoped — e.g. "5 Cards" over an empty
// second swimlane. Fall back to the list's own binding when no container id
// is provided (older callers / non-swimlane include paths).
swimlaneId =
typeof containerSwimlaneId === 'string' && containerSwimlaneId
? containerSwimlaneId
: list.swimlaneId || '';
}
// In lazy mode minimongo holds only the loaded window, so use the accurate
@@ -208,6 +219,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 +398,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;
}
+56 -35
View File
@@ -3,6 +3,7 @@ import { findWhere } from '/imports/lib/collectionHelpers';
import { TAPi18n } from '/imports/i18n';
import Attachments from '/models/attachments';
import { Utils } from '/client/lib/utils';
import { memberMatchesTerm } from '/models/lib/memberAutocomplete';
import autosize from 'autosize';
var converter = require('@wekanteam/html-to-markdown');
@@ -25,33 +26,6 @@ const specialHandleNames = specialHandles.map(m => m.username);
Template.editor.onRendered(function () {
const tpl = this;
// Start: Copy <pre> code https://github.com/wekan/wekan/issues/5149
// TODO: Try to make copyPre visible at Card Details after editing or closing editor or Card Details.
// - Also this same TODO below at event, if someone gets it working.
var copy = function(target) {
var textArea = document.createElement('textarea');
textArea.setAttribute('style','width:1px;border:0;opacity:0;');
document.body.appendChild(textArea);
textArea.value = target.innerHTML;
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
var pres = document.querySelectorAll(".viewer > pre");
pres.forEach(function(pre){
var button = document.createElement("a");
button.className = "fa fa-copy btn btn-sm right";
// TODO: Translate text 'Copy text to clipboard'
button.setAttribute('title','Copy text to clipboard');
button.innerHTML = '';
pre.parentNode.insertBefore(button, pre);
button.addEventListener('click', function(e){
e.preventDefault();
copy(pre.childNodes[0]);
})
})
// End: Copy <pre> code
const textareaSelector = 'textarea';
const mentions = [
// User mentions
@@ -59,14 +33,14 @@ Template.editor.onRendered(function () {
match: /\B@([\w.-]*)$/,
search(term, callback) {
const currentBoard = Utils.getCurrentBoard();
const searchTerm = term.toLowerCase();
// Shared with the add-card '@' mention (models/lib/memberAutocomplete):
// case-insensitive substring match on username OR full name, and safe
// when getUser() returns null (previously threw on user.username).
const users = currentBoard
.activeMembers()
.map(member => {
const user = ReactiveCache.getUser(member.userId);
const username = user.username.toLowerCase();
const fullName = user.profile && user.profile !== undefined && user.profile.fullname ? user.profile.fullname.toLowerCase() : "";
return username.includes(searchTerm) || fullName.includes(searchTerm) ? user : null;
return memberMatchesTerm(user, term) ? user : null;
})
.filter(Boolean);
// Order: 1. Users, 2. Card-specific options, 3. Board-wide options
@@ -332,10 +306,9 @@ Template.editor.events({
const $editor = tpl.$('textarea.editor');
$editor[0].value = converter.convert($editor[0].value);
},
// TODO: Try to make copyPre visible at Card Details after editing or closing editor or Card Details.
//'click .js-close-inlined-form'(event) {
// Utils.copyPre();
//},
// #5149: the code-block copy button is now (re)added by Template.viewer
// .onRendered for every viewer render, so it no longer needs to be poked
// after closing the inline editor.
});
import DOMPurify from 'dompurify';
@@ -463,6 +436,54 @@ Blaze.Template.registerHelper(
}),
);
// #5149: copy a code block to the clipboard from the read-only card viewer.
// Copy the RAW text (textContent) of the <pre> — the previous version copied the
// HTML-escaped innerHTML of the first child node (often the wrong content or
// undefined) via a one-time global `document.querySelectorAll('.viewer > pre')`
// in Template.editor.onRendered, which decorated only whichever viewers happened
// to be on the page at that instant ("works sporadically") and lost the button
// after editing. Decorating in Template.viewer.onRendered runs for EVERY viewer
// (description, checklist item, comment) and again on every re-render, and is
// de-duplicated so re-renders never stack multiple buttons.
function copyCodeBlockText(text) {
const value = typeof text === 'string' ? text : '';
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(value).catch(() => fallbackCopyText(value));
} else {
fallbackCopyText(value);
}
}
function fallbackCopyText(value) {
const textArea = document.createElement('textarea');
textArea.setAttribute('style', 'position:fixed;top:0;left:0;width:1px;height:1px;border:0;opacity:0;');
textArea.value = value;
document.body.appendChild(textArea);
textArea.select();
try { document.execCommand('copy'); } catch (e) { /* clipboard unavailable */ }
document.body.removeChild(textArea);
}
Template.viewer.onRendered(function () {
const title = TAPi18n.__('copy-text-to-clipboard');
this.$('pre').each(function () {
const pre = this;
// De-dup: skip a <pre> that already has its copy button in front of it.
const prev = pre.previousElementSibling;
if (prev && prev.classList && prev.classList.contains('js-copy-code')) {
return;
}
const button = document.createElement('a');
button.className = 'fa fa-copy btn btn-sm right js-copy-code';
button.setAttribute('href', '#');
button.setAttribute('title', title);
pre.parentNode.insertBefore(button, pre);
button.addEventListener('click', function (e) {
e.preventDefault();
copyCodeBlockText(pre.textContent);
});
});
});
Template.viewer.events({
// Viewer sometimes have click-able wrapper around them (for instance to edit
// the corresponding text). Clicking a link shouldn't fire these actions, stop
+94 -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;
}
@@ -149,3 +141,94 @@ code {
outline-offset: -2px;
background-color: rgba(33, 102, 148, 0.08);
}
/* ── Search All Boards on a phone ────────────────────────────────────────────
`.global-search-page` is `width: 40%; min-width: 400px`, sized for a desktop
column. On a 375px phone the min-width alone pushes the whole block past the
right edge, so everything inside it - the Label Colors palette most visibly -
sits partly in the invisible area off-screen. The palette already has
`flex-wrap: wrap`; it could not help, because its container was wider than the
screen, so there was nothing to wrap into.
At small width the page block is simply as wide as the screen. The wrappers
with their own desktop minimums are released too, or they would re-create the
overflow one level down. */
@media screen and (max-width: 800px) {
.global-search-page {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding-inline: 8px;
}
/* ONE scroller: the body. Dragging the header bars (which sit OUTSIDE
#content) already scrolls the page, which proves the body is the real
scroller. The previous attempt made #content its own `overflow-y: auto`
scroller - but in the grey content area that TRAPPED the touch: #content
caught the gesture and, when it had nothing of its own to scroll, did not
pass it on to the body, so a finger drag over the text did nothing while the
same drag over the header still scrolled.
So #content is made a pass-through again: `overflow: visible` (do not catch
the touch) and `flex: 0 0 auto` with `min-height: auto` so it takes its
content's full natural height rather than being squeezed to the viewport by
the default `flex: 1`. That makes the body's content taller than the body,
so the BODY scrolls - one scroller, reachable by a finger anywhere, header or
content alike.
The body is `100dvh` so its bottom tracks Safari's toolbar (not the large
`100vh`), `overflow-y: auto` so it scrolls, and `overscroll-behavior:
contain` so the drag does not chain out to pull-to-refresh. All scoped with
`:has`, so only this page is affected. */
body:has(.wrapper.global-search-wrapper) {
height: 100dvh;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
body:has(.wrapper.global-search-wrapper) #content {
flex: 0 0 auto;
min-height: auto;
height: auto;
overflow: visible;
}
.wrapper.global-search-wrapper {
height: auto;
min-height: 0;
max-height: none;
overflow: visible;
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}
.global-search-page,
.global-search-results-list-wrapper.global-search-results-list,
form.global-search-page.js-search-query-form {
max-height: none;
overflow: visible;
}
.global-search-board-wrapper,
.global-search-results-list-wrapper {
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
/* Chips wrap onto as many rows as they need and never force a row wider than
the screen. `min-width: 0` lets a chip shrink below its content width, which
a flex item otherwise refuses to do. */
.global-search-page .palette-colors {
flex-wrap: wrap;
max-width: 100%;
}
.global-search-page .palette-colors .palette-color,
.global-search-page .lists-wrapper .card-label {
min-width: 0;
max-width: 100%;
overflow-wrap: break-word;
}
}
+8 -3
View File
@@ -58,7 +58,12 @@ template(name="resultsPaged")
template(name="globalSearch")
if currentUser
.wrapper(class=boardColorClass)
//- `global-search-wrapper` gives this page a scoped hook so its CSS can undo
the `height: 100vh; overflow: hidden` that the All Boards stylesheet pins on
the GLOBAL `.wrapper` at phone widths - that clip is what hid this page's
lower content. With the clip gone the page's existing scroller (#content)
scrolls it, natively on touch, so no per-element dragscroll is needed.
.wrapper.global-search-wrapper(class=boardColorClass)
form.global-search-page.js-search-query-form(role="search" onsubmit="return false;")
label.sr-only(for="global-search-input") {{_ 'globalSearch-title'}}
input.global-search-query-input(
@@ -71,8 +76,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) || []);
});
});
+222 -10
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;
@@ -168,6 +187,33 @@ body:not(.board-view) #header #header-main-bar h1 {
font-size: 14px;
padding: 4px 15px;
}
/* Mobile mode: the home ICON only, never the "All Boards" label.
`body.mobile-mode` is set by Utils.applyMobileMode() from the phone/desktop
toggle in this same bar, so it is a deliberate user choice and applies at ANY
viewport width - a narrow-viewport media query would miss it on a wide screen
in mobile mode, and miss the point: the user asked for mobile MODE, not for a
width. The label is the widest item in the bar and longer still in most
languages ("Kaikki taulut"), and the house icon alone is unambiguous.
The label is a bare text node inside the link, not a span, so it cannot be
selected on its own: font-size:0 on the link collapses it, and the icon's own
font-size brings the icon back. The text stays in the DOM, so screen readers
and the accessibility tree still announce "All Boards". */
body.mobile-mode #header-quick-access .home-icon a {
font-size: 0;
padding: 4px 6px;
}
body.mobile-mode #header-quick-access .home-icon a .fa-home,
body.mobile-mode #header-quick-access .home-icon .fa-home {
font-size: 16px;
margin-inline-end: 0;
}
body.mobile-mode #header-quick-access .allBoards {
padding: 4px 6px;
}
#header-quick-access a {
text-decoration: none;
}
@@ -1195,13 +1241,21 @@ body:not(.board-view) #header #header-main-bar h1 {
/* ---- Small-screen quick-access bar (ALL browsers, width-based) ----
These rules use width-based selectors (not the .iphone-device class) so
they apply to every browser at small width — Fairphone/Android, narrow
desktop and iPhone alike. Keep every item visible: let the bar wrap and
grow instead of pinning its height and clipping the overflow. */
desktop and iPhone alike.
This used to force `flex-wrap: wrap !important` ("so ~7 items fit in two
wrapped rows"), which was the reason the avatar dropped to a second line
even with space left beside the bell: an !important wrap beats any nowrap,
and a wrapping row breaks BEFORE the last item rather than shrinking. The
items are now made to fit instead (icon-only home link, capped logo, trimmed
bell and username hidden below), so the bar can stay one row: ~320px of a
375px iPhone 12 mini bar. `overflow: visible` stays, so if a translation or
a custom logo ever does overflow, the items spill rather than disappear. */
#header-quick-access {
height: auto !important;
min-height: 28px !important;
overflow: visible !important;
flex-wrap: wrap !important;
flex-wrap: nowrap !important;
align-items: center !important;
row-gap: 4px !important;
}
@@ -1258,6 +1312,48 @@ body:not(.board-view) #header #header-main-bar h1 {
.iphone-device body:not(.board-view) #header-quick-access .zoom-controls .zoom-input {
font-size: 0.7em !important; /* ~30% smaller than surrounding text */
}
/* ── One-row quick-access bar on phones (#6522) ─────────────────────────────
The bar wrapped to two rows on an iPhone 12 mini: the home link carried its
full "All Boards" label (longer still in most languages), the logo was
allowed 120px of a 375px bar, and several items had 0.5rem margins on BOTH
sides. layouts.css now makes the row fit; these repeat it for the
`.iphone-device` fallback, which sets font-size and per-item margins with
!important and would otherwise re-expand the bar straight back to two rows.
`.iphone-device` is added by boardBody.js, so it is present on a board page
and absent on All Boards - both paths have to agree, or the header would
change height when navigating between them.
The home LABEL is a bare text node, not a span, so it cannot be selected:
font-size:0 on the link collapses it and the icon's own size brings the icon
back. The text stays in the DOM, so screen readers still announce it. */
.iphone-device #header-quick-access .home-icon a {
font-size: 0 !important;
padding: 4px 6px !important;
}
.iphone-device #header-quick-access .home-icon a .fa-home,
.iphone-device #header-quick-access .home-icon .fa-home {
font-size: 16px !important;
margin-inline-end: 0 !important;
}
/* 0.5rem on both sides of five items was ~80px of a 375px bar. */
.iphone-device #header-quick-access .home-icon,
.iphone-device #header-quick-access .zoom-controls,
.iphone-device #header-quick-access .mobile-mode-toggle,
.iphone-device #header-quick-access #notifications,
.iphone-device #header-quick-access #header-user-bar {
margin: 0 3px !important;
}
/* One auto margin, so the user menu still sits at the end when there is room
and collapses to nothing when there is not. */
.iphone-device #header-quick-access #header-user-bar {
margin-inline-start: auto !important;
}
.iphone-device #header-quick-access img {
max-height: 20px !important;
max-width: 84px !important;
}
}
@media print {
#header-quick-access .allBoards,
@@ -1292,3 +1388,119 @@ body:not(.board-view) #header #header-main-bar h1 {
#headerIsSettingDatabaseCallDone {
display: none;
}
/* ── Small screens: home ICON only, never the "All Boards" label ─────────────
This block is last in the file on purpose.
The label lives in the quick-access bar as a BARE TEXT NODE inside the home
link (not a span), so it cannot be selected on its own. Collapsing the link's
font-size hides it and the icon's own font-size brings the icon back; the text
stays in the DOM, so screen readers still announce "All Boards".
Doing that needs !important and the last word, because eleven other rules in
this file set a font-size on the same link and most of them are !important -
including three that target the All Boards page specifically via
`body:not(.board-view)` / `.wrapper ~`, and one that sets 16px "Much larger
text". A plain rule (which is what the first attempt at this used) loses to
every one of them, so the label kept showing on exactly the page it was
reported on. Each competing selector shape is repeated here so that where
specificity ties, source order decides in favour of hiding.
Width, not mobile MODE: this is for "all smartphones, small width". The
separate `body.mobile-mode` rule above covers the explicit phone/desktop
toggle at any width. */
@media screen and (max-width: 800px) {
#header-quick-access .home-icon a,
.wrapper ~ #header-quick-access .home-icon a,
body:not(.board-view) #header-quick-access .home-icon a,
.iphone-device #header-quick-access .home-icon a,
.iphone-device .wrapper ~ #header-quick-access .home-icon a,
.iphone-device body:not(.board-view) #header-quick-access .home-icon a {
font-size: 0 !important;
padding: 4px 6px !important;
}
#header-quick-access .home-icon a .fa-home,
#header-quick-access .home-icon .fa-home,
.wrapper ~ #header-quick-access .home-icon .fa-home,
body:not(.board-view) #header-quick-access .home-icon .fa-home,
.iphone-device #header-quick-access .home-icon .fa-home,
.iphone-device .wrapper ~ #header-quick-access .home-icon .fa-home,
.iphone-device body:not(.board-view) #header-quick-access .home-icon .fa-home {
font-size: 16px !important;
margin-inline-end: 0 !important;
padding: 0 !important;
}
#header-quick-access .home-icon,
#header-quick-access .allBoards {
margin-inline-end: 0 !important;
padding-inline-start: 0 !important;
padding-inline-end: 0 !important;
}
/* The notification bell sat in 36px of margin around a 28px icon: 10px on each
side of the button itself (notifications.css) PLUS 0.5rem on each side of its
`#notifications` wrapper, applied by several of the mobile blocks above. That
is more empty space than icon, and on a 375px bar it is space the other
buttons need. Both layers are trimmed here; 2px each side keeps the icons
from touching without wasting a tenth of the bar. */
#header-quick-access #notifications,
.wrapper ~ #header-quick-access #notifications,
body:not(.board-view) #header-quick-access #notifications,
.iphone-device #header-quick-access #notifications,
.iphone-device .wrapper ~ #header-quick-access #notifications,
.iphone-device body:not(.board-view) #header-quick-access #notifications {
margin: 0 !important;
}
#header-quick-access #notifications .notifications-drawer-toggle,
.iphone-device #header-quick-access #notifications .notifications-drawer-toggle {
margin: 0 2px !important;
}
/* Avatar only - no username/full name beside it. The template already hides it
`unless isMiniScreen`, but that is a JS/Session decision (Utils.isMiniScreen
also weighs mobile mode and device), so it can be true while the bar is
narrow. A full name is easily 100px+ of a 375px bar, which is what pushed the
avatar onto a second row. Same bare-text-node situation as the home link:
font-size:0 on the link collapses the name, and the avatar has its own size,
so it is unaffected. The name stays in the DOM for screen readers. */
#header-quick-access #header-user-bar .header-user-bar-name,
.wrapper ~ #header-quick-access #header-user-bar .header-user-bar-name,
body:not(.board-view) #header-quick-access #header-user-bar .header-user-bar-name,
.iphone-device #header-quick-access #header-user-bar .header-user-bar-name,
.iphone-device body:not(.board-view) #header-quick-access #header-user-bar .header-user-bar-name {
font-size: 0 !important;
gap: 0 !important;
padding-inline-end: 0 !important;
}
#header-quick-access #header-user-bar .header-user-bar-avatar,
.iphone-device #header-quick-access #header-user-bar .header-user-bar-avatar {
font-size: 12px !important;
margin-inline-end: 0 !important;
}
}
/* ── Trailing group hugs the END of the top bar (LTR right, RTL left) ─────────
The zoom %, notification bell and user/avatar menu are the last items in the
quick-access bar. When the middle starred-boards list is shown it fills the
gap (`flex: 1`) and pushes them to the end; but that list is `display: none`
on a phone, so with nothing filling the middle the group bunched at the START
(left in LTR) with empty bar to its right.
`margin-inline-start: auto` on the FIRST item of the group (the zoom control)
pushes the whole group - zoom, bell, avatar - to the end. It is a LOGICAL
margin, so it is the RIGHT edge in LTR and the LEFT edge in RTL automatically,
which is exactly the requested behaviour. There must be exactly ONE auto
margin in the row, so any auto margin an earlier rule put on the user bar is
cleared here - two autos would split the free space and separate the group.
Last in the file, so it wins on source order at equal specificity. */
#header-quick-access .zoom-controls {
margin-inline-start: auto !important;
}
#header-quick-access #notifications,
#header-quick-access #header-user-bar {
margin-inline-start: 0 !important;
}
+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.
+8 -5
View File
@@ -177,14 +177,17 @@ Template.header.events({
Session.set('currentCard', null);
},
'click .js-toggle-desktop-drag-handles'() {
// Toggle the EFFECTIVE state and store it EXPLICITLY, so the choice sticks on
// a touch screen too. Storing "off" as a real `false` (rather than removing
// the setting) is what lets Utils.showDragHandles() tell "turned off" apart
// from "never chosen", which is the only reason the toggle could not hide the
// handles on a touch device.
const show = !Utils.showDragHandles();
const currentUser = Meteor.user();
if (currentUser) {
Meteor.call('toggleDesktopDragHandles');
} else if (window.localStorage.getItem('showDesktopDragHandles')) {
window.localStorage.removeItem('showDesktopDragHandles');
location.reload();
Meteor.call('toggleDesktopDragHandles', show);
} else {
window.localStorage.setItem('showDesktopDragHandles', 'true');
window.localStorage.setItem('showDesktopDragHandles', show ? 'true' : 'false');
location.reload();
}
},
+91 -19
View File
@@ -112,6 +112,32 @@ summary:focus-visible,
z-index: 30;
}
/* #6521: these controls are focusable but `opacity: 0`, and they sit at the
START of the card/list content. Tabbing therefore went: card (visible focus)
-> invisible -> invisible -> next card, so the focus ring kept vanishing and
reappearing at a position that looked like it had gone BACKWARDS. Reveal them
while focused - the same pattern the skip-to-content link below already uses -
so every tab stop is somewhere the eye can follow. They stay invisible to the
mouse, and :focus-visible keeps a mouse click from popping them open. */
.minicard-move-buttons.sr-only a:focus,
.list-move-buttons.sr-only a:focus,
.minicard-move-buttons.sr-only a:focus-visible,
.list-move-buttons.sr-only a:focus-visible {
opacity: 1;
width: auto;
height: auto;
min-width: 1.6em;
padding: 0 0.3em;
overflow: visible;
text-indent: 0;
line-height: 1.6;
border-radius: 3px;
background: var(--theme-accent, #01628c);
color: #fff;
outline: 2px solid var(--theme-accent, #01628c);
outline-offset: 1px;
}
/* Skip-to-content link (WCAG 2.4.1 Bypass Blocks): hidden until focused, then
* pinned to the top-left so keyboard users can jump past the header chrome. */
.skip-link {
@@ -272,6 +298,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 {
@@ -711,15 +758,19 @@ a:not(.disabled).is-active i.fa {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
/* Tight but still tappable spacing: on a 375px phone (iPhone 12 mini) the
gaps alone were 8px x 6 = 48px, an eighth of the whole bar. */
gap: 6px;
width: 100%;
/* On narrow screens (e.g. iPhone 12 mini, 375px) the icon buttons cannot
shrink below their min-width, so a single nowrap row overflows and the
base rule's overflow:hidden clips the right-side items (mobile/desktop
toggle, notifications, member/user menu). Allow wrapping to a second row
and let the bar grow so nothing is ever hidden. */
flex-wrap: wrap;
overflow: visible;
/* ONE row. This used to wrap to a second row because the items did not fit:
the home link kept its full "All Boards" label, the logo was allowed 120px,
and TWO items had `margin-inline-start: auto`, each eating all remaining
space on its line and pushing everything after it down. Rather than let the
bar grow to two rows (or clip the right-hand items, which is what nowrap
did before that), the items below are made to FIT: icon-only home link,
smaller logo cap, one auto margin, tighter padding. */
flex-wrap: nowrap;
overflow: hidden;
height: auto;
min-height: 28px;
}
@@ -730,26 +781,47 @@ a:not(.disabled).is-active i.fa {
display: none !important;
}
/* Show the home link with its full "All Boards" text on mobile. The label is
a bare text node (not a span), so the old max-width:28px + overflow:hidden
clip revealed only "A"; removing it shows the whole label. The bar wraps,
so the extra width is fine. */
/* Home link: icon only. The label ("All Boards" / "Kaikki taulut" - it is
longer in most languages than in English) is the single widest item in the
bar, and the home icon on its own is unambiguous. The label is a bare text
node rather than a span, so it cannot be targeted directly: font-size:0 on
the link collapses it and the icon's own font-size brings the icon back.
The text stays in the DOM, so screen readers still announce it. */
#header-quick-access .home-icon {
margin-inline-end: 0;
flex-shrink: 0;
}
#header-quick-access .home-icon a {
display: inline-flex;
align-items: center;
max-width: none;
overflow: visible;
white-space: nowrap;
font-size: 0;
padding: 4px 6px;
}
#header-quick-access .home-icon a .fa-home {
font-size: 16px;
margin-inline-end: 0;
}
/* `.allBoards` is the same element as `.home-icon`; its 15px side padding is
desktop spacing. */
#header-quick-access .allBoards {
padding: 4px 6px;
}
/* Ensure proper spacing for mobile header elements */
/* Exactly ONE auto margin in the row. Two of them (zoom AND user bar) each
absorbed the remaining space on their line, which is what forced the wrap:
the first auto margin filled line 1, so everything after it started line 2.
The single one here right-aligns the user menu when there is space left and
collapses to 0 when there is not. */
#header-quick-access .zoom-controls {
margin-inline-start: auto;
margin-inline-end: 8px;
margin-inline-start: 0;
margin-inline-end: 0;
}
.mobile-mode-toggle {
margin-inline-end: 8px;
margin-inline-end: 0;
}
#header-user-bar {
@@ -761,10 +833,10 @@ a:not(.disabled).is-active i.fa {
min-width: 0; /* Allow flexbox to shrink */
}
/* Make sure logo doesn't take too much space on mobile */
/* The logo is decorative here and was allowed a third of a 375px bar. */
#header-quick-access img {
max-height: 24px;
max-width: 120px;
max-height: 20px;
max-width: 84px;
}
/* Ensure zoom controls are compact on mobile */
+5
View File
@@ -23,6 +23,7 @@ template(name="main")
//link(rel="stylesheet" type="text/css" class="__meteor-css__" href="css/html5-default-theme.css")
template(name="userFormsLayout")
+recoveryMaintenance
section.auth-layout
if currentSetting.hideLogo
h1.at-form-landing-logo
@@ -84,6 +85,10 @@ template(name="userFormsLayout")
br
template(name="defaultLayout")
+recoveryMaintenance
//- Shared migration / board-repair progress dashboard. Mounted app-wide so it
//- shows for both a board-open repair AND an Admin Panel database migration.
+migrationProgress
a.skip-link(href="#content") {{_ 'skip-to-content'}}
+header
#content(role="main" tabindex="-1")
@@ -0,0 +1,156 @@
/* Shared look for EVERY prev/next pagination control in WeKan.
Admin Panel reports, People/Org/Team/Domain, All Boards, Board Table view,
Archived boards and the Cron settings tables all page the same way, so they
all get the same themed buttons here instead of each stylesheet inventing its
own greys.
Why this file exists at all: the global button rules in
client/components/forms/forms.css set
button { background: var(--theme-accent, #000) }
button:focus { background: var(--theme-accent, #222) }
button:active { background: var(--theme-accent, #111) }
button:active:hover { background: #e6e6e6 }
With no custom theme colour chosen those fall back to BLACK / dark grey /
grey. `button:focus` and `button:active` have the same specificity as a
`.some-pagination button` rule, and `button:active:hover` has a HIGHER one, so
the per-page pagination rules lost as soon as the button was clicked: pressing
"next" left the button sitting there with a black or grey background until it
lost focus. Every state is therefore spelled out below, at a specificity that
beats the global button rules, and every colour comes from the theme accent
(Member Settings -> Change color sets --theme-accent on :root) with the WeKan
blue as the fallback. */
.admin-report-pagination button,
.admin-event-pagination button,
.people-pagination button,
.org-pagination button,
.team-pagination button,
.domain-pagination button,
.boards-pagination button,
.table-view-pagination button,
.cron-settings .pagination button {
min-width: 32px;
height: 30px;
padding: 0 10px;
border: 1px solid var(--theme-accent, #01628c);
border-radius: 0.4vw;
background: transparent;
background-image: none;
color: var(--theme-accent, #01628c);
font-weight: 700;
line-height: 1.3;
cursor: pointer;
box-shadow: none;
}
/* Hover, keyboard focus and the pressed state all use the accent as the FILL,
so a clicked "next" button reads as themed instead of black. :focus-visible is
listed separately so a mouse click does not leave a permanent focus ring, but
keyboard users still get one. */
.admin-report-pagination button:hover:not(.disabled),
.admin-report-pagination button:focus:not(.disabled),
.admin-report-pagination button:active:not(.disabled),
.admin-event-pagination button:hover:not(.disabled),
.admin-event-pagination button:focus:not(.disabled),
.admin-event-pagination button:active:not(.disabled),
.people-pagination button:hover:not(.disabled),
.people-pagination button:focus:not(.disabled),
.people-pagination button:active:not(.disabled),
.org-pagination button:hover:not(.disabled),
.org-pagination button:focus:not(.disabled),
.org-pagination button:active:not(.disabled),
.team-pagination button:hover:not(.disabled),
.team-pagination button:focus:not(.disabled),
.team-pagination button:active:not(.disabled),
.domain-pagination button:hover:not(.disabled),
.domain-pagination button:focus:not(.disabled),
.domain-pagination button:active:not(.disabled),
.boards-pagination button:hover:not(.disabled),
.boards-pagination button:focus:not(.disabled),
.boards-pagination button:active:not(.disabled),
.table-view-pagination button:hover:not(.disabled),
.table-view-pagination button:focus:not(.disabled),
.table-view-pagination button:active:not(.disabled),
.cron-settings .pagination button:hover,
.cron-settings .pagination button:focus,
.cron-settings .pagination button:active {
background: var(--theme-accent, #01628c);
background-image: none;
border-color: var(--theme-accent, #01628c);
color: #fff;
box-shadow: none;
}
/* forms.css turns a held-down button grey (#e6e6e6) with a higher specificity
than a plain `.pagination button` rule - keep the accent instead. */
.admin-report-pagination button:active:hover,
.admin-report-pagination button:active:focus,
.admin-event-pagination button:active:hover,
.admin-event-pagination button:active:focus,
.people-pagination button:active:hover,
.people-pagination button:active:focus,
.org-pagination button:active:hover,
.org-pagination button:active:focus,
.team-pagination button:active:hover,
.team-pagination button:active:focus,
.domain-pagination button:active:hover,
.domain-pagination button:active:focus,
.boards-pagination button:active:hover,
.boards-pagination button:active:focus,
.table-view-pagination button:active:hover,
.table-view-pagination button:active:focus,
.cron-settings .pagination button:active:hover,
.cron-settings .pagination button:active:focus {
background: var(--theme-accent, #01628c);
background-image: none;
color: #fff;
}
/* The current page in a numbered pager. */
.admin-report-pagination button.active,
.people-pagination button.active,
.org-pagination button.active,
.team-pagination button.active,
.domain-pagination button.active,
.boards-pagination button.active,
.table-view-pagination button.active {
background: var(--theme-accent, #01628c);
background-image: none;
border-color: var(--theme-accent, #01628c);
color: #fff;
}
/* Disabled = the same themed outline, faded. Not a grey block. */
.admin-report-pagination button.disabled,
.admin-event-pagination button.disabled,
.people-pagination button.disabled,
.org-pagination button.disabled,
.team-pagination button.disabled,
.domain-pagination button.disabled,
.boards-pagination button.disabled,
.table-view-pagination button.disabled {
background: transparent;
background-image: none;
border-color: var(--theme-accent, #01628c);
color: var(--theme-accent, #01628c);
opacity: 0.4;
pointer-events: none;
}
/* "3 / 42" between the buttons: inherit the surrounding text colour so it stays
readable in both the light and the dark theme (it used to be hard-coded #333,
which disappears on a dark background). */
.admin-report-page-info,
.people-page-info,
.org-page-info,
.team-page-info,
.domain-page-info,
.boards-page-info,
.table-view-page-info,
.cron-settings .pagination .page-info {
min-width: 72px;
text-align: center;
color: inherit;
}
+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
@@ -0,0 +1,38 @@
/* #6492 full-page maintenance spinner shown while a data recovery is in progress. */
.recovery-maintenance-overlay {
position: fixed;
inset: 0;
z-index: 100000; /* above modals, popups and the board canvas */
display: flex;
align-items: center;
justify-content: center;
background: rgba(20, 30, 45, 0.92);
color: #fff;
text-align: center;
padding: 24px;
}
.recovery-maintenance-box {
max-width: 520px;
}
.recovery-maintenance-box .sk-spinner {
margin: 0 auto 24px;
}
.recovery-maintenance-title {
font-size: 22px;
font-weight: bold;
margin: 0 0 12px;
color: #fff;
}
.recovery-maintenance-message {
font-size: 16px;
margin: 0 0 12px;
}
.recovery-maintenance-note {
font-size: 13px;
opacity: 0.8;
}
@@ -0,0 +1,9 @@
template(name="recoveryMaintenance")
if isMaintenanceActive
.recovery-maintenance-overlay(role="alertdialog" aria-live="assertive" aria-label="{{_ 'recovery-maintenance-title'}}")
.recovery-maintenance-box
+spinner
h1.recovery-maintenance-title {{_ 'recovery-maintenance-title'}}
if maintenanceMessage
p.recovery-maintenance-message {{ maintenanceMessage }}
p.recovery-maintenance-note.quiet {{_ 'recovery-maintenance-note'}}
@@ -0,0 +1,17 @@
import RecoveryStatus from '/models/recoveryStatus';
// #6492 Full-page maintenance spinner shown while a data recovery / remediation is in
// progress (the server sets RecoveryStatus active). Subscribed globally so it can
// appear on any page, logged in or not.
Template.recoveryMaintenance.onCreated(function () {
this.subscribe('recoveryMaintenance');
});
Template.recoveryMaintenance.helpers({
isMaintenanceActive() {
return RecoveryStatus.isMaintenanceActive();
},
maintenanceMessage() {
return RecoveryStatus.maintenanceMessage();
},
});
-162
View File
@@ -1,162 +0,0 @@
/* ============================================================================
responsiveFixes.css
Global mobile / responsive corrective overrides. Meteor auto-includes every
.css file under client/, so simply creating this file loads it.
Everything here is ADDITIVE and scoped under (max-width) media queries so the
desktop layout is left untouched. It deliberately lives in one place so the
mobile regressions below are easy to find and tune.
Issues addressed:
#5942 card-detail sub-popups (assign user / due date) disappearing on
touch -> JS guard lives in client/components/main/popup.js; this
file just makes sure those popups can't be visually clipped.
#5902 minicards rendering two-per-row instead of one-per-row on phones;
tiny hard-to-tap Home button in the header.
#6018 enormous icons / menus / text below the menu bar after upgrade.
#5982 excessive padding / margins and tiny button labels.
============================================================================ */
/* ----------------------------------------------------------------------------
#5902 - One minicard per row on narrow screens.
The minicard width itself is owned by minicard.css; we only add a mobile
override here (per task constraints) so phones get a single full-width card
per row instead of two side by side.
---------------------------------------------------------------------------- */
@media (max-width: 768px) {
.minicard,
.minicard-wrapper {
width: 100% !important;
box-sizing: border-box !important;
}
/* The list/swimlane card container should stack cards vertically, not wrap
them into columns. */
.list-body .minicard-wrapper,
.swimlane .minicard-wrapper {
display: block !important;
float: none !important;
}
}
/* ----------------------------------------------------------------------------
#5902 - Make the header "Home / All Boards" button a proper tap target.
The home icon link was tiny. Apple/Material guidance is a >=44px touch
target; give it comfortable padding and a minimum size on touch widths.
---------------------------------------------------------------------------- */
@media (max-width: 768px) {
#header-quick-access .home-icon a {
min-height: 44px !important;
min-width: 44px !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
padding: 6px 12px !important;
}
/* Keep the home glyph visible and tappable without it ballooning. */
#header-quick-access .home-icon .fa-home {
font-size: 18px !important;
line-height: 1 !important;
}
}
/* ----------------------------------------------------------------------------
#6018 - "text and icons are enormous" below the menu bar.
After the upgrade, em/vw cascades in the content area scaled icons, menus and
text far too large (zoom only affected task names). Re-anchor a sane base
font-size on mobile and cap runaway icon sizing in the content region so the
UI is usable again. Scoped to #content so the (separately tuned) header bar
is not affected.
---------------------------------------------------------------------------- */
@media (max-width: 768px) {
/* Anchor the root size so rem-based sizing can't drift huge. */
html {
font-size: 100% !important; /* 16px base */
}
/* Body text in the working area: a readable, non-enormous default. */
#content {
font-size: 14px !important;
}
/* Cap oversized Font Awesome icons in the content area. Many icons had
inherited a multiplied em size; clamp them to a sane range while still
allowing the explicit small/large fa-modifiers to scale relative to it. */
#content .fa {
font-size: 16px !important;
}
#content .fa.fa-lg {
font-size: 20px !important;
}
#content .fa.fa-2x {
font-size: 24px !important;
}
/* Sidebar and card-detail menus: keep labels at a readable size rather than
the blown-up post-upgrade sizing. */
#content .sidebar .fa,
.card-details .fa {
font-size: 16px !important;
}
/* Menu / list item labels should match their controls, not be tiny next to
oversized padding (#5982 symptom of the same cluster). */
#content .pop-over-list li > a,
#content .sidebar .board-sidebar-content a {
font-size: 14px !important;
}
}
/* ----------------------------------------------------------------------------
#5982 - Excessive padding / margins and lots of empty space on mobile.
Trim the inflated spacing so controls sit closer together and button labels
no longer look tiny inside huge padded boxes.
---------------------------------------------------------------------------- */
@media (max-width: 768px) {
#content .pop-over-list li > a {
padding-top: 4px !important;
padding-bottom: 4px !important;
line-height: 1.4 !important;
}
/* Generic over-padded buttons inside the content area. */
#content button,
#content .btn,
#content .js-submit {
padding: 6px 12px !important;
margin: 4px 0 !important;
}
/* Reduce empty space between sidebar sections. */
#content .sidebar .sidebar-list li,
#content .board-sidebar-content > * {
margin-top: 4px !important;
margin-bottom: 4px !important;
}
}
/* ----------------------------------------------------------------------------
#5942 - Keep card-detail sub-popups visible & usable on mobile.
The JS fix (popup.js) stops touch events inside the popup from bubbling to
the document close handler. As belt-and-braces, make sure the assign-user /
due-date popups are never clipped off-screen and can scroll their content.
---------------------------------------------------------------------------- */
@media (max-width: 768px) {
.pop-over[data-popup="cardAssigneesPopup"],
.pop-over[data-popup="cardMembersPopup"],
.pop-over[data-popup="editCardDueDatePopup"],
.pop-over[data-popup="editCardReceivedDatePopup"],
.pop-over[data-popup="editCardStartDatePopup"],
.pop-over[data-popup="editCardEndDatePopup"] {
max-height: 100vh !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
}
}
+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');
},
+93 -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,78 @@
.admin-report-page-info {
min-width: 72px;
text-align: center;
color: #333;
}
/* Admin Panel → Problems → CPU usage: the live figure between the page title and
the search box. The table underneath is a history of past high-CPU periods, so
this line is the only place the page says what the CPU is doing right now. */
.admin-cpu-current {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
margin-bottom: 12px;
padding: 8px 10px;
border: 1px solid var(--theme-accent, #01628c);
border-radius: 0.4vw;
}
.admin-cpu-label {
font-weight: 700;
}
.admin-cpu-percent {
font-size: 1.4em;
font-weight: 700;
color: var(--theme-accent, #01628c);
}
/* Sustained high CPU: warn without inventing a second colour system. */
.admin-cpu-current.admin-cpu-high {
border-color: #b04632;
}
.admin-cpu-current.admin-cpu-high .admin-cpu-percent {
color: #b04632;
}
/* 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; }
+164 -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
@@ -36,9 +64,24 @@ template(name="adminReports")
i.fa.fa-user-secret
| {{_ 'impersonationReportTitle'}}
li
a.js-report-recovery(data-id="report-recovery")
i.fa.fa-medkit
| {{_ 'recoveryReportTitle'}}
.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
@@ -51,6 +94,8 @@ template(name="adminReports")
+cardsReport(currentPage=cardsCurrentPage totalPages=cardsTotalPages hasPrevPage=hasCardsPrevPage hasNextPage=hasCardsNextPage)
else if showImpersonationReport.get
+impersonationReport(currentPage=impersonationCurrentPage totalPages=impersonationTotalPages hasPrevPage=hasImpersonationPrevPage hasNextPage=hasImpersonationNextPage)
else if showRecoveryReport.get
+recoveryReport(currentPage=recoveryCurrentPage totalPages=recoveryTotalPages hasPrevPage=hasRecoveryPrevPage hasNextPage=hasRecoveryNextPage)
template(name="brokenCardsReport")
@@ -63,17 +108,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 +137,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 +159,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 +170,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 +203,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
@@ -202,19 +235,47 @@ template(name="impersonationReport")
else
div {{_ 'no-results' }}
template(name="recoveryReport")
h1 {{_ 'recoveryReportTitle'}}
p.quiet {{_ 'recovery-report-desc'}}
.admin-report-controls
input.js-recovery-search-input(type="search" placeholder="{{_ 'search'}}")
.admin-report-pagination
button.js-recovery-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.admin-report-page-info {{currentPage}} / {{totalPages}}
button.js-recovery-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
| &gt;
if resultsCount
table.table
thead
tr
th {{_ 'date'}}
th {{_ 'recovery-event'}}
th {{_ 'recovery-severity'}}
th {{_ 'recovery-db'}}
th {{_ 'recovery-detail'}}
each row in results
tbody
tr(class="{{ severityClass row.severity }}")
td {{ formatDate row.createdAt }}
td {{ row.type }}
td {{ row.severity }}
td {{ row.db }}
td {{ row.detail }}
else
div {{_ 'recovery-no-events'}}
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 +300,50 @@ template(name="boardsReport")
td {{teams board.teams }}
else
div {{_ 'no-results' }}
template(name="eventStreamReport")
.global-search-results-list-wrapper
h1 {{streamTitle}}
//- CPU usage page: the live figure, between the title and the search box.
//- The table below lists past high-CPU periods, not the current state.
if cpuCurrent
.admin-cpu-current(class="{{#if cpuCurrent.high}}admin-cpu-high{{/if}}")
span.admin-cpu-label {{_ 'cpu-usage-current'}}
span.admin-cpu-percent {{cpuCurrent.percent}}%
span.quiet
| {{cpuCurrent.cores}} {{_ 'cpu-cores-suffix'}} · {{_ 'cpu-load-average'}} {{cpuLoadAverage}}
if cpuCurrent.activity
span.quiet · {{cpuCurrent.activity}}
.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'}}
+277 -18
View File
@@ -7,6 +7,7 @@ import Boards from '/models/boards';
import Cards from '/models/cards';
import Rules from '/models/rules';
import ImpersonatedUsers from '/models/impersonatedUsers';
import RecoveryEvents from '/models/recoveryEvents';
const { filesize } = require('filesize');
// --- Shared helper functions (formerly AdminReport base class methods) ---
@@ -62,11 +63,18 @@ function reportConfig(tmpl) {
'report-boards': { page: tmpl.boardsPage, count: tmpl.boardsCount, search: tmpl.boardsSearch, pub: 'boardsReport', countMethod: 'getBoardsReportCount' },
'report-cards': { page: tmpl.cardsPage, count: tmpl.cardsCount, search: tmpl.cardsSearch, pub: 'cardsReport', countMethod: 'getCardsReportCount' },
'report-impersonation': { page: tmpl.impersonationPage, count: tmpl.impersonationCount, search: tmpl.impersonationSearch, pub: 'impersonationReport', countMethod: 'getImpersonationReportCount' },
'report-recovery': { page: tmpl.recoveryPage, count: tmpl.recoveryCount, search: tmpl.recoverySearch, pub: 'recoveryReport', countMethod: 'getRecoveryReportCount' },
};
}
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);
@@ -74,6 +82,7 @@ Template.adminReports.onCreated(function () {
this.showCardsReport = new ReactiveVar(false);
this.showBoardsReport = new ReactiveVar(false);
this.showImpersonationReport = new ReactiveVar(false);
this.showRecoveryReport = new ReactiveVar(false);
this.sessionId = SessionData.getSessionId();
this.error = new ReactiveVar('');
this.loading = new ReactiveVar(false);
@@ -85,22 +94,36 @@ Template.adminReports.onCreated(function () {
this.boardsPage = new ReactiveVar(1);
this.cardsPage = new ReactiveVar(1);
this.impersonationPage = new ReactiveVar(1);
this.recoveryPage = new ReactiveVar(1);
this.filesCount = new ReactiveVar(0);
this.rulesCount = new ReactiveVar(0);
this.boardsCount = new ReactiveVar(0);
this.cardsCount = new ReactiveVar(0);
this.impersonationCount = new ReactiveVar(0);
this.recoveryCount = new ReactiveVar(0);
// Current search term per report (empty string = no filter).
this.filesSearch = new ReactiveVar('');
this.rulesSearch = new ReactiveVar('');
this.boardsSearch = new ReactiveVar('');
this.cardsSearch = new ReactiveVar('');
this.impersonationSearch = new ReactiveVar('');
this.recoverySearch = new ReactiveVar('');
// Which search term each report's total count was last computed for, so
// paging does not recount. See loadReport().
this.countedFor = {};
// (Re)subscribe the given report for its current page and refresh its total
// count. Server-side search + limit/skip means only the matching page ever
// reaches minimongo.
this.loadReport = (reportId) => {
//
// `recount` (opening a report, or searching in it) re-runs the count method;
// plain prev/next paging does NOT. The count is a full collection count —
// getCardsReportCount counts every card in the database — and the total cannot
// change just because you moved to the next page, so re-running it on every
// click only added a second server round trip to each prev/next. That is what
// made paging through a big Cards report feel slow.
this.loadReport = (reportId, { recount = false } = {}) => {
const cfg = reportConfig(this)[reportId];
if (!cfg) {
return;
@@ -115,14 +138,31 @@ 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);
},
});
if (!recount && this.countedFor[reportId] === searchTerm) {
return;
}
Meteor.call(cfg.countMethod, searchTerm, (error, count) => {
if (error) {
console.error(`Failed to load ${cfg.countMethod}:`, error);
return;
}
this.countedFor[reportId] = searchTerm;
const total = count || 0;
const totalPages = Math.max(1, Math.ceil(total / REPORTS_PER_PAGE));
// If rows were removed while on a now-out-of-range page, clamp and reload.
@@ -137,6 +177,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;
},
@@ -158,6 +213,9 @@ Template.adminReports.helpers({
showImpersonationReport() {
return Template.instance().showImpersonationReport;
},
showRecoveryReport() {
return Template.instance().showRecoveryReport;
},
loading() {
return Template.instance().loading;
},
@@ -202,9 +260,32 @@ Template.adminReports.helpers({
const tpl = Template.instance();
return tpl.impersonationPage.get() < Math.max(1, Math.ceil((tpl.impersonationCount.get() || 0) / REPORTS_PER_PAGE));
},
recoveryCurrentPage() { return Template.instance().recoveryPage.get(); },
recoveryTotalPages() { return Math.max(1, Math.ceil((Template.instance().recoveryCount.get() || 0) / REPORTS_PER_PAGE)); },
hasRecoveryPrevPage() { return Template.instance().recoveryPage.get() > 1; },
hasRecoveryNextPage() {
const tpl = Template.instance();
return tpl.recoveryPage.get() < Math.max(1, Math.ceil((tpl.recoveryCount.get() || 0) / REPORTS_PER_PAGE));
},
});
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());
},
@@ -223,6 +304,9 @@ Template.adminReports.events({
'click a.js-report-impersonation'(event) {
switchMenu(event, Template.instance());
},
'click a.js-report-recovery'(event) {
switchMenu(event, Template.instance());
},
// --- Pagination controls (buttons live inside the report sub-templates,
// their clicks bubble up to this parent template) ---
@@ -236,18 +320,16 @@ Template.adminReports.events({
'click .js-cards-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-cards'); },
'click .js-impersonation-prev-page'(event, tmpl) { goPrevPage(event, tmpl, 'report-impersonation'); },
'click .js-impersonation-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-impersonation'); },
'click .js-recovery-prev-page'(event, tmpl) { goPrevPage(event, tmpl, 'report-recovery'); },
'click .js-recovery-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-recovery'); },
// --- 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'); },
'keydown .js-recovery-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-recovery', '.js-recovery-search-input'); },
});
function goPrevPage(event, tmpl, reportId) {
@@ -278,17 +360,24 @@ function runSearch(tmpl, reportId, inputSelector) {
const value = (tmpl.$(inputSelector).val() || '').trim();
cfg.search.set(value);
cfg.page.set(1);
tmpl.loadReport(reportId);
// A different search term means a different total: recount.
tmpl.loadReport(reportId, { recount: true });
}
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);
tmpl.showRulesReport.set(false);
tmpl.showRecoveryReport.set(false);
tmpl.showBoardsReport.set(false);
tmpl.showCardsReport.set(false);
tmpl.showImpersonationReport.set(false);
@@ -301,7 +390,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',
@@ -314,27 +420,32 @@ function switchMenu(event, tmpl) {
tmpl.showFilesReport.set(true);
tmpl.filesPage.set(1);
tmpl.filesSearch.set('');
tmpl.loadReport('report-files');
tmpl.loadReport('report-files', { recount: true });
} else if ('report-rules' === targetID) {
tmpl.showRulesReport.set(true);
tmpl.rulesPage.set(1);
tmpl.rulesSearch.set('');
tmpl.loadReport('report-rules');
tmpl.loadReport('report-rules', { recount: true });
} else if ('report-boards' === targetID) {
tmpl.showBoardsReport.set(true);
tmpl.boardsPage.set(1);
tmpl.boardsSearch.set('');
tmpl.loadReport('report-boards');
tmpl.loadReport('report-boards', { recount: true });
} else if ('report-cards' === targetID) {
tmpl.showCardsReport.set(true);
tmpl.cardsPage.set(1);
tmpl.cardsSearch.set('');
tmpl.loadReport('report-cards');
tmpl.loadReport('report-cards', { recount: true });
} else if ('report-impersonation' === targetID) {
tmpl.showImpersonationReport.set(true);
tmpl.impersonationPage.set(1);
tmpl.impersonationSearch.set('');
tmpl.loadReport('report-impersonation');
tmpl.loadReport('report-impersonation', { recount: true });
} else if ('report-recovery' === targetID) {
tmpl.showRecoveryReport.set(true);
tmpl.recoveryPage.set(1);
tmpl.recoverySearch.set('');
tmpl.loadReport('report-recovery', { recount: true });
}
}
}
@@ -343,10 +454,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 +561,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);
@@ -478,6 +611,25 @@ Template.impersonationReport.events({
},
});
// --- recoveryReport template (#6492) ---
Template.recoveryReport.helpers({
results() {
// Publication paginates + sorts newest-first; mirror that sort. RecoveryEvents is
// a plain Mongo.Collection, so its cursor reacts to the published page.
return collectionResults(RecoveryEvents, { createdAt: -1 });
},
resultsCount() {
return collectionResultsCount(RecoveryEvents);
},
formatDate(date) {
return date ? new Date(date).toLocaleString() : '';
},
severityClass(severity) {
return `recovery-severity-${severity || 'info'}`;
},
});
// --- brokenCardsReport template ---
Template.brokenCardsReport.onCreated(function () {
@@ -516,3 +668,110 @@ 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('');
// CPU usage page only: the live figure shown between the title and the search
// box. The event rows below it are a HISTORY of high-CPU periods, so without
// this the page could not answer "what is the CPU doing right now?" at all.
this.cpu = new ReactiveVar(null);
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();
if (this.stream === 'cpu') {
this.loadCpu = () => {
Meteor.call('problemDetailReport', 'cpu', (err, res) => {
if (!err && res) this.cpu.set(res.current || null);
});
};
this.loadCpu();
// The monitor samples the system every CPU_SAMPLE_MS; refreshing on the same
// order keeps the header live without polling the server pointlessly often.
this.cpuTimer = Meteor.setInterval(this.loadCpu, 5000);
}
});
Template.eventStreamReport.onDestroyed(function () {
if (this.cpuTimer) Meteor.clearInterval(this.cpuTimer);
});
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)); },
// Live CPU figure for the CPU usage page header; null on every other stream,
// so the block simply does not render there.
cpuCurrent() { return Template.instance().cpu.get(); },
// "37% of 8 cores · load 1.42 / 1.10 / 0.98" — one line, no chart, matching
// the plain-table look of the rest of the Problems pages.
cpuLoadAverage() {
const cpu = Template.instance().cpu.get();
if (!cpu || !Array.isArray(cpu.loadAverage)) return '';
return cpu.loadAverage.map(n => (Number.isFinite(n) ? n.toFixed(2) : '?')).join(' / ');
},
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(); }
},
});
+56
View File
@@ -1,6 +1,7 @@
import { Meteor } from 'meteor/meteor';
import AttachmentBulkMoveStatus from '/models/attachmentBulkMoveStatus';
import { TAPi18n } from '/imports/i18n';
import { migrationProgressManager } from '/client/components/settings/migrationProgress';
// DOM field ids per cloud provider, used to read the admin form on save/test.
const CLOUD_FIELD_IDS = {
@@ -322,10 +323,61 @@ Template.attachments.onDestroyed(function () {
});
// Text-data database migration (MongoDB <-> FerretDB v1 SQLite) helpers.
// Mirror the polled DB-migration status into the shared blue, Product-name-branded
// migration-progress dashboard, so the same overlay shows for an Admin Panel
// database migration as for a board-open repair. The migration has two phases the
// server reports: `repairing` (board data-repairs on the live DB) then `migrating`
// (copying collections); map them to the two dashboard steps.
function driveMigrationDashboard(tpl, status) {
if (!status) return;
const done = status.phase === 'completed' || status.success === true;
const failed = status.phase === 'error' || status.success === false;
if (done) {
if (tpl._migrationDashboard) { migrationProgressManager.completeMigration(); tpl._migrationDashboard = false; }
return;
}
if (failed) {
if (tpl._migrationDashboard) {
migrationProgressManager.failMigration({ message: status.error || 'Database migration failed' });
tpl._migrationDashboard = false;
}
return;
}
if (!tpl._migrationDashboard) {
migrationProgressManager.startMigration();
tpl._migrationDashboard = true;
}
if (status.phase === 'repairing') {
const bt = status.boardsTotal || 0;
const bd = status.boardsDone || 0;
const stepPct = bt ? Math.round((bd / bt) * 100) : 0;
migrationProgressManager.updateProgress({
overallProgress: Math.round(stepPct * 0.4), // repair is the first ~40%
currentStep: 1, totalSteps: 2,
stepName: 'repair_board_data', stepProgress: stepPct,
stepStatus: `${bd}/${bt} boards`,
stepDetails: status.repaired || undefined,
});
} else {
const ct = status.collectionsTotal || 0;
const cd = status.collectionsDone || 0;
const stepPct = ct ? Math.round((cd / ct) * 100) : 0;
migrationProgressManager.updateProgress({
overallProgress: 40 + Math.round(stepPct * 0.6), // copy is the last ~60%
currentStep: 2, totalSteps: 2,
stepName: 'copy_collections', stepProgress: stepPct,
stepStatus: status.collection || status.phase || 'migrating',
stepDetails: { collection: status.collection, done: status.collDone, total: status.collTotal },
});
}
}
function pollMigrateStatus(tpl) {
Meteor.call('migrateTextDatabaseStatus', (err, status) => {
if (err || !status) return;
tpl.migrateStatus.set(status);
driveMigrationDashboard(tpl, status);
if (status.running) {
tpl.migratePoll = Meteor.setTimeout(() => pollMigrateStatus(tpl), 2000);
}
@@ -339,9 +391,13 @@ function startDbMigration(tpl, direction) {
return;
}
tpl.migrateStatus.set({ running: true, phase: 'starting', direction });
migrationProgressManager.startMigration();
tpl._migrationDashboard = true;
Meteor.call('migrateTextDatabase', direction, error => {
if (error) {
tpl.migrateStatus.set({ phase: 'error', error: error.reason || error.message, success: false });
migrationProgressManager.failMigration({ message: error.reason || error.message });
tpl._migrationDashboard = false;
} else {
pollMigrateStatus(tpl);
}
-904
View File
@@ -1,904 +0,0 @@
/* Cron Settings Styles */
.cron-settings-content {
min-height: 600px;
}
.cron-migrations {
padding: 20px;
}
.migration-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.migration-header h2 {
margin: 0;
color: #333;
font-size: 24px;
font-weight: 600;
}
.migration-header h2 i {
margin-inline-end: 10px;
color: #667eea;
}
.migration-controls {
display: flex;
gap: 10px;
}
.migration-controls .btn {
padding: 8px 16px;
font-size: 14px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.migration-controls .btn-primary {
background-color: #28a745;
color: white;
}
.migration-controls .btn-primary:hover {
background-color: #218838;
}
.migration-controls .btn-warning {
background-color: #ffc107;
color: #212529;
}
.migration-controls .btn-warning:hover {
background-color: #e0a800;
}
.migration-controls .btn-danger {
background-color: #dc3545;
color: white;
}
.migration-controls .btn-danger:hover {
background-color: #c82333;
}
.migration-progress {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
border-inline-start: 4px solid #667eea;
}
.progress-overview {
margin-bottom: 20px;
}
.progress-bar {
width: 100%;
height: 12px;
background-color: #e0e0e0;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
position: relative;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 6px;
transition: width 0.3s ease;
position: relative;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
inset-inline-start: 0;
inset-inline-end: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.4),
transparent
);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.progress-text {
text-align: center;
font-weight: 700;
color: #667eea;
font-size: 18px;
}
.progress-label {
text-align: center;
color: #666;
font-size: 14px;
margin-top: 4px;
}
.current-step {
text-align: center;
color: #333;
font-size: 16px;
font-weight: 500;
margin-bottom: 16px;
}
.current-step i {
margin-inline-end: 8px;
color: #667eea;
}
.migration-status {
text-align: center;
color: #333;
font-size: 16px;
background-color: #e3f2fd;
padding: 12px 16px;
border-radius: 6px;
border: 1px solid #bbdefb;
}
.migration-status i {
margin-inline-end: 8px;
color: #2196f3;
}
.migration-steps {
margin-top: 30px;
}
.migration-steps h3 {
margin: 0 0 20px 0;
color: #333;
font-size: 20px;
font-weight: 600;
}
.steps-list {
max-height: 400px;
overflow-y: auto;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.migration-step {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
transition: all 0.3s ease;
}
.migration-step:last-child {
border-bottom: none;
}
.migration-step.completed {
background-color: #d4edda;
border-inline-start: 4px solid #28a745;
}
.migration-step.current {
background-color: #cce7ff;
border-inline-start: 4px solid #667eea;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.4);
}
70% {
box-shadow: 0 0 0 10px rgba(102, 126, 234, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
}
}
.step-header {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.step-icon {
margin-inline-end: 12px;
font-size: 18px;
width: 24px;
text-align: center;
}
.step-icon i.fa-check-circle {
color: #28a745;
}
.step-icon i.fa-cog.fa-spin {
color: #667eea;
}
.step-icon i.fa-circle-o {
color: #ccc;
}
.step-info {
flex: 1;
}
.step-name {
font-weight: 600;
color: #333;
font-size: 14px;
margin-bottom: 2px;
}
.step-description {
color: #666;
font-size: 12px;
line-height: 1.3;
}
.step-progress {
text-align: end;
min-width: 40px;
}
.step-progress .progress-text {
font-size: 12px;
font-weight: 600;
}
.step-progress-bar {
width: 100%;
height: 4px;
background-color: #e0e0e0;
border-radius: 2px;
overflow: hidden;
margin-top: 8px;
}
.step-progress-bar .progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 2px;
transition: width 0.3s ease;
}
/* Cron Jobs Styles */
.cron-jobs {
padding: 20px;
}
.jobs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.jobs-header h2 {
margin: 0;
color: #333;
font-size: 24px;
font-weight: 600;
}
.jobs-header h2 i {
margin-inline-end: 10px;
color: #667eea;
}
.jobs-controls .btn {
padding: 8px 16px;
font-size: 14px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.jobs-controls .btn-success {
background-color: #28a745;
color: white;
}
.jobs-controls .btn-success:hover {
background-color: #218838;
}
.jobs-list {
margin-top: 20px;
}
.table {
width: 100%;
border-collapse: collapse;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.table thead {
background-color: #f8f9fa;
}
.table th,
.table td {
padding: 12px 16px;
text-align: start;
border-bottom: 1px solid #e0e0e0;
}
.table th {
font-weight: 600;
color: #333;
font-size: 14px;
}
.table td {
font-size: 14px;
color: #666;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.status-running {
background-color: #d4edda;
color: #155724;
}
.status-badge.status-stopped {
background-color: #f8d7da;
color: #721c24;
}
.status-badge.status-paused {
background-color: #fff3cd;
color: #856404;
}
.status-badge.status-completed {
background-color: #d1ecf1;
color: #0c5460;
}
.status-badge.status-error {
background-color: #f8d7da;
color: #721c24;
}
.btn-group {
display: flex;
gap: 4px;
}
.btn-group .btn {
padding: 4px 8px;
font-size: 12px;
border-radius: 3px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-group .btn-success {
background-color: #28a745;
color: white;
}
.btn-group .btn-success:hover {
background-color: #218838;
}
.btn-group .btn-warning {
background-color: #ffc107;
color: #212529;
}
.btn-group .btn-warning:hover {
background-color: #e0a800;
}
.btn-group .btn-danger {
background-color: #dc3545;
color: white;
}
.btn-group .btn-danger:hover {
background-color: #c82333;
}
/* Add Job Form Styles */
.cron-add-job {
padding: 20px;
}
.add-job-header {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.add-job-header h2 {
margin: 0;
color: #333;
font-size: 24px;
font-weight: 600;
}
.add-job-header h2 i {
margin-inline-end: 10px;
color: #667eea;
}
.add-job-form {
max-width: 600px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #333;
font-size: 14px;
}
.form-control {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.3s ease;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.form-control[type="number"] {
width: 100px;
}
.form-actions {
display: flex;
gap: 10px;
margin-top: 30px;
}
.form-actions .btn {
padding: 10px 20px;
font-size: 14px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.form-actions .btn-primary {
background-color: #667eea;
color: white;
}
.form-actions .btn-primary:hover {
background-color: #5a6fd8;
}
.form-actions .btn-default {
background-color: #6c757d;
color: white;
}
.form-actions .btn-default:hover {
background-color: #5a6268;
}
/* Board Operations Styles */
.cron-board-operations {
padding: 20px;
}
.board-operations-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.board-operations-header h2 {
margin: 0;
color: #333;
font-size: 24px;
font-weight: 600;
}
.board-operations-header h2 i {
margin-inline-end: 10px;
color: #667eea;
}
.board-operations-controls {
display: flex;
gap: 10px;
}
.board-operations-controls .btn {
padding: 8px 16px;
font-size: 14px;
border-radius: 4px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.board-operations-controls .btn-success {
background-color: #28a745;
color: white;
}
.board-operations-controls .btn-success:hover {
background-color: #218838;
}
.board-operations-controls .btn-primary {
background-color: #667eea;
color: white;
}
.board-operations-controls .btn-primary:hover {
background-color: #5a6fd8;
}
.board-operations-stats {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
border-inline-start: 4px solid #667eea;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 20px;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #667eea;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.system-resources {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 30px;
border-inline-start: 4px solid #28a745;
}
.resource-item {
display: flex;
align-items: center;
margin-bottom: 15px;
}
.resource-item:last-child {
margin-bottom: 0;
}
.resource-label {
min-width: 120px;
font-weight: 600;
color: #333;
font-size: 14px;
}
.resource-bar {
flex: 1;
height: 12px;
background-color: #e0e0e0;
border-radius: 6px;
overflow: hidden;
margin: 0 15px;
position: relative;
}
.resource-fill {
height: 100%;
border-radius: 6px;
transition: width 0.3s ease;
position: relative;
}
.resource-item:nth-child(1) .resource-fill {
background: linear-gradient(90deg, #28a745, #20c997);
}
.resource-item:nth-child(2) .resource-fill {
background: linear-gradient(90deg, #007bff, #6f42c1);
}
.resource-value {
min-width: 50px;
text-align: end;
font-weight: 600;
color: #333;
font-size: 14px;
}
.board-operations-search {
margin-bottom: 30px;
}
.search-box {
position: relative;
max-width: 400px;
}
.search-box .form-control {
padding-inline-end: 40px;
}
.search-icon {
position: absolute;
inset-inline-end: 12px;
top: 50%;
transform: translateY(-50%);
color: #999;
font-size: 16px;
}
.board-operations-list {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.operations-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
}
.operations-header h3 {
margin: 0;
color: #333;
font-size: 18px;
font-weight: 600;
}
.pagination-info {
color: #666;
font-size: 14px;
}
.operations-table {
overflow-x: auto;
}
.operations-table .table {
margin: 0;
border: none;
}
.operations-table .table th {
background-color: #f8f9fa;
border-bottom: 2px solid #e0e0e0;
font-weight: 600;
color: #333;
white-space: nowrap;
}
.operations-table .table td {
vertical-align: middle;
border-bottom: 1px solid #f0f0f0;
}
.board-id {
font-family: monospace;
font-size: 12px;
color: #666;
background: #f8f9fa;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
}
.operation-type {
font-weight: 500;
color: #333;
text-transform: capitalize;
}
.progress-container {
display: flex;
align-items: center;
gap: 8px;
min-width: 120px;
}
.progress-container .progress-bar {
flex: 1;
height: 8px;
background-color: #e0e0e0;
border-radius: 4px;
overflow: hidden;
}
.progress-container .progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-container .progress-text {
font-size: 12px;
font-weight: 600;
color: #667eea;
min-width: 35px;
text-align: end;
}
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: #f8f9fa;
border-top: 1px solid #e0e0e0;
}
.pagination .btn {
padding: 6px 12px;
font-size: 12px;
border-radius: 4px;
border: 1px solid #ddd;
background: white;
color: #333;
cursor: pointer;
transition: all 0.3s ease;
}
.pagination .btn:hover {
background: #f8f9fa;
border-color: #667eea;
}
.pagination .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
color: #666;
font-size: 14px;
}
/* Responsive design */
@media (max-width: 768px) {
.migration-header,
.jobs-header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.migration-controls,
.jobs-controls {
width: 100%;
justify-content: center;
}
.table {
font-size: 12px;
}
.table th,
.table td {
padding: 8px 12px;
}
.btn-group {
flex-direction: column;
}
.add-job-form {
max-width: 100%;
}
}
/* Progress bar styles for #cron-setting section */
#cron-setting .progress-section {
margin-top: 15px;
}
#cron-setting .step-counter {
margin-bottom: 8px;
font-weight: 600;
color: #333;
font-size: 14px;
}
#cron-setting .progress {
height: 30px;
background-color: #e9ecef;
border-radius: 4px;
overflow: visible;
margin-bottom: 5px;
max-width: calc(100% - 40px);
}
#cron-setting .progress-bar {
height: 30px;
line-height: 30px;
background-color: #28a745;
color: white;
font-weight: 600;
font-size: 14px;
text-align: center;
transition: width 0.3s ease;
border-radius: 4px;
}
#cron-setting .progress-text {
font-size: 13px;
color: #666;
margin-top: 5px;
max-width: calc(100% - 40px);
}
@@ -1,340 +0,0 @@
template(name="cronSettings")
ul#cron-setting.setting-detail
li
h3 {{_ 'cron-migrations'}}
.form-group
label {{_ 'select-migration'}}
select.js-migration-select.wekan-form-control
option(value="0") 0 - {{_ 'all-migrations'}}
each migrationStepsWithIndex
option(value="{{index}}") {{index}} - {{name}}
.form-group
label {{_ 'migration-status'}}
.status-indicator
span.status-value {{migrationStatus}}
if isMigrating
.progress-section
.step-counter
| Step {{migrationCurrentStepNum}}/{{migrationTotalSteps}}
.progress
.progress-bar(role="progressbar" style="width: {{migrationProgress}}%" aria-valuenow="{{migrationProgress}}" aria-valuemin="0" aria-valuemax="100")
| {{migrationProgress}}%
.progress-text
| {{migrationProgress}}% {{_ 'complete'}}
.form-group
button.js-start-migration.btn.btn-primary(disabled="{{#if isMigrating}}disabled{{/if}}") {{_ 'start'}}
button.js-pause-migration.btn.btn-warning(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'pause'}}
button.js-stop-migration.btn.btn-danger(disabled="{{#unless isMigrating}}disabled{{/unless}}") {{_ 'stop'}}
.form-group.migration-errors-section
h4 {{_ 'cron-migration-errors'}}
if hasErrors
.error-actions
button.js-clear-all-errors.btn.btn-sm.btn-warning {{_ 'cron-clear-errors'}}
.errors-list
each migrationErrors
.error-item(class="error-{{severity}}")
.error-header
span.error-severity(class="severity-{{severity}}") {{severity}}
span.error-time {{formatDateTime createdAt}}
if stepId
span.error-step {{stepId}}
.error-message {{errorMessage}}
if context
.error-context
each contextValue context
span.context-item {{this}}
else
.no-errors
| {{_ 'cron-no-errors'}}
li
h3 {{_ 'board-operations'}}
.form-group
label {{_ 'scheduled-board-operations'}}
button.js-schedule-board-cleanup.btn.btn-primary {{_ 'schedule-board-cleanup'}}
button.js-schedule-board-archive.btn.btn-warning {{_ 'schedule-board-archive'}}
button.js-schedule-board-backup.btn.btn-info {{_ 'schedule-board-backup'}}
li
h3 {{_ 'cron-jobs'}}
.form-group
label {{_ 'active-cron-jobs'}}
each cronJobs
.job-item
.job-info
.job-name {{name}}
.job-schedule {{schedule}}
.job-description {{description}}
.job-actions
button.js-pause-job.btn.btn-sm.btn-warning(data-job-id="{{_id}}") {{_ 'pause'}}
button.js-delete-job.btn.btn-sm.btn-danger(data-job-id="{{_id}}") {{_ 'delete'}}
.add-job-section
button.js-add-cron-job.btn.btn-success {{_ 'add-cron-job'}}
template(name="cronMigrations")
.cron-migrations
.migration-header
h2
| 🗄️
| {{_ 'database-migrations'}}
.migration-controls
button.btn.btn-primary.js-start-all-migrations
i.fa.fa-play
| {{_ 'start-all-migrations'}}
button.btn.btn-warning.js-pause-all-migrations
i.fa.fa-pause
| {{_ 'pause-all-migrations'}}
button.btn.btn-danger.js-stop-all-migrations
i.fa.fa-stop
| {{_ 'stop-all-migrations'}}
.migration-progress
.progress-overview
.progress-bar
.progress-fill(style="width: {{migrationProgress}}%")
.progress-text {{migrationProgress}}%
.progress-label {{_ 'overall-progress'}}
.current-step
i.fa.fa-cog
| {{migrationCurrentStep}}
.migration-status
i.fa.fa-info-circle
| {{migrationStatus}}
.migration-steps
h3 {{_ 'migration-steps'}}
.steps-list
each migrationSteps
.migration-step(class="{{#if completed}}completed{{/if}}" class="{{#if isCurrentStep}}current{{/if}}")
.step-header
.step-icon
if completed
i.fa.fa-check
else if isCurrentStep
i.fa.fa-cog
else
i.fa.fa-circle-o
.step-info
.step-name {{name}}
.step-description {{description}}
.step-progress
if completed
.progress-text 100%
else if isCurrentStep
.progress-text {{progress}}%
else
.progress-text 0%
if isCurrentStep
.step-progress-bar
.progress-fill(style="width: {{progress}}%")
template(name="cronBoardOperations")
.cron-board-operations
.board-operations-header
h2
i.fa.fa-list
| {{_ 'board-operations'}}
.board-operations-controls
button.btn.btn-success.js-refresh-board-operations
i.fa.fa-recycle
| {{_ 'refresh'}}
button.btn.btn-primary.js-start-test-operation
i.fa.fa-play
| {{_ 'start-test-operation'}}
button.btn.btn-info.js-force-board-scan
i.fa.fa-search
| {{_ 'force-board-scan'}}
.board-operations-stats
.stats-grid
.stat-item
.stat-value {{operationStats.total}}
.stat-label {{_ 'total-operations'}}
.stat-item
.stat-value {{operationStats.running}}
.stat-label {{_ 'running'}}
.stat-item
.stat-value {{operationStats.completed}}
.stat-label {{_ 'completed'}}
.stat-item
.stat-value {{operationStats.error}}
.stat-label {{_ 'errors'}}
.stat-item
.stat-value {{queueStats.pending}}
.stat-label {{_ 'pending'}}
.stat-item
.stat-value {{queueStats.maxConcurrent}}
.stat-label {{_ 'max-concurrent'}}
.stat-item
.stat-value {{boardMigrationStats.unmigratedCount}}
.stat-label {{_ 'unmigrated-boards'}}
.stat-item
.stat-value {{boardMigrationStats.isScanning}}
.stat-label {{_ 'scanning-status'}}
.system-resources
.resource-item
.resource-label {{_ 'cpu-usage'}}
.resource-bar
.resource-fill(style="width: {{systemResources.cpuUsage}}%")
.resource-value {{systemResources.cpuUsage}}%
.resource-item
.resource-label {{_ 'memory-usage'}}
.resource-bar
.resource-fill(style="width: {{systemResources.memoryUsage}}%")
.resource-value {{systemResources.memoryUsage}}%
.resource-item
.resource-label {{_ 'cpu-cores'}}
.resource-value {{systemResources.cpuCores}}
.board-operations-search
.search-box
input.form-control.js-search-board-operations(type="text" placeholder="{{_ 'search-boards-or-operations'}}")
i.fa.fa-search.search-icon
.board-operations-list
.operations-header
h3 {{_ 'board-operations'}} ({{pagination.total}})
.pagination-info
| {{_ 'showing'}} {{pagination.start}} - {{pagination.end}} {{_ 'of'}} {{pagination.total}}
.operations-table
table.table.table-striped
thead
tr
th {{_ 'board-id'}}
th {{_ 'operation-type'}}
th {{_ 'status'}}
th {{_ 'progress'}}
th {{_ 'start-time'}}
th {{_ 'duration'}}
th {{_ 'actions'}}
tbody
each boardOperations
tr
td
.board-id {{boardId}}
td
.operation-type {{operationType}}
td
span.status-badge(class="status-{{status}}") {{status}}
td
.progress-container
.progress-bar
.progress-fill(style="width: {{progress}}%")
.progress-text {{progress}}%
td {{formatDateTime startTime}}
td {{formatDuration startTime endTime}}
td
.btn-group
if isRunning
button.btn.btn-sm.btn-warning.js-pause-operation(data-operation="{{id}}")
i.fa.fa-pause
else
button.btn.btn-sm.btn-success.js-resume-operation(data-operation="{{id}}")
i.fa.fa-play
button.btn.btn-sm.btn-danger.js-stop-operation(data-operation="{{id}}")
i.fa.fa-stop
button.btn.btn-sm.btn-info.js-view-details(data-operation="{{id}}")
i.fa.fa-info-circle
.pagination
if pagination.hasPrev
button.btn.btn-sm.btn-default.js-prev-page
i.fa.fa-caret-left
| {{_ 'previous'}}
.page-info
| {{_ 'page'}} {{pagination.page}} {{_ 'of'}} {{pagination.totalPages}}
if pagination.hasNext
button.btn.btn-sm.btn-default.js-next-page
| {{_ 'next'}}
i.fa.fa-caret-right
template(name="cronJobs")
.cron-jobs
.jobs-header
h2
i.fa.fa-clock-o
| {{_ 'cron-jobs'}}
.jobs-controls
button.btn.btn-success.js-refresh-jobs
i.fa.fa-refresh
| {{_ 'refresh'}}
.jobs-list
table.table.table-striped
thead
tr
th {{_ 'job-name'}}
th {{_ 'schedule'}}
th {{_ 'status'}}
th {{_ 'last-run'}}
th {{_ 'next-run'}}
th {{_ 'actions'}}
tbody
each cronJobs
tr
td {{name}}
td {{schedule}}
td
span.status-badge(class="status-{{status}}") {{status}}
td {{formatDate lastRun}}
td {{formatDate nextRun}}
td
.btn-group
if isRunning
button.btn.btn-sm.btn-warning.js-pause-job(data-job="{{name}}")
i.fa.fa-pause
else
button.btn.btn-sm.btn-success.js-start-job(data-job="{{name}}")
i.fa.fa-caret-right
button.btn.btn-sm.btn-danger.js-stop-job(data-job="{{name}}")
i.fa.fa-stop
button.btn.btn-sm.btn-danger.js-remove-job(data-job="{{name}}")
i.fa.fa-trash
template(name="cronAddJob")
.cron-add-job
.add-job-header
h2
i.fa.fa-plus
| {{_ 'add-cron-job'}}
.add-job-form
form.js-add-cron-job-form
.form-group
label(for="job-name") {{_ 'job-name'}}
input.form-control#job-name(type="text" name="name" required)
.form-group
label(for="job-description") {{_ 'job-description'}}
textarea.form-control#job-description(name="description" rows="3")
.form-group
label(for="job-schedule") {{_ 'schedule'}}
select.form-control#job-schedule(name="schedule")
option(value="every 1 minute") {{_ 'every-1-minute'}}
option(value="every 5 minutes") {{_ 'every-5-minutes'}}
option(value="every 10 minutes") {{_ 'every-10-minutes'}}
option(value="every 30 minutes") {{_ 'every-30-minutes'}}
option(value="every 1 hour") {{_ 'every-1-hour'}}
option(value="every 6 hours") {{_ 'every-6-hours'}}
option(value="every 1 day") {{_ 'every-1-day'}}
option(value="once") {{_ 'run-once'}}
.form-group
label(for="job-weight") {{_ 'weight'}}
input.form-control#job-weight(type="number" name="weight" value="1" min="1" max="10")
.form-actions
button.btn.btn-primary(type="submit")
i.fa.fa-plus
| {{_ 'add-job'}}
button.btn.btn-default.js-cancel-add-job
i.fa.fa-times-thin
| {{_ 'cancel'}}
@@ -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}}
@@ -36,7 +36,8 @@
}
.migration-progress-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
/* WeKan blue (matches the main header #2980b9) */
background: linear-gradient(135deg, #2980b9 0%, #1a5276 100%);
color: white;
padding: 20px;
display: flex;
@@ -85,7 +86,8 @@
}
.migration-progress-overall-fill {
background: linear-gradient(90deg, #28a745, #20c997);
/* WeKan blue */
background: linear-gradient(90deg, #2980b9, #3498db);
height: 100%;
border-radius: 10px;
transition: width 0.3s ease;
@@ -135,7 +137,8 @@
}
.migration-progress-step-fill {
background: linear-gradient(90deg, #007bff, #0056b3);
/* WeKan blue */
background: linear-gradient(90deg, #3498db, #2980b9);
height: 100%;
border-radius: 8px;
transition: width 0.3s ease;
@@ -153,7 +156,7 @@
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
border-inline-start: 4px solid #007bff;
border-inline-start: 4px solid #2980b9;
}
.migration-progress-status-label {
@@ -5,7 +5,7 @@ template(name="migrationProgress")
.migration-progress-header
h3.migration-progress-title
i.fa.fa-recycle
| {{_ 'migration-progress-title'}}
| {{productName}} {{_ 'migration-progress-title'}}
.migration-progress-close.js-close-migration-progress
i.fa.fa-times-thin
@@ -5,6 +5,7 @@
import { ReactiveVar } from 'meteor/reactive-var';
import { ReactiveCache } from '/imports/reactiveCache';
import { productNameOrDefault } from '/models/lib/productName';
// Reactive variables for migration progress
export const migrationProgress = new ReactiveVar(0);
@@ -133,6 +134,13 @@ Template.migrationProgress.helpers({
return isMigrating.get();
},
// The configured Product name (Admin Panel / Settings / Layout / Product name)
// so the migration modal is branded with it instead of the hard-coded WeKan.
productName() {
const setting = ReactiveCache.getCurrentSetting();
return productNameOrDefault(setting && setting.productName);
},
overallProgress() {
return migrationProgress.get();
},
+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
+11 -28
View File
@@ -1376,15 +1376,20 @@ Template.editUserPopup.events({
const isChangePassword = password.length > 0;
const isChangeUserName = username !== user.username;
const isChangeInitials = initials.length > 0;
const isChangeEmailVerified = verified !== user.emails[0].verified;
// If previously email address has not been set, it is undefined,
// check for undefined, and allow adding email address.
// An imported (placeholder) user, and some SSO users, have NO `emails` array at
// all, so `user.emails[0]` threw "Cannot read properties of undefined (reading
// '0')" when an admin gave such a user an email in Admin Panel / People (#6508).
// Read the primary email defensively (missing array OR empty array).
const primaryEmail =
Array.isArray(user.emails) && user.emails.length ? user.emails[0] : null;
const isChangeEmailVerified =
verified !== (primaryEmail ? primaryEmail.verified : undefined);
// If no email was set before, allow adding one (compare against `false`).
const isChangeEmail =
email.toLowerCase() !==
(typeof user.emails !== 'undefined'
? user.emails[0].address.toLowerCase()
: false);
(primaryEmail ? primaryEmail.address.toLowerCase() : false);
// Build user teams list
let userTeamsList = userTeams.split(",");
@@ -1895,16 +1900,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 +1938,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 +1963,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,56 @@
template(name="problemsSummary")
.global-search-results-list-wrapper
h1 {{_ 'summary'}}
//- Status overview: migrations / repairs in progress + detected problems.
//- Same data as `snap run wekan.problems`.
if statusOverview
h2 {{_ 'problems-status-title'}}
if statusOverview.anyInProgress
p {{_ 'problems-in-progress-help'}}
.admin-problem-banner
each item in inProgress
.admin-problem-item
i.fa.fa-spinner.fa-spin
span.admin-problem-path {{item.message}}
else
p {{_ 'problems-none-in-progress'}}
if statusOverview.anyProblems
.admin-problem-banner
each problem in statusProblems
.admin-problem-item
i.fa.fa-exclamation-triangle
span.admin-problem-path {{problem.title}}
if problem.count
span.admin-problem-count {{problem.count}}
br
span.quiet {{problem.detail}}
//- Broken cards are the one detected problem the admin can fix
//- from here: run the repair migration over every board.
if isBrokenCards problem
.admin-problem-actions
button.js-repair-broken-cards.primary(disabled="{{#if repairRunning}}disabled{{/if}}")
if repairRunning
i.fa.fa-spinner.fa-spin
| {{_ 'repairing'}}
else
i.fa.fa-wrench
| {{_ 'repair-broken-cards'}}
if repairResult
br
span.quiet {{repairResult}}
h2 {{_ 'problems'}}
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,118 @@
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([]);
// "Broken cards N" repair button state (see the repairBrokenCards method in
// server/methods/repairBrokenCards.js).
this.repairRunning = new ReactiveVar(false);
this.repairResult = new ReactiveVar('');
// Status overview: everything in progress (migrations / repairs) + detected
// problems (broken cards, login-page "Must be logged in" causes). Same data as
// `snap run wekan.problems`.
this.status = new ReactiveVar(null);
this.reload = () => {
Meteor.call('eventLogProblemAreas', (err, res) => {
if (!err) this.areas.set(Array.isArray(res) ? res : []);
});
Meteor.call('systemStatusReport', (err, res) => {
if (!err) this.status.set(res || null);
});
};
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);
},
statusOverview() {
const s = Template.instance().status.get();
return s && s.overview ? s.overview : null;
},
inProgress() {
const s = Template.instance().status.get();
return s && s.overview ? s.overview.inProgress : [];
},
statusProblems() {
const s = Template.instance().status.get();
return s && s.overview ? s.overview.problems : [];
},
// Only the broken-cards problem gets a Repair button — the other detected
// problems (login-page causes) are configuration, not repairable data.
isBrokenCards(problem) {
return !!problem && problem.id === 'broken-cards';
},
repairRunning() {
return Template.instance().repairRunning.get();
},
repairResult() {
return Template.instance().repairResult.get();
},
});
Template.problemsSummary.events({
// Run the broken-card repair migration over every board. The button stays
// disabled while it runs; when it finishes the overview is reloaded so the
// "Broken cards N" count reflects the repair straight away.
'click .js-repair-broken-cards'(event, templateInstance) {
event.preventDefault();
if (templateInstance.repairRunning.get()) return;
templateInstance.repairRunning.set(true);
templateInstance.repairResult.set('');
Meteor.call('repairBrokenCards', (error, res) => {
templateInstance.repairRunning.set(false);
if (error) {
templateInstance.repairResult.set(error.reason || error.message || String(error));
return;
}
const fixed =
(res.cardsAssigned || 0) +
(res.cardsRescued || 0) +
(res.archivedCardsFixed || 0) +
(res.listsAssigned || 0) +
(res.swimlanesAssigned || 0);
// Cards with no boardId at all cannot be placed on any board, so say so
// explicitly instead of leaving a count that never reaches zero unexplained.
const unfixable = res.unfixable || 0;
templateInstance.repairResult.set(
unfixable > 0
? TAPi18n.__('repair-broken-cards-done-unfixable', { fixed, unfixable })
: TAPi18n.__('repair-broken-cards-done', { fixed }),
);
templateInstance.reload();
});
},
'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());
},
});
+38 -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;
}
@@ -331,3 +331,38 @@ li.has-error .form-group .wekan-form-control {
overflow-x: auto !important;
}
}
/* ── Admin tables on a phone: fit the width, wrap the text ───────────────────
The rules above deliberately force EVERY admin table to `min-width: 1200px`
with `white-space: nowrap` cells, to guarantee a horizontal scrollbar for the
wide data tables. On a 375px phone that also applies to a two-column table
like Admin Panel / Version, where "WeKan Version" and its value sit ~1000px
apart and you have to scroll right to read a value you already have room for.
At small width the table is instead allowed to be as wide as the screen and no
wider, and cells may use as many lines as they need. Long unbroken values -
version strings, paths, URLs - break rather than push the table wide again.
Horizontal scrolling still works if a genuinely wide table cannot fit; it is
just no longer forced on tables that would fit comfortably. */
@media screen and (max-width: 800px) {
.setting-content .content-body .main-body table {
min-width: 0 !important;
width: 100% !important;
table-layout: auto !important;
}
.setting-content .content-body .main-body table td,
.setting-content .content-body .main-body table th {
white-space: normal !important;
min-width: 0 !important;
max-width: none !important;
overflow-wrap: break-word;
word-break: break-word;
}
/* The label column keeps a sensible share rather than collapsing to one
character per line when a value is long. */
.setting-content .content-body .main-body table th {
width: 45%;
}
}
+1 -345
View File
@@ -8,24 +8,6 @@ import Announcements from '/models/announcements';
import Settings from '/models/settings';
import { resolveDefaultAuthenticationMethod } from '/models/lib/authenticationMethod';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
// import {
// cronMigrationProgress,
// cronMigrationStatus,
// cronMigrationCurrentStep,
// cronMigrationSteps,
// cronIsMigrating,
// cronJobs,
// cronMigrationCurrentStepNum,
// cronMigrationTotalSteps,
// cronMigrationCurrentAction,
// cronMigrationJobProgress,
// cronMigrationJobStepNum,
// cronMigrationJobTotalSteps,
// cronMigrationEtaSeconds,
// cronMigrationElapsedSeconds,
// cronMigrationCurrentNumber,
// cronMigrationCurrentName,
// } from '/imports/cronMigrationClient';
import { format } from '/imports/lib/dateUtils';
// Helper functions shared across the template
@@ -275,9 +257,6 @@ Template.setting.onCreated(function () {
apiUploadMaxBytes: false,
apiDownloadMaxBytes: false,
});
// this.cronSettings = new ReactiveVar(false);
// this.migrationErrorsList = new ReactiveVar([]);
Meteor.subscribe('setting');
Meteor.subscribe('mailServer');
Meteor.subscribe('accountSettings');
@@ -288,16 +267,6 @@ Template.setting.onCreated(function () {
Meteor.subscribe('lockoutSettings');
Meteor.subscribe('attachmentStorageSettings');
// Poll for migration errors
// this.errorPollInterval = Meteor.setInterval(() => {
// if (this.cronSettings.get()) {
// Meteor.call('cron.getAllMigrationErrors', 50, (error, result) => {
// if (!error && result) {
// this.migrationErrorsList.set(result);
// }
// });
// }
// }, 5000); // Poll every 5 seconds
});
Template.setting.onDestroyed(function () {
@@ -391,10 +360,6 @@ Template.setting.helpers({
const inst = Template.instance();
return inst.attachmentSettings && inst.attachmentSettings.get();
},
// isCronSettings() {
// const inst = Template.instance();
// return inst.cronSettings && inst.cronSettings.get();
// },
isLoading() {
const inst = Template.instance();
return inst.loading && inst.loading.get();
@@ -469,127 +434,6 @@ Template.setting.helpers({
return enabledMap[fieldName] === true;
},
// Cron settings helpers
// migrationStatus() {
// return cronMigrationStatus.get() || TAPi18n.__('idle');
// },
//
// migrationProgress() {
// return cronMigrationProgress.get() || 0;
// },
//
// migrationCurrentStep() {
// return cronMigrationCurrentStep.get() || '';
// },
//
// isMigrating() {
// return cronIsMigrating.get() || false;
// },
//
// migrationSteps() {
// return cronMigrationSteps.get() || [];
// },
//
// migrationStepsWithIndex() {
// const steps = cronMigrationSteps.get() || [];
// return steps.map((step, idx) => ({
// ...step,
// index: idx + 1,
// }));
// },
//
// cronJobs() {
// return cronJobs.get() || [];
// },
//
// isCronJobPaused(status) {
// return status === 'paused';
// },
//
// migrationCurrentStepNum() {
// return cronMigrationCurrentStepNum.get() || 0;
// },
//
// migrationTotalSteps() {
// return cronMigrationTotalSteps.get() || 0;
// },
//
// migrationCurrentAction() {
// return cronMigrationCurrentAction.get() || '';
// },
//
// migrationJobProgress() {
// return cronMigrationJobProgress.get() || 0;
// },
//
// migrationJobStepNum() {
// return cronMigrationJobStepNum.get() || 0;
// },
//
// migrationJobTotalSteps() {
// return cronMigrationJobTotalSteps.get() || 0;
// },
//
// migrationEtaSeconds() {
// return cronMigrationEtaSeconds.get();
// },
//
// migrationElapsedSeconds() {
// return cronMigrationElapsedSeconds.get();
// },
//
// migrationNumber() {
// return cronMigrationCurrentNumber.get();
// },
//
// migrationName() {
// return cronMigrationCurrentName.get() || '';
// },
//
// migrationStatusLine() {
// const number = cronMigrationCurrentNumber.get();
// const name = cronMigrationCurrentName.get();
// if (number && name) {
// return `${number} - ${name}`;
// }
// return cronMigrationStatus.get() || TAPi18n.__('idle');
// },
//
// isUpdatingMigrationDropdown() {
// const status = cronMigrationStatus.get() || TAPi18n.__('idle');
// return (
// status && status.startsWith('Updating Select Migration dropdown menu')
// );
// },
//
// migrationErrors() {
// return Template.instance().migrationErrorsList ? Template.instance().migrationErrorsList.get() : [];
// },
//
// hasErrors() {
// const inst = Template.instance();
// const errors = inst.migrationErrorsList ? inst.migrationErrorsList.get() : [];
// return errors && errors.length > 0;
// },
//
// formatDateTime(date) {
// if (!date) return '';
// return format(date, 'YYYY-MM-DD HH:mm:ss');
// },
//
// formatDurationSeconds(seconds) {
// if (seconds === null || seconds === undefined) return '';
// const total = Math.max(0, Math.floor(seconds));
// const hrs = Math.floor(total / 3600);
// const mins = Math.floor((total % 3600) / 60);
// const secs = total % 60;
// const parts = [];
// if (hrs > 0) parts.push(String(hrs).padStart(2, '0'));
// parts.push(String(mins).padStart(2, '0'));
// parts.push(String(secs).padStart(2, '0'));
// return parts.join(':');
// },
boards() {
const ret = ReactiveCache.getBoards(
{
@@ -672,8 +516,6 @@ Template.setting.events({
tpl.layoutSetting.set(false);
tpl.webhookSetting.set(false);
tpl.attachmentSettings.set(false);
// tpl.cronSettings.set(false);
// Set the selected setting to true
if (targetID === 'registration-setting') {
tpl.generalSetting.set(true);
@@ -696,10 +538,7 @@ Template.setting.events({
refreshAttachmentStorageSettings(tpl, true);
// Set default sub-menu state for attachment settings
console.log('Initializing attachment sub-menu');
} // else if (targetID === 'cron-settings') {
// tpl.cronSettings.set(true);
// console.log('Initializing cron sub-menu');
// }
}
}
},
'click a.js-toggle-board-choose'(event) {
@@ -1125,189 +964,6 @@ Template.setting.events({
});
},
// Event handlers for cron settings
// 'click button.js-start-migration'(event, tpl) {
// event.preventDefault();
// tpl.loading.set(true);
// cronIsMigrating.set(true);
// cronMigrationStatus.set(TAPi18n.__('migration-starting'));
// cronMigrationCurrentAction.set('');
// cronMigrationJobProgress.set(0);
// cronMigrationJobStepNum.set(0);
// cronMigrationJobTotalSteps.set(0);
// const selectedIndex = parseInt($('.js-migration-select').val() || '0', 10);
//
// if (selectedIndex === 0) {
// // Run all migrations
// Meteor.call('cron.startAllMigrations', (error, result) => {
// tpl.loading.set(false);
// if (error) {
// alert(TAPi18n.__('migration-start-failed') + ': ' + error.reason);
// } else {
// alert(TAPi18n.__('migration-started'));
// }
// });
// } else {
// // Run specific migration
// Meteor.call(
// 'cron.startSpecificMigration',
// selectedIndex - 1,
// (error, result) => {
// tpl.loading.set(false);
// if (error) {
// alert(TAPi18n.__('migration-start-failed') + ': ' + error.reason);
// } else if (result && result.skipped) {
// cronIsMigrating.set(false);
// cronMigrationStatus.set(TAPi18n.__('migration-not-needed'));
// alert(TAPi18n.__('migration-not-needed'));
// } else {
// alert(TAPi18n.__('migration-started'));
// }
// },
// );
// }
// },
//
// 'click button.js-start-all-migrations'(event, tpl) {
// event.preventDefault();
// tpl.loading.set(true);
// Meteor.call('cron.startAllMigrations', (error) => {
// tpl.loading.set(false);
// if (error) {
// alert(TAPi18n.__('migration-start-failed') + ': ' + error.reason);
// } else {
// alert(TAPi18n.__('migration-started'));
// }
// });
// },
//
// 'click button.js-pause-all-migrations'(event, tpl) {
// event.preventDefault();
// tpl.loading.set(true);
// Meteor.call('cron.pauseAllMigrations', (error) => {
// tpl.loading.set(false);
// if (error) {
// alert(TAPi18n.__('migration-pause-failed') + ': ' + error.reason);
// } else {
// alert(TAPi18n.__('migration-paused'));
// }
// });
// },
//
// 'click button.js-stop-all-migrations'(event, tpl) {
// event.preventDefault();
// if (confirm(TAPi18n.__('migration-stop-confirm'))) {
// tpl.loading.set(true);
// Meteor.call('cron.stopAllMigrations', (error) => {
// tpl.loading.set(false);
// if (error) {
// alert(TAPi18n.__('migration-stop-failed') + ': ' + error.reason);
// } else {
// alert(TAPi18n.__('migration-stopped'));
// }
// });
// }
// },
//
// 'click button.js-pause-migration'(event, tpl) {
// event.preventDefault();
// tpl.loading.set(true);
// cronIsMigrating.set(false);
// cronMigrationStatus.set(TAPi18n.__('migration-pausing'));
// Meteor.call('cron.pauseAllMigrations', (error, result) => {
// tpl.loading.set(false);
// if (error) {
// alert(TAPi18n.__('migration-pause-failed') + ': ' + error.reason);
// } else {
// alert(TAPi18n.__('migration-paused'));
// }
// });
// },
//
// 'click button.js-stop-migration'(event, tpl) {
// event.preventDefault();
// if (confirm(TAPi18n.__('migration-stop-confirm'))) {
// tpl.loading.set(true);
// cronIsMigrating.set(false);
// cronMigrationStatus.set(TAPi18n.__('migration-stopping'));
// cronMigrationCurrentAction.set('');
// cronMigrationJobProgress.set(0);
// cronMigrationJobStepNum.set(0);
// cronMigrationJobTotalSteps.set(0);
// Meteor.call('cron.stopAllMigrations', (error, result) => {
// tpl.loading.set(false);
// if (error) {
// alert(TAPi18n.__('migration-stop-failed') + ': ' + error.reason);
// } else {
// alert(TAPi18n.__('migration-stopped'));
// }
// });
// }
// },
//
// 'click button.js-start-job'(event, tpl) {
// event.preventDefault();
// const jobName = $(event.target).data('job-name');
// tpl.loading.set(true);
// Meteor.call('cron.startJob', jobName, (error) => {
// tpl.loading.set(false);
// if (error) {
// alert(TAPi18n.__('cron-job-start-failed') + ': ' + error.reason);
// } else {
// alert(TAPi18n.__('cron-job-started'));
// }
// });
// },
//
// 'click button.js-pause-job'(event, tpl) {
// event.preventDefault();
// const jobName = $(event.target).data('job-name');
// tpl.loading.set(true);
// Meteor.call('cron.pauseJob', jobName, (error) => {
// tpl.loading.set(false);
// if (error) {
// alert(TAPi18n.__('cron-job-pause-failed') + ': ' + error.reason);
// } else {
// alert(TAPi18n.__('cron-job-paused'));
// }
// });
// },
//
// 'click button.js-resume-job'(event, tpl) {
// event.preventDefault();
// const jobName = $(event.target).data('job-name');
// tpl.loading.set(true);
// Meteor.call('cron.resumeJob', jobName, (error) => {
// tpl.loading.set(false);
// if (error) {
// alert(TAPi18n.__('cron-job-resume-failed') + ': ' + error.reason);
// } else {
// alert(TAPi18n.__('cron-job-resumed'));
// }
// });
// },
//
// 'click button.js-delete-job'(event, tpl) {
// event.preventDefault();
// const jobName = $(event.target).data('job-name');
// if (confirm(TAPi18n.__('cron-job-delete-confirm'))) {
// tpl.loading.set(true);
// Meteor.call('cron.removeJob', jobName, (error) => {
// tpl.loading.set(false);
// if (error) {
// alert(TAPi18n.__('cron-job-delete-failed') + ': ' + error.reason);
// } else {
// alert(TAPi18n.__('cron-job-deleted'));
// }
// });
// }
// },
//
// 'click button.js-add-cron-job'(event) {
// event.preventDefault();
// // Placeholder for adding a new cron job (e.g., open a modal)
// alert(TAPi18n.__('add-cron-job-placeholder'));
// },
});
Template.accountSettings.helpers({
@@ -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. */
+31 -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
@@ -859,6 +859,11 @@ template(name="memberPopup")
a.js-change-role
| {{_ 'change-permissions'}}
span.quiet (#{memberType})
if isImportedMember
li
a.js-map-imported-member
i.fa.fa-user-plus
| {{_ 'map-to-existing-user'}}
unless currentUser.isWorker
li
if $eq currentUser._id userId
@@ -866,6 +871,25 @@ template(name="memberPopup")
else if currentUser.isBoardAdmin
a.js-remove-member {{_ 'remove-from-board'}}
template(name="mapImportedMemberPopup")
p.quiet {{_ 'map-to-existing-user-desc'}}
.js-map-target-search
input.js-map-target-search-input(type="text" placeholder="{{_ 'map-to-existing-user-search'}}" autocomplete="off")
if searching
.map-target-status.quiet {{_ 'loading'}}
ul.pop-over-list
each u in mapTargets
li
a.js-pick-map-target(data-id="{{u._id}}")
+userAvatar(userId=u._id)
span.full-name= u.profile.fullname
| (@#{u.username})
unless u.isBoardMember
span.quiet — {{_ 'map-to-existing-user-not-member'}}
unless hasMapTargets
li
p.quiet {{mapTargetsEmptyText}}
template(name="removeMemberPopup")
p {{_ 'remove-member-pop' name=user.profile.fullname username=user.username boardTitle=board.title}}
button.js-confirm.negate.full(type="submit") {{_ 'remove-member'}}
Loaded 100 of 792 files, more files were not shown because too many files have changed in this diff. Show more