Compare commits

...
244 Commits
Author SHA1 Message Date
github-actions[bot] e9c5888485 Bump versions for v12.95 2026-11-05 19:04:13 +00:00
Lauri Ojansivu 09c9d72398 Prepare v12.95 release 2026-11-05 21:03:04 +02:00
Lauri Ojansivu 4f3d45f3de CHANGELOG: the attachment upload policy method is loaded.
Thanks to Raymond-Voogt and xet7 !
2026-11-05 20:57:07 +02:00
Lauri Ojansivu 3aaa46fdae The attachment upload policy method is loaded, so a card hides the "+" when uploads are blocked.
d8b3579117 added server/methods/attachmentUploadPolicy.js but nothing
imported it, so the method did not exist: the card's
Meteor.callAsync('attachmentUploadPolicy') failed, the client treated that
as "not blocked", and the "+" stayed. server/imports.js now imports it with
the other server methods. tests/serverModulesLoaded.test.cjs, which failed
on it, passes.

Thanks to Raymond-Voogt and xet7 !
2026-11-05 20:57:01 +02:00
Lauri Ojansivu 59f1b705e0 CHANGELOG: missing users, organizations and teams return 404, Requested by on the minicard, and the pull request rules.
Thanks to AhmedLukman, mueschel and xet7 !
2026-11-05 20:56:19 +02:00
Lauri Ojansivu 22e5df55d7 Requested by and Assigned by show on the minicard when a member was picked, with the same icon everywhere.
The opened card's + button picks board members into card.requesters /
card.assigners; the free-text requestedBy / assignedBy is optional. The
minicard only drew the free text, so a requester picked the usual way never
showed, although Show on Minicard was on. It now draws the picked members
as avatars beside the typed name, as the opened card does.

Card Settings showed Requested by with a user-plus icon and Assigned by with
a shopping cart, the other way round from the opened card and the minicard.
The settings rows now use the cart for Requested by and user-plus for
Assigned by.

tests/minicardRequestedAssignedBy6872.test.cjs pins the avatars, the
either-or condition and one icon per field across all three places, and
fails on a minicard person block gated on the free text alone.
tests/playwright/specs/minicard-requested-by-6872.e2e.js drives it.

Thanks to mueschel and xet7 !

Fixes #6872.
2026-11-05 20:55:28 +02:00
Lauri Ojansivu 016b4bf3bd CLAUDE.md, AGENTS.md: only maintainer mode commits directly and updates changelogs; one pull request per feature or fix.
A pull request contributor, in wekan/wekan and in the maintainer's
repositories under .tools/, sends each feature or fix as its own pull
request, with only related changes in it, and changes no changelog: the
maintainer writes the entry after merging, when there is a commit to link.
The CHANGELOG section and the security CRITICAL entry say they are
maintainer mode only, and "good practice for everyone" no longer includes
the CHANGELOG structure. tests/changelogFormat.test.cjs checks both files.

Thanks to xet7 !
2026-11-05 20:55:28 +02:00
Lauri Ojansivu 4d44349ab6 Merge pull request #6874 from AhmedLukman/codex/rest-people-features-not-found
Return 404 for missing organization and team feature updates.
2026-11-05 20:53:48 +02:00
Lauri Ojansivu d3edc56170 Merge pull request #6873 from AhmedLukman/codex/user-update-not-found
Return 404 for updates to missing users
2026-11-05 20:53:12 +02:00
Ahmed Lukman 5134e3d2a8 Return 404 for missing organization and team feature updates.
Return a JSON not-found response when the feature update lookup finds no organization or team. Cover missing records, successful updates, authorization, and reactive People-table checkboxes.

Thanks to AhmedLukman and xet7 !
2026-11-05 21:28:38 +03:00
Ahmed Lukman f686b76023 Keep the user update fix limited to code and tests.
Thanks to AhmedLukman and xet7 !
2026-11-05 20:54:17 +03:00
Ahmed Lukman cd2adcad02 Document user update not-found responses.
Thanks to AhmedLukman and xet7 !
2026-11-05 20:48:43 +03:00
Ahmed Lukman 0766a65fef Return 404 for updates to missing users.
Thanks to AhmedLukman and xet7 !
2026-11-05 20:47:06 +03:00
Lauri Ojansivu 58ad81e301 continuousBackup integration suite: stop each engine before its directory is removed.
Three tests registered the hook that removes their temp directory before the hook
that stops their engine. node:test runs after-hooks in the order they were
registered, not last-in first-out, so the directory was gone when stop() sealed
into it, and the ENOENT (files/index.ndjson) surfaced after the test as
"generated asynchronous activity after the test ended" - which failed the whole
suite although all 8 tests passed. One hook now stops the engine first and then
removes the directory. Five runs in a row, and the suite runner, pass.

Thanks to xet7 !
2026-11-04 19:20:58 +02:00
Lauri Ojansivu 2912f4b080 Close #6870: attachments are already off site-wide in Admin Panel / Attachments / Transfer limits.
How: Admin Panel / Attachments / Transfer limits, set "Attachments upload" and
"API upload" to Blocked. That is the site-wide "Disable attachments" the issue
asks for, as two settings instead of one checkbox.

Comment for the issue:

  A site-wide switch for this already exists, so a new "Disable attachments"
  setting is not needed. Open Admin Panel / Attachments / Transfer limits and set
  "Attachments upload" and "API upload" to Blocked. While they are on, the server
  refuses an upload on every path - the upload pipeline, the api.attachment.upload
  and api.attachment.copy methods, and the /api/attachment upload,
  upload-background and copy routes - on every board, including boards created
  later, and no board's allowsAttachments flag has to be touched. Download, move
  and delete are not affected, so files uploaded earlier stay reachable. What was
  missing is that a card still showed the "+" button and accepted a drop for an
  upload that would then be refused; since d8b3579117 the card asks the server
  once a minute and hides both. There is no second setting, so nothing can
  disagree with what the server enforces. Two settings instead of one checkbox
  is the one difference from the request.

Thanks to Raymond-Voogt and xet7 !

Fixes #6870,
2026-11-04 19:17:53 +02:00
Lauri Ojansivu 97d1eca09d CHANGELOG: In short for the upcoming release. 2026-11-04 19:12:14 +02:00
Lauri Ojansivu 95b75177b8 CHANGELOG: continuous backup runs on one server at a time, with a lease. 2026-11-04 19:12:06 +02:00
Lauri Ojansivu 880ec2d707 Continuous backup runs on one server at a time, with a lease.
With several WeKan servers on one database, every server applied the continuous
backup setting, so each of them tailed the oplog and wrote its own archives into
the same target directory (#6861, item 3).

The leader holds a lease: one continuousBackupLease document with its id and an
expiry. Taking it is one atomic operation (an insert while there is none, else
an update that matches only an unheld, own or expired lease), the leader renews
it every 15 seconds and it lapses after 45, so another server takes over within
a minute when the leader stops. A server that loses the lease stops its stream.
Startup, saving the settings and a change saved on another server all converge
through that loop, and a restore or a fetch from the cloud takes the lease and
waits one renewal so it is the only writer. One server is always its own leader.

Checked against the real driver on FerretDB (SQLite): 24 clients at once give
exactly one leader; renewal, expiry, loss of the old leader and release behave.
tests/continuousBackupLease.test.cjs pins the same and the wiring in
server/continuousBackup.js. docs/Backup/Continuous-Backup.md no longer says
single-server only.

Thanks to Raymond-Voogt and xet7 !

Refs #6861, not closed: the remaining items of the issue were fixed in 4e28465964.
2026-11-04 19:11:32 +02:00
Lauri Ojansivu dbc7cccea3 CHANGELOG: a card no longer offers the attachment plus when uploads are blocked for the whole site. 2026-11-04 19:07:11 +02:00
Lauri Ojansivu d8b3579117 A card no longer offers the attachment "+" when uploads are blocked for the whole site.
#6870 asks for a site-wide switch that turns attachments off, because the
per-board allowsAttachments flag has to be set on every board. That switch is
there already: Admin Panel / Attachments / Transfer limits, "Attachments upload"
and "API upload" set to Blocked, and the server refuses on every path - the
upload pipeline, api.attachment.upload and .copy, and the /api/attachment
upload, upload-background and copy routes. What was missing is that the card
still showed the "+" button and accepted a drop for an upload that would be
refused.

attachmentUploadPolicy tells a signed-in client the one boolean (nothing about
the limits), asked at most once a minute, and the "+" and the drop/paste upload
follow it. No second setting is added, so there is nothing that could disagree
with what the server enforces. tests/attachmentUploadPolicy.test.cjs pins the
client, the method, and the server enforcement it relies on, and that no
"disableAttachments" setting exists.

Thanks to Raymond-Voogt and xet7 !

Refs #6870, not closed: the reporter asked for a single Features checkbox, and the
two Blocked limits are two settings.
2026-11-04 19:07:09 +02:00
Lauri Ojansivu c7e53e9f38 CHANGELOG: an integration update for a missing or wrong-board integration returns 404. 2026-11-04 19:04:14 +02:00
Lauri Ojansivu 6012521ccc An integration update for a missing or wrong-board integration returns 404.
PUT /api/boards/:boardId/integrations/:intId answered 200 with the requested id
when no integration matched that id and board (PR #6871). The two activities
routes (POST and DELETE .../activities) had the same fault: they updated
nothing and answered 200 with `data: null`. All three now look the integration
up on that board after board-admin authorization and answer the JSON 404 the GET
route already uses (#6862), before validating or writing anything.

tests/restIntegrationUpdate.test.cjs and the Playwright spec are the PR's.
tests/restIntegrationWriteRoutes404.test.cjs adds the activities routes and
pins, for the whole file, that every handler that updates an integration looks
it up first and only after authorization.

Thanks to AhmedLukman and xet7 !
2026-11-04 19:04:12 +02:00
Lauri Ojansivu 0195a5b3d4 Merge remote-tracking branch 'origin/main' 2026-11-04 15:02:36 +02:00
Lauri Ojansivu b12e761afb CHANGELOG: missing-board exports return 404 and an over-long export token 400. 2026-11-04 14:50:49 +02:00
Lauri Ojansivu 0c0d666ffa Missing-board exports return 404, and an over-long export token returns 400.
The board Excel export answered a missing board with HTTP 200 and the text
"Board not found" (PR #6869). The two PDF export routes had the same fault, and
the Excel and PDF routes answered an over-long auth token with 200 too, while
export.js already used 404 and 400. They now choose their status the same way.

tests/boardExcelNotFound.test.cjs and the Playwright spec are the PR's.
tests/exportErrorStatuses.test.cjs scans every models/export*.js route for a
text answer without a status, so the fault cannot return anywhere there.

Thanks to AhmedLukman and xet7 !
2026-11-04 14:50:33 +02:00
Lauri Ojansivu fc2ad0a48b CHANGELOG: nodemailer, body-parser, qs, uuid and postcss-selector-parser raised in the bundle. 2026-11-04 14:45:57 +02:00
Lauri Ojansivu dad03959d9 Raise nodemailer, body-parser, qs, uuid and postcss-selector-parser in the bundle.
A Grype scan of the image found advisories in packages that Meteor's own
packages carry in programs/server/npm/node_modules/meteor/<package>, which
package.json cannot reach.

nodemailer 8.0.11 -> 10.3.1: meteor/email only calls createTransport, the
'stream' plugin hook, nodemailer/lib/well-known and nodemailer/lib/mail-composer,
and all four work in 10.3.1 (checked with the bundle's own package specifiers,
through its exports map). body-parser 1.20.6/1.20.8 -> 1.20.9 and 2.3.0 ->
2.4.0: a minimum may now be "name@MAJOR", so each major is raised inside
itself. qs -> 6.16.1, postcss-selector-parser 6.1.4 -> 6.1.5.

uuid 8.3.2 and 9.0.1 -> 11.1.1: 11 moved its entry point, which is why it was
refused before. The bumper now writes the recorded dist/index.js back as an
ESM re-export of dist/esm/index.js, which require() loads and which leaves
uuid's own ESM and CJS entry points as they are.

glob 14 and minimatch 11 are already in package.json. Run against a copy of
the published image's bundle, the bumper raises all of these and every raised
copy loads.

Thanks to the Grype scan reporter and xet7 !
2026-11-04 14:45:04 +02:00
github-actions[bot] ac960e7d81 Bump versions for v12.94 2026-11-04 01:07:15 +00:00
Lauri Ojansivu 3213d7fa1d Prepare v12.94 release 2026-11-04 03:06:07 +02:00
Lauri Ojansivu 4757676abf CHANGELOG: on a phone in desktop mode the opened card starts below the header bar. 2026-11-04 03:04:05 +02:00
Lauri Ojansivu efaf94242e On a phone in desktop mode the opened card starts below the header bar.
The header wraps to several rows on a phone, far taller than the 8px the
floating card docks at, so the card's top row sat among the header's rows and
was only seen by dragging. Below 801px a card without an inline top, popup
cards included, starts at --wekan-header-height + 8px; a dragged place is an
inline top and is never overridden.

Thanks to the iPhone 12 mini reporter and xet7 !
2026-11-04 03:03:59 +02:00
Lauri Ojansivu d87baa0244 CHANGELOG: the opened card covers the header bar on an iPad. 2026-11-04 02:56:44 +02:00
Lauri Ojansivu 006d24502c The opened card covers the header bar on an iPad: the header drops to the content's level.
On iPad Chrome and Safari (both WebKit) the opened card's top row - the collapse
caret, the card number, the menu, maximize and X - was behind the header bar,
although the card has z-index 2001 and the header 1000. iOS WebKit makes every
scrolling element (#content, the board wrapper) a stacking context of its own, so
the card's z-index is compared only inside it while the header bar, a flex item of
body and a stacking context too, is painted over all of it. While a card is open
the header bar (#header-quick-access) is now at z-index 0, the level of the
content, which comes after it in the page and so paints over it; with no card open
it keeps 1000.

A browser spec makes #content and the board wrapper stacking contexts with
isolation: isolate, which is the same trap, and shows the card over the header open
and collapsed, its close button reachable, in Chromium and WebKit; without the rule
it fails with the card's top at 8 behind a header 88 high, as in the screenshot.

Thanks to xet7 !
2026-11-04 02:56:33 +02:00
Lauri Ojansivu 8f4efbca57 Merge remote-tracking branch 'origin/main' 2026-11-04 01:35:33 +02:00
Lauri Ojansivu dde09dead5 CHANGELOG: where the time goes when a WeKan board is opened. 2026-11-04 01:21:48 +02:00
Lauri Ojansivu 3621413d6f Speed test: where the time goes when a WeKan board is opened, on FerretDB and on MongoDB.
app/board-open.cjs seeds a board of 200, 2000 or 5000 cards, opens it in Chromium and
records the wall clock to the first card and until the DDP WebSocket is quiet, what
crossed it, the Meteor server's CPU by who spends it (Node inspector) and FerretDB's
CPU (pprof). Two runs on each database: the Meteor server's CPU is the same on both
(0.1-0.5 s) and the DDP server is 1% of it; a 5000-card board takes FerretDB (SQLite)
400-600 ms longer to open, the data sent to the browser being the same ~210 KB, a
small board 70-100 ms. FerretDB uses about 4 s of CPU for the 5000-card open: SQLite
39%, filtering in Go 34%, find/getMore/aggregate 0.7-0.8 s each. The README says what
was measured and what was not: the development build, one machine, the browser's own
time not separated, and the aggregate's source not identified.

Thanks to Raymond-Voogt and xet7 !
2026-11-04 01:21:16 +02:00
github-actions[bot] 3c3fc07b3c Bump versions for v12.93 2026-11-03 23:04:07 +00:00
Lauri Ojansivu c1e8976b28 Prepare v12.93 release 2026-11-04 01:02:51 +02:00
Lauri Ojansivu dcce935310 CHANGELOG: the opened card and popups move under a finger. 2026-11-04 01:00:21 +02:00
Lauri Ojansivu 1b05673200 The opened card and every popup move under a finger: Pointer Events and touch-action.
On an iPad the opened card did not move when dragged by a finger from the collapse
caret, the space beside it, the card number, or the drag handle, while the page
behind it scrolled. The card was moved by mousedown / mousemove / mouseup, and a
finger sends none of them while it moves; the drag areas also lacked
touch-action: none, so the browser took the gesture for a scroll. The card window
is now moved by Pointer Events: a press on the header row or the handle moves it, a
press on a link or button in the row (the caret, close, the menu) waits for a real
drag so a tap still does its job and a drag does not also collapse or close, the
title still edits, and with drag handles on the handle alone moves the window. The
card number is above the title's edit zone so it drags too, the window is kept
within reach, and touch-action: none is on the card header, its handle and every
popup's title bar and handle. Blaze hands over a jQuery event without isPrimary, so
the native event is read.

A browser spec drags with a real touch (DevTools protocol, Pointer Events) from the
caret, the number and the space beside the title, with handles on and off, a tap
that still collapses, a mouse that still drags and a popup; without the fix the
finger tests fail.

Thanks to xet7 !
2026-11-04 01:00:19 +02:00
Lauri Ojansivu dafb63299f CHANGELOG: a popup card covers the header bars. 2026-11-04 00:42:59 +02:00
Lauri Ojansivu 84b0480aa8 An opened card opened as a popup covers the header bars, in every browser.
A card opened as a popup (a search result, a table row, a Roadmap/Gantt bar) got
the generic wide-layout rule top: 97px and z-index 30, because the desktop-mode
rules that dock an opened card at the top (top: 8px, z-index 2001) skip a popup.
So it opened BELOW the header, and collapsed it was a bar floating over the
swimlane, while the same file's popup wrapper rule says it must cover the header
bars. The popup card now docks at top: 8px with z-index 2001 in the wide layout;
mobile mode keeps its own full-window rule. A browser spec shows the card over
the header, open and collapsed, in Chromium and WebKit, and fails without the fix
(top 97 against a header 88 high).

Thanks to xet7 !
2026-11-04 00:42:57 +02:00
Lauri Ojansivu f6fc97e77a The full screen button says Minimize while the page is in full screen.
The same button leaves full screen; its title and name were still Maximize, which
does not say so. The existing Minimize translation is used, so no language file
changes.
2026-11-04 00:29:00 +02:00
Lauri Ojansivu d7105e531d CHANGELOG: a full screen button in the header bar. 2026-11-04 00:27:30 +02:00
Lauri Ojansivu f25957dcf3 A full screen button in the header bar, to the right of the logo.
It puts the whole page in full screen with the Fullscreen API (the unprefixed one
and the webkit-prefixed one) and takes it out again; the icon follows the
browser's own events, so Esc is understood. The label is the existing Maximize
translation, so no language file changes. It is drawn only where the browser
offers the API and the page may use it: not on iPhone, not in an iframe without
allowfullscreen. Unit tests drive the pure part with fake documents, including a
refusal; a browser spec checks the position, the label, entering, leaving, Esc,
and that nothing is drawn where the API is not enabled, in Chromium and WebKit.

Thanks to xet7 !
2026-11-04 00:27:28 +02:00
Lauri Ojansivu 984ea93aa8 CHANGELOG: Dependabot alerts 180 and 183-187, and the SQLite membership test fix. 2026-11-04 00:13:58 +02:00
Lauri Ojansivu 2441563008 Speed test: SQLite after the membership test fix (68.3 ms, 4.1x), the Due Cards profile and what was tried.
Thanks to Raymond-Voogt and xet7 !
2026-11-04 00:13:34 +02:00
Lauri Ojansivu f3950b9adc Dependabot alerts 180 and 183-187: minimatch in the other lockfiles, and the Go modules of the embedded benchmark.
minimatch before 11.0.1 (GHSA-g3hv-75wg-m249): npm-packages/exceljs still had
10.2.6 through readdir-glob 3, and the vendored jade lockfile under
meteor-jade-loader had three copies through glob 13 and test-exclude; both get
an override to minimatch 11, as the root lockfile has. A test now reads every
package-lock.json of the repository, not only the reported ones.

The benchmark module of embedded engines pulled klauspost/compress 1.18.0,
golang.org/x/net 0.23.0 and golang/glog 1.0.0 (GHSA-259r-337f-4rfw,
GHSA-5cv4-jp36-h3mw, GHSA-qxp5-gwg8-xv66, GHSA-vvgc-356p-c3xw,
GHSA-6wxm-mpqj-6jpf). They are raised to 1.18.7, 0.62.0 and 1.2.4, and
govulncheck then found more in x/net, x/text and OpenTelemetry, which are raised
too (x/net 0.62.0, x/text 0.43.0, otel 1.42.0): it now reports none. A test
holds every go.mod of the repository at or above these floors.

Thanks to dependabot and xet7 !
2026-11-04 00:12:45 +02:00
Lauri Ojansivu 5e07b8db6c Docs: the speed test, version history and embedded engines READMEs link directories, not README.md files. 2026-11-03 23:59:03 +02:00
Lauri Ojansivu eeeb0dbec9 CHANGELOG: Go databases compared with FerretDB's SQLite. 2026-11-03 23:47:33 +02:00
Lauri Ojansivu 96d56ecc6c Go databases that could be linked into FerretDB, against its SQLite: none is faster where it counts.
A benchmark of its own (docs/Databases/FerretDB/1/SpeedTest/embedded, a Go module,
no cgo) loads the speed test's cards into SQLite set up as FerretDB sets it and
into Pebble, Badger, bbolt, BuntDB, Nutsdb and Clover, and asks each the six
questions WeKan asks of cards, at 15,377 and 153,768 cards. A board's cards take
the same time on every engine that is not slower, because decoding the JSON
dominates; the key-value stores win single reads and writes by microseconds that
a FerretDB call hides; Pebble is 12x slower on fetching a board, Clover 90-190x
slower in total, and bbolt with its default fsync 100-160x slower on writes. The
README accounts for every entry of the awesome-go section.

Thanks to Raymond-Voogt and xet7 !
2026-11-03 23:47:13 +02:00
Lauri Ojansivu aadfd9b6b3 CHANGELOG: Dolt in the speed test and the version history experiment. 2026-11-03 23:19:00 +02:00
Lauri Ojansivu 1c44876beb Speed test: Dolt, and what a database with version history would give WeKan's undo and redo.
Dolt runs under the existing MySQL handler with no new handler and gives the same
answers, but is by far the slowest: 20,470 ms in total, every card query a
2.8 s whole-table scan. The VersionHistory scripts drive WeKan-shaped changes
through FerretDB onto Dolt (a commit per write, undo and redo with DOLT_REVERT,
AS OF, +0.4 ms a write, a conflict when the same document is restored again) and
through the REST API of TerminusDB (author and message per commit, field-level
restore). ClickHouse and StarRocks were tried with the MySQL handler and need a
handler of their own; the README says where each fails.

Thanks to Raymond-Voogt and xet7 !
2026-11-03 23:18:51 +02:00
Lauri Ojansivu bd86b518ea CHANGELOG and tests: the small-scale speed test, pgrust, TiDB and the databases that cannot run FerretDB. 2026-11-03 22:47:16 +02:00
Lauri Ojansivu f576a0cca9 Speed test: TiDB added, and the README says what was tried: pgrust, TiDB, Noria, YugabyteDB, CockroachDB.
TiDB (MySQL protocol, Apache-2.0) runs FerretDB after its metadata table's
generated columns became VIRTUAL, and gives the same answers as MongoDB; it is
a speed test row and opt-in for the conformance run (WEKAN_CONFORMANCE_TIDB=1).
Noria has no JSON type, triggers or generated columns, which the MySQL handler
needs; YugabyteDB refuses a CREATE INDEX on a jsonb expression, which every
FerretDB v1 PostgreSQL index is; CockroachDB is not open source. The README
also records that analyzing a PostgreSQL table after its indexes are created
took the PostgreSQL row from 454 to 145 ms, and that the same on MySQL and
MariaDB did nothing.

Thanks to Raymond-Voogt and xet7 !
2026-11-03 22:45:56 +02:00
Lauri Ojansivu 46c0ad43e8 Speed test: small-scale results ordered fastest to slowest, with times slower, and pgrust.
Results.md now opens with an At a glance table (rank, total, how many times
slower than the fastest, the same for the slowest user), every table is ordered
by its own numbers, the header says the real data size and the real operating
system, and the load table is ordered by the whole load time. pgrust
(PostgreSQL in Rust, malisper/pgrust:v0.4-beta) is one more row through the
same PostgreSQL handler; in the conformance run it is opt-in with
WEKAN_CONFORMANCE_PGRUST=1. The run is 1/20 of a production WeKan so it takes
minutes.

Thanks to Raymond-Voogt and xet7 !
2026-11-03 22:36:56 +02:00
Lauri Ojansivu 499772e4c2 Speed test README: sorting in PostgreSQL was tried and was not faster.
The ten newest of 30,116 users took 52 ms with an ORDER BY in the database and
38-49 ms decoded and sorted in Go, so the change was not kept; the README says
what was measured so it is not repeated.

Thanks to Raymond-Voogt and xet7 !
2026-11-03 21:53:31 +02:00
Lauri Ojansivu 90fe133363 Merge remote-tracking branch 'origin/main' 2026-11-03 21:37:36 +02:00
Lauri Ojansivu b812d36871 CHANGELOG: the speed test README. 2026-11-03 21:24:06 +02:00
Lauri Ojansivu d05a42a5af Speed test README: the FerretDB v1.100-v1.105 and WeKan improvements since the last run.
SQLite sorts and stores JSONB, PostgreSQL uses a GIN index for membership and
pushes down $or, $elemMatch and $and, MySQL and MariaDB read through their
indexes with multikey side tables, and WeKan stopped scanning whole collections
every hour or at every start. Results.md still shows v1.99.0, which the README
now says, and the open items are updated to match.

Thanks to Raymond-Voogt and xet7 !
2026-11-03 21:24:03 +02:00
github-actions[bot] 42507de1d0 Bump versions for v12.92 2026-11-03 18:51:08 +00:00
Lauri Ojansivu 131b0cc509 Prepare v12.92 release 2026-11-03 20:48:47 +02:00
Lauri Ojansivu 93ae5ac6ee Merge remote-tracking branch 'origin/main' 2026-11-03 20:46:48 +02:00
Lauri Ojansivu 34bb91d1f5 CHANGELOG: a missing REST checklist answers a JSON 404. 2026-11-03 20:43:21 +02:00
Lauri Ojansivu d231df56fc REST API: a missing checklist, or one under another card, answers a JSON 404, not an empty 500.
GET /api/boards/:boardId/cards/:cardId/checklists/:checklistId answered 500 with
no body when the checklist did not exist or belonged to another card. It now
answers 404 with the same error text the checklist update and delete routes
use; board access and the parent-card check are unchanged. Route tests drive
the registered handler (found, missing, other card, other board, denied, lookup
failure) and a browser spec reads the checklist over REST and after a reload.

Thanks to AhmedLukman and xet7 !
2026-11-03 20:43:06 +02:00
github-actions[bot] e422ab691e Bump versions for v12.91 2026-11-03 18:01:08 +00:00
Lauri Ojansivu 3f712a0445 Prepare v12.91 release 2026-11-03 19:59:16 +02:00
Lauri Ojansivu d99dadadee Merge remote-tracking branch 'origin/main' 2026-11-03 19:42:04 +02:00
Lauri Ojansivu a58b7bee2c CHANGELOG: code scanning alert 600, a test replacing getLabel with itself. 2026-11-03 19:17:12 +02:00
Lauri Ojansivu 62da564a1e Code scanning #600: a test no longer replaces getLabel with itself, and the guard knows anchored patterns.
tests/boardLabelLookup.test.cjs sliced the getLabel method out of models/boards.js
and then ran .replace(/^getLabel/, 'getLabel') on it, which does nothing: the
slice already starts at getLabel. tests/noIdentityReplacement.test.cjs skipped
every regular expression with an anchor, so it could not see it; a leading ^ or
a trailing $ over plain characters is now compared like any other.

Thanks to GitHub CodeQL and xet7 !
2026-11-03 19:16:44 +02:00
github-actions[bot] 2ada2e2d61 Bump versions for v12.90 2026-11-03 15:58:18 +00:00
Lauri Ojansivu 5de1acf496 Prepare v12.90 release 2026-11-03 17:57:04 +02:00
Lauri Ojansivu 3008bb5606 Merge remote-tracking branch 'origin/main' 2026-11-03 17:55:41 +02:00
Lauri Ojansivu 4f7521ae3c Tigre: the login page background label uses the Tigre word for color, not the Tigrinya one.
Thanks to Raymond-Voogt and xet7 !
2026-11-03 17:31:40 +02:00
Lauri Ojansivu 1c63049913 CHANGELOG: login page background color and a readable support page. 2026-11-03 17:20:07 +02:00
Lauri Ojansivu 6b27764452 Admin Panel: a login page background colour, and a readable layout for the login text and the support page.
Lay-out has a new Login page background color (loginBackgroundColor). Only a
hex colour is accepted, so the value in the CSS custom property can never carry
CSS; the text under the login logo turns white on a dark colour and black on a
light one. The colour is on body only while the sign-in layout shows. The
support page and the text under the login logo get a centred column with sized,
bold headings and space between paragraphs. The label is translated into every
language file.

Thanks to Raymond-Voogt and xet7 !

Fixes #6866,
2026-11-03 17:19:22 +02:00
Lauri Ojansivu ae49937b44 Tests: the Playwright specs expect the accessibility-audit colours and drop below the last checklist row.
The belize and nephritis buttons, the limegreen header, the red-string and
typed-relation lines are now the darker shades that keep their contrast, and
the checklist drag released on the sortable's own bottom edge.
2026-11-03 16:48:44 +02:00
Lauri Ojansivu 7f27ea1f26 CHANGELOG: Show focus outline, and Escape closes the add-list composer. 2026-11-03 16:04:58 +02:00
Lauri Ojansivu 144777ae6b Member Settings: a per-member switch to hide the focus outline.
Change Settings has Show focus outline, on by default: the blue ring round the
focused control is an accessibility feature (WCAG 2.4.7), and it also shows
after a click on a minicard. A member who turns it off - profile.hideFocusOutline,
a toggle method that takes the signed-in user only - gets a class on body that
hides the outline and its halo for them and nobody else. The label is translated
into every language file (best effort; the low-confidence ones are listed in the
changelog).

Thanks to mimZD and xet7 !

Refs #6755,
2026-11-03 16:04:44 +02:00
Lauri Ojansivu f4b5025a7b Escape closes the inline add-list composer.
The composer opened after a list (or in an empty swimlane) stays open after a
list is created, so several can be added, and only its X closed it. Escape now
closes it at the level of an inlined form, with the same reset as the X, and not
on a click elsewhere.

Thanks to mimZD and xet7 !

Fixes #6865,
2026-11-03 15:39:24 +02:00
github-actions[bot] cdcf8ec39d Bump versions for v12.89 2026-11-03 13:35:18 +00:00
Lauri Ojansivu 739f9ed7a0 Prepare v12.89 release 2026-11-03 15:33:40 +02:00
Lauri Ojansivu f06d082e34 Merge remote-tracking branch 'origin/main' 2026-11-03 15:33:13 +02:00
Lauri Ojansivu 4036b16774 CHANGELOG: shortcuts run once on a non-Latin keyboard. 2026-11-03 15:30:41 +02:00
Lauri Ojansivu 0110e84ce5 Keyboard shortcuts run once on a non-Latin layout.
On a Farsi (or Cyrillic) keyboard the d shortcut opened the Due date popup and
closed it at a glance. keyboard.js re-dispatched every key typed on another
script as its Latin key, a leftover from Mousetrap; hotkeys-js resolves the key
from event.code itself, so the shortcut ran twice and the repeat clicked the
same control again, closing it. The re-dispatch is removed, and a test drives
real key events for Latin, Farsi and Cyrillic at the library.

Thanks to mimZD and xet7 !

Fixes #6840,
2026-11-03 15:30:39 +02:00
github-actions[bot] 00a50ae139 Bump versions for v12.88 2026-11-03 12:58:20 +00:00
Lauri Ojansivu c67a67bc9a Prepare v12.88 release 2026-11-03 14:57:20 +02:00
Lauri Ojansivu 0b8a7face3 CHANGELOG: every theme and chosen color is readable; padded REST labels. 2026-11-03 12:37:32 +02:00
Lauri Ojansivu 8b2114cd0f Themes: every board theme and every color a member chooses is readable.
Eight themes (nephritis, pumpkin, moderatepink, strongcyan, limegreen, relax,
corteza, modern) had white text on an accent at 2.1 to 4.3:1: their accents and
hover, selected and emphasis shades are darkened until it reads at 4.5:1. The
filter sidebar's headings, the clear themes' quiet text and cleanlight's
add-dependency link are darker. A color a member chooses is made legible: a
custom theme accent or slide end is darkened under the header's white text, the
text color is moved to read on the theme's surfaces and no longer reaches the
header, labels and primary buttons, a dependency line is drawn at 3:1, Kanri's
tag text is the better of dark and light, and a custom label, card, list or
swimlane color already derives its text. All 25 themes show no colour contrast
failure on the board, the sidebars, Card Settings, the card and its popups.

Thanks to Samunosuke and xet7 !
2026-11-03 12:37:10 +02:00
Lauri Ojansivu 0426c7741a Board labels: a padded name finds the stored label, in every way a label is added.
A label is looked up by name and color before one is added, and the lookup
compared the name as sent while the schema stores it trimmed: a padded ' Bug '
missed the stored 'Bug', and each repeat created another label with the same
name and color. The REST builder now trims, as the pull request did, and so
does the lookup itself, which the card's label form, the importers and the list
sync share. A legacy label stored with padding is still found.

Thanks to AhmedLukman and xet7 !
2026-11-03 12:37:10 +02:00
Lauri Ojansivu c2a28a9233 CHANGELOG: the dark board themes are readable again. 2026-11-03 11:42:09 +02:00
Lauri Ojansivu a6b10e3592 Dark themes: the quiet greys, Card Settings, popups and sidebars are readable again.
The accessibility audit made the quiet text greys dark for every theme, and
old rules never followed the dark themes: Card Settings drew white text on
light grey blocks, popup items, the sidebar's shortcuts row, quiet buttons and
sidebar labels were black on dark, and an !important list header beat each
theme's own. The quiet text is a variable with a light value on the dark
surfaces; Card Settings' sections, popup items and the other rules follow the
theme. axe-core finds no colour contrast failure in cleandark, moderndark, dark
and exodark on the board, sidebar, Card Settings, an opened card and the custom
fields popup.

Thanks to Samunosuke and xet7 !

Fixes #6863,
2026-11-03 11:41:58 +02:00
github-actions[bot] 1ac4d1b1f9 Bump versions for v12.87 2026-11-02 22:34:10 +00:00
Lauri Ojansivu 222492f313 Prepare v12.87 release 2026-11-03 00:33:10 +02:00
Lauri Ojansivu 4f95b10f0d Merge remote-tracking branch 'origin/main' 2026-11-03 00:26:43 +02:00
Lauri Ojansivu 86a18665f0 CHANGELOG: REST single-item GETs answer 404 when nothing is found. 2026-11-03 00:22:26 +02:00
Lauri Ojansivu 1ded34dfdd REST API: a single-item GET that finds nothing answers 404, not 200 with an empty body.
The scoped card GET (/api/boards/:boardId/lists/:listId/cards/:cardId) sent
200 with no body for a missing, wrong-list, wrong-board or archived card. The
list, swimlane, custom field, comment and integration GETs did the same, and
the user GET threw a TypeError for an unknown name. Each now answers 404 after
the board check, so a stranger still learns nothing about which ids exist.

Thanks to AhmedLukman and xet7 !
2026-11-03 00:22:24 +02:00
github-actions[bot] db791e1d40 Bump versions for v12.86 2026-11-02 17:03:48 +00:00
Lauri Ojansivu e8e22dc25e Prepare v12.86 release 2026-11-02 19:02:52 +02:00
Lauri Ojansivu 08d20930de Merge remote-tracking branch 'origin/main' 2026-11-02 18:56:24 +02:00
Lauri Ojansivu 10f244de77 CHANGELOG: daily jobs run once per installation. 2026-11-02 18:49:22 +02:00
Lauri Ojansivu 4e28465964 Daily jobs run once per installation: notification cleanup and the file integrity audit.
The notification cleanup ran at every start and daily on every server, reading
and rewriting the same users 24 times at a rollout; a notification added by
another server between a read and its write was lost. A run now claims a
document per job and day in dailyJobRuns, and the timer looks hourly for an
unclaimed day. The file integrity audit read 'lastRun', checked it, then wrote
it, so two servers could both scan; one atomic update now claims the run.

Continuous backup has no leader: that is documented as single-server, and the
lease it needs is not built, so #6861 item 3 stays open and this commit does
not close it.

Thanks to Raymond-Voogt and xet7 !
2026-11-02 18:49:19 +02:00
github-actions[bot] 2936a682ce Bump versions for v12.85 2026-11-02 15:27:08 +00:00
Lauri Ojansivu ab80aaf9a3 Prepare v12.85 release 2026-11-02 17:26:05 +02:00
Lauri Ojansivu 0fb943b6fb Merge remote-tracking branch 'origin/main' 2026-11-02 17:25:22 +02:00
Lauri Ojansivu 6d8b0c15ec CHANGELOG: a cleared connection pool is a lost connection; the board key backfill uses the key index. 2026-11-02 17:18:17 +02:00
Lauri Ojansivu abc23a7eda Database problems: a cleared connection pool is a lost connection; the board key backfill uses the key index.
collection2 wraps a failed write as 'ValidationError: Failed validation <driver
text>', and the driver's 'Connection pool for <host> was cleared' names a
replica set member that MONGO_URL does not list, so the schema rule matched.
The text is now a lost connection. The board key backfill looked for boards
without a key with an $or including an exists-false branch, a scan of every
board at every start of every server; one $in on the key index finds the same
boards.

Thanks to Raymond-Voogt and xet7 !

Fixes #6860,
2026-11-02 17:17:58 +02:00
Lauri Ojansivu d9aa210012 Accessibility: the results of the fixes in Audit.md, page by page. 2026-11-02 17:17:07 +02:00
Lauri Ojansivu 2e8e1e2ce2 CHANGELOG: the accessibility audit findings are fixed in the default theme. 2026-11-02 16:08:47 +02:00
Lauri Ojansivu c8bcc4817d Accessibility: fix the findings of the axe-core audit in the default theme.
The default theme's blue is #2573a7, so the header's white and #f2f2f2 text
passes WCAG AA; label colours, quiet greys and grey tiles have text that
passes too. The Starred menu, the user menu and initials avatars carry their
visible text in their accessible name. The card's Pomodoro field and the
comment and description editors have a name. A card section heading is an h3
holding a real button with aria-expanded, and its menu and layout toggle sit
beside it. The sign-in form is the main landmark with a level-one title, every
page has a level-one heading, and the shortcuts list is reachable by keyboard.

After the fixes axe-core finds no violation on 22 pages. Spec 45 runs the scan
and tests/accessibilityAuditFixes.test.cjs pins each fault by its shape.

Thanks to xet7 !
2026-11-02 16:08:32 +02:00
Lauri Ojansivu 2dd25c733e Accessibility: the result of an automated axe-core audit of seven pages.
Thanks to xet7 !
2026-11-02 15:03:54 +02:00
github-actions[bot] cb6c6533c5 Bump versions for v12.84 2026-11-02 12:45:02 +00:00
Lauri Ojansivu 292b804474 Prepare v12.84 release 2026-11-02 14:43:46 +02:00
Lauri Ojansivu 623fccf06e Merge remote-tracking branch 'origin/main' 2026-11-02 14:39:48 +02:00
Lauri Ojansivu cfd7ad2e5f Catch two unhandled rejections found by the wider promise lint.
The instance-editing hook looked up the board of an edit without a catch,
and the service worker registration had none.

Thanks to xet7 !
2026-11-02 14:37:12 +02:00
Lauri Ojansivu 762e5a9432 CHANGELOG: npm run lint:promises. 2026-11-02 14:30:59 +02:00
Lauri Ojansivu f1d2a497bc Lint for promises that are not awaited, handled or marked void.
npm run lint:promises runs typescript-eslint's no-floating-promises and
no-misused-promises over models/lib, server/lib and imports/lib, with type
information read from tsconfig.lint.json (nothing is type-checked). The
eleven findings were promises that never reject (their own catch) and now
say so with void, and three checks that used a promise as a boolean.

Thanks to xet7 !
2026-11-02 14:30:49 +02:00
Lauri Ojansivu 2f9c972d46 CHANGELOG: the code-quality findings. 2026-11-02 14:15:17 +02:00
Lauri Ojansivu 582a22c291 Fix GitHub code scanning code-quality findings.
Real faults: a card's original-position description formatted a promise
because it did not await the history; the attachment swipe distances and
two drag-scroll locals were undeclared globals; a file that cannot be read
during a GridFS upload left the upload open; api.py called exit() instead of
sys.exit(); a rules test redefined a property and a security-log test kept
an unreachable branch. Also 31 missing semicolons, unused imports in the
card details, exporters and attachment API, unused Python imports, and
comments on empty except clauses. The list-sync scanner test cut its source
at the last Meteor.startup, which the new index startup of #6855 had moved.

Thanks to GitHub CodeQL and xet7 !
2026-11-02 14:15:02 +02:00
github-actions[bot] d200234535 Bump versions for v12.83 2026-11-02 11:40:29 +00:00
Lauri Ojansivu 8548bae527 Prepare v12.83 release 2026-11-02 13:38:58 +02:00
Lauri Ojansivu d6b2d55c36 Merge remote-tracking branch 'origin/main' 2026-11-02 13:35:16 +02:00
Lauri Ojansivu f7e3533b13 CHANGELOG: LDAP account binding. 2026-11-02 13:32:03 +02:00
Lauri Ojansivu e91c14cd9a LDAP: a reused login name no longer opens the previous owner's account.
An account found by username or e-mail that records another directory
entry's identifier (same attribute, other value) is refused at login and
skipped by the import. An existing account now records its identifier and
its attribute, so LDAP_UNIQUE_IDENTIFIER_FIELD takes effect for it.
LDAP_EMAIL_MATCH_REQUIRE looks users up by username instead of _id, which
never matched. The background sync skips an account whose username matches
several entries instead of binding it to the first.

Thanks to Raymond-Voogt and xet7 !

Fixes #6859,
2026-11-02 13:31:40 +02:00
github-actions[bot] e7439e1d45 Bump versions for v12.82 2026-11-02 11:00:45 +00:00
Lauri Ojansivu 4a34b91ecf Prepare v12.82 release 2026-11-02 12:59:36 +02:00
Lauri Ojansivu d9984d2069 Merge remote-tracking branch 'origin/main' 2026-11-02 12:54:11 +02:00
Lauri Ojansivu e982282954 CHANGELOG: card numbers on a move, a restore and a set number. 2026-11-02 12:42:43 +02:00
Lauri Ojansivu e68938698c Card numbers: a card moved to another board, restored or given a number cannot take one another card has.
#6815 checked the number of every new card, but a card also enters a board's
numbering by an update: a move from the browser, which computed the
destination number from the cards it had loaded (often none, so 1), a restore
from the archive, and a number set by the REST API or an import. A before-update
hook now does what the insert hook does: a taken number is replaced by a fresh
one from the board's counter, a free one is kept and the counter raised past it.

Thanks to Raymond-Voogt and xet7 !

Fixes #6858,
2026-11-02 12:42:33 +02:00
github-actions[bot] 55299a5172 Bump versions for v12.81 2026-11-02 10:22:59 +00:00
Lauri Ojansivu 8bbc6fcda1 Prepare v12.81 release 2026-11-02 12:20:06 +02:00
Lauri Ojansivu 71463fb445 Merge remote-tracking branch 'origin/main' 2026-11-02 12:17:05 +02:00
Lauri Ojansivu b60a1e5a85 CHANGELOG: the database health probe reports a finding once. 2026-11-02 12:15:29 +02:00
Lauri Ojansivu 114e9ecd5a Database health: one server probes the database per period, so a finding is reported once.
Every server read the same MongoDB log and counters and recorded each
finding itself: one collection scan became up to 24 identical rows in Admin
Panel / Problems on 24 replicas. A probe now claims the period in a shared
document, and the state that compares one probe with the next lives there.
A claim that cannot be made at all does not stop the probe.

Thanks to Raymond-Voogt and xet7 !

Fixes #6857,
2026-11-02 12:15:09 +02:00
github-actions[bot] c6739fe452 Bump versions for v12.80 2026-11-02 10:01:31 +00:00
Lauri Ojansivu efd45c651e Prepare v12.80 release 2026-11-02 12:00:25 +02:00
Lauri Ojansivu fdc2c57b8f Merge remote-tracking branch 'origin/main' 2026-11-02 11:52:22 +02:00
Lauri Ojansivu 2c75b95908 CHANGELOG: minimatch 11.0.4 for GHSA-g3hv-75wg-m249. 2026-11-02 11:42:44 +02:00
Lauri Ojansivu a3630d96ce Update minimatch to 11.0.4, GHSA-g3hv-75wg-m249 (Dependabot alerts #180 and #182).
minimatch before 11.0.1 grows its glob parts without bound while it
preprocesses a pattern, then compares them pairwise. archiver (readdir-glob),
@typescript-eslint/typescript-estree and @babel/cli's glob used 10.2.6; they
now use the root minimatch 11.0.4.

Thanks to dependabot and xet7 !
2026-11-02 11:41:52 +02:00
github-actions[bot] db2aafe16c Bump versions for v12.79 2026-11-02 09:35:57 +00:00
Lauri Ojansivu c4b90d08a0 Prepare v12.79 release 2026-11-02 11:34:58 +02:00
Lauri Ojansivu cb5278ddb3 CHANGELOG: a collection-scan row says which query it is. 2026-11-02 11:31:54 +02:00
Lauri Ojansivu bc79de8c57 Database health: a collection scan row names the command, the operators per field and the queryHash.
A row said only which fields a slow query filtered on, which is not enough
to find the query in the code or to run explain(). It now shows the command
(for a slow getMore, the query it continues, instead of 'filter on nothing'),
the operators used on each field - never values - and MongoDB's queryHash.

Thanks to Raymond-Voogt and xet7 !

Fixes #6856,
2026-11-02 11:31:47 +02:00
Lauri Ojansivu 81714cd695 CHANGELOG: the schema re-check and due-date reminders run once. 2026-11-02 11:28:59 +02:00
Lauri Ojansivu d42369e96a Full collection scans run once, not at every start of every server; due-date reminders are sent once.
The startup schema re-check claims a run in _wekan_migration so one server
re-checks while the others skip (a claim older than 2 hours is taken over),
and a run that leaves work unresolved is retried once a day instead of at
every start. The daily due-date reminder scan is claimed per day in
dueReminderRuns, so 24 replicas no longer send 24 reminders per card.
A partial index on lists serves the 15-minute list sync scan, and a sparse
index on boards.dueReminderDays serves the due-date lookup.

Thanks to Raymond-Voogt and xet7 !

Fixes #6855,
2026-11-02 11:28:44 +02:00
Lauri Ojansivu efff590f74 CHANGELOG: the announcement shows on Home and All Boards. 2026-11-02 11:26:12 +02:00
Lauri Ojansivu 64b5762020 Announcement: shown on Home and every All Boards list, not only on a board.
The banner waited for membership of the current board, and Home, Remaining,
Starred, Templates and the other All Boards pages have none. It now shows to
every signed-in user there, and to the members of the open board on a board.
Signed-out visitors still do not see it.

Thanks to titver968 and xet7 !

Fixes #6854,
2026-11-02 11:25:57 +02:00
Lauri Ojansivu c29a59eb95 CHANGELOG: any year by number in the date picker. 2026-11-02 11:17:04 +02:00
Lauri Ojansivu dcaa316a01 Date picker popup: the year is a number field, any year by typing or stepping.
A list of years had to end somewhere. The year is now a whole number
(0 is 1 BC) found by jumping through the calendar in use, so thousands of
years back or ahead are one entry away, up to the range a date holds.

Thanks to brlin-tw and xet7 !

Fixes #6853,
2026-11-02 11:17:02 +02:00
Lauri Ojansivu 7721c5f655 CHANGELOG: year, month and day drop-downs in the date picker. 2026-11-02 11:10:08 +02:00
Lauri Ojansivu 492895200b Date picker popup: year, month and day drop-downs under the calendar.
The heading above the calendar shows the month and year only, not the
YYYY-MM-DD of the selected day. Under the calendar, year, month and day
drop-downs pick any past, present or future date, in every calendar
system.

Thanks to brlin-tw and xet7 !

Fixes #6853,
2026-11-02 11:09:34 +02:00
Lauri Ojansivu 1b346dc465 CHANGELOG: the date picker heading names the year. 2026-11-02 10:50:22 +02:00
Lauri Ojansivu 45c291b406 Date picker popup: the heading names the year as well as the month.
Thanks to brlin-tw and xet7 !

Fixes #6853,
2026-11-02 10:50:13 +02:00
Lauri Ojansivu c3af2a08a9 CHANGELOG: the macOS sandbox keychain and Claude Code token. 2026-11-02 10:16:03 +02:00
Lauri Ojansivu 21c94ce098 Fix macOS Sandbox for VSCodium.
Thanks to xet7 !
2026-11-02 10:14:47 +02:00
Lauri Ojansivu 2d8a28c2f6 Merge remote-tracking branch 'origin/main' 2026-11-02 09:44:15 +02:00
Lauri Ojansivu c58b2896d1 Fix macOS Sandbox for VSCodium.
Thanks to xet7 !
2026-11-02 09:43:32 +02:00
github-actions[bot] 6b98272272 Bump versions for v12.78 2026-11-02 07:14:42 +00:00
Lauri Ojansivu ee22fb1a3b Prepare v12.78 release 2026-11-02 09:13:43 +02:00
Lauri Ojansivu 154c9a6e52 Updated Changelog. 2026-11-02 09:11:57 +02:00
Lauri Ojansivu 121f37be91 Tests: drawing refit, Due Cards board scope, dependency import counts, and SAML's one Issuer.
Changes since October whose commits lacked a unit, negative or browser
test:
- 2929b61c85: a new drawing refits when its window changes, and is left
  alone once drawn on, edited, or zoomed or resized by hand.
- c64b3a3b1d: Due Cards, in "me" and "all users", shows an assigned-only
  member only their assigned cards; the owner sees them all.
- 8a6aeba7d1: the All Boards dependency import says how many lines were
  added, already there and unmatched.
- 5c50f38e25: a signed SAML Response with two Issuers is refused, which
  node-saml 5.1.0 accepted; the same Response with one Issuer logs in.
The browser specs pass in Chromium.

Thanks to xet7 !
2026-11-02 00:54:59 +02:00
Lauri Ojansivu 79c40407d1 Tests: Admin Panel pane titles, audit log alignment, GCS help, Data health "Never", card reports.
October Admin Panel changes whose commits had no test of their own:
- 85af731c1b and 2017f2871f: the Password length and Audit log panes
  name themselves in the title bar, the audit log has its own URL, and
  its cells align to the start - right in Hebrew - with no left/right
  rule left in its CSS.
- e6f7d226ca: no help line of a storage field is shown twice.
- 77956c98ed: Data health's "Never" is its own key, also when an
  administrator translates the List Sync one differently.
- 69f3358154: the card reports stay instance-wide and admin-only.
tests/playwright/specs/admin-pane-titles-and-help.e2e.js passes in
Chromium.

Thanks to xet7 !
2026-11-02 00:31:24 +02:00
Lauri Ojansivu 148d18d456 Tests: OpenSSL 4 decrypt refusal, the Scrum rollover lease, and the backup catch-up scan.
October changes whose commits had no node test of their own:
- ffe49dd2fd: decryptFile() refuses both the OpenSSL 4 and the OpenSSL 3
  wording of a failed authentication as "changed or the key is wrong",
  on any OpenSSL; an unrelated error (ENOSPC) is passed through.
- 0218cd823f: the rollover and batch-job leases are claimed before every
  write, another server's live lease is refused, and each lease field is
  written in one place in the whole tree.
- 8cbe2605fe: on macOS, a stopped engine and one with watching off run no
  catch-up scan.

Thanks to xet7 !
2026-11-02 00:30:57 +02:00
Lauri Ojansivu 15877dd1bc Source server: a local package fork that a prebuilt package implies is loaded first.
ecmascript implies the local dynamic-import fork, so compiling ddp-server,
listed before it in .meteor/versions, stopped the source-loaded server
with "isopack dynamic-import not yet loaded". The loader now loads the
local package asked for and retries. The source server still stops later,
on "Cannot find module 'meteor/accounts-base'"; the bundle server is what
the browser tests ran against.

Thanks to xet7 !
2026-11-02 00:24:55 +02:00
Lauri Ojansivu 3ccb465c3f Tests: the #6755 browser test focuses the minicard link itself.
Chromium does not leave the focus on the minicard after its menu popup
is closed with Escape - the menu's opener is a link without href, which
cannot take the focus back - so the test asserted a state the browser
had not reached. It now focuses the minicard's link, the state of the
report, and checks that d still opens the Due date editor from there.
Passes in Chromium and WebKit.

Thanks to mimZD and xet7 !
2026-11-02 00:24:55 +02:00
Lauri Ojansivu 71d9776b61 CHANGELOG: the Rules page has a Back to the board.
Thanks to mimZD and xet7 !
2026-11-02 00:07:50 +02:00
Lauri Ojansivu 34d9817110 Rules page: Back to the board.
The Rules page lost its Back with its own sidebar; only the trigger, action
and rule details pages kept one, to the rules list, so the way back to the
board was the Board View menu. The rules list and its Workflow, Bots,
Blocks and History views now have a Back link to the board at the top. The
three sub-pages keep their own Back and show no second one.

Thanks to mimZD and xet7 !

Fixes #6852.
2026-11-02 00:07:42 +02:00
Lauri Ojansivu aa939d7bec Tests: CrossBoardExportBleed and UnassignedAttachBleed are in the security regression inventory.
Both are published on the Hall of Fame, so the inventory counts 143 and
names each one's unit and browser regression tests. CrossBoardExportBleed
was published without being added, which failed the whole node-suite run.

Thanks to xet7 !
2026-11-02 00:05:44 +02:00
Lauri Ojansivu 194e5b0a51 CHANGELOG: UnassignedAttachBleed, and shortcuts after a click on a minicard or a closed popup.
Thanks to Javier García Herrera, mimZD and xet7 !
2026-11-02 00:05:10 +02:00
Lauri Ojansivu 532a96da11 Security: UnassignedAttachBleed - attachment API writes obey assigned-only card scope.
An assigned-only member whose card assignment was removed kept the card and
attachment ids and, through POST /api/attachment/upload and
DELETE /api/attachment/delete/:id, still added a file to that card and
soft-deleted its existing one: both asked only for board write access. The
DDP methods api.attachment.upload/delete, and copy and move onto a target
card over REST and DDP, had the same shape.

mayWriteCardAttachment (server/lib/assignedOnlyAttachments.js) applies the
same card rule the ordinary card Delete already used to all eight handlers.
A refusal is recorded in Admin Panel / Problems as UnassignedAttachBleed
(authz.assigned-attachment-write, medium: never disables the account).
tests/unassignedAttachBleed.test.cjs reproduces the reported sequence and
fails if any attachment API write lacks the card check;
tests/playwright/specs/unassigned-attach-bleed.e2e.js replays it over REST.

GHSA-w26g-rc43-q6cm.

Thanks to Javier García Herrera and xet7 !
2026-11-02 00:04:03 +02:00
Lauri Ojansivu ba0de93f79 Keyboard shortcuts keep working after a click on a minicard or a closed popup.
The shortcut filter refused every key while a link or button had the focus.
The minicard is itself a link, so a click on its date badge or its menu left
that link focused, and closing a popup returns focus to the button that
opened it: hovering a card and pressing d then did nothing until a reload.

A focused control now keeps only the keys it uses itself - Enter for any of
them, Space for a button, summary or checkbox, End/Home/arrows for a tab -
so Space still activates a focused button (audit A01) and every other key
is a shortcut again.

Thanks to mimZD and xet7 !

Fixes #6755.
2026-11-02 00:03:54 +02:00
Lauri Ojansivu 9a15491985 CHANGELOG: sign-in errors kept through a redraw, and the Firefox full-run test fixes.
Thanks to xet7 !
2026-11-01 20:35:06 +02:00
Lauri Ojansivu c8ed3e0fd0 Tests: the bulk export popup is opened until it stays, in all five Haitian Creole tests.
Each test navigates to All Boards and at once opens Export all boards.
All Boards is still drawing just after the route changes, and in
EVERYTHING's full Firefox run the popup opened then was closed again: no
header title to read (the first test; it passed in the run before).

All five tests open the popup again until its title is there. The spec in
Firefox, three times beside the running suite: 15 of 15.

Thanks to xet7 !
2026-11-01 18:56:50 +02:00
Lauri Ojansivu 4a9c3f21d2 Tests: the drawing surface's width is waited for, not read once after its viewBox.
The test waited until the drawing's viewBox had grown past the old 800,
then read the surface's box once. The element's layout follows the
viewBox a frame later, and in EVERYTHING's full Firefox run it was still
800 px against more than 1088 expected (it passed in the run before).

It polls the surface's width as well. The spec in Firefox, three times
beside the running suite: 9 of 9.

Thanks to xet7 !
2026-11-01 18:23:13 +02:00
Lauri Ojansivu 3a2fef4dc7 Sign-in: login errors are rendered from template state, and the SAML error test clicks a finished form.
saml-popup-error's first test failed in EVERYTHING's full Firefox run and
2 of 3 times alone: #login-error-message stayed empty after the SAML
button was clicked with a stub that fails at once.

The test clicked #at-saml (force) as soon as Meteor connected, while the
sign-in form was still drawing: the click landed on a button being
replaced, or - in another attempt - below the window ("Element is
outside of the viewport"), and no handler ran. It now waits for the form,
scrolls the button into view and clicks until the error shows. With one
worker both tests pass 6 of 6 in Firefox against a rebuilt bundle.

Found on the way: #login-error-message sits inside `if isLoading`, and
showLoginError wrote into the element - when that block is drawn again
the element is new and empty, so an error shown just before was lost. The
template now renders the message from a ReactiveVar (loginErrorText) as
escaped text, so a redraw keeps it and it can still never be HTML.
oauthProviders.test.cjs pins the same region, the translated codes and
text-only output in that form.

The other sign-in specs (44 files) pass in Firefox with it, apart from
runs where two copies of a settings-changing spec ran at once.

Thanks to xet7 !
2026-11-01 17:49:21 +02:00
Lauri Ojansivu f241681e80 Tests: background notification recovery checks its own mail, not the first one the sink caught.
The SMTP sink captures every mail the shared test server sends while it
listens. The test waited until user2 had exactly one mail, then read
sink.messages[0] - in EVERYTHING's full Firefox run another worker's
comment notification ("Please look", to another user) had arrived first,
so "Frozen recovery body 0" was looked for in the wrong mail.

It reads the mail addressed to user2. The whole spec in Firefox, twice:
24 passed, 2 conditionally skipped.

Thanks to xet7 !
2026-11-01 17:32:09 +02:00
Lauri Ojansivu c71c875649 Tests: the custom field settings popup is opened until its Create button shows.
The String Template help test opened a card and at once its custom field
settings popup. The opened card was still drawing, and in Firefox under
the whole suite the popup sometimes closed again before Create could be
clicked: ve-CC and rup failed in EVERYTHING's full Firefox run, 42
others passed.

The test opens the popup again until its Create button is there. ve-CC,
rup and en, twice each in Firefox beside the running suite: 6 of 6.

Thanks to xet7 !
2026-11-01 17:06:02 +02:00
Lauri Ojansivu 0da86171e9 CHANGELOG: TODO Later - the whole EVERYTHING run has passed in the browsers, conformance and FerretDB.
Thanks to xet7 !
2026-11-01 14:15:06 +02:00
Lauri Ojansivu b80e21fd48 CHANGELOG: Android bundles start again.
Thanks to xet7 !
2026-11-01 14:11:20 +02:00
Lauri Ojansivu 24a7270b7f Android bundles start again: npm 12 runs argon2's and bcrypt's install scripts.
All four Android bundle jobs failed: WeKan crash-looped on

  No native build was found for platform=android arch=x64 runtime=node
  abi=147 ... loaded from: .../meteor/accounts-password/node_modules/argon2

until the 240 s smoke test gave up. Meteor's npm rebuild had printed
"install scripts blocked because they are not covered by allowScripts",
though prepare-bundle-npm.mjs writes argon2 and bcrypt into every
rebuild directory's allowScripts.

npm 12 applies allowScripts to a project's DECLARED dependencies only.
The package.json that prepare-bundle-npm writes there declared none, so
argon2 and bcrypt were "extraneous" and their scripts stayed blocked -
reproduced with npm 12.2.1 on this release's bundle, and with the two
declared the same rebuild runs both install scripts. Linux, macOS and
Windows never noticed: prebuilds load there, and the Windows jobs compile
the two themselves. Android has no prebuild, and check-addon-arch passed
the Linux ones it cannot load.

prepare-bundle-npm.mjs now declares each reviewed installer a rebuild
directory has installed, at its installed version, and leaves a declared
one as it is. The Android build runs it itself first - the Linux bundle
it rebuilds may predate this - and loads argon2 and bcrypt with Android's
Node.js right after the install, so a missing addon stops the build with
its name instead of a smoke-test timeout.

prepareBundleNpm.test.cjs and androidBundles.test.cjs pin both; they fail
on the code before. Not run on Android here: the build needs Termux on
an Android-CPU runner.

Thanks to xet7 !
2026-11-01 14:11:10 +02:00
Lauri Ojansivu 73ea323da1 CHANGELOG: CrossBoardExportBleed, GHSA-8885-xrq4-j47h.
Thanks to ccsimplyspolit and xet7 !
2026-11-01 14:01:07 +02:00
Lauri Ojansivu be6727607c CrossBoardExportBleed: a checklist scope stays on its board, and every export obeys Disable all export and Anonymize export users.
GHSA-8885-xrq4-j47h, reported by ccsimplyspolit, both findings confirmed
in the current code:

1. GET /api/boards/<board>/export?checklistId=<id> (and exportZip)
   selected the scoped checklist and its items by id alone. A checklist
   of another board - a private one too - was streamed with every item
   and its creator, whose profile the anonymization map did not cover.
   Both selectors are now bound to the exported board's cards, and a
   checklist of another board is recorded in Admin Panel / Problems
   (authz.export-checklist-scope).

2. The streaming CSV/TSV export asked neither Disable all export nor
   Anonymize export users. Neither did, it turned out, Excel (board,
   card, all boards), PDF (board and card), the chart exports, the
   calendar feed or the external formats: only the WeKan JSON, ZIP and
   Trello exports did, though the setting promises that "the server
   rejects any export request" and names CSV and Excel.
   - refuseDisabledExport() answers 403 before anything is written, and
     all 13 HTTP export routes call it first; assertExportEnabled() still
     guards the builders. Both record the attempt (authz.export-disabled,
     never blocking the account: a page opened before the switch still
     shows the menus).
   - anonymizeExportIdentities() anonymizes what one exporter writes -
     users, @mentions, requested-by / assigned-by, comments - with the map
     build() uses, board members included. The CSV/TSV stream, Excel,
     PDF, chart exports (forExport: the chart views keep names), the
     calendar feed and every external format call it.

tests/crossBoardExportBleed.test.cjs drives the gate and the anonymizer
and scans the tree: no scoped checklist selected by id alone, no export
route without the gate, no exporter naming people without anonymizing.
All 7 fail on the code before. The new spec
security-cross-board-export.e2e.js replays the reporter's requests and
checks every export route while export is disabled and the CSV and
Kanboard exports while anonymizing; it passes with export-access and
scrum-export-anonymization against a rebuilt bundle.

Thanks to ccsimplyspolit and xet7 !
2026-11-01 14:00:19 +02:00
Lauri Ojansivu 9c5fa760c3 CHANGELOG: glob 14.0.3, checked: no WeKan code loads it, no telemetry, tarballs match the lockfile.
Thanks to dependabot and xet7 !
2026-11-01 13:41:40 +02:00
Lauri Ojansivu 27040c6f2f Merge pull request #6851 from wekan/dependabot/npm_and_yarn/npm_and_yarn-0e36e05768
Bump glob from 13.0.6 to 14.0.3 in the npm_and_yarn group across 1 directory
2026-11-01 13:36:11 +02:00
Lauri Ojansivu c15e1ab4b2 Tests: the Card Settings splitter is dragged once the popup holds still.
The splitter test measured the splitter straight after opening Card
Settings, while the popup was still placing itself. In WebKit the press
landed where the splitter had been and dragged nothing: the pane stayed
526 px against more than 606 expected (the first attempt in EVERYTHING's
WebKit stage; the retry passed).

It now measures once two reads of the splitter and the panes 150 ms apart
agree. WebKit 5 of 5, Chromium 3 of 3.

Thanks to xet7 !
2026-11-01 11:58:06 +02:00
Lauri Ojansivu b676cdc169 CHANGELOG: the WebKit date-range refusal and the language and push fixes of the browser tests.
Thanks to xet7 !
2026-11-01 11:25:20 +02:00
Lauri Ojansivu 4ad13db5ef Filters: the date-range forms are checked by WeKan, not refused by WebKit after Clear.
After Clear emptied a refused range, WebKit kept a half-edited date
segment in the From field and fired `invalid` on it at the next submit -
though its validity read valid - so the form never submitted and the new
range never applied, with no message (card-date-range.e2e.js in WebKit,
also on retry: 0 submit events, `invalid` on .js-card-date-from). The old
Clear handler emptied the fields the same way, so this was there before.

Both range forms are novalidate now. The submit handler already decides
whether a range is valid (rangeRefused), marks the end date invalid with
the reason and says it in the form, so nothing is lost.

filterDateRangeRefusal.test.cjs pins novalidate on both forms. Against a
rebuilt bundle card-date-range and card-movement-filter pass twice each
in WebKit, Firefox and Chromium.

Thanks to xet7 !
2026-11-01 11:24:59 +02:00
Lauri Ojansivu efe0e9287c Tests: a language switch is waited for, and WebKit does not freeze on asking for a push subscription.
Two causes behind most of EVERYTHING's WebKit failures so far, both in the
test pages rather than in WeKan:

- 94 specs switch language with setLanguage and then click at once. The
  client loads that language afterwards and draws everything again, which
  closes a popup opened before that: the board view popup of the flow and
  report help specs (Latin monte-carlo in WebKit; Corsican and Scottish
  Gaelic in earlier Firefox runs) had no entry left to click. The
  fixture's context now wraps Meteor.callAsync in each page so that a
  setLanguage call resolves once <html lang> - which client/lib/i18n.js
  sets in the same flush that redraws - names the language. It waits at
  most 10 s and then resolves anyway, so it can only make a spec patient,
  never fail one. A page of a context a spec makes itself uses
  untilLanguageShown().
- Playwright's WebKit freezes the page's main thread on
  PushManager.getSubscription(): after it not even a timer runs. Member
  Notification Settings asks it on opening, to show whether this device
  gets push (client/lib/webPush.js), so every WebKit spec that opened it
  hung in its next click - notification-activity-mute and the member
  scope of notification-delivery-settings, in every language. In WebKit
  the page now answers as a device with no subscription does. Chromium,
  Firefox and Safari answer the real call; no spec subscribes.

WebKit: those notification specs 10 of 10, and the Latin, Corsican and
Product Backlog specs 13 of 13; Chromium and Firefox 11 of 11 each with
the same subset. playwrightPageInit.test.cjs runs both page scripts on a
fake page: setLanguage waits for the language and keeps its result,
other methods are not held, a page whose Meteor arrives later is
wrapped, the wait is capped; WebKit's getSubscription answers null and
Chromium's and Firefox's are left alone; the fixture installs both.

Thanks to xet7 !
2026-11-01 11:21:45 +02:00
Lauri Ojansivu cfcead6a1a CHANGELOG: the Firefox fixes - date range refusal, history Retry, whiteboard line width - and the browser test fixes.
Thanks to xet7 !
2026-11-01 09:23:34 +02:00
Lauri Ojansivu 40f60582b7 Whiteboard: a line is drawn at its own width, not the whiteboard's 800.
Inside `each strokes` the template drew stroke-width=width, and Spacebars
looks a name up among the template's helpers before the line's own
fields: the width helper is the drawing's width, so every line was drawn
800 wide - one black shape over the whole whiteboard, in every browser.
The lines are mapped with strokeWidth now.

whiteboard.test.cjs: every name the line's path uses is a field of the
line and none is a helper of the template, which would win - it fails on
the code before. whiteboard.e2e.js checks each drawn path is the pen's
width (4) in both browsers.

The same spec drew the second person in another context of the same
browser. In Firefox two contexts share the pointer capture the first pen
takes: one line zig-zagged between both paths and the other page drew
nothing, 4 of 4. The second person now has a browser of their own, as
two people do; both pens are still down at once. Firefox 6 of 6 and
Chromium 4 of 4 against a rebuilt bundle.

Thanks to xet7 !
2026-11-01 09:22:05 +02:00
Lauri Ojansivu 96845f6f4b History recovery: Retry is enabled only once the board lets this person write on it.
After a reload the notice for an unanswered keyboard undo or redo shows
as soon as its board is open - before the board and its members have
arrived. undoRedoLast does nothing while Utils.canModifyBoard() is false,
so a Retry clicked in that moment was dropped without a word and the
notice stayed (history-recovery-notice.e2e.js, Firefox).

Retry is disabled until the retry can act; Forget needs no right and
stays usable. For someone who may only read the board Retry stays
disabled, as it could never do anything for them.

historyRecoveryNotice.test.cjs pins the binding and the guard it mirrors,
and that Forget is not gated - it fails on the code before. The spec
waits for Retry to be enabled before it clicks; the spec passes in
Firefox against a rebuilt bundle.

Thanks to xet7 !
2026-11-01 09:22:05 +02:00
Lauri Ojansivu c5f5ad2338 Filters: a refused date range says why in the form, so Clear still works in Firefox.
The filter sidebar's two date-range forms (card dates, card movement)
reported a reversed range with reportValidity(). Firefox opens that
bubble just under the end date - over the Apply and Clear buttons - and
while the field has focus it takes every click on them: no mousedown and
no click reached the page, twice in a row, so a refused range could not
be cleared at all (card-date-range.e2e.js, 4 of 4 in Firefox; with the
bubble suppressed it passed).

The end date is still marked invalid with the reason (setCustomValidity,
for :invalid and screen readers), and the reason is shown in the form as
a role="alert" line under the buttons. Typing in the range or Clear takes
it back.

filterDateRangeRefusal.test.cjs: both forms report through rangeRefused,
show the line and take it back on input and Clear, and nothing in the
filter sidebar calls reportValidity() - it fails on the code before. The
card-date-range and card-movement-filter specs check the line appears
with the reason, is gone after Clear or an edit, and is absent for an
accepted range; both pass in Firefox against a rebuilt bundle.

Thanks to xet7 !
2026-11-01 09:22:05 +02:00
Lauri Ojansivu 0c8c254dc5 Tests: the viewport matrix cleans up only what it seeded.
viewport-matrix is Chromium-only (device emulation), so in Firefox and
WebKit every test of a viewport is skipped and its beforeAll never runs -
but its afterAll still does, and read board.boardId of a board that was
never seeded: each of the twelve viewports failed in EVERYTHING's Firefox
stage, on the last test of the group, in 0 ms.

afterAll returns when nothing was seeded. Firefox: 132 skipped, none
failed. Chromium: phone-portrait 11 of 11, and its board is cleaned up.

Thanks to xet7 !
2026-11-01 09:09:14 +02:00
Lauri Ojansivu 0a1f8584ee Tests: the board view popup is opened until it stays open for the Product Backlog.
The test switches language and then opens the board, and when the language
arrives the board header draws again and closes a popup opened before
that. In Firefox the click on the board view toggle often came first: no
popup at all, and no Product Backlog entry to open - 2 of 6 runs alone,
the st variant in EVERYTHING's Firefox stage.

The test opens the popup again until its Product Backlog entry is there.
9 of 9 in Firefox (st, en, haw, three times each).

Thanks to xet7 !
2026-11-01 09:01:09 +02:00
Lauri Ojansivu 2042fd5102 Tests: the Blocks drag test grabs the block's body, not its edge.
In Firefox a Khmer label overflows its Blocks block and widens the box the
test measures, so the first point of the block it found, scanning from the
left, was the block's outline - and a press there dragged nothing: km and
km-KH failed every time (4 of 4), every other language passed.

The grab point is now one with the block's own body on both sides of it.
The whole spec in Firefox: 104 of 104, Khmer, Georgian, Fijian,
Kinyarwanda (the long labels the search was written for) and Arabic
right-to-left among them.

Thanks to xet7 !
2026-11-01 08:23:41 +02:00
Lauri Ojansivu 0403e27c62 Tests: the notification delivery sections are opened until they stay open.
The member-scope test switches language just before it opens Notification
Settings, and when the language arrives the popup draws again with every
section closed. Under a whole-suite run in Firefox that came after the
click on the section's summary, so the text checks passed and the select
the test then used was hidden: vi and zh_SG failed, 101 others passed,
and vi passed 3 of 3 alone.

expand() opens the section when it is closed and waits until its content
shows, again if a redraw closes it. vi, zh_SG, en and fi twice each, and
the board-scope, webhook-form and tray tests: all pass.

Thanks to xet7 !
2026-11-01 08:04:55 +02:00
Lauri Ojansivu d9e48a909d Tests: the list-sync specs' mock servers are reachable from WeKan when the browsers run in Docker on macOS.
The list-sync specs answer as Jira or GitLab from a server in the test
process, and WeKan's server fetches from it. With the browsers in Docker
on macOS that server was on the container's loopback, which Docker
Desktop keeps from the host: "fetch failed", and every test of
list-sync-estimate, -planning, -source and -time-estimates failed in
EVERYTHING's Firefox stage.

build.sh already forwarded the test SMTP port from the host into the
container through docker exec; the same forwarder carries any TCP. On
macOS it now also forwards a small pool, 39101-39104 by default
(WEKAN_TEST_REVERSE_PORTS), and names it to the specs, which listen
through helpers/reachable-server.cjs: the first free port of the pool,
or any port when there is none, as before. Linux forwards nothing.

30 of 30 of those specs pass in Firefox in Docker now.
playwrightReachableServer.test.cjs: the pool is read strictly, a port in
use is skipped, no pool listens anywhere, and when every forwarded port
is taken it fails clearly instead of listening where WeKan cannot reach.
playwrightDockerHost.test.cjs: macOS forwards the SMTP port and the pool
(or the one configured), names it to the container and removes its ready
files; Linux forwards nothing.

Thanks to xet7 !
2026-11-01 07:50:40 +02:00
Lauri Ojansivu 0cea141702 Tests: the duplicate relation picker is opened once the card has opened.
The test clicked Add dependency straight after the minicard. Under a
whole-suite run in Firefox that click landed while the card was still
opening, and the picker it opened was closed again when the card's route
and details settled - no popup, and the wait for its type select ran out
(the ve-PP variant; 59 of 60 passed, and it passed 3 of 3 alone).

The test waits for the opened card, then opens the picker again until its
type select shows. 6 of 6 (en, ve-PP, tlh, twice each) beside the suite.

Thanks to xet7 !
2026-11-01 07:21:43 +02:00
Lauri Ojansivu 9188843369 Tests: the checklist drag tests drag once the opened card's rows hold still.
The opened card draws the sections above its checklist for a moment after
it opens, and they push the rows down: in Firefox they moved 50 px between
the test measuring them and releasing the mouse. With handles off the
release landed on item 1's own title and opened its editor, so the titles
read 2,3 (EVERYTHING's Firefox stage); with handles on the enabled drag
dropped item 1 between 2 and 3. 2 of 6 in a row failed that way.

drag() waits until two reads of the rows' positions 150 ms apart agree,
then measures and drags. 10 of 10, both variants five times, while the
whole suite ran beside it.

Thanks to xet7 !
2026-11-01 06:57:18 +02:00
Lauri Ojansivu dbf07aadf9 Tests: the activity recovery report is searched again until no worker lease covers it.
The report is a snapshot taken when the search runs, and the live
one-second recovery worker leases each pending intent while it looks at
it. A search made during that lease shows the seeded orphan as "Delivery
reservation active" - correct - and the page keeps showing it, so the
wait for "Original activity is missing" ran out in EVERYTHING's Firefox
stage (the en variant).

The test searches again until a snapshot falls between leases, up to 30 s,
and then asserts the same status and the disabled Retry. 10 of 10 in
Firefox, every test twice, while the whole suite ran beside it.

Thanks to xet7 !
2026-11-01 06:40:42 +02:00
Lauri Ojansivu e001c4f9c2 CHANGELOG: the Docker test proxy redials an unanswered connect.
Thanks to xet7 !
2026-11-01 06:23:55 +02:00
Lauri Ojansivu ffe207e191 Tests: the Docker test proxy dials the host again when a connect is never answered.
Even on the bridge network, EVERYTHING's Firefox stage lost a test now and
then to a page whose document never came (RuleBleed's cross-board test,
Red Strings, shared templates). Inside the container the cause was in
plain sight: a connect to host.docker.internal:3000 in SYN_SENT after nine
retransmits, while the next connection went straight through. Through the
container's proxy 9 of 1000 requests went unanswered; from the Mac itself
none did.

The proxy no longer waits on such a connect. It reads nothing from the
browser until a connection to the host is up (pauseOnConnect), drops a
dial that has not connected in 1.5 s - a normal one takes milliseconds -
and dials again, five times, so no byte is lost or sent twice. If none
connects the browser's connection is closed rather than left open.

playwrightDockerHostProxy.test.cjs: two unanswered dials, then one that
connects - the request arrives once and is answered, and the abandoned
dials are closed; when every dial is unanswered the client is closed after
exactly the attempts, in well under the old 30-60 s wait.

Thanks to xet7 !
2026-11-01 06:23:34 +02:00
Lauri Ojansivu b2198cb61b CHANGELOG: the Firefox stage's Docker network, host paths and phone invitation test.
Thanks to xet7 !
2026-11-01 06:10:30 +02:00
Lauri Ojansivu a6bec88266 Tests: the phone invitation tile is measured in one layout with its buttons.
43-mobile-allboards read the invitation tile's box, then each control's
box, one round trip at a time. Firefox draws the row above the board list
later than Chromium, and in between it moved the whole list down 29.4 px:
the Decline button (1102-1162) was compared with where the tile had been
(853-1139) and failed, 2 of 2, in EVERYTHING's Firefox stage. The tile was
never clipped - settled, it runs 882-1168 around all three controls.

The tile and its message and two buttons are measured in one layout now,
polled until they hold, and a missing or zero-height control fails too.
Firefox: 3 of 3. With the original #6488 bug put back (the tile forced to
4rem) it still fails, naming the message and both buttons.

Thanks to xet7 !
2026-11-01 06:10:03 +02:00
Lauri Ojansivu 8a1f7a14a9 Tests: the Playwright container sees the repo and the files root at their own host paths.
A spec and the server share files by path. admin-continuous-backup gives
the server a key file it wrote under .tools/tmp, and reads the stream.json
the server wrote to its target - and with the repository mounted at /repo
and the files root at /wekan-files, every such path named a file only one
side had: "The encryption key file cannot be read", and no stream.json, in
both of its tests in EVERYTHING's Firefox stage (the only browser there that
runs in Docker on this Mac, and on Linux arm64 every browser does).

Both are mounted at the path they have on the host now, the specs are told
that path in WEKAN_FILES_PATH, and the in-container script finds the proxy
and the Playwright binaries from its working directory. Both tests pass in
the container now.

playwrightDockerHost.test.cjs drives run_playwright_docker on macOS and
Linux: the repo and the files root are mounted at their own paths,
WEKAN_FILES_PATH and HOME follow, and the function names no container-only
path. It fails on the code before.

Thanks to xet7 !
2026-11-01 06:08:05 +02:00
Lauri Ojansivu 7f948bf438 Tests: the macOS Playwright container uses Docker's bridge network, not the host network.
Three Firefox tests of one EVERYTHING run waited the full 60 s in
waitForMeteor: their page's own document never arrived. Measured from
containers during that run, on Docker Desktop for macOS:

- host network, through the WeKan proxy:          3 of 600 requests lost
- host network, straight to host.docker.internal: 8 of 600 connects never
  completed
- bridge network, through the same proxy:          0 of 1000
- from the Mac itself:                             0 of 300

So it is Docker Desktop's host network, not WeKan and not the proxy. On
macOS nothing needs that network - WeKan and MongoDB are reached through
host.docker.internal and SMTP through docker exec - so the container runs
on the bridge network, where the proxy still serves localhost:3000 and the
browser keeps ROOT_URL's origin. Linux keeps the host network, which is
the real one there.

playwrightDockerHost.test.cjs drives run_playwright_docker with uname
stubbed: Darwin gets bridge and the proxy, Linux gets host and none, and
the function names no network of its own. It fails on the code before.

Thanks to xet7 !
2026-11-01 06:06:04 +02:00
Lauri Ojansivu 6d0ab6cf11 CHANGELOG: continuous backup, single-file builds and the EVERYTHING test fixes.
The seven commits since the Dependabot entries: two bug fixes (continuous
backup watching before its first scan; single-file Mach-O signing and
start/backup waiting for WeKan) and five test-run fixes, grouped by topic.
The short changelog guard is a group of its own now, so the developer-
tooling subsection is grouped throughout. CHANGELOG.md regenerated.

Thanks to xet7 !
2026-11-01 05:47:52 +02:00
Lauri Ojansivu 89b64966c6 Tests: the password minimum specs wait until the server has the policy they wrote.
password-min-length-signup-login.e2e.js writes the password policy straight
into the settings document. The server learns it from its settings observer,
which polls on the standalone test MongoDB EVERYTHING runs: for up to ten
seconds it still answered the old policy. A sign-up form loaded meanwhile kept
the old minimum (6 instead of 10), and an administrator's Set password was
refused under the minimum the test before had left enforced - two failures in
EVERYTHING's Chromium stage that never showed on a dev server, whose MongoDB
is a replica set.

untilServerHas() asks the server (getPasswordPolicy) until it has the policy
the test wrote; the sign-up form is loaded again after it. 6 of 6 with two
workers, each test twice.

Thanks to xet7 !
2026-11-01 05:01:18 +02:00
Lauri Ojansivu d5e3325e87 Tests: Card Settings section order waits for the published board before reading the card.
"with every field allowed, the card draws exactly the Card pane's
sections" writes the board's Scrum visibility straight to the database and
reloads. The board publication's cursor has limit: 1, which Meteor's
change-stream driver does not follow, so it polls: a reload could still be
served the board as it was for up to ten seconds, and the card drew no
Scrum section - failing every time in EVERYTHING's Chromium stage. Live in
the browser the update arrived after 9.8 s; in the app itself a write
through a method re-polls at once, so only this direct write waited. The
test waits for the published board (up to 15 s) before it reads the card.

Thanks to xet7 !
2026-11-01 03:27:46 +02:00
Lauri Ojansivu 897376ba54 Tests: the Board Settings button theme test checks the settings that still have action buttons.
button-theme-foreground.e2e.js opened Board Settings / Swimlane, List and
Card and asked each for its column-actions buttons. Card Settings has had
none since 3e5bc58105 ("Card Settings: a checkbox per section, ...":
card actions live in the card's own section hamburgers), so every theme
failed on Card, in EVERYTHING's Chromium stage. Swimlane and List are
checked, in all four themes, and the test says why Card is not.

Thanks to xet7 !
2026-11-01 03:17:38 +02:00
Lauri Ojansivu 16103f9ad2 Tests: the whole-suite run keeps its test database in .tools/, not beside the checkout.
build.sh and build.bat started the test mongod on :3001 with its dbpath
in the checkout's parent directory (../mongodb-test-3001). That is the
one thing in a test run that was written outside the repository, against
the rule that everything WeKan's scripts write stays in .tools/ - and a
sandboxed editor, which may write only the repository, could not create
it, so EVERYTHING's browser stages had no database to run against. It is
.tools/mongodb-test-<port> now, which git and Meteor already ignore.

toolsDirOnly.test.cjs pins the new path in both scripts and fails on a
test database beside the checkout.

Thanks to xet7 !
2026-11-01 02:51:41 +02:00
Lauri Ojansivu 8cbe2605fe Continuous backup: files are watched before the first scan, and a macOS start catches up once.
The file engine scanned its sources first and started watching them
after, so a change made between the two - after the scan had passed a
file, before the watcher began - waited for the next scan, five minutes
by default. And macOS reports file events through FSEvents, which
delivers nothing for a moment after a watcher is created: an EVERYTHING
run on a loaded Mac lost a log line appended right after the start
(tests/integration/continuousBackup.test.cjs, "the appended log line was
streamed by watching", 4 !== 5).

The watchers start first now; a change seen by the scan and a watcher
too is recorded once, as they share one queue. On macOS one catch-up
scan runs two seconds after the start. Not elsewhere: inotify is
watching when fs.watch returns, and a second full scan of a large store
would double the start-up cost.

A new test, on macOS and Linux, with a watcher that never reports:
watching starts before the first scan; on macOS the catch-up scan
records a change made right after the start; on Linux no second scan
runs. It fails on the code before this change.

Thanks to xet7 !
2026-11-01 02:42:03 +02:00
Lauri Ojansivu f5eb4ecf61 Mac smoke test: a process group's live members come from libproc, not ps.
When macOS refuses to signal a process group (EPERM, which it returns for
a group of zombies), the smoke test checked with `ps` whether any member
was still alive. A Mac that will not let the build run `ps` - "Operation
not permitted", as here, where EVERYTHING's node suites run - failed the
check itself, and with it a correct app: macAppPackaging.test.cjs, every
EVERYTHING run on this machine.

On macOS it asks the kernel through libproc now (proc_listpids for the
group, proc_pidinfo for each member: a zombie's info cannot be read, a
live one's status is not SZOMB), with no process started. Elsewhere it
uses ps exactly as before.

tests/mac-smoke-app.py: the ps cases run as the non-macOS path; on macOS
an EPERM asks libproc and never ps; and a real group - a live child, then
a zombie leader alone - is told apart. macAppPackaging.test.cjs passes,
three runs in a row.

Thanks to xet7 !
2026-11-01 02:32:31 +02:00
Lauri Ojansivu 8d4f4caa2f Single file: a macOS build is signed whatever its target is called, and start/backup return once WeKan runs.
EVERYTHING's node suites run under Meteor's Node.js, which has single
executable applications, so singleFile.test.cjs built and ran a real
single executable - and it failed twice:

- The builder ad-hoc signed only a target named mac*/darwin*. Built from
  this Mac's Node.js under another label (the test builds 'amd64'), the
  file is still a Mach-O binary, the injected archive had invalidated its
  signature, and macOS killed it (SIGKILL) before it printed anything:
  status null. It signs every Mach-O output now, read from the file's
  magic number.
- `start --background` and `backup` returned as soon as they had spawned
  WeKan, before it wrote its pid file - which it does only after reading
  its settings and extracting FerretDB. A `stop` right after them said
  "WeKan was not running" and left it running. Both wait now
  (status.waitUntilStarted) until the pid file names the process they
  started, give up at once if it ended, and say so if it did not start.

singleFile.test.cjs: 24 of 24 under Meteor's Node.js, the real single
executable included; a new test pins waitUntilStarted, and that both
commands use it.

Thanks to xet7 !
2026-11-01 02:23:36 +02:00
dependabot[bot] 9a74138a84 Bump glob
Bumps the npm_and_yarn group with 1 update in the / directory: [glob](https://github.com/isaacs/node-glob).


Updates `glob` from 13.0.6 to 14.0.3
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v13.0.6...v14.0.3)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 14.0.3
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 23:51:54 +00:00
Lauri Ojansivu fa7e447536 CHANGELOG: ten Dependabot updates, checked; node-saml 5.2.0 where logins run; @smithy/core 3.38.1.
Thanks to dependabot and xet7 !
2026-10-31 23:06:40 +02:00
Lauri Ojansivu 05cc2460ea Tests: the short changelog guard holds between releases too.
changelogShort.test.cjs asserted an Upcoming section always, and one
Upcoming in CHANGELOG.md - but right after a release there is none until
the next real entry (CLAUDE.md, Making a release), so it failed after
every release, v12.77 included. With no Upcoming it checks that the
newest release is first and that nothing links an Upcoming that does
not exist; with one, what it checked before.

Thanks to xet7 !
2026-10-31 23:06:08 +02:00
Lauri Ojansivu 5c50f38e25 SAML logins are verified by @node-saml/node-saml 5.2.0, as package.json says.
Dependabot's 5.1.0 -> 5.2.0 bump (#6850) changed the app's package.json,
but a SAML login is verified by the wekan-accounts-saml package's own
copy, pinned by its Npm.depends to 5.1.0 - so nothing that runs changed.
5.2.0 is a hardening release a login relies on: it rejects a message
with more than one Issuer, checks idpIssuer on the Response and its
assertion, rejects a parsed query whose RelayState is not the signed
one, and checks a POST LogoutResponse's status and issuer.

The package pins 5.2.0 now. samlLogin.test.cjs required '5.1.0' in one
place and '^5.1.0' in the other; it requires the package and package.json
to name the SAME version, and at least 5.2.0, so a bump of one alone
fails.

A login roundtrip with the package's own copy (node-saml 5.2.0,
xml-crypto 6.4.0) and WeKan's options: a signed Response and a signed
Assertion log in; a NameID changed after signing, a signature by another
key, an unsigned response, and an answer to a request WeKan never made
are refused. saml-popup-error.e2e.js completes a real signed login in
the browser.

Thanks to xet7 !
2026-10-31 23:06:08 +02:00
Lauri Ojansivu 338afc15f8 @smithy/core 3.38.1: the AWS SDK's logger redaction fix, and the telemetry gate passes again.
The @aws-sdk/client-s3 3.1159.0 bump (#6844) resolved @smithy/core to
3.37.0, which npm marks deprecated: "a bug in custom logger data
redaction" (aws/aws-sdk-js-v3#8393). With a custom logger passed to an
AWS client, sensitive fields nested in a list or map reached the logger
unredacted. @smithy/core 3.24.0 to 3.38.0 have it, so 3.36.2 before the
bump did too; 3.38.1 is the fix.

WeKan's only AWS client is the S3 backup (server/methods/backup.js), and
it passes no logger, so nothing was logged. Updated all the same, in the
lockfile only and within the ranges the SDK already allows - one package,
nothing else moved. Its tarball matches the lockfile's integrity hash,
and compared with 3.37.0 it adds no network host, telemetry, process
spawning or install script. A multipart upload to a local S3 stand-in
still arrives signed AWS4-HMAC-SHA256, whole.

releases/check-telemetry.py --source failed on it: the deprecation text
links github.com, a URL origin new to package-lock.json. It passes now.

Thanks to xet7 !
2026-10-31 23:06:08 +02:00
Lauri Ojansivu dd2082b0ea Merge pull request #6846 from wekan/dependabot/npm_and_yarn/isomorphic-git-1.44.8
Bump isomorphic-git from 1.44.3 to 1.44.8
2026-10-31 22:39:27 +02:00
Lauri Ojansivu 4a86e9c19e Merge pull request #6850 from wekan/dependabot/npm_and_yarn/node-saml/node-saml-5.2.0
Bump @node-saml/node-saml from 5.1.0 to 5.2.0
2026-10-31 22:38:24 +02:00
dependabot[bot] 568cd9b6ec Bump isomorphic-git from 1.44.3 to 1.44.8
Bumps [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) from 1.44.3 to 1.44.8.
- [Release notes](https://github.com/isomorphic-git/isomorphic-git/releases)
- [Commits](https://github.com/isomorphic-git/isomorphic-git/compare/v1.44.3...v1.44.8)

---
updated-dependencies:
- dependency-name: isomorphic-git
  dependency-version: 1.44.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:37:49 +00:00
Lauri Ojansivu fea468471d Merge pull request #6849 from wekan/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-8.72.1
Bump @typescript-eslint/eslint-plugin from 8.72.0 to 8.72.1
2026-10-31 22:37:36 +02:00
Lauri Ojansivu 83199b9af7 Merge pull request #6848 from wekan/dependabot/npm_and_yarn/rspack/dev-server-2.2.2
Bump @rspack/dev-server from 2.2.1 to 2.2.2
2026-10-31 22:37:10 +02:00
Lauri Ojansivu 3839ee698d Merge pull request #6845 from wekan/dependabot/npm_and_yarn/mailparser-3.12.4
Bump mailparser from 3.12.2 to 3.12.4
2026-10-31 22:36:48 +02:00
dependabot[bot] a60b996c92 Bump @node-saml/node-saml from 5.1.0 to 5.2.0
Bumps [@node-saml/node-saml](https://github.com/node-saml/node-saml) from 5.1.0 to 5.2.0.
- [Release notes](https://github.com/node-saml/node-saml/releases)
- [Changelog](https://github.com/node-saml/node-saml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/node-saml/node-saml/compare/v5.1.0...v5.2.0)

---
updated-dependencies:
- dependency-name: "@node-saml/node-saml"
  dependency-version: 5.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:36:39 +00:00
Lauri Ojansivu 1230164c16 Merge pull request #6847 from wekan/dependabot/npm_and_yarn/i18next-26.5.11
Bump i18next from 26.5.4 to 26.5.11
2026-10-31 22:36:29 +02:00
dependabot[bot] 8008f410fe Bump @typescript-eslint/eslint-plugin from 8.72.0 to 8.72.1
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.72.0 to 8.72.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.72.1/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.72.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:36:27 +00:00
Lauri Ojansivu bd90782da2 Merge pull request #6844 from wekan/dependabot/npm_and_yarn/aws-sdk/client-s3-3.1159.0
Bump @aws-sdk/client-s3 from 3.1155.0 to 3.1159.0
2026-10-31 22:35:45 +02:00
dependabot[bot] 5eadc45563 Bump @rspack/dev-server from 2.2.1 to 2.2.2
Bumps [@rspack/dev-server](https://github.com/rstackjs/rspack-dev-server) from 2.2.1 to 2.2.2.
- [Release notes](https://github.com/rstackjs/rspack-dev-server/releases)
- [Commits](https://github.com/rstackjs/rspack-dev-server/compare/v2.2.1...v2.2.2)

---
updated-dependencies:
- dependency-name: "@rspack/dev-server"
  dependency-version: 2.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:35:25 +00:00
Lauri Ojansivu 3785a13f6a Merge pull request #6842 from wekan/dependabot/npm_and_yarn/imapflow-2.9.1
Bump imapflow from 2.8.0 to 2.9.1
2026-10-31 22:35:18 +02:00
Lauri Ojansivu 97cabdeca4 Merge pull request #6843 from wekan/dependabot/github_actions/helm/chart-releaser-action-1.8.0
Bump helm/chart-releaser-action from 1.7.0 to 1.8.0
2026-10-31 22:34:58 +02:00
Lauri Ojansivu 2802326888 Merge pull request #6841 from wekan/dependabot/github_actions/docker/login-action-4.7.0
Bump docker/login-action from 4.6.0 to 4.7.0
2026-10-31 22:34:17 +02:00
dependabot[bot] 1f46af0639 Bump i18next from 26.5.4 to 26.5.11
Bumps [i18next](https://github.com/i18next/i18next) from 26.5.4 to 26.5.11.
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.5.4...v26.5.11)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.5.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:33:51 +00:00
dependabot[bot] 1bcb1da92f Bump mailparser from 3.12.2 to 3.12.4
Bumps [mailparser](https://github.com/nodemailer/mailparser) from 3.12.2 to 3.12.4.
- [Release notes](https://github.com/nodemailer/mailparser/releases)
- [Changelog](https://github.com/nodemailer/mailparser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/mailparser/compare/v3.12.2...v3.12.4)

---
updated-dependencies:
- dependency-name: mailparser
  dependency-version: 3.12.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:33:31 +00:00
dependabot[bot] 067efac7f8 Bump @aws-sdk/client-s3 from 3.1155.0 to 3.1159.0
Bumps [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) from 3.1155.0 to 3.1159.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.1159.0/clients/client-s3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:33:21 +00:00
dependabot[bot] 2bdd16060e Bump imapflow from 2.8.0 to 2.9.1
Bumps [imapflow](https://github.com/postalsys/imapflow) from 2.8.0 to 2.9.1.
- [Release notes](https://github.com/postalsys/imapflow/releases)
- [Changelog](https://github.com/postalsys/imapflow/blob/master/CHANGELOG.md)
- [Commits](https://github.com/postalsys/imapflow/compare/v2.8.0...v2.9.1)

---
updated-dependencies:
- dependency-name: imapflow
  dependency-version: 2.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:32:54 +00:00
dependabot[bot] 433bc62211 Bump helm/chart-releaser-action from 1.7.0 to 1.8.0
Bumps [helm/chart-releaser-action](https://github.com/helm/chart-releaser-action) from 1.7.0 to 1.8.0.
- [Release notes](https://github.com/helm/chart-releaser-action/releases)
- [Commits](https://github.com/helm/chart-releaser-action/compare/cae68fefc6b5f367a0275617c9f83181ba54714f...df287104677278f869ca5480bd260358397cc3f9)

---
updated-dependencies:
- dependency-name: helm/chart-releaser-action
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:32:54 +00:00
dependabot[bot] 29f9c62a64 Bump docker/login-action from 4.6.0 to 4.7.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.6.0 to 4.7.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-31 20:32:36 +00:00
github-actions[bot] 21766cf38d Bump versions for v12.77 2026-10-31 19:47:43 +00:00
Lauri Ojansivu 101cdb7579 Prepare v12.77 release 2026-10-31 21:46:40 +02:00
Lauri Ojansivu 54cf951d9c Merge remote-tracking branch 'origin/main' 2026-10-31 21:46:38 +02:00
Lauri Ojansivu 57a43ee8e1 CHANGELOG: the date-popup guards follow movable popups and the date picker's empty cells.
Thanks to xet7 !
2026-10-31 21:44:13 +02:00
Lauri Ojansivu ff2c0ed7f6 Tests: the date-popup guards follow movable popups and the date picker's empty cells.
Two node guards still described v12.76's behaviour before it changed on
purpose. calendarPopupResize asserted that a popup without a date editor
does not drag; every popup drags by its title bar now (#6839), and with
drag handles on only by its handle - both pinned instead, its mock no
longer answering every selector as if a drag handle were there.
calendarTodayMarker picked a month's days with filter(Boolean), which an
empty cell passes now that it is an object with its own _id (#6838); it
skips the cells marked empty.

Thanks to xet7 !
2026-10-31 21:44:11 +02:00
645 changed files with 16982 additions and 1538 deletions

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"project": [
"./tsconfig.lint.json"
]
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error"
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ jobs:
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
+1 -1
View File
@@ -32,6 +32,6 @@ jobs:
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Run chart-releaser
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
uses: helm/chart-releaser-action@df287104677278f869ca5480bd260358397cc3f9 # v1.8.0
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+35 -9
View File
@@ -26,7 +26,7 @@ Work out which you are from the git identity (the next section says how), then:
| Who | Where the work goes |
| --- | --- |
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
| **A contributor** — anybody else | **A branch in your own fork, then one pull request per feature or fix** for the maintainer to review. Never a commit on `main`, and no changelog changes. |
Nothing else is a third option. If a tool, a task runner or an agent harness offers
some other arrangement — a topic branch for the maintainer, a direct push for a
@@ -41,7 +41,9 @@ released section links a commit that release does not contain.
The contributor's half is review. Nobody but the maintainer commits to `main` in
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
the only place it can be discussed before it lands.
the only place it can be discussed before it lands. That is why each pull request
holds one feature or one fix, and leaves the changelogs to the maintainer: see
"Contributor mode" below.
Check which branch you are on before committing:
@@ -143,12 +145,29 @@ git config user.name && git config user.email
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
steps** below are available to a human maintainer only: an AI stops after
committing locally and never runs a step that pushes or publishes.
**Maintainer mode is the only mode that commits directly and the only mode that
updates changelogs**, in WeKan and in the maintainer's repositories under
`.tools/` alike. It applies to the changes the WeKan maintainer makes, and to the
changes of an agent working as the maintainer according to this file.
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
own — a person, or an AI that files pull requests on its own account. Then: do
**not** commit to `main` and do **not** run any release/publishing step. Make the
changes on a branch and open a **pull request** for the maintainer to review. The
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
instructions below are **maintainer-only and do not apply to you**.
**not** commit directly to the repository or to `main`, and do **not** run any
release/publishing step. Make the changes on a branch and open a **pull request**
for the maintainer to review. The "commit as Lauri Ojansivu", "commit directly to
`main`", changelog and release instructions below are **maintainer-only and do
not apply to you**.
Every pull request, to wekan/wekan or to a maintainer's repository under `.tools/`,
follows two more rules:
- **One feature or one fix per pull request.** Changes share a pull request only
when they are related to that one feature or fix. Anything unrelated, however
small, goes in a pull request of its own, so each can be reviewed, merged or
declined without holding up the others.
- **No changelog changes.** Do not edit `CHANGELOG-big.md`, `CHANGELOG.md`, or the
`CHANGELOG.md` or `CHANGELOG` of a repository under `.tools/` — not an Upcoming
entry, not TODO Later. Describe the change in the pull request instead. The
maintainer writes the entry after merging, when there is a commit for it to link.
Who that pull request credits follows the attribution rule above, and the two
cases differ:
@@ -171,8 +190,8 @@ The one repository under `.tools/` that is somebody else's is `sandstorm-io/sand
cloned for reference only.
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
author, updating changelogs, and the entire "Making a release" / publishing flow) applies
only in maintainer mode. The rest (translation policy, tests, validating from code) is
good practice for everyone.
## Process: "Fix open issues"
@@ -206,6 +225,9 @@ When asked to fix an open issue (one issue at a time):
Fixes #1234,
```
A contributor instead commits on a branch for one pull request that fixes this
one issue, with no changelog changes (see "Contributor mode" above).
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.
@@ -407,6 +429,10 @@ directly after the merge.
### CHANGELOG
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
section is for the maintainer and for an agent working as the maintainer, in WeKan
and in the repositories under `.tools/`. A pull request contributor changes no
changelog in any of them (see "Contributor mode" above).
- **Every repository writes its changelog in the format that repository's own file
already uses.** Open its changelog, read the entries above the place you are
adding one, and match them — never import another project's shape into it. The
@@ -817,7 +843,7 @@ have cost a released section its accuracy:
### Security issues
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
entries (maintainer mode only, as with every changelog change), and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
subdirectory `index.html` like previous security issues.
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
fault cannot exist ANYWHERE in the codebase — not just at the place it was
+1784 -7
View File
File diff suppressed because it is too large. Load diff
+30 -33
View File
@@ -5,50 +5,47 @@ each one changed and the project status are in
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
make changes there.
# v12.76 2026-10-31 WeKan ® release
# v12.95 2026-11-05 WeKan ® release
Security features: a **password policy** with an optional
breached-password check, **Admin Panel / Problems / Password length**,
**compulsory two-factor authentication**, sign-out of **idle sessions** and of
every session after a password change, an **audit log** of what administrators
did, **HSTS**, a cross-site request guard, and **S3 encryption at rest**.
**Sign-up** works again; the date picker has **Today**, the whole card title
edits and **every popup can be moved**; and an old **Sandstorm** grain migrated
from MongoDB 3 shows its boards again.
**Continuous backup** now runs on one server at a time, with a
lease, when WeKan has several servers. Missing-board **exports** and REST
updates of missing **integrations**, **users**, **organizations** and **teams**
answer 404 instead of 200, the **minicard** shows a picked Requested by and
Assigned by, a card stops offering the attachment "+" when uploads are blocked,
and the Docker image's **nodemailer**, **body-parser**, **qs**, **uuid** and
**postcss-selector-parser** are raised past the advisories a Grype scan found.
[All changes in v12.76](CHANGELOG-big.md#v1276-2026-10-31-wekan--release)
[All changes in v12.95](CHANGELOG-big.md#v1295-2026-11-05-wekan--release)
# v12.75 2026-10-30 WeKan ® release
# v12.94 2026-11-04 WeKan ® release
Changing a password signs the account out in every other browser,
and now a test proves it in two real browsers.
On an iPad the **opened card covers the header bar** instead of
sitting
behind it, and the speed test measures where the time goes when a WeKan board is
opened, on FerretDB and on MongoDB.
[All changes in v12.75](CHANGELOG-big.md#v1275-2026-10-30-wekan--release)
[All changes in v12.94](CHANGELOG-big.md#v1294-2026-11-04-wekan--release)
# v12.74 2026-10-30 WeKan ® release
# v12.93 2026-11-04 WeKan ® release
**Caddy** and **FerretDB**, the Go servers WeKan ships, no longer
open MPTCP listeners, which Linux kernel bug **CVE-2026-98432** is reachable
through. The real fix is a patched host kernel.
The database **speed test** has a new small-scale result, ordered
from fastest to slowest with how many times slower each database is, and two
more databases: **pgrust** (PostgreSQL in Rust) and **TiDB**. Noria, YugabyteDB
and CockroachDB were looked at and cannot be used, for reasons the README gives.
[All changes in v12.74](CHANGELOG-big.md#v1274-2026-10-30-wekan--release)
[All changes in v12.93](CHANGELOG-big.md#v1293-2026-11-04-wekan--release)
# v12.73 2026-10-30 WeKan ® release
# v12.92 2026-11-03 WeKan ® release
**CHANGELOG.md** is now short: the In short of the upcoming
release and of the five newest releases, in plain Markdown. The status and every
change of each release are in **CHANGELOG-big.md**, which is the one that is
written. **CodeQL** alert #598 in the Sandstorm host test is fixed, and the
release script pulls before releasing, so a moved main no longer stops it.
A missing **REST checklist** answers a JSON 404 instead of an
empty 500.
[All changes in v12.73](CHANGELOG-big.md#v1273-2026-10-30-wekan--release)
[All changes in v12.92](CHANGELOG-big.md#v1292-2026-11-03-wekan--release)
# v12.72 2026-10-30 WeKan ® release
# v12.91 2026-11-03 WeKan ® release
A card link can no longer launch an operating-system handler with
a code-execution history, such as **ms-msdt** (Follina) or **telnet**, even if
an administrator lists it, and an attempt to write one shows in **Admin Panel →
Problems**. The **Apache** guide follows the 2024 Confusion Attacks, and the
Sandstorm store build publishes to **both app markets**.
A **GitHub CodeQL** finding in a test is fixed, and the guard
against
replacing text with itself now also sees patterns with a `^` or `$` anchor.
[All changes in v12.72](CHANGELOG-big.md#v1272-2026-10-30-wekan--release)
[All changes in v12.91](CHANGELOG-big.md#v1291-2026-11-03-wekan--release)
+35 -9
View File
@@ -26,7 +26,7 @@ Work out which you are from the git identity (the next section says how), then:
| Who | Where the work goes |
| --- | --- |
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
| **A contributor** — anybody else | **A branch in your own fork, then one pull request per feature or fix** for the maintainer to review. Never a commit on `main`, and no changelog changes. |
Nothing else is a third option. If a tool, a task runner or an agent harness offers
some other arrangement — a topic branch for the maintainer, a direct push for a
@@ -41,7 +41,9 @@ released section links a commit that release does not contain.
The contributor's half is review. Nobody but the maintainer commits to `main` in
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
the only place it can be discussed before it lands.
the only place it can be discussed before it lands. That is why each pull request
holds one feature or one fix, and leaves the changelogs to the maintainer: see
"Contributor mode" below.
Check which branch you are on before committing:
@@ -143,12 +145,29 @@ git config user.name && git config user.email
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
steps** below are available to a human maintainer only: an AI stops after
committing locally and never runs a step that pushes or publishes.
**Maintainer mode is the only mode that commits directly and the only mode that
updates changelogs**, in WeKan and in the maintainer's repositories under
`.tools/` alike. It applies to the changes the WeKan maintainer makes, and to the
changes of an agent working as the maintainer according to this file.
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
own — a person, or an AI that files pull requests on its own account. Then: do
**not** commit to `main` and do **not** run any release/publishing step. Make the
changes on a branch and open a **pull request** for the maintainer to review. The
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
instructions below are **maintainer-only and do not apply to you**.
**not** commit directly to the repository or to `main`, and do **not** run any
release/publishing step. Make the changes on a branch and open a **pull request**
for the maintainer to review. The "commit as Lauri Ojansivu", "commit directly to
`main`", changelog and release instructions below are **maintainer-only and do
not apply to you**.
Every pull request, to wekan/wekan or to a maintainer's repository under `.tools/`,
follows two more rules:
- **One feature or one fix per pull request.** Changes share a pull request only
when they are related to that one feature or fix. Anything unrelated, however
small, goes in a pull request of its own, so each can be reviewed, merged or
declined without holding up the others.
- **No changelog changes.** Do not edit `CHANGELOG-big.md`, `CHANGELOG.md`, or the
`CHANGELOG.md` or `CHANGELOG` of a repository under `.tools/` — not an Upcoming
entry, not TODO Later. Describe the change in the pull request instead. The
maintainer writes the entry after merging, when there is a commit for it to link.
Who that pull request credits follows the attribution rule above, and the two
cases differ:
@@ -171,8 +190,8 @@ The one repository under `.tools/` that is somebody else's is `sandstorm-io/sand
cloned for reference only.
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
author, updating changelogs, and the entire "Making a release" / publishing flow) applies
only in maintainer mode. The rest (translation policy, tests, validating from code) is
good practice for everyone.
## Process: "Fix open issues"
@@ -206,6 +225,9 @@ When asked to fix an open issue (one issue at a time):
Fixes #1234,
```
A contributor instead commits on a branch for one pull request that fixes this
one issue, with no changelog changes (see "Contributor mode" above).
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.
@@ -407,6 +429,10 @@ directly after the merge.
### CHANGELOG
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
section is for the maintainer and for an agent working as the maintainer, in WeKan
and in the repositories under `.tools/`. A pull request contributor changes no
changelog in any of them (see "Contributor mode" above).
- **Every repository writes its changelog in the format that repository's own file
already uses.** Open its changelog, read the entries above the place you are
adding one, and match them — never import another project's shape into it. The
@@ -817,7 +843,7 @@ have cost a released section its accuracy:
### Security issues
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
entries (maintainer mode only, as with every changelog change), and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
subdirectory `index.html` like previous security issues.
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
fault cannot exist ANYWHERE in the codebase — not just at the place it was
+2 -2
View File
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=12.76
ARG VERSION=12.95
ARG DEBIAN_FRONTEND=noninteractive
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
@@ -151,7 +151,7 @@ ENV \
NODE_VERSION=v26.13.0 \
METEOR_RELEASE=METEOR@3.6-rc.1 \
USE_EDGE=false \
NPM_VERSION=12.2.1 \
NPM_VERSION=12.2.2 \
SRC_PATH=./ \
WITH_API=true \
MONGO_OPLOG_URL="" \
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v12.76.0"
appVersion: "v12.95.0"
files:
userUploads:
- README.md
+43 -50
View File
@@ -8,13 +8,6 @@
# TODO:
# addcustomfieldtoboard: There is error: Settings must be object. So adding does not work yet.
try:
# python 3
from urllib.parse import urlencode
except ImportError:
# python 2
from urllib import urlencode
import json
import requests
import sys
@@ -163,7 +156,7 @@ If *nix: chmod +x api.py => ./api.py users
if arguments == 0:
print(syntax)
exit
sys.exit()
# TODO:
# print(" python3 api.py attachmentjson BOARDID ATTACHMENTID # One attachment as JSON base64")
@@ -329,7 +322,7 @@ if arguments == 8:
if new_card_id:
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + new_card_id
put_data = {'labelIds': labelIds}
body = requests.put(edcard, data=put_data, headers=headers)
requests.put(edcard, data=put_data, headers=headers)
print("=== EDIT CARD ===\n")
body = requests.get(edcard, headers=headers)
data2 = body.text.replace('}', "}\n")
@@ -372,7 +365,7 @@ if arguments == 6:
print(edcard)
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
put_data = {'title': '{}'.format(newcardtitle), 'description': '{}'.format(newcarddescription)}
body = requests.put(edcard, data=put_data, headers=headers)
requests.put(edcard, data=put_data, headers=headers)
print("=== EDIT CARD ===\n")
body = requests.get(edcard, headers=headers)
data2 = body.text.replace('}',"}\n")
@@ -411,7 +404,7 @@ if arguments == 5:
print(edcard)
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
put_data = {'labelIds': labelIds}
body = requests.put(edcard, data=put_data, headers=headers)
requests.put(edcard, data=put_data, headers=headers)
print("=== ADD LABEL ===\n")
body = requests.get(edcard, headers=headers)
data2 = body.text.replace('}',"}\n")
@@ -437,7 +430,7 @@ if arguments == 5:
print(edcard)
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
put_data = {'color': '{}'.format(newcolor)}
body = requests.put(edcard, data=put_data, headers=headers)
requests.put(edcard, data=put_data, headers=headers)
print("=== EDIT CARD COLOR ===\n")
body = requests.get(edcard, headers=headers)
data2 = body.text.replace('}', "}\n")
@@ -456,7 +449,7 @@ if arguments >= 4:
print(edlist)
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
put_data = {'title': '{}'.format(newlisttitle)}
body = requests.put(edlist, data=put_data, headers=headers)
requests.put(edlist, data=put_data, headers=headers)
print("=== EDIT LIST ===\n")
body = requests.get(edlist, headers=headers)
data2 = body.text.replace('}', "}\n")
@@ -482,7 +475,7 @@ if arguments >= 4:
print(edlist)
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
put_data = {'color': '{}'.format(newcolor)}
body = requests.put(edlist, data=put_data, headers=headers)
requests.put(edlist, data=put_data, headers=headers)
print("=== EDIT LIST COLOR ===\n")
body = requests.get(edlist, headers=headers)
data2 = body.text.replace('}', "}\n")
@@ -1050,7 +1043,7 @@ if arguments >= 1:
if arguments < 6:
print("Usage: python3 api.py uploadattachment BOARDID SWIMLANEID LISTID CARDID FILEPATH [STORAGE_BACKEND]")
print("Storage backends: fs, gridfs, s3")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
swimlaneid = sys.argv[3]
@@ -1064,7 +1057,7 @@ if arguments >= 1:
import os, mimetypes
if not os.path.isfile(filepath):
print(f"Error: File '{filepath}' not found")
exit(1)
sys.exit(1)
filename = os.path.basename(filepath)
file_type = mimetypes.guess_type(filepath)[0] or 'application/octet-stream'
params = {'boardId': boardid, 'swimlaneId': swimlaneid, 'listId': listid, 'cardId': cardid,
@@ -1092,7 +1085,7 @@ if arguments >= 1:
# ------- DOWNLOAD ATTACHMENT START -----------
if arguments < 3:
print("Usage: python3 api.py downloadattachment ATTACHMENTID OUTPUTPATH")
exit(1)
sys.exit(1)
attachmentid = sys.argv[2]
outputpath = sys.argv[3]
@@ -1121,7 +1114,7 @@ if arguments >= 1:
# ------- ATTACHMENT INFO START -----------
if arguments < 2:
print("Usage: python3 api.py attachmentinfo ATTACHMENTID")
exit(1)
sys.exit(1)
attachmentid = sys.argv[2]
@@ -1160,7 +1153,7 @@ if arguments >= 1:
# ------- LIST CARD ATTACHMENTS START -----------
if arguments < 5:
print("Usage: python3 api.py listcardattachments BOARDID SWIMLANEID LISTID CARDID")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
swimlaneid = sys.argv[3]
@@ -1199,7 +1192,7 @@ if arguments >= 1:
# ------- COPY/MOVE ATTACHMENT START -----------
if arguments < 6:
print("Usage: python3 api.py copymoveattachment ATTACHMENTID TARGETBOARDID TARGETSWIMLANEID TARGETLISTID TARGETCARDID [copy|move]")
exit(1)
sys.exit(1)
attachmentid = sys.argv[2]
targetboardid = sys.argv[3]
@@ -1210,7 +1203,7 @@ if arguments >= 1:
if operation not in ['copy', 'move']:
print("Operation must be 'copy' or 'move'")
exit(1)
sys.exit(1)
# Prepare request data
request_data = {
@@ -1253,7 +1246,7 @@ if arguments >= 1:
# ------- DELETE ATTACHMENT START -----------
if arguments < 2:
print("Usage: python3 api.py deleteattachment ATTACHMENTID")
exit(1)
sys.exit(1)
attachmentid = sys.argv[2]
@@ -1322,7 +1315,7 @@ if arguments >= 1:
# ------- ADD BOARD MEMBER START -----------
if arguments < 3:
print("Usage: python3 api.py addboardmember BOARDID USERID ROLE")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
userid = sys.argv[3]
role = sys.argv[4] if arguments > 3 else 'normal'
@@ -1339,7 +1332,7 @@ if arguments >= 1:
# ------- REMOVE BOARD MEMBER START -----------
if arguments < 3:
print("Usage: python3 api.py removeboardmember BOARDID USERID")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
userid = sys.argv[3]
remove_member_url = wekanurl + apiboards + boardid + s + 'members' + s + userid + s + 'remove'
@@ -1354,7 +1347,7 @@ if arguments >= 1:
# ------- SET BOARD MEMBER ROLE START -----------
if arguments < 4:
print("Usage: python3 api.py setboardmemberrole BOARDID USERID ROLE")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
userid = sys.argv[3]
role = sys.argv[4]
@@ -1370,7 +1363,7 @@ if arguments >= 1:
# ------- LIST BOARD DOMAINS START -----------
if arguments < 2:
print("Usage: python3 api.py boarddomains BOARDID")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
domains_url = wekanurl + apiboards + boardid + s + 'domains'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
@@ -1383,7 +1376,7 @@ if arguments >= 1:
# ------- ADD BOARD DOMAIN START -----------
if arguments < 3:
print("Usage: python3 api.py addboarddomain BOARDID DOMAIN")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
domain = sys.argv[3]
add_domain_url = wekanurl + apiboards + boardid + s + 'domains'
@@ -1398,7 +1391,7 @@ if arguments >= 1:
# ------- REMOVE BOARD DOMAIN START -----------
if arguments < 3:
print("Usage: python3 api.py removeboarddomain BOARDID DOMAIN")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
domain = sys.argv[3]
remove_domain_url = wekanurl + apiboards + boardid + s + 'domains' + s + domain
@@ -1413,7 +1406,7 @@ if arguments >= 1:
if arguments < 4:
print("Usage: python3 api.py {} BOARDID LISTID CARDID IDS".format(sys.argv[1]))
print("IDS = comma-separated userIds, or '' to clear")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
@@ -1435,7 +1428,7 @@ if arguments >= 1:
print("Usage: python3 api.py setcarddate BOARDID LISTID CARDID DATETYPE DATEVALUE")
print("DATETYPE: received, start, due, end")
print("DATEVALUE: ISO 8601, e.g. 2026-06-07T12:00:00.000Z")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
@@ -1453,7 +1446,7 @@ if arguments >= 1:
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
put_data = {datemap[datetype]: datevalue}
body = requests.put(edcard, data=put_data, headers=headers)
requests.put(edcard, data=put_data, headers=headers)
print("=== SET CARD DATE {} ===\n".format(datetype.upper()))
print(body.text)
# ------- SET CARD DATE END -----------
@@ -1463,7 +1456,7 @@ if arguments >= 1:
if arguments < 4:
print("Usage: python3 api.py setcardlabels BOARDID LISTID CARDID LABELIDS")
print("LABELIDS = comma-separated labelIds, or '' to clear")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
@@ -1483,7 +1476,7 @@ if arguments >= 1:
# ------- MOVE CARD START -----------
if arguments < 7:
print("Usage: python3 api.py movecard BOARDID LISTID CARDID NEWBOARDID NEWSWIMLANEID NEWLISTID")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
@@ -1497,7 +1490,7 @@ if arguments >= 1:
'newSwimlaneId': newswimlaneid,
'newListId': newlistid,
}
body = requests.put(edcard, data=put_data, headers=headers)
requests.put(edcard, data=put_data, headers=headers)
print("=== MOVE CARD ===\n")
print(body.text)
# ------- MOVE CARD END -----------
@@ -1508,7 +1501,7 @@ if arguments >= 1:
print("Usage: python3 api.py bulkaddcards BOARDID LISTID AUTHORID SWIMLANEID CARDSJSONFILE")
print("CARDSJSONFILE = JSON file with a list of titles, e.g. [\"Card A\", \"Card B\"],")
print(" or a list of card objects [{\"title\": \"Card A\", \"description\": \"...\"}, ...]")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
authorid = sys.argv[4]
@@ -1516,16 +1509,16 @@ if arguments >= 1:
cardsjsonfile = sys.argv[6] if arguments > 5 else None
if not cardsjsonfile:
print("CARDSJSONFILE is required")
exit(1)
sys.exit(1)
try:
with open(cardsjsonfile, 'r') as f:
raw_cards = json.load(f)
except Exception as e:
print("Error reading CARDSJSONFILE: {}".format(e))
exit(1)
sys.exit(1)
if not isinstance(raw_cards, list) or len(raw_cards) == 0:
print("CARDSJSONFILE must contain a non-empty JSON list")
exit(1)
sys.exit(1)
cards = []
for item in raw_cards:
if isinstance(item, str):
@@ -1534,7 +1527,7 @@ if arguments >= 1:
cards.append(item)
else:
print("Each card must be a string (title) or an object")
exit(1)
sys.exit(1)
bulk_url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + 'bulk'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
post_data = {'authorId': authorid, 'swimlaneId': swimlaneid, 'cards': cards}
@@ -1548,7 +1541,7 @@ if arguments >= 1:
if arguments < 3:
print("Usage: python3 api.py bulkdeletecards BOARDID CARDIDS")
print("CARDIDS = comma-separated cardIds")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
cardids_raw = sys.argv[3]
cardids = [x for x in cardids_raw.split(',') if x]
@@ -1565,7 +1558,7 @@ if arguments >= 1:
if arguments < 5:
print("Usage: python3 api.py bulkcardlabels BOARDID CARDIDS ADDLABELIDS REMOVELABELIDS")
print("CARDIDS / ADDLABELIDS / REMOVELABELIDS = comma-separated, or '' for none")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
cardids = [x for x in sys.argv[3].split(',') if x]
add_labelids = [x for x in sys.argv[4].split(',') if x]
@@ -1582,7 +1575,7 @@ if arguments >= 1:
# ------- CREATE LINKED CARD START -----------
if arguments < 6:
print("Usage: python3 api.py linkcard BOARDID LISTID SWIMLANEID AUTHORID LINKEDCARDID")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
swimlaneid = sys.argv[4]
@@ -1600,7 +1593,7 @@ if arguments >= 1:
# ------- ADD/REMOVE CARD MEMBER / ASSIGNEE START -----------
if arguments < 5:
print("Usage: python3 api.py {} BOARDID LISTID CARDID USERID".format(sys.argv[1]))
exit(1)
sys.exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
@@ -1620,14 +1613,14 @@ if arguments >= 1:
# ------- COPY CARD START -----------
if arguments < 4:
print("Usage: python3 api.py copycard BOARDID LISTID CARDID TOSWIMLANEID [TOBOARDID] [TOLISTID] [POSITION]")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
toswimlaneid = sys.argv[5] if arguments > 4 else None
if not toswimlaneid:
print("TOSWIMLANEID is required")
exit(1)
sys.exit(1)
toboardid = sys.argv[6] if arguments > 5 else boardid
tolistid = sys.argv[7] if arguments > 6 else listid
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid + s + 'copy'
@@ -1644,7 +1637,7 @@ if arguments >= 1:
# ------- COPY/MOVE SWIMLANE START -----------
if arguments < 3:
print("Usage: python3 api.py {} BOARDID SWIMLANEID [TOBOARDID] [POSITION]".format(sys.argv[1]))
exit(1)
sys.exit(1)
boardid = sys.argv[2]
swimlaneid = sys.argv[3]
toboardid = sys.argv[4] if arguments > 3 else boardid
@@ -1663,7 +1656,7 @@ if arguments >= 1:
# ------- COPY/MOVE LIST START -----------
if arguments < 3:
print("Usage: python3 api.py {} BOARDID LISTID [TOSWIMLANEID] [TOBOARDID] [POSITION]".format(sys.argv[1]))
exit(1)
sys.exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
toswimlaneid = sys.argv[4] if arguments > 3 else None
@@ -1702,7 +1695,7 @@ if arguments >= 1:
# ------- GET CARD SETTINGS START -----------
if arguments < 2:
print("Usage: python3 api.py getcardsettings BOARDID")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
url = wekanurl + apiboards + boardid + s + 'cardSettings'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
@@ -1717,7 +1710,7 @@ if arguments >= 1:
if arguments < 4:
print("Usage: python3 api.py setcardsetting BOARDID KEY VALUE")
print("e.g. python3 api.py setcardsetting BOARDID allowsDueDate true")
exit(1)
sys.exit(1)
boardid = sys.argv[2]
key = sys.argv[3]
value = sys.argv[4]
@@ -1945,7 +1938,7 @@ if arguments >= 3 and sys.argv[1] == 'uploadbackground':
response = requests.post(url, headers=headers, params=params, data=f)
except Exception as e:
print(f"Error reading file: {e}")
exit(1)
sys.exit(1)
print(response.text)
if arguments >= 3 and sys.argv[1] == 'downloadbackground':
+1 -1
View File
@@ -1980,7 +1980,7 @@ netstat -ano | findstr /r /c:":3001 .*LISTENING" >nul 2>&1
if not errorlevel 1 (
echo ==^> Reusing the MongoDB already listening on :3001 ^(not started or stopped by this run^).
) else (
for %%i in ("%REPO%\..\mongodb-test-3001") do set "DBPATH=%%~fi"
for %%i in ("%REPO%\.tools\mongodb-test-3001") do set "DBPATH=%%~fi"
if not exist "!DBPATH!" md "!DBPATH!"
echo ==^> Starting MongoDB ^(Meteor's mongod^) on :3001, dbpath !DBPATH!.
start "WekanTestMongo" /MIN "%MONGOD_BIN%" --port 3001 --dbpath "!DBPATH!" --bind_ip 127.0.0.1 --logpath "%RUN_LOGDIR%\wekan-test-mongod.log"
+61 -28
View File
@@ -652,8 +652,18 @@ function run_playwright_docker(){
local docker_base_url="${WEKAN_BASE_URL:-http://127.0.0.1:3000}"
local docker_mongo_url="${WEKAN_MONGO_URL:-mongodb://127.0.0.1:3001/meteor}"
local docker_host_proxy_port=""
local docker_network="host"
# Docker Desktop on macOS keeps the host outside the container's loopback.
if [ "$(uname -s)" = Darwin ]; then
# Its host network loses connections to host.docker.internal: from a
# container on that network about 1 connect in 75 never completed, so a
# test page waited 60 s for a document that never came (Firefox, 2026-11-01:
# 8 of 600 direct connects, against 0 of 1000 from the bridge network).
# Nothing here needs the host network on macOS - WeKan and Mongo are reached
# through host.docker.internal and SMTP through docker exec - so the
# container uses Docker's bridge network, where the proxy below still
# serves localhost.
docker_network="bridge"
# Meteor's dynamic imports use ROOT_URL (localhost in the test server).
# Keep the browser on that same origin and bridge its loopback to the host.
# Browsing host.docker.internal while ROOT_URL is localhost makes Firefox
@@ -677,35 +687,53 @@ function run_playwright_docker(){
mkdir -p "$filesroot"
echo "Running Playwright $browser in Docker ($image)."
echo "Expecting WeKan at $docker_base_url."
local smtp_proxy_pid="" smtp_proxy_ready="" container_name="wekan-playwright-${browser}-$$"
if [ "$(uname -s)" = Darwin ] && [ -n "${WEKAN_TEST_SMTP_PORT:-}" ]; then
smtp_proxy_ready="$(mktemp "$WEKAN_DIR/.tools/tmp/smtp-proxy.XXXXXX")"
node "$pwdir/helpers/docker-smtp-proxy.cjs" "$container_name" "$WEKAN_TEST_SMTP_PORT" "$smtp_proxy_ready" &
smtp_proxy_pid=$!
for i in 1 2 3 4 5 6 7 8 9 10; do
[ -s "$smtp_proxy_ready" ] && break
kill -0 "$smtp_proxy_pid" 2>/dev/null || break
sleep 0.1
local container_name="wekan-playwright-${browser}-$$"
# Docker Desktop keeps the container's loopback from the host, so a server a
# spec starts in the container cannot be reached by WeKan outside it. Each
# port below is forwarded from the host into the container through docker
# exec (helpers/docker-smtp-proxy.cjs, which carries any TCP): the test SMTP
# server, and a small pool for the mock Jira/GitLab servers of the list-sync
# specs, which take a free port from WEKAN_TEST_REVERSE_PORTS
# (helpers/reachable-server.cjs). Elsewhere they listen on any port.
local forward_pids="" forward_ready="" reverse_ports=""
if [ "$(uname -s)" = Darwin ]; then
reverse_ports="${WEKAN_TEST_REVERSE_PORTS:-39101 39102 39103 39104}"
local fport fready fpid i
for fport in ${WEKAN_TEST_SMTP_PORT:-} $reverse_ports; do
fready="$(mktemp "$WEKAN_DIR/.tools/tmp/docker-port-proxy.XXXXXX")"
node "$pwdir/helpers/docker-smtp-proxy.cjs" "$container_name" "$fport" "$fready" &
fpid=$!
forward_pids="$forward_pids $fpid"; forward_ready="$forward_ready $fready"
for i in 1 2 3 4 5 6 7 8 9 10; do
[ -s "$fready" ] && break
kill -0 "$fpid" 2>/dev/null || break
sleep 0.1
done
if [ ! -s "$fready" ]; then
kill $forward_pids 2>/dev/null; wait $forward_pids 2>/dev/null
rm -f $forward_ready
echo "ERROR: local test proxy for port $fport did not start."
return 1
fi
done
if [ ! -s "$smtp_proxy_ready" ]; then
kill "$smtp_proxy_pid" 2>/dev/null; wait "$smtp_proxy_pid" 2>/dev/null
rm -f "$smtp_proxy_ready"
echo "ERROR: local SMTP test proxy did not start."
return 1
fi
fi
# Mount the whole repo so specs that reach the repo-root node_modules
# (e.g. @wekanteam/exceljs) and .tools resolve; run from tests/playwright.
# The repo and the files root are mounted at their OWN host paths, so a path
# names the same file for the specs in here and for the server outside:
# admin-continuous-backup gives the server a key file it wrote and reads the
# stream the server wrote, and with the repo at /repo neither side found the
# other's file.
# Run as the host user (--user) with a writable HOME so the container does
# not leave root-owned files under test-results/ (which would later make
# native Chromium/Firefox runs fail with "EACCES: permission denied, mkdir
# .../test-results/.playwright-artifacts-N").
docker_exec run --rm --init --ipc=host --network host \
docker_exec run --rm --init --ipc=host --network "$docker_network" \
--name "$container_name" \
--label org.wekan.test-run=everything \
--user "$(id -u):$(id -g)" \
-e HOME=/repo/.tools/tmp \
-e TMPDIR=/repo/.tools/tmp \
-e HOME="$reporoot/.tools/tmp" \
-e TMPDIR="$reporoot/.tools/tmp" \
-e WEKAN_BASE_URL="$docker_base_url" \
-e WEKAN_DOCKER_HOST_PROXY_PORT="$docker_host_proxy_port" \
-e WEKAN_MONGO_URL="$docker_mongo_url" \
@@ -717,16 +745,17 @@ function run_playwright_docker(){
-e WEKAN_TEST_SERIAL="${WEKAN_TEST_SERIAL:-0}" \
-e WEKAN_TEST_SERVER_MODE="${WEKAN_TEST_SERVER_MODE:-bundle}" \
-e WEKAN_TEST_SMTP_PORT="${WEKAN_TEST_SMTP_PORT:-}" \
-e WEKAN_TEST_REVERSE_PORTS="$reverse_ports" \
-e PLAYWRIGHT_HTML_OPEN=never \
-e WEKAN_FILES_PATH=/wekan-files \
-e WEKAN_FILES_PATH="$filesroot" \
-e PLAYWRIGHT_JSON_OUTPUT_NAME="${PLAYWRIGHT_JSON_OUTPUT_NAME:-}" \
-v "$filesroot":/wekan-files \
-v "$reporoot":/repo -w /repo/tests/playwright \
-v "$filesroot":"$filesroot" \
-v "$reporoot":"$reporoot" -w "$pwdir" \
"$image" \
sh -c '
export PATH=/repo/tests/playwright/node_modules/.bin:$PATH
export PATH="$PWD/node_modules/.bin:$PATH"
if [ -n "$WEKAN_DOCKER_HOST_PROXY_PORT" ]; then
node /repo/tests/playwright/helpers/docker-host-proxy.cjs &
node "$PWD/helpers/docker-host-proxy.cjs" &
proxy_pid=$!
trap '\''kill "$proxy_pid" 2>/dev/null || true; wait "$proxy_pid" 2>/dev/null || true'\'' EXIT
ready=0
@@ -739,9 +768,9 @@ function run_playwright_docker(){
npx playwright test --project="$0" "$@"
' "$browser" "$@"
local playwright_rc=$?
if [ -n "$smtp_proxy_pid" ]; then
kill "$smtp_proxy_pid" 2>/dev/null; wait "$smtp_proxy_pid" 2>/dev/null
rm -f "$smtp_proxy_ready"
if [ -n "$forward_pids" ]; then
kill $forward_pids 2>/dev/null; wait $forward_pids 2>/dev/null
rm -f $forward_ready
fi
return "$playwright_rc"
}
@@ -1513,7 +1542,11 @@ function run_all_tests(){
echo "ERROR: nothing is listening on :$TEST_DB_PORT and no mongod was found (Meteor dev_bundle or PATH)."
echo " Meteor's bundled mongod is normally at <dev_bundle>/mongodb/bin/mongod."; rm -rf "$STATDIR"; return 1
fi
local DBPATH="../mongodb-test-$TEST_DB_PORT"
# In .tools/, where everything this script writes lives (CLAUDE.md;
# tests/toolsDirOnly.test.cjs). It was in the checkout's PARENT directory,
# which a sandboxed editor cannot write - the test database never
# started there.
local DBPATH="$WEKAN_DIR/.tools/mongodb-test-$TEST_DB_PORT"
mkdir -p "$DBPATH"
echo "==> Starting MongoDB (Meteor's mongod) on :$TEST_DB_PORT, dbpath $DBPATH."
{ echo "===== mongod :$TEST_DB_PORT - started $(date '+%Y-%m-%d %H:%M:%S %Z') ====="; exec "$MONGOD_BIN" --port "$TEST_DB_PORT" --dbpath "$DBPATH" --bind_ip 127.0.0.1 --nounixsocket; } > "$RUN_LOGDIR/wekan-test-mongod.log" 2>&1 &
@@ -1541,7 +1574,7 @@ function run_all_tests(){
export WEKAN_FILES_PATH_HOST="$WRITABLE_ABS/files"
# ...and the same path for a LOCAL playwright run, not only the Docker one.
# Only WEKAN_FILES_PATH_HOST was exported, which is read when the browsers run
# in a container (it is mounted there as /wekan-files). A local run fell back
# in a container (it is mounted there at the same path). A local run fell back
# to the spec's own default of .build/bundle/files, so a spec that writes a
# stored attachment wrote it OUTSIDE the root this server reads - and then
# tested what WeKan does with a file it does not own.
+4 -1
View File
@@ -62,7 +62,10 @@ try {
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
const swPath = new URL('pwa-service-worker.js', Meteor.absoluteUrl()).pathname;
navigator.serviceWorker.register(swPath);
navigator.serviceWorker.register(swPath).catch(error => {
// No service worker (a private window, an old browser): the app works without the offline cache.
if (process.env.DEBUG === 'true') console.warn('service worker registration failed:', error);
});
});
}
+1 -1
View File
@@ -53,7 +53,7 @@
background-color: #fff;
border: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
height: 36px;
margin-block: 4px 6px;
margin-inline: 0 4px;
+2 -2
View File
@@ -15,7 +15,7 @@ template(name="commentForm")
a.js-cancel-reply(title="{{_ 'close' }}")
i.fa.fa-times-thin
form.js-new-comment-form
+editor(class="js-new-comment-input")
+editor(class="js-new-comment-input" labelKey="comment")
| {{getUnsavedValue 'cardComment' currentCard._id}}
.add-controls
button.primary.confirm.clear.js-add-comment(type="submit") {{_ 'comment'}}
@@ -40,7 +40,7 @@ template(name="comment")
= parentComment.text
+inlinedForm(classNames='js-edit-comment')
+editor(autofocus=true)
+editor(autofocus=true labelKey="comment")
= text
.edit-controls
button.primary(type="submit" title="{{_ 'edit'}}" aria-label="{{_ 'edit'}}")
+203 -97
View File
@@ -6,9 +6,9 @@
/* Ordinary solid-colour themes. */
.board-color-nephritis {
--board-theme-accent: #27ae60;
--board-theme-dark: #1f8b4d;
--board-theme-hover: #2cc66d;
--board-theme-accent: #217d49;
--board-theme-dark: #1c6c3f;
--board-theme-hover: #1f7645;
--board-theme-emphasis: #ae2775;
--board-theme-emphasis-hover: #9d2369;
--board-theme-emphasis-close: #8b1f5e;
@@ -36,7 +36,10 @@
}
.board-color-belize {
--board-theme-accent: #2980b9;
/* The default theme. The earlier blue #2980b9 gave the header's white text a ratio of 4.30 and
its #f2f2f2 button labels 3.84, below the 4.5 WCAG 2.1 AA (1.4.3) asks of
small text; this blue keeps the hue and gives 5.14 and 4.59. */
--board-theme-accent: #2573a7;
--board-theme-dark: #216694;
--board-theme-hover: #2e90d0;
--board-theme-emphasis: #b96229;
@@ -47,7 +50,7 @@
--board-theme-selected-hover-bg: #f8fbfd;
--board-theme-bright: #459cd6;
--board-theme-canvas-scrollbar: #2573a7f2;
--board-theme-sidebar-scrollbar: #2980b9f2;
--board-theme-sidebar-scrollbar: #2573a7f2;
}
.board-color-wisteria {
@@ -81,12 +84,12 @@
}
.board-color-pumpkin {
--board-theme-accent: #e67e22;
--board-theme-dark: #be6415;
--board-theme-hover: #e98b38;
--board-theme-emphasis: #228ae6;
--board-theme-emphasis-hover: #187dd5;
--board-theme-emphasis-close: #156fbe;
--board-theme-accent: #a75919;
--board-theme-dark: #904d15;
--board-theme-hover: #9d5417;
--board-theme-emphasis: #196ec2;
--board-theme-emphasis-hover: #1765b2;
--board-theme-emphasis-close: #155ca3;
--board-theme-checklist-bg: #f9e5d1;
--board-theme-selected-bg: #fdf2e9;
--board-theme-selected-hover-bg: #fefbf8;
@@ -96,12 +99,12 @@
}
.board-color-moderatepink {
--board-theme-accent: #cd5a91;
--board-theme-dark: #b53773;
--board-theme-hover: #d26b9c;
--board-theme-emphasis: #5acd96;
--board-theme-emphasis-hover: #43c688;
--board-theme-emphasis-close: #37b579;
--board-theme-accent: #bd3a79;
--board-theme-dark: #a33268;
--board-theme-hover: #b23772;
--board-theme-emphasis: #217b54;
--board-theme-emphasis-hover: #1e714d;
--board-theme-emphasis-close: #1c6747;
--board-theme-checklist-bg: #f4dde8;
--board-theme-selected-bg: #faeef4;
--board-theme-selected-hover-bg: #fefafc;
@@ -111,9 +114,9 @@
}
.board-color-strongcyan {
--board-theme-accent: #00aecc;
--board-theme-dark: #008ba3;
--board-theme-hover: #00c8eb;
--board-theme-accent: #00798d;
--board-theme-dark: #006879;
--board-theme-hover: #007285;
--board-theme-emphasis: #cc1e00;
--board-theme-emphasis-hover: #b81b00;
--board-theme-emphasis-close: #a31800;
@@ -126,12 +129,12 @@
}
.board-color-limegreen {
--board-theme-accent: #4bbf6b;
--board-theme-dark: #389d54;
--board-theme-hover: #5dc57a;
--board-theme-emphasis: #bf4b9f;
--board-theme-emphasis-hover: #b13f91;
--board-theme-emphasis-close: #9d3881;
--board-theme-accent: #277c48;
--board-theme-dark: #226b3e;
--board-theme-hover: #257544;
--board-theme-emphasis: #b34093;
--board-theme-emphasis-hover: #a53b87;
--board-theme-emphasis-close: #96367b;
--board-theme-checklist-bg: #daf4de;
--board-theme-selected-bg: #edf9f0;
--board-theme-selected-hover-bg: #fafdfb;
@@ -141,12 +144,12 @@
}
.board-color-corteza {
--board-theme-accent: #568ba2;
--board-theme-dark: #456f82;
--board-theme-hover: #6597ad;
--board-theme-emphasis: #a26d56;
--board-theme-emphasis-hover: #92624d;
--board-theme-emphasis-close: #825745;
--board-theme-accent: #4a738a;
--board-theme-dark: #406377;
--board-theme-hover: #466c82;
--board-theme-emphasis: #94614f;
--board-theme-emphasis-hover: #885949;
--board-theme-emphasis-close: #7c5142;
--board-theme-checklist-bg: #dce6ec;
--board-theme-selected-bg: #eef3f6;
--board-theme-selected-hover-bg: #fafcfc;
@@ -901,10 +904,10 @@ THEME - Relax
.board-list li.board-color-relax,
.board-color-relax .setting-content .content-body .side-menu ul li.active,
.board-color-relax .setting-content .content-body .side-menu ul li.active:hover {
background-color: #27ae61;
background-color: #217d4a;
}
.board-color-relax .is-selected .minicard {
border-inline-start: 3px solid #27ae61;
border-inline-start: 3px solid #217d4a;
}
.board-color-relax .minicard {
border-radius: 7px;
@@ -915,17 +918,17 @@ THEME - Relax
.board-color-relax input[type=submit].primary,
.board-color-relax .sidebar .sidebar-content .sidebar-btn,
.board-color-relax .selected-calendar-picker button.primary {
background-color: #1f8b4e;
background-color: #1c6c40;
border-radius: 7px;
}
.board-color-relax.pop-over .pop-over-list li a:not(.disabled):hover,
.board-color-relax .sidebar .sidebar-content .sidebar-btn:hover,
.board-color-relax .sidebar-list li a:hover {
background-color: #2cc66f;
background-color: #1f7646;
}
.board-color-relax#header ul li.current,
.board-color-relax#header-quick-access ul li.current {
border-bottom: 2px solid #2cc66f;
border-bottom: 2px solid #1f7646;
}
.board-color-relax#header-quick-access {
color: #fff;
@@ -941,14 +944,14 @@ THEME - Relax
background: #8b1f5d;
}
.board-color-relax .materialCheckBox.is-checked {
border-bottom: 2px solid #27ae61;
border-right: 2px solid #27ae61;
border-bottom: 2px solid #217d4a;
border-right: 2px solid #217d4a;
}
.board-color-relax .checklist-progress-bar {
background-color: #d4f1dd !important;
}
.board-color-relax .checklist-progress-bar .checklist-progress {
background-color: #27ae61 !important;
background-color: #217d4a !important;
}
.board-color-relax .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #e7faef;
@@ -957,17 +960,17 @@ THEME - Relax
background: #f8fdfa;
}
.board-color-relax .toggle-label:after {
background-color: #1f8b4e;
background-color: #1c6c40;
}
.board-color-relax .toggle-switch:checked ~ .toggle-label {
background-color: #3dd37e;
}
.board-color-relax .toggle-switch:checked ~ .toggle-label:after {
background-color: #1f8b4e;
background-color: #1c6c40;
}
@media screen and (max-width: 800px) {
.board-color-relax.pop-over .header {
background: #27ae61;
background: #217d4a;
color: #fff;
}
}
@@ -1554,7 +1557,7 @@ THEME - Clear Blue
.board-backgrounds-list .board-color-clearblue.background-box,
.public-board-row.board-color-clearblue,
.board-list li.board-color-clearblue {
background: linear-gradient(180deg, #499bea 0%, #00aecc 100%);
background: linear-gradient(180deg, #499bea 0%, #00798d 100%);
}
.board-color-clearblue .is-selected .minicard {
border-inline-start: 3px solid #499bea;
@@ -1566,7 +1569,7 @@ THEME - Clear Blue
}
.board-color-clearblue#header ul li.current,
.board-color-clearblue#header-quick-access ul li.current {
border-bottom: 4px solid #00c8eb;
border-bottom: 4px solid #007285;
}
.board-color-clearblue#header-quick-access {
color: #fff;
@@ -1585,14 +1588,14 @@ THEME - Clear Blue
.board-color-clearblue#header-quick-access,
.board-color-clearblue .setting-content .content-body .side-menu ul li.active,
.board-color-clearblue .setting-content .content-body .side-menu ul li.active:hover {
background-color: #00aecc;
border-bottom: 1px solid #008ba3;
background-color: #00798d;
border-bottom: 1px solid #006879;
border-top: 1px solid #00687a;
}
/* clearblue's bar is a colour SLIDE, and the slide has to be on the bar that is
actually on screen. It was only ever on `#header #header-main-bar` - the
inner element of the SECOND bar - because that is where the theme used to
live. The first bar got the flat `background-color: #00aecc` from the rule
live. The first bar got the flat `background-color: #00798d` from the rule
above instead, which is exactly strongcyan's colour: picking clearblue
painted the header strongcyan.
`#header-quick-access` has no inner bar of its own, so the slide goes
@@ -1601,7 +1604,7 @@ THEME - Clear Blue
The All Boards left menu's selected row is named here too. Every other theme
paints that row through `--theme-accent`, which is one colour and is exactly
right for them; a slide is not a colour, so the variable cannot carry it and
clearblue's row came out flat #00aecc while the Admin Panel's row beside it
clearblue's row came out flat #00798d while the Admin Panel's row beside it
slid. The Admin Panel's row has always been named in this rule; this is the
All Boards row joining it. docs/Features/General/Theme/Change-Color.md */
.board-color-clearblue#header #header-main-bar,
@@ -1610,13 +1613,13 @@ THEME - Clear Blue
.board-color-clearblue .setting-content .content-body .side-menu ul li.active:hover,
.board-color-clearblue .boards-left-menu .menu-item.active a,
.board-color-clearblue .boards-left-menu .menu-item.active a:hover {
background: linear-gradient(180deg, #499bea 0%, #00aecc 100%);
background: linear-gradient(180deg, #499bea 0%, #00798d 100%);
}
.board-color-clearblue#header #header-main-bar p {
margin-bottom: 6px;
}
.board-color-clearblue#header #header-main-bar .board-header-btn.emphasis {
background: #00c8eb;
background: #007285;
}
.board-color-clearblue#header #header-main-bar .board-header-btn.emphasis:hover,
.board-color-clearblue#header #header-main-bar .board-header-btn.emphasis .board-header-btn-close {
@@ -1648,7 +1651,7 @@ THEME - Clear Blue
background-color: #197ddc;
}
.board-color-clearblue.board-wrapper {
background: linear-gradient(135deg, #499bea 0%, #00aecc 100%);
background: linear-gradient(135deg, #499bea 0%, #00798d 100%);
}
.board-color-clearblue .swimlane {
background: none;
@@ -1740,7 +1743,7 @@ THEME - Clear Blue
}
.board-color-clearblue .quiet,
.board-color-clearblue .quiet a {
color: rgba(0,0,0,0.4);
color: rgba(0,0,0,0.68);
}
.board-color-clearblue .list-header .list-header-watch-icon {
color: rgba(0,0,0,0.5);
@@ -1767,7 +1770,7 @@ THEME - Clear Blue
}
.board-color-clearblue .sidebar-shadow {
box-shadow: none;
border-inline-start: 9px solid #00aecc;
border-inline-start: 9px solid #00798d;
}
.board-color-clearblue .is-open .sidebar-shadow {
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
@@ -1833,7 +1836,7 @@ THEME - Clear Blue
/* Apply scrollbar to sidebar content*/
.board-color-clearblue .sidebar .sidebar-content {
scrollbar-color: #00aecc #ffffff00;
scrollbar-color: #00798d #ffffff00;
}
/* Remove margins in between columns/fix spacing */
@@ -1984,10 +1987,10 @@ THEME - Modern
.board-list li.board-color-modern,
.board-color-modern .setting-content .content-body .side-menu ul li.active,
.board-color-modern .setting-content .content-body .side-menu ul li.active:hover {
background-color: #2a80b8;
background-color: #2372a4;
}
.board-color-modern .is-selected .minicard {
border-inline-start: 3px solid #2a80b8;
border-inline-start: 3px solid #2372a4;
}
.board-color-modern .minicard {
border-radius: 7px;
@@ -1998,40 +2001,40 @@ THEME - Modern
.board-color-modern input[type=submit].primary,
.board-color-modern .sidebar .sidebar-content .sidebar-btn,
.board-color-modern .selected-calendar-picker button.primary {
background-color: #226693;
background-color: #1e628d;
border-radius: 7px;
}
.board-color-modern.pop-over .pop-over-list li a:not(.disabled):hover,
.board-color-modern .sidebar .sidebar-content .sidebar-btn:hover,
.board-color-modern .sidebar-list li a:hover {
background-color: #2f90cf;
background-color: #216b9a;
}
.board-color-modern#header ul li.current,
.board-color-modern#header-quick-access ul li.current {
border-bottom: 2px solid #2f90cf;
border-bottom: 2px solid #216b9a;
}
.board-color-modern#header-quick-access {
color: #fff;
}
.board-color-modern#header #header-main-bar .board-header-btn.emphasis {
background: #b8622a;
background: #a45b23;
}
.board-color-modern#header #header-main-bar .board-header-btn.emphasis:hover,
.board-color-modern#header #header-main-bar .board-header-btn.emphasis .board-header-btn-close {
background: #a65826;
background: #975420;
}
.board-color-modern#header #header-main-bar .board-header-btn.emphasis:hover .board-header-btn-close {
background: #934e22;
background: #8a4c1d;
}
.board-color-modern .materialCheckBox.is-checked {
border-bottom: 2px solid #2a80b8;
border-right: 2px solid #2a80b8;
border-bottom: 2px solid #2372a4;
border-right: 2px solid #2372a4;
}
.board-color-modern .checklist-progress-bar {
background-color: #d1e7f5 !important;
}
.board-color-modern .checklist-progress-bar .checklist-progress {
background-color: #2a80b8 !important;
background-color: #2372a4 !important;
}
.board-color-modern .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #e8f3fa;
@@ -2040,17 +2043,17 @@ THEME - Modern
background: #f8fbfd;
}
.board-color-modern .toggle-label:after {
background-color: #226693;
background-color: #1e628d;
}
.board-color-modern .toggle-switch:checked ~ .toggle-label {
background-color: #469cd5;
}
.board-color-modern .toggle-switch:checked ~ .toggle-label:after {
background-color: #226693;
background-color: #1e628d;
}
@media screen and (max-width: 800px) {
.board-color-modern.pop-over .header {
background: #2a80b8;
background: #2372a4;
color: #fff;
}
}
@@ -2379,7 +2382,7 @@ THEME - Modern Dark
.board-color-moderndark button[type=submit].primary,
.board-color-moderndark .board-color-modern input[type=submit].primary,
.board-color-moderndark .selected-calendar-picker button.primary {
background-color: #777;
background-color: #6e6e6e;
border-radius: 7px;
}
.board-color-moderndark .toggle-switch:checked~.toggle-label {
@@ -3012,7 +3015,15 @@ THEME - Exodark
.pop-over.board-color-exodark {
background: #2b2b2b;
color: #fff;
--wekan-quiet-text: #b4b4b4;
/* Card Settings opens in this popup: dark sections for its white text. */
--card-settings-section-bg: rgba(255, 255, 255, 0.08);
--card-settings-helper-bg: #3a3a46;
--card-settings-notch-bg: #2c3340;
}
/* The popup's own links keep the light popup's black otherwise: black on
#2b2b2b was 1.48:1 (#6863). */
.pop-over.board-color-exodark .header {
background: #2b2b2b;
color: #fff;
@@ -3791,7 +3802,7 @@ THEME - Clean Light
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
font-weight: 400;
line-height: calc(24px * var(--wekan-ui-font-scale, 1));
color: rgba(10, 10, 20, 0.5);
color: rgba(10, 10, 20, 0.7);
}
.board-color-cleanlight .card-details-header,
@@ -4614,7 +4625,7 @@ THEME - Clean Light
/* ===============
THEME - cleargreen
A colour SLIDE, like clearblue: #8ad59f down to #4bbf6b.
A colour SLIDE, like clearblue: #8ad59f down to #277c48.
The base is limegreen's own colour, so the flat theme and the slide theme are
the same colour family and a reader picking either gets what they expected.
@@ -4631,7 +4642,7 @@ docs/Features/General/Theme/Change-Color.md
.board-backgrounds-list .board-color-cleargreen.background-box,
.public-board-row.board-color-cleargreen,
.board-list li.board-color-cleargreen {
background: linear-gradient(180deg, #8ad59f 0%, #4bbf6b 100%);
background: linear-gradient(180deg, #8ad59f 0%, #277c48 100%);
}
.board-color-cleargreen .is-selected .minicard {
border-inline-start: 3px solid #8ad59f;
@@ -4658,7 +4669,7 @@ docs/Features/General/Theme/Change-Color.md
.board-color-cleargreen#header-quick-access,
.board-color-cleargreen .setting-content .content-body .side-menu ul li.active,
.board-color-cleargreen .setting-content .content-body .side-menu ul li.active:hover {
background-color: #4bbf6b;
background-color: #277c48;
border-bottom: 1px solid #3c9956;
border-top: 1px solid #2d7340;
}
@@ -4669,7 +4680,7 @@ docs/Features/General/Theme/Change-Color.md
.board-color-cleargreen .setting-content .content-body .side-menu ul li.active:hover,
.board-color-cleargreen .boards-left-menu .menu-item.active a,
.board-color-cleargreen .boards-left-menu .menu-item.active a:hover {
background: linear-gradient(180deg, #8ad59f 0%, #4bbf6b 100%);
background: linear-gradient(180deg, #8ad59f 0%, #277c48 100%);
}
.board-color-cleargreen#header #header-main-bar p {
margin-bottom: 6px;
@@ -4707,7 +4718,7 @@ docs/Features/General/Theme/Change-Color.md
background-color: #42a85e;
}
.board-color-cleargreen.board-wrapper {
background: linear-gradient(135deg, #8ad59f 0%, #4bbf6b 100%);
background: linear-gradient(135deg, #8ad59f 0%, #277c48 100%);
}
.board-color-cleargreen .swimlane {
background: none;
@@ -4799,7 +4810,7 @@ docs/Features/General/Theme/Change-Color.md
}
.board-color-cleargreen .quiet,
.board-color-cleargreen .quiet a {
color: rgba(0,0,0,0.4);
color: rgba(0,0,0,0.68);
}
.board-color-cleargreen .list-header .list-header-watch-icon {
color: rgba(0,0,0,0.5);
@@ -4826,7 +4837,7 @@ docs/Features/General/Theme/Change-Color.md
}
.board-color-cleargreen .sidebar-shadow {
box-shadow: none;
border-inline-start: 9px solid #4bbf6b;
border-inline-start: 9px solid #277c48;
}
.board-color-cleargreen .is-open .sidebar-shadow {
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
@@ -4891,7 +4902,7 @@ docs/Features/General/Theme/Change-Color.md
/* Apply scrollbar to sidebar content*/
.board-color-cleargreen .sidebar .sidebar-content {
scrollbar-color: #4bbf6b #ffffff00;
scrollbar-color: #277c48 #ffffff00;
}
/* Remove margins in between columns/fix spacing */
@@ -4910,7 +4921,7 @@ docs/Features/General/Theme/Change-Color.md
/* ===============
THEME - clearorange
A colour SLIDE, like clearblue: #efab6f down to #e67e22.
A colour SLIDE, like clearblue: #efab6f down to #a75919.
The base is pumpkin's own colour, so the flat theme and the slide theme are
the same colour family and a reader picking either gets what they expected.
@@ -4927,7 +4938,7 @@ docs/Features/General/Theme/Change-Color.md
.board-backgrounds-list .board-color-clearorange.background-box,
.public-board-row.board-color-clearorange,
.board-list li.board-color-clearorange {
background: linear-gradient(180deg, #efab6f 0%, #e67e22 100%);
background: linear-gradient(180deg, #efab6f 0%, #a75919 100%);
}
.board-color-clearorange .is-selected .minicard {
border-inline-start: 3px solid #efab6f;
@@ -4954,7 +4965,7 @@ docs/Features/General/Theme/Change-Color.md
.board-color-clearorange#header-quick-access,
.board-color-clearorange .setting-content .content-body .side-menu ul li.active,
.board-color-clearorange .setting-content .content-body .side-menu ul li.active:hover {
background-color: #e67e22;
background-color: #a75919;
border-bottom: 1px solid #b8651b;
border-top: 1px solid #8a4c14;
}
@@ -4965,7 +4976,7 @@ docs/Features/General/Theme/Change-Color.md
.board-color-clearorange .setting-content .content-body .side-menu ul li.active:hover,
.board-color-clearorange .boards-left-menu .menu-item.active a,
.board-color-clearorange .boards-left-menu .menu-item.active a:hover {
background: linear-gradient(180deg, #efab6f 0%, #e67e22 100%);
background: linear-gradient(180deg, #efab6f 0%, #a75919 100%);
}
.board-color-clearorange#header #header-main-bar p {
margin-bottom: 6px;
@@ -5003,7 +5014,7 @@ docs/Features/General/Theme/Change-Color.md
background-color: #ca6f1e;
}
.board-color-clearorange.board-wrapper {
background: linear-gradient(135deg, #efab6f 0%, #e67e22 100%);
background: linear-gradient(135deg, #efab6f 0%, #a75919 100%);
}
.board-color-clearorange .swimlane {
background: none;
@@ -5095,7 +5106,7 @@ docs/Features/General/Theme/Change-Color.md
}
.board-color-clearorange .quiet,
.board-color-clearorange .quiet a {
color: rgba(0,0,0,0.4);
color: rgba(0,0,0,0.68);
}
.board-color-clearorange .list-header .list-header-watch-icon {
color: rgba(0,0,0,0.5);
@@ -5122,7 +5133,7 @@ docs/Features/General/Theme/Change-Color.md
}
.board-color-clearorange .sidebar-shadow {
box-shadow: none;
border-inline-start: 9px solid #e67e22;
border-inline-start: 9px solid #a75919;
}
.board-color-clearorange .is-open .sidebar-shadow {
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
@@ -5187,7 +5198,7 @@ docs/Features/General/Theme/Change-Color.md
/* Apply scrollbar to sidebar content*/
.board-color-clearorange .sidebar .sidebar-content {
scrollbar-color: #e67e22 #ffffff00;
scrollbar-color: #a75919 #ffffff00;
}
/* Remove margins in between columns/fix spacing */
@@ -5206,7 +5217,7 @@ docs/Features/General/Theme/Change-Color.md
/* ===============
THEME - clearpink
A colour SLIDE, like clearblue: #df94b8 down to #cd5a91.
A colour SLIDE, like clearblue: #df94b8 down to #bd3a79.
The base is moderatepink's own colour, so the flat theme and the slide theme are
the same colour family and a reader picking either gets what they expected.
@@ -5223,7 +5234,7 @@ docs/Features/General/Theme/Change-Color.md
.board-backgrounds-list .board-color-clearpink.background-box,
.public-board-row.board-color-clearpink,
.board-list li.board-color-clearpink {
background: linear-gradient(180deg, #df94b8 0%, #cd5a91 100%);
background: linear-gradient(180deg, #df94b8 0%, #bd3a79 100%);
}
.board-color-clearpink .is-selected .minicard {
border-inline-start: 3px solid #df94b8;
@@ -5250,7 +5261,7 @@ docs/Features/General/Theme/Change-Color.md
.board-color-clearpink#header-quick-access,
.board-color-clearpink .setting-content .content-body .side-menu ul li.active,
.board-color-clearpink .setting-content .content-body .side-menu ul li.active:hover {
background-color: #cd5a91;
background-color: #bd3a79;
border-bottom: 1px solid #a44874;
border-top: 1px solid #7b3657;
}
@@ -5261,7 +5272,7 @@ docs/Features/General/Theme/Change-Color.md
.board-color-clearpink .setting-content .content-body .side-menu ul li.active:hover,
.board-color-clearpink .boards-left-menu .menu-item.active a,
.board-color-clearpink .boards-left-menu .menu-item.active a:hover {
background: linear-gradient(180deg, #df94b8 0%, #cd5a91 100%);
background: linear-gradient(180deg, #df94b8 0%, #bd3a79 100%);
}
.board-color-clearpink#header #header-main-bar p {
margin-bottom: 6px;
@@ -5299,7 +5310,7 @@ docs/Features/General/Theme/Change-Color.md
background-color: #b44f80;
}
.board-color-clearpink.board-wrapper {
background: linear-gradient(135deg, #df94b8 0%, #cd5a91 100%);
background: linear-gradient(135deg, #df94b8 0%, #bd3a79 100%);
}
.board-color-clearpink .swimlane {
background: none;
@@ -5391,7 +5402,7 @@ docs/Features/General/Theme/Change-Color.md
}
.board-color-clearpink .quiet,
.board-color-clearpink .quiet a {
color: rgba(0,0,0,0.4);
color: rgba(0,0,0,0.68);
}
.board-color-clearpink .list-header .list-header-watch-icon {
color: rgba(0,0,0,0.5);
@@ -5418,7 +5429,7 @@ docs/Features/General/Theme/Change-Color.md
}
.board-color-clearpink .sidebar-shadow {
box-shadow: none;
border-inline-start: 9px solid #cd5a91;
border-inline-start: 9px solid #bd3a79;
}
.board-color-clearpink .is-open .sidebar-shadow {
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
@@ -5483,7 +5494,7 @@ docs/Features/General/Theme/Change-Color.md
/* Apply scrollbar to sidebar content*/
.board-color-clearpink .sidebar .sidebar-content {
scrollbar-color: #cd5a91 #ffffff00;
scrollbar-color: #bd3a79 #ffffff00;
}
/* Remove margins in between columns/fix spacing */
@@ -5687,7 +5698,7 @@ docs/Features/General/Theme/Change-Color.md
}
.board-color-clearpurple .quiet,
.board-color-clearpurple .quiet a {
color: rgba(0,0,0,0.4);
color: rgba(0,0,0,0.68);
}
.board-color-clearpurple .list-header .list-header-watch-icon {
color: rgba(0,0,0,0.5);
@@ -5983,7 +5994,7 @@ docs/Features/General/Theme/Change-Color.md
}
.board-color-clearred .quiet,
.board-color-clearred .quiet a {
color: rgba(0,0,0,0.4);
color: rgba(0,0,0,0.68);
}
.board-color-clearred .list-header .list-header-watch-icon {
color: rgba(0,0,0,0.5);
@@ -6090,3 +6101,98 @@ docs/Features/General/Theme/Change-Color.md
}
/* === END clearred THEME === */
/* The quiet text colour (#595959 on the light themes' greys, WCAG 1.4.3) is a
variable, so the themes whose popups and sidebars are DARK can have a light
one: a dark grey on a dark surface was nearly invisible (#6863). cleandark and
moderndark are those; dark and exodark keep light popups and sidebars, where
the dark grey is right. Popups carry the theme class themselves, so the
variable reaches them. */
:is(
.board-color-cleandark,
.board-color-moderndark
) {
--wekan-quiet-text: #b4b4b4;
/* Card Settings: its sections are light grey blocks (cardAddFields.css), and
these themes' text on them is white (1.07:1 in cleandark). */
--card-settings-section-bg: rgba(255, 255, 255, 0.08);
--card-settings-helper-bg: #3a3a46;
--card-settings-notch-bg: #2c3340;
}
/* moderndark's surfaces are lighter greys (#545454): the quiet text needs more. */
.board-color-moderndark {
--wekan-quiet-text: #dcdcdc;
}
/* The board sidebar's "Keyboard shortcuts" row is outside .sidebar-content, so
the white of its buttons does not reach it, and sidebar.css makes it black:
black on these sidebars was 1.34:1. */
:is(
.board-color-cleandark,
.board-color-moderndark
) .sidebar .sidebar-shortcuts {
color: #fff;
}
/* moderndark: grey (#808080) on its near-black sidebar was 4.02. */
.board-color-moderndark :is(.js-open-board-menu, h3[data-fold], .activity-title) {
color: var(--wekan-quiet-text);
}
/* dark: the swimlane header's text was black on its #2c3e50 bar (1.91:1). */
.board-color-dark .swimlane-header {
color: #fff;
}
/* dark and exodark: the board, the opened card and the sidebar are dark surfaces
(#222, #2b2b2b, #2c3e50). Their popups differ: exodark's is dark too (above),
dark's is light, so the dark theme's popups keep the dark quiet text. */
:is(
.board-color-dark,
.board-color-exodark
) :is(.board-canvas, .card-details, .sidebar) {
--wekan-quiet-text: #b4b4b4;
}
/* A "quiet button" (Add custom field, Link fields to another card, ...) is #222 in
forms.css, for a light surface; on a dark popup or sidebar it was 1.18:1 (#6863). */
:is(
.board-color-cleandark,
.board-color-moderndark
) .quiet-button,
:is(
.board-color-cleandark,
.board-color-moderndark,
.board-color-exodark
).pop-over .quiet-button {
color: #e0e0e0;
}
/* The board sidebar of the dark themes: what is not a button or a heading
(a form label, the list of swimlanes' lists and cards) kept the light
sidebar's black: 1.34:1 on cleandark's #23232b (#6863). */
:is(
.board-color-cleandark,
.board-color-moderndark
) .sidebar .sidebar-content :is(label, .minilists, .minicards) {
color: #fff;
}
/* Popup items and sidebar lists: popup.css forces `.pop-over-list li > a` to black
with !important, which is right on a light surface. moderndark's popups and
sidebar and exodark's popups are dark: black on #454545 was 2.19:1, on #2b2b2b
1.48:1 (#6863). */
:is(
.board-color-moderndark,
.board-color-exodark
).pop-over .pop-over-list li > a,
:is(
.board-color-moderndark,
.board-color-exodark
).pop-over .pop-over-list li > a *,
.board-color-moderndark .sidebar .pop-over-list li > a,
.board-color-moderndark .sidebar .pop-over-list li > a * {
color: #fff !important;
}
/* moderndark: the sidebar's headings were #808080 on #222 (4.02:1). */
.board-color-moderndark .sidebar .sidebar-content h3 {
color: var(--wekan-quiet-text);
}
/* moderndark: the selected minicard's title was #ccc on #666 (3.57:1). */
.board-color-moderndark .is-selected > .minicard .minicard-title,
.board-color-moderndark .is-selected > .minicard .minicard-title * {
color: #fff;
}
+7 -7
View File
@@ -173,7 +173,7 @@
crossed it - which is the bug that had to be fixed in the Admin Panel's. */
.boards-left-menu .menu-item.active a,
.boards-left-menu .menu-item.active a:hover {
background: var(--theme-accent, #2980b9);
background: var(--theme-accent, #2573a7);
}
/* The Admin Panel puts the selected background on the LI, not the anchor, so
the fill reaches the row's full width. Here the anchor is what fills, so it
@@ -215,7 +215,7 @@
docs/Features/Pages/Starred.md */
.board-list .board-list-item-bookmark .board-list-item {
border: 4px solid #fff;
background: var(--theme-accent, #2980b9);
background: var(--theme-accent, #2573a7);
color: #fff;
/* Room at the end for the unstar button, which sits over the tile. */
padding-inline-end: 34px;
@@ -481,7 +481,7 @@
content: '';
display: block;
height: 30px;
border: 2px dashed var(--theme-accent, #2980b9);
border: 2px dashed var(--theme-accent, #2573a7);
border-radius: 4px;
background: rgba(41, 128, 185, 0.08);
margin: 2px 0;
@@ -694,7 +694,7 @@
as the pointer crossed it. */
.workspace-node.active > .workspace-node-content .js-select-space,
.workspace-node.active > .workspace-node-content:hover .js-select-space {
background: var(--theme-accent, #2980b9);
background: var(--theme-accent, #2573a7);
}
.workspace-node.active > .workspace-node-content .js-select-space,
.workspace-node.active > .workspace-node-content .js-select-space i {
@@ -1046,7 +1046,7 @@
display: flex;
align-items: center;
justify-content: center;
background-color: #999; /* Darker background for better text contrast */
background-color: #767676; /* Grey with white text: 4.54, above the 4.5 of WCAG 1.4.3 (#999 gave 2.84) */
border-radius: 3px;
/* 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
@@ -1661,7 +1661,7 @@
.js-board {
display: block;
background-color: #999; /* Default gray background if no color class is applied */
background-color: #767676; /* 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: 0; /* grid gap spaces the tiles now (#6523) */
@@ -1724,7 +1724,7 @@
chose, not a colour, and painting over it would be losing something rather
than calming it down. */
body.has-theme-board-tiles .board-list > li.js-board {
background: var(--theme-accent-fill, var(--theme-accent, #2980b9));
background: var(--theme-accent-fill, var(--theme-accent, #2573a7));
}
body.has-theme-board-tiles .board-list > li.js-board .board-list-item:not(.has-background-image) {
background-color: transparent;
+10 -3
View File
@@ -1,6 +1,9 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { ReactiveVar } from 'meteor/reactive-var';
import { Utils } from '/client/lib/utils';
import { ensureContrast } from '/models/lib/contrastColor';
import { surfacesFor } from '/models/lib/uiFonts';
import { activeThemeName } from '/client/lib/activeTheme';
import {
DEPENDENCY_ICON_CHOICES,
DEPENDENCY_TYPES,
@@ -138,13 +141,17 @@ Template.dependencyOverlay.onCreated(function () {
const y2 = dependent.y + dependent.h / 2;
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
const path = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
const markerId = markerIdForColor(dep.color);
// The colour is the member's free choice (a colour wheel), drawn over the
// board: made visible against the theme's surface - a line is a graphic
// that says something, 3:1 (WCAG 1.4.11). The stored colour is untouched.
const drawColor = ensureContrast(dep.color, surfacesFor(activeThemeName()), 3);
const markerId = markerIdForColor(drawColor);
if (meta.directed) {
markerColors.add(dep.color);
markerColors.add(drawColor);
}
lines.push({
path,
color: dep.color,
color: drawColor,
type: dep.type,
icon: dep.icon,
directed: meta.directed,
+2 -2
View File
@@ -23,9 +23,9 @@
* so the row keeps the table's white background: with the light text below that
* is white on white, and only the row's emoji was visible. The All Boards tile
* has the same problem and solves it the same way — `.board-list-item` falls
* back to a grey (#999) so its white text always has something to sit on. */
* back to a grey (#767676) so its white text always has something to sit on. */
.public-board-row {
background-color: #999;
background-color: #767676;
background-size: cover;
background-position: center;
cursor: pointer;
+1 -1
View File
@@ -87,7 +87,7 @@ template(name="attachmentGallery")
.attachment-gallery
//- #6796: a small "+" like every other section's, not a full-width box.
if canModifyCard
if canAddAttachment
a.card-label.add-label.add-attachment.js-add-attachment(title="{{_ 'add-attachment'}}" aria-label="{{_ 'add-attachment'}}")
i.fa.fa-plus
+10 -2
View File
@@ -16,6 +16,7 @@ import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManag
import Attachments from '/models/attachments';
import { Utils } from '/client/lib/utils';
import { buildAttachmentUploadConfig } from '/client/lib/attachmentUploadConfig';
import { attachmentUploadsBlocked } from '/client/lib/attachmentUploadPolicy';
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
const { attachmentTileCompact } = require('/models/lib/attachmentTile.cjs');
import { formatDateTime } from '/imports/lib/dateUtils';
@@ -338,8 +339,8 @@ function openPrevAttachment() {
function processTouch(){
xDist = touchEndCoords.x - touchStartCoords.x;
yDist = touchEndCoords.y - touchStartCoords.y;
const xDist = touchEndCoords.x - touchStartCoords.x;
const yDist = touchEndCoords.y - touchStartCoords.y;
// Left swipe
if (Math.abs(xDist) > Math.abs(yDist) && xDist < 0) {
@@ -467,6 +468,10 @@ Template.attachmentGallery.onCreated(function () {
});
Template.attachmentGallery.helpers({
// #6870: the "+" button - a card the user may edit, unless uploads are blocked site-wide
canAddAttachment() {
return Utils.canModifyCard() && !attachmentUploadsBlocked();
},
// #6796: the small file button instead of the preview tile
// (models/lib/attachmentTile.cjs).
attachmentTileCompact(file) {
@@ -672,6 +677,9 @@ export async function handleFileUpload(card, files) {
return [];
}
// #6870: the administrator blocked uploads for the whole site; the server refuses them too
if (attachmentUploadsBlocked()) return [];
// Check if board allows attachments
const board = card.board();
if (!board || !board.allowsAttachments) {
+15 -13
View File
@@ -37,12 +37,12 @@
}
.card-settings-pane-collapse {
padding: 2px 4px;
color: #808080;
color: var(--wekan-quiet-text, #595959);
text-decoration: none;
cursor: pointer;
}
.card-settings-pane-collapse:hover,
.card-settings-pane-collapse:focus-visible { color: #2980b9; }
.card-settings-pane-collapse:focus-visible { color: #2573a7; }
.card-settings-pane-title {
margin: 0;
font-size: calc(17px * var(--wekan-ui-font-scale, 1));
@@ -113,7 +113,9 @@ body.card-settings-splitting-active {
/* A section: the checklist's grey block, its title line and its items. */
.card-settings-inline-section {
background: #f7f7f7;
/* The checklist's grey block; a dark theme has its own (#6863: white text on
this light grey was 1.07:1 in cleandark). */
background: var(--card-settings-section-bg, #f7f7f7);
border-radius: 3px;
margin-block-end: 8px;
padding: 4px 8px 6px;
@@ -139,19 +141,19 @@ body.card-settings-splitting-active {
overflow-wrap: anywhere;
}
.card-settings-inline-section-title.js-card-settings-section-handle { cursor: grab; }
.card-settings-inline-section-count { color: #808080; white-space: nowrap; }
.card-settings-inline-section-count { color: var(--wekan-quiet-text, #595959); white-space: nowrap; }
.card-settings-inline-collapse,
.card-settings-inline-section-move,
.card-settings-inline-menu {
padding: 2px 4px;
color: #808080;
color: var(--wekan-quiet-text, #595959);
text-decoration: none;
cursor: pointer;
}
.card-settings-inline-collapse:hover,
.card-settings-inline-collapse:focus-visible,
.card-settings-inline-menu:hover,
.card-settings-inline-menu:focus-visible { color: #2980b9; }
.card-settings-inline-menu:focus-visible { color: #2573a7; }
/* The section's own checkbox, left of its title: the rows' look. A section
hidden whole on this side keeps its rows' ticks, drawn faded. */
.card-settings-section-side { flex: 0 0 auto; }
@@ -171,11 +173,11 @@ body.card-settings-splitting-active {
min-width: 28px;
min-height: 28px;
border-radius: 3px;
color: #808080;
color: var(--wekan-quiet-text, #595959);
text-decoration: none;
}
.card-settings-add-custom-field-button:hover,
.card-settings-add-custom-field-button:focus-visible { background: rgba(0, 0, 0, 0.06); color: #2980b9; }
.card-settings-add-custom-field-button:focus-visible { background: rgba(0, 0, 0, 0.06); color: #2573a7; }
/* Collapsed: the items hidden, not removed, so their drag stays bound. */
.card-settings-inline-section.is-collapsed .card-settings-inline-items,
.card-settings-inline-section.is-collapsed .card-settings-add-custom-field { display: none; }
@@ -189,7 +191,7 @@ body.card-settings-splitting-active {
border-bottom: 0;
padding-inline-start: 22px;
}
.card-settings-inline .card-settings-line.ui-sortable-helper { background: #fff; }
.card-settings-inline .card-settings-line.ui-sortable-helper { background: var(--card-settings-helper-bg, #fff); }
.card-settings-inline .materialCheckBox { transition: none; }
.card-settings-inline .card-field-order-toggle.is-animating .materialCheckBox { transition: 0.2s; }
/* The hamburgers' checkboxes, likewise: only the one just clicked animates. */
@@ -232,7 +234,7 @@ body.card-settings-splitting-active {
.card-settings-inline-menu.is-open,
.card-settings-add-custom-field-button.is-open {
background: rgba(41, 128, 185, 0.15);
color: #2980b9;
color: #2573a7;
border-radius: 3px;
}
.card-settings-inline-panel {
@@ -257,7 +259,7 @@ body.card-settings-splitting-active {
border-inline-start: 1px solid rgba(41, 128, 185, 0.45);
border-block-start: 1px solid rgba(41, 128, 185, 0.45);
transform: rotate(45deg);
background-color: #eef4f9;
background-color: var(--card-settings-notch-bg, #eef4f9);
}
/* The + is at the start of its line: the notch is under it there. */
.card-settings-inline-panel-create::before {
@@ -270,11 +272,11 @@ body.card-settings-splitting-active {
position: absolute;
inset-block-start: 4px;
inset-inline-end: 6px;
color: #808080;
color: var(--wekan-quiet-text, #595959);
padding: 2px 4px;
}
.card-settings-inline-panel-close:hover,
.card-settings-inline-panel-close:focus-visible { color: #2980b9; }
.card-settings-inline-panel-close:focus-visible { color: #2573a7; }
.card-settings-section-action-title {
display: flex;
align-items: center;
+1 -1
View File
@@ -38,7 +38,7 @@
background-color: #fff;
border: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
height: 36px;
margin: 4px 4px 6px 0;
width: 92%;
+1 -1
View File
@@ -3,5 +3,5 @@ template(name="descriptionForm")
.new-description.js-new-description(
class="{{#if descriptionFormIsOpen}}is-open{{/if}}")
form.js-new-description-form
+editor(class="js-new-description-input" autofocus="autofocus")
+editor(class="js-new-description-input" autofocus="autofocus" labelKey="description")
| {{getUnsavedValue 'cardDescription' _id getDescription}}
+73 -4
View File
@@ -185,6 +185,23 @@ body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-colla
bottom: 8px;
}
/* A phone in desktop mode: the header bar wraps to several rows (up to five with
the board controls open), far taller than the 8px the floating window docks
at, so the card's own top row (caret, number, menu, X) sat among the header's
rows and was only seen by dragging the card down. Below 801px the card starts
BELOW the header, at the height Utils publishes as --wekan-header-height.
Only a card WITHOUT an inline top matches (`:not([style*="top"])`), so the
place a finger dragged it to is an inline top and is kept when the finger is
lifted - this rule never competes with it. */
@media screen and (max-width: 800px) {
body.desktop-mode .card-details:not(.card-details-popup):not([style*="top"]),
/* a card opened as a popup (search result, table, Roadmap bar) docks the same way */
body.desktop-mode .card-details.card-details-popup:not([style*="top"]),
body.desktop-mode .card-details.card-details-maximized:not(.card-details-popup):not(.card-details-collapsed) {
top: calc(var(--wekan-header-height, 0px) + 8px) !important;
}
}
/* #4822: the MAXIMIZED card must be anchored to the VIEWPORT (position: fixed
with viewport insets), never laid out inside — or measured from — the
scrolled swimlane/board container. Historically the maximized pane was an
@@ -250,6 +267,24 @@ body.desktop-mode .card-details.card-details-collapsed {
color: #b3b3b3;
display: inline-block;
margin-inline-end: 6px;
/* Above the title's edit zone (z-index 1, over the whole title): a press on the
card number moves the window like the rest of the header row; only the title
itself edits. */
position: relative;
z-index: 2;
}
/* The areas that move the window by a finger. Without touch-action: none the browser
takes a finger drag for a scroll and cancels the pointer, so the window never moves
(the page behind it scrolls instead). Only in desktop mode, where the card is a
window; the full-screen card of mobile mode is not moved. */
body.desktop-mode .card-details .card-details-header,
body.desktop-mode .card-details .card-details-header .card-drag-handle {
touch-action: none;
}
body.card-window-dragging {
user-select: none;
-webkit-user-select: none;
}
/* Collapse toggle triangle */
@@ -536,6 +571,19 @@ body.desktop-mode .card-details.card-details-collapsed {
resize: both;
}
/* A card opened as a POPUP (Session popupCardId: from a search result, a table
or a Roadmap/Gantt bar) docks at the very top of the window like every other
opened card, and covers the header bars, as the .pop-over[data-popup=
"cardDetailsPopup"] rule near the end of this file says it must. The 97px
above is the room the header takes for a card that is NOT docked by the
desktop-mode rules (those skip popups), and it opened the popup BELOW the
header: collapsed, it was a bar floating over the swimlane. Mobile mode keeps
its own full-window rule (top: 0 !important). */
.card-details.card-details-popup {
top: 8px;
z-index: 2001;
}
/* Override for mobile mode even on larger screens */
body.mobile-mode .card-details {
width: 100vw !important;
@@ -1096,7 +1144,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
}
.card-details-item-location .card-location-remove {
margin-inline-start: 8px;
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
cursor: pointer;
}
.card-details-item-location .card-location-remove:hover {
@@ -1212,8 +1260,8 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
and Activities - has one rule above it and a caret in its heading that
collapses it. The rule comes from the same template as the caret, so eleven
sections cannot end up with ten separators.
The heading is a button: it says so to a screen reader (role/tabindex in the
template) and it looks like one to a mouse. */
The heading holds a real button (cardSectionHeader in the template) that a
screen reader and the keyboard reach, and it looks like one to a mouse. */
/* The GROUP is the full-width row of the card, and the fields inside it share
rows within it - which is what puts Assignee and Creator beside Members
rather than under it. The class that made an individual field full width is
@@ -1248,11 +1296,32 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
changes length, so it gets a fixed box. `text-align: center` rather than a
side, because the glyph itself differs between the open and closed states
(down, and right or left) and a fixed side would make it jump. */
.card-details h3.card-details-item-title.js-toggle-card-section > .fa:first-child {
.card-details h3.card-details-item-title.js-toggle-card-section .card-details-section-toggle > .fa:first-child {
width: 12px;
text-align: center;
flex: 0 0 auto;
}
/* The button inside the heading looks like the heading: no button chrome, the
heading's own size, weight and colour, the caret, icon and label in a row. */
.card-details h3.card-details-item-title .card-details-section-toggle {
align-items: center;
background: none;
border: 0;
border-radius: 3px;
color: inherit;
cursor: pointer;
display: inline-flex;
font: inherit;
gap: 6px;
margin: 0;
padding: 0;
text-align: start;
width: auto;
}
.card-details h3.card-details-item-title .card-details-section-toggle:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.card-details h3.card-details-item-title.js-toggle-card-section:hover {
opacity: 0.85;
}
+16 -9
View File
@@ -1055,15 +1055,22 @@ template(name="cardSectionHeader")
sits under a rule of the layout's own.
unless noRule
hr.card-details-section-rule
h3.card-details-item-title.js-toggle-card-section(data-section="{{section}}" role="button" tabindex="0")
i.fa(class="{{sectionCaret section}}")
if icon
i.fa(class="{{icon}}")
| {{_ label}}
//- An optional count after the name - "Attachments (3)" - for a section
whose size is worth knowing before it is opened.
if count
| ({{count}})
//- A real heading with a real button in it (the WAI disclosure pattern): the h3
keeps the outline, the button is what the keyboard and a screen reader reach
and says whether the section is open. The section menu and the layout toggle
are beside the button, not inside it - a button must not contain controls
(WCAG 4.1.2; axe nested-interactive and aria-allowed-role). A click anywhere
on the heading still folds the section: the handler is on the h3.
h3.card-details-item-title.js-toggle-card-section(data-section="{{section}}")
button.card-details-section-toggle(type="button" aria-expanded="{{#if isSectionOpen section}}true{{else}}false{{/if}}")
i.fa(class="{{sectionCaret section}}")
if icon
i.fa(class="{{icon}}")
| {{_ label}}
//- An optional count after the name - "Attachments (3)" - for a section
whose size is worth knowing before it is opened.
if count
| ({{count}})
//- Custom Fields puts its layout selector on the heading row, between the
title and hamburger. A collapsed section hides actions for content that
is not visible, so the selector only exists while this section is open.
+87 -110
View File
@@ -38,26 +38,7 @@ import {
datePickerRendered,
datePickerHelpers,
} from '/client/lib/datepicker';
import {
formatDateTime,
formatDate,
formatTime,
getISOWeek,
isValidDate,
isBefore,
isAfter,
isSame,
add,
subtract,
startOf,
endOf,
format,
parseDate,
now,
createDate,
fromNow,
calendar
} from '/imports/lib/dateUtils';
import { formatDateTime, add, now } from '/imports/lib/dateUtils';
import Cards from '/models/cards';
import Boards from '/models/boards';
import Checklists from '/models/checklists';
@@ -100,6 +81,7 @@ const editingLocationId = new ReactiveVar(null);
// Shared with Admin Panel, which links the places accounts log in from the same
// way - models/lib/mapLink.js.
const { mapLinkFor, GCJ } = require('/models/lib/mapLink');
const { cardHeaderPress, dragThreshold, passedThreshold, clampWindow } = require('/models/lib/windowDrag');
@@ -1209,18 +1191,68 @@ Template.cardSectionHeader.events({
if (!section) return;
cardSectionState.set(section, !isCardSectionOpen(section));
},
// A heading that can be operated with the mouse can be operated with the
// keyboard: it carries role="button" and tabindex, so Enter and Space are
// what a screen reader user presses.
'keydown .js-toggle-card-section'(event) {
if (event.target !== event.currentTarget) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
const section = event.currentTarget.dataset.section;
if (section) cardSectionState.set(section, !isCardSectionOpen(section));
},
});
// Moves the card window under a finger or a mouse. `press` is cardHeaderPress's answer.
// A press on a control waits until the pointer has really moved, so a tap still
// collapses, closes or opens the menu; once it moved, the click that follows the drag
// is swallowed.
function beginCardWindowDrag(jqEvent, press) {
// Blaze hands over a jQuery event, which does not copy `isPrimary`; the browser's
// own event has it.
const event = jqEvent.originalEvent || jqEvent;
if (!press.start || !event.isPrimary) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
const $card = $(event.target).closest('.card-details');
if (!$card.length) return;
const startX = event.clientX;
const startY = event.clientY;
const offset = $card.offset();
const startLeft = offset.left - window.scrollX;
const startTop = offset.top - window.scrollY;
const threshold = dragThreshold(event.pointerType, press.deferred);
const pointerId = event.pointerId;
let moving = false;
const onMove = (e) => {
if (e.pointerId !== pointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moving) {
if (!passedThreshold(dx, dy, threshold)) return;
moving = true;
// #6465: where the user put it wins over where the minicard is, so a later
// resize re-places every other window but only clamps this one into view.
markCardDetailsUserMoved($card);
document.body.classList.add('card-window-dragging');
}
e.preventDefault();
const placed = clampWindow(startLeft + dx, startTop + dy, $card.outerWidth(),
{ width: window.innerWidth, height: window.innerHeight });
$card.css({ left: `${placed.left}px`, top: `${placed.top}px` });
};
const onEnd = (e) => {
if (e.pointerId !== pointerId) return;
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onEnd);
document.removeEventListener('pointercancel', onEnd);
document.body.classList.remove('card-window-dragging');
if (moving && press.deferred) {
// The drag started on a link or a button: its click is not a tap.
const swallow = (click) => { click.preventDefault(); click.stopPropagation(); };
document.addEventListener('click', swallow, { capture: true, once: true });
setTimeout(() => document.removeEventListener('click', swallow, { capture: true }), 400);
}
};
document.addEventListener('pointermove', onMove, { passive: false });
document.addEventListener('pointerup', onEnd);
document.addEventListener('pointercancel', onEnd);
}
Template.cardDetails.events({
// #1591 folded each FIELD by its own title, storing a class per card in the
// profile. The section carets replaced it: one handle per section, on the
@@ -1250,86 +1282,31 @@ Template.cardDetails.events({
Users.setPublicCardCollapsed(!currentState);
}
},
'mousedown .js-card-drag-handle'(event) {
event.preventDefault();
const $card = $(event.target).closest('.card-details');
// #6465: where the user put it wins over where the minicard is, so a later
// resize re-places every other window but only clamps this one into view.
markCardDetailsUserMoved($card);
const startX = event.clientX;
const startY = event.clientY;
const startLeft = $card.offset().left;
const startTop = $card.offset().top;
const onMouseMove = (e) => {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
$card.css({
left: startLeft + deltaX + 'px',
top: startTop + deltaY + 'px'
});
};
const onMouseUp = () => {
$(document).off('mousemove', onMouseMove);
$(document).off('mouseup', onMouseUp);
};
$(document).on('mousemove', onMouseMove);
$(document).on('mouseup', onMouseUp);
// The opened card's window is moved by a finger as well as a mouse, so these are
// Pointer Events (a finger sends no mouse events while it moves, which left the card
// fixed on an iPad while the page behind it scrolled) and the areas that start a
// drag are `touch-action: none` (cardDetails.css). With drag handles ON the handle
// is the only thing that moves the window; with them off the header row moves it,
// except the title (it edits). A link or button in the row - the collapse caret, the
// menu, the card number's neighbours - still does its job when tapped, and moves the
// window when dragged. models/lib/windowDrag.js
'pointerdown .js-card-drag-handle'(event) {
beginCardWindowDrag(event, { start: true, deferred: false });
},
'mousedown .js-card-title-drag-handle'(event) {
// The title bar drags the window. Don't interfere with text selection.
if (
$(event.target).closest(
'a, input, textarea, button, select, option, .js-card-details-title',
).length > 0
) {
// #6641: while the editor replaces the header, a textarea press still
// bubbles to this drag bar. Leave it to the browser so mouse dragging
// selects text instead of moving the card window.
return;
}
// #6839: ...and never from the title, which edits - all of it
// (cardDetails.css). The window is moved from the empty space of the
// header row above it, beside the collapse caret and the menu.
if ($(event.target).closest('.card-details-title-edit-zone').length > 0) {
return;
}
// The drag handle has its own handler; without this both would run and the
// window would be moved twice by one press. And when there IS a handle -
// drag handles on - it is the only thing that moves the window, which is
// what turning handles on means.
if ($(event.target).closest('.js-card-drag-handle').length > 0
|| $(event.target).closest('.card-details').find('.js-card-drag-handle').length > 0) {
return;
}
event.preventDefault();
const $card = $(event.target).closest('.card-details');
// #6465: same as the drag handle above — a dragged window keeps its place.
markCardDetailsUserMoved($card);
const startX = event.clientX;
const startY = event.clientY;
const startLeft = $card.offset().left;
const startTop = $card.offset().top;
const onMouseMove = (e) => {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
$card.css({
left: startLeft + deltaX + 'px',
top: startTop + deltaY + 'px'
});
};
const onMouseUp = () => {
$(document).off('mousemove', onMouseMove);
$(document).off('mouseup', onMouseUp);
};
$(document).on('mousemove', onMouseMove);
$(document).on('mouseup', onMouseUp);
'pointerdown .js-card-title-drag-handle'(event) {
const target = event.target;
const closest = selector => (target.closest ? target.closest(selector) : null);
const press = cardHeaderPress({
onEditable: !!closest('input, textarea, select, option, .js-card-details-title'),
onTitleEditZone: !!closest('.card-details-title-edit-zone'),
// The handle has its own handler above; this one steps aside for it.
onHandle: false,
cardHasHandle: !!closest('.js-card-drag-handle')
|| !!(closest('.card-details') && closest('.card-details').querySelector('.js-card-drag-handle')),
onControl: !!closest('a, button'),
});
if (closest('.js-card-drag-handle')) return;
beginCardWindowDrag(event, press);
},
'click .js-close-card-details'(event) {
event.preventDefault();
@@ -2396,7 +2373,7 @@ const filterMembers = (filterTerm) => {
.map(({ member }) => member);
}
return members;
}
};
Template.editCardRequesterForm.onRendered(function () {
autosize(this.$('.js-edit-card-requester'));
+1 -1
View File
@@ -9,5 +9,5 @@ template(name="cardPomodoro")
button.js-stop-pomodoro.pomodoro-btn.negate(type="button") {{_ 'stop'}}
else
if canControlPomodoro
input.js-pomodoro-work-minutes.pomodoro-minutes(type="number" min="1" value="{{workMinutesInput}}")
input.js-pomodoro-work-minutes.pomodoro-minutes(type="number" min="1" value="{{workMinutesInput}}" aria-label="{{_ 'pomodoro-work'}} ({{_ 'minutes'}})")
button.js-start-pomodoro.pomodoro-btn.primary(type="button") {{_ 'pomodoro-start'}}
+6 -6
View File
@@ -1,5 +1,5 @@
.js-add-checklist {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
}
textarea.js-add-checklist-item,
textarea.js-edit-checklist-item {
@@ -11,7 +11,7 @@ textarea.js-edit-checklist-item {
.delete-text,
.js-delete-checklist-item,
.js-convert-checklist-item-to-card {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
text-decoration: underline;
word-wrap: break-word;
float: inline-end;
@@ -139,7 +139,7 @@ textarea.js-edit-checklist-item {
overflow-wrap: anywhere;
}
.checklist-item .item-title.is-checked {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
font-style: italic;
text-decoration: line-through;
}
@@ -167,11 +167,11 @@ textarea.js-edit-checklist-item {
.checklist-item .checklist-item-due-date-add {
align-self: center;
margin-inline-start: 6px;
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
text-decoration: none;
}
.checklist-item .checklist-item-due-date-add:hover {
color: #2980b9;
color: #2573a7;
}
.js-delete-checklist-item,
.js-convert-checklist-item-to-card {
@@ -235,7 +235,7 @@ textarea.js-edit-checklist-item {
.checklist-title .checklist-collapse:hover,
.checklist-title .checklist-collapse:focus-visible {
color: #2980b9;
color: #2573a7;
}
.checklist-title .checklist-due-date-add {
+7 -2
View File
@@ -27,8 +27,8 @@
}
.ui-font-btn.active,
.ui-font-size-btn.active {
background: #2980b9;
border-color: #2980b9;
background: #2573a7;
border-color: #2573a7;
color: #fff;
}
/* The "Unset" colour reset links look like buttons. */
@@ -155,6 +155,7 @@
}
.card-label-green {
background-color: #3cb500;
color: #000;
}
.card-label-green:hover {
color: #000;
@@ -169,6 +170,7 @@
}
.card-label-red {
background-color: #eb4646;
color: #000;
}
.card-label-purple {
background-color: #a632db;
@@ -182,6 +184,7 @@
}
.card-label-sky {
background-color: #00c2e0;
color: #000;
}
.card-label-black {
background-color: #4d4d4d;
@@ -213,6 +216,7 @@
}
.card-label-magenta {
background-color: #f0f;
color: #000;
}
.card-label-gold {
background-color: #ffd700;
@@ -223,6 +227,7 @@
}
.card-label-gray {
background-color: #808080;
color: #000;
}
.card-label-saddlebrown {
background-color: #8b4513;
+2 -2
View File
@@ -1011,7 +1011,7 @@
.minicard-list-name {
clear: inline-start;
font-size: 0.75em;
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
margin-top: 2px;
display: flex;
align-items: center;
@@ -1023,7 +1023,7 @@
.minicard-swimlane-name {
clear: inline-start;
font-size: 0.75em;
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
margin-top: 2px;
display: flex;
align-items: center;
+13 -4
View File
@@ -419,18 +419,27 @@ template(name="minicard")
.minicard-location
i.fa.fa-map-marker
| {{#if name}}{{name}}{{else}}{{address}}{{/if}}
//- #6872: the + button on the opened card picks board members into
requesters / assigners; the free-text name is optional. Show both,
as the opened card does, or a picked member never reached here.
if $eq field "requestedBy"
unless $eq currentBoard.allowsRequestedByOnMinicard false
if getRequestedBy
if hasRequestedByOnMinicard
.minicard-requested-by(title="{{_ 'requested-by'}}")
i.fa.fa-shopping-cart
| {{ getRequestedBy }}
each userId in getRequesters
+userAvatar(userId=userId noRemove=true)
if getRequestedBy
span {{ getRequestedBy }}
if $eq field "assignedBy"
unless $eq currentBoard.allowsAssignedByOnMinicard false
if getAssignedBy
if hasAssignedByOnMinicard
.minicard-assigned-by(title="{{_ 'assigned-by'}}")
i.fa.fa-user-plus
| {{ getAssignedBy }}
each userId in getAssigners
+userAvatar(userId=userId noRemove=true)
if getAssignedBy
span {{ getAssignedBy }}
if $eq field "flowtime"
if currentBoard.allowsFlowtimeOnMinicard
if isFlowActive
+7
View File
@@ -261,6 +261,13 @@ Template.minicard.helpers({
isWatching() {
return this.getRealCard().findWatcher(Meteor.userId());
},
// #6872: a picked member (requesters / assigners) or a free-text name.
hasRequestedByOnMinicard() {
return !!this.getRequestedBy() || (this.getRequesters() || []).length > 0;
},
hasAssignedByOnMinicard() {
return !!this.getAssignedBy() || (this.getAssigners() || []).length > 0;
},
showMembers() {
const board = minicardBoardOf(this);
+7 -7
View File
@@ -1,5 +1,5 @@
.js-add-subtask {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
}
textarea.js-add-subtask-item,
textarea.js-edit-subtask-item {
@@ -12,7 +12,7 @@ textarea.js-edit-subtask-item {
.subtask-title .js-delete-subtask,
.subtask-title .js-view-subtask,
.js-delete-subtask-item {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
text-decoration: underline;
word-wrap: break-word;
flex-shrink: 0;
@@ -163,7 +163,7 @@ textarea.js-edit-subtask-item {
padding-inline-start: 10px;
}
.subtasks-item .item-title.is-checked {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
font-style: italic;
}
.subtasks-item .item-title .viewer p {
@@ -186,7 +186,7 @@ textarea.js-edit-subtask-item {
instead of disappearing — the same dimmed/italic treatment a checked
checklist item gets (.subtasks-item .item-title.is-checked above). */
.subtask.is-completed .subtask-title .title {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
font-style: italic;
text-decoration: line-through;
}
@@ -199,7 +199,7 @@ textarea.js-edit-subtask-item {
.subtasks-hide-completed-toggle {
display: inline-block;
margin: 0.2em 0 0.5em 1.33em;
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
cursor: pointer;
}
.subtasks-hide-completed-toggle:hover {
@@ -224,7 +224,7 @@ textarea.js-edit-subtask-item {
flex-shrink: 0;
margin: 0 0.5em;
padding-top: 6px;
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
font-size: 0.85em;
white-space: nowrap;
overflow: hidden;
@@ -251,5 +251,5 @@ textarea.js-edit-subtask-item {
}
.subtask-title.is-done .title {
text-decoration: line-through;
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
}
+1 -1
View File
@@ -28,4 +28,4 @@ template(name="cardWhiteboard")
svg.card-whiteboard-surface.js-whiteboard-surface(viewBox="0 0 {{width}} {{height}}" preserveAspectRatio="xMidYMid meet" class="{{#if canDraw}}can-draw{{/if}}")
rect(width=width height=height fill="#ffffff")
each strokes
path(d=pathD stroke=color stroke-width=width fill="none" stroke-linecap="round" stroke-linejoin="round")
path(d=pathD stroke=color stroke-width=strokeWidth fill="none" stroke-linecap="round" stroke-linejoin="round")
+5 -1
View File
@@ -116,7 +116,11 @@ Template.cardWhiteboard.helpers({
const current = tpl.current.get();
return [...saved, ...pending, ...(current ? [current] : [])]
.filter(s => s && Array.isArray(s.points) && s.points.length)
.map(s => ({ color: s.color, width: s.width, pathD: pathD(s.points) }));
// strokeWidth, not width: inside `each strokes` Spacebars looks a name up
// among this template's helpers before the stroke's own fields, and the
// width helper is the drawing's (800) - every line was drawn 800 wide and
// covered the whiteboard.
.map(s => ({ color: s.color, strokeWidth: s.width, pathD: pathD(s.points) }));
},
saving() { return Template.instance().saving.get(); },
message() { return Template.instance().message.get(); },
+20 -2
View File
@@ -107,6 +107,24 @@
white-space: nowrap;
padding-inline: 6px;
}
.selected-calendar-heading .selected-calendar-date {
margin-inline-start: 4px;
/* #6853: year, month and day drop-downs under the calendar. */
.selected-calendar-selects {
display: flex;
gap: 4px;
margin-block-start: 6px;
}
.selected-calendar-selects select,
.selected-calendar-selects input {
flex: 1;
box-sizing: border-box;
height: auto;
margin-bottom: 0;
min-width: 0;
padding-inline: 4px;
}
.selected-calendar-selects .js-calendar-year { flex: 1.4; }
.calendar-date-input .selected-calendar-selects select:focus-visible,
.calendar-date-input .selected-calendar-selects input:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
+11 -2
View File
@@ -20,8 +20,6 @@ template(name="calendarDateInput")
button.primary.js-calendar-previous(type="button" aria-label="{{_ 'previous'}} {{_ 'month'}}") ‹
span(aria-live="polite")
| {{monthLabel}}
if inline
span.selected-calendar-date {{headingDateText}}
//- #6838: back to today's month from wherever the arrows went. It only
//- shows the month; the date stays as it is until a day is clicked.
button.primary.js-calendar-today(type="button" aria-label="{{_ 'today'}}") {{_ 'today'}}
@@ -39,3 +37,14 @@ template(name="calendarDateInput")
td(class="{{#if this.today}}is-today-cell{{/if}}")
unless this.empty
button.primary.js-calendar-day(type="button" class="{{#if today}}is-today{{/if}}" data-date=iso title=title aria-label=title aria-pressed=selected aria-current=ariaCurrent tabindex=tabIndex) {{label}}
//- #6853: any year, month and day by a pick, under the calendar.
.selected-calendar-selects
input.js-calendar-year(type="number" step="1" inputmode="numeric" value=yearValue aria-label="{{_ 'predicate-year'}}")
select.js-calendar-month(aria-label="{{_ 'month'}}")
each monthOptions
option(value=value selected=selected) {{label}}
select.js-calendar-day-select(aria-label="{{_ 'day'}}")
if noDaySelected
option(value="" selected disabled) –
each dayOptions
option(value=value selected=selected) {{label}}
+74 -5
View File
@@ -6,7 +6,10 @@ import { TAPi18n } from '/imports/i18n';
import { formatDate } from '/imports/lib/dateUtils';
import { formatDateForDisplay, dateDisplayPreferences } from '/client/lib/dateDisplay';
const { CALENDAR_SYSTEMS, nativeCalendarParts } = require('/imports/lib/calendarSystems');
const { calendarMonthDays, calendarMonthRange, shiftedDay, shiftCalendarMonth, shiftCalendarYear } = require('/imports/lib/calendarMonth');
const {
calendarMonthDays, calendarMonthRange, shiftedDay, shiftCalendarMonth, shiftCalendarYear,
calendarSignedYear, dateInCalendarYear, calendarYearMonths, sameDayInMonth,
} = require('/imports/lib/calendarMonth');
function fromInput(value) {
if (!value) return null;
@@ -53,9 +56,45 @@ Template.calendarDateInput.helpers({
useNativeInput() { return !this.inline && dateDisplayPreferences().calendarSystem === 'gregorian'; },
inputType() { return this.withTime ? 'datetime-local' : 'date'; },
selectedDateText() { return formatDateForDisplay(Template.instance().selectedDate.get(), false); },
headingDateText() {
// #6853: the year, month and day drop-downs under the calendar. Each option
// carries a date as its value, found in the selected calendar system, so any
// past, present or future year, month and day is one pick away.
// The year as a number any year can be typed or stepped to, also one before
// the era (0 is 1 BC) or thousands of years away.
yearValue() { return calendarSignedYear(Template.instance().shownDate.get(), selectedSystem().intl); },
monthOptions() {
const tpl = Template.instance();
return formatDateForDisplay(tpl.selectedDate.get() || tpl.shownDate.get(), false);
const calendar = selectedSystem().intl;
const language = TAPi18n.getLanguage() || 'en';
return calendarYearMonths(tpl.shownDate.get(), calendar).map(month => {
let label;
try {
label = new Intl.DateTimeFormat(language, { calendar, month: 'long', numberingSystem: 'latn' }).format(month.date);
} catch (error) { label = formatDateForDisplay(month.date, false); }
return { _id: formatDate(month.date), value: formatDate(month.date), label, selected: month.selected };
});
},
dayOptions() {
const tpl = Template.instance();
const calendar = selectedSystem().intl;
const { start, end } = calendarMonthRange(tpl.shownDate.get(), calendar);
const selected = tpl.selectedDate.get();
const selectedIso = selected ? formatDate(selected) : '';
const days = [];
for (let day = shiftedDay(start, 0); day < end; day = shiftedDay(day, 1)) {
days.push({
_id: formatDate(day), value: formatDate(day),
label: nativeCalendarParts(day, calendar).day, selected: formatDate(day) === selectedIso,
});
}
return days;
},
noDaySelected() {
const tpl = Template.instance();
const selected = tpl.selectedDate.get();
if (!selected) return true;
const { start, end } = calendarMonthRange(tpl.shownDate.get(), selectedSystem().intl);
return selected < start || selected >= end;
},
expanded() { return !!Template.instance().data.inline || Template.instance().expanded.get(); },
calendarName() { return TAPi18n.__(selectedSystem().labelKey); },
@@ -63,8 +102,11 @@ Template.calendarDateInput.helpers({
const date = Template.instance().shownDate.get();
try {
return new Intl.DateTimeFormat(TAPi18n.getLanguage() || 'en', {
calendar: selectedSystem().intl, month: 'long',
...(Template.instance().data.inline ? {} : { year: 'numeric' }), numberingSystem: 'latn',
// #6853: the year is always shown, also in the popup: month and year
// above the calendar, never the YYYY-MM-DD of the selected day.
calendar: selectedSystem().intl, month: 'long', year: 'numeric', numberingSystem: 'latn',
// A year before the era needs its era, or 3000 BC reads as 3000.
...(calendarSignedYear(date, selectedSystem().intl) <= 0 ? { era: 'short' } : {}),
}).format(date);
} catch (error) { return formatDateForDisplay(date, false); }
},
@@ -136,6 +178,33 @@ Template.calendarDateInput.events({
},
'click .js-calendar-year-previous'(event, tpl) { event.preventDefault(); tpl.shownDate.set(shiftCalendarYear(tpl.shownDate.get(), -1, selectedSystem().intl)); },
'click .js-calendar-year-next'(event, tpl) { event.preventDefault(); tpl.shownDate.set(shiftCalendarYear(tpl.shownDate.get(), 1, selectedSystem().intl)); },
// #6853: a pick in a drop-down selects that date and shows its month.
'change .js-calendar-year'(event, tpl) {
const input = event.currentTarget;
const target = dateInCalendarYear(tpl.shownDate.get(), Number(input.value), selectedSystem().intl);
if (!target || input.value.trim() === '') {
// A year that is not a whole number, or beyond what a date can hold:
// the field goes back to the year being shown.
input.value = calendarSignedYear(tpl.shownDate.get(), selectedSystem().intl);
return;
}
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
},
// Enter in the year field applies the year, not the popup's Save.
'keydown .js-calendar-year'(event) {
if (event.key !== 'Enter') return;
event.preventDefault(); event.stopPropagation();
event.currentTarget.dispatchEvent(new Event('change', { bubbles: true }));
},
'change .js-calendar-month'(event, tpl) {
const target = sameDayInMonth(tpl.selectedDate.get() || tpl.shownDate.get(), fromInput(event.currentTarget.value), selectedSystem().intl);
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
},
'change .js-calendar-day-select'(event, tpl) {
const target = fromInput(event.currentTarget.value);
if (!target) return;
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
},
'click .js-calendar-day'(event, tpl) {
event.preventDefault();
writeValue(tpl, fromInput(event.currentTarget.dataset.date));
+9 -7
View File
@@ -192,9 +192,11 @@ body.mobile-mode [id^="swimlane-"] .list[style*="--list-width"]:not(.list-collap
min-height: 30px !important;
height: auto !important;
padding: 28px 21px 6px !important;
/* Make sure the background covers the full height */
background-color: #e4e4e4 !important;
border-bottom: 8px solid #e4e4e4 !important;
/* Make sure the background covers the full height. Not !important: it beat
every theme's own list header (moderndark's #6a6a6a, exodark's #222), and
left their light text on this grey at 1.09:1 and 1.27:1 (#6863). */
background-color: #e4e4e4;
border-bottom: 8px solid #e4e4e4;
/* Use original display for consistent button positioning */
display: block !important;
position: relative !important;
@@ -470,7 +472,7 @@ form.inlined-form .list-composer {
.list.list-composer .open-list-composer,
.list .list-composer .open-list-composer {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
}
.list.list-composer .list-name-input,
.list .list-composer .list-name-input {
@@ -534,7 +536,7 @@ form.inlined-form .list-composer {
padding: 0;
border-radius: 0;
background: transparent;
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
font-weight: bold;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
vertical-align: middle;
@@ -722,7 +724,7 @@ form.inlined-form .list-composer {
}
.list-header .cardCount {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
}
@@ -781,7 +783,7 @@ form.inlined-form .list-composer {
}
.list-body .open-minicard-composer {
border-radius: 2px;
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
display: block;
/* Smaller than a card title, because it is not a card: it is the affordance
for making one. At the inherited 16px it was the largest text in the list
+1 -1
View File
@@ -74,7 +74,7 @@
selected thing. The fill is the slide where the theme has one. */
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a,
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a:hover {
background: var(--theme-accent-fill, var(--theme-accent, #2980b9));
background: var(--theme-accent-fill, var(--theme-accent, #2573a7));
color: #fff !important;
}
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a i {
+1 -1
View File
@@ -39,7 +39,7 @@ template(name="dueCards")
else
.global-search-results-list-wrapper
.no-results
h3 {{_ 'dueCards-noResults-title'}}
h2 {{_ 'dueCards-noResults-title'}}
p {{_ 'dueCards-noResults-description'}}
template(name="dueCardsViewChangePopup")
+1
View File
@@ -7,6 +7,7 @@ template(name="editor")
dir="auto"
class="{{class}}"
id=id
aria-label=labelText
autofocus=autofocus
placeholder="{{_ 'comment-placeholder'}}")
+Template.contentBlock
+10
View File
@@ -45,6 +45,16 @@ function syncMarkdownUrlSchemes(setting) {
return schemes;
}
// WCAG 4.1.2: a field has a name. The including template passes the KEY of its
// name (labelKey="comment"), and the editor translates it, as every included
// template does (tests/jadeInclusionArgsNoMustache.test.cjs).
Template.editor.helpers({
labelText() {
const data = Template.currentData() || {};
return data.labelKey ? TAPi18n.__(data.labelKey) : null;
},
});
const specialHandles = [
{userId: 'board_members', username: 'board_members'},
{userId: 'card_members', username: 'card_members'},
+2
View File
@@ -106,6 +106,8 @@ code {
}
.list-title {
background-color: #a9a9a9;
/* White on this grey was 2.35; black is 8.94 (WCAG 1.4.3). */
color: #000;
}
.global-search-footer {
border: none;
+3 -1
View File
@@ -4,6 +4,7 @@ import { Session } from 'meteor/session';
import { ReactiveCache } from '/imports/reactiveCache';
import { Template } from 'meteor/templating';
import { activeAccent, activeFill } from '/models/lib/themeAccents';
import { readableAccent } from '/models/lib/contrastColor';
// #5778 + docs/Theme/Theme.md: apply the theme color to the whole UI (All Boards,
// Search, Admin Panel, My Cards, etc.) via a `board-color-<name>` class on <body>,
@@ -99,7 +100,8 @@ Meteor.startup(() => {
document.body.classList.remove('has-custom-theme-color');
}
if (c2) {
root.style.setProperty('--theme-accent-2', c2);
// Made readable like the first end: white text is drawn across the slide.
root.style.setProperty('--theme-accent-2', readableAccent(c2));
document.body.classList.add('has-custom-theme-slide');
} else {
root.style.removeProperty('--theme-accent-2');
+16 -4
View File
@@ -17,9 +17,21 @@
#header {
color: #fff;
transition: background-color 0.4s;
background: #2980b9;
background: #2573a7;
z-index: 17;
}
/* An opened card covers the header bars, in every browser. iOS WebKit - iPad Safari and
iPad Chrome, which is WebKit too - makes every SCROLLING element (#content, the board
wrapper) a stacking context of its own. The card is inside them, so its z-index (2001)
is only compared there, while the header bar (#header-quick-access, z-index 1000, a
flex item of body and so a stacking context itself) is painted over all of it: the
card's top row - the collapse caret, the card number, the menu, maximize and X - sat
BEHIND the header bar. While a card is open the header drops to the level of the
content, which comes after it in the page and so paints over it; a browser without that
trap shows no difference. */
body:has(.card-details) #header-quick-access {
z-index: 0;
}
#header #header-main-bar {
/* 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,
@@ -247,7 +259,7 @@
float: inline-start;
display: block;
line-height: calc(28px * var(--wekan-ui-font-scale, 1));
color: #27ae60;
color: #217d49;
margin: 0 10px;
cursor: pointer;
}
@@ -298,7 +310,7 @@ body:not(.board-view) #header #header-main-bar h1 {
of colour now, so the header reads as one header. The per-theme rules do
the same, by naming this bar in the selector list the main bar already has
(boardColors.css) rather than by restating each hex a second time. */
background: #2980b9;
background: #2573a7;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
display: flex;
z-index: 1000;
@@ -745,7 +757,7 @@ body.mobile-mode #header-quick-access .allBoards {
color: #fff !important;
/* The active theme, like the rest of the bar; the stock blue where no theme
has published an accent. */
background: var(--theme-accent, #2980b9) !important;
background: var(--theme-accent, #2573a7) !important;
}
#header-quick-access .mobile-mode-toggle .board-header-btn.mobile-active i.mobile-icon .fa,
+20 -3
View File
@@ -44,7 +44,9 @@ template(name="header")
that do the same job. Board admins only - everybody else gets the
plain title. The `is-editable` class also gives the element a minimum
width, so a board whose title is EMPTY still has an area to click.
span.header-page-title(title="{{headerTitleFullPath}}"
//- role="heading" aria-level="1": every page has one level-one heading, and this
//- is the page's name (WCAG 1.3.1 / 2.4.6; axe page-has-heading-one).
span.header-page-title(title="{{headerTitleFullPath}}" role="heading" aria-level="1"
class="{{#if canEditBoardTitle}}js-edit-board-title is-editable{{/if}}")
if headerTitleKey
| {{_ headerTitleKey}}
@@ -67,6 +69,19 @@ template(name="header")
div.header-logo
img(src="{{pathFor '/logo-header.png'}}" alt="{{headerLogoAlt}}" title="{{headerLogoAlt}}")
//- Full screen, to the right of the logo (iPad Safari, desktop and Android
browsers). Only drawn where the browser has the Fullscreen API and the page
may use it: not on iPhone, not in an iframe without allowfullscreen. The
label is the existing "Maximize" one. It stays before the first
.js-header-collapsible-icon, so "hide the icons" does not hide it.
models/lib/fullscreen.js
if fullscreenSupported
a.board-header-btn.js-toggle-fullscreen(href="#" role="button" title="{{#if isFullscreen}}{{_ 'minimize-window'}}{{else}}{{_ 'maximize-window'}}{{/if}}" aria-label="{{#if isFullscreen}}{{_ 'minimize-window'}}{{else}}{{_ 'maximize-window'}}{{/if}}" aria-pressed="{{#if isFullscreen}}true{{else}}false{{/if}}")
if isFullscreen
i.fa.fa-compress
else
i.fa.fa-expand
// Mobile/desktop mode toggle (swapped with zoom)
// #6680 follow-up: .js-header-collapsible-icon marks every icon the new
// .js-toggle-header-icons-collapsed button (beside Home) hides as a
@@ -119,7 +134,8 @@ template(name="header")
//- One GROUP: the two adjacent buttons are about the same thing - how
many items you have starred, and whether this one is among them.
.header-star-group.js-header-collapsible-icon
a.board-header-btn.js-open-starred-boards(title="{{_ 'allboards.starred'}}")
//- aria-label: WCAG 2.5.3 Label in Name - the visible text is the star count.
a.board-header-btn.js-open-starred-boards(title="{{_ 'allboards.starred'}}" aria-label="{{starredBoardsCount}} {{_ 'allboards.starred'}}")
i.fa.fa-caret-down
span.board-star-counter {{starredBoardsCount}}
@@ -245,7 +261,8 @@ template(name="header")
if appIsOffline
+offlineWarning
if currentUser.isBoardMember
//- #6854: on Home and All Boards too, not only on a board.
if announcementForUser
if hasAnnouncement
.announcement(role="status" aria-live="polite")
p
+41
View File
@@ -7,6 +7,7 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Announcements, {
announcementVersion,
shouldShowAnnouncement,
showAnnouncementToUser,
} from '/models/announcements';
import { Utils } from '/client/lib/utils';
// What this bar calls the page you are on. models/lib/pageTitles.js
@@ -58,6 +59,7 @@ function customPageTitle(routeName) {
// Drag-to-scroll on the two top header bars (they are not scroll containers
// themselves, so the drag is forwarded to the board canvas / page scroller).
import '/client/lib/headerDragscroll';
const fullscreen = require('/models/lib/fullscreen');
Meteor.subscribe('user-admin');
Meteor.subscribe('boards');
@@ -69,6 +71,10 @@ Meteor.subscribe('setting');
Meteor.subscribe('announcements');
Template.header.onCreated(function () {
const templateInstance = this;
// Full screen: whether the browser offers it here, and whether the page is in it.
// Both are set when the header is drawn (onRendered), where there is a document.
templateInstance.fullscreenSupported = new ReactiveVar(false);
templateInstance.isFullscreen = new ReactiveVar(false);
templateInstance.currentSetting = new ReactiveVar();
templateInstance.isLoading = new ReactiveVar(false);
templateInstance.subscribe('setting');
@@ -82,6 +88,14 @@ Template.header.onCreated(function () {
});
Template.header.onRendered(function () {
// The Fullscreen API, where it exists and the page may use it (models/lib/fullscreen.js).
// The state follows the browser's own events, so leaving with Esc, or with the
// browser's own control, changes the icon too.
this.fullscreenSupported.set(fullscreen.isSupported(document));
this.isFullscreen.set(fullscreen.isFullscreen(document));
this.onFullscreenChange = () => this.isFullscreen.set(fullscreen.isFullscreen(document));
fullscreen.CHANGE_EVENTS.forEach(name => document.addEventListener(name, this.onFullscreenChange));
this.autorun(() => {
Meteor.userId();
FlowRouter.watchPathChange();
@@ -91,7 +105,19 @@ Template.header.onRendered(function () {
});
});
Template.header.onDestroyed(function () {
if (this.onFullscreenChange) {
fullscreen.CHANGE_EVENTS.forEach(name => document.removeEventListener(name, this.onFullscreenChange));
}
});
Template.header.helpers({
fullscreenSupported() {
return Template.instance().fullscreenSupported.get();
},
isFullscreen() {
return Template.instance().isFullscreen.get();
},
logoSettingsReady() {
return Template.instance().subscriptionsReady();
},
@@ -316,6 +342,14 @@ Template.header.helpers({
return ['waiting', 'failed', 'offline'].includes(status.status);
},
announcementForUser() {
const user = ReactiveCache.getCurrentUser();
return showAnnouncementToUser({
userId: user && user._id,
board: Utils.getCurrentBoard(),
});
},
hasAnnouncement() {
const announcement = Announcements.findOne();
if (!announcement || !announcement.enabled) {
@@ -356,6 +390,13 @@ Template.header.helpers({
});
Template.header.events({
// Full screen on and off. The request is made inside this click, which is the
// user gesture browsers require; a refusal is not an error (models/lib/fullscreen.js).
'click .js-toggle-fullscreen'(event) {
event.preventDefault();
fullscreen.toggle(document);
},
// The starred boards, by name. They were listed inline in this bar; the bar
// is one row now and the names are in the popup. docs/Features/Header-Bar/Header.md
// Titled "Starred Boards", from the key the app already has for that phrase,
@@ -7,7 +7,7 @@ template(name="historyRecoveryNotice")
.history-recovery-notice.js-history-recovery-notice(role="status" aria-live="polite")
p {{_ pending.messageKey}}
p.quiet {{_ 'history-request-hint'}}
button.primary.js-history-retry(type="button" disabled=busy) {{_ 'history-request-retry'}}
button.primary.js-history-retry(type="button" disabled=retryDisabled) {{_ 'history-request-retry'}}
button.js-history-forget(type="button" disabled=busy) {{_ 'history-request-forget'}}
if checkpoint
.history-recovery-notice.history-checkpoint-notice.js-history-checkpoint-notice(role="status" aria-live="polite" class=checkpointPlacement)
@@ -52,6 +52,13 @@ Template.historyRecoveryNotice.helpers({
busy() {
return Template.instance().busy.get();
},
// undoRedoLast does nothing for someone who may not write on the board, and
// right after a reload that is everyone until the board and its members
// arrive: the notice shows first, and a Retry clicked then was dropped
// without a word. It is enabled once the retry can act.
retryDisabled() {
return Template.instance().busy.get() || !Utils.canModifyBoard();
},
});
async function resolve(tpl, action) {
@@ -4,7 +4,8 @@ template(name="shortcutsModalTitle")
| {{_ 'keyboard-shortcuts'}}
template(name="keyboardShortcuts")
.wrapper.shortcuts-list
//- tabindex: the list scrolls when the window is short, and a scrolling region must be reachable by keyboard (WCAG 2.1.1; axe scrollable-region-focusable).
.wrapper.shortcuts-list(tabindex="0" role="region" aria-label="{{_ 'keyboard-shortcuts'}}")
each mapping
.shortcuts-list-item
.shortcuts-list-item-keys
+87 -2
View File
@@ -49,6 +49,20 @@ summary:focus-visible,
box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.35);
border-radius: 2px;
}
/* #6755: a member who turned off "Show focus outline" (Member Settings / Change
* Settings) sees no ring: the outline and its halo, for keyboard focus and for the
* fallback below. Field borders and the colour a button takes on focus are not the
* ring and stay. The ring is an accessibility feature, so it is on unless hidden. */
body.has-hide-focus-outline :focus-visible,
body.has-hide-focus-outline a:focus,
body.has-hide-focus-outline button:focus,
body.has-hide-focus-outline [tabindex]:focus {
outline: none !important;
outline-offset: 0 !important;
}
body.has-hide-focus-outline :is(a, button, input, select, textarea, summary, [tabindex], [role="button"], [contenteditable="true"]):focus-visible {
box-shadow: none !important;
}
/* Browsers that do not support :focus-visible fall back to :focus so keyboard
* users are never left without an indicator. */
.no-focus-visible-support a:focus,
@@ -378,14 +392,14 @@ h6 {
}
.quiet,
.quiet a {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
}
.error,
.error a {
color: #eb3800;
}
.no-items-message {
color: #666;
color: var(--wekan-quiet-text, #595959);
margin: 36px 0;
text-align: center;
}
@@ -1133,6 +1147,77 @@ a:not(.disabled).is-active i.fa {
align-items: center;
justify-content: center;
}
/* The administrator's login page background colour (layouts.js sets the two
custom properties and the class while the sign-in layout is shown). The
foreground is white on a dark colour and black on a light one. */
body.has-login-background {
background: var(--login-background);
}
body.has-login-background .textBelowCustomLoginLogo,
body.has-login-background .textBelowCustomLoginLogo a {
color: var(--login-foreground);
}
body.has-login-background hr {
border-color: var(--login-foreground);
opacity: 0.4;
}
/* The text under the login logo: one column, real headings, space between
paragraphs. It has no card or background of its own, so the page colour shows
through. */
.textBelowCustomLoginLogo > * {
width: 100%;
max-width: 860px;
box-sizing: border-box;
padding: 0 16px;
}
.textBelowCustomLoginLogo h1,
.textBelowCustomLoginLogo h2,
.textBelowCustomLoginLogo h3,
.support-page-content h1,
.support-page-content h2,
.support-page-content h3 {
font-weight: 700;
line-height: 1.25;
margin: 1em 0 0.5em;
}
.textBelowCustomLoginLogo h1,
.support-page-content h1 { font-size: 1.8em; }
.textBelowCustomLoginLogo h2,
.support-page-content h2 { font-size: 1.45em; }
.textBelowCustomLoginLogo h3,
.support-page-content h3 { font-size: 1.2em; }
.textBelowCustomLoginLogo p,
.textBelowCustomLoginLogo ul,
.textBelowCustomLoginLogo ol,
.support-page-content p,
.support-page-content ul,
.support-page-content ol {
margin: 0 0 1em;
}
/* The support page: a centred column instead of text flush against the window's
left edge. */
.support-page {
padding: 24px 16px;
}
.support-page-content {
box-sizing: border-box;
max-width: 860px;
margin: 0 auto;
padding: 24px 28px;
background: #fff;
color: #1a1a1a;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
overflow-wrap: anywhere;
}
@media (max-width: 600px) {
.support-page {
padding: 8px;
}
.support-page-content {
padding: 16px;
}
}
.auth-layout .auth-dialog {
margin: 0 !important;
}
+7 -4
View File
@@ -27,11 +27,12 @@ template(name="userFormsLayout")
section.auth-layout
if logoSettingsReady
if currentSetting.hideLogo
h1.at-form-landing-logo
//- A spacer, not a heading: an empty h1 is announced as an empty heading.
div.at-form-landing-logo(aria-hidden="true")
br
br
unless currentSetting.hideLogo
h1.at-form-landing-logo
div.at-form-landing-logo(aria-hidden="true")
if currentSetting.customLoginLogoImageUrl
if currentSetting.customLoginLogoLinkUrl
a(href="{{currentSetting.customLoginLogoLinkUrl}}")
@@ -49,13 +50,15 @@ template(name="userFormsLayout")
+viewer
| {{currentSetting.textBelowCustomLoginLogo}}
hr
section.auth-layout
//- role="main": the sign-in and register form is the page's main landmark
(WCAG 1.3.1 / 2.4.1), so the title, the links and the language picker are in one.
section.auth-layout(role="main")
section.auth-dialog
if isLoading
+loader
else
// ARIA live region for error messages
div#login-error-message(role="alert" aria-live="assertive" style="color: #d32f2f; margin-bottom: 1em;")
div#login-error-message(role="alert" aria-live="assertive" style="color: #d32f2f; margin-bottom: 1em;")= loginErrorText
// Second sign-in step for accounts-2fa (issue #3058). Hidden unless a
// password login comes back with the "no-2fa-code" / "invalid-2fa-code"
// error - see decideTwoFactorLoginStep() in
+60 -5
View File
@@ -2,6 +2,7 @@ import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
const { OAUTH_PROVIDERS, providerByKey } = require('/models/lib/oauthProviders');
const { loginOriginMismatch, offersProviderLogin } = require('/models/lib/loginOriginMismatch');
const { loginBackgroundStyle } = require('/models/lib/loginBackground');
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { Accounts } from 'meteor/accounts-base';
import Users from '/models/users';
@@ -303,6 +304,7 @@ Template.userFormsLayout.onDestroyed(function () {
});
Template.userFormsLayout.helpers({
loginErrorText() { return loginErrorText.get(); },
passkeysSupported() { return require('/client/lib/passkeys').passkeysSupported(); },
passkeyMessage() { return Template.instance().passkeyMessage.get(); },
windowsSsoOffered() { return Meteor.settings?.public?.windowsSsoEnabled === true; },
@@ -557,20 +559,23 @@ Tracker.autorun(() => {
}
});
// The message in #login-error-message, rendered by the template as text. Not
// written into the element: it is inside `if isLoading`, and when that flips
// Blaze draws a new, empty element - a SAML popup error shown just before
// then was gone (saml-popup-error.e2e.js, Firefox). Text, never HTML.
const loginErrorText = new ReactiveVar('');
function showLoginError(err) {
const errorDiv = document.getElementById('login-error-message');
if (!errorDiv) return;
if (!err) {
errorDiv.textContent = '';
loginErrorText.set('');
return;
}
const reason = err.reason || err.message || '';
// #2460: an account its member locked to SQRL says so in their language.
const translated = ['oauth-account-conflict', 'sqrl-only', 'sqrl-hardlock']
.find(key => err.error === key || reason === key);
errorDiv.textContent = translated
loginErrorText.set(translated
? TAPi18n.__(translated)
: reason || String(err.error || err);
: reason || String(err.error || err));
}
Template.defaultLayout.events({
@@ -668,6 +673,56 @@ Template.userFormsLayout.onDestroyed(function () {
this.removeAuthenticationSubmit?.();
});
// The administrator's login page background colour (Admin Panel -> Lay-out).
// It is put on <body> only while the sign-in layout is shown, as two custom
// properties and a class, and taken off again when it closes so the pages after
// sign-in keep their own background. loginBackgroundStyle accepts a hex colour
// only, so the value can never carry CSS of its own.
const LOGIN_BACKGROUND_PROPERTIES = ['--login-background', '--login-foreground'];
function clearLoginBackground() {
document.body.classList.remove('has-login-background');
LOGIN_BACKGROUND_PROPERTIES.forEach(name => document.body.style.removeProperty(name));
}
Template.userFormsLayout.onRendered(function () {
this.autorun(() => {
const setting = this.currentSetting.get();
const style = loginBackgroundStyle(setting && setting.loginBackgroundColor);
if (!style) {
clearLoginBackground();
return;
}
document.body.style.setProperty('--login-background', style.background);
document.body.style.setProperty('--login-foreground', style.foreground);
document.body.classList.add('has-login-background');
});
});
Template.userFormsLayout.onDestroyed(clearLoginBackground);
// The form's title ("Sign In", "Create an Account") is an h3 of the useraccounts
// package and the first heading of the page: a level-one heading, whatever the
// package draws it as (WCAG 1.3.1; axe page-has-heading-one and heading-order).
// The package redraws the form when the user moves between sign in and register,
// so the title is marked again whenever the dialog changes.
Template.userFormsLayout.onRendered(function () {
const dialog = this.find('.auth-dialog');
if (!dialog) return;
const mark = () => {
dialog.querySelectorAll('.at-title h1, .at-title h2, .at-title h3').forEach(heading => {
if (heading.getAttribute('aria-level') !== '1') heading.setAttribute('aria-level', '1');
});
};
mark();
this.titleObserver = new MutationObserver(mark);
this.titleObserver.observe(dialog, { childList: true, subtree: true });
});
Template.userFormsLayout.onDestroyed(function () {
if (this.titleObserver) this.titleObserver.disconnect();
});
function getAuthenticationMethod(
settings,
match,
@@ -111,7 +111,7 @@
/* Disabled = the same themed outline, faded. Not a grey block. */
/* A disabled pager still has to look like the THEME. It is filled with the
theme colour, and dimming it is what takes the theme back out: at 0.4 the
belize blue #2980b9 arrived on the page's grey as rgb(122,170,202), a pale
belize blue #2573a7 arrived on the page's grey as rgb(122,170,202), a pale
wash with no family resemblance to the header above it - and on a one-page
list, where BOTH arrows are disabled, that is the whole pager.
+10 -5
View File
@@ -39,6 +39,10 @@
first row of a popup, and it must match the space beside it: `.content` has 18px of
side padding, so 6 + 12 = 18 makes the top gap and the left gutter the same. */
.pop-over .header {
/* A popup is moved by its title bar (popup.js, Pointer Events). Without this a
finger drag on it is taken for a scroll and the pointer is cancelled, so only a
mouse could move it. */
touch-action: none;
height: calc(41px * var(--wekan-ui-font-scale, 1));
position: relative;
margin-bottom: 6px;
@@ -807,7 +811,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
above pins every row's text to `#000 !important`; the icon is named separately
for the same reason. */
.pop-over-list li > a:not(.disabled):hover {
background-color: var(--theme-accent, #2980b9);
background-color: var(--theme-accent, #2573a7);
color: #fff !important;
}
.pop-over-list li > a:not(.disabled):hover i,
@@ -822,7 +826,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
background: #fff;
}
.pop-over-list li > a .sub-name {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
display: block;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: 400;
@@ -867,7 +871,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
background-color: #2e85b8;
}
.pop-over-list li > a.disabled {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
cursor: default;
}
.pop-over-list li > a.disabled .vis-icon {
@@ -878,7 +882,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
}
.pop-over-list li > a.disabled:hover .sub-name,
.pop-over-list li > a.disabled:hover .quiet {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
}
.pop-over-list li > a.disabled:active {
background: none;
@@ -972,7 +976,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
user's own theme, the board's colour or the site theme - whichever is in
force. The fallback is the stock WeKan blue, so an unthemed instance is
unchanged. */
background: var(--theme-accent, #2980b9);
background: var(--theme-accent, #2573a7);
height: calc(48px * var(--wekan-ui-font-scale, 1));
padding: 0px 0px;
border: 0px;
@@ -1441,6 +1445,7 @@ body.mobile-mode .pop-over .content-wrapper {
button (popup.js MOVABLE_POPUPS / MAXIMIZABLE_POPUPS), placed like the opened
card's: the handle before the title, the button beside the close button. */
.pop-over .header .popup-drag-handle {
touch-action: none;
float: inline-start;
padding: 12px 0 12px 12px;
cursor: move;
@@ -43,3 +43,6 @@ template(name="themeColorPicker")
input.js-theme-wheel(type="color" data-index="{{index}}" value="{{value}}" title="{{_ 'custom-color'}}")
.theme-preview
span.background-box(class="{{previewClass}}" style="{{previewStyle}}")
//- WCAG 1.4.3: a colour that white text cannot be read on is darkened; this says so.
if customAdjusted
p.theme-custom-adjusted.js-theme-custom-adjusted {{_ 'custom-color'}}: {{customAdjusted}} (4.5:1)
+13 -3
View File
@@ -2,7 +2,7 @@ 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 { isHexColor, toHex, readableAccent } from '/models/lib/contrastColor';
import { BOARD_COLORS } from '/models/metadata/colors';
import {
THEME_CATEGORY_ORDER,
@@ -33,7 +33,7 @@ import {
// 3. the user's own override
// See docs/Features/General/Theme/Change-Color.md for the whole design.
const DEFAULT_WHEEL = ['#2980b9', '#6dd5fa']; // stock flat accent / second slide stop
const DEFAULT_WHEEL = ['#2573a7', '#6dd5fa']; // stock flat accent / second slide stop
function readCurrent(scope) {
if (scope === 'board') {
@@ -138,7 +138,10 @@ Template.themeColorPicker.helpers({
const tpl = Template.instance();
const cat = customCategory(tpl);
if (!allowsCustomColor(cat)) return '';
const cc = tpl.customColors.get();
// The colours as the theme applies them: made readable under white text
// (models/lib/themeAccents.js activeAccent), so the preview is what the header
// will be, not what the wheel showed.
const cc = tpl.customColors.get().map(c => (c ? readableAccent(c) : c));
if (!cc.some(Boolean)) return '';
if (customColorCount(cat) === 2 && cc[0] && cc[1]) {
return `background: linear-gradient(135deg, ${cc[0]}, ${cc[1]}) !important;`;
@@ -146,6 +149,13 @@ Template.themeColorPicker.helpers({
if (cc[0]) return `background: ${cc[0]} !important;`;
return '';
},
// The colours that had to change to be readable, for the note under the preview.
customAdjusted() {
const tpl = Template.instance();
const chosen = tpl.customColors.get().filter(Boolean).map(c => toHex(c)).filter(Boolean);
const changed = chosen.map(c => readableAccent(c)).filter((c, i) => c !== chosen[i]);
return changed.join(' ');
},
});
// Collect the custom colors to save: [] unless the user actually engaged a wheel, in
+12 -1
View File
@@ -61,9 +61,20 @@ html.has-ui-font-size {
* follow this anyway; the ones with a colour of their own - a red alert, a
* green tick - keep saying what they mean. */
body.has-ui-text-color,
body.has-ui-text-color *:not(.fa) {
body.has-ui-text-color *:not(.fa):not(
#header, #header *,
#header-quick-access, #header-quick-access *,
.card-label, .card-label *,
button.primary, button.primary *
) {
color: var(--wekan-ui-text-color) !important;
}
/* The elements left out of the rule above sit on a COLOUR of their own - the
* theme's header bars, a label's colour, a primary button's accent - with a text
* colour that is chosen to read on it (4.5:1, WCAG 1.4.3). The member's colour is
* made legible on the page's surfaces (models/lib/uiFonts.js readableUiTextColor),
* which is not the same as legible on those: #626262 on the blue header was
* 1.18:1. */
/* There was a "text background color" here (#4759), a colour painted behind the
* text. It is REMOVED. A background needs elements to sit on, and neither choice
+16 -2
View File
@@ -1,6 +1,7 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { fontFamilyValue, fontSizeValue, fontScaleValue, colorValue } from '/models/lib/uiFonts';
import { fontFamilyValue, fontSizeValue, fontScaleValue, readableUiTextColor } from '/models/lib/uiFonts';
import { activeThemeName } from '/client/lib/activeTheme';
// #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
@@ -37,6 +38,17 @@ Meteor.startup(() => {
}
}
// #6755: a member who hid the focus outline (Member Settings / Change Settings):
// a class on <body>, which layouts.css turns into no ring.
Tracker.autorun(() => {
const user = Meteor.user();
try {
document.body.classList.toggle('has-hide-focus-outline', !!(user && user.profile && user.profile.hideFocusOutline === true));
} catch (_) {
// document.body may not exist yet in exotic embeddings; ignore.
}
});
Tracker.autorun(() => {
const user = Meteor.user();
const profile = (user && user.profile) || {};
@@ -50,7 +62,9 @@ Meteor.startup(() => {
// of it that happened to be written in rem. No class goes with it - a
// variable that is not set falls back to 1 in the calc itself.
toggle('--wekan-ui-font-scale', null, fontScaleValue(profile.uiFontSize));
toggle('--wekan-ui-text-color', 'has-ui-text-color', colorValue(profile.uiTextColor));
// The chosen colour, made legible on the surface of the theme in use (4.5:1,
// WCAG 1.4.3): a colour that already reads is applied as chosen.
toggle('--wekan-ui-text-color', 'has-ui-text-color', readableUiTextColor(profile.uiTextColor, activeThemeName()));
// There was a `--wekan-ui-bg-color` here, for "text background color". It is
// removed: a colour behind the text needs elements to sit on, and there is no
// set of them that looks right - on the boxes it painted the page, on the text
@@ -22,7 +22,7 @@ section#notifications-drawer {
padding-top: 36px;
}
section#notifications-drawer a:hover {
color: #2980b9 !important;
color: #2573a7 !important;
}
section#notifications-drawer .header {
position: fixed;
@@ -47,7 +47,7 @@ section#notifications-drawer .header .notification-menu-toggle {
line-height: calc(24px * var(--wekan-ui-font-scale, 1));
}
section#notifications-drawer .header .notification-menu-toggle:hover {
color: #2980b9;
color: #2573a7;
}
section#notifications-drawer .header .notification-menu {
position: absolute;
@@ -90,7 +90,7 @@ section#notifications-drawer .header .notification-menu .menu-item .check-icon {
min-width: 20px;
margin-inline-end: 8px;
text-align: center;
color: #2980b9;
color: #2573a7;
font-weight: bold;
}
section#notifications-drawer .header .notification-menu .menu-item .menu-icon {
+30 -3
View File
@@ -100,7 +100,7 @@
}
/* Drag-and-drop workflow editor */
.rules-workflow .workflow-help {
color: #727479;
color: var(--wekan-quiet-text, #5a5c61);
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
margin-bottom: 9px;
}
@@ -247,6 +247,33 @@
.rules-back .js-goback .fa-arrow-left {
color: #fff !important;
}
/* #6852: Back to the board, at the top of the Rules page. */
.rules-back-to-board {
margin-block: 8px;
margin-inline: 14px;
}
/* A link, so it opens the board as a link does (also in a new tab), drawn as
the primary buttons around it: forms.css styles only button.primary. */
.rules-back-to-board .js-rules-back-to-board {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 20px;
border-radius: 3px;
background: var(--theme-accent, #005377);
box-shadow: 0 1px 0 #4d4d4d;
color: #fff;
font-weight: 700;
text-decoration: none;
}
.rules-back-to-board .js-rules-back-to-board:hover,
.rules-back-to-board .js-rules-back-to-board:focus {
background: var(--theme-accent, #004766);
color: #fff;
}
.rules-back-to-board .js-rules-back-to-board .fa-arrow-left {
color: #fff !important;
}
.flex,
.triggers-content .triggers-body .triggers-side-menu ul li a {
display: -webkit-box;
@@ -257,7 +284,7 @@
display: flex;
}
.triggers-content {
color: #727479;
color: var(--wekan-quiet-text, #5a5c61);
background: #dedede;
}
.triggers-content .triggers-body {
@@ -410,7 +437,7 @@
}
.rules-import-export .rules-ie-hint {
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
color: #727479;
color: var(--wekan-quiet-text, #5a5c61);
margin-bottom: 0.3em;
}
.rules-import-export .rules-ie-textarea {
+9
View File
@@ -6,6 +6,15 @@
template(name="rulesMain")
.wrapper
.rules-page(class=currentBoard.colorClass)
//- #6852: the way back to the board. The trigger, action and rule
details pages have their own Back, to the rules list, so this one is
shown only where there is none - two Backs going to two places would
be one too many.
if showBackToBoard
.rules-back-to-board
a.js-rules-back-to-board(href="{{pathFor 'board' id=currentBoard._id slug=currentBoard.slug}}" title="{{currentBoard.title}}")
i.fa.fa-arrow-left
| {{_ 'back'}}
//- #2217: who may edit sees every view; a member a board shows its Rules
to reads the rules list and rule details, with no control that
changes a rule; anybody else sees nothing (client/lib/rulesAccess.js).
+4
View File
@@ -44,6 +44,10 @@ Template.rulesMain.helpers({
rulesCurrentTab() {
return Template.instance().rulesCurrentTab;
},
// #6852: every Rules page but the three that already go Back to the list.
showBackToBoard() {
return !['trigger', 'action', 'ruleDetails'].includes(Template.instance().rulesCurrentTab.get());
},
ruleName() {
return Template.instance().ruleName;
},
@@ -36,8 +36,8 @@
}
.migration-progress-header {
/* WeKan blue (matches the main header #2980b9) */
background: linear-gradient(135deg, #2980b9 0%, #1a5276 100%);
/* WeKan blue (matches the main header #2573a7) */
background: linear-gradient(135deg, #2573a7 0%, #1a5276 100%);
color: white;
padding: 20px;
display: flex;
@@ -87,7 +87,7 @@
.migration-progress-overall-fill {
/* WeKan blue */
background: linear-gradient(90deg, #2980b9, #3498db);
background: linear-gradient(90deg, #2573a7, #3498db);
height: 100%;
border-radius: 10px;
transition: width 0.3s ease;
@@ -138,7 +138,7 @@
.migration-progress-step-fill {
/* WeKan blue */
background: linear-gradient(90deg, #3498db, #2980b9);
background: linear-gradient(90deg, #3498db, #2573a7);
height: 100%;
border-radius: 8px;
transition: width 0.3s ease;
@@ -156,7 +156,7 @@
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
border-inline-start: 4px solid #2980b9;
border-inline-start: 4px solid #2573a7;
}
.migration-progress-status-label {
+7 -7
View File
@@ -2029,7 +2029,7 @@ Template.modifyTeamsUsers.events({
currentUser = ReactiveCache.getUser(selectedUserChkBoxUserIds[i]);
// Copy, so the cached minimongo document is not mutated in place.
userTms = (currentUser.teams || []).slice();
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId});
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId;});
if(currUserTeamIndex == -1){
userTms.push({
"teamId": selectedEltValueId,
@@ -2048,7 +2048,7 @@ Template.modifyTeamsUsers.events({
for(let i = 0; i < selectedUserChkBoxUserIds.length; i++){
currentUser = ReactiveCache.getUser(selectedUserChkBoxUserIds[i]);
userTms = (currentUser.teams || []).slice();
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId});
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId;});
if(currUserTeamIndex != -1){
userTms.splice(currUserTeamIndex, 1);
}
@@ -2336,7 +2336,7 @@ Template.editUserPopup.events({
userTms.push({
"teamId": userTeamsIdsList[i],
"teamDisplayName": userTeamsList[i],
})
});
}
}
@@ -2349,7 +2349,7 @@ Template.editUserPopup.events({
userOrganizations.push({
"orgId": userOrgsIdsList[i],
"orgDisplayName": userOrgsList[i],
})
});
}
}
@@ -2553,7 +2553,7 @@ const UpdateUserOrgsOrTeamsElement = function(isNewUser = false){
}
selectedElt.value = "-1";
selectedElt.style.display = "none";
}
};
Template.newOrgPopup.events({
submit(event, templateInstance) {
@@ -2633,7 +2633,7 @@ Template.newUserPopup.events({
userTms.push({
"teamId": userTeamsIdsList[i],
"teamDisplayName": userTeamsList[i],
})
});
}
}
@@ -2645,7 +2645,7 @@ Template.newUserPopup.events({
userOrganizations.push({
"orgId": userOrgsIdsList[i],
"orgDisplayName": userOrgsList[i],
})
});
}
}
+7 -7
View File
@@ -10,7 +10,7 @@
height: 100%;
}
.setting-content {
color: #727479;
color: var(--wekan-quiet-text, #5a5c61);
background: #dedede;
width: 100%;
height: 100%;
@@ -99,7 +99,7 @@
selected entry back to white as soon as the pointer crossed it. */
.setting-content .content-body .side-menu ul li.active,
.setting-content .content-body .side-menu ul li.active:hover {
background: var(--theme-accent, #2980b9);
background: var(--theme-accent, #2573a7);
}
/* …and when a THEME is active, the row is painted by that theme's own #header rule
@@ -488,7 +488,7 @@ body.left-menu-resizing-active {
}
.setting-content label:has(> input[type="checkbox"]:focus-visible) {
outline: 2px solid #2980b9;
outline: 2px solid #2573a7;
outline-offset: 2px;
border-radius: 3px;
}
@@ -511,7 +511,7 @@ body.left-menu-resizing-active {
}
.setting-content input[type="checkbox"]:focus-visible {
outline: 2px solid var(--theme-accent, #2980b9);
outline: 2px solid var(--theme-accent, #2573a7);
outline-offset: 2px;
}
@@ -562,13 +562,13 @@ body.left-menu-resizing-active {
}
.setting-content .schedule-day-btn.active {
background: var(--theme-accent, #2980b9);
border-color: var(--theme-accent, #2980b9);
background: var(--theme-accent, #2573a7);
border-color: var(--theme-accent, #2573a7);
color: #fff;
}
.setting-content .schedule-day-btn:focus-visible {
outline: 2px solid var(--theme-accent, #2980b9);
outline: 2px solid var(--theme-accent, #2573a7);
outline-offset: 2px;
}
@@ -630,6 +630,10 @@ template(name='tableVisibilityModeSettings')
.title {{_ 'text-below-custom-login-logo'}}
.form-group
textarea#text-below-custom-login-logo.wekan-form-control(data-editor-submit=".js-visibility-logo-save")= currentSetting.textBelowCustomLoginLogo
li.tableVisibilityMode-form
.title {{_ 'login-background-color'}}
.form-group
input.wekan-form-control#login-background-color(type="text", placeholder="#1b365d" value="{{currentSetting.loginBackgroundColor}}")
li.tableVisibilityMode-form
.title {{_ 'custom-top-left-corner-logo-image-url'}}
.form-group
@@ -811,6 +811,7 @@ Template.tableVisibilityModeSettings.events({
['#custom-login-logo-image-url', 'customLoginLogoImageUrl'],
['#custom-login-logo-link-url', 'customLoginLogoLinkUrl'],
['#text-below-custom-login-logo', 'textBelowCustomLoginLogo'],
['#login-background-color', 'loginBackgroundColor'],
['#custom-top-left-corner-logo-image-url', 'customTopLeftCornerLogoImageUrl'],
['#custom-top-left-corner-logo-link-url', 'customTopLeftCornerLogoLinkUrl'],
['#custom-top-left-corner-logo-height', 'customTopLeftCornerLogoHeight'],
+7 -1
View File
@@ -43,7 +43,7 @@
display: none;
}
.sidebar .sidebar-content h3 {
color: #808080;
color: var(--wekan-quiet-text, #595959);
font-size: 1em;
margin-bottom: 10px;
font-weight: bold;
@@ -750,6 +750,12 @@ body.sidebar-resizing-active {
margin-inline-end: 6px;
appearance: auto;
}
/* Why a date range was refused, below its buttons (sidebarFilters.js,
rangeRefused): the browser's own bubble covered those buttons in Firefox. */
.filter-date-range-error {
color: #c0392b;
margin: 6px 0 0;
}
.filter-native-group {
border: 0;
padding: 0;
+5 -5
View File
@@ -3782,7 +3782,7 @@ Template.addMemberPopup.helpers({
const board = ReactiveCache.getBoard(boardId);
return board && board.hasMember(userId);
}
})
});
Template.addBoardOrgPopup.onCreated(function() {
initGroupPickerState(this, 'org');
@@ -3823,7 +3823,7 @@ Template.addBoardOrgPopup.events({
"orgId": selectedOrgId,
"orgDisplayName": selectedOrgDisplayName,
"isActive" : true,
})
});
if (selectedOrgId != "-1") {
Meteor.call('setBoardOrgs', boardOrganizations, currentBoard._id);
@@ -3994,7 +3994,7 @@ Template.addBoardTeamPopup.events({
"teamId": selectedTeamId,
"teamDisplayName": selectedTeamDisplayName,
"isActive" : true,
})
});
if (selectedTeamId != "-1") {
let members = currentBoard.members;
@@ -4011,7 +4011,7 @@ Template.addBoardTeamPopup.events({
let index;
if (boardTeamUsers && boardTeamUsers.length > 0) {
boardTeamUsers.forEach((u) => {
index = members.findIndex(function(m){ return m.userId == u._id});
index = members.findIndex(function(m){ return m.userId == u._id;});
if(index == -1){
members.push({
"isActive": true,
@@ -4076,7 +4076,7 @@ Template.removeBoardTeamPopup.events({
let index;
if (boardTeamUsers && boardTeamUsers.length > 0) {
boardTeamUsers.forEach((u) => {
index = members.findIndex(function(m){ return m.userId == u._id});
index = members.findIndex(function(m){ return m.userId == u._id;});
if(index !== -1 && !members[index].isAdmin){
members.splice(index, 1);
}
@@ -140,7 +140,10 @@ template(name="filterSidebar")
| {{_ 'filter-dates-label' }}
each dateFilterProviderViews
+Template.dynamic(template=template data=data)
form.js-card-movement-range
//- novalidate: the range is checked in the submit handler (rangeRefused in
sidebarFilters.js). WebKit keeps a half-edited date segment after Clear
empties a field and then refused every submit of the form, with no message.
form.js-card-movement-range(novalidate)
h4 {{_ 'filter-movement-range'}}
label(for="filter-card-movement-from") {{_ 'filter-date-range-from'}}
input.js-card-movement-from(id="filter-card-movement-from" type="date" value=movementFrom)
@@ -148,7 +151,9 @@ template(name="filterSidebar")
input.js-card-movement-to(id="filter-card-movement-to" type="date" value=movementTo)
button.primary(type="submit") {{_ 'apply'}}
button.js-clear-movement-range(type="button") {{_ 'filter-clear'}}
form.js-card-date-range
if movementRangeError
p.js-card-movement-range-error.filter-date-range-error(role="alert") {{_ 'filter-date-range-invalid'}}
form.js-card-date-range(novalidate)
label(for="filter-card-date-field") {{_ 'filter-date-range-field'}}
select.js-card-date-field(id="filter-card-date-field")
each dateRangeFields
@@ -162,6 +167,8 @@ template(name="filterSidebar")
| {{_ 'filter-date-range-missing'}}
button.primary(type="submit") {{_ 'apply'}}
button.js-clear-date-range(type="button") {{_ 'filter-clear'}}
if dateRangeError
p.js-card-date-range-error.filter-date-range-error(role="alert") {{_ 'filter-date-range-invalid'}}
hr
fieldset.filter-native-group
legend {{_ 'due-date'}}
+26 -12
View File
@@ -13,9 +13,23 @@ import { getSidebarInstance } from '/client/features/sidebar/service';
import { CARD_DATE_RANGE_FIELDS, CARD_RECENCY_PRESETS } from '/models/lib/cardDateRange';
import { DEPENDENCY_TYPES } from '/models/metadata/dependencies';
// A refused date range marks its end field invalid - so it is :invalid and
// screen readers announce the reason - and says why in the form itself. Not
// with reportValidity(): Firefox's validation bubble opens over the Apply and
// Clear buttons below the field and takes every click on them while the field
// has focus, so the range could not be cleared at all.
function rangeRefused(end, error, refused) {
end.setCustomValidity(refused ? TAPi18n.__('filter-date-range-invalid') : '');
error.set(refused);
}
Template.filterSidebar.onCreated(function () {
this.presetMessage = new ReactiveVar('');
this.presetBusy = new ReactiveVar(false);
// Why a date range was refused, shown in the form rather than in the
// browser's own validation bubble (see rangeRefused below).
this.movementRangeError = new ReactiveVar(false);
this.dateRangeError = new ReactiveVar(false);
this.autorun(() => {
const boardId = Session.get('currentBoard');
if (Meteor.userId() && boardId) this.subscribe('savedCardFilters', boardId);
@@ -28,6 +42,8 @@ Template.filterSidebar.helpers({
},
presetMessage() { return Template.instance().presetMessage.get(); },
presetBusy() { return Template.instance().presetBusy.get(); },
movementRangeError() { return Template.instance().movementRangeError.get(); },
dateRangeError() { return Template.instance().dateRangeError.get(); },
filterProviderViews() { return Filter.providers.views(); },
dateFilterProviderViews() { return Filter.providers.views('dates'); },
dueUnrestricted() { return Filter.dueAt.state() === null; },
@@ -102,15 +118,15 @@ Template.filterSidebar.events({
const end = tpl.find('.js-card-movement-to');
const valid = Filter.movementDate.set({ field: 'createdAt',
from: tpl.find('.js-card-movement-from').value, to: end.value, includeMissing: false });
end.setCustomValidity(valid ? '' : TAPi18n.__('filter-date-range-invalid'));
if (!valid) end.reportValidity();
else Filter.resetExceptions();
rangeRefused(end, tpl.movementRangeError, !valid);
if (valid) Filter.resetExceptions();
},
'input .js-card-movement-range input'(event, tpl) {
tpl.find('.js-card-movement-to').setCustomValidity('');
rangeRefused(tpl.find('.js-card-movement-to'), tpl.movementRangeError, false);
},
'click .js-clear-movement-range'(event, tpl) {
event.preventDefault(); tpl.find('.js-card-movement-to').setCustomValidity('');
event.preventDefault();
rangeRefused(tpl.find('.js-card-movement-to'), tpl.movementRangeError, false);
Filter.movementDate.reset(); Filter.resetExceptions();
},
'submit .js-card-date-range'(event, tpl) {
@@ -119,22 +135,20 @@ Template.filterSidebar.events({
const valid = Filter.dateRange.set({ field: tpl.find('.js-card-date-field').value,
from: tpl.find('.js-card-date-from').value, to: end.value,
includeMissing: tpl.find('.js-card-date-missing').checked });
end.setCustomValidity(valid ? '' : TAPi18n.__('filter-date-range-invalid'));
if (!valid) end.reportValidity();
else Filter.resetExceptions();
rangeRefused(end, tpl.dateRangeError, !valid);
if (valid) Filter.resetExceptions();
},
'input .js-card-date-range input'(event, tpl) {
tpl.find('.js-card-date-to').setCustomValidity('');
rangeRefused(tpl.find('.js-card-date-to'), tpl.dateRangeError, false);
},
'click .js-clear-date-range'(event, tpl) {
event.preventDefault();
// Reset the native date controls as well as the reactive model. WebKit can
// retain an edited date segment after Blaze changes the value attribute.
for (const selector of ['.js-card-date-from', '.js-card-date-to']) {
const input = tpl.find(selector);
input.value = '';
input.setCustomValidity('');
tpl.find(selector).value = '';
}
rangeRefused(tpl.find('.js-card-date-to'), tpl.dateRangeError, false);
Filter.dateRange.reset(); Filter.resetExceptions();
},
'submit .js-column-age-filter'(event, tpl) {
+3 -3
View File
@@ -417,14 +417,14 @@ body.swimlane-resizing-active * {
`outline`, not `border` or `box-shadow`: it is drawn outside the box and so
moves neither the swimlane's rows nor the list's cards by a pixel. */
.is-revealed {
outline: 3px solid #2980b9;
outline: 3px solid #2573a7;
outline-offset: -3px;
animation: wekan-reveal-fade 2s ease-out forwards;
}
@keyframes wekan-reveal-fade {
0% { outline-color: #2980b9; }
70% { outline-color: #2980b9; }
0% { outline-color: #2573a7; }
70% { outline-color: #2573a7; }
100% { outline-color: transparent; }
}
+21 -3
View File
@@ -1039,12 +1039,30 @@ Template.addListInline.events({
// #2809
'click .js-link-list': Popup.open('linkList'),
'click .js-close-add-list-inline'() {
Session.set('wekan-add-list-after', null);
Session.set('wekan-add-list-swimlane', null);
Session.set('wekan-add-list-empty', null);
closeAddListComposer();
},
});
// Close the inline add-list composer (the one opened after a list, or in an empty
// swimlane). The X in the composer and Escape do the same thing.
function closeAddListComposer() {
Session.set('wekan-add-list-after', null);
Session.set('wekan-add-list-swimlane', null);
Session.set('wekan-add-list-empty', null);
}
// #6865: Escape closes the add-list composer. Only the X did: the composer stays
// open after a list is created, so one can add several, and then Escape did
// nothing. It is closed at the level of an inlined form (below popups and modals,
// above the card's details and the sidebar), and not by a click elsewhere, as an
// inlined form is not.
EscapeActions.register(
'inlinedForm',
closeAddListComposer,
() => !!(Session.get('wekan-add-list-after') || Session.get('wekan-add-list-empty')),
{ enabledOnClick: false },
);
Template.listsGroup.helpers({
// #6745: board-role checks only (see listHeader.js) - not the whole user.
currentUser() {
+2 -2
View File
@@ -140,7 +140,7 @@
display: flex;
align-items: center;
padding: 0 12px;
color: #8c8c8c !important;
color: var(--wekan-quiet-text, #595959) !important;
}
/* Outweighs .pop-over-list li > a:not(.disabled):hover, which would otherwise
paint the delete button with the generic blue row-hover colour. */
@@ -149,7 +149,7 @@
background-color: #b04632;
}
.avatar-upload-hints {
color: #8c8c8c;
color: var(--wekan-quiet-text, #595959);
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
line-height: calc(16px * var(--wekan-ui-font-scale, 1));
margin: 6px 0;
+3 -1
View File
@@ -1,5 +1,7 @@
template(name="userAvatar")
a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}} {{memberAccountClass}}" title="{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{#if isImportedMember}} — {{_ 'imported-member-no-account'}}{{else}}{{#if isInactiveMember}} — {{_ 'inactive-member'}}{{/if}}{{/if}}{{/if}}")
//- WCAG 2.5.3 Label in Name: the visible text of an initials avatar is its initials,
//- so the accessible name starts with them (initialsInName). A picture has no text.
a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}} {{memberAccountClass}}" title="{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{#if isImportedMember}} — {{_ 'imported-member-no-account'}}{{else}}{{#if isInactiveMember}} — {{_ 'inactive-member'}}{{/if}}{{/if}}{{/if}}" aria-label="{{initialsInName}}{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{#if isImportedMember}} — {{_ 'imported-member-no-account'}}{{else}}{{#if isInactiveMember}} — {{_ 'inactive-member'}}{{/if}}{{/if}}{{/if}}")
if userData
if userData.profile.avatarUrl
img.avatar.avatar-image(src="{{avatarUrl}}" alt="")
+12
View File
@@ -45,6 +45,18 @@ const AVATAR_USER_FIELDS = {
};
Template.userAvatar.helpers({
// WCAG 2.5.3 Label in Name: an avatar drawn as initials shows those letters as
// text, so the link's accessible name has to contain them. The name is the
// initials followed by the title, "AB Ann Brown (ann) Admin". A picture shows
// no text, so its name stays the title.
initialsInName() {
const user = ReactiveCache.getUser(this.userId, { fields: AVATAR_USER_FIELDS });
if (user && user.profile && user.profile.avatarUrl) return '';
const defaultAvatar = Template.userAvatar.__helpers.get('defaultAvatarUrl');
if (user && defaultAvatar && defaultAvatar.call(this)) return '';
const initials = (user && user.getInitials && user.getInitials()) || '';
return initials ? `${initials} ` : '';
},
defaultAvatarUrl() {
if (!(Meteor.settings.public && Meteor.settings.public.defaultAvatar)) return '';
const userId = this.userId;
+13 -1
View File
@@ -1,6 +1,8 @@
template(name="headerUserBar")
#header-user-bar
a.header-user-bar-name.js-open-header-member-menu(title="{{_ 'memberMenuPopup-title'}}")
//- aria-label: WCAG 2.5.3 Label in Name - the name shown (and the initials of the
//- avatar) are part of the accessible name, followed by what the link opens.
a.header-user-bar-name.js-open-header-member-menu(title="{{_ 'memberMenuPopup-title'}}" aria-label="{{userBarLabel}}")
.header-user-bar-avatar
+userAvatar(userId=currentUser._id)
unless isMiniScreen
@@ -408,6 +410,9 @@ template(name="changeFontPopup")
.color-wheel-wrap
input.js-ui-text-color(type="color" value=textColorHex title="{{_ 'text-color'}}")
a.js-reset-text-color(class="{{#unless hasTextColor}}disabled{{/unless}}") {{_ 'remove-btn'}}
//- WCAG 1.4.3: the colour is made legible on the theme's surface; this says so.
if textColorAdjusted
p.ui-text-color-adjusted.js-ui-text-color-adjusted {{_ 'text-color'}}: {{readableTextColor}} (4.5:1)
template(name="changeSettingsPopup")
//- #6835: its own class - a popup keeps the screens before it (the member
@@ -434,6 +439,13 @@ template(name="changeSettingsPopup")
| {{_ 'keep-title-tags'}}
if isKeepTitleTags
i.fa.fa-check
//- #6755: the blue focus outline (an accessibility feature, so on by default).
li
a.js-toggle-hide-focus-outline
i.fa.fa-square-o
| {{_ 'show-focus-outline'}}
if isShowFocusOutline
i.fa.fa-check
//- #6827: the Add card form stays open after a card is added.
li
a.js-toggle-keep-add-card-form-open(title="{{_ 'keep-add-card-form-open-description'}}")
+34 -2
View File
@@ -9,9 +9,23 @@ import Users from '/models/users';
import { Utils } from '/client/lib/utils';
import { ReactiveVar } from 'meteor/reactive-var';
import { detectAvailableFonts } from '/client/lib/fontDetector';
import { fontFamilyValue, fontSizeValue, UI_FONT_SIZES, isHexColor6, colorValue } from '/models/lib/uiFonts';
import { fontFamilyValue, fontSizeValue, UI_FONT_SIZES, isHexColor6, colorValue, readableUiTextColor } from '/models/lib/uiFonts';
import { activeThemeName } from '/client/lib/activeTheme';
const { allBoardsPath, SECTION_ARCHIVE } = require('/models/lib/allBoardsUrls');
// WCAG 2.5.3 Label in Name: the link shows the avatar's initials (when the avatar
// is not a picture) and the name, so its accessible name starts with exactly
// those, followed by what it opens.
Template.headerUserBar.helpers({
userBarLabel() {
const user = ReactiveCache.getCurrentUser();
if (!user) return TAPi18n.__('memberMenuPopup-title');
const initials = Template.userAvatar.__helpers.get('initialsInName').call({ userId: user._id });
const name = (user.profile && user.profile.fullname) || user.username || '';
return `${initials}${name} ${TAPi18n.__('memberMenuPopup-title')}`.replace(/\s+/g, ' ').trim();
},
});
Template.headerUserBar.events({
'click .js-open-header-member-menu': Popup.open('memberMenu'),
'click .js-change-avatar': Popup.open('changeAvatar'),
@@ -489,6 +503,11 @@ Template.changeSettingsPopup.helpers({
const currentUser = ReactiveCache.getCurrentUser();
return currentUser ? currentUser.hasKeepTitleTags() : false;
},
// #6755: the outline is shown unless this member hid it.
isShowFocusOutline() {
const currentUser = ReactiveCache.getCurrentUser();
return currentUser ? !currentUser.hasHideFocusOutline() : true;
},
isKeepAddCardFormOpen() {
const currentUser = ReactiveCache.getCurrentUser();
return currentUser ? currentUser.hasKeepAddCardFormOpen() : false;
@@ -555,6 +574,9 @@ Template.changeSettingsPopup.events({
'click .js-toggle-unread-changes'() {
if (ReactiveCache.getCurrentUser()) Meteor.call('toggleUnreadChanges');
},
'click .js-toggle-hide-focus-outline'() {
if (ReactiveCache.getCurrentUser()) Meteor.call('toggleHideFocusOutline');
},
'click .js-toggle-keep-add-card-form-open'() {
if (ReactiveCache.getCurrentUser()) Meteor.call('toggleKeepAddCardFormOpen');
},
@@ -653,12 +675,22 @@ Template.changeFontPopup.helpers({
hasTextColor() {
return !!Template.instance().textColor.get();
},
// The chosen text color as it is applied: made legible on the theme's surface
// (4.5:1), and whether that changed it, so the member is told why the page does
// not show exactly what the wheel did.
readableTextColor() {
return readableUiTextColor(Template.instance().textColor.get(), activeThemeName());
},
textColorAdjusted() {
const chosen = colorValue(Template.instance().textColor.get());
return !!chosen && readableUiTextColor(chosen, activeThemeName()) !== chosen.toLowerCase();
},
// Preview reflects the chosen font, size and text color.
previewStyle() {
const tpl = Template.instance();
const family = fontFamilyValue(tpl.selected.get());
const size = fontSizeValue(tpl.selectedSize.get());
const color = colorValue(tpl.textColor.get());
const color = readableUiTextColor(tpl.textColor.get(), activeThemeName());
return [
family && `font-family: ${family};`,
size && `font-size: ${size};`,
+18
View File
@@ -0,0 +1,18 @@
import { Meteor } from 'meteor/meteor';
import { Session } from 'meteor/session';
import { ReactiveCache } from '/imports/reactiveCache';
// The name of the theme the page is drawn in, in the order of themes of
// client/components/main/globalThemeColor.js: the member's own override, then the
// board's own colour on a board page, then the site theme. '' for WeKan's default.
// Reactive, so a Tracker.autorun that reads it follows a change of theme.
export function activeThemeName() {
const user = Meteor.user();
const own = user && user.profile && user.profile.globalThemeColor;
if (own) return own;
const boardId = Session.get('currentBoard');
const board = boardId ? ReactiveCache.getBoard(boardId) : null;
if (board && board.color) return board.color;
const setting = ReactiveCache.getCurrentSetting();
return (!board && setting && setting.themeColor) || '';
}
+23
View File
@@ -0,0 +1,23 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
// #6870: whether the administrator blocked attachment uploads for the whole site
// (models/lib/attachmentUploadPolicy.js). Asked of the server once a minute at most; until it
// answers - or if it cannot - uploads are shown as before, because the server refuses them either
// way and a wrong guess here only costs a button.
const blocked = new ReactiveVar(false);
let askedAt = 0;
export function refreshUploadPolicy() {
if (Date.now() - askedAt < 60 * 1000) return;
askedAt = Date.now();
Meteor.callAsync('attachmentUploadPolicy')
.then(policy => blocked.set(!!(policy && policy.uploadsBlocked === true)))
.catch(() => { askedAt = 0; });
}
// reactive
export function attachmentUploadsBlocked() {
refreshUploadPolicy();
return blocked.get();
}
+2 -2
View File
@@ -419,7 +419,7 @@ export default Popup => {
const fileFullPath = `${zipDirName}/webfonts/${filename}`;
zip.file(fileFullPath, responseBody);
});
}
};
const downloadFontAwesomeFonts = async (zip, zipDirName) => {
// List of Font Awesome webfont files to include
@@ -476,7 +476,7 @@ export default Popup => {
} catch (e) {
console.log('Error in downloadFontAwesomeFonts:', e);
}
}
};
const downloadCardCovers = async (elements, zip, boardSlug, clonedHtmlElement, zipDirName) => {
await asyncForEach(elements, async elem => {
+24 -30
View File
@@ -53,7 +53,20 @@ hotkeys.filter = (event) => {
if (currentElement instanceof HTMLInputElement || currentElement instanceof HTMLSelectElement || currentElement instanceof HTMLTextAreaElement)
return false;
if (currentElement.closest('button, a[href], summary, [role="button"], [role="tab"], [role="checkbox"]'))
// A focused control keeps the keys it uses itself, and only those: Enter
// activates any of them, Space a button, summary or checkbox (not a link),
// and End, Home and the arrows move between tabs (A01). Every other key is
// still a shortcut. Refusing ALL keys left shortcuts dead after a click on
// a minicard - the minicard is a link, and clicking its date badge or its
// menu leaves that link focused - and after any popup closed, because
// closing returns focus to the button that opened it (#6755).
const keysOwnedBy = {
'button, a[href], summary, [role="button"], [role="tab"], [role="checkbox"]': [13],
'button, summary, [role="button"], [role="checkbox"]': [32],
'[role="tab"]': [35, 36, 37, 38, 39, 40],
};
if (Object.keys(keysOwnedBy).some(controls =>
keysOwnedBy[controls].includes(event.keyCode) && currentElement.closest(controls)))
return false;
// We can trigger events!
@@ -61,35 +74,16 @@ hotkeys.filter = (event) => {
};
// Handle non-Latin keyboards
window.addEventListener('keydown', (e) => {
// Only handle event if coming from body
if (e.target !== document.body) return;
// Only a character typed on a non-Latin layout: one non-ASCII character.
// Comparing it with the key code's lowercase letter also matched every
// SHIFTED Latin key ('D', '!', and 'Shift' itself), and re-dispatched it, so
// each Shift shortcut ran twice: Shift+D opened the due date editor and the
// copy clicked the same control again, closing it (#6750).
if (typeof e.key !== 'string' || e.key.length !== 1 || e.key.charCodeAt(0) < 128) return;
// Trigger the corresponding action by dispatching a new event with the ASCII key
const key = String.fromCharCode(e.which).toLowerCase();
if (!/^[a-z0-9]$/.test(key)) return;
// Create a synthetic event for hotkeys to handle, with the same modifiers,
// so Shift shortcuts keep working on non-Latin layouts.
const syntheticEvent = new KeyboardEvent('keydown', {
key: key,
keyCode: e.which,
which: e.which,
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey,
altKey: e.altKey,
metaKey: e.metaKey,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(syntheticEvent);
});
//
// Nothing to do here, on purpose (#6840). This used to re-dispatch a key typed on
// another script (a Farsi or Cyrillic letter) as its Latin key, for the old
// Mousetrap, which read the typed character. hotkeys-js does not: it resolves a
// key from `event.code` ('KeyD') and `keyCode`, whatever character the layout
// types, so it already runs the shortcut for the physical d key on any layout.
// The copy then ran every shortcut a SECOND time - the due date editor opened
// and the repeat clicked the same control, closing it again, so `d` "opens the
// popup and closes it at a glance" on a Farsi keyboard (and Shift+D did the same
// on a Latin one, #6750). tests/keyboardNonLatinOnce.test.cjs.
function getHoveredCardId() {
const card = $('.js-minicard:hover').get(0);
+2 -2
View File
@@ -119,7 +119,7 @@ window.Popup = new (class {
dataContext: openOptions.dataContext || (this && this.currentData && this.currentData()) || (options && options.dataContextIfCurrentDataIsUndefined) || this,
});
const $contentWrapper = $('.content-wrapper')
const $contentWrapper = $('.content-wrapper');
if ($contentWrapper.length > 0) {
const contentWrapper = $contentWrapper[0];
self._getTopStack().scrollTop = contentWrapper.scrollTop;
@@ -195,7 +195,7 @@ window.Popup = new (class {
/// steps back is greater than the popup stack size, the popup will be closed.
back(n = 1) {
if (this._stack.length > n) {
const $contentWrapper = $('.content-wrapper')
const $contentWrapper = $('.content-wrapper');
if ($contentWrapper.length > 0) {
const contentWrapper = $contentWrapper[0];
const stack = this._stack[this._stack.length - n];
+24
View File
@@ -20,6 +20,30 @@ only what changes: documents, files and log lines. It writes them seconds after
they happen, to a directory that can be another disk, an NFS share or an
[rclone mount](Rclone/Rclone.md).
## Several servers: one of them streams
When WeKan runs as several servers on one database (for example the replicas of a Kubernetes
Deployment), **one of them runs the stream** and the others stand by
([#6861](https://github.com/wekan/wekan/issues/6861)). They coordinate through a *lease*: one
document, `continuousBackupLease`, holding the leader's id and an expiry.
- Every server with the feature switched on tries to take the lease. Taking it is one atomic
database operation, so two servers can never both lead.
- The leader renews it every 15 seconds. It lapses 45 seconds after the last renewal, so when the
leader stops (a pod restart, a rollout, a crash) another server takes over within about a minute
and continues from the position the stream saved in the target.
- A server that finds it has lost the lease stops its stream, so two never write one target.
- Saving the settings, switching the feature off and on, and a change saved on another server all
converge through the same loop: the leader follows the change on its next tick.
- A restore, and *fetch from the cloud*, must be the only writer: they take the lease from whoever
holds it and wait one renewal, so the former leader has stopped before anything is written. The
stream resumes through the loop afterwards.
An installation with one server is always its own leader, so nothing changes for it. The target
directory is still one place: with several servers it must be on storage they all reach (a shared
volume, NFS, an [rclone mount](Rclone/Rclone.md)), or the server that takes over starts a new
generation instead of continuing.
## Licences: only copyfree code by default
The built-in engines are part of WeKan and use the MIT licence. They depend
+220 -18
View File
@@ -1,3 +1,13 @@
The full-scale tables below were measured on 2026-10-23 with FerretDB v1.99.0.
Every change in this section came after it (FerretDB v1.100.0 to v1.105.0,
2026-10-24 to 2026-10-31, WeKan up to 2026-11-02), so **those tables do not show
them**; [Results.md](Results.md) is a new, small-scale run that does. The
figures quoted in this section are the ones in FerretDB's own
[CHANGELOG](https://github.com/wekan/FerretDB/blob/main-v1/CHANGELOG.md), each
measured on a smaller database (61,507 cards, 92,000 boards or the 615,070 cards
of this test, as stated), so they show the size of a change, not a place in the
table.
# WeKan database speed test
How fast does WeKan's database answer the queries WeKan actually makes - on
@@ -24,6 +34,9 @@ exist, which images they use or how they are started:
| FerretDB v1 + PostgreSQL | `postgres:18`, the image of [docker-compose-ferretdb-v1-postgresql.yml](../../../../../docker-compose-ferretdb-v1-postgresql.yml) |
| FerretDB v1 + MySQL | `mysql:9`, from [docker-compose-ferretdb-v1-mysql.yml](../../../../../docker-compose-ferretdb-v1-mysql.yml) |
| FerretDB v1 + MariaDB | `mariadb:12`, from [docker-compose-ferretdb-v1-mariadb.yml](../../../../../docker-compose-ferretdb-v1-mariadb.yml) |
| FerretDB v1 + pgrust | `malisper/pgrust:v0.4-beta`: [PostgreSQL rewritten in Rust](https://github.com/malisper/pgrust), wire compatible, so FerretDB's PostgreSQL handler talks to it unchanged. In the conformance run only with `WEKAN_CONFORMANCE_PGRUST=1` |
| FerretDB v1 + TiDB | `pingcap/tidb:latest` (Apache-2.0, MySQL protocol) as one process on its own storage, no PD or TiKV. In the conformance run only with `WEKAN_CONFORMANCE_TIDB=1` |
| FerretDB v1 + Dolt | `dolthub/dolt-sql-server:latest`: [git for data](https://github.com/dolthub/dolt), MySQL protocol, run as it comes (no commit per write). In the conformance run only with `WEKAN_CONFORMANCE_DOLT=1`. What its version history gives WeKan's undo is in [VersionHistory](../VersionHistory/) |
| MongoDB 6, 7, 8, 9 | `mongo:6.0`, `mongo:7` ([docker-compose-mongodb-v7.yml](../../../../../docker-compose-mongodb-v7.yml)), `mongo:8.0`, `mongo:9.0` - queried directly, no FerretDB |
| FerretDB v1 + SAP HANA | only with `WEKAN_CONFORMANCE_HANA=1` (amd64, ~16 GB RAM, SAP's licence) |
| FerretDB 2 + PostgreSQL with DocumentDB | `ghcr.io/ferretdb/ferretdb:2` and `ghcr.io/ferretdb/postgres-documentdb:17`, the images of [docker-compose-ferretdb-v2-postgresql.yml](../../../../../docker-compose-ferretdb-v2-postgresql.yml), on a network of their own; in the conformance run only with `WEKAN_CONFORMANCE_FERRETDB2=1` |
@@ -59,7 +72,11 @@ the busy users.
It is **deterministic**: a fixed seed and a seeded generator, never
`Math.random()`, so every database is loaded with byte-for-byte identical
documents - which is what makes it possible to ask whether they answer the same.
`WEKAN_SPEEDTEST_SCALE` changes the size (1 = production, 0.05 for a quick try).
`WEKAN_SPEEDTEST_SCALE` changes the size (1 = production, 2 = twice that, the
default). **`WEKAN_SPEEDTEST_SCALE=0.05` is 1/20 of a production WeKan - 15,377
cards - and the whole run, every database, takes under half an hour instead of
hours** (with the images already pulled) (the SQLite indexes alone take 19 minutes at scale 2 and 7 seconds at
0.05). Results.md says which scale it was measured at.
After loading, the test creates **the indexes WeKan creates**. They are not
listed here: [indexes.cjs](indexes.cjs) reads every `ensureIndex(...)` call in
@@ -135,6 +152,12 @@ Things to know when reading the numbers:
That costs FerretDB some load and write time MongoDB does not pay here.
- Each database runs with its image's default settings - what an administrator
gets - not tuned.
- Results.md was measured at scale 0.05 (see "The data"), with FerretDB v1.105.0
plus the changes of "Improvements since the last run". A row's FerretDB commit
is in its name; the rows differ because a database is re-run alone after a
change to FerretDB, which does not touch the others. The full-scale tables
further down are from 2026-10-23 (v1.98 and v1.99) and are kept as the record
of what each change was worth.
- A row's FerretDB commit is in its name: the databases were measured as their
FerretDB fixes went in (the PostgreSQL row after its index fix, MySQL and
MariaDB after their `_id` fix).
@@ -208,28 +231,207 @@ and its board scope is a plain `boardId` list when no board is assigned-only. It
docker-compose for FerretDB 2 could not start with the current DocumentDB image
(`POSTGRES_DB` must be `postgres`).
## Improvements since the last run
[Results.md](Results.md) was measured on 2026-10-23 with FerretDB v1.99.0. Every
change below came after it (FerretDB v1.100.0 to v1.105.0, 2026-10-24 to
2026-10-31, and WeKan up to 2026-11-02), so **the tables in Results.md do not
show them yet** - run the speed test again for full-scale numbers. The figures
quoted here are the ones in FerretDB's own
[CHANGELOG](https://github.com/wekan/FerretDB/blob/main-v1/CHANGELOG.md), each
measured on a smaller database (61,507 cards, 92,000 boards or the 615,070 cards
of this test, as stated), so they show the size of a change, not a place in the
table.
**FerretDB on SQLite**
- **SQLite sorts in the database** (v1.100.0). A sorted query used to read,
decode and keep every matching document in Go to return a few - People's ten
newest users, the first page of a list. SQLite now orders the row ids by a key
that reproduces MongoDB's order for simple values, and documents are fetched a
batch at a time in that order: the ten newest of 30,000 users allocate 2.6 MB
instead of 264 MB. A document whose sort field holds an array, a document, NaN
or an infinity comes first, and then everything is sorted in Go as before, so
the answer is the same. The key is read once per row, in one JSON path.
- **Documents are stored as SQLite JSONB** (v1.101.0). Every filter, sort key,
index and multikey trigger used to parse the JSON text of a document again.
New collections are binary, and an existing database is converted once when
it is opened, table by table in a transaction (204,212 documents took 81 s; a
table that fails stays text and keeps working). On 61,507 cards, due cards went
from 1,003 to 519 ms, All Boards from 95 to 42 ms and creating the 44 indexes
from 107 to 70 s. After the conversion an older FerretDB cannot read the
database.
- **`distinct` works when documents lack the field** (v1.105.0), which a database
migrated from MongoDB 3 needs on its first start.
- **A membership test reads the array from the column, not from the text of `->`**
(found by profiling Due Cards, fixed after v1.105.0). `{members: {$elemMatch:
{userId: X}}}` was `json_each(col->"members")`: `->` renders the whole array as
JSON text and `json_each` parses that text again, for every row. It is now
`json_each(col, '$."members"')`, which SQLite walks in place. The visible-boards
query went from 26.2 to 15.7 ms on 2,308 boards, and in the speed test All Boards
count from 6.9 to 4.9 ms, All Boards page from 5.9 to 3.9 ms and Due Cards from
54.5 to 43.9 ms (the SQLite total 82.7 → 68.3 ms); the 169 answers of 12 queries
are identical to before.
**FerretDB on PostgreSQL**
- **Membership tests use a GIN index** (v1.100.0): an index whose leading key is
`ARRAY.FIELD` (`members.userId`) also gets a `jsonb_path_ops` GIN index of the
array, and `{members: {$elemMatch: {userId: X}}}` uses it.
- **`$or` and `$elemMatch` are conditions in the database** (v1.101.0), so Due
Cards' query went from 2,031 to 815 ms on 61,507 cards.
- **A top-level `$and` is pushed down** (v1.100.0). WeKan's Due Cards wraps its
conditions in one, and every document was read.
- **A table is analyzed once its indexes are created** (found by the small run,
fixed after v1.105.0). PostgreSQL has statistics for an expression index
(`_jsonb->'field'`) only after `ANALYZE`, which autovacuum runs a minute or
more after a bulk load. Before that the planner guessed **one row** for a Due
Cards query that returns 2,000, and chose a plan with no parallelism: 407 ms
on 15,377 cards, 84 ms after. The whole PostgreSQL row went from 454 ms to
145 ms and from last place to eighth. The same `ANALYZE TABLE` was tried on
MySQL and MariaDB and made no difference (100 and 279 ms before, 106 and 283
after), so it was not kept.
**FerretDB on MySQL and MariaDB**
- **Reads use their indexes** (v1.100.0): an equality or `$in` narrows through
the index's generated column. A card by `_id` among 615,070 went from 3.4 s
(MySQL) and 18.7 s (MariaDB) to 1.8 ms. Generated columns are `VIRTUAL`, so
adding the 44 indexes of a 600,000-document database takes 157 s, not 20
minutes; an index of four or more fields indexes prefixes, so it fits the
3,072-byte key limit that three of WeKan's compound indexes used to exceed;
and a path with a dash or a digit (`services.accounts-lockout.unlockTime`, `profile.emailBuffer.0`) no longer
fails with "Invalid JSON path expression".
- **Multikey side tables** (v1.100.0), as on SQLite, so a membership test reads
only the matching documents. Without the right to create triggers (MySQL with
binary logging and no SUPER) nothing is created and queries read as before.
- **An `$or` of `_id` sets is one `UNION ALL`** (v1.100.0): All Boards for a user
with a starred board went from 900 ms (MySQL) and 5.7 s (MariaDB) among 92,000
boards to 6-7 ms.
- **A long `$in` is one `IN`** (v1.101.0), not 2,596 `JSON_CONTAINS` in an OR
(52 million comparisons over 20,000 cards).
- **Updates and deletes by `_id` use the `_id` index** (v1.99.0, listed above).
**WeKan** - less work for the database, which matters more the bigger it is:
- **Collection scans are gone from the hourly and startup paths.** The recurring
cards job has a partial index on `recurrenceInterval` (it read 1,108,017 cards
in 1,155 ms every hour); search sessions have the indexes their upsert and
lookup use, and the stale sessions of every user are removed (one instance had
4,751 taking 10 GB); the board key backfill is one `$in` on the key index; the
list sync scan and the due-date lookup have partial and sparse indexes.
- **One server does it, not all of them.** The startup schema re-check is claimed
in `_wekan_migration`, the daily due-date reminder scan and the database
health probe are claimed per period, so 24 replicas no longer scan, send or
report 24 times.
- **Admin Panel / Problems / Database** reports what makes an instance slow: a
query that read a whole collection of at least 10,000 documents (which
collection, the command, the operators per field and the `queryHash`), and a
high read rate. The speed test's Due Cards and All Boards numbers say what a
query costs on one database; this says which query an instance is actually
asking.
## What could still be improved
- **Sorting and `limit` in the database.** FerretDB v1 sorts in Go: People's ten
newest users, All Boards' first page and a card window read and sort every
matching document. Pushing an ORDER BY down needs MongoDB's ordering across
types, missing fields and arrays to be exact in SQL.
- **Due Cards on SQLite, 44 ms of the 68 ms total.** A CPU profile of FerretDB
while it ran Due Cards (taken before the change above): half the CPU was inside
SQLite, and about a fifth of all of it converting JSON between binary and text
(`_jsonTranslateBlobToText` 10%, `_jsonTranslateTextToBlob` 10%, besides
`_jsonLookupStep`); another quarter was Go runtime (thread signalling,
`madvise`, the network poller). Of the cards query's 24 ms, 18 ms is the index
lookups (the 700-board `IN` list) and 6 ms the five per-user conditions
(`members`, `assignees`, `requesters`, `assigners`, `userId`), each of which
evaluates `->` three times per row. Reading each once, as
`json_type(col,'$."f"') = 'array' OR col->>'$."f"' = ?`, took that step from
24.4 to 20.5 ms with the same rows - **not done**, because it may only be applied
to a field no index covers (an index is matched by the expression `col->"f"`),
and FerretDB's WHERE builder does not know which fields are indexed.
- **Tried on SQLite and not worth it, or not conclusive**: `ANALYZE` made no
difference (FerretDB chooses its indexes itself); a Unix socket instead of TCP
saves about 0.05 ms (20%) on a point read (0.24 → 0.19 ms), which is free when
WeKan and FerretDB share a machine (`--listen-unix`); `GOGC=400` gave 10-15% on
some queries in one test (board cards 5.3 → 4.4 ms) and nothing in another that
also set a memory limit, so it is a lead, not a result.
- **The busiest users.** What remains of All Boards for a member of thousands of
boards, and of Due Cards, is decoding those boards and sorting them in Go.
- **SQLite stores JSON as text.** Every `->` parses the document again - which is
also why creating an index takes 19 minutes for SQLite and 115 s for PostgreSQL,
whose `jsonb` is binary. Storing SQLite's JSONB would remove that, but changes
the storage format and needs a migration.
- **Multikey indexes for PostgreSQL, MySQL and MariaDB.** Only SQLite has the side
tables; All Boards takes 8-9 s on the others.
- **MySQL and MariaDB reads do not use their indexes**: equality is
`JSON_CONTAINS(JSON_EXTRACT(doc, ?))`, so a card by `_id` takes 3.4 s and 18.7 s
(and an edit as long - it reads the card first). Their indexes are generated
`VARCHAR(255)` columns added with a table rebuild each (20 and 40 minutes for
44 indexes; VIRTUAL columns would avoid the rebuild), and three of WeKan's
compound indexes exceed the 3,072-byte key limit and are not created.
boards, and of Due Cards, is decoding those boards and sorting them in Go on
the databases that do not sort in SQL.
- **Sorting in the database on PostgreSQL, MySQL and MariaDB.** Only SQLite does
it. PostgreSQL was tried (2026-11-03): an `ORDER BY` of type class, number and
text (`COLLATE "C"`, MongoDB's byte order) with the documents that cannot be
ordered first, as on SQLite, and the tests matched the Go sort over mixed
types. It was **not faster**: the ten newest of 30,116 users took 52 ms, against
38-49 ms for fetching, decoding and sorting them in Go, because the Go path is
already fast after the decoding changes and the sort expressions cost about as
much. It would only save memory and transfer, so it was not kept. A sort that
wins needs an expression index on the sort field, which has to know the field
in advance; MySQL and MariaDB have the same cost structure and no measurement
that says otherwise.
- **MariaDB stores JSON as text** that every JSON function parses again, so it
keeps only what an index answers (`$or` and `$elemMatch` are not pushed down).
- **Existing MySQL/MariaDB tables** keep their case-insensitive index columns
until rebuilt.
- **Measuring it again at full scale.** Results.md is scale 0.05. A full-scale
run (`WEKAN_SPEEDTEST_SCALE=2`, hours) would show the busiest users, where the
differences are largest.
## Other databases tried
Asked for: any other fast database that FerretDB can use and that is open source.
FerretDB v1 has handlers for PostgreSQL, MySQL, SQLite and SAP HANA, so a
candidate has to speak one of those protocols **and** run the SQL the handler
writes: `jsonb` expression indexes, `@>` and GIN on PostgreSQL; `JSON`,
`JSON_EXTRACT`, generated columns and triggers on MySQL.
| Database | Result |
| --- | --- |
| [pgrust](https://github.com/malisper/pgrust) v0.4-beta (PostgreSQL in Rust, AGPL-3.0) | **Works**, same answers as MongoDB, and is in Results.md. It is not faster than PostgreSQL for WeKan's queries: 174 ms against 145 ms in total, and creating the 47 indexes takes 38 s against 1 s. |
| [TiDB](https://github.com/pingcap/tidb) 7.5 (Apache-2.0, MySQL protocol) | **Works**, same answers, and is in Results.md (240 ms, between pgrust and MariaDB). It needed one change in FerretDB: its metadata table's generated columns are `VIRTUAL`, because TiDB refuses `ALTER TABLE ... ADD COLUMN ... STORED`. This is one process with its own storage; a real cluster (PD and TiKV) is a different test. |
| [Dolt](https://github.com/dolthub/dolt) 2.6.2 (git for data, Apache-2.0, MySQL protocol) | **Works with the existing MySQL handler, no new handler**, same answers as MongoDB, and is in Results.md - and is **by far the slowest: 20,470 ms in total, 1,230x MongoDB**. Every query over cards takes about 2.8 s on 15,377 cards (a whole-table scan, about 187 µs a document: the generated-column indexes the handler relies on do not narrow a read there), All Boards 850 ms, and creating the indexes 106 s. Its point is version history, not speed; see [VersionHistory](../VersionHistory/). |
| [Noria](https://github.com/mit-pdos/noria) (MIT research prototype, Apache-2.0) | **Cannot run FerretDB.** It is a streaming dataflow system that precomputes the results of a fixed set of prepared queries. Its SQL has no `JSON` type (the parser's type list is `INT`, `VARCHAR`, `TEXT`, `BLOB`, `DATE`, ...), no triggers and no generated columns, and the MySQL handler needs all three: it uses `JSON` 79 times, `JSON_EXTRACT` 74 times and 7 generated columns. It also needs unpinned nightly Rust (2021) and ZooKeeper, and has not changed since October 2021. Not built or run. |
| [YugabyteDB](https://github.com/yugabyte/yugabyte-db) 2026.1.2 (core Apache-2.0, PostgreSQL 15 compatible) | **Cannot run FerretDB.** Started, and FerretDB's first `CREATE INDEX` fails: `INDEX on column of type 'JSONB' not yet supported`. Every FerretDB v1 index on PostgreSQL is an expression of type `jsonb`, and so is every query that uses one; YugabyteDB can index a `jsonb` column only with GIN. Supporting it means writing the indexes and conditions as `->>` text, which is a different handler. |
| [CockroachDB](https://github.com/cockroachdb/cockroach) | **Not tried: not open source.** Its LICENSE is the proprietary CockroachDB Software License, not an open-source one. |
Tried with the MySQL handler, to see how far it gets (both speak the MySQL protocol):
| Database | Result |
| --- | --- |
| [ClickHouse](https://github.com/ClickHouse/ClickHouse) 26.10 (Apache-2.0) | **Needs a handler of its own.** The first statement FerretDB sends through its MySQL interface fails: `sql: expected 0 arguments, got 2`. Behind that it has no `JSON_EXTRACT`, `JSON_CONTAINS`, triggers or generated columns as the handler uses them (it has `JSONExtract*`), and a row update is an asynchronous mutation, which is not what WeKan's edits and moves are. |
| [StarRocks](https://github.com/StarRocks/starrocks) 4.1 (Apache-2.0) | **Needs a handler of its own.** The first `CREATE TABLE` fails: `Data type of first column cannot be JSON`; its tables need a key column and a distribution. An analytical engine again, with no triggers. |
A handler for either is a new backend of the size of the SAP HANA one (about
2,000 lines of Go: query, insert, update, delete, indexes, statistics) plus its own
SQL for every filter FerretDB can push down; without the pushdown a query reads
the whole collection, which the speed test would measure as slow. That has not
been written. Not tried at all: DuckDB (no server; the Go driver needs cgo, which
FerretDB's builds do not use) and SingleStore (not open source, and its image
needs a licence key). A MongoDB-protocol database needs no FerretDB and joins the
MongoDB rows; FerretDB 2 with DocumentDB is already there.
## Opening a board in WeKan
The same databases, one level up: [app/](app/) opens a WeKan board of 200, 2,000 and
5,000 cards on FerretDB (SQLite) and on MongoDB 8 and profiles both the Meteor server
and FerretDB. The Meteor server's CPU is the same on both (0.1-0.5 s); on a 5,000-card
board FerretDB (SQLite) takes **400-600 ms longer** (to the first card +42-48%), a
small board only 70-100 ms, and it uses about 4 s of CPU to open the 5,000-card board,
a third of it filtering in Go what it read; the browser receives the same ~210 KB at
every size.
## Go databases linked into FerretDB
Could a database implemented in Go be linked into the FerretDB binary beside its
SQLite, and be faster? [embedded/](embedded/) asks that of the
[awesome-go list](https://github.com/avelino/awesome-go#databases-implemented-in-go):
Pebble, Badger, bbolt, BuntDB, Nutsdb and Clover against SQLite set up as FerretDB
sets it, on the speed test's cards, in minutes. **None is faster where it counts**:
a board's cards take the same time on every engine that is not slower, because
decoding the JSON dominates; the key-value stores win single reads and writes by
microseconds that the 0.2 ms floor of a FerretDB call hides, and would make the
handler do in Go what SQL does in SQLite. bbolt with its default fsync is 100-160x
slower on writes, Pebble 12x slower on fetching a board, Clover 90-190x slower.
## FerretDB 2
+76 -40
View File
@@ -1,12 +1,34 @@
# WeKan database speed test results
Run 2026-10-23 on Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, 12 threads, 30 GiB RAM, Linux 7.2.6-070206-generic, by
Run 2026-11-03 on Apple M2 Max, 12 threads, 32 GiB RAM, Darwin 27.2.0, by
[speedtest.sh](speedtest.sh). How it works, and what was improved because of it:
[the README](.).
The data is twice a production WeKan: 30,116 users, 92,306 boards, 221,038 lists, 222,546 swimlanes, 615,070 cards (production: 307,535 cards). Every database got identical
The data is 1/20 of a production WeKan: 753 users, 2,308 boards, 5,526 lists, 5,564 swimlanes, 15,377 cards (production: 307,535 cards). Every database got identical
documents and WeKan's own indexes, and was asked identical questions.
## At a glance
One number per database: the time of one call of each of the common WeKan queries below,
added together. **Times slower** is that sum divided by the fastest database's. Ordered
from fastest to slowest. The second pair of columns is the same for the slowest parameter -
the biggest board or the busiest user, who waits longest.
| # | Database | Total (ms) | Times slower | Slowest user (ms) | Times slower |
| ---: | --- | ---: | ---: | ---: | ---: |
| 1 | MongoDB 9.0.2 | 16.6 | 1.0x | 45.1 | 1.0x |
| 2 | MongoDB 8.0.32 | 17.4 | 1.0x | 48.8 | 1.1x |
| 3 | MongoDB 6.0.28 | 18.0 | 1.1x | 50.2 | 1.1x |
| 4 | MongoDB 7.0.43 | 18.1 | 1.1x | 50.1 | 1.1x |
| 5 | FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | 57.9 | 3.5x | 154 | 3.4x |
| 6 | FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | 68.3 | 4.1x | 219 | 4.9x |
| 7 | FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | 103 | 6.2x | 415 | 9.2x |
| 8 | FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | 145 | 8.7x | 483 | 11x |
| 9 | FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | 174 | 10x | 594 | 13x |
| 10 | FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | 240 | 14x | 877 | 19x |
| 11 | FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | 290 | 17x | 1,063 | 24x |
| 12 | FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | 20,470 | 1,230x | 20,904 | 464x |
## Speed
Milliseconds for one call - smaller is faster. Each query is asked with 15 parameters (the
@@ -16,66 +38,80 @@ fastest database at the top.
| Database | Total | Login | User by id | All Boards count | All Boards page | Board by id | Board lists | Board swimlanes | Board cards | Card window | Card count | Cards per list | Card by id | Card search | Due Cards | People page | Edit card | Move card | Add card |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| MongoDB 9.0.4 | **495** | 0.75 | 0.66 | 5.76 | 16.4 | 0.68 | 1.06 | 0.98 | 47.7 | 0.77 | 1.61 | 2.62 | 0.66 | 5.34 | 394 | 13.6 | 0.77 | 1.16 | 1.00 |
| MongoDB 6.0.28 | **562** | 0.78 | 0.73 | 5.49 | 21.0 | 0.83 | 1.01 | 0.93 | 48.2 | 0.89 | 2.01 | 4.38 | 0.74 | 4.76 | 447 | 20.9 | 0.83 | 0.90 | 0.77 |
| MongoDB 7.0.45 | **565** | 0.86 | 0.74 | 6.29 | 23.7 | 0.79 | 1.07 | 1.00 | 48.9 | 0.87 | 2.00 | 4.35 | 0.72 | 5.52 | 445 | 20.7 | 0.79 | 0.77 | 0.76 |
| MongoDB 8.0.34 | **577** | 0.81 | 0.69 | 6.83 | 16.8 | 0.77 | 1.19 | 1.01 | 48.5 | 0.88 | 1.90 | 2.87 | 0.69 | 6.28 | 470 | 15.6 | 0.78 | 0.89 | 0.78 |
| FerretDB v1.99.0 (b6c494e9) + SQLite (modernc.org/sqlite v1.61.0) | **18,848** | 0.99 | 0.93 | 1,405 | 1,655 | 1.08 | 4.15 | 2.94 | 223 | 67.2 | 86.0 | 119 | 0.89 | 81.7 | 12,604 | 2,582 | 4.69 | 6.36 | 2.32 |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | **88,884** | 1.35 | 1.03 | 183 | 241 | 1.15 | 1.72 | 1.68 | 113 | 53.5 | 43.5 | 46.0 | 0.93 | 46.1 | 88,013 | 118 | 7.89 | 6.09 | 6.20 |
| FerretDB v1.99.0 (b6c494e9) + MariaDB 12.3.3 | **97,536** | 1.76 | 2.05 | 11,389 | 11,396 | 1.82 | 3.41 | 2.43 | 189 | 2.43 | 171 | 173 | 1.79 | 171 | 73,217 | 791 | 9.24 | 8.49 | 6.68 |
| FerretDB v1.99.0 (b6c494e9) + PostgreSQL 18.6 | **98,435** | 1.24 | 1.21 | 159 | 149 | 1.36 | 2.14 | 1.62 | 217 | 1.46 | 121 | 132 | 1.14 | 137 | 96,035 | 1,444 | 9.51 | 11.9 | 9.48 |
| FerretDB v1.99.0 (b6c494e9) + MySQL 9.7.4 | **106,082** | 1.86 | 3.09 | 1,073 | 1,082 | 1.86 | 2.90 | 2.37 | 249 | 4.88 | 133 | 140 | 1.74 | 153 | 101,505 | 1,687 | 14.4 | 15.7 | 11.6 |
| MongoDB 9.0.2 | **16.6** | 0.48 | 0.41 | 0.89 | 0.87 | 0.43 | 0.42 | 0.43 | 2.08 | 0.42 | 0.45 | 0.53 | 0.35 | 0.60 | 6.23 | 0.68 | 0.39 | 0.52 | 0.48 |
| MongoDB 8.0.32 | **17.4** | 0.46 | 0.41 | 0.86 | 0.85 | 0.39 | 0.48 | 0.45 | 2.14 | 0.42 | 0.49 | 0.52 | 0.35 | 0.63 | 7.09 | 0.78 | 0.38 | 0.40 | 0.34 |
| MongoDB 6.0.28 | **18.0** | 0.44 | 0.46 | 0.71 | 0.96 | 0.43 | 0.44 | 0.41 | 2.13 | 0.39 | 0.43 | 0.50 | 0.36 | 0.60 | 7.42 | 0.82 | 0.49 | 0.49 | 0.53 |
| MongoDB 7.0.43 | **18.1** | 0.45 | 0.41 | 0.71 | 0.91 | 0.41 | 0.46 | 0.41 | 2.05 | 0.40 | 0.42 | 0.51 | 0.36 | 0.55 | 7.81 | 0.76 | 0.46 | 0.52 | 0.48 |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | **57.9** | 0.67 | 0.55 | 2.10 | 2.82 | 0.69 | 0.71 | 0.56 | 3.21 | 1.12 | 1.06 | 1.29 | 0.52 | 1.42 | 33.8 | 1.91 | 1.87 | 1.77 | 1.85 |
| FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | **68.3** | 0.29 | 0.23 | 4.35 | 3.40 | 0.28 | 0.42 | 0.37 | 4.91 | 0.72 | 1.69 | 2.36 | 0.22 | 1.01 | 40.7 | 3.90 | 1.36 | 1.39 | 0.67 |
| FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | **103** | 0.92 | 0.87 | 6.13 | 6.40 | 0.91 | 0.97 | 0.88 | 7.79 | 1.04 | 5.05 | 5.13 | 0.85 | 5.75 | 35.9 | 16.9 | 2.65 | 2.87 | 2.08 |
| FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | **145** | 0.76 | 0.67 | 5.84 | 6.14 | 0.59 | 0.57 | 0.55 | 7.70 | 0.70 | 4.54 | 4.57 | 0.50 | 5.20 | 83.8 | 16.1 | 2.34 | 2.27 | 1.66 |
| FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | **174** | 0.75 | 0.62 | 6.00 | 6.39 | 0.70 | 0.68 | 0.61 | 7.49 | 0.73 | 4.75 | 4.70 | 0.57 | 5.29 | 112 | 15.3 | 2.59 | 2.58 | 1.95 |
| FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | **240** | 2.00 | 1.48 | 32.7 | 33.0 | 1.44 | 1.95 | 1.68 | 10.4 | 2.96 | 7.61 | 7.59 | 1.50 | 8.35 | 97.2 | 18.4 | 4.32 | 4.81 | 2.71 |
| FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | **290** | 1.06 | 0.90 | 11.6 | 11.6 | 1.20 | 1.13 | 0.96 | 7.68 | 1.28 | 6.63 | 6.77 | 0.91 | 6.90 | 216 | 9.12 | 2.52 | 2.48 | 1.43 |
| FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | **20,470** | 42.6 | 1.87 | 852 | 847 | 2.09 | 188 | 84.7 | 2,876 | 2,846 | 2,857 | 2,836 | 2.06 | 2,834 | 4,126 | 56.6 | 6.94 | 6.92 | 4.75 |
### The slowest parameter
The same, for the slowest of the 15 parameters - nearly always the biggest board or the
busiest user. This is how long the person who waits longest waits. Same row order.
busiest user. This is how long the person who waits longest waits. Ordered by its own
total, so the order can differ from the table above.
| Database | Total | Login | User by id | All Boards count | All Boards page | Board by id | Board lists | Board swimlanes | Board cards | Card window | Card count | Cards per list | Card by id | Card search | Due Cards | People page | Edit card | Move card | Add card |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| MongoDB 9.0.4 | **1,110** | 0.78 | 0.69 | 28.1 | 187 | 0.72 | 1.89 | 1.63 | 297 | 0.83 | 4.48 | 9.99 | 0.68 | 28.1 | 525 | 19.9 | 0.85 | 1.48 | 1.02 |
| MongoDB 6.0.28 | **1,257** | 0.80 | 0.78 | 29.6 | 256 | 0.95 | 1.82 | 1.43 | 300 | 0.98 | 7.77 | 22.4 | 0.78 | 24.9 | 585 | 21.1 | 0.87 | 1.11 | 0.79 |
| MongoDB 7.0.45 | **1,303** | 0.88 | 0.77 | 34.1 | 291 | 0.83 | 1.83 | 1.65 | 304 | 0.89 | 7.95 | 22.1 | 0.77 | 29.6 | 583 | 20.8 | 0.84 | 1.04 | 0.79 |
| MongoDB 8.0.34 | **1,212** | 0.87 | 0.71 | 33.7 | 184 | 0.82 | 2.09 | 1.66 | 301 | 0.93 | 5.14 | 11.0 | 0.73 | 33.8 | 611 | 22.1 | 0.84 | 1.12 | 0.80 |
| FerretDB v1.99.0 (b6c494e9) + SQLite (modernc.org/sqlite v1.61.0) | **29,845** | 1.04 | 0.97 | 4,780 | 4,812 | 1.27 | 18.1 | 8.09 | 1,419 | 389 | 538 | 755 | 0.96 | 504 | 13,798 | 2,804 | 5.69 | 7.52 | 2.83 |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | **136,362** | 1.39 | 1.07 | 190 | 252 | 1.22 | 2.90 | 2.50 | 451 | 113 | 90.2 | 90.2 | 0.98 | 90.0 | 134,900 | 147 | 13.8 | 7.81 | 6.78 |
| FerretDB v1.99.0 (b6c494e9) + MariaDB 12.3.3 | **130,784** | 1.87 | 2.46 | 15,784 | 15,689 | 2.05 | 9.24 | 5.17 | 1,192 | 2.71 | 1,101 | 1,079 | 2.63 | 1,079 | 93,914 | 891 | 11.3 | 10.5 | 7.36 |
| FerretDB v1.99.0 (b6c494e9) + PostgreSQL 18.6 | **127,323** | 1.27 | 1.31 | 887 | 812 | 1.56 | 6.39 | 3.80 | 1,365 | 1.63 | 762 | 874 | 1.20 | 848 | 120,122 | 1,601 | 12.2 | 13.1 | 10.4 |
| FerretDB v1.99.0 (b6c494e9) + MySQL 9.7.4 | **131,734** | 1.98 | 3.56 | 1,821 | 1,815 | 2.41 | 7.84 | 4.79 | 1,561 | 6.74 | 791 | 873 | 1.84 | 936 | 122,154 | 1,709 | 15.7 | 16.6 | 12.5 |
| MongoDB 9.0.2 | **45.1** | 0.59 | 0.47 | 2.54 | 4.03 | 0.48 | 0.50 | 0.50 | 13.9 | 0.45 | 0.64 | 0.90 | 0.37 | 1.87 | 15.6 | 0.71 | 0.42 | 0.59 | 0.54 |
| MongoDB 8.0.32 | **48.8** | 0.52 | 0.44 | 2.63 | 4.09 | 0.42 | 0.59 | 0.55 | 14.6 | 0.44 | 0.66 | 0.91 | 0.40 | 2.08 | 18.4 | 0.83 | 0.40 | 0.45 | 0.37 |
| MongoDB 7.0.43 | **50.1** | 0.51 | 0.46 | 2.41 | 5.25 | 0.44 | 0.54 | 0.48 | 14.3 | 0.42 | 0.64 | 1.26 | 0.40 | 1.83 | 18.7 | 0.80 | 0.52 | 0.60 | 0.54 |
| MongoDB 6.0.28 | **50.2** | 0.48 | 0.52 | 2.49 | 5.59 | 0.49 | 0.52 | 0.50 | 14.6 | 0.42 | 0.66 | 1.33 | 0.39 | 2.05 | 17.6 | 0.87 | 0.54 | 0.55 | 0.59 |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | **154** | 0.74 | 0.59 | 2.40 | 3.22 | 0.88 | 0.91 | 0.65 | 20.4 | 1.65 | 1.43 | 2.17 | 0.57 | 2.74 | 107 | 2.01 | 2.28 | 2.17 | 2.06 |
| FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | **219** | 0.34 | 0.30 | 21.6 | 11.2 | 0.35 | 0.82 | 0.67 | 44.0 | 3.71 | 13.9 | 20.6 | 0.24 | 5.43 | 85.6 | 6.88 | 1.53 | 1.49 | 0.77 |
| FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | **415** | 1.00 | 0.93 | 36.7 | 38.9 | 1.05 | 1.43 | 1.11 | 61.6 | 1.26 | 36.6 | 37.1 | 0.93 | 41.5 | 128 | 18.3 | 3.26 | 3.21 | 2.63 |
| FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | **483** | 0.87 | 0.73 | 38.3 | 39.1 | 0.68 | 0.97 | 0.79 | 66.7 | 1.22 | 36.7 | 37.0 | 0.57 | 41.4 | 193 | 16.9 | 2.65 | 2.86 | 1.91 |
| FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | **594** | 0.88 | 0.67 | 36.5 | 38.8 | 0.80 | 1.13 | 0.92 | 62.5 | 1.19 | 36.4 | 36.1 | 0.60 | 40.3 | 314 | 15.8 | 2.78 | 2.77 | 2.12 |
| FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | **877** | 2.12 | 1.52 | 77.1 | 77.7 | 1.58 | 2.64 | 2.11 | 76.4 | 4.03 | 51.1 | 49.9 | 1.62 | 55.6 | 442 | 18.9 | 4.53 | 5.05 | 2.86 |
| FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | **1,063** | 1.19 | 0.98 | 104 | 104 | 1.39 | 1.66 | 1.34 | 54.4 | 1.39 | 49.9 | 50.7 | 0.98 | 51.3 | 622 | 9.26 | 2.84 | 2.63 | 1.61 |
| FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | **20,904** | 43.8 | 1.99 | 875 | 887 | 2.36 | 192 | 88.5 | 2,912 | 2,890 | 2,895 | 2,908 | 2.57 | 2,860 | 4,262 | 61.4 | 8.81 | 8.48 | 4.91 |
## Loading the data
Inserting the documents in batches of 1,000, then creating every index WeKan creates.
Ordered by the whole time, fastest first.
| Database | users | boards | lists | swimlanes | cards | Indexes | Index errors |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| MongoDB 9.0.4 | 0.8 s | 3.4 s | 4.5 s | 4.1 s | 17.0 s | 43.6 s | none |
| MongoDB 6.0.28 | 0.8 s | 3.3 s | 4.6 s | 4.2 s | 17.7 s | 41.6 s | none |
| MongoDB 7.0.45 | 0.8 s | 3.3 s | 4.7 s | 4.2 s | 17.3 s | 42.8 s | none |
| MongoDB 8.0.34 | 0.8 s | 3.2 s | 4.9 s | 4.3 s | 17.9 s | 45.3 s | none |
| FerretDB v1.99.0 (b6c494e9) + SQLite (modernc.org/sqlite v1.61.0) | 10.5 s | 65.5 s | 59.6 s | 41.4 s | 315.3 s | 1157.0 s | none |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | 1.5 s | 6.0 s | 7.7 s | 6.9 s | 32.3 s | 219.2 s | none |
| FerretDB v1.99.0 (b6c494e9) + MariaDB 12.3.3 | 8.5 s | 35.9 s | 33.7 s | 28.3 s | 148.9 s | 900.9 s | none |
| FerretDB v1.99.0 (b6c494e9) + PostgreSQL 18.6 | 15.2 s | 83.7 s | 74.6 s | 63.3 s | 376.9 s | 116.8 s | none |
| FerretDB v1.99.0 (b6c494e9) + MySQL 9.7.4 | 11.2 s | 58.6 s | 58.0 s | 53.1 s | 254.2 s | 172.0 s | none |
| MongoDB 9.0.2 | 0.0 s | 0.0 s | 0.1 s | 0.0 s | 0.2 s | 0.6 s | none |
| MongoDB 7.0.43 | 0.0 s | 0.0 s | 0.1 s | 0.0 s | 0.2 s | 0.6 s | none |
| MongoDB 6.0.28 | 0.0 s | 0.0 s | 0.1 s | 0.0 s | 0.2 s | 0.6 s | none |
| MongoDB 8.0.32 | 0.0 s | 0.0 s | 0.1 s | 0.0 s | 0.2 s | 0.7 s | none |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | 0.0 s | 0.1 s | 0.1 s | 0.1 s | 0.3 s | 1.3 s | none |
| FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | 0.2 s | 0.5 s | 0.4 s | 0.4 s | 2.0 s | 1.7 s | none |
| FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | 0.1 s | 0.3 s | 0.3 s | 0.2 s | 1.3 s | 7.2 s | none |
| FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | 0.1 s | 0.5 s | 0.3 s | 0.2 s | 1.9 s | 7.4 s | none |
| FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | 0.3 s | 0.6 s | 0.5 s | 0.5 s | 2.2 s | 7.9 s | none |
| FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | 0.1 s | 0.7 s | 0.5 s | 0.4 s | 3.0 s | 16.0 s | none |
| FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | 0.1 s | 0.7 s | 0.5 s | 0.4 s | 3.2 s | 38.0 s | none |
| FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | 0.2 s | 0.8 s | 0.8 s | 0.6 s | 4.2 s | 105.7 s | none |
## Same data
Every answer of every query, for every parameter, and the cards the writes left behind,
compared with **MongoDB 8.0.34**. Answers are compared as typed values with
compared with **MongoDB 8.0.32**. Answers are compared as typed values with
sorted keys; a query whose order WeKan relies on must also return the same order.
| Database | Same answers | Different |
| --- | --- | --- |
| MongoDB 9.0.4 | 19 of 19 | none |
| MongoDB 9.0.2 | 19 of 19 | none |
| MongoDB 8.0.32 | 19 of 19 | none |
| MongoDB 6.0.28 | 19 of 19 | none |
| MongoDB 7.0.45 | 19 of 19 | none |
| MongoDB 8.0.34 | 19 of 19 | none |
| FerretDB v1.99.0 (b6c494e9) + SQLite (modernc.org/sqlite v1.61.0) | 19 of 19 | none |
| MongoDB 7.0.43 | 19 of 19 | none |
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | 19 of 19 | none |
| FerretDB v1.99.0 (b6c494e9) + MariaDB 12.3.3 | 19 of 19 | none |
| FerretDB v1.99.0 (b6c494e9) + PostgreSQL 18.6 | 19 of 19 | none |
| FerretDB v1.99.0 (b6c494e9) + MySQL 9.7.4 | 19 of 19 | none |
| FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | 19 of 19 | none |
| FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | 19 of 19 | none |
| FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | 19 of 19 | none |
| FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | 19 of 19 | none |
| FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | 19 of 19 | none |
| FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | 19 of 19 | none |
| FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | 19 of 19 | none |
## The queries
@@ -103,7 +139,7 @@ Each is the query WeKan itself sends; see [queries.cjs](queries.cjs).
| Add card | cards.insertOne({ ...a new card }) | the 3 biggest boards + 12 others |
Each query is first run once per parameter to warm up (those answers are the ones compared),
then repeated for up to 5 passes or 10 seconds; 15-90 calls per query were timed.
then repeated for up to 5 passes or the time budget (WEKAN_SPEEDTEST_BUDGET_MS); 15-90 calls per query were timed.
A query too slow to be repeated in that time keeps its warm-up time.
Raw results: `.tools/log/test-db-speedtest/2026-10-23_19-33-27/speedtest-<database>.json`.
Raw results: `.tools/log/test-db-speedtest/2026-11-03_22-10-05/speedtest-<database>.json`.
@@ -0,0 +1,108 @@
# Opening a WeKan board: where the time goes
The [speed test](../) asks the database alone. This asks what a person waits for: the
whole of opening a board in WeKan, with WeKan on FerretDB (SQLite) and on MongoDB 8,
and how much of it is the database. [board-open.cjs](board-open.cjs) seeds a board of
N cards, logs in, opens it in Chromium and records
- the wall clock to the first minicard and until the DDP WebSocket has been quiet for
1.5 s (**settled**), and what crossed the WebSocket;
- the Meteor server's CPU, by who spends it (a Node inspector profile of the server);
- FerretDB's CPU (its pprof), against what it spends with nobody in the browser.
```sh
# WeKan on FerretDB: MONGO_URL=mongodb://127.0.0.1:27040/wekan?directConnection=true,
# DEFAULT_METEOR_REACTIVITY_ORDER=polling (as WeKan is deployed), meteor run --inspect=9229,
# and FerretDB with --debug-addr=127.0.0.1:8098
WEKAN_BASE_URL=http://localhost:3310 WEKAN_MONGO_URL=mongodb://127.0.0.1:27040/wekan \
node docs/Databases/FerretDB/1/SpeedTest/app/board-open.cjs --sizes 200,2000,5000 \
--inspect 9229 --ferret-pprof http://127.0.0.1:8098 --label ferretdb-sqlite
```
Run on 2026-11-03/04, Apple M2 Max, **the development build of Meteor** (so the browser's
own part is larger than in production), one user, `polling` reactivity, FerretDB v1.105.0
with the SQLite membership-test change, MongoDB 8.0 in Docker. Each board is opened
twice (the second is warm), and the whole measurement was run **twice** (run 1 / run 2
below), because one run is not enough: the same board's "settled" time moved by up to
300 ms between runs.
## Result: the database is not what you wait for on a small board, and is on a big one
Milliseconds, first open of a board with this many cards (run 1 / run 2). WeKan loads
cards lazily, so the browser gets **the same ~210 KB and draws 70-120 minicards at every
size**.
| Cards | FerretDB (SQLite): first card | settled | MongoDB 8: first card | settled |
| ---: | ---: | ---: | ---: | ---: |
| 200 | 750 / 764 | 966 / 880 | 752 / 698 | 865 / 813 |
| 2,000 | 931 / 1,031 | 1,763 / 1,555 | 829 / 856 | 1,457 / 1,582 |
| 5,000 | 1,331 / 1,334 | 2,166 / 2,065 | 938 / 899 | 1,562 / 1,622 |
What is the same in both runs:
- **5,000 cards: FerretDB is 400-600 ms slower**, to the first card by 42-48% and until
settled by 27-39%.
- **2,000 cards: within the noise** for "settled" (-30 to +300 ms), and 100-175 ms
slower to the first card.
- **200 cards: 70-100 ms**, which is 9-12%.
The warm second open of run 2: 5,000 cards 1,321 against 865 ms to the first card
and 1,946 against 1,594 ms settled; 2,000 cards 904 against 824 and 1,628 against
1,446; 200 cards 721 against 689 and 934 against 805.
The Meteor server's CPU is the same on both databases within the noise (first open, run
1 / run 2: 102 / 119, 276 / 421 and 470 / 517 ms on FerretDB; 178 / 93, 245 / 342 and
521 / 483 ms on MongoDB), so the difference is **time spent waiting for the database**,
and it grows with the size of the board while the data the browser receives does not.
For a board of a few hundred cards, 0.7-0.75 s to the first card is nearly the same on
both: that is the application (the development bundle, Blaze, the DDP round trips), not
the database.
## Where the Meteor server spends its CPU (5,000 cards, first open, run 2)
| | FerretDB (517 ms) | MongoDB 8 (483 ms) |
| --- | ---: | ---: |
| Garbage collector | 31% | 24% |
| MongoDB driver (wire, decode, encode) | 19% | 22% |
| Meteor mongo (observers, polling, diff) | 12% | 13% |
| Node internals | 11% | 12% |
| WeKan code | 11% | 10% |
| Other Meteor packages and dependencies | 8% | 9% |
| Native, EJSON, DDP server (publish, mergebox) | 8% | 10% |
The DDP server itself, publish and mergebox, is 1%. The largest single functions are
the garbage collector, `CollectionDocument` (WeKan's model class), `replaceTypes`
(Meteor's BSON-to-EJSON step) and the BSON decoder.
## Where FerretDB spends its CPU
With nobody in the browser FerretDB uses 110-510 ms of CPU in the 14 s window the
profile covers. Opening a board adds, net: about nothing at 200 cards, **about 1.6 s at
2,000 and about 3.9-4.1 s at 5,000** - eight times the Meteor server's CPU for the same
open. For the 5,000-card board, of 4.41 s (run 2):
- the SQLite engine, 1.72 s (39%), a good part of it moving through the pages of the
cards table (`btreeInitPage` 18% by itself);
- 34% is `filterIterator.Next`: documents read from SQLite and filtered again in Go;
- by MongoDB command: `find` 0.79 s, `getMore` 0.74 s, `aggregate` 0.68 s (a `$group`
done in Go over the documents it read);
- the Go garbage collector, 0.57 s (13%).
Which WeKan query sends the `aggregate` during the open was **not identified**; the
debug log of a 2,000-card open contains none, so the 5,000-card profile may have
included the All Boards page's card counters (`getAllBoardsTileData`, a `$group` over
the user's boards) that the login lands on first. Treat that line as a lead.
## What this does and does not say
- It says the database layer is the part of opening a board that **scales with the board
size**, and that on FerretDB (SQLite) a 5,000-card board takes 400-600 ms longer than on
MongoDB, with the same data sent to the browser.
- It says the **Meteor server is not the bottleneck**: 0.1-0.5 s of CPU, the same on both
databases, and the DDP server is about 1% of it. A backend rewritten in another language
could save a part of that, and would not change the database or the browser.
- It does not separate the browser's own time from the wait for the server: the first
card at 0.7 s on a small board is mostly the development bundle and rendering.
- Single machine, the development build, one user, two runs, two opens per board, `settled`
meaning 1.5 s without a DDP frame. A production build and a loaded server would differ.
@@ -0,0 +1,188 @@
'use strict';
// Where the time goes when a WeKan board is opened, with WeKan running on a database
// (FerretDB on SQLite, or MongoDB): the browser's wall clock, what crosses the DDP
// WebSocket, the Meteor server's CPU by who spends it (Node inspector profile), and
// FerretDB's CPU (its pprof). The speed test asks the database alone; this asks whether
// the database is what a person waits for.
//
// # WeKan on FerretDB: meteor run --port 3310 --inspect=9229 with MONGO_URL set,
// # FerretDB with --debug-addr=127.0.0.1:8098
// WEKAN_BASE_URL=http://localhost:3310 WEKAN_MONGO_URL=mongodb://127.0.0.1:27040/wekan \
// node docs/Databases/FerretDB/1/SpeedTest/app/board-open.cjs --sizes 200,2000 \
// --inspect 9229 --ferret-pprof http://127.0.0.1:8098 --label ferretdb-sqlite
//
// Dev servers: the Meteor build is the development one, so the client's own time is
// larger than in production; the comparison between databases is what it is for.
const path = require('path');
const { execFileSync } = require('child_process');
const ROOT = path.join(__dirname, '../../../../../..');
const pw = require(path.join(ROOT, 'tests/playwright/node_modules/playwright-core'));
const { MongoClient } = require(path.join(ROOT, 'tests/playwright/node_modules/mongodb'));
const WebSocket = require(path.join(ROOT, 'node_modules/ws'));
function arg(name, fallback) {
const i = process.argv.indexOf(`--${name}`);
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
const BASE = process.env.WEKAN_BASE_URL || 'http://localhost:3310';
process.env.WEKAN_BASE_URL = BASE;
const MONGO = process.env.WEKAN_MONGO_URL || 'mongodb://127.0.0.1:27040/wekan';
process.env.WEKAN_MONGO_URL = MONGO;
const db = require(path.join(ROOT, 'tests/playwright/helpers/db.js'));
const { loginWithToken } = require(path.join(ROOT, 'tests/playwright/helpers/auth.js'));
const SIZES = arg('sizes', '200,2000').split(',').map(Number);
const INSPECT = arg('inspect', '');
const PPROF = arg('ferret-pprof', '');
const LABEL = arg('label', 'run');
const GO = arg('go', path.join(ROOT, '.tools/go/bin/go'));
const FERRET_BIN = arg('ferret-bin', path.join(ROOT, '.tools/FerretDB/bin/ferretdb'));
const QUIET_MS = 1500;
// ── seeding ─────────────────────────────────────────────────────────────────
async function seedBoard(user, size) {
const board = db.seedBoard({ ownerId: user.id, title: `Open ${size}`, listCount: 6, cardTitlesPerList: [['Template']] });
const template = db.findOne('cards', { boardId: board.boardId, title: 'Template' });
const client = await new MongoClient(MONGO).connect();
try {
const cards = client.db().collection('cards');
const docs = [];
for (let i = 0; i < size; i += 1) {
docs.push({
...template,
_id: `card${board.boardId}x${i}`,
title: `Card ${i} ${['plan', 'build', 'review', 'ship'][i % 4]} the thing`,
listId: board.listIds[i % board.listIds.length],
sort: Math.floor(i / board.listIds.length) * 100,
cardNumber: i + 2,
createdAt: new Date(), modifiedAt: new Date(), dateLastActivity: new Date(),
});
}
for (let i = 0; i < docs.length; i += 1000) await cards.insertMany(docs.slice(i, i + 1000));
} finally {
await client.close();
}
return board;
}
// ── the Meteor server's CPU, from the Node inspector ────────────────────────
async function inspector() {
const list = await (await fetch(`http://127.0.0.1:${INSPECT}/json`)).json();
const ws = new WebSocket(list[0].webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
let id = 0;
const pending = new Map();
ws.on('message', (m) => {
const msg = JSON.parse(m);
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
});
const send = (method, params = {}) => new Promise((resolve) => { id += 1; pending.set(id, resolve); ws.send(JSON.stringify({ id, method, params })); });
await send('Profiler.enable');
await send('Profiler.setSamplingInterval', { interval: 250 });
return {
start: () => send('Profiler.start'),
stop: async () => (await send('Profiler.stop')).result.profile,
close: () => ws.close(),
};
}
const { analyse } = require('./profileAnalysis.cjs');
// ── FerretDB's CPU, from its pprof ──────────────────────────────────────────
async function ferretProfile(seconds) {
if (!PPROF) return null;
const res = await fetch(`${PPROF}/debug/pprof/profile?seconds=${seconds}`);
const file = path.join(process.env.TMPDIR || '/tmp', `ferret-open-${Date.now()}.prof`);
require('fs').writeFileSync(file, Buffer.from(await res.arrayBuffer()));
const env = { ...process.env, HOME: path.join(ROOT, '.tools') };
const top = execFileSync(GO, ['tool', 'pprof', '-top', '-nodecount=80', FERRET_BIN, file], { encoding: 'utf8', env });
const total = /Total samples = ([\d.]+)(m?s)/.exec(top);
const cpuMs = total ? Number(total[1]) * (total[2] === 's' ? 1000 : 1) : null;
// A line reads: flat flat% sum% cum cum% name - SQLite's share is the cumulative time of its VM.
const line = top.split('\n').find((l) => /_sqlite3VdbeExec/.test(l));
const sqlite = line ? /([\d.]+)(m?s)\s+[\d.]+%\s+[\d.]+%\s+([\d.]+)(m?s)\s+[\d.]+%/.exec(line) : null;
// The cumulative top, for the first open: who in FerretDB spends it.
const cum = execFileSync(GO, ['tool', 'pprof', '-top', '-cum', '-nodecount=34', FERRET_BIN, file], { encoding: 'utf8', env })
.split('\n').filter((l) => /^\s+[\d.]+m?s/.test(l)).map((l) => l.trim().replace(/\s+/g, ' ')).slice(0, 34);
return { cum, cpuMs, sqliteMs: sqlite ? Number(sqlite[3]) * (sqlite[4] === 's' ? 1000 : 1) : null };
}
// ── one board open ──────────────────────────────────────────────────────────
async function openBoard(user, board, size) {
const browser = await pw.chromium.launch();
const context = await browser.newContext({ viewport: { width: 1500, height: 1000 } });
const page = await context.newPage();
await page.addInitScript(() => { try { localStorage.setItem('wekan-mobile-mode', 'false'); } catch (e) { /* none */ } });
let frames = 0; let bytes = 0; let last = Date.now();
page.on('websocket', (ws) => ws.on('framereceived', (e) => { frames += 1; bytes += e.payload.length; last = Date.now(); }));
await loginWithToken(page, user.id, user.token);
await page.waitForTimeout(1500);
const insp = INSPECT ? await inspector() : null;
frames = 0; bytes = 0;
const ferret = PPROF ? ferretProfile(14) : Promise.resolve(null);
if (insp) await insp.start();
const t0 = Date.now();
await page.goto(`${BASE}/b/${board.boardId}/${board.slug}`, { waitUntil: 'commit' });
let tFirst = null;
await page.waitForSelector('.minicard', { timeout: 60000 }).then(() => { tFirst = Date.now() - t0; });
// quiet: no DDP frame for QUIET_MS
for (;;) {
if (Date.now() - last > QUIET_MS && Date.now() - t0 > (tFirst || 0)) break;
if (Date.now() - t0 > 60000) break;
await page.waitForTimeout(100);
}
const tQuiet = Date.now() - t0 - QUIET_MS;
const profile = insp ? await insp.stop() : null;
const minicards = await page.locator('.minicard').count();
const ferretResult = await ferret;
if (insp) insp.close();
await browser.close();
return { size, tFirst, tQuiet, frames, kb: Math.round(bytes / 1024), minicards, server: profile ? analyse(profile) : null, ferret: ferretResult };
}
const f = (v) => (v === null || v === undefined ? '-' : Math.round(v).toLocaleString('en-US'));
async function main() {
const user = db.seedUser();
const results = [];
// What FerretDB spends in the same 14 s with nobody in the browser: the baseline.
const idle = PPROF ? await ferretProfile(14) : null;
if (idle) console.error(`FerretDB idle for 14 s: ${f(idle.cpuMs)} ms of CPU`);
for (const size of SIZES) {
console.error(`seeding a board of ${size} cards...`);
const board = await seedBoard(user, size);
// The first open of a board is the one a person waits for; a second shows a warm server.
for (const pass of ['first open', 'second open']) {
console.error(`opening ${size} (${pass})...`);
const r = await openBoard(user, board, size);
r.pass = pass;
results.push(r);
}
}
console.log(`\n## ${LABEL}: opening a board\n`);
if (idle) console.log(`FerretDB with nobody in the browser: ${f(idle.cpuMs)} ms of CPU in the same 14 s window (subtract it).\n`);
console.log('| Cards | Open | First card (ms) | Settled (ms) | DDP frames | DDP KB | Minicards drawn | Meteor server busy (ms) | FerretDB CPU (ms) | of it SQLite (ms) |');
console.log('| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |');
for (const r of results) {
console.log(`| ${r.size} | ${r.pass} | ${f(r.tFirst)} | ${f(r.tQuiet)} | ${f(r.frames)} | ${f(r.kb)} | ${f(r.minicards)} | ${f(r.server && r.server.busyMs)} | ${f(r.ferret && r.ferret.cpuMs)} | ${f(r.ferret && r.ferret.sqliteMs)} |`);
}
for (const r of results.filter((x) => x.pass === 'first open' && x.server)) {
console.log(`\n### The Meteor server, ${r.size} cards, first open: ${f(r.server.busyMs)} ms of CPU\n`);
for (const [c, ms] of r.server.categories) console.log(`- ${c}: ${f(ms)} ms (${Math.round((100 * ms) / r.server.busyMs)}%)`);
if (r.ferret && r.ferret.cum) {
console.log(`\n### FerretDB, ${r.size} cards, first open: cumulative CPU by function (flat flat% sum% cum cum% name)\n`);
console.log('```');
r.ferret.cum.forEach((l) => console.log(l));
console.log('```');
}
console.log('\nTop functions:');
for (const [fn, ms] of r.server.topFunctions) console.log(`- ${f(ms)} ms ${fn}`);
}
process.exit(0);
}
if (require.main === module) main().catch((e) => { console.error(e); process.exit(1); });
@@ -0,0 +1,46 @@
'use strict';
// A Node inspector CPU profile of the Meteor server, by who spends the time. Apart from
// the browser and the database so a test can drive it with a made-up profile.
function categorise(node) {
const { functionName, url } = node.callFrame;
if (functionName === '(idle)') return 'idle';
if (functionName === '(garbage collector)') return 'garbage collector';
if (functionName === '(program)') return 'native (program)';
if (/\/mongodb\/|npm-mongo|\/bson\//.test(url)) return 'MongoDB driver (wire, decode, encode)';
if (/packages\/ddp-server|packages\/ddp-common|packages\/ddp\b/.test(url)) return 'Meteor DDP (publish, mergebox, sessions)';
if (/packages\/mongo|minimongo|diff-sequence/.test(url)) return 'Meteor mongo (observe, polling, diff)';
if (/packages\/ejson/.test(url)) return 'EJSON';
// Other Meteor packages and dependencies are not WeKan's: their paths contain /server/ too.
if (/programs\/server\/packages|node_modules/.test(url)) return 'other packages';
if (/programs\/server\/app|\/_build\/|\/models\/|\/server\/|\/imports\/|wekan/.test(url)) return 'WeKan code';
if (/^node:/.test(url) || url === '') return 'node internals';
return 'other packages';
}
function analyse(profile) {
const byId = new Map(profile.nodes.map((n) => [n.id, n]));
const cats = new Map();
const fns = new Map();
let total = 0;
profile.samples.forEach((sid, i) => {
const dt = (profile.timeDeltas[i + 1] ?? profile.timeDeltas[i] ?? 0) / 1000; // ms
const node = byId.get(sid);
const cat = categorise(node);
cats.set(cat, (cats.get(cat) || 0) + dt);
total += dt;
if (cat !== 'idle') {
const key = `${node.callFrame.functionName || '(anonymous)'} ${node.callFrame.url.split('/').slice(-2).join('/')}`;
fns.set(key, (fns.get(key) || 0) + dt);
}
});
const idle = cats.get('idle') || 0;
return {
totalMs: total, busyMs: total - idle,
categories: [...cats.entries()].filter(([c]) => c !== 'idle').sort((a, b) => b[1] - a[1]),
topFunctions: [...fns.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8),
};
}
module.exports = { categorise, analyse };
@@ -0,0 +1,148 @@
# Go databases that could be linked into FerretDB, against its SQLite
FerretDB is one Go binary, and its SQLite handler is the pure-Go driver
[modernc.org/sqlite](https://gitlab.com/cznic/sqlite) inside it. Is any database
implemented in Go (the [awesome-go list](https://github.com/avelino/awesome-go#databases-implemented-in-go))
one that could be linked in beside it, has enough to take SQLite's place, and is
faster for what WeKan asks?
**Short answer: no. Nothing is faster in a way that matters, and the ones that are
faster at a single read or write are key-value stores that would make the handler
do SQLite's job.**
This is the engine level of the question, so it can be answered in minutes and
without writing a handler for each: [bench.go](main.go) loads the speed test's cards
and asks each engine the questions WeKan asks of cards, the way a FerretDB handler
written for it would ask them.
```sh
node docs/Databases/FerretDB/1/SpeedTest/embedded/export.cjs 0.05 <dir> # the speed test's cards as JSON lines
cd docs/Databases/FerretDB/1/SpeedTest/embedded && go run . -data <dir> # go 1.24+, pure Go, no cgo
```
Run on 2026-11-03 on an Apple M2 Max, Go 1.27, one engine at a time, each in its own
directory. `-only "Pebble (CockroachDB's LSM),Nutsdb"` runs some.
## What is asked
| Column | The question |
| --- | --- |
| By id | one card by `_id` |
| Board cards | the unarchived cards of a board (an index on boardId and archived), **decoded** from JSON as FerretDB must |
| (raw, not decoded) | the same fetch without the decoding: the part that differs between engines |
| Count | how many unarchived cards a board has |
| Edit, Move | read the card, change it in Go, write it back (FerretDB's `updateOne`), 200 different cards |
| Add | insert a card |
Each read is asked of 15 boards - the 3 biggest and 12 ordinary ones, as in the
speed test - and a cell is the average of the 15 medians. Times are milliseconds
for one call, **Total** is the sum of the six and the table is ordered by it. Every
engine first has to give the right number of cards and the right counts for every
board, and the writes must be there afterwards: a fast wrong answer is no answer.
**SQLite is set up as FerretDB sets it**: WAL, `synchronous=NORMAL`, a 64 MiB cache
and 256 MiB of mmap, the document as JSON and the indexes on `json_extract`
expressions. A row with plain columns shows what SQLite could do and FerretDB does not.
The key-value stores keep a document under `d/<id>` and an index of
(boardId, archived) as keys, which is what a handler on them would have to build.
No engine fsyncs per commit except where the row says so, to match SQLite's
`NORMAL`.
## 15,377 cards (scale 0.05)
| # | Engine | Total | By id | Board cards | (raw, not decoded) | Count | Edit | Move | Add | Load (s) | Size (MB) |
| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 1 | Nutsdb | **1.394** | 0.0004 | 1.332 | 0.057 | 0.0066 | 0.019 | 0.032 | 0.0041 | 0.0 | 13 |
| 2 | bbolt, no fsync | **1.481** | 0.0005 | 1.349 | 0.055 | 0.0047 | 0.037 | 0.054 | 0.035 | 0.2 | 27 |
| 3 | SQLite, plain columns (not what FerretDB does) | **1.598** | 0.0067 | 1.385 | 0.116 | 0.025 | 0.057 | 0.067 | 0.058 | 0.1 | 15 |
| 4 | Badger | **1.623** | 0.0015 | 1.536 | 0.152 | 0.012 | 0.027 | 0.040 | 0.0065 | 0.0 | n/a |
| 5 | SQLite as FerretDB uses it (JSON, expression indexes) | **1.677** | 0.0078 | 1.421 | 0.113 | 0.037 | 0.073 | 0.082 | 0.057 | 0.2 | 14 |
| 6 | SQLite as FerretDB uses it, synchronous=FULL | **1.747** | 0.0079 | 1.421 | 0.118 | 0.037 | 0.098 | 0.099 | 0.084 | 0.2 | 14 |
| 7 | BuntDB (in memory with an append-only file) | **1.795** | 0.0003 | 1.470 | 0.186 | 0.166 | 0.066 | 0.072 | 0.021 | 0.3 | 12 |
| 8 | Pebble (CockroachDB's LSM) | **1.841** | 0.0028 | 1.770 | 0.298 | 0.011 | 0.021 | 0.034 | 0.0010 | 0.1 | 16 |
| 9 | bbolt, fsync on every commit (its default) | **25.5** | 0.0005 | 1.370 | 0.057 | 0.0048 | 8.062 | 8.025 | 8.015 | 0.2 | 27 |
| 10 | Clover (document database over Badger) | **145** | 0.016 | 59.1 | 58.5 | 57.2 | 9.412 | 9.635 | 9.751 | 0.7 | 41 |
## 153,768 cards (scale 0.5)
| # | Engine | Total | By id | Board cards | (raw, not decoded) | Count | Edit | Move | Add | Load (s) | Size (MB) |
| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 1 | SQLite, plain columns (not what FerretDB does) | **5.950** | 0.0071 | 5.693 | 0.418 | 0.062 | 0.052 | 0.072 | 0.064 | 1.1 | 147 |
| 2 | Nutsdb | **6.007** | 0.0005 | 5.923 | 0.326 | 0.024 | 0.022 | 0.033 | 0.0044 | 0.3 | 132 |
| 3 | bbolt, no fsync | **6.065** | 0.0005 | 5.909 | 0.289 | 0.019 | 0.042 | 0.056 | 0.039 | 14.5 | 274 |
| 4 | SQLite as FerretDB uses it, synchronous=FULL | **6.118** | 0.0089 | 5.726 | 0.413 | 0.103 | 0.098 | 0.097 | 0.085 | 1.8 | 141 |
| 5 | SQLite as FerretDB uses it (JSON, expression indexes) | **6.153** | 0.0082 | 5.830 | 0.430 | 0.103 | 0.072 | 0.079 | 0.062 | 1.8 | 141 |
| 6 | BuntDB (in memory with an append-only file) | **7.001** | 0.0003 | 6.130 | 0.753 | 0.682 | 0.080 | 0.083 | 0.026 | 4.3 | 124 |
| 7 | Badger | **7.419** | 0.0025 | 7.273 | 1.483 | 0.060 | 0.032 | 0.042 | 0.0096 | 0.4 | n/a |
| 8 | Pebble (CockroachDB's LSM) | **10.5** | 0.023 | 10.3 | 4.992 | 0.056 | 0.033 | 0.032 | 0.0012 | 2.0 | 67 |
| 9 | bbolt, fsync on every commit (its default) | **42.0** | 0.0006 | 5.923 | 0.296 | 0.019 | 11.8 | 12.2 | 12.1 | 14.4 | 274 |
| 10 | Clover (document database over Badger) | **1176** | 0.017 | 577 | 571 | 563 | 11.9 | 11.7 | 11.8 | 25.7 | 301 |
## What it says
- **Nothing is faster where it counts.** A board's cards cost 1.4 ms at 15,000 cards
and 5.7-6.2 ms at 154,000 for every engine that is not slower, and nearly all of
it is decoding the JSON, which is the same work whatever stores it. Totals are
within 5% of SQLite's for Nutsdb and bbolt.
- **The key-value stores win single reads and writes, by microseconds.** A card by
id takes 0.0003-0.0006 ms on bbolt, Nutsdb and BuntDB against SQLite's 0.008; an insert
0.001 ms on Pebble against 0.062; an edit 0.022 ms on Nutsdb against 0.072. But the
cheapest call through FerretDB costs about 0.2 ms in the speed test (the protocol,
the handler, the network) and most cost more, so an engine that took nothing at all would make one call
at most a few percent faster.
- **Some are slower, and by a lot.** Pebble's LSM is 12x SQLite on fetching a big
board's cards (5.0 ms against 0.43), Badger 3.5x, Clover about 1,300x (it decodes every
document to answer anything), 90-190x in total. **bbolt with its default - an fsync on every commit
- takes 8-12 ms per write, 100-160x SQLite's**; without it, it is as fast as
SQLite and not durable the same way.
- **The cost is in what is not there.** A key-value store has keys. FerretDB's SQLite
handler is about 8,500 lines because filters, sorting, `$or`, `$in`, regular
expressions, aggregation, indexes and statistics are SQL there. On a key-value
store the handler would do them in Go, and this benchmark asked only the six
easiest questions. Of the stores here only BuntDB and Clover have indexes on JSON
fields, and neither has a query language that could take SQLite's place.
- **Loading** at 154,000 cards: Badger 0.4 s, Nutsdb 0.3 s, Pebble 2.0 s, SQLite 1.8
s, BuntDB 4.3 s, bbolt 14.5 s (unsorted inserts), Clover 26 s.
- Disk: SQLite 141 MB, Pebble 67, Nutsdb 132, BuntDB 124, bbolt 274, Clover 301.
## The list, entry by entry
Tested (Go library, open source, maintained, pure Go):
| Entry | Licence, last push | Result |
| --- | --- | --- |
| [go-sqlite](https://gitlab.com/cznic/sqlite) | BSD-3, FerretDB's own | the baseline, three ways |
| [Pebble](https://github.com/cockroachdb/pebble) | BSD-3, 2026-11 | fastest insert and edit, 12x slower board fetch |
| [Badger](https://github.com/dgraph-io/badger) | Apache-2.0, 2026-10 | fast writes, 3.5x slower board fetch |
| [bbolt](https://github.com/etcd-io/bbolt) | MIT, 2026-10 | as fast as SQLite without fsync, 100x slower writes with it |
| [BuntDB](https://github.com/tidwall/buntdb) | MIT, 2026-05 | fastest by id, slowest count (index scan), in memory |
| [Nutsdb](https://github.com/nutsdb/nutsdb) | Apache-2.0, 2026-10 | on par with SQLite, faster point operations |
| [Clover](https://github.com/ostafen/clover) | MIT, 2025-09 | has indexes and queries, 90-190x slower in total |
Not tested, and why:
| Entry | Why not |
| --- | --- |
| goleveldb (2024-05), tiedot (2021), pudge (2021), scribble (2023), ledisdb (2023), hare, diskv, libradb | not maintained since 2021-2024, or toys; the engines that matter of their kind (LSM, B+tree) are Pebble, Badger and bbolt, tested |
| Bitcask, pogreb, moss, LotusDB, RoseDB, Coffer, tempdb | more key-value stores of the same kind; they would also need the handler to do what SQL does |
| gedb, [minisql](https://github.com/RichardKnop/minisql) (54 stars) | not looked at beyond their descriptions: a MongoDB-like store and a young single-file SQL database |
| [rqlite](https://github.com/rqlite/rqlite), [redka](https://github.com/nalgeon/redka) | they are SQLite underneath, so not faster by construction |
| [Dolt](https://github.com/dolthub/dolt), [TiDB](https://github.com/pingcap/tidb) | tested as servers in the [speed test](../) (Dolt the slowest of all, TiDB 2.3x MySQL's total); embedding their engines (go-mysql-server) would bring the same engines and far more code |
| CockroachDB | not open source (proprietary licence) |
| immudb | GitHub does not recognise its licence as an open-source one (`NOASSERTION`); an immutable ledger |
| DiceDB, godis, rotom, Vasto, ledisdb | Redis-style servers, not libraries |
| Dgraph, EliasDB, NornicDB, MemHop | graph databases |
| InfluxDB, Prometheus, VictoriaMetrics, LinDB, lynxdb, nanotdb, unitdb | time series and logs |
| Milvus | vector database |
| column | in-memory columnar store |
| objectbox-go | needs cgo |
| CovenantSQL, Databunker, NoKV, piladb | blockchain, a PII vault, a metadata service, a stack |
## What would be needed to know more
A real answer for one engine means a handler for it: query pushdown, updates,
indexes, statistics, 2,000-8,000 lines each. This benchmark says where it could pay:
nowhere on reads (decoding dominates), and only on small writes, which the protocol
hides. It does not measure what SQLite does in the engine and a key-value store
would do in Go - sorting, `$or`, regular expressions, aggregation - where SQLite can
only be ahead.
@@ -0,0 +1,120 @@
package main
import (
"github.com/dgraph-io/badger/v4"
)
type badgerEng struct {
db *badger.DB
dir string
}
func openBadger() maker {
return maker{name: "Badger", open: func(dir string) (Engine, error) {
db, err := badger.Open(badger.DefaultOptions(dir).WithLogger(nil).WithSyncWrites(false))
if err != nil {
return nil, err
}
return &badgerEng{db: db, dir: dir}, nil
}}
}
func (e *badgerEng) Name() string { return "Badger" }
func (e *badgerEng) Load(cards []Card) error {
wb := e.db.NewWriteBatch()
defer wb.Cancel()
for _, c := range cards {
if err := wb.Set(docKey(c.ID), c.Raw); err != nil {
return err
}
if err := wb.Set(indexKey(c), nil); err != nil {
return err
}
}
return wb.Flush()
}
func (e *badgerEng) ByID(id string) ([]byte, error) {
var out []byte
err := e.db.View(func(tx *badger.Txn) error {
it, err := tx.Get(docKey(id))
if err != nil {
return err
}
out, err = it.ValueCopy(nil)
return err
})
return out, err
}
func (e *badgerEng) BoardCards(board string) ([][]byte, error) {
var out [][]byte
err := e.db.View(func(tx *badger.Txn) error {
p := boardPrefix(board)
it := tx.NewIterator(badger.IteratorOptions{Prefix: p})
defer it.Close()
var ids []string
for it.Rewind(); it.Valid(); it.Next() {
ids = append(ids, idFromIndexKey(it.Item().Key()))
}
for _, id := range ids {
item, err := tx.Get(docKey(id))
if err != nil {
return err
}
v, err := item.ValueCopy(nil)
if err != nil {
return err
}
out = append(out, v)
}
return nil
})
return out, err
}
func (e *badgerEng) CountBoard(board string) (int, error) {
n := 0
err := e.db.View(func(tx *badger.Txn) error {
it := tx.NewIterator(badger.IteratorOptions{Prefix: boardPrefix(board)})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
n++
}
return nil
})
return n, err
}
func (e *badgerEng) Update(id string, change func([]byte) ([]byte, error)) error {
return e.db.Update(func(tx *badger.Txn) error {
item, err := tx.Get(docKey(id))
if err != nil {
return err
}
d, err := item.ValueCopy(nil)
if err != nil {
return err
}
next, err := change(d)
if err != nil {
return err
}
return tx.Set(docKey(id), next)
})
}
func (e *badgerEng) Insert(c Card) error {
return e.db.Update(func(tx *badger.Txn) error {
if err := tx.Set(docKey(c.ID), c.Raw); err != nil {
return err
}
return tx.Set(indexKey(c), nil)
})
}
// SizeBytes is -1: Badger keeps a recent write in its memtable and preallocates its
// log files, so the directory says nothing about what the data takes.
func (e *badgerEng) SizeBytes() int64 { return -1 }
func (e *badgerEng) Close() error { return e.db.Close() }
@@ -0,0 +1,120 @@
package main
import (
"bytes"
"fmt"
"path/filepath"
bolt "go.etcd.io/bbolt"
)
type boltEng struct {
db *bolt.DB
dir string
}
// bbolt fsyncs on every commit, which the others here do not; "NoSync" is the same
// engine without it, for a like-for-like with SQLite's synchronous=NORMAL.
func openBolt(name string, noSync bool) maker {
return maker{name: name, open: func(dir string) (Engine, error) {
db, err := bolt.Open(filepath.Join(dir, "cards.bolt"), 0o600, &bolt.Options{NoSync: noSync})
if err != nil {
return nil, err
}
db.NoSync = noSync
return &boltEng{db: db, dir: dir}, nil
}}
}
func (e *boltEng) Name() string { return "bbolt" }
func (e *boltEng) Load(cards []Card) error {
return e.db.Update(func(tx *bolt.Tx) error {
d, err := tx.CreateBucketIfNotExists([]byte("d"))
if err != nil {
return err
}
b, err := tx.CreateBucketIfNotExists([]byte("b"))
if err != nil {
return err
}
for _, c := range cards {
if err := d.Put([]byte(c.ID), c.Raw); err != nil {
return err
}
if err := b.Put(indexKey(c)[2:], nil); err != nil { // without the b/
return err
}
}
return nil
})
}
func (e *boltEng) ByID(id string) ([]byte, error) {
var out []byte
err := e.db.View(func(tx *bolt.Tx) error {
v := tx.Bucket([]byte("d")).Get([]byte(id))
if v == nil {
return fmt.Errorf("not found")
}
out = append([]byte(nil), v...)
return nil
})
return out, err
}
func (e *boltEng) scan(tx *bolt.Tx, board string, f func(id string) error) error {
p := boardPrefix(board)[2:]
c := tx.Bucket([]byte("b")).Cursor()
for k, _ := c.Seek(p); k != nil && bytes.HasPrefix(k, p); k, _ = c.Next() {
if err := f(idFromIndexKey(k)); err != nil {
return err
}
}
return nil
}
func (e *boltEng) BoardCards(board string) ([][]byte, error) {
var out [][]byte
err := e.db.View(func(tx *bolt.Tx) error {
d := tx.Bucket([]byte("d"))
return e.scan(tx, board, func(id string) error {
out = append(out, append([]byte(nil), d.Get([]byte(id))...))
return nil
})
})
return out, err
}
func (e *boltEng) CountBoard(board string) (int, error) {
n := 0
err := e.db.View(func(tx *bolt.Tx) error { return e.scan(tx, board, func(string) error { n++; return nil }) })
return n, err
}
func (e *boltEng) Update(id string, change func([]byte) ([]byte, error)) error {
return e.db.Update(func(tx *bolt.Tx) error {
d := tx.Bucket([]byte("d"))
cur := d.Get([]byte(id))
if cur == nil {
return fmt.Errorf("not found")
}
next, err := change(append([]byte(nil), cur...))
if err != nil {
return err
}
return d.Put([]byte(id), next)
})
}
func (e *boltEng) Insert(c Card) error {
return e.db.Update(func(tx *bolt.Tx) error {
if err := tx.Bucket([]byte("d")).Put([]byte(c.ID), c.Raw); err != nil {
return err
}
return tx.Bucket([]byte("b")).Put(indexKey(c)[2:], nil)
})
}
func (e *boltEng) SizeBytes() int64 { return dirSize(e.dir) }
func (e *boltEng) Close() error { return e.db.Close() }
@@ -0,0 +1,101 @@
package main
import (
"encoding/json"
"fmt"
"path/filepath"
"github.com/tidwall/buntdb"
)
// BuntDB keeps everything in memory and writes an append-only file. It has indexes
// on JSON fields of its own, so no index keys are built by hand.
type buntEng struct {
db *buntdb.DB
dir string
}
func openBunt() maker {
return maker{name: "BuntDB (in memory with an append-only file)", open: func(dir string) (Engine, error) {
db, err := buntdb.Open(filepath.Join(dir, "cards.db"))
if err != nil {
return nil, err
}
return &buntEng{db: db, dir: dir}, nil
}}
}
func (e *buntEng) Name() string { return "BuntDB" }
func (e *buntEng) Load(cards []Card) error {
if err := e.db.Update(func(tx *buntdb.Tx) error {
for _, c := range cards {
if _, _, err := tx.Set("card:"+c.ID, string(c.Raw), nil); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
return e.db.CreateIndex("board", "card:*", buntdb.IndexJSON("boardId"), buntdb.IndexJSON("archived"))
}
func (e *buntEng) ByID(id string) ([]byte, error) {
var out []byte
err := e.db.View(func(tx *buntdb.Tx) error {
v, err := tx.Get("card:" + id)
out = []byte(v)
return err
})
return out, err
}
func (e *buntEng) pivot(board string) string {
b, _ := json.Marshal(board)
return fmt.Sprintf(`{"boardId":%s,"archived":false}`, b)
}
func (e *buntEng) BoardCards(board string) ([][]byte, error) {
var out [][]byte
err := e.db.View(func(tx *buntdb.Tx) error {
return tx.AscendEqual("board", e.pivot(board), func(_, v string) bool {
out = append(out, []byte(v))
return true
})
})
return out, err
}
func (e *buntEng) CountBoard(board string) (int, error) {
n := 0
err := e.db.View(func(tx *buntdb.Tx) error {
return tx.AscendEqual("board", e.pivot(board), func(_, _ string) bool { n++; return true })
})
return n, err
}
func (e *buntEng) Update(id string, change func([]byte) ([]byte, error)) error {
return e.db.Update(func(tx *buntdb.Tx) error {
v, err := tx.Get("card:" + id)
if err != nil {
return err
}
next, err := change([]byte(v))
if err != nil {
return err
}
_, _, err = tx.Set("card:"+id, string(next), nil)
return err
})
}
func (e *buntEng) Insert(c Card) error {
return e.db.Update(func(tx *buntdb.Tx) error {
_, _, err := tx.Set("card:"+c.ID, string(c.Raw), nil)
return err
})
}
func (e *buntEng) SizeBytes() int64 { return dirSize(e.dir) }
func (e *buntEng) Close() error { return e.db.Close() }
@@ -0,0 +1,143 @@
package main
import (
"encoding/json"
c "github.com/ostafen/clover/v2"
d "github.com/ostafen/clover/v2/document"
q "github.com/ostafen/clover/v2/query"
)
// Clover is a document database with indexes and queries of its own, over Badger.
type cloverEng struct {
db *c.DB
dir string
}
func openClover() maker {
return maker{name: "Clover (document database over Badger)", open: func(dir string) (Engine, error) {
db, err := c.Open(dir)
if err != nil {
return nil, err
}
if err := db.CreateCollection("cards"); err != nil {
return nil, err
}
return &cloverEng{db: db, dir: dir}, nil
}}
}
func (e *cloverEng) Name() string { return "Clover" }
func toDoc(raw []byte) (*d.Document, error) {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
// _id is Clover's own (a UUID); the card's id is kept in cid.
m["cid"] = m["_id"]
delete(m, "_id")
return d.NewDocumentOf(m), nil
}
// fromDoc is the card as JSON again, with its own _id back.
func fromDoc(doc *d.Document) ([]byte, error) {
m := map[string]any{}
for k, v := range doc.AsMap() {
m[k] = v
}
m["_id"] = m["cid"]
delete(m, "cid")
delete(m, "_id_clover")
return json.Marshal(m)
}
func (e *cloverEng) Load(cards []Card) error {
docs := make([]*d.Document, 0, len(cards))
for _, card := range cards {
doc, err := toDoc(card.Raw)
if err != nil {
return err
}
docs = append(docs, doc)
}
if err := e.db.Insert("cards", docs...); err != nil {
return err
}
for _, f := range []string{"cid", "boardId"} {
if err := e.db.CreateIndex("cards", f); err != nil {
return err
}
}
return nil
}
func (e *cloverEng) one(id string) (*d.Document, error) {
return e.db.FindFirst(q.NewQuery("cards").Where(q.Field("cid").Eq(id)))
}
func (e *cloverEng) ByID(id string) ([]byte, error) {
doc, err := e.one(id)
if err != nil || doc == nil {
return nil, err
}
return fromDoc(doc)
}
func (e *cloverEng) query(board string) *q.Query {
return q.NewQuery("cards").Where(q.Field("boardId").Eq(board).And(q.Field("archived").Eq(false)))
}
func (e *cloverEng) BoardCards(board string) ([][]byte, error) {
docs, err := e.db.FindAll(e.query(board))
if err != nil {
return nil, err
}
// Clover returns decoded documents. They are encoded again so the runner decodes
// them like every other engine's - which charges Clover for a step it does not need.
out := make([][]byte, 0, len(docs))
for _, doc := range docs {
b, err := fromDoc(doc)
if err != nil {
return nil, err
}
out = append(out, b)
}
return out, nil
}
func (e *cloverEng) CountBoard(board string) (int, error) { return e.db.Count(e.query(board)) }
func (e *cloverEng) Update(id string, change func([]byte) ([]byte, error)) error {
doc, err := e.one(id)
if err != nil {
return err
}
raw, err := fromDoc(doc)
if err != nil {
return err
}
next, err := change(raw)
if err != nil {
return err
}
var m map[string]any
if err := json.Unmarshal(next, &m); err != nil {
return err
}
m["cid"] = m["_id"]
delete(m, "_id")
return e.db.Update(q.NewQuery("cards").Where(q.Field("cid").Eq(id)), m)
}
func (e *cloverEng) Insert(card Card) error {
doc, err := toDoc(card.Raw)
if err != nil {
return err
}
_, err = e.db.InsertOne("cards", doc)
return err
}
func (e *cloverEng) SizeBytes() int64 { return dirSize(e.dir) }
func (e *cloverEng) Close() error { return e.db.Close() }
@@ -0,0 +1,17 @@
package main
// engines lists what is compared, the baseline first.
func engines() []maker {
return []maker{
openSQLite("SQLite as FerretDB uses it (JSON, expression indexes)", "normal", false),
openSQLite("SQLite, plain columns (not what FerretDB does)", "normal", true),
openSQLite("SQLite as FerretDB uses it, synchronous=FULL", "full", false),
openPebble(),
openBadger(),
openBolt("bbolt, no fsync", true),
openBolt("bbolt, fsync on every commit (its default)", false),
openBunt(),
openNuts(),
openClover(),
}
}
@@ -0,0 +1,35 @@
'use strict';
// Writes the speed test's deterministic data as JSON lines for the Go benchmark of
// embedded engines (bench.go): the cards, with the fields the queries read, and the
// parameters (the 3 biggest boards and 12 ordinary ones) the same way run.cjs picks
// them. node embedded/export.cjs <scale> <out-directory>
const fs = require('fs');
const path = require('path');
const data = require('../data.cjs');
const scale = Number(process.argv[2] || '0.05');
const out = path.resolve(process.argv[3] || '.');
fs.mkdirSync(out, { recursive: true });
const plan = data.plan({ scale });
const lines = [];
const perBoard = new Map();
let n = 0;
for (const batch of data.documents(plan, 'cards', 5000)) {
for (const doc of batch) {
lines.push(JSON.stringify(doc, (k, v) => (v instanceof Date ? { $date: v.getTime() } : v)));
perBoard.set(doc.boardId, (perBoard.get(doc.boardId) || 0) + 1);
n += 1;
}
}
fs.writeFileSync(path.join(out, 'cards.jsonl'), `${lines.join('\n')}\n`);
// Parameters: the 3 biggest boards, then 12 ordinary ones spread over the rest.
const boards = [...perBoard.entries()].sort((a, b) => b[1] - a[1]);
const biggest = boards.slice(0, 3).map(b => b[0]);
const rest = boards.slice(3);
const step = Math.max(1, Math.floor(rest.length / 12));
const ordinary = Array.from({ length: 12 }, (_, i) => rest[Math.min(rest.length - 1, i * step)][0]);
fs.writeFileSync(path.join(out, 'params.json'), JSON.stringify({ scale, cards: n, boards: [...biggest, ...ordinary], sizes: boards.slice(0, 3).map(b => b[1]) }));
console.log(`${n} cards, ${perBoard.size} boards; biggest boards have ${boards.slice(0, 3).map(b => b[1]).join(', ')} cards`);
@@ -0,0 +1,86 @@
module embeddedbench
go 1.26.0
require (
github.com/cockroachdb/pebble v1.1.5
github.com/dgraph-io/badger/v4 v4.9.6
github.com/nutsdb/nutsdb v1.1.0
github.com/ostafen/clover/v2 v2.0.0-alpha.3
github.com/tidwall/buntdb v1.3.2
go.etcd.io/bbolt v1.5.0
modernc.org/sqlite v1.61.0
)
require (
github.com/DataDog/zstd v1.4.5 // indirect
github.com/antlabs/stl v0.0.2 // indirect
github.com/antlabs/timer v0.1.4 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cockroachdb/errors v1.11.3 // indirect
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce // indirect
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect
github.com/cockroachdb/redact v1.1.5 // indirect
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgraph-io/badger/v3 v3.2103.2 // indirect
github.com/dgraph-io/ristretto v0.1.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/edsrzf/mmap-go v1.2.0 // indirect
github.com/getsentry/sentry-go v0.27.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gofrs/flock v0.8.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.2.4 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/orderedcode v0.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.7 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.15.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.42.0 // indirect
github.com/prometheus/procfs v0.9.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/satori/go.uuid v1.2.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tidwall/btree v1.6.0 // indirect
github.com/tidwall/gjson v1.14.3 // indirect
github.com/tidwall/grect v0.1.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/rtred v0.1.2 // indirect
github.com/tidwall/tinyqueue v0.1.1 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xujiajun/utils v0.0.0-20220904132955-5f7c5b914235 // indirect
go.opencensus.io v0.23.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
golang.org/x/net v0.62.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.79.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
)
Loaded 100 of 645 files, more files were not shown because too many files have changed in this diff. Show more