Compare commits

..
849 Commits
Author SHA1 Message Date
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
github-actions[bot] 63d022fe5f Bump versions for v12.76 2026-10-31 19:44:06 +00:00
Lauri Ojansivu e9cf825b22 Prepare v12.76 release 2026-10-31 21:43:03 +02:00
Lauri Ojansivu 4ce8514e36 CHANGELOG: open issues #6834-#6840 - Today in the date picker, the card title and movable popups, Change Settings, the d shortcut, minicard dates.
#6834 is fixed in part and #6837 needs a decision: both in TODO Later.

Thanks to mimZD and xet7 !
2026-10-31 21:32:28 +02:00
Lauri Ojansivu 429fdc5319 Tests: a card-button rule is a button on the minicard (#6836 was already there).
"Show rule button on minicard" is Board Settings / Card, Card buttons,
Show on Minicard (allowsCardButtonsOnMinicard), in WeKan since v12.16:
the board's card-button rules are drawn under each minicard, and a click
runs the rule on that card without opening it. Off by default, which is
why it was not seen. It had no test.

minicard-rule-button-6836.e2e.js (Chromium and WebKit): with it on, a
card-button rule's button is on the minicard and a click archives the
card without opening it; with it off, no minicard has one.

Thanks to mimZD and xet7 !

Fixes #6836,
2026-10-31 21:31:36 +02:00
Lauri Ojansivu ffd5cbd2ad Change Settings: icons in a column on the first line, compact wrapped lines, rows divided.
Member Settings / Change Settings used the shared popup list as it is:
a flex row whose every line is 33px high with its icon centred. Its
settings are sentences, and most wrap - in Persian, in German - so a
setting became two tall lines with its icon floating between them and
the next setting right under it, with nothing to tell where one ended.
Each row is now an icon column, the words and the check mark at the
end; the icon sits on the first line, a wrapped line is an ordinary
line high, a rule divides the rows, and the Card settings checkbox has
the same space beside its words as an icon has.

Scoped to the popup's own list (change-settings-list): a popup keeps
the screens before it - the member menu - in the same .pop-over, so its
data-popup would have restyled those too.

change-settings-popup-6835.e2e.js, in Persian and English, Chromium and
WebKit: each row's icon is on its first line, a line is under 28px high
and a row only as tall as its lines, rows are divided, and the member
menu beside it is untouched.

Thanks to mimZD and xet7 !

Fixes #6835,
2026-10-31 21:29:31 +02:00
Lauri Ojansivu cf4b48df09 Minicard: a due date uses the card's width instead of three lines beside an empty strip.
The dates row of a minicard reserved 84px at its trailing edge - a third
of a 250px card - for nothing: the avatars float after it, and clear:
both starts it below the menu. A due date with its "1 day left" was
broken over three lines beside that empty strip, in a Persian board of
the report. It reserves the drag handle's room now, 24px, and the same
badge takes two lines at the default list width.

The board header bar wrapping to a second row, and the list header icons
under the card count, in the same report, did not reproduce on the
current code at the same window width, in Persian, with the card count
on; they are in CHANGELOG TODO Later until the reporter can say which
WeKan and which browser zoom showed them.

minicard-dates-width-6834.e2e.js (Chromium and WebKit, English and
Persian): the dates row reserves at most 24px, and the due date badge is
at most two lines high.

Thanks to mimZD and xet7 !
2026-10-31 21:26:15 +02:00
Lauri Ojansivu 73c21dfe3e Tests: typing d in the card title types at the end of the title in WebKit too.
WebKit puts the caret at the start of a field on focus and Chromium at
the end, so the d typed into the title landed at its start in WebKit and
the check for a title ending in dd failed there - while what the test is
for, that d typed in a field opens no Due date editor, held in both.
The test puts the caret at the end itself now.

Thanks to xet7 !
2026-10-31 21:23:04 +02:00
Lauri Ojansivu 22a12dc7f8 Opened card: the whole title edits; the window and every popup move by their bar or handle.
The opened card's title was split like a minicard's: the leading half
opened the editor and the trailing half dragged the window, so clicking
the right of the title, or beside a short last line, did not edit. Now
all of the title edits. With drag handles off, the window is moved from
the empty space of its header row above the title - between the collapse
caret and the menu; with drag handles on, only by the drag handle. A
reader who may not edit has no edit zone and still drags by the title.

Every popup now moves the same way - by its title bar with drag handles
off, by the handle before its title alone with them on - where only the
Drawing, Board View and Board Card settings popups and the date popups
moved before. A dragged popup is kept inside the window as it grows. The
drag pins a popup where it is before moving it: one placed from the
right edge (the member menu) jumped across the window, and one with a
margin by that margin, as the drag began.

card-title-edit-and-move-6839.e2e.js, in Chromium and WebKit: a two-line
title edits from its right, middle and left; the empty header row moves
the window by exactly the drag with handles off and not at all with them
on, when the handle does; and the card menu, Labels and the member menu
move by their title bar, or with handles on by the handle alone.
titleClickToEdit, popupMoveMaximize and popupBounds pin it.

Thanks to mimZD and xet7 !

Fixes #6839,
2026-10-31 21:23:04 +02:00
Lauri Ojansivu b1403ca7af The d shortcut opens the Due date editor of a card that is still being opened.
On a slower server a card's details render a few hundred milliseconds
after the click that opens it. `d` pressed in that moment found no Due
date control yet and did nothing - only the hovered-minicard path (#6755)
waited for it. Both paths now wait for THAT card's control, stop if
another card was opened meanwhile, click it once, and give up after 3
seconds.

The card details also closed any popup of their card when they were
destroyed (#6686), which a re-render of the card still open does too: a
popup could open and shut again at once. They close it now only when
the card is no longer the open one - closed, or another card opened.

The Playwright test emulates a 300 ms connection in Chromium, clicks
each card open and presses d at once: the editor opens and is still
open 1.5 s later, and closing the card still closes it. It fails on the
code before this change. dueDateKeyboardShortcut and
labelsPopupStaleCardContext pin both halves.

Thanks to mimZD and xet7 !

Fixes #6840,
2026-10-31 21:06:53 +02:00
Lauri Ojansivu bc21c901f4 Date picker: a Today button, and a month navigated back to is drawn whole.
The board Calendar view already has Today (and Calendar mode too), but
the date picker of every date popup - Due, Start, End, Received and the
rest - had only « ‹ › », so a user who paged to another month had to
page back. Today, between the month and ›, shows today's month and
focuses today; it changes nothing until a day is clicked and saved.

Testing it found a picker bug that was already there: going back to a
month drew it wrong - October came back as "29, empty, 30, empty", with
no 31st. Blaze matches list items by _id, and the weeks and days had
none; the empty cells, all alike, were moved around. Every week and
cell, empty ones too, now has an _id that names its month.

calendarPickerControls.test.cjs checks the ids, that Today shows today's
month and focuses today, and that it selects nothing; the Playwright test
pages two months ahead, presses Today, and checks every day of the month
in order, today focused, and the card's due date unchanged.

Thanks to mimZD and xet7 !

Fixes #6838,
2026-10-31 21:06:53 +02:00
Lauri Ojansivu 88fef5dc08 CHANGELOG: the Password length strings in every language, and its title bar.
Thanks to xet7 !
2026-10-31 15:18:23 +02:00
Lauri Ojansivu 3f9379ac86 Translations: the Password length page and Change your password, in 234 languages.
The 18 new strings of Admin Panel / Problems / Password length and of the
"Change your password" dialog, written in the language and script each
locale tag names, with the terms each file already uses for password,
account, save and sign in, and __min__ kept exactly. Only those 18 keys
changed in each file, written with fill-translations.mjs --apply, which
fills English placeholders only.

Low confidence, for a speaker to correct on Transifex: ace, ak, an, ary,
ast-ES, ay, ba, bho, bi, bm, bo, br, bua, chr, co, csb, cv, cy, dz, ee,
ff, fj, fo, fur, ga, gd, gn, gv, ha, haw, hsb, ig, iu, jv, kl, kok, ks,
kw, lg, lld, mg, mi, mt, nah, nap, nd, nso, ny, om, pap, qu, rm, rn,
rup, rw, sah, sc, scn, sd, se, si, sm, sn, so, ss, st, szl, te-IN, tg,
ti, tig, tk_TM, tlh, tn, to, tpi, ts, tt, uz-AR, ve, ve-CC, ve-PP, vo,
wa, wa-RR, wal, wo, wuu-Hans, xh, yi, zgh.

Thanks to xet7 !
2026-10-31 15:18:13 +02:00
Lauri Ojansivu 85af731c1b Admin Panel / Problems / Password length names itself in the title bar.
The title bar keeps its own copy of the menu labels, and the new pane
had none: tests/adminUrls.test.cjs failed on problems/password-length.

Thanks to xet7 !
2026-10-31 15:18:11 +02:00
Lauri Ojansivu bb32a50550 CHANGELOG: sign-up works again, Admin Panel / Problems / Password length, and a changed password signs every session out.
Thanks to xet7 !
2026-10-31 14:56:22 +02:00
Lauri Ojansivu c99aa7f845 Admin Panel / Problems / Password length, and a changed password signs every session out.
PASSWORD_MIN_LENGTH (default 8) is now the length below which a sign-in
is REPORTED, in the new Admin Panel / Problems / Password length, and
PASSWORD_MIN_LENGTH_ENFORCE (off by default) is what makes it a rule: a
shorter new password is refused, and an account that signs in with an
older shorter one sees only "Change your password" until it has a long
enough one. Signing in is never refused, enforced or not, so nobody is
locked out by a minimum set after their password. LDAP, OAuth2, OIDC,
SAML, CAS and Sandstorm accounts are neither reported nor asked: their
password is their provider's. An administrator's Set password no longer
applies WeKan's policy to such an account.

Meteor sends a password hashed, so only the browser knows its length: it
notes it on the sign-in form and, after a sign-in that worked, says "it
was short" - never the password or its length.

The page has both settings and a table, one row per account: username,
first and last short-password login, the latest IPv4 and IPv6 address
with the country's flag and city when a CDN says where it is, the count,
and when it was fixed, with the Too short (default) / Fixed / All filter.
Fixed is a changed password not seen short since; a short one seen
again reopens the row.

Changing one's own password - Member Settings, the forced change, a
reset link - now signs EVERY session of the account out, the one that
changed it too, and only the new password signs in again. The browser
goes to the sign-in page as soon as it is signed out, since the server
closes the connection before the answer can arrive.

Documented in docs/Features/Admin-Panel/Problems/Password-length.md, the
settings on every platform. The new strings are in every locale in
English for now. Tests: passwordPolicy, passwordChangeRequired and
shortPasswordLogins (node); password-min-length-signup-login,
password-length-report and password-change-other-sessions (Playwright,
Chromium and WebKit), which change each setting on the page and check
what it does, and that sign-up with no mail server registers and signs
the user in.

Thanks to xet7 !
2026-10-31 14:54:22 +02:00
Lauri Ojansivu 508484ef1e Sign-up, and every method and publication, pass audit-argument-checks again.
Registering answered "Internal server error" - and created the account
anyway, so the next try said it already existed - and the
starredPublicBoards and translation publications failed for everybody:
"Did not check() all arguments". audit-argument-checks asks,
synchronously, the moment a handler returns, whether check() saw every
argument. The REQUIRE_2FA wrapper of every method and publication, and
the Audit log's wrapper of the administrators' methods, were async and
awaited before they called the handler - even with REQUIRE_2FA off - so
the handler's own check() came after the audit had already failed the
call, and the handler went on running.

A wrapper now calls the handler at once when it needs no lookup
(REQUIRE_2FA off, nobody signed in, a cached answer). When it must await
first, it hands the audit over (server/lib/argumentChecks.js): the
arguments count as seen for the outer audit, and the handler runs under
an audit of its own that it must satisfy exactly as unwrapped.

tests/wrappedHandlerArgumentChecks.test.cjs runs both wrappers under a
model of the check package's audit: a checking handler passes, off and
on, a refusal stays a refusal, a handler that does not check still fails
on every path, and no wrapper in server/ awaits before it has checked.

Thanks to xet7 !
2026-10-31 14:13:40 +02:00
Lauri Ojansivu 548196c9a9 CHANGELOG: Meteor in .tools/.meteor no longer springboards forever.
Thanks to xet7 !
2026-10-31 13:23:36 +02:00
Lauri Ojansivu 9a118c83b2 Meteor in .tools/.meteor no longer springboards forever.
A build from the menu printed uname -p and sysctl hw.cpu64bit_capable
over and over and never reached its first package. With
METEOR_WAREHOUSE_DIR set and no SANDBOX, Meteor takes the running tool
to be wherever the warehouse's "meteor" link points - 3.5.4, the release
it was installed with - so with .meteor/release at 3.6-rc.1 every start
springboarded to 3.6.0-rc.1, found itself the wrong tool again and
springboarded again: 28 times in 20 seconds of meteor --version. The
earlier 1.1 GB build log was the same loop, its module trace printed
anew on every exec.

tools-env.sh and tools-env.bat now set SANDBOX=true beside the
warehouse, as Meteor's own self-test does; that one check is all it
changes. The Nextcloud test scripts and the dev-source runner, which set
the warehouse themselves, do the same. meteor --version now springboards
once, from the linked 3.5.4 to 3.6.0-rc.1.

tests/meteorSpringboardLoop.test.cjs checks both env scripts, that
nothing sets the warehouse without SANDBOX, and runs the installed
Meteor to count its springboards.

Thanks to xet7 !
2026-10-31 13:23:24 +02:00
Lauri Ojansivu d442bd8a7e CHANGELOG: builds trace module resolution only with WEKAN_BUILD_TRACE_MODULES=1.
Thanks to xet7 !
2026-10-31 13:02:19 +02:00
Lauri Ojansivu 5a13e927bc Builds trace module resolution only with WEKAN_BUILD_TRACE_MODULES=1.
Turning the NODE_DEBUG=module trace off for EVERYTHING alone was not
enough: a dev build from the build.sh menu wrote 1.1 GB, three million
lines, in its first three minutes, before it was interrupted. The trace
is now opt-in in build.sh and build.bat alike, and the diagnostics line
says whether it is on and how to turn it on. The other build diagnostics
(resolver commands, npm verbose, METEOR_PROFILE) are unchanged.

Thanks to xet7 !
2026-10-31 13:01:57 +02:00
Lauri Ojansivu c78af24a36 CHANGELOG: EVERYTHING builds without the per-require module trace.
Thanks to xet7 !
2026-10-31 12:46:14 +02:00
Lauri Ojansivu 6551062492 EVERYTHING builds without the per-require module trace.
The build's NODE_DEBUG=module trace wrote over 12 GB in the first half
hour of an EVERYTHING run on macOS - twice, through tee - and slowed the
build it traced, so the run would fill the disk before reaching the tests.
A build from the menu still traces by default; WEKAN_BUILD_TRACE_MODULES=0
turns it off, and EVERYTHING sets that unless the caller asked for 1. The
other build diagnostics (resolver commands, npm verbose, METEOR_PROFILE)
are unchanged.

Thanks to xet7 !
2026-10-31 04:15:01 +02:00
Lauri Ojansivu a0ed5de6f1 CHANGELOG: the breached-password check's lookup digest (CodeQL #599).
Thanks to GitHub CodeQL and xet7 !
2026-10-31 03:52:47 +02:00
Lauri Ojansivu f406d46fe7 Breached-password check: one Web Crypto lookup digest for the browser and the server.
CodeQL #599 (js/insufficient-password-hash, CWE-916) flagged the SHA-1
the server computed for PASSWORD_CHECK_PWNED. That digest is not password
storage - accounts-password keeps passwords with bcrypt - and it is never
stored or logged: Have I Been Pwned's range API is keyed by SHA-1, its
first 5 hex digits are the question and the rest is compared in memory.
A slow hash cannot be used, because the service could not match it.

The browser and the server now compute it with one implementation,
breachLookupDigest in models/lib/passwordPolicy.js, which says what it is
and is not; the server no longer hashes a password with a hash of its
own, and a digest that cannot be computed lets the password through.
Tests pin that the digest is SHA-1 for ASCII and UTF-8, that it reaches
only the lookup and the comparison, and that nothing in WeKan hashes a
password-named value with a fast hash. Unreleased hardening, so no Hall
of Fame entry: docs/Security/Password-Breach-Check-Digest-2026-10-31.md.

Thanks to GitHub CodeQL and xet7 !
2026-10-31 03:52:32 +02:00
Lauri Ojansivu 4b0aa715eb Merge remote-tracking branch 'origin/main' 2026-10-31 03:36:43 +02:00
Lauri Ojansivu f5af33ac38 CHANGELOG: Audit log documentation.
Thanks to xet7 !
2026-10-31 00:44:17 +02:00
Lauri Ojansivu f165a24192 docs: Admin Panel / People / Audit log.
Thanks to xet7 !
2026-10-31 00:44:14 +02:00
Lauri Ojansivu e6ea524541 CHANGELOG: password policy, compulsory 2FA, idle sign-out, audit log, HSTS, cross-site guard and S3 encryption.
Thanks to xet7 !
2026-10-31 00:33:31 +02:00
Lauri Ojansivu de23dece66 Translations: the password policy, two-factor, audit log and S3 encryption strings in 234 languages.
The 16 strings the security features added were English placeholders in
every locale; each is now in the language and script its locale tag
names, with the terms the file already uses for password, account,
attachment, backup and two-factor authentication. __min__ is kept
character for character. Where the right word is spelled like the
English ("Administrator"), the column header says "Admin" or "Who"
instead, because a value equal to English is not a translation.

fy and fy-NL: the existing two-factor strings were Dutch in a Frisian
file, and are Frisian now; tests/frisianTwoFactorLanguage.test.cjs.
tig: Tigre spells storage መክዘን, not Tigrinya's መኽዘን.

Low confidence, for a speaker to correct on Transifex: ace, ak, an, ary,
as, ast-ES, ay, ba, bho, bi, bm, bo, br, bua, chr, ckb, co, csb, cv, dz,
ee, ff, fj, fy, gn, gv, haw, hsb, ht, ig, iu, kl, kok, ks, kw, la, lg,
lld, mg, mi, nah, nd, nso, ny, om, pap, ps, qu, rm, rn, rup, rw, sah, sc,
scn, sd, se, si, sm, sn, so, ss, st, szl, tig, tlh, tn, to, tpi, ts,
uz-AR, ve, ve-CC, ve-PP, vo, wa, wa-RR, wal, wo, xh, yi, zgh.

Thanks to xet7 !
2026-10-31 00:32:43 +02:00
Lauri Ojansivu 2017f2871f Audit log: its own Admin Panel URL and title, and right-to-left table alignment.
/admin/people/audit-log opens the pane and names it in the title bar like
every other People pane, and the table aligns to the start of the line,
so it reads right to left in Arabic, Hebrew, Persian and the rest.

Thanks to xet7 !
2026-10-31 00:32:33 +02:00
Lauri Ojansivu 302b47db6f docs: Security settings, every hardening option in one page.
Thanks to xet7 !
2026-10-31 00:15:13 +02:00
Lauri Ojansivu 482c2c29f4 Attachments, avatars and backups in S3 can be encrypted at rest.
Admin Panel / Attachments / S3 / Server-side encryption: SSE-S3 (AES256)
or SSE-KMS (aws:kms, with a KMS key or the account's default). S3
encrypts each object as it is written and decrypts it for WeKan on read,
so reads, range requests and moves are unchanged. Every attachment and
avatar upload, every renamed object and every backup written to S3 now
carries the choice; a value that is not one of them, or a malformed key,
adds nothing.

On the filesystem, encryption at rest stays with the disk or volume
(LUKS, an encrypted cloud volume): exports, PDF, thumbnails, virus scans,
backups and WebDAV all read the stored files directly, and encrypting
them inside WeKan would change every one of those and convert every file
already stored.

The new interface strings are pending translation.

Thanks to xet7 !
2026-10-31 00:12:09 +02:00
Lauri Ojansivu 4d5b832499 Admin Panel / People / Audit log: what site administrators did.
Nothing recorded who set somebody's password, made somebody an admin,
changed a login setting or restored a backup. Every call of those
administrative methods by a site administrator is now kept, one document
each, for a year: when, who, which action, the outcome - a refused call
too - its arguments and the address. A password or a secret never
reaches the trail: values under a credential-named key, the arguments
that are a password by position, and a password inside a URL are
replaced by [redacted]. An administrator editing cards is not in it,
the trail is shown to site administrators only, and a trail that cannot
be written never changes what the method did. It and the REQUIRE_2FA
wrapper keep each other's marks, so neither wraps the other twice.

The new interface strings are pending translation.

Thanks to xet7 !
2026-10-31 00:09:20 +02:00
Lauri Ojansivu 41173f0f6b Sign a browser session out after it has been idle for LOGOUT_IDLE_MINUTES.
LOGOUT_WITH_TIMER ends a login at a deadline however busy its user is;
nothing ended a session somebody walked away from. LOGOUT_IDLE_MINUTES
(Admin Panel / People / Login, every platform; empty or 0 is off) does:
the browser reports a key press, click, touch or scroll at most once a
minute, the server keeps the time per login token in idleSessions, and
once a minute every token idle past the limit is removed, which signs its
connections out. A tab that reconnects and resumes its login by itself -
a laptop waking up - is not activity and does not extend the session.
REST API tokens have no session record and are never removed. The time
is kept beside the user rather than on the token, because updating one
element of the token array needs the positional $ operator, which not
every database WeKan runs on supports.

Thanks to xet7 !
2026-10-31 00:08:20 +02:00
Lauri Ojansivu d7abe9910a Password policy for new passwords, and compulsory two-factor authentication.
New passwords: PASSWORD_MIN_LENGTH (default 8) and PASSWORD_CHECK_PWNED
(off by default), in Admin Panel / People / Login and on every platform.
The browser sends a password to Meteor hashed, so the server checks every
password that reaches it as text - Admin Panel Set password and Add user,
POST /users/register - and the sign-up, Change Password, reset and
enrolment forms check in the browser. Signing in is never checked, so an
existing shorter password keeps working until it is changed. A breached
password is found with Have I Been Pwned's k-anonymity range API: only 5
hex digits of the SHA-1 leave WeKan, and an unreachable service never
refuses a password.

REQUIRE_2FA: off (default), admins or all. It covers accounts that sign in
with a WeKan password; LDAP, OAuth2, SAML, CAS and Sandstorm accounts sign
in through an identity provider and are not asked. Such an account can
still sign in, sees only the 2FA set-up until it has 2FA, and until then
the server refuses every method and publication but signing in and
enrolling, HTTP routes do not accept its login and the REST API gives it
no token. Once required, 2FA cannot be switched off.

The new interface strings are pending translation.

Thanks to xet7 !
2026-10-31 00:07:58 +02:00
Lauri Ojansivu 48a2e97ea3 A state-changing request that logs in only with the cookie must come from WeKan itself.
POST /import-trello and other HTTP routes accept the login cookie as the
login, so a page on another site could make a signed-in browser send one.
The cookie is SameSite=Strict, which keeps it off such a request in
today's browsers; this is the second line, for older browsers, same-site
sibling hosts and routes added later. A POST, PUT, PATCH or DELETE whose
only login is the cookie must carry an Origin, or Referer, naming
ROOT_URL's host or the Host / X-Forwarded-Host it was sent to, which on
Sandstorm is the grain's own host. Token-authenticated API calls, Meteor's
cookie routes and DDP are not affected. A refusal is recorded in Admin
Panel / Problems as csrf.cross-site-cookie and never blocks the account
whose cookie it was.

Thanks to xet7 !
2026-10-30 23:52:09 +02:00
Lauri Ojansivu 4993242c5f Strict-Transport-Security on every response of an HTTPS site.
WeKan sent no HSTS header, so a link typed as plain HTTP or a downgrade on
a hostile network could strip TLS from the next visit unless the reverse
proxy added it. It is now sent whenever ROOT_URL is https: 180 days, this
host only. HSTS_MAX_AGE, HSTS_INCLUDE_SUBDOMAINS and HSTS_PRELOAD (Admin
Panel / People / Login, every platform) change it; 0 stops it, and preload
is sent only with includeSubDomains and at least a year. Every response
also carries X-Content-Type-Options: nosniff and
Referrer-Policy: strict-origin-when-cross-origin.

Thanks to xet7 !
2026-10-30 23:50:02 +02:00
Lauri Ojansivu 12eef31162 CHANGELOG: snap backup and restore instructions work on AlmaLinux, Rocky Linux and Fedora.
Thanks to Group-Deed and xet7 !
2026-10-30 23:14:01 +02:00
Lauri Ojansivu 0b5c855820 Snap backup and restore instructions work on AlmaLinux, Rocky Linux and Fedora.
snapd keeps the snaps in /var/lib/snapd/snap on Fedora, RHEL, AlmaLinux,
Rocky Linux, CentOS Stream and Arch Linux, which have no /snap, so the
instructions that exported /snap/wekan/current/bin and its lib directory
did not find mongodump and mongorestore there. They now pick the directory
with one S= line and the CPU's library directory with $(uname -m), and the
scripts that can be run from a host shell find the snap in either place.

Thanks to Group-Deed and xet7 !
2026-10-30 23:13:50 +02:00
Lauri Ojansivu 0c53ef6960 CHANGELOG: boards from before the templates feature are shown on All Boards again.
Thanks to xet7 !
2026-10-30 22:56:38 +02:00
Lauri Ojansivu 2ab0afdc93 Old boards without a type are shown on All Boards again.
Boards, swimlanes, lists and cards from before the templates feature have
no `type`: the schemas' defaultValue fills it on insert only. All Boards
publishes `type: 'board'` and the card queries ask for 'cardType-card', so
a Sandstorm grain migrated from MongoDB 3 opened onto an empty All Boards
page although its board was intact in the database. The new
type-defaults-backfill startup step sets the schema default on every
document whose type is missing, null or empty, and never touches an
explicit type.

Thanks to xet7 !
2026-10-30 22:56:24 +02:00
github-actions[bot] f2a1d7b9ba Bump versions for v12.75 2026-10-30 20:26:45 +00:00
Lauri Ojansivu cd1f1b8f05 Prepare v12.75 release 2026-10-30 22:25:30 +02:00
Lauri Ojansivu b941f36e4c Merge remote-tracking branch 'origin/main' 2026-10-30 22:25:01 +02:00
Lauri Ojansivu 487a528806 CHANGELOG: a changed password signs the account out in every other browser.
Thanks to xet7 !
2026-10-30 21:39:35 +02:00
Lauri Ojansivu dc9a7bb1ac Tests: a changed password signs the account out in every other browser.
A report described "failure to invalidate session on password change": the
same account signed in on two browsers, the password changed in one, and
the other's session "updated" rather than ended. WeKan does not do that.
Member Settings / Change Password is Meteor's changePassword, which removes
every login token but the changing session's; the admin's Set password is
setPasswordAsync, which removes them all, as resetPassword does; and a
session whose token is gone is closed, while WeKan's HTTP routes accept only
a token still in the list.

password-change-other-sessions.e2e.js runs the report in two browser
contexts: both signed in with the password, the password changed through
the form in one, and the other is signed out within seconds, stays signed
out after a reload, and only the new password signs in. It passes three
times in a row in Chromium and in WebKit. passwordChangeSessions.test.cjs
pins those paths and fails on a password change with logout: false, the
LDAP sign-in's password sync being the one stated exception.

Thanks to xet7 !
2026-10-30 21:39:20 +02:00
github-actions[bot] 696a1a2c8a Bump versions for v12.74 2026-10-30 08:15:31 +00:00
Lauri Ojansivu ef1e683cdd Prepare v12.74 release 2026-10-30 10:14:18 +02:00
Lauri Ojansivu 55163d0ce6 Merge remote-tracking branch 'origin/main' 2026-10-30 10:14:15 +02:00
Lauri Ojansivu b974ba426d CHANGELOG: Caddy and FerretDB run with no MPTCP listener.
Thanks to xet7 !
2026-10-30 04:31:14 +02:00
Lauri Ojansivu e9751b65d4 Caddy and FerretDB run with no MPTCP listener (CVE-2026-98432).
CVE-2026-98432 is a Linux kernel use-after-free in IPv4 TCP connection
handling, shown to give remote code execution through an MPTCP listener
(golang-announce, 2026-10). Since Go 1.24 every Linux listen socket of a
Go program is MPTCP by default, and WeKan ships two Go servers: Caddy, the
snap's web server on ports 80 and 443, and FerretDB, the default database.

Every place WeKan starts one sets GODEBUG=multipathtcp=0: the snap's
environment (every app, Caddy and FerretDB among them), the Docker
entrypoint and the bundle launcher - which keep an operator's own
multipathtcp setting - every docker-compose FerretDB service (the commented
FerretDB 2 example too), and the FerretDB the Sandstorm grain starts. Caddy
is an upstream binary, so no rebuild is needed: Go reads GODEBUG at start.
wekan/FerretDB builds with `godebug multipathtcp=0` as well. The real fix
is a patched host kernel.

Checked on Linux: a Go server's default listener reports MPTCP true;
GODEBUG=multipathtcp=0, or the go.mod setting, makes it false.
tests/goMptcpOff.test.cjs pins every launcher, runs the launcher line, and
fails when something turns MPTCP back on.

Thanks to xet7 !
2026-10-30 04:30:39 +02:00
github-actions[bot] 5abc2809b9 Bump versions for v12.73 2026-10-30 02:18:18 +00:00
Lauri Ojansivu 03f3a197cf Prepare v12.73 release 2026-10-30 04:17:07 +02:00
Lauri Ojansivu 6ed3f5cfd0 CHANGELOG: v12.73 was not released, so its section is Upcoming again.
"Prepare v12.73 release" renamed Upcoming, but its push was refused and
the release workflow was never dispatched: there is no v12.73 tag and no
v12.73 GitHub Release. As CLAUDE.md says for a release that published
nothing, the heading goes back to "# Upcoming WeKan ® release", so the next
release-all.sh takes v12.73 again. It also gains the entry for the fix that
keeps this from happening again.

Thanks to xet7 !
2026-10-30 04:16:04 +02:00
Lauri Ojansivu e372016826 Release All pulls before releasing, and pulls and retries when its push is refused.
v12.73 was prepared on a main that origin had moved past: the release
bot's v12.72 version bump had landed meanwhile. release-all.sh renamed
Upcoming and committed "Prepare v12.73 release", its bare git push was
refused, and set -e stopped it before gh workflow run - so no release ran,
and no tag was made, while the CHANGELOG already said v12.73.

release-all.sh now runs build.sh's git pull right after its checks, before
it chooses the version or renames anything, and checks the Upcoming section
again after it; and it pushes with build.sh's git push, which repairs moved
CHANGELOG links and pulls and pushes once more when origin moved meanwhile.
If the push still does not finish, it says that the workflow was NOT
dispatched, and the command to dispatch it.

releaseUpcomingPreflight pins the order - the pull before the version and
the rename, the push before the dispatch - and fails on a bare git push;
releaseTelemetry and the version-selection fixture follow.

Thanks to xet7 !
2026-10-30 04:14:48 +02:00
Lauri Ojansivu fd699b67e9 Merge remote-tracking branch 'origin/main' 2026-10-30 04:07:50 +02:00
Lauri Ojansivu 827641193f Prepare v12.73 release 2026-10-30 04:07:36 +02:00
Lauri Ojansivu fb7bd9c6de CHANGELOG: CodeQL alert #598, the Sandstorm host test.
Thanks to xet7 !
2026-10-30 04:02:02 +02:00
Lauri Ojansivu fcd995095d Tests: the Sandstorm host check names no host but sandstorm.org.
CodeQL alert #598 (incomplete URL substring sanitization, CWE-20) flagged
tests/sandstormOrgHosts.test.cjs, which skipped files that did not include
the old Sandstorm host name as a substring. The file no longer names that
host at all. It finds every Sandstorm host on a top-level domain other than
org by its shape, written as a URL - a scheme or // before it, or a port or
path after it - so property paths such as services.sandstorm.id, file names
such as sandstorm.js and the domains reserved for tests are not hosts. The
demo and the store workflow's older-market publish step stay allowed by
their subdomain and place.

A new self-test checks what it finds and what it does not; run against the
docs and workflows from before the sandstorm.org switch it finds 13 old
hosts, the installer address of release-all.yml among them.

Thanks to xet7 !
2026-10-30 04:01:36 +02:00
Lauri Ojansivu b7b5ec3711 CHANGELOG: the short CHANGELOG.md starts with the upcoming release.
Thanks to xet7 !
2026-10-30 03:56:32 +02:00
Lauri Ojansivu 1af642069c The short CHANGELOG.md starts with the upcoming release's In short.
CHANGELOG.md showed only the five newest releases, so what was about to be
released was only in CHANGELOG-big.md. changelog-short.mjs now puts the
unreleased # Upcoming WeKan ® release first - its In short and a link to
its full section - and the five newest releases below it; right after a
release, when there is no Upcoming yet, none is invented.

CLAUDE.md and AGENTS.md say what "update wekan changelog" means: find the
commits not in the changelog yet, add an Upcoming section when a release
was cut meanwhile, write the entries and the In short into
CHANGELOG-big.md, run changelog-short.mjs, run the changelog tests, and
commit both files.

tests/changelogShort.test.cjs: Upcoming first, whole, above the releases,
its link resolves, and no Upcoming appears after a release.

Thanks to xet7 !
2026-10-30 03:56:11 +02:00
Lauri Ojansivu 0343e6614b CHANGELOG: the short CHANGELOG.md, written from CHANGELOG-big.md.
Thanks to xet7 !
2026-10-30 03:30:23 +02:00
Lauri Ojansivu f1c0db247a CHANGELOG.md is the short changelog, written from CHANGELOG-big.md.
CHANGELOG-big.md is the changelog as it was: # Status, # Upcoming and every
release in full, with its <details> entries. Everything that writes or edits
the changelog uses it - release-all.sh, release-notes.sh and the release
workflow, the commit-link repair in build.sh and fix-changelog-hashes.sh,
the archive, the wrapper, the version scripts - and the release notes'
"More details at ChangeLog" link goes to its section. remote-release.py,
shared with FerretDB and the patch repositories, takes the file from its
config, and only WeKan's names CHANGELOG-big.md.

CHANGELOG.md is new: no Status and no Upcoming, only the In short paragraph
of each of the five newest releases and a link to its full section, in plain
Markdown - no <details>, <summary>, </summary> or </details>, and HTML links
written as Markdown links. releases/changelog-short.mjs writes it;
release-all.sh runs it right after naming the release, so it is in the
release commit, and changelog.sh does too. The Sandstorm App Market keeps
embedding CHANGELOG.md, now the short one.

old-CHANGELOG/'s headers say they moved out of CHANGELOG-big.md. References
to Upcoming or TODO Later in code comments and docs name CHANGELOG-big.md.
CLAUDE.md and AGENTS.md describe both files: CHANGELOG-big.md is written,
CHANGELOG.md is generated and never edited by hand.

tests/changelogShort.test.cjs: CHANGELOG.md is exactly what the generator
writes, the five newest releases with their whole In short, links that
resolve, and none of the four tags or an HTML link; the changelog tests
read CHANGELOG-big.md.

Thanks to xet7 !
2026-10-30 03:29:48 +02:00
Lauri Ojansivu 61e838df46 Rename CHANGELOG.md to CHANGELOG-big.md.
Only the rename, in a commit of its own, so that git log --follow and blame
keep the changelog's history; the next commit adds the new short
CHANGELOG.md and points the release tooling at CHANGELOG-big.md.

Thanks to xet7 !
2026-10-30 03:29:34 +02:00
github-actions[bot] 057cbc26d9 Bump versions for v12.72 2026-10-30 00:37:33 +00:00
Lauri Ojansivu 22e8b9bc86 Prepare v12.72 release 2026-10-30 02:36:31 +02:00
Lauri Ojansivu 602b47f06a CHANGELOG: new Sandstorm app market screenshots.
Thanks to xet7 !
2026-10-30 02:35:31 +02:00
Lauri Ojansivu a086323a1f Updated Sandstorm WeKan screenshots.
Thanks to xet7 !
2026-10-30 02:33:12 +02:00
Lauri Ojansivu 22140739ab CHANGELOG: card links never launch an OS handler, the Apache guide and Sandstorm publishing.
Thanks to ocdtrekkie and xet7 !
2026-10-30 01:26:40 +02:00
Lauri Ojansivu 55b71ce19e Tests: the Sandstorm store workflow may publish to apps.sandstorm.io too.
#6833 publishes WeKan to both Sandstorm app markets, apps.sandstorm.org
and the older apps.sandstorm.io, whose API is alpha-api.sandstorm.io.
sandstormOrgHosts.test.cjs refused every sandstorm.io host; it allows
that publish step in sandstorm-store.yml only, and checks that both
markets are published to.

Thanks to ocdtrekkie and xet7 !
2026-10-30 01:25:31 +02:00
Lauri Ojansivu 1a97c8bd86 Apache guide: no request captures in proxy targets, and httpd 2.4.60 or newer.
Checked against the 2024 Confusion Attacks on Apache httpd
(blog.orange.tw, CVE-2024-38472 to -38477 and CVE-2024-39573, fixed in
2.4.60). The guide's examples were reverse proxies only, with no
RewriteRule, but each passed the WebSocket with ProxyPassMatch and $1 - a
request capture in a proxy target, the shape those attacks abuse - and in
the sub-URL example that rule looked for ^/sockjs while the requests come
to /wekan/sockjs, so its WebSocket never connected.

- Both examples pass the WebSocket with "ProxyPass ... upgrade=websocket"
  on their fixed prefix (2.4.47+); no ProxyPassMatch remains.
- The guide asks for httpd 2.4.60 or newer, drops NameVirtualHost (a no-op
  in 2.4) and the path in the sub-URL ServerName, which is not valid.
- A "What not to add" section: no RewriteRule or ProxyPassMatch with a
  request capture in the target, no <Files>/<Location> ACL in front of
  WeKan, AllowEncodedSlashes Off and MergeSlashes On, <Directory /> denied.

Verified with Apache 2.4.67: both virtual hosts parse, and in front of a
WebSocket server both pass HTTP and the WebSocket, at / and at /wekan;
the old sub-URL example fails its WebSocket ("Unexpected server response:
200"). tests/apacheReverseProxy.test.cjs pins it and fails on the old guide.

WeKan's own redirects stay on its origin (CAS checks the callback's origin
against ROOT_URL; the file and avatar redirects build their own paths), so
a backend-chosen Location cannot be steered elsewhere.

Thanks to xet7 !
2026-10-30 01:24:48 +02:00
Lauri Ojansivu b4e5f90afb Card links never launch an OS handler with a code-execution history.
Custom URL schemes in card text link only when Admin Panel -> Features ->
URL lists them, and never javascript:, data: or another scheme that runs
code in the page. A listed operating-system handler was linked, though, and
some have a public code-execution history: ms-msdt (Follina,
CVE-2022-30190), search-ms and search, ms-officecmd, ms-appinstaller,
ms-cxh and ms-cxh-full, and telnet - the last step of the 2026 Edge full
chain, where Windows ran telnet.exe from a user-writable PATH directory.
An administrator listing one would let any board member plant a link that
runs something on whoever clicks it.

- They are never linked, listed or not, by the app's parser and by the
  markdown package's copy of the rule (the two lists must be identical),
  and a click on one does nothing should such a link reach the page.
- A card title or description, comment, checklist or checklist item that
  ADDS a link to one is shown in Admin Panel -> Problems (HandlerLink,
  CWE-939), with who wrote it; the text is saved as written and the writer
  is told nothing. The word in prose, or a link the text already had, is
  not recorded.
- docs/Features/Admin-Panel/Settings/Visibility.md lists both kinds of
  never-linked scheme, and says what a listed file: does on Windows (an SMB
  link outside the network sends the clicker's NTLM hash).

Tests: tests/urlSchemeAllowlist.test.cjs (positive and negative, through
the real DOMPurify, both sanitizer passes and the detector; fails on the
old code), and custom-url-schemes.e2e.js, which passes in Chromium and
WebKit: listed handlers do not link, a description saved with one through
the editor is recorded for its writer, and a kept link or a comment that
only mentions the word is not. Firefox does not start in this
environment.

Thanks to xet7 !
2026-10-30 01:19:16 +02:00
Lauri Ojansivu 2c1fc39f61 Fix Sandstorm store spk publish syntax.
Thanks to ocdtrekkie and xet7 !
2026-10-30 01:14:55 +02:00
Lauri Ojansivu ee82c21718 Merge pull request #6833 from ocdtrekkie/patch-1
Sandstorm: Publish to both app markets
2026-10-30 01:08:19 +02:00
Jacob Weisz 10851cd3f4 Sandstorm: Publish to both app markets 2026-10-29 18:04:45 -05:00
github-actions[bot] a937ed2180 Bump versions for v12.71 2026-10-29 22:39:44 +00:00
Lauri Ojansivu 22be2f2dc0 Prepare v12.71 release 2026-10-30 00:38:34 +02:00
Lauri Ojansivu 833c0bc021 CHANGELOG: two entries made after v12.70 go to Upcoming, not into v12.70.
The v12.70 release renamed Upcoming to v12.70 at 23:59, and the entries for
the Hall of Fame guards (43c4d41877) and the Sandstorm install address
(ca2dc5fae5), committed after it, were added to its Tests and Releases
groups - inside a released section, for commits that release does not
contain, and with no Upcoming section left for release-all.sh to take the
next version from. They move to a new Upcoming section; the v12.70 section is
again exactly what v12.70 shipped. changelogEntriesBelongToTheirRelease
passes.

Thanks to xet7 !
2026-10-30 00:36:01 +02:00
Lauri Ojansivu 1251c1c8b3 Merge remote-tracking branch 'origin/main' 2026-10-30 00:33:58 +02:00
Lauri Ojansivu 5b428faea6 CHANGELOG: the Sandstorm build installs from install.sandstorm.org.
Thanks to xet7 !
2026-10-30 00:28:08 +02:00
Lauri Ojansivu ca2dc5fae5 Sandstorm: build from install.sandstorm.org, and link sandstorm.org everywhere.
v12.69's build-sandstorm in release-all.yml stopped because the
certificate of install.sandstorm.io had expired. It fetches the installer
from install.sandstorm.org, as sandstorm.yml and sandstorm-store.yml
already did, and so does releases/install-sandstorm.sh. That installer
downloads Sandstorm from dl.sandstorm.org and configures a development
server at local.sandstorm.org, so the docs follow: docs, apps, dl, install
and local.sandstorm.io links and addresses become sandstorm.org in the docs,
SECURITY.md, code comments and the accounts-sandstorm README, each URL
checked to answer there. demo.sandstorm.io stays, as it has no sandstorm.org
name; released CHANGELOG sections are records and stay as they are. The
release audit's baseline allows the two code comments' docs.sandstorm.org.

tests/sandstormOrgHosts.test.cjs: every Sandstorm build installs from
install.sandstorm.org, and no tracked file names a sandstorm.io host
outside those kept on purpose; it fails on the old release-all.yml.

Thanks to xet7 !
2026-10-30 00:27:54 +02:00
Lauri Ojansivu b9f6eff3d9 CHANGELOG: the Hall of Fame guards read linked vulnerability names.
Thanks to xet7 !
2026-10-30 00:20:59 +02:00
Lauri Ojansivu 43c4d41877 Tests: the Hall of Fame guards read a linked vulnerability name too.
hallOfFameProblemsCoverage and securityRegressionCoverage collected the
names from <td valign="top"><b>Name</b></td>, so a name that was a link to
its page was never seen. Now that every name is one, the match takes the
link; the four it had missed - ImportPlacementBleed, SamlSubjectBleed,
StampBleed and UploadBleed - are listed as guarded by the suites that name
them, and the inventory count goes from 137 to 141.

Thanks to xet7 !
2026-10-30 00:20:47 +02:00
github-actions[bot] 609c6d3d64 Bump versions for v12.70 2026-10-29 22:00:25 +00:00
Lauri Ojansivu 8097935f5d Prepare v12.70 release 2026-10-29 23:59:10 +02:00
Lauri Ojansivu 4f7222f541 CHANGELOG: AppImages, Launchpad snap source, certificate errors and three test fixes.
Thanks to xet7 !
2026-10-29 23:50:04 +02:00
Lauri Ojansivu 6bbb4d39bc Downloads: an unverifiable TLS certificate is named as that, not as an outage.
v12.69's build-sandstorm stopped because install.sandstorm.io's certificate
expired that day (Let's Encrypt, notAfter 2026-10-29 19:02 GMT), and
fetch.sh reported "The server is having an outage" for curl's exit 60. It
now says the certificate could not be verified and that only that server's
owner can fix it. dl.sandstorm.io, where Sandstorm itself is downloaded
from, is valid.

Thanks to xet7 !
2026-10-29 23:49:36 +02:00
Lauri Ojansivu a1f2c006b4 Snap on Launchpad: fetch.sh is uploaded with the tools-env.sh it sources.
releases/prepare-launchpad-source.sh uploads only the files the snap recipe
reads, and listed releases/fetch.sh without releases/tools-env.sh, which
fetch.sh sources for its TMPDIR. In v12.69's armhf build the caddy part's
fetch.sh had no TMPDIR, mktemp made nothing, and curl got an empty -o seven
times ("option -o: is badly used here"), with pauses of up to 480s, before
it was reported as a server outage.

- tools-env.sh is uploaded, and it makes .tools/tmp in a tree that has only
  snapcraft.yaml and releases/, as the Launchpad source does.
- fetch.sh stops at once when it has no temporary file, and does not retry
  curl's exit 2 (it could not start) or call it an outage.
- tests: every releases/ file a listed script sources or runs must be
  listed; tools-env.sh and fetch.sh work in such a tree with no .git and no
  TMPDIR; both fail on the old list. fetch.sh's two new refusals are tested.

s390x failed earlier, on Launchpad: even a new build answered "Could not
find repository at path" three times.

Thanks to xet7 !
2026-10-29 23:48:30 +02:00
Lauri Ojansivu dfdadaa8ca AppImages: FerretDB and mongo-tools are readable by every user again.
releases/fetch.sh downloaded into a mktemp file and moved it into place, so
every download kept mktemp's 0600, and the bundled ferretdb and mongo-tools
were 0711 after chmod +x. A user other than the owner gets "Permission
denied" from such a file, and since today's appimagetool checks for it, all
six v12.69 AppImages (i686, x86_64, aarch64, armhf and both musl ones)
failed: "has mode 0711, but it must be readable for all users".

A download now gets the mode its umask gives a new file, and the AppDir is
made a+rX before it is packed. tests/releaseDownloads.test.cjs fails on the
old fetch.sh with 711 instead of 755 and checks that a stricter umask is
still followed; tests/appImageRuntime.test.cjs pins both.

Thanks to xet7 !
2026-10-29 23:44:34 +02:00
Lauri Ojansivu 856a32968b Tests: board drag settings open Card's Draggable table in its section hamburger.
Since Board Settings / Card became the card's Card Settings (52de57c844),
the Card Draggable table is in the Card section's hamburger, not in the
popup, so board-drag-settings.e2e.js failed in every language waiting for a
table that is no longer there. It opens Card the way the cog menu does and
then that hamburger, as board-settings-columns.e2e.js already does.

Thanks to xet7 !
2026-10-29 23:42:36 +02:00
Lauri Ojansivu e4b83d2274 Tests: the keyboard card move waits for the reset sorts to reach the client.
F02 F06 reset three cards to sort 0 in the database and waited only for the
minicards to show in id order. gamma 0, alpha .125, beta .25 already is id
order one time in six, so the wait passed while the client still had the old
sorts, the move was computed from them, and the card landed last instead of
second (1 in 3 runs here). The test waits for the client's own copy of all
three cards to read sort 0; 8 repeats pass.

Thanks to xet7 !
2026-10-29 23:28:31 +02:00
Lauri Ojansivu d06b468629 Tests: ReplyBleed's mocha test counts the attempt in every ReplyBleed row.
ReplyBleed is one Problems row per source, and the webhook's source is the
client address, so the bounce and board-address tests leave rows of their
own in the test database, which persists between runs. The test's findOne
picked one of those (source "ses"), whose count the spoofed reply never
moves, and failed although the attempt was recorded under 127.0.0.1. It sums
the count over all ReplyBleed rows, the way emailBounces.tests.js already
does.

Thanks to xet7 !
2026-10-29 23:07:04 +02:00
Lauri Ojansivu 379058bc9d CHANGELOG: OpenSSL 4 backup errors, Bun assets on Python 3.9, snap build logs, macOS test fixes.
Thanks to xet7 !
2026-10-29 22:52:42 +02:00
Lauri Ojansivu 5534c8830c Tests: the macOS suites run inside the sandboxed editor too.
mac-smoke-app.py checked that a killed descendant was gone with ps, which a
sandboxed macOS app may not run; it waits for os.kill(pid, 0) to find the
process gone and uses ps only to tell a zombie from a live one. macosSandbox
skips its live sandbox-exec check only when even an (allow default) profile
cannot be applied, which is the case inside the sandbox it is testing.

Thanks to xet7 !
2026-10-29 22:51:29 +02:00
Lauri Ojansivu 48a8937ff0 Snap on Launchpad: this attempt's build log is found by name, not only by mtime.
Whether a Launchpad build log belonged to the current attempt was decided by
[ "$log" -nt "$attempt_marker" ] alone. bash 3.2 compares whole seconds, so a
log written in the marker's second was not "newer": it was neither printed
nor checked for an infrastructure failure. this_attempts_log() counts a log
whose name did not exist when the attempt started, or one rewritten after the
marker; an earlier attempt's untouched log still does not count. Used by
infra_failure() and by the log printing, and tested for all three cases.

Thanks to xet7 !
2026-10-29 22:51:29 +02:00
Lauri Ojansivu 2dcccf0d4a Bun release assets: runs on Python 3.9, which macOS ships as python3.
hashlib.file_digest is Python 3.11+ and Path.write_text(newline=) is 3.10+,
so releases/bun/assets.py failed with AttributeError/TypeError on macOS's
python3 3.9. It hashes in chunks and writes the LF-only .sha256sum through
open(newline='\n'), and the test still pins LF on every platform.

Thanks to xet7 !
2026-10-29 22:51:29 +02:00
Lauri Ojansivu bf266fb4da DB conformance: a live run is still recognised where ps is not allowed.
In a sandboxed macOS app ps fails with "operation not permitted", so every
live conformance run looked dead and a second run would remove its
containers. command_line_of() falls back to pgrep -lf, which still works
there. The test pins the fallback.

Thanks to xet7 !
2026-10-29 22:51:29 +02:00
Lauri Ojansivu ffe49dd2fd Continuous backup: a changed encrypted file is reported as changed, also with OpenSSL 4.
A Node.js linked to OpenSSL 4 reports an AES-GCM tag mismatch as
ERR_OSSL_BAD_DECRYPT ("bad decrypt") instead of OpenSSL 3's "Unsupported
state or unable to authenticate data", so decryptFile() let the raw OpenSSL
error through instead of "An encrypted backup file has changed or the key is
wrong". Both are recognised now; tests/continuousBackupEncryption.test.cjs
passes with OpenSSL 4.1.0 and 3.5.9.

Thanks to xet7 !
2026-10-29 22:51:29 +02:00
github-actions[bot] ee16bdce6b Bump versions for v12.69 2026-10-29 20:03:10 +00:00
Lauri Ojansivu 44bae91f8e Prepare v12.69 release 2026-10-29 22:02:00 +02:00
Lauri Ojansivu ae13eab23e SECURITY.md, CLAUDE.md, AGENTS.md: a report without a GHSA goes from 3 to 5 stars when its GHSA arrives.
A researcher who sent a report to security@wekan.fi, or some other way, and
is later able to add it as a GitHub security advisory: the maintainer
requests the CVE through that GHSA, and the Hall of Fame row's 3 stars
become 5. SECURITY.md says so to researchers. CLAUDE.md and AGENTS.md tell
an AI updating or auditing the Hall of Fame to check each such 3-star row
against the advisories, match by the report's subject and reporter, and
upgrade the index row, its own page, the CVE cell, the Process cell and the
star audit together.

Thanks to xet7 !
2026-10-29 21:52:15 +02:00
Lauri Ojansivu f5f6ad4d4d CHANGELOG: ImportPlacementBleed, and Release all missing builds after a failed rerun.
Thanks to Zachariah Mithani (AploSec) and xet7 !
2026-10-29 20:27:59 +02:00
Lauri Ojansivu 0d6d481fcb ImportPlacementBleed: an import into a board is placed only beside that board's own list, swimlane or card.
The scoped importer, reached by POST /api/import/zip and the importScoped
method, authorized the import on boardId alone. The listId and swimlaneId
beside it were written onto the new cards as given, and a cardId was looked
up with no board filter, so a writer of board A could create cards on A
whose list and swimlane were board B's, and could probe whether a card id
existed on a board they cannot see.

Before anything is written, the list, swimlane and card are now looked up
together with the board's id; an id of another board is refused (400 over
REST, error-import-placement from the method), with the same answer for a
foreign card and a missing one. Cards are placed only by what was found on
the board. A refusal is recorded in Admin Panel / Problems as
ImportPlacementBleed (authz.import-placement), without blocking the account.

Tests: the decision with the reporter's ids, the check before every write,
a negative scan for a target id written or looked up without the board, and
a browser test of the reporter's attack by both routes, which fails on the
previous code.

Thanks to Zachariah Mithani (AploSec) and xet7 !
2026-10-29 20:27:14 +02:00
Lauri Ojansivu 99d9958e39 Release all missing: the missing assets are built even when the rerun of failed jobs fails again.
The planner needed recover, so one job failing again in the rerun of the
full release skipped the planner and every build after it, and assets that
could be filled were not. For v12.68 the rerun failed on four Launchpad
snaps (503 / timeout) and on the OpenBSD bundle, whose start-wekan.sh fault
is in the tagged code and so in every rerun of it. The planner now runs
unless the run is cancelled, and each job after it checks the planner's own
result, since an implicit success() also counts recover. recover stays red
and now names the jobs that failed again.

Thanks to xet7 !
2026-10-29 20:15:32 +02:00
Lauri Ojansivu daae36b313 CHANGELOG: Invite via Email adds the invited new user, not the first user of the database.
Thanks to momosari and xet7 !
2026-10-29 17:36:01 +02:00
Lauri Ojansivu 8556459f97 Invite via Email adds the invited new user, not the first user of the database.
Meteor 3's server Accounts.createUser is createUserAsync. inviteUserToBoard
called it without await, so the new user's id was a Promise; findOneAsync
read the Promise as the empty selector {} and returned the FIRST user,
usually the site administrator. That user was added to the board and got
the invitee's language and invitedBoards, while the invitee was created but
not added. It was not FerretDB: any database did the same.

createUser and sendEnrollmentEmail are awaited (an enrollment email that
fails is logged, as the invitation email is), and the user found must be
the one just created. Every server ReactiveCache getter now refuses a
Promise as its id or selector, so a missing await fails loudly instead of
picking the first document. Tests: the guard and the reporter's case in
memory, a scan that no server file calls an async Accounts API without
await, and a browser test that inviting a new address adds exactly that new
account - it fails on the previous code.

Thanks to momosari and xet7 !

Fixes #6831.
2026-10-29 17:35:21 +02:00
Lauri Ojansivu 7f9e8ea7a3 CHANGELOG: History on FerretDB, date paths as strings, migration reports refused documents.
Thanks to vijlautens and xet7 !
2026-10-29 16:58:42 +02:00
Lauri Ojansivu 01968fc637 History on FerretDB: date paths are stored as strings, and the migration reports refused documents.
A History row of a date-valued custom field held datePaths as an array of
path arrays, [["3","value"]]. FerretDB v1 does not store an array inside an
array, so on FerretDB the change was not recorded, and migrateTextDatabase
to FerretDB dropped those rows while it swallowed the per-document error and
still reported success.

Each path is now a JSON string, '["3","value"]'. Rows already written in
the array form on MongoDB still restore, and the migration to FerretDB
rewrites them to strings. The migration counts every document the target
refuses and ends as failed with the count per collection and the first
error, instead of success: true.

Thanks to vijlautens and xet7 !

Fixes #6832.
2026-10-29 16:58:21 +02:00
Lauri Ojansivu 04c2598cf4 Merge remote-tracking branch 'origin/main' 2026-10-29 10:55:23 +02:00
Lauri Ojansivu 77ded27994 CHANGELOG: OpenBSD bundle starts again.
Thanks to xet7 !
2026-10-29 08:25:49 +02:00
Lauri Ojansivu ec6cf66bff OpenBSD bundle starts again: start-wekan.sh takes only a number from sysctl.
OpenBSD's sysctl prints nothing and exits 0 for a name it does not have, so
`sysctl -n hw.memsize || sysctl -n hw.physmem` stopped at the empty
hw.memsize and $(( / 1048576 )) ended start-wekan.sh before WeKan answered.
Both start-wekan.sh copies now try hw.memsize, hw.physmem64 (NetBSD, whose
hw.physmem is 32-bit) and hw.physmem in turn and take the first numeric one.
The test runs the lines against fake OpenBSD, NetBSD and macOS sysctls, and
shows the old chain failing on the OpenBSD one.

Thanks to xet7 !
2026-10-29 08:25:38 +02:00
github-actions[bot] 8561f49ed9 Bump versions for v12.68 2026-10-29 05:16:53 +00:00
Lauri Ojansivu 749e9b76a7 Prepare v12.68 release 2026-10-29 07:15:43 +02:00
Lauri Ojansivu ad43de3c01 CHANGELOG: Card Settings, two panes, the card and the minicard follow its order, menus inline.
Thanks to xet7 !
2026-10-29 02:03:11 +02:00
Lauri Ojansivu 2bd5d1180a Card Settings: two panes, the card and the minicard follow their order, menus inline.
The opened card and the minicard now draw Card Settings' sections, in
Card Settings' order, each section's fields in the order of its rows:
- Section order is per side: cardSettingsSectionOrder for the card,
  the new minicardSettingsSectionOrder for the minicard. A board that
  stores none: the card starts with Labels, Date, Members, Card, ...
  (the card's historical order as far as the grouping allows), the
  minicard with what it drew first before.
- CARD_LAYOUT's and MINICARD_LAYOUT's reorderable sections are Card
  Settings' sections, so dragging a section or a row in Card Settings
  moves it on the card or the minicard. Stored field orders map: a
  field keeps its place relative to the others of its new section.
- On the card every section has the shared heading (its icon, title,
  caret and hamburger) - Voting and Planning Poker too, which had a
  heading of their own. The minicard stays compact: no headings.

Card Settings shows both sides at once: the minicard's pane first (on
the left), the card's second, each with a caret that folds it, each
scrolling on its own, stacked when the popup is too narrow for two.
The Show radio is gone. A splitter between the panes changes their
widths by dragging, like spreadsheet columns (clamped, kept per user
as a fraction, reset by a double-click, moved by the arrow keys). The
popup is wider again for two panes and its corner grip resizes it,
the panes following.

A hamburger's content opens under its line inside the pane, not in a
popup: the hamburger shows it is open (aria-expanded, highlighted, the
panel attached to it); its button, the panel's X or Escape closes it.
A custom field's edit form and the + create form open inline the same
way and close only their panel when saved. The popup layer added in
9c9742e09d is removed, as nothing uses it now.

Docs: Card-Settings.md, Card-Fields.md and Board Settings / Card.md.
New tests cardSectionsOnCard, minicardSections, cardSettingsSplit,
cardSettingsInlinePanels and card-settings-sections-order.e2e.js; the
guards of the old layouts and orders say why they changed.

Thanks to xet7 !
2026-10-29 02:02:54 +02:00
Lauri Ojansivu eddfd515ea CHANGELOG: Card Settings, hamburger menus open on top of it, next to their button.
Thanks to xet7 !
2026-10-28 23:23:18 +02:00
Lauri Ojansivu 9c9742e09d Card Settings: hamburger menus open on top of it, next to their button.
A hamburger of Card Settings opened its menu in Card Settings' place:
the popup slid to the menu and Card Settings disappeared until its back
arrow. Its menus, and the forms it opens (a custom field's edit form,
the + under Custom Fields, Subtask settings), now open as a popup
LAYER: on top of Card Settings, next to the button that opened them,
with Card Settings visible under it and scrolled where it was.

Popup.open(name, { layer: true }), from inside a popup, pushes the
layer on the stack as before, so Popup.back() - the layer's X, Escape,
a form's Save - closes the layer alone. The popup template draws the
popup under it with the layer on top, outside the scrolling content;
depth counts the sliding popups only, so a confirmation opened from a
layer still slides. One layer at a time: another replaces it, its own
button closes it, a press elsewhere in the popup closes it.

A layer opens toward the middle of the popup: a hamburger at the end of
a line gets its right edge, the + on the left its left edge, so the
create form no longer ran out of the popup's left side where its Save
could not be clicked; below the button, or above when there is more
room, its height and width capped to the room
(models/lib/popupLayerPosition.js). A menu's checkboxes start at its
edge rather than .check-div's form indent.

tests/popupLayer.test.cjs runs the placement and pins the Popup wiring
and that every Card Settings menu opens as a layer;
card-add-fields.e2e.js checks Card Settings stays visible under the
menu, the menu sits by its button, toggles, closes on a press elsewhere
and by its X alone, and that the + form opens inside Card Settings.

Thanks to xet7 !
2026-10-28 23:23:08 +02:00
Lauri Ojansivu 0250e54a07 CHANGELOG: Card Settings, a checkbox per section, every field always shown, only in Board Settings.
Thanks to xet7 !
2026-10-28 22:44:02 +02:00
Lauri Ojansivu 3e5bc58105 Card Settings: a checkbox per section, always every field, only in Board Settings.
Card Settings is now only Board Settings / Card. The opened card has no
Card Settings button any more: it simply shows every field its board
shows on cards, empty or not.

- On this card, Show empty and Show empty fields are removed, with the
  hamburgers' Select all / Select none rows, their handlers, helpers,
  board methods (setCardFieldAlwaysShown, setShowEmptyCardFields), CSS
  and the three translation keys. The card no longer reads its own
  shownFields / hiddenFields, nor the board's cardFieldsAlwaysShown /
  showEmptyCardFields; the schema keeps them so old documents validate.
- Every section has a checkbox before its title: whether the whole
  section is shown on the chosen side, whatever its rows are ticked.
  Stored per side in cardSettingsHiddenOnCard / cardSettingsHiddenOnMinicard
  (setCardSettingsSectionShown, board admins). The opened card's view
  of the board and a new minicard view read false for every row of a
  hidden section, Scrum fields included; Card Settings itself reads the
  raw board, so the rows keep their ticks.
- A section with one row on the chosen side is one line: one checkbox,
  the name, one hamburger.
- A hamburger is drawn only when it holds something.
- Custom Fields: its items are the board's custom fields, in their sort
  order, reordered by drag and drop within the section, with a + under
  them that creates one. Show on Minicard sets a field's showOnCard,
  Show on Card its alwaysOnCard; its hamburger edits the field. The
  section's checkbox is the board's Custom Fields flag of that side.
- Card actions (Filter: Subtasks, Edit voting, Card kind, ...) live in
  the card's own section hamburgers; Card Settings keeps only board
  actions.

Docs: docs/Features/Cards/Card-Settings.md replaces
Card-Settings-On-Card.md; Card-Fields.md and Board Settings / Card.md
rewritten. New tests cardSettingsSectionShown and
cardSettingsCustomFields; the guards of the removed behaviour say why
they changed; card-add-fields.e2e.js covers section hiding per side, an
empty card showing every field, empty hamburgers, the one-row line, the
Custom Fields items and +, and that the opened card has no Card
Settings.

Thanks to xet7 !
2026-10-28 22:43:29 +02:00
Lauri Ojansivu 039935dd0b CHANGELOG: Card Settings, a space between a checkbox and its label.
Thanks to xet7 !
2026-10-28 20:46:50 +02:00
Lauri Ojansivu 5adfc1b9db Card Settings: a space between a checkbox and its label.
On the rows of Card Settings - on the opened card and in Board Settings /
Card - and in their hamburgers' popups, a checkbox touched its label:
the rows' grid had lost its column gap with the old pane's CSS, and a
hamburger's option put the box and the text side by side with none.
Both now have a space's width (0.3em) between them.

A ticked box still touched the label, because a tick is drawn rotated
and reaches about 3px past its box. Left to right it is drawn 3px
further left, so a ticked box keeps the same space and the label does
not move when it is ticked; right to left the tick leans away from the
label and stays as it was.

tests/cardSettingsOnCard.test.cjs pins the gaps and the tick rule, and
card-add-fields.e2e.js measures at least 3px for ticked and unticked
boxes on the rows and in Card's hamburger, with every label in the same
place.

Thanks to xet7 !
2026-10-28 20:46:42 +02:00
Lauri Ojansivu f269b1ef16 CHANGELOG: Board Settings / Card shows the same checklists as the opened card, by the same templates.
Thanks to xet7 !
2026-10-28 20:23:42 +02:00
Lauri Ojansivu 52de57c844 Board Settings / Card is the opened card's Card Settings, same templates.
Board Settings / Card had its own layout: an Admin Panel left menu of
sections and a pane with two lists, column headings and per-column Select
all. It now renders the same template as the opened card's Card Settings
(cardSettingsInline), with no card: Show on Minicard / Show on Card, the
sections as checklists, their rows as the items, and the same section and
row hamburgers. The popup's header is the title and the close button, so
the inline's own title and X are not drawn there, and a row's hamburger
has no On this card. The popup is 600px wide, about an opened card,
instead of 1200px.

The popup no longer registers the Card Settings handlers itself: the
template inside it does, and registering them on both would handle every
click twice. The left menu, the pane, the column Select all, the
in-place action forms and the personal-only mode are removed with their
helpers and CSS. A non-admin sees what a card shows them: the board's
rows disabled, their own rows (Labels text, List title) usable.

Dragging, on the card and in Board Settings / Card:
- With drag handles on, a row or a section is dragged only by its
  handle; the rest of the line scrolls the page. With them off, by its
  icon and name.
- A row dropped on another section goes back where it was and nothing
  is saved: the sections are unrelated kinds of fields. jQuery UI fires
  a delayed "out" just before "stop", so where it was dropped is read
  in beforeStop.

Found while testing both open at once:
- Each Card Settings has its own Show radio group name; one name made
  the card's and the popup's radios one group.
- A section hamburger's Select all acts on the Card Settings it was
  opened from.
- Rows are keyed, so switching Show moves each row with its side instead
  of relabelling rows in place a flush before they reorder, when a
  click could land on the neighbouring field.

Docs: Card-Settings-On-Card.md, Card-Fields.md and Board Settings /
Card.md. The node guards that pinned the old layout now pin the shared
template, with negatives that the old layout and the double handler
registration are gone; card-add-fields.e2e.js compares the two views
section by section and row by row on both sides, and tests the drag
rules, and the specs that drove the old pane drive the new one.

Thanks to xet7 !
2026-10-28 20:23:05 +02:00
Lauri Ojansivu 81e6988491 Southern Sotho: "dikarolo", not "masimo", for data fields.
Three Southern Sotho strings called a data field "masimo", which means
farm fields: Show empty fields in Card Settings, the five fields of a
cron schedule, and the webhook's optional fields. They now use
"dikarolo", the word the file already uses for card and custom fields.
sesotho-popup-titles.e2e.js, which checks that Card Settings names data
fields as such, now reads Card's "Show empty fields" in Board Settings /
Card and caught the first. The cron and webhook wording is low
confidence; a Southern Sotho speaker's correction on Transifex replaces
it.

Thanks to xet7 !
2026-10-28 20:22:56 +02:00
Lauri Ojansivu 8b4987d5bd CHANGELOG: the Data health entry no longer repeats its group's name.
Thanks to xet7 !
2026-10-28 18:50:35 +02:00
Lauri Ojansivu e12106ce7a CHANGELOG: Meteor 3.6-rc.1, with WeKan's six Meteor package forks rebased onto it.
Thanks to xet7 !
2026-10-28 18:50:00 +02:00
Lauri Ojansivu 120f484a30 Meteor 3.6-rc.1, with WeKan's six Meteor package forks rebased onto it.
meteor update --release 3.6-rc.1 failed with "No version of
shell-server satisfies all constraints" (and the same for
dynamic-import, allow-deny, ddp-server, facebook-oauth and
google-oauth): those six are WeKan's forks in packages/, made because
Meteor 3.6-rc.0 lacked the 3.5.3 security fixes, and pinned to rc.0's
versions.

Meteor 3.6-rc.1 carries the security fixes of 3.5.3 and 3.5.4, but not
what is WeKan's own in the forks: the Admin Panel / Problems reports of
refused attempts, and the crypto-RNG keys of dynamic-import and meteor
shell (rc.1 still makes them with Math.random()). So the forks are kept
and rebased: each is rc.1's package with WeKan's changes over rc.0's
package merged three-way onto it. The 3.5.3 hunks merged away, as rc.1
has them; what remains over rc.1 is only the hooks and the keys. The
versions are rc.1's with a _1 wrap number.

@meteorjs/rspack moves to 3.0.0-rc.2 with the release. The Dockerfile's
METEOR_RELEASE and the snap description follow. The experimental Bun
build's Meteor patch applies unchanged to 3.6-rc.1; prepare-meteor.sh
now checks for rc.1's tag commit and applies meteor-3.6-rc.1.patch, and
the rc.0 copy is removed.

tests/meteor353SecurityFixes.test.cjs and meteorForkRandomKeys.test.cjs
are moved to rc.1 with their assertions kept;
meteor-353-security.e2e.js passes on rc.1 in Chromium and Firefox.

Thanks to xet7 !
2026-10-28 18:49:59 +02:00
Lauri Ojansivu 08ff3327c4 Card fields guard: the Scrum section filter asks the board first.
tests/cardShownFields.test.cjs still pinned the Scrum filter of the
opened card's sections as it was before #6830, which now requires the
board's Scrum visibility before asking the card.

Thanks to jullbo and xet7 !
2026-10-28 18:49:18 +02:00
Lauri Ojansivu d74dbbe409 CHANGELOG: repoint commit links after history moved.
A rebase rewrote the commits these entries link to, so the links named commits
that no longer exist and would have 404ed on GitHub. Repointed by
releases/fix-changelog-hashes.sh, which only touches a link no ref in this clone
can reach.

Thanks to xet7 !
2026-10-28 18:43:49 +02:00
Lauri Ojansivu 2fd0c77164 Merge remote-tracking branch 'origin/main' 2026-10-28 18:43:48 +02:00
Lauri Ojansivu 6e18251e5f CHANGELOG: Show empty fields shows no Scrum section the board has turned off (#6830).
Thanks to jullbo and xet7 !
2026-10-28 18:28:57 +02:00
Lauri Ojansivu 0a224b3cea Show empty fields shows no Scrum section the board has turned off.
With Board Settings / Card's "Show empty fields" on, every opened card
showed the "Scrum settings" heading and its sections although Board
Settings / Scrum had none of its fields turned on. The Scrum fields are
the card's only sections with no board flag in the template, and
orderedSectionsOf asked only whether the card shows the field, which
Show empty fields answers yes for every field. A Scrum section now
needs the board's Scrum visibility (boardAllows) first, as Members,
Vote and Poker already do.

Thanks to jullbo and xet7 !

Fixes #6830.
2026-10-28 18:28:31 +02:00
Lauri Ojansivu a834211ab6 CHANGELOG: Card Settings on the opened card, its sections as checklists.
Thanks to xet7 !
2026-10-28 14:39:48 +02:00
Lauri Ojansivu c0ec28e82f Card Settings on the opened card, its sections as checklists.
The opened card's "+ Add" button is now "Card Settings". Clicking it no
longer opens the Board Settings / Card popup: the button and everything
below it on the card are replaced by Card Settings itself, until the X at
the end of its title puts the card back. Top to bottom:

- Card Settings, with its X (close) at the right of the title.
- "Show" and two radio buttons, Show on Minicard and Show on Card, which
  choose what the checkboxes below are.
- The sections (Card, Date, Time, ... Comments) drawn like checklists: the
  section is the checklist's title, with a count of the ticked rows and a
  hamburger; its rows are the checklist items. An item's checkbox is the
  board-wide Show on Card or Show on Minicard of that field.
- A row's hamburger holds On this card and Show empty; a section's holds
  Select all / Select none of each column, the section's card actions and,
  for Card, Show empty fields, Ask for a reason and the Draggable table.

Sections and rows reorder by drag and drop like checklists and their items,
and by the Up and Down keys. A section's order is the board's new
cardSettingsSectionOrder (normalised by sectionOrder() in
models/lib/cardSettingsSections.js); a row's is the card's or minicard's
field order, as in the popup. Sections collapse like checklists, kept per
user and board. The rows, checkboxes and handlers are the popup's, shared
rather than copied, and Board Settings / Card still opens the popup.

Design: docs/Features/Cards/Card-Settings-On-Card.md.

New strings: "Show" (card-settings-show) and the two hamburgers' titles,
"Actions". Every language takes its existing translation of the same
words; "Show" was written directly, with low confidence, for an, ary, bi,
csb, cy, cy-GB, fur, hsb, hy, jv, ka, kk, km, km-KH, km_KH, kok, lb, lld,
mai, mn, oc, or_IN, pa, sc, sn, so, szl, te-IN, tg, tk_TM, tl, tpi and tt.

Tests: tests/cardSettingsOnCard.test.cjs (the order rules, the layout, the
shared handlers, the translations) and
tests/playwright/specs/card-add-fields.e2e.js rewritten for the card's Card
Settings, the popup's own tests now opening it from Board Settings. The
guards that pinned the card template's indentation and the old opener say
why they changed.

Thanks to xet7 !
2026-10-28 14:39:29 +02:00
Lauri Ojansivu 6dcb9fa6e2 CHANGELOG: Data health cleans up custom fields whose boards were all deleted (#6829).
Thanks to Raymond-Voogt and xet7 !
2026-10-28 13:51:17 +02:00
Lauri Ojansivu 9dc3fc3f31 Data health: custom fields whose boards were all deleted get their own check.
customfield-dead-board counted every custom field that names a deleted
board, but its Repair only removes dead boards from a field that is also on
a live board; a field whose boards were ALL deleted was skipped on purpose,
since removing them would leave it on no board. So those fields stayed in
the count with a Repair button that could never change it: one instance
showed 510 and "Repaired: 0" after every click.

- New check customfield-boards-deleted: fields whose boards no longer exist
  (through boardIds or the legacy boardId). No existing board shows them.
  Repair deletes the definitions after the confirming second click, like
  the other repairs that delete data. A value a card on a live board still
  holds is left alone and stays listed under card-dead-field-values.
- customfield-dead-board counts only fields that still have a live board,
  so its count goes to zero when its Repair has run.

docs/Features/Admin-Panel/Problems/Data-Health.md lists the new check.
Test: tests/dataHealth6818.test.cjs.

Thanks to Raymond-Voogt and xet7 !

Fixes #6829.
2026-10-28 13:50:49 +02:00
github-actions[bot] e0eeae82d6 Bump versions for v12.67 2026-10-28 03:56:20 +00:00
Lauri Ojansivu b349576a15 Prepare v12.67 release 2026-10-28 05:55:24 +02:00
Lauri Ojansivu d519091909 Merge remote-tracking branch 'origin/main' 2026-10-28 05:13:33 +02:00
Lauri Ojansivu 55d64e6493 CHANGELOG: WeKan vs Trello vs Jira dates.
Thanks to xet7 !
2026-10-28 05:10:26 +02:00
Lauri Ojansivu cbdff47edb Docs: WeKan vs Trello vs Jira says when WeKan added the features it listed as missing.
The page listed Trello's Email Inbox, Complete Card Checkbox, Workspaces,
Butler scheduled and repeating tasks and Card Repeater as not yet in WeKan,
and Sync Lists and Map Card as WeKan ideas not yet implemented. Each now has
the WeKan release that added it: Workspaces v8.17 (2025-11-06), the complete
checkbox and card location v9.42 (2026-06-13), Scheduled Rules and recurring
cards v9.46 (2026-06-15), list sync v11.69 (2026-09-11), the Map view v12.10
(2026-09-29) and Email to card v12.40 (2026-10-14). The links to the 2018 and
2021 releases point to old-CHANGELOG/, where those sections are now.

Thanks to xet7 !
2026-10-28 05:10:21 +02:00
Lauri Ojansivu b36092896b CHANGELOG: the Add card form closes after a card (#6827), and the Bun guard's mongo:9.0.4.
Thanks to brlin-tw and xet7 !
2026-10-28 04:47:19 +02:00
Lauri Ojansivu 70de255333 Add card: the form closes after the card is added, unless Member Settings keeps it open.
A list's Add card form stayed open after a card was added, for the next
card, which the reporter found unexpected. It now closes once the card - or
every card of a multi-line paste - is added. Member Settings has a new
switch, "Keep the Add card form open after adding a card", off by default,
for who types several cards in a row. The close happens after the last
title, not at submit, because addCard still reads the form between the
titles of a paste; a failed add keeps the form and its text.

The two new strings are translated into every locale. Low confidence: ace,
ak, am, an, ary, as, ast-ES, ay, ba, bho, bi, bm, bo, br, bua, chr, ckb, co,
csb, cv, dz, ee, ff, fj, fo, fur, gn, gv, ha, haw, hsb, ig, iu, kl, kok, ks,
kw, lb, lg, lld, mai, mg, nah, nap, nd, nso, ny, om, or_IN, pap, ps, qu, rn,
rup, rw, sah, sc, scn, sd, se, sm, sn, so, ss, st, szl, ti, tig, tlh, tn,
to, tpi, ts, uz-AR, ve, ve-CC, ve-PP, vo, wa, wa-RR, wal, wo, wuu-Hans, zgh.

tests/keepAddCardFormOpen6827.test.cjs pins the switch, its default and
where the form is closed, with negative cases for a paste and a failure.
tests/playwright/specs/keep-add-card-form-open-6827.e2e.js drives it: closed
after one card and after a paste, kept open with an empty title once the
switch is on. multiline-titles opens the form again for its second card, and
assist-due-from-title keeps it open to check the next card's form has no
leftover hint.

Thanks to brlin-tw and xet7 !

Fixes #6827,
2026-10-28 04:46:52 +02:00
Lauri Ojansivu bce686ce46 Tests: the Bun smoke-test guard pins mongo:9.0.4, not the mongo:8 that stopped starting.
tests/bunSmokeMongo.test.cjs required the workflow's docker run to say
mongo:8, the floating tag that refused to start on the runner's 6.19+ kernel
in v12.66. It now requires mongo:9.0.4, and fails if mongo:8 comes back.

Thanks to xet7 !
2026-10-28 04:42:42 +02:00
Lauri Ojansivu facfc125ee CHANGELOG: the v12.66 release build is whole again.
Thanks to xet7 !
2026-10-28 04:31:25 +02:00
Lauri Ojansivu 1167cbd3b0 Bun bundles: the smoke-test MongoDB starts on a 6.19+ kernel, and Chromium is found.
linux-x64: mongo:8 refused to start on the runner's kernel ("Linux kernel
versions 6.19 and newer has a known incompatibility"). The smoke test pins
mongo:9.0.4, which recognises Ubuntu kernels that have the fix
(docs/Databases/Supported.md).

linux-arm64: Chromium was installed into ~/.cache/ms-playwright, while
build-target.sh loads tools-env.sh, which points Playwright at
.tools/ms-playwright, so the browser smoke test found no browser. The install
step uses the same path.

Thanks to xet7 !
2026-10-28 04:30:46 +02:00
Lauri Ojansivu d24598ce25 Snap variants: a copy of the source tree without .git gets its own .tools/tmp.
tools-env.sh made .tools/tmp only in a git checkout or where .tools already
existed, so that scripts mounted alone into a container (/releases) do not
make a /.tools there. The wekan-ondra and wekan-gantt-gpl snaps build from a
copy of the tree without .git (/root/project), so tools-env.sh found no
TMPDIR, and every fetch.sh in snapcraft.yaml stopped at "TMPDIR: unbound
variable" - reported as the Caddy download failing (v12.66). A tree with
package.json beside releases/tools-env.sh is a source tree too.

tests/toolsDirOnly.test.cjs sources it under set -euo pipefail with no
TMPDIR in such a copy, and, as the negative case, in releases/ alone, where
no .tools/ is made and no TMPDIR is invented.

Thanks to xet7 !
2026-10-28 04:30:46 +02:00
Lauri Ojansivu f68b130f15 Sandstorm: meteor-spk is assembled where the .spk is packed from, in .tools.
sandstorm-src/build-deps.sh still built meteor-spk.deps in
~/projects/meteor-spk, and the workflows put that on PATH, while
sandstorm-slim-spk.sh had moved to .tools/projects/meteor-spk with
aeff4900df: "no meteor-spk.deps at .tools/projects/..." and the v12.66 .spk
did not pack. build-deps.sh sources tools-env.sh and defaults to
WEKAN_METEOR_SPK_DIR, and the Sandstorm, Sandstorm store and meteor-spk
workflows use $GITHUB_WORKSPACE/.tools/projects/meteor-spk.

tests/toolsDirOnly.test.cjs scans sandstorm-src/ too, and fails on a workflow
that names ~/projects.

Thanks to xet7 !
2026-10-28 04:30:46 +02:00
Lauri Ojansivu 290e525b46 start-wekan.sh starts again where there is no /proc: FreeBSD, OpenBSD and macOS.
The memory budget read _memory_mb=$(awk ... /proc/meminfo) with no fallback
since 1f9ada5599. awk fails where there is no /proc, and under set -eu an
assignment from a failed substitution ends the script without a word, so the
FreeBSD and OpenBSD bundles and the Mac app never started WeKan (v12.66).
It is `|| true` again, and sysctl then gives the memory.

tests/macosShellPortability.test.cjs runs the memory lines under set -eu
without /proc and requires a number, with the unguarded line as the negative
case, and fails on any set -e script with a /proc read in $(...) that has no
fallback.

Thanks to xet7 !
2026-10-28 04:30:46 +02:00
Lauri Ojansivu 427d212627 Release: the emulated-arch builds mount .tools/tmp as the container's /tmp.
install-node-for-arch.sh refuses to run without TMPDIR, because the
container's own /tmp is a small part of its writable layer.
repack-bundle-for-arch.sh mounts this checkout's .tools/tmp as /tmp and sets
TMPDIR; the workflow's own docker run did not, so armhf, armv6, armv7, i386,
ppc64le, riscv64 and s390x all stopped at "TMPDIR must be the mounted
.tools/tmp" in v12.66, and win32, which builds from the i386 bundle's
Node.js, had nothing to build with.

Thanks to xet7 !
2026-10-28 04:30:46 +02:00
github-actions[bot] c1fe35bf6c Bump versions for v12.66 2026-10-28 01:02:24 +00:00
Lauri Ojansivu 0dee1da686 Prepare v12.66 release 2026-10-28 03:01:09 +02:00
Lauri Ojansivu 8ff34b423b CHANGELOG: the Drawing popup's width, File status's list of findings, and three Firefox specs.
Thanks to xet7 !
2026-10-28 02:57:47 +02:00
Lauri Ojansivu 0c40503a2b Tests: three Firefox specs wait for what they test, and drag inside the window.
- assist-search-phrases: the feature is turned off in the database, and a
  search sent before the browser had the published setting still ran the
  phrase. The search is repeated until the browser has it; a phrase that is
  never turned off still fails.
- board-settings-columns: a non-admin member's count of 0 passed in Chromium
  before the pane had rendered, and in Firefox counted Show on Card's
  Select all / Select none, which share the class and are a member's like the
  checkboxes under them. It waits for the pane and counts only the board-admin
  column actions.
- card-drawing-canvas: a maximized drawing fills the window, so its corner
  handle is at x 1236 of 1280 and a drag 120px further was not delivered by
  Firefox. The drawing is zoomed out to 80% first, which leaves room.

Thanks to xet7 !
2026-10-28 02:57:29 +02:00
Lauri Ojansivu 35ba67ee69 File status: one kind of finding no longer fills the whole list and hides the others.
The report lists at most 500 findings and counts the rest. They were listed
first come, first served, so on the test server 14358 baseline-move-candidate
rows of the integrity baseline took all 500, and the extension-mismatch and
mime-mismatch a type check is run to find were only numbers in the totals -
the Firefox stage's file-status-audit.e2e.js failed on it, and an
administrator would not have seen which file it was.

When the list is full, a kind with fewer rows listed now takes the place of
the last row of the kind with the most, so every kind found is listed and the
totals still count all of them. fileStatusAudit.test.cjs: 8 rows are 8
kinds with 40 orphan files crowding them, every finding is listed or counted
as omitted, and with room for all nothing is dropped.

Thanks to xet7 !
2026-10-28 02:57:29 +02:00
Lauri Ojansivu 58751aa339 Drawing: the popup is as wide as its drawing, so opened from the card's menu nothing is cut off.
The previous fix assumed the Drawing popup grew to its drawing's width and
ran off the window. Measured in the rebuilt app, it did not grow at all:
every popup is min(380px, 55vw) wide, the drawing's min(820px, 90vw) overflowed
inside it and the popup clipped it - the larger pen sizes and Save were cut
off by the popup, not by the window. Its box never changed, so the
ResizeObserver that keeps a movable popup inside had nothing to react to.

popup.css gives the Drawing popup min(860px, calc(100vw - 20px)), not
!important so that maximized `width: auto !important` still wins. Opened from
the card's menu at the right of a 1280px window it is now 860px wide at
x 408, 12px from the edge.

popup-drawing-window.e2e.js waits for the drawing and a box that stays put
before measuring: the Drawing popup is the card menu's popup element renamed,
which for a moment still had the 960px menu's place, and a drag pressed at
that place selected the page's text and closed the popup. The first drag
goes toward the side that has room.

Thanks to xet7 !
2026-10-28 02:57:21 +02:00
Lauri Ojansivu 2844fa07f7 CHANGELOG: autofocus, the drawing's size and place, the storage settings' English, the Helm chart reference, and four specs.
Thanks to xet7 !
2026-10-28 00:32:41 +02:00
Lauri Ojansivu bb614743fd Drawing: maximized it takes the window, and opened at the right it is not cut off.
Two faults the Chromium stage found, measured in the browser:

- A maximized new drawing was sized to its stage (1212 x 563 in a
  1280 x 720 window) but drawn 784px wide. popup.css still had the rule of
  the older design that kept the drawing at 800 x 500's shape -
  width min(100%, (100vh - 230px) * 1.6), aspect-ratio 8 / 5 - which
  overrode the surface's own size. d5d027a6f2 replaced that design; the
  rule is removed. (2929b61c85's refit was right, and is kept.)
- Opened from the card's menu at the right of the window, the Drawing
  popup grows to min(820px, 90vw) in the same popup the narrow menu was
  placed for, and its right part - the larger pen sizes and Save - was
  cut off by the window until it was dragged. A movable popup that grows
  is now kept inside the window by the rule a drag keeps it there, 12px
  from each edge (client/lib/popupBounds.js); not while maximized.

tests/popupBounds.test.cjs drives the bounds and fails on the 8 x 5 rule
coming back; popupMoveMaximize and popup-drawing-window.e2e.js pin the
drawing taking the window when maximized instead of its old shape.

Thanks to xet7 !
2026-10-28 00:31:58 +02:00
Lauri Ojansivu 9549388dd0 Tests: the smaller font preset is measured on the Card Settings heading text, not its line.
c8a15b514f moved the headings' smaller font from the heading line to its
text (.card-settings-pane-head > span, sidebar.css): on the line, the
em-sized columns shrank and each heading drifted off its cells. The
clean-task-boards spec still measured the line, which inherits the
body's 11.2px, and failed in Chromium; it measures the text, 9.6px. All
four clean-board specs pass.

Thanks to xet7 !
2026-10-28 00:28:35 +02:00
Lauri Ojansivu 9c0f408a00 Autofocus no longer takes the keyboard from a field being typed in, nor replaces its text.
client/lib/autofocus.js focused an [autofocus] element whenever a re-render
added one: the LAST [autofocus] anywhere in the document, selecting its
text, 50 ms later - and decided before those 50 ms whether it was already
focused. On the import page, typing the attachment token after pasting a
Jira export moved the focus to the pasted JSON, all of it selected, and the
token replaced it: the import then said "Your text is not valid JSON"
(import-attachment-auth.e2e.js in Chromium). A focus() then select() on the
textarea, recorded in the browser and found at autofocus.js in the running
bundle, showed it.

Now only an [autofocus] element that was itself just added is a candidate,
and whether to focus it is decided when the focus happens: never while
the user is typing in another field (an input, textarea, select or
contenteditable). Opening a popup or a form still focuses its field -
focus is then on the button that opened it, or on a composer field the
re-render removed. client/lib/autofocusDecision.js is the decision;
tests/autofocusDecision.test.cjs drives it with stand-in elements.

Thanks to xet7 !
2026-10-28 00:27:24 +02:00
Lauri Ojansivu 3f72fcd7f9 Tests: the Move and Copy Card helpers wait for the destination picker before reading it.
CardPage._selectBoard asked whether the popup had a boards select with
count(), which answers at once. Asked while the popup was still rendering,
it said 0 - which the helper reads as "a worker, who has no boards
select" - so the move went to the named list of the SAME board, and
#6793's Move Card spec waited in vain for the card's new boardId. A trace
showed it: one select, and that for the list. The helper now waits for
the lists select, which every picker draws, before asking about the
boards one. The #6793 specs and 03-cards-operations (29 tests) pass in
Chromium.

Thanks to xet7 !
2026-10-28 00:21:29 +02:00
Lauri Ojansivu 3e998651ea Admin Panel / Attachments: a storage setting's English text is not shown twice.
Each storage setting (S3, Azure, Google Cloud, WebDAV ...) shows its
description and menu path in English and in the user's language, so the
product's own terms stay recognizable. The English line was typed into
the template a second time above the {{_ key}} line: in English, and in
every language without that translation yet, the same sentence stood
twice in a row - which is also why Playwright's strict getByText found
two and storage-settings-translations.e2e.js failed in Chromium.

The English now comes from en.i18n.json (englishText; all 26 typed copies
were identical to it, so no text changes), and the translated line is
shown only when it differs (translatedDiffers), reactively with the
language. tests/attachmentSettingsBilingual.test.cjs fails on a typed
English twin coming back and on a translation shown unconditionally.

Thanks to xet7 !
2026-10-28 00:15:32 +02:00
Lauri Ojansivu cbf9d2f843 Tests: the UploadBleed and StampBleed specs do the legitimate case first, and check the block.
Both failed in Chromium on their negative case: an upload onto one's own
card answered 403 "Please sign in to upload.", and a checklist without a
foreign stamp "Access denied". The guards were right. The attempt came
first, and a signed-in account that attempts a vulnerability is blocked
(server/lib/blockOnSecurityEvent.js): loginDisabled, and the page's
session ended - so the legitimate call that followed ran signed out.
Printing Meteor.userId() around the first call showed it.

Each spec now does the legitimate case first and the attempt last, and
checks that the attempt blocked the account (services.securityBlock names
UploadBleed or StampBleed). The stamped checklist comes before the
stamped upload: its refusal names no account, so the user is still
signed in for the upload. Both pass in Chromium.

Thanks to xet7 !
2026-10-28 00:11:35 +02:00
Lauri Ojansivu 9994e82bae Helm chart: values.yaml's settings reference is generated from docker-compose.yml.
The chart's commented reference of every WeKan setting was copied from
docker-compose.yml by hand once and had fallen 74 settings behind, which
tests/chartsFerretdb.test.cjs reported as soon as .tools/charts was there.
releases/charts-values-reference.mjs regenerates the block from the wekan
service's environment, and release-charts.sh runs it on every chart
release. A comment after a value goes on the line above, not into the
value.

The chart change itself, and the 512 MiB request / 2 GiB limit #6606 had
asked of it, are committed in wekan/charts. All 19 chart tests pass.
tests/chartsValuesReference.test.cjs runs the generator on a temporary
values.yaml: every compose setting written, nothing outside the block
touched, no comment inside a value, and a second run changes nothing.

Thanks to xet7 !
2026-10-28 00:06:39 +02:00
Lauri Ojansivu e13a5a59ad CHANGELOG: .tools/ only, the macOS and Windows editor sandboxes, and the mirror source.
Thanks to xet7 !
2026-10-28 00:04:07 +02:00
Lauri Ojansivu 6ac70c4cdd Windows sandbox: VSCodium in Windows Sandbox, seeing only Downloads\repos\wekan.
The Windows counterpart of the Linux Flatpak sandbox and the macOS
sandbox-exec one. Windows Sandbox is a disposable Hyper-V virtual machine
that comes with Windows 10 and 11 Pro, Enterprise and Education; it sees
nothing of the host but the folders mapped into it.

- docs/Security/Sandboxes/Windows/sandbox.ps1 (on the host; sandbox.bat
  starts it) offers to turn the Windows Sandbox feature on, copies
  user.name and user.email into the checkout's .git\config, and writes
  .tools\sandbox\wekan-vscodium.wsb: the checkout as the only mapped
  folder, read-write at the same path under the sandbox's user; network
  on; microphone, camera and printers off; ProtectedClient; half of the
  host's memory, 4 to 16 GiB.
- sandbox-logon.ps1 runs inside at logon: TEMP, TMP and TMPDIR in
  .tools\tmp, git's configuration in .tools\home, PortableGit and the
  VSCodium zip downloaded once into .tools - each verified (VSCodium's
  .sha256, the SHA-256 in Git for Windows' release notes) before it is
  unpacked - and VSCodium opened on the checkout, its settings and
  extensions in .tools\vscodium.
- README.md is the design: what is refused and allowed, how, and the
  trade-offs.

Not run on Windows yet: written and reviewed on macOS, where PowerShell is
not available. tests/windowsSandbox.test.cjs reads the scripts and pins
the single mapping, the .tools locations, verification before unpacking,
and that native commands cannot stop Windows PowerShell 5.1.

Thanks to xet7 !
2026-10-28 00:03:42 +02:00
Lauri Ojansivu 42ed50413f macOS sandbox: VSCodium under sandbox-exec, reaching only ~/Documents/repos/wekan.
The macOS counterpart of the Linux Flatpak sandbox
(docs/Security/Sandboxes/vscodium). docs/Security/Sandboxes/macOS/sandbox.sh
installs VSCodium with Homebrew when it is missing, writes a Seatbelt
profile for this checkout's path to .tools/sandbox/vscodium-macos.sb, a
Finder launcher beside it, and starts VSCodium under sandbox-exec:

- every file under /Users and /Volumes is refused - the home directory,
  ~/.ssh, ~/.gitconfig, other users and /Users/Shared, other disks - except
  the checkout, and the login session's ssh-agent socket is refused;
- HOME is .tools/home, TMPDIR .tools/tmp, VSCodium's settings and
  extensions are in .tools/vscodium, and env -i drops SSH_AUTH_SOCK and an
  editor's ELECTRON_RUN_AS_NODE;
- --no-sandbox, because Chromium's own helper sandbox cannot start inside
  sandbox-exec ("Operation not permitted"); every helper is inside this
  one instead;
- user.name and user.email are copied into the checkout's .git/config,
  since ~/.gitconfig cannot be read inside.

Verified on macOS 27 with --check (home, ~/.ssh, ~/.gitconfig and
/Users/Shared refused; the checkout, git and the network allowed) and by
starting VSCodium in it: the window, its helpers and the extension host
ran. README.md is the design: what is protected, how, and the trade-offs.
tests/macosSandbox.test.cjs pins the rules and, on macOS, runs a profile
against a shell.

Thanks to xet7 !
2026-10-28 00:00:20 +02:00
Lauri Ojansivu 8af1e8aec8 mirror.sh: the WeKan checkout is the source, not a second clone in .tools/wekan-github.
Syncing wekan/wekan cloned the whole GitHub repository - 365,000 objects,
500 MB - into .tools/wekan-github, although it is this checkout
(~/repos/wekan on Linux, ~/Documents/repos/wekan on macOS,
Downloads\repos\wekan on Windows). Only the destination mirrors
(wekan-gitlab, wekan-codeberg ...) need checkouts of their own.

The source's branches and tags are fetched into this checkout instead,
under the private namespace refs/mirror-source/<source>/, which already
holds nearly every object: 1.7 seconds for 1,278 refs, measured. That
namespace is not a branch; the working tree and local main are untouched,
so unpushed local commits never reach a mirror. The fetched refs are
deleted again in a finally, after the push and also when the fetch, the
push or the merge-preserving retry fails.

tests/activeForgeMirror.test.cjs and tests/mirrorGitDirectories.test.cjs
run syncGit against a stand-in checkout (WEKAN_MIRROR_SOURCE_CHECKOUT for
the CLI), assert that GitHub's wekan is not cloned, that only the
destination is, and that the source refs are cleared - also after a failed
fetch.

Thanks to xet7 !
2026-10-27 23:59:26 +02:00
Lauri Ojansivu e79ec61bcc Windows: build.bat and the releases\ batch files keep every file in .tools\ too.
releases\tools-env.bat is the counterpart of releases/tools-env.sh: TEMP,
TMP and TMPDIR in .tools\tmp, npm's cache and global installs, Go's caches
and the Playwright browsers in .tools\, METEOR_WAREHOUSE_DIR at
.tools\.meteor once Meteor is there, and those tools first on PATH, so no
user or system PATH setting has to change. build.bat and every
releases\ batch file call it; the bundle launchers (start-wekan.bat) do
not, as they run a finished bundle where there is no .tools\.

build.bat's install option no longer installs a system-wide Node.js with
Chocolatey: it unpacks the Dockerfile's version from nodejs.org's zip
into .tools\node-v<version>-win-<arch>, verified against SHASUMS256.txt
first, and Meteor goes into .tools\.meteor (LOCALAPPDATA is .tools for the
installer only). build-bundle-win64.bat works in .tools\tmp\win64-bundle-
<version> and says where the zip is. tools-env.sh also keeps Go's caches
and the Playwright browsers in .tools/, as tools-env.bat does.

tests/toolsDirOnly.test.cjs pins tools-env.bat, its callers and the .tools
install; tests/buildScriptParity.test.cjs reads the Playwright directory
and Node.js version from their new places.

Thanks to xet7 !
2026-10-27 23:53:23 +02:00
Lauri Ojansivu aeff4900df Every file the scripts write stays in .tools/: temporary files, downloads, Meteor, Node.js, npm.
build.sh and the releases/ scripts wrote all over the machine: temporary
files to /tmp and macOS's /var/folders, downloads and release zips to
~/Downloads or ~/Lataukset, Meteor to ~/.meteor, Node.js to /usr/local
(sudo n) or ~/.nvm, the npm cache to ~/.npm, Volta to ~/.volta, the
Sandstorm toolchain to ~/projects, and PATH lines into ~/.zshrc and
~/.bashrc. A sandboxed editor (Flatpak on Linux, sandbox-exec on macOS)
can reach only the checkout, and a container's own /tmp is a few hundred
megabytes. Everything is in .tools/ now, which git and Meteor ignore:

- releases/tools-env.sh, sourced by build.sh and every script that writes
  files, sets TMPDIR/TMP/TEMP to .tools/tmp (also downloads and release
  zips), NVM_DIR, npm_config_cache, VOLTA_HOME and the meteor-spk
  directory to .tools/, METEOR_WAREHOUSE_DIR to .tools/.meteor once Meteor
  is installed there, and puts those tools first on PATH, so no shell rc
  file is edited. HOME is not moved: git needs ~/.gitconfig and ~/.ssh.
  A TMPDIR already inside a .tools/ is kept, so a test's own directory
  there stays its own; without a writable one it stops rather than fall
  back to /tmp. It never fails a caller running set -euo pipefail.
- macOS's mktemp ignores TMPDIR when given no template, so every mktemp
  names one: mktemp "$TMPDIR/name.XXXXXX". Fixed /tmp files are in
  $TMPDIR.
- build.sh installs Node.js on Linux into .tools/n (N_PREFIX, no sudo),
  nvm on macOS into .tools/nvm (PROFILE=/dev/null: no rc file edited), and
  Meteor with HOME=.tools for the installer only, into .tools/.meteor.
  build.sh is replaced, not edited in place, while it may be running.
- Containers get this checkout's .tools/tmp as their /tmp: every
  db-conformance database (MariaDB's temporary tables were verified to land
  there, and MongoDB's socket) and the repack container, which empty it
  themselves before they are removed. install-node-for-arch.sh requires
  the mounted TMPDIR.
- tests/run-node-suites.cjs gives every suite TMPDIR in .tools/tmp, and the
  tests that copied a script into a fixture copy tools-env.sh too; tests
  that pinned ~/.meteor, the rc file edits, sudo n or /tmp/manifest.json
  pin the .tools/ places now and say why.

tests/toolsDirOnly.test.cjs runs tools-env.sh against a TMPDIR already
set elsewhere and under set -euo pipefail, and fails on any script that
makes a temporary file without naming .tools/tmp, writes to the home
directory, or edits a shell rc file, or on a container whose /tmp is its
own.

Thanks to xet7 !
2026-10-27 23:49:55 +02:00
Lauri Ojansivu d1b96f65c3 CHANGELOG: the checkout on every system, and companion repositories only in .tools/.
Thanks to xet7 !
2026-10-27 23:15:41 +02:00
Lauri Ojansivu 96ad680cd4 Release scripts find the checkout on every system, not only at Linux's ~/repos/wekan.
The checkout is at ~/repos/wekan on Linux, ~/Documents/repos/wekan on macOS
and Downloads\repos\wekan on Windows (CLAUDE.md). release.sh,
release-bundle.sh, release-snap.sh, release-sandstorm.sh, release-cleanup.sh,
install-sandstorm.sh, node-update.sh, docker-push-gantt.sh, up.sh and
rel.sh hard-coded Linux's ~/repos/wekan (rel.sh also /home/wekan/repos), so
on a Mac they cd'd into nothing - or into a stale copy - and edited or
packed the wrong tree. Each finds the checkout from its own location now
(REPO_DIR, as mirror.sh already did), which is right on every system; the
parent directory is $(dirname "$REPO_DIR").

DOWNLOAD_DIR defaulted to ~/Lataukset, the Finnish Linux name of Downloads.
macOS keeps ~/Downloads in every language, so it is Lataukset where that
exists and Downloads otherwise.

The VirtualBox appliance scripts keep /home/wekan: that image is a Linux VM
with its own fixed layout. tests/repoLocationPerOs.test.cjs pins the three
locations in CLAUDE.md and AGENTS.md and fails on any script that
hard-codes one system's checkout path or assumes Lataukset.

Thanks to xet7 !
2026-10-27 23:15:29 +02:00
Lauri Ojansivu 9927adcb44 Companion repositories are read from .tools/ only, never from ../w/.
wekan.fi, charts and docker-cleanup-volumes used to be checked out in ../w/
beside the repository, and several places kept that as a fallback after
they moved to .tools/ (CLAUDE.md). With no .tools/wekan.fi, the Hall of Fame
tests read a stale ../w/wekan.fi and failed on markup the site no longer
has.

- tests/hallOfFameProblemsCoverage.test.cjs and
  tests/fixedVulnerabilityClasses.test.cjs read only .tools/wekan.fi (and
  skip, saying so, when it is not there).
- release-charts.sh, backfill-charts.sh, reindex-charts.py and version.sh
  look for the charts checkout only in .tools/charts.
- docker-build.sh clones and runs docker-cleanup-volumes in .tools/.

tests/noLegacySiblingRepos.test.cjs fails when a script, test or document
under releases/, tests/, docs/ or tools/, or at the root, looks in ../w/
again; tests/reindexCharts.test.cjs now requires ../w/charts to be gone.

Thanks to xet7 !
2026-10-27 23:13:02 +02:00
Lauri Ojansivu 6675f0e5d3 CHANGELOG: the shell scripts and tests on macOS, and two Card Settings specs.
Thanks to xet7 !
2026-10-27 23:07:27 +02:00
Lauri Ojansivu 43e09631c4 Single file: a Node.js without single executable applications is named; tests run on an arm64 Mac.
A Node.js can be compiled without single executable applications - the
Homebrew one on macOS is - and `node --build-sea` then only says "Single
executable application is disabled" and exits 9, which the build
reported as a bare failed command. build.mjs asks first (seaSupported) and
stops with what to do: build with a Node.js from nodejs.org, given with
--host-node.

tests/singleFile.test.cjs skips the end-to-end build on such a Node.js
and says why, tests seaSupported against stand-ins of both kinds, and
expects the 32-bit refusal to name the host's own arch - it expected
"x64", so it failed on an arm64 Mac.

Thanks to xet7 !
2026-10-27 23:06:49 +02:00
Lauri Ojansivu 1f9ada5599 Shell scripts on macOS: no /proc, so command lines and memory are asked another way.
macOS and the BSDs have no /proc.

- releases/db-conformance.sh decided whether a container's run was still
  going from /proc/<pid>/cmdline. On a Mac every live run looked dead, so
  a second conformance run removed the first one's containers and
  networks. The command line comes from ps -o command= where there is no
  /proc (tests/dbConformanceStaleContainers.test.cjs passes on macOS now).
- start-wekan.sh and releases/ferretdb/start-wekan.sh sized the Node.js
  and FerretDB heaps from /proc/meminfo, so on a Mac they assumed 2 GiB
  whatever the machine had. They ask sysctl hw.memsize (macOS) or
  hw.physmem (BSD) there, as build.sh already did.
- releases/debug-speed-watch.sh shows the load average from sysctl
  vm.loadavg where there is no /proc/loadavg.

tests/macosShellPortability.test.cjs fails on a /proc memory, load or
command-line read without such a fallback (the Docker entrypoint, which
runs only on Linux, excepted). releases/nextcloud-pack.sh is listed in
tests/buildScriptParity.test.cjs as run by nextcloud.yml.

Thanks to xet7 !
2026-10-27 23:06:49 +02:00
Lauri Ojansivu 0ed8fb20f2 Tests: strip-node.sh is exercised on a Mac, and the ELF cases skip there.
tests/stripNode.test.cjs built its ELF fixture with `gcc`, which on macOS
is clang and writes a Mach-O, so the section reader found nothing and the
test failed with "the fixture has symbols and debug data". The ELF cases
now skip unless the compiler really wrote an ELF, and a new case runs
releases/strip-node.sh on a real Mach-O where there is a Mac: the local
symbol is gone, the exported one stays, the binary is re-signed ad hoc,
passes codesign --verify --strict and still runs.

Thanks to xet7 !
2026-10-27 23:02:53 +02:00
Lauri Ojansivu 283e51eac0 Shell scripts: the root and releases/ scripts run on macOS too.
Started from zsh on macOS, a script gets bash 3.2 as /bin/bash and BSD
tools. Every .sh at the repository root and under releases/ was checked
with bash 3.2 and for GNU-only tool options; what failed:

- test-menu.sh: declare -A (bash 4) is a case function, runner_for, and
  an empty match list no longer trips set -u in bash 3.2.
- releases/ferretdb-latest-tag.sh: with no GitHub token, "${auth[@]}"
  was an unbound variable under set -u in bash 3.2; it uses
  ${auth[@]+...} as ferretdb-asset.sh already did.
- find-replace.sh: never worked anywhere ('$1' in single quotes searched
  for the text $1) and used GNU grep -Z and sed -i. It replaces literal
  text with perl now, skipping the directories find.sh skips.
- sha256sum (missing on older macOS) falls back to shasum -a 256 in
  release-x2.sh, repack-bundle-for-arch.sh, install-node-for-arch.sh and
  sandstorm-capnp-schemas.sh; -c works with both.
- sandstorm-capnp-schemas.sh: nproc falls back to sysctl hw.ncpu, and
  sed -i takes a .bak suffix (GNU and BSD alike) that is then removed.
- sandstorm-slim-spk.sh: ln -sfT is ln -sfn, stat -c%s is wc -c.
- virtualbox/ipaddress.sh: ifconfig where there is no ip.

tests/macosShellPortability.test.cjs parses every bash script with
bash 3.2 when /bin/bash is 3.x, and fails on any of these shapes coming
back: associative arrays or mapfile, flock without a fallback, tar
--transform, a GNU-only sed -i, sha256sum / nproc / ip without a
fallback, ln -T or stat -c.

Thanks to xet7 !
2026-10-27 23:02:14 +02:00
Lauri Ojansivu fa1bb293f9 UCS migration and Nextcloud packing work on macOS: no flock, no GNU tar.
The UCS App Center preinst hook locked with flock(1), which is util-linux
and missing on macOS and the BSDs, so tests/ucsMigration.test.cjs could not
run the hook there ("flock: command not found"). Without flock it now takes
the same lock with perl's flock() on the inherited fd 9: a flock(2) lock
belongs to the open file, so it is held until the hook closes fd 9, exactly
as flock -n 9 holds it. Verified: a second locker is refused and the lock
frees when the first one closes. perl is on UCS (Debian) and macOS.

The Nextcloud ExApp archive was packed with GNU tar's --transform, which
bsdtar (macOS, the BSDs) does not have. releases/nextcloud-pack.sh copies
the source under a staging <app-id>/ and packs that directory, with the
same exclusions, using only options both tars share; COPYFILE_DISABLE=1
keeps macOS tar from adding ._* AppleDouble files. The workflow and
tests/nextcloudRelease.test.py both use it, and
tests/ucsNextcloudWorkflows.test.cjs fails if --transform comes back.

Thanks to xet7 !
2026-10-27 22:57:57 +02:00
Lauri Ojansivu d8d7b01334 Tests: the Card Settings both-sides and Scrum title specs follow the current Card Settings.
card-settings-both-sides read row[side].needsCard for every row, but six
rows have no minicard side by design (suggested members, checklist from
description, attachment previews and details, Drawing, action items), so
it threw "Cannot read properties of undefined". It expects what
rowsForSide lists now: rows that have the side, less the card-only List
title.

The Scrum settings title spec read the pane title right after clicking
Scrum. Card Settings opens on Card now, so it read "Card" and failed on
the "Card button" label. It waits for the pane title to be Scrum's menu
name first.

Both failed in the Chromium stage of the 2026-10-27 EVERYTHING run.

Thanks to xet7 !
2026-10-27 22:25:33 +02:00
Lauri Ojansivu b1370fbc5e CHANGELOG / TODO Later: the six maintainer-decision items are done.
API JSON uploads and the site logo stored again, the validate-and-move
method moves again, REST card placement checked, WIP limits admin-only,
AuthBleed attempts recorded and Data health's "Never" with a key of its
own - each removed from TODO Later now that its fix is in Upcoming. The
browser runs and WebKit stay there.

Thanks to xet7 !
2026-10-27 21:46:31 +02:00
Lauri Ojansivu a40c4495d4 Lists: a WIP limit is set by a board admin, on every path.
The applyWipLimit, enableWipLimit and enableSoftLimit methods have always
required a board admin ("You must be a board admin to modify WIP
limits."), but a member with write access could still set wipLimit with a
plain list update - the Lists allow rule grants write members every field
- or with REST PUT /api/boards/:boardId/lists/:listId. A WIP limit is a
board-wide workflow rule, so the methods' rule now holds everywhere:

- a Lists deny rule refuses an update touching wipLimit unless the caller
  is admin of the list's board. Every WeKan client path goes through the
  methods, so it refuses nothing in ordinary use;
- the REST PUT asks for a board admin (checkBoardAdmin) when the body
  carries a wipLimit; write access still edits title, color and starred.

tests/wipLimitAdminOnly.test.cjs runs the real deny rule, pins the REST
check before the write, and fails on any list method that changes a WIP
limit without a board admin check first.

This was in CHANGELOG TODO Later as needing a maintainer decision;
decided: board admins only.

Thanks to xet7 !
2026-10-27 21:45:43 +02:00
Lauri Ojansivu 02bb0814c3 Tests: linkedWritePolicy and webhookBleed read the code as it is now.
linkedWritePolicy sliced POST cards' linked-card branch up to the getCard
that #6823 removed, so the slice ran to the end of the file and found
checkBoardAccess elsewhere; it ends at the branch's author-spoof note now,
and fails if that is missing. webhookBleed (GHSA-7w2h-g83c-jqrp) pinned
copyBoard's signed-out refusal as one line; it records an AuthBleed
attempt first now, so the guard accepts the block and still requires the
refusal before the board is read.

Thanks to xet7 !
2026-10-27 21:42:47 +02:00
Lauri Ojansivu 77956c98ed Admin Panel / Problems: Data health's "Never" has a key of its own.
The Data health status line ("Last Run: Never") reused List Sync's
list-sync-last-synced-never, so a translator who words that one as "never
synced" would make Data health read wrong. It is data-health-never-run
now, added after data-health-repair with add-pending-keys.mjs
--all-locales (the completeness suites require every locale to carry
English's keys in order) and listed in pending-transifex.json.

Every locale already translates the identical English "Never" under
list-sync-last-synced-never; fill-translations.mjs --apply filled the new
key from that in 234 locales, writing only the English placeholders. A
real Transifex translation still replaces it on the next pull.

This was in CHANGELOG TODO Later as needing a maintainer decision.

Thanks to xet7 !
2026-10-27 21:42:47 +02:00
Lauri Ojansivu 314c153c9b Admin Panel / Problems: AuthBleed attempts are recorded.
copyBoard, copyCard, copySwimlane and createCardWithDueDate - four of the
AuthBleed methods - refused with plain not-authorized errors, so an
attempt left no trace in Admin Panel / Problems. AuthBleed was on the
PENDING list of tests/hallOfFameProblemsCoverage.test.cjs.

A new catalog key, authz.copy (AuthBleed, high, CWE-862), is recorded
where no legitimate client can be:

- a signed-out caller of any of the four - no WeKan client copies while
  signed out, and with no userId nobody can be blocked;
- any refusal of createCardWithDueDate, which no WeKan client calls.

The membership refusals of copyBoard, copyCard and copySwimlane are
deliberately NOT recorded: a member who is not admin of a shared or
default board template, or a stale Copy Card / Copy Swimlane dialog,
reaches them in ordinary use, and a high-severity record naming the
account disables that login (server/lib/blockOnSecurityEvent.js). Each
record is wrapped so logging cannot break the guard.

tests/authBleedCopyMethods.test.cjs runs the real method bodies and pins
both: what is recorded, and that the ordinary-use refusals are not.
AuthBleed leaves the PENDING list.

This was in CHANGELOG TODO Later as needing a maintainer decision.

Thanks to xet7 !
2026-10-27 21:29:17 +02:00
Lauri Ojansivu e7505a4fef Attachments: the API's JSON uploads, copies and the site logo are stored again.
The JSON branches of REST /api/attachment/upload, upload-background and
copy, their Meteor-method twins in server/attachmentApi.js, and Admin
Panel's site logo upload created the file with Attachments.insertAsync.
Meteor-Files (ostrio:files 3) has insertAsync only on the client; on the
server it is not a function, so every one of them failed - a REST 500
"Attachments.insertAsync is not a function". Only the multipart branches,
which use addAttachmentFromStream, worked.

All seven store with writeAsync(buffer, { fileName, type, userId, meta },
true) now, as email-to-card and the importers already do. Its
after-upload hook validates the file - type, size and the external
scanner, which these paths skipped - and moves it to the default storage,
or to meta.copyStorage for a copy, so the second deferred moveToStorage
each branch made is removed. The file records who stored it.

tests/attachmentApiServerWrite.test.cjs pins the seven calls and fails on
any server-side Attachments.insertAsync or Avatars.insertAsync.

This was in CHANGELOG TODO Later as needing a maintainer decision; the
site logo upload was found by the new test's tree-wide check.

Thanks to xet7 !
2026-10-27 21:26:30 +02:00
Lauri Ojansivu d3e80af1ac Attachments: validateAttachmentAndMoveToStorage moves the file again.
After validating, the method moved the file from Meteor.defer by calling
the moveAttachmentToStorage method. A deferred call has no method
invocation and so no userId, and that method's first check is "You must be
logged in.": every move was refused, the error went nowhere, and the file
stayed where it was.

Every check moveAttachmentToStorage makes - a site admin, the board
visible, the destination on the allowlist - is made by this method before
the defer, so the deferred step now calls moveToStorage itself, as the
upload path already does, and logs a failure instead of dropping it.

tests/moveStorageBleed.test.cjs pins it; its existing guard still requires
the admin check and the allowlist before every caller-named move, and a
new one fails on any Meteor.defer or Meteor.setTimeout in server/ or
models/ that calls a Meteor method.

This was in CHANGELOG TODO Later as needing a maintainer decision.

Thanks to xet7 !
2026-10-27 21:23:54 +02:00
Lauri Ojansivu c355926767 REST: a new card's list and swimlane must belong to the board in the URL.
POST /api/boards/:boardId/lists/:listId/cards and its bulk sibling wrote the
URL's listId and the body's swimlaneId onto the new card without checking
either against the board. A list or swimlane id of ANOTHER board gave a
card that pointed at a list and a swimlane its own board does not have -
the #6572 shape, a card that shows on neither board - and the list
permission check was evaluated against the wrong board's rules. The PUT
route has refused this since #6572; the create routes now do the same with
a 400 that names the field, and the bulk route reports it per entry. An id
that is not a string (such as { $ne: null }) is refused rather than looked
up. The linked-card branch was already checked by createLinkedCardFor.

tests/restCardPlacement.test.cjs pins both routes and fails on any REST
handler in server/ that inserts a card without the check.

This was in CHANGELOG TODO Later as needing a maintainer decision; the PUT
route's #6572 rule already decided it.

Thanks to xet7 !
2026-10-27 21:22:49 +02:00
Lauri Ojansivu 6abcc8fa1f CHANGELOG: Problems reports collection scans and read rate, literal board search, one "created" activity, and less database load.
The six fixes from Raymond-Voogt's reports #6821-#6826. CHANGELOG.md went
over its 450,000-byte budget with them, so v12.40 is moved to
old-CHANGELOG/2026/10.md by releases/changelog-archive.mjs.

Thanks to Raymond-Voogt and xet7 !
2026-10-27 21:20:58 +02:00
Lauri Ojansivu b87858baaf Problems / Database: report queries without an index and a high read rate.
The database health probe (server/lib/databaseHealth.js) saw restarts, slow
storage and a full disk, but not what made one instance slow: a 10 GB
collection scanned three times per search, and an hourly job reading all
1.1 million cards (#6824, #6825). Both were visible only in MongoDB's own
log, as "Slow query" lines with COLLSCAN, and in serverStatus: 1.6 billion
documents read in two hours.

Two more checks, every 5 minutes with the others:

- db.collection-scan: the "Slow query" lines MongoDB keeps (getLog
  'global'), newer than the last probe, where a query read a whole
  collection of at least 10,000 documents. Grouped by collection and the
  field names filtered on - never values - with count, documents read and
  time. Small tables read whole by design (settings, roles) and internal
  namespaces are left out. Needs the getLog privilege; without it, and on
  FerretDB, the check finds nothing and is skipped.
- db.scan-rate: serverStatus.metrics.queryExecutor.scannedObjects between
  two probes above 50,000 documents per second. A database restart, which
  resets the counter, is not counted.

Both are rows in Admin Panel / Problems / Database with what to do;
docs/Databases/MongoDB/Storage-Requirements.md lists them.

tests/databaseHealthScans.test.cjs drives the log parsing and the rate,
including that a filter's values never reach the row. From the reporter's
patch.

Thanks to Raymond-Voogt and xet7 !

Fixes #6826.
2026-10-27 21:19:51 +02:00
Lauri Ojansivu d481bffdfe History audit and position-history cleanup: once per installation.
Both were Meteor.setInterval jobs, so every WeKan process behind one
database ran them. On a 24-pod installation (54,234 History rows, 148,660
board memberships, 1,760 position-history entries):

- the change history integrity audit read the whole History 24 times an
  hour, and
- UserPositionHistory.cleanup ran 24 times a day, each run one query per
  board membership of every user, each fetching up to 1000 whole
  documents, to trim nothing - outside the CPU governor.

Both are SyncedCron jobs now, the way the other periodic jobs already
are: synced-cron's { intendedAt, name } lock runs each scheduled time on
one pod only, and its running-job check (indexed in #6769) skips a run
while the previous one is still going. No new collection is needed for
it. The audit stays hourly; the cleanup runs at 4:15 am.

The cleanup asks the database which user/board pairs hold more than 1000
non-checkpoint entries (aggregate) and trims only those: the 1000th
newest entry's date with one indexed query, everything older in one
remove. The result is the same as before - the newest 1000 per user and
board are kept, checkpoints never - also for pairs whose user has since
left the board. It waits for a quiet CPU and pauses between pairs.

models/lib/positionHistoryCleanup.js holds the plan with the database
calls passed in; tests/positionHistoryCleanup.test.cjs runs it on an
in-memory history against the old algorithm's result, counts the queries,
and fails if anything in server/ or models/ drives either job from a
per-process timer again. From the reporter's patch, with the claim moved
onto the existing SyncedCron.

Thanks to Raymond-Voogt and xet7 !

Fixes #6821.
2026-10-27 21:18:08 +02:00
Lauri Ojansivu dad16609c9 Board search: match the typed text, not a regular expression.
The sidebar card search, the link-card popup and the list, swimlane and
board pickers (Board.searchCards / searchLists / searchSwimlanes /
searchBoards, models/lib/cardSearch.js) and the import member search
(UserSearchIndex) compiled the typed term AS a regular expression:

- "C++", "(2026)", "[x" or a case number like "PL0900-2026(1)" threw
  "Invalid regular expression" and the search showed nothing;
- "." matched any character and "*" or "?" changed what was searched for.

Every server-side search (global search, publications, boardTextSearch)
already escapes the term and matches it literally. These now do the same,
still case-insensitive, through literalSearchRegex() in cardSearch.js,
which the user search reuses rather than carrying its own escape. The
Advanced Filter's /regex/ syntax is a separate path and is unchanged.

tests/noUnescapedDynamicRegExp.test.cjs no longer needs its reviewed
exceptions for models/boards.js, models/lib/cardSearch.js and
models/users.js; tests/searchLiteralTerm.test.cjs drives the matching and
fails on any model, client or imports file that compiles a raw term into
a RegExp. From the reporter's patch.

Thanks to Raymond-Voogt and xet7 !

Fixes #6822.
2026-10-27 21:15:51 +02:00
Lauri Ojansivu 5f3b211b37 New issue and REST linked card: one "created" activity, not two.
A card inserted through the Cards collection gets its "created" activity
from Cards.after.insert. Two paths inserted through the collection AND
called cardCreation() themselves:

- Board View / Version Control, New issue (vcsCreateIssue), and
- POST /api/boards/:boardId/lists/:listId/cards with linkedId
  (createLinkedCardFor).

Each such card got two "created" activities, and every watcher two
notifications and two emails. The other explicit callers - REST create,
bulk create, email view, email to card - insert with Cards.direct, where
no hook runs, and are right to record it themselves.

Both extra calls are removed. Outside a method (a REST route, a server
job) the hook has no user, so the REST linked card's activity would have
lost its author: the hook now falls back to the card's own userId, which
createLinkedCardFor sets to the session user. Activities never exclude
their actor from notifications, so the fallback only names an author
where there was none, such as a recurring card's next copy.

tests/noDoubleCreateActivity.test.cjs walks server/ and models/ and fails
on any explicit cardCreation that follows a hooked insert. The
GHSA-6jr3-42jf-vhm5 guard in restApiIdorBatch now pins the linked card's
attribution through createLinkedCardFor and the hook. From the reporter's
patch.

Thanks to Raymond-Voogt and xet7 !

Fixes #6823.
2026-10-27 21:14:23 +02:00
Lauri Ojansivu 2be842351b Search sessions: indexes, and stale sessions of every user are removed.
Every global search and card report (server/publications/cards.js) upserts
its search session by { userId, sessionId }, reads it back by the same two
fields, and removed the user's sessions older than a day by
{ userId, modifiedAt }. The sessiondata collection had only its _id index,
so each search scanned every stored session three times.

And only the searching user's OWN old sessions were removed: a user who
never searched again kept theirs forever. Each session holds the pickled
selector of the search, which lists every board the user can see - large
for an administrator. One instance had 4,751 sessions taking 10 GB, the
biggest collection in its database.

- Indexes { userId: 1, sessionId: 1 } and { modifiedAt: 1 }.
- Sessions older than a day are removed for everybody on each search, as
  one indexed delete. Paging (next/previous) works within the day as before.

Test: tests/searchSessionIndexes.test.cjs. From the reporter's patch.

Thanks to Raymond-Voogt and xet7 !

Fixes #6824.
2026-10-27 21:12:45 +02:00
Lauri Ojansivu e9440d503e Recurring cards: an index for the hourly job.
server/cardRecurrenceSchedule.js runs every hour and asks for the cards
with a recurrenceInterval ($exists: true, not null or 'none'). There was no
index on the field, so the query read every card in the database to find
the few that recur. One instance logged it as a slow query: COLLSCAN,
1,108,017 documents examined, 1,155 ms.

A partial index on recurrenceInterval holds only the cards that have one,
the same way activities indexes commentId and attachmentId. The job's
$exists: true lets MongoDB use it; tests/recurrenceIndex.test.cjs pins both.
From the reporter's patch.

Thanks to Raymond-Voogt and xet7 !

Fixes #6825.
2026-10-27 21:12:45 +02:00
Lauri Ojansivu 66b4ad8dcb CHANGELOG / TODO Later: the unverified browser runs, WebKit, and five behaviours that need a maintainer decision.
Thanks to xet7 !
2026-10-27 21:01:51 +02:00
Lauri Ojansivu aae0490b71 CHANGELOG: FrameBleed follow-up - a 304 kept a file's own CSP - and the Scrum, Sesotho and drag reorder specs.
Thanks to xet7 !
2026-10-27 20:59:24 +02:00
Lauri Ojansivu 4a656baea3 Tests: the Scrum, Sesotho and drag reorder specs read one list per side, and open the SMTP fields first.
Card Settings draws each row once per list, and its minicard list head is
empty; the Date section's card list starts with the fixed Mark as complete
row; the SMTP host field is shown only while email settings are enabled and
SMTP is chosen.

Thanks to xet7 !
2026-10-27 20:57:38 +02:00
Lauri Ojansivu e8a1106782 FrameBleed follow-up: a 304 Not Modified no longer replaces a stored file's own Content-Security-Policy.
Files are served private, no-cache, so every later view of an attachment,
avatar or thumbnail revalidates and gets a 304, and a browser updates the
stored response's headers with the 304's. The app-wide frame-ancestors 'self'
that FrameBleed's restore puts on every response therefore replaced the
file's own policy - an SVG's default-src 'none' and sandbox - from the second
view on. The SVG body is still sanitized on every serve, so this lost a layer
of defence rather than opening script execution. The app-wide framing headers
are now left off a 304, so the stored 200 keeps the policy its route chose; a
header a route set on the 304 itself is kept. Not recorded in Admin Panel /
Problems: it fires on ordinary revalidation, not on an attempt.

Thanks to xet7 !
2026-10-27 20:57:38 +02:00
Lauri Ojansivu a599e066b3 CHANGELOG: Apple Glass Pastel muted text, and the S3 settings spec.
Thanks to xet7 !
2026-10-27 20:46:59 +02:00
Lauri Ojansivu 9679aaf636 Apple Glass Pastel: muted text is darker, and the contrast spec waits for the theme.
The theme's quiet text, #6b7280, was 4.51:1 on #f7f7f7 and 4.44:1 on the
tinted card header, at or under the 4.5:1 it needs; #4b5563 is about 7:1.
The opened card and sidebar contrast tests measured right after a reload,
before the board's colour had reached the page, and so measured WeKan's stock
colours; they now wait for the theme class as the header test does.

Thanks to xet7 !
2026-10-27 20:32:47 +02:00
Lauri Ojansivu 13d3a74b3f Tests: the S3 settings spec expects both of its translated Save buttons.
The S3 pane has had two Save buttons since #6465, one under the Enabled and
Read switches and one after the connection fields; the spec matched one.

Thanks to xet7 !
2026-10-27 20:30:13 +02:00
Lauri Ojansivu 06e27909df CHANGELOG: #6820, and the oldest release moved to old-CHANGELOG/2026/10.md to stay under the size budget.
Thanks to mimZD and xet7 !
2026-10-27 20:26:11 +02:00
Lauri Ojansivu 66ee2d12f3 Card Settings: a ticked Draggable checkbox is a checkbox, not a dot.
forms.css gives every input the 9px 14px padding of a text field. The
Draggable table brought its native checkboxes back without removing it, so a
16px box left the browser about 2px to draw in and a ticked one was a small
blue dot beside an unticked square. The same was true of the WIP limit groups
popup and of task-list checkboxes in a description. All three have no padding
now, and a test fails on any stylesheet that brings back a native checkbox
with it.

Thanks to mimZD and xet7 !

Fixes #6820.
2026-10-27 20:25:48 +02:00
Lauri Ojansivu 9e47a188ce CHANGELOG: the browser test run's fixes - opened card, editors, visibility, drawing, and the specs.
Thanks to xet7 !
2026-10-27 20:14:39 +02:00
Lauri Ojansivu a8f65adff2 Tests: browser specs follow deliberate changes, and their fake SMTP server waits for its port.
LinkedWriteBleed over REST is refused and recorded but no longer disables the
account (#6798); notification intents are seeded recent, past the expiry of
#6768; quoted custom field values; the Login and Windows SSO forms each have
a Save; the Bots view under Rules; the popup stub has onCreated and helpers;
the board view panel fills the window first. An invite mail goes to a running
SMTP sink, a sink waits for a busy port, and the allowed AssignedBleed read
retries on a socket reset only. The first checklist is added with the title
line's +.

Thanks to xet7 !
2026-10-27 20:14:03 +02:00
Lauri Ojansivu 2929b61c85 Drawing: a new drawing fills the window it is shown in, also once maximized.
A new drawing was fitted to the popup before it was maximized and kept that
size. It is refitted whenever its room changes, until it is zoomed or resized
by hand.

Thanks to xet7 !
2026-10-27 20:14:03 +02:00
Lauri Ojansivu 69a8bae0c6 Editors: text typed right after a form opens is no longer selected and replaced.
50 ms after an inlined form opens it focuses the field and selects what it
holds, so the first key replaces a title being edited. Text typed or pasted in
those 50 ms was selected too, and the next key - Enter for a new line - left
only a line break. The selection is skipped when the field already changed.

Thanks to xet7 !
2026-10-27 20:14:03 +02:00
Lauri Ojansivu 978af29b33 Tests: the language specs read the option values, tabs, addresses and texts the app has.
The Gregorian option's value is gregorian; the date-changed triggers are on
the card triggers tab; the shared templates and security panes have their own
addresses; Shortcuts opens as a modal; the roles pane has two descriptions;
saved rule descriptions are lowercased; the feature list is FEATURE_KEYS long;
a plain member is not shown the Rules editor at all (#2217), and the server
still refuses its rule writes.

Thanks to xet7 !
2026-10-27 20:07:01 +02:00
Lauri Ojansivu e6f7d226ca Admin Panel / Attachments: the Google Cloud Storage key help is shown once.
A leftover copy of gcs-credentials-description, from before the English and
translated pairs, showed the same sentence twice under the key field.

Thanks to xet7 !
2026-10-27 20:07:01 +02:00
Lauri Ojansivu 437da1d9c7 Board visibility: the Private and Public descriptions are shown again.
The helper handed TAPi18n.__ over unbound; __ reads this, so it threw and both
descriptions of the visibility popup were an empty line in every language
since the custom descriptions of Admin Panel / Settings / Visibility. It is
passed bound, and a test fails on any TAPi18n.__ passed as a bare value.

Thanks to xet7 !
2026-10-27 20:07:01 +02:00
Lauri Ojansivu a4197f228f Tests: the Card Settings specs open the section a row is in, and pick the list by its side.
The popup opens on its Card section and draws only the open section's rows,
each once per list (Show on Card, Show on Minicard). The specs looked for rows
of other sections and matched a row twice. The linked timer spec reloads after
its database writes, so the board's spent time has reached the page.

Thanks to xet7 !
2026-10-27 20:05:44 +02:00
Lauri Ojansivu f852cb7032 Add card: the due date hint follows the board's switch while the form is open.
The hint was recomputed only on typing, so turning Due date from title off in
Board Settings / Card left a hint already shown.

Thanks to xet7 !
2026-10-27 20:05:44 +02:00
Lauri Ojansivu 8f9103c336 Opened card: it stays visible after Maximize and Minimize, or Collapse.
The class that keeps the window unpainted until it is placed beside its
minicard was a static class of the template, removed by hand once placed.
Blaze writes a tag's static classes again whenever its class attribute
changes, so maximizing and minimizing, or collapsing, brought it back and left
the card open but invisible. It is now a reactive class, off for good once the
window is placed.

Thanks to xet7 !
2026-10-27 20:05:44 +02:00
Lauri Ojansivu 6241be9053 Tests: the browser specs add a first checklist with the title line's +, where #6796 put it.
Since #6796 a card with no checklist has only the + of the Checklists title
line (a.add-checklist-top); the bottom a.add-checklist appears once there is
a checklist. The specs looked for the bottom one only, so every spec that adds
a card's first checklist failed, and 12-checklists skipped its checks silently.

Thanks to xet7 !
2026-10-27 19:45:29 +02:00
Lauri Ojansivu c438682700 CHANGELOG: documentation of Show empty fields, the [label] switch and Data health.
Thanks to xet7 !
2026-10-27 19:42:29 +02:00
Lauri Ojansivu 30e960e129 Docs: Show empty fields of Card Settings, the [label] title switch of Member Settings, and Data health repairs.
Thanks to xet7 !
2026-10-27 19:42:20 +02:00
Lauri Ojansivu 9bea13f152 CHANGELOG: #6818 Data health, and the shared-template spec fix.
Thanks to Raymond-Voogt and xet7 !
2026-10-27 17:08:29 +02:00
Lauri Ojansivu d9cf523f9e Admin Panel / Problems: Data health, a nightly scan of broken references, with repairs.
The scan counts what points at nothing: cards on a missing or another board's
list or swimlane, ghost and duplicate board members, boards with no active
admin, custom field values and boards that no longer exist, checklist items
without a checklist, and subtasks or linked cards whose card is gone. It runs
nightly when the CPU is low, claimed so that only one pod scans or repairs.

A repair that only moves data reuses the upgrade steps; one that deletes asks
twice, and the server refuses it without that confirmation. Every repair edits
with $pull or a guarded update, so a concurrent edit is not overwritten, and a
member dedupe keeps the admin entry. Legacy scalar boardId and string boardIds
of custom fields are understood.

Translations done with low confidence: ay, csb, lld, kok, wa-RR, ve-CC, bi, tpi.

Thanks to Raymond-Voogt and xet7 !

Fixes #6818.
2026-10-27 17:08:15 +02:00
Lauri Ojansivu c1c9d3fb03 CHANGELOG: #6808 follow-up, linked card fields, search fixes, browser spec seed and Tigre/Tsonga wording.
Thanks to xet7 !
2026-10-27 16:44:06 +02:00
Lauri Ojansivu 00cf8f0958 Tests: the shared-template language specs read the description beside the scope list, where it is.
The Corsican and Southern Sotho specs looked for .templates-description and
.shared-templates-hint inside ul#templates-setting, which holds only the three
scope checkboxes; both paragraphs are its siblings in .templates-settings.

Thanks to xet7 !
2026-10-27 16:44:06 +02:00
Lauri Ojansivu 125e1659f8 Search: attachments are found again, by has:attachment, by their filename, and has:-attachment / has:-checklist work.
Found by the Haitian attachment-search browser test. Every attachment
lookup of the global search read the attachment's card from a top-level
cardId, which attachments do not have - they name it in meta.cardId - so
has:attachment, a free-text match on a filename and attachment:<name>
found no card at all; tests/databaseQueryBounds.test.cjs had pinned that
projection. The negated predicates (has:-attachment, has:-checklist)
were ignored and returned the cards WITH one. Soft-deleted attachments
do not count for has:attachment.

Thanks to xet7 !
2026-10-27 16:39:00 +02:00
Lauri Ojansivu 6c2edd6752 Board search: one result highlighted at a time.
Stepping to the next result with Enter highlighted it while the last
one stayed lit for the rest of its three seconds, so two cards were
highlighted at once and it was not clear which was current - found by
the #2171 browser test. Revealing a result removes the highlight from
any other.

Thanks to xet7 !
2026-10-27 16:36:01 +02:00
Lauri Ojansivu 8788458a87 Tests: the browser specs' boards show every field, as the specs were written for.
The specs predate an opened card showing only the fields it has data
in: a checklist spec found no Add checklist on an empty card (and five
others skipped their assertions the same way). The seeded board turns
Card Settings / Card's Show empty fields on - what an opened card did
before - and the specs of the empty-field rules (card-add-fields) turn
it off.

Thanks to xet7 !
2026-10-27 16:35:24 +02:00
Lauri Ojansivu 1f5a7b8220 A linked card shows the fields its source card has.
An opened card shows the fields it has data in, but a linked card keeps
its data on its source card - members, labels, dates, custom fields,
checklists - and the decision read the linked card's own, empty
document: a linked card hid every field of its source. Found by the
#1946/#6613 browser test, whose source member's avatar was missing.

shownFieldsOf() takes the data, and the counts of checklists, subtasks,
text notes, attachments and comments, from the source card, and the
card's own choices (On this card) from the card opened. The opened
card, its sections and Card Settings' On this card all decide through
it. tests/linkedCardShownFields.test.cjs runs it.

Thanks to xet7 !
2026-10-27 16:34:50 +02:00
Lauri Ojansivu 036ac4f56b #6808 follow-up: a card deleted elsewhere also leaves the card's address.
Found by the #6808 browser test: when an opened card really is deleted,
its window is destroyed with its data - usually before the server has
answered whether the card is gone - and the confirmation was dropped
for a destroyed window, so the window closed but the address stayed
the card's. The question is asked outside the window now
(confirmGoneCard, waiting for a settled connection and login), and the
window's end asks it too when the card is missing; the close
(closeGoneCard) clears the window, the popup and the address. A card
that only left for a reconnect still answers available and stays.

Thanks to brlin-tw and xet7 !
2026-10-27 16:32:52 +02:00
Lauri Ojansivu c7145402af Translations: Tigre card noun in the Retention help line of #6819.
The Retention help text came with Tigrinya's bare ካርድ for card; Tigre
says ወረቀት ካርድ, which tests/tigreAllBoardCardContexts.test.cjs holds.

Thanks to xet7 !
2026-10-27 16:30:59 +02:00
Lauri Ojansivu f615d06e47 CHANGELOG: StampBleed, UploadBleed, the regression audit, #6817 and #6819.
Thanks to mimZD, Raymond-Voogt and xet7 !
2026-10-27 16:28:52 +02:00
Lauri Ojansivu ebcd02be6f Hall of Fame regression audit: every fixed vulnerability still fixed, siblings of six of them closed.
Every Hall of Fame vulnerability without a test naming it (24 names, 30
advisories) was checked against the current source. No fix had been
reverted. Looking for the same mistake elsewhere found siblings:

- FloppyBleed: the fs-path-heal upgrade step joined an attachment's
  stored name into candidate paths as-is; a record a client inserted
  named ../../../etc/passwd made the next upgrade copy that file into
  storage - an arbitrary file read. Only the name's last component is
  used now; the bulk move limits a legacy name's extension the same way.
- FilePathBleed (GHSA-g6vm-7757-pr88): the insert guard looked only
  inside versions; with versions left out ostrio:files falls back to a
  top-level path, which removing the record deletes and validation
  reads. A client's top-level path is refused for attachments and
  avatars, recorded by the existing canaries.
- MoveStorageBleed: validateAttachment, which deletes a file failing the
  current upload rules, was open to anybody who could see the board, a
  public one included. Only who may change the file, or a site admin,
  validates now; a refusal is recorded (authz.attachment-validate).
- HookUrlBleed / IntegrationBleed: the REST reads of a board's
  integrations gave the webhook URL - whose path is its secret - to
  every member reading the board; non-admins get what the publication
  gives them.
- AttachmentMigrationBleed: four migration methods answered any
  logged-in user about any board, deleteDuplicateEmptyLists.getStatus
  with a private board's list titles; they require a visible board and
  record refusals (authz.migration-board).
- LegacyAttachBleed (GHSA-xcxp-hx9w-q5q9) and MimeStorageBleed
  (GHSA-rv5g-5p4g-3v89): the legacy route judged a name dangerous only
  for .svg, the response policy missed x-javascript, ecmascript, xsl and
  +xml types, and the sniffers missed <tag/onx= handlers.

OIDCBleed refusals are now recorded in Admin Panel / Problems too. New
suites name the vulnerabilities they guard, existing ones name the
advisories they already covered, and tests/securityRegressionCoverage
counts 128 of 137 published vulnerabilities with a named test (was 125).

Thanks to xet7 !
2026-10-27 16:28:12 +02:00
Lauri Ojansivu 2799eb900f Admin Panel / Attachments / Retention: readable labels, and the default shown.
The Retention page (#6789) showed only the variable names
(ATTACHMENTS_RETENTION_DAYS, ...), the raw choices uploaded and
card-inactive, and Default () with nothing between the brackets
whenever the variable was set but empty - which is what a Helm chart or
a docker-compose file listing the variable does.

Catalog fields (models/lib/authConfigCatalog.js) can name a labelKey, a
helpKey and choiceLabelKeys: the form shows the translated label with
the variable name beside it, a help line, and the choices by name.
Fields without them look as before. An empty variable is unset for the
value shown, as the retention code itself reads it, so the default is
shown again; yes/no defaults are translated. The five retention fields
use them; seven new strings in every language (low confidence for the
rarest and constructed ones). From the reporter's patch.

Thanks to Raymond-Voogt and xet7 !

Fixes #6819.
2026-10-27 16:24:58 +02:00
Lauri Ojansivu ccc67ddf68 Show on Card and an empty field: already handled by Show empty fields, Show empty and On this card.
#6817: on v12.65 the reporter ticked Due in Board Settings / Card's
Show on Card and an opened card without a due date showed no Due field.
Show on Card says what a card can show; since v12.65 an opened card
shows a field when it has data. Showing an empty field is done since
#6806 (Upcoming) by Card Settings / Card's Show empty fields, the Show
empty column, or + Add / On this card. The test pins the reporter's
board through each, and that Show on Card off still wins.

Thanks to mimZD and xet7 !

Fixes #6817.
2026-10-27 16:14:34 +02:00
Lauri Ojansivu 51111e1958 StampBleed and UploadBleed: documents on one's own card stamped with another board, and unauthorized attachment uploads.
StampBleed (found reviewing the patch for #6813): checklists, checklist
items and text notes carry a denormalized boardId, by which the board
publication publishes them, and items a checklistId, by which a
checklist draws them. The insert allow rules check write access on the
document's card only, and the server's insert hooks kept a boardId the
client sent. So a member of any board could insert, on their own card,
a document stamped with a private board's id - published to that
board's members - and an item with that board's checklist id, drawn in
it. 4bd6a7f088 made the server take boardId from the card; now the
forged insert is refused outright and recorded, for those three and for
a direct attachment insert with a foreign meta.boardId. WeKan's own
client never sends a mismatch.

UploadBleed: Meteor-Files' upload pipeline (_FilesCollectionStart_
attachments / Write and its HTTP twin) inserts the stored attachment
SERVER-side, so the allow rules were never asked, and the attachments'
onBeforeUpload only cleaned the file name: any DDP client, signed in or
not, could put a file on any card of any board - published to its
members with an addAttachment activity and its notifications - and the
admin's uploads-blocked switch was skipped too. The upload is
authorized on every chunk now: signed in, allowed to edit the card (or
write the board, for a background or map image), uploads not blocked,
and a card attachment's board, list and swimlane are its card's.

Both are recorded in Admin Panel / Problems (authz.card-child-stamp,
authz.attachment-upload), once per upload. tests/stampBleed.test.cjs and
tests/uploadBleed.test.cjs run the real decisions, and fail on a card
child with no insert board check or a FilesCollection without a server
upload check; upload-stamp-bleed.e2e.js attacks the running server.

Thanks to xet7 !
2026-10-27 15:38:58 +02:00
Lauri Ojansivu eade07c914 CHANGELOG: #6809, #6810, #6811, #6812, #6813, #6814, #6815 and #6816.
Thanks to brlin-tw, jullbo, Raymond-Voogt and xet7 !
2026-10-27 15:23:55 +02:00
Lauri Ojansivu 595e26cfa8 Translations: Tigre and Tsonga wording of the [label] switch of Member Settings.
The new texts of f89c8f8030 took Tigrinya's word for name (ስም) in Tigre,
which has its own (ስሜት), and repeated Tsonga's nhloko-mhaka (title)
three times in one sentence - the shape the guards against generated
filler catch. tests/tigreAllEstablishedNounContexts.test.cjs and
tests/wrongLanguageCorrections20261014.test.cjs pass again.

Thanks to xet7 !
2026-10-27 15:23:55 +02:00
Lauri Ojansivu 32031c64c4 Upgrade: cards moved to another board before #5874 are shown again.
#5874 fixed new cross-board moves (server/lib/cardBoardConsistency.js),
but a card moved before that fix can still point at a list of the board
it LEFT. Every view of its own board selects cards by that board's
lists, so the card is shown nowhere, and swimlane-structure's
dangling-listId rescue does not see it because the list exists. One
production database (1.1 million cards) had 706 such cards on 383
boards, 391 not archived.

A startup upgrade step, card-list-other-board, gives each such card its
own board's first unarchived list (and that list's swimlane when it is
bound to one), or a Rescued Data list when the board has none. Cards are
streamed with two fields, one updateMany per (list, board) pair,
idempotent. From the reporter's patch, with one change: the step runs
BEFORE swimlane-structure, so a rescued card whose new list is not bound
to a swimlane - it kept the swimlane of the board it left - is put on a
swimlane of its board in the same pass, not hidden in the Swimlanes view
until the next release re-runs the checks.

Thanks to Raymond-Voogt and xet7 !

Fixes #6814.
2026-10-27 15:09:38 +02:00
Lauri Ojansivu f2908f34e7 Card numbers stay unique: the server checks the number a browser sends.
#5813 / #4743 made server-side card numbers atomic with a per-board
counter, but a card added in the browser - the add-card form, a card
from a template, a link, a checklist item turned into a card - still
brings the number the browser computed: the highest among the cards it
has, plus one. With lazy card loading the browser has only the cards in
view, so it reissued numbers other cards already carried, and the
counter never saw them, so the server reissued them as well. One
production database had 842 numbers used by two or more open cards.

Cards.before.insert on the server: a number another card on the board
already has is replaced from the counter, after raising the counter to
the board's highest number; a free number the counter did not issue
raises the counter to it. A board with no counter yet is seeded from its
highest number first, as getNextCardNumber's lazy seed does - the
reporter's patch created it from the browser's number alone, which can
be below numbers outside that browser's view, and the seed would then
never run. 0 / missing stays no number. Existing duplicates are not
renumbered: a number may be written down elsewhere.

Thanks to Raymond-Voogt and xet7 !

Fixes #6815.
2026-10-27 15:08:21 +02:00
Lauri Ojansivu 742996dcce Deleting a checklist deletes its items.
Every way to delete a checklist - the card's checklist menu, DELETE
/api/boards/:boardId/cards/:cardId/checklists/:checklistId, the
removeChecklist rule action and the durable rule plans - removed only the
checklist document; Checklists.before.remove writes the removeChecklist
activity and nothing else. Every item stayed in the database with a
checklistId pointing nowhere: invisible on the card and never cleaned
up. One production database had 2335 of them from 690 deleted
checklists.

Checklists.after.remove now removes the checklist's items with
ChecklistItems.direct.removeAsync, as deleting a card already removes
its children: no activity per item, the checklist's own removeChecklist
activity records the deletion. Items orphaned before this are left for
the administrator to decide. From the reporter's patch.

Thanks to Raymond-Voogt and xet7 !

Fixes #6816.
2026-10-27 15:06:32 +02:00
Lauri Ojansivu f89c8f8030 Add card: [Text] at the start of a title becomes a label only when turned on in Member Settings.
Since #3986 a new card's title starting with [Text] followed by more
text lost the prefix and got the label Text, created on the board when
it had none - for everybody, without a hint. Titles pasted from ticket
systems, mails and monitoring tools ([Follower] #123456 - ...,
[EXTERNAL] ...) were changed and filled the board with unwanted labels.

It is a Member Settings switch now, Turn [label] at the start of a new
card title into a label (profile.titleBracketLabel), off by default: a
title is kept as typed. Who wants #3986's behaviour turns it on. Both
texts are translated in every language; low confidence for the
constructed and rarest ones (tlh, vo and some 35 others).

Thanks to jullbo and xet7 !

Fixes #6810.
2026-10-27 15:04:31 +02:00
Lauri Ojansivu 6f5aab875b Date popups: Save without picking a day saves today instead of Invalid date.
A date popup of a card without that date opens the calendar on today,
marks today (#6751) and names it in the calendar's heading - so today
is what the form shows. But the form's date field was empty, and Save
without clicking a day answered Invalid date.

The form starts from today now (draftDate in
imports/lib/datePickerTime.js): the date field, the calendar's selected
day and its heading agree, and Save saves today at the popup's default
time (17:00 for a due date, #1502). A card's own date is kept as before,
and an emptied date field is still refused.

Thanks to brlin-tw and xet7 !

Fixes #6809.
2026-10-27 14:56:21 +02:00
Lauri Ojansivu 4bd6a7f088 Dragging a checklist item on one's own board is no longer reported as a cross-board attack.
Dragging a checklist item within its card sets cardId and checklistId
to where the item already is. The ChecklistBleed deny rule
(GHSA-gv8h-5p3p-6hx7) checked only that the destination board was
writable, so for a comment-only member or a Worker - whose own board is
one they cannot write to - an ordinary drag tripped the high-severity
canary checklist-item.cross-board-move and named them in Admin Panel /
Problems / Security as if they had attacked another board.

The board the document is already on is no destination now. That board
is the one of its SOURCE CARD, which the allow rule decides on - not
the document's own denormalized boardId, as the reporter's patch read
it: the insert hooks kept a boardId the client sent, so an item on the
attacker's card could carry a victim board's id and would have passed
as staying while moved onto a victim card. Those hooks (checklists,
checklist items, and now text notes on the server) set boardId from the
card always. A move to another unwritable board, and an unknown
destination, are still denied and still reported.

Thanks to Raymond-Voogt and xet7 !

Fixes #6813.
2026-10-27 14:52:06 +02:00
Lauri Ojansivu 2dfdf77c2b Tests: the Card Settings drag and drop spec turns drag handles on and off for its user by user.id.
The fixture's user has an id, not an _id: setHandles(user._id, ...) of
8224852de1 matched no user, so the handle half of the spec never had
drag handles on.

Thanks to xet7 !
2026-10-27 14:52:06 +02:00
Lauri Ojansivu f6fd560817 Upgrade: boards with a Show on card custom field keep it on their minicards.
Custom fields on minicards became a Board Settings / Card switch,
allowsCustomFieldsOnMinicard (1d4ffd4080), opt-in, with false written
into every board. Before it, a custom field marked Show on card was
always drawn on the minicard, so after the upgrade those fields
disappeared from every minicard until a board admin found the switch -
reported as custom fields being visible only after opening the card.

A startup upgrade step, custom-fields-minicard-on, turns the switch on
for each board that has at least one custom field with showOnCard: that
board's own earlier choice. Boards without one keep the opt-in default.
Once ever, like cover-on-card-on: it records itself in _wekan_migration,
so a board turned off afterwards stays off on later upgrades. The test
db's distinct() returns array elements one by one, as MongoDB does.
From the reporter's patch.

Thanks to Raymond-Voogt and xet7 !

Fixes #6812.
2026-10-27 14:48:19 +02:00
Lauri Ojansivu a24da2a039 Minicard: only the custom fields that have a value, as before.
Until the minicard's sections were rewritten (1d4ffd4080), a minicard
drew a custom field marked Show on card only when it had a value - the
2019 check of e2a374f0aa. The rewrite dropped it, so every field a card
carries was drawn, also the ones added empty by Automatically on card /
Always on card: boards with several such fields got a stack of empty
rows on every minicard.

hasCustomFieldValue() in models/lib/customFieldsWD.js: null, undefined,
an empty or blank string and an empty multi-select are empty; 0 and an
explicitly unticked checkbox are values (the old check hid 0). The
minicard draws a field only when it has one; the opened card is
unchanged. From the reporter's patch.

Thanks to Raymond-Voogt and xet7 !

Fixes #6811.
2026-10-27 14:47:25 +02:00
Lauri Ojansivu d05e4db865 CHANGELOG: Card Settings drag and drop lists, and #6808.
Thanks to brlin-tw and xet7 !
2026-10-27 10:05:27 +02:00
Lauri Ojansivu 7ed3ca7c5c A card opened from its link stays open when the connection drops and comes back while the page loads.
Behind a reverse proxy the DDP connection can drop and come back while a
card's link is still loading (a WebSocket upgrade the proxy refuses,
SockJS falling back). Every subscription re-runs on the way back, one
answers before the resumed login with nothing, and the card leaves
Minimongo for a moment. #3114's close-on-removal took that moment for a
deletion: it closed the card and went to the board's address.

The window now waits while the subscription, the connection or the login
is settling, and when the card looks gone with everything settled, asks
the server (cards.openCardAvailable, the 'card' publication's own rule,
now one function in server/lib/openCardAccess.js) and closes only on its
no, if Minimongo still agrees. A card really deleted, archived or moved
elsewhere still closes. Not reproduced here: no proxy in this
environment; the browser test drops and restores the connection.

Thanks to brlin-tw and xet7 !

Fixes #6808.
2026-10-27 10:05:01 +02:00
Lauri Ojansivu 8224852de1 Card Settings: a Show on Card and a Show on Minicard list per section, reordered by drag and drop again.
On 2026-10-25 each row became one line with both sides' cells and order
arrows. But the lines could follow only one side's order, and the
number beside an arrow was the field's place among ALL of that side's
fields: an arrow often changed the number and nothing visibly moved,
the minicard's arrows never moved a line, and an arrow that did nothing
was not disabled. Drag and drop, which the arrows had replaced, had been
there since 2026-10-02.

Each section has the two lists again, each in its own order and
reordered by drag and drop: Show on Card - with On this card and Show
empty beside each row - and Show on Minicard. A row is dragged by its
handle (drag handles on) or its icon or name; Up and Down on a focused
one move it one row in the list. A dropped row goes right after the row
now above it in the side's whole order, through placeKey, so a pinned
header stays first and a section moves as one. The arrows and their
numbers are gone.

Thanks to xet7 !
2026-10-27 10:05:01 +02:00
Lauri Ojansivu 8d61402f5b CHANGELOG: Ask for a reason and the select buttons in Card Settings.
Thanks to xet7 !
2026-10-27 09:17:10 +02:00
Lauri Ojansivu 37b3c8f599 Card Settings: Ask for a reason is the same checkbox as Show empty fields, and Select all / Select none are Draggable's buttons.
Ask for a reason when moving cards was a Font Awesome square a few
pixels off Show empty fields above it, its classes toggled by hand. It
is the same animated checkbox now, in the same place, read reactively
from the board and set by a board admin. Select all and Select none
under the columns' headings were links; they are the Draggable table's
buttons - white text on the theme's colour - in its container.

Thanks to xet7 !
2026-10-27 09:17:00 +02:00
Lauri Ojansivu 9b65aed902 Merge remote-tracking branch 'origin/main' 2026-10-27 08:30:03 +02:00
Lauri Ojansivu 37e9253c93 Merge pull request #6807 from ocdtrekkie/sandstorm-org
Switch Sandstorm build to new fork
2026-10-27 08:23:40 +02:00
Jacob Weisz 3f718859bc Switch Sandstorm build to new fork 2026-10-27 01:09:09 -05:00
Lauri Ojansivu 831b2bd8b5 CHANGELOG: Card Settings Select all and Select none, aligned columns.
Thanks to xet7 !
2026-10-26 16:40:33 +02:00
Lauri Ojansivu c8a15b514f Card Settings: Select all and Select none per column, headings aligned with their cells, labels whole.
Under each column heading of every pane - On this card, Show on
Minicard, Show on Card, Show empty - Select all and Select none set
that column for the open section's rows at once, each the way its own
checkbox would: On this card in one write of the card, Show empty in
one write of the board's list, the Scrum rows in one Scrum write (one
by one, each would carry the same revision), the others through their
own checkbox's handler. Who may change the column gets the buttons; a
personal row (Labels text, List title) is left as it is.

The headings drifted left of their cells, further each column, and the
labels were cut: the heading line had the smaller font, and the columns
are in em, so its columns were narrower than the rows'. The font is on
the headings now, every heading and cell starts at its column's start,
and the popup is up to 1200px wide instead of 900px, so a row's whole
label fits where the window has the room.

Thanks to xet7 !
2026-10-26 16:40:14 +02:00
Lauri Ojansivu c687fe5577 CHANGELOG: Card Settings opens on Card, Show empty fields, Show empty.
Thanks to jullbo and xet7 !
2026-10-26 16:23:01 +02:00
Lauri Ojansivu 1aa9df6075 Card Settings: opens on Card, Show empty fields for the board, and the Show empty column.
Card Settings opens on its topmost entry, Card - also from + Add,
which opened on the first section of fields. Under the Card pane's
title, Show empty fields (the board's showEmptyCardFields, off by
default): ticked, every card of the board shows all its fields, the
empty ones too, and the Show empty columns are hidden - by CSS only, so
their ticks stay saved and come back as they were when it is unticked.
On this card still hides a field from one card, and Show on Card off
still hides it from all.

The Always show column is called Show empty; it is stored as before, in
cardFieldsAlwaysShown. Both headings are translated in every language.

Thanks to jullbo and xet7 !

Fixes #6806.
2026-10-26 16:22:29 +02:00
Lauri Ojansivu cca9ce36cf CHANGELOG: Always show in Board Settings / Card.
Thanks to jullbo and xet7 !
2026-10-26 15:33:30 +02:00
Lauri Ojansivu b54525abbe Board Settings / Card: Always show, for every field - the description, attachments and comments by default.
Show when empty became Always show and covers every per-card field,
the description, attachments and comments included: those were always
shown by a fixed list, and are now the default ticks of the board's
cardFieldsAlwaysShown. A board that never set it shows them as before;
unticked, they are shown when the card has a description, an
attachment or a comment, like any other field. The board's list is
exactly what is ticked once set, an empty list too. On this card still
hides an always-shown field from one card.

The first name, cardFieldsShownEmpty, was never pushed, so there is
nothing to migrate. tests/cardFieldsAlwaysShown6806.test.cjs pins the
default, unticking the four, the storage and the column; the browser
test unticks Comments.

Thanks to jullbo and xet7 !

Fixes #6806.
2026-10-26 15:33:12 +02:00
Lauri Ojansivu 5056790c05 CHANGELOG: Show when empty in Board Settings / Card, and the heading colour guard.
Thanks to jullbo and xet7 !
2026-10-26 15:06:52 +02:00
Lauri Ojansivu 80b936223f Board Settings / Card: Show when empty, the fields every card of the board shows even while empty.
Since an opened card shows only the fields it has data in, a board where
nearly every card gets labels, a due date and a checklist needed + Add,
the section and On this card for each new card before a field could be
filled in. The new Show when empty column of Board Settings / Card (and
of + Add, the same popup) is the board's list, cardFieldsShownEmpty: a
ticked field counts like data and is on every card from the start. On
this card still hides it from one card, Show on Card off still hides it
from all, and the four always-shown fields have no such checkbox.

It is a board admin's setting, stored normalised by
setCardFieldShownEmpty, and translated in every language.
tests/cardFieldsShownEmpty6806.test.cjs pins the rule, its storage, the
column, and fails on any caller of isShown or cardFieldsAfter that does
not pass the board; card-add-fields.e2e.js ticks it in the browser.

Thanks to jullbo and xet7 !

Fixes #6806.
2026-10-26 15:05:10 +02:00
Lauri Ojansivu 21b2da9ad2 Tests: the title heading colour guard reads the heading block itself.
It read from the LAST comment in minicard.css and cardDetails.css to the
end of the file, so it passed only while the heading rules' neighbours
happened to set no colour - and failed once the Card kind check mark,
appended at the end of cardDetails.css with its own colour, came after
them. It reads the heading block, from its comment to the blank line
after it, and still fails on a colour there.

Thanks to xet7 !
2026-10-26 15:05:09 +02:00
github-actions[bot] 43c8606548 Bump versions for v12.65 2026-10-25 16:13:18 +00:00
Lauri Ojansivu e5ed5d16dc Prepare v12.65 release 2026-10-25 18:12:06 +02:00
Lauri Ojansivu 213af1c1bd CHANGELOG: Custom Fields forms in the pane, Card kind shows the selected one, #6805.
Thanks to mimZD and xet7 !
2026-10-25 18:02:36 +02:00
Lauri Ojansivu ca53019d96 Card Settings / Scrum settings and Card kind popup: the selected card kind is shown.
A check mark at the end of its line, bold, and aria-checked in a radiogroup.
Before, the selected kind only had an is-active class that nothing styled.

Thanks to xet7 !
2026-10-25 18:02:14 +02:00
Lauri Ojansivu 37024aa9f9 Right-to-left: the comment form's avatar is inside the card.
The comment form keeps 38px free for the writer's avatar, which is drawn
at the reading start (inset-inline-start: -38px) - but the room was a
physical LEFT margin, so on a right-to-left page (Persian in the report)
the avatar was drawn on the right, outside the card. The room is
margin-inline now, and the other margins of the comments' layout are
logical too, so all of it mirrors with the page.
tests/commentFormRtl6805.test.cjs pins the avatar to the room kept for it
and fails on any left/right-only margin or padding in comments.css.

Thanks to mimZD and xet7 !

Fixes #6805.
2026-10-25 17:59:52 +02:00
Lauri Ojansivu f33987931f Card Settings / Custom Fields: Create Field and Link fields to another card are forms in the pane, not popups.
Under Edit custom fields, the Custom Fields pane has the two forms its
buttons opened: Create Field (also from Board Settings, for a board
admin) and Link fields to another card. In the pane the list's own two
buttons are not repeated; a new field saved there empties the form for
the next one, and Card Settings stays open. Opened as their own popup -
the card's Custom Fields hamburger - they step back when saved, as
before (client/lib/popupBack.js, now for a form drawn by more than one
popup, and telling whether it stepped back).

Thanks to xet7 !
2026-10-25 17:59:52 +02:00
Lauri Ojansivu 80d0b2c47e CHANGELOG: Card Settings pane columns line up.
Thanks to xet7 !
2026-10-25 17:34:54 +02:00
Lauri Ojansivu ede0d68b27 Card Settings: the pane's columns line up, and an inline form's buttons no longer push the rows aside.
Each line of a section pane is its own grid, and its side columns were
sized by their content, so no two lines - nor the heading line - agreed.
They have fixed widths now (On this card 7em, Show on Minicard and Show
on Card 9em each), a longer heading wraps in its column. The inline forms
float their Save left and Delete right (Change spent time, ...); the form
keeps those floats inside it (flow-root), where they had pushed the
heading and the first row aside.

Thanks to xet7 !
2026-10-25 17:34:52 +02:00
Lauri Ojansivu a433d20189 CHANGELOG: Card Settings sections and + Add as one popup, card actions in their sections, the card menu's order.
Thanks to xet7 !
2026-10-25 17:31:02 +02:00
Lauri Ojansivu c69f3fe25a Card Settings: Card is the first entry, Subtasks and Planning Poker are sections of their own, and each section's settings are forms in its pane.
- The first entry is Card, with the card icon: Ask for a reason when
  moving cards, the Draggable table, and the card's own rows that were
  the last entry (Card number, Collapse, Parent card, ...).
- Subtasks is its own section under Checklists, Planning Poker its own
  under Voting.
- Each section's settings are the form itself under the pane's title,
  not a link to a popup: Subtask settings, Edit custom fields, Change
  spent time, Card kind, Edit voting, Edit Planning Poker. Saved in the
  pane Card Settings stays open; the section hamburgers of the opened card
  still open the same forms as their own popup, which step back when saved
  (client/lib/popupBack.js).

Thanks to xet7 !
2026-10-25 17:30:37 +02:00
Lauri Ojansivu 65621f44cc Card actions menu in the maintainer's order; Card Settings: Time under Date, Edit voting and Edit Planning Poker in the Voting pane, Select all centred under Draggable.
- The opened card's actions menu is one list, in this order: Move Card,
  Copy Card, Save as Template, Copy Template to Many Cards, Card
  recurrence, Move to Top, Move to Bottom, Watch, Star | Set color,
  Drawing | Copy link to clipboard, Link to board, QR code, History,
  Export, Import, Move Card to Archive, More. Each item keeps the
  permission it had; the two copies of the menu for the two kinds of
  member are one list (cardMenuWrites). tests/cardActionsOrder.test.cjs.
- Card Settings: Time is under Date in the left menu.
- Card Settings / Voting: Edit voting and Edit Planning Poker are the forms
  themselves, under the pane's title; saved there, Card Settings stays
  open, saved in their own popup (the section hamburgers) they step back.
- Select all / Unselect all are centred under their Draggable heading.

Thanks to xet7 !
2026-10-25 17:13:36 +02:00
Lauri Ojansivu 4f20f24201 Card actions in their sections: Filter: Subtasks, Edit voting, Edit Planning Poker, Change spent time and Card kind in the opened card's section hamburgers and in Card Settings' panes.
- The five leave the card's actions menu. On the opened card each is in
  the hamburger of its section: Subtasks (Filter: Subtasks, and the
  subtask settings), Voting, Planning Poker, Spent time, and a new Scrum
  settings heading over the Scrum fields the card shows (Card kind).
  Voting, Planning Poker and Spent time show their heading when ticked
  "On this card" before there is anything in them, so the hamburger can
  fill them in.
- Card Settings: everything in a section's hamburger is also at the top
  of that section's pane, under its title - the card's actions when it
  is opened from a card, Subtask settings also from Board Settings.
  tests/cardSectionActions.test.cjs fails for a hamburger that is not.
- Filter: Subtasks is one function, client/lib/filterSubtasks.js.
- Card Settings: no select-all rows for Show on Minicard and Show on Card
  in its first entry, where there is nothing under them; the popup is
  high enough for every entry of its menu.

Docs: docs/Features/Cards/Card-Fields.md.

Thanks to xet7 !
2026-10-25 16:55:03 +02:00
Lauri Ojansivu 20e2273c07 Board Settings / Card and the opened card's "+ Add" are one popup: sections in the Admin Panel's left menu, each row with Show on Minicard, Show on Card and On this card.
- Board Settings / Card is the Admin Panel's left menu and pane. The menu
  lists the sections (models/lib/cardSettingsSections.js): Card Settings
  first (Ask for a reason when moving cards, the select-all of each side,
  Draggable), then Date, Members, Labels, Description, Checklists,
  Attachments, Time, Custom Fields, Voting, Scrum settings, Comments and
  Card. Every row of the table is in exactly one section.
- The pane shows the open section's rows, one line each, in the reading
  order On this card, Show on Minicard, Show on Card, the field: each side
  cell the board-wide checkbox, the field's position on that side and its
  up and down arrows (the Up and Down keys on a focused arrow too; the
  maintainer's choice, replacing the drag and drop of the two long lists).
  Every checkbox is the same animated one, and only the one just clicked
  animates.
- The opened card's "+ Add" opens this same popup for the card, with the
  "On this card" column; the separate Add popup is gone. A reader who may
  not change the board sees its checkboxes disabled.
- A row's name leaves out what its section says: under Scrum settings the
  rows are Sprint, Release, ..., not "Scrum settings: Sprint".
- The popup moves by its title bar, maximizes, is 900px wide (popupOffset
  and popup.css agree) and high enough for its menu.
- A maximized card's comments, at the top of the right column, have no
  separator line above them.
- New string "On this card" in every language. Low confidence: ace, ak, ay,
  bi, bm, bua, chr, csb, dz, ff, fj, gn, gv, iu, kl, ks, kw, lld, nah, nap,
  nso, om, pap, rn, rup, rw, sc, scn, se, sm, ss, st, tig, tlh, to, tpi, ts,
  ve, ve-PP, vo, wal, wo, wa-RR, zgh, nd, xh, zu, ja-HI.

Docs: docs/Features/Cards/Card-Fields.md and
docs/Features/Board-Sidebar/Board-Settings/Card.md. The Playwright specs of
the old two-list layout are rewritten for the sections and arrows.

Thanks to xet7 !
2026-10-25 16:12:19 +02:00
Lauri Ojansivu cef5ddc506 CHANGELOG: cover on by default, new drawing as cover, + Add popup, maximized card comments.
Thanks to xet7 !
2026-10-25 15:13:55 +02:00
Lauri Ojansivu 5d80b07bed Opened card: when maximized, the comments section is in the right column.
The comments are their own template, cardCommentsSection, in the left
column of the opened card and, on a maximized card, at the top of the
right one beside the fields; the right column is as wide as the left.

Thanks to xet7 !
2026-10-25 15:13:44 +02:00
Lauri Ojansivu c4f528159b Opened card: cover on by default, a new drawing is the cover, one line under + Add, no empty Similar cards or Custom Fields; the + Add popup moves, maximizes and fits its menu and labels.
- The cover image on the opened card is on by default. The schema default
  is true, and the once-ever upgrade step cover-on-card-on turns it on for
  the boards created while it was opt-in; a board turned off later stays
  off, also on an install that had no board yet.
- A new drawing becomes the card's cover; saving an edit leaves the cover.
- One separator line under "+ Add": the Sort group's gate read the board
  itself, not the card's view, and drew its rule over fields the card does
  not show.
- Similar cards: asked when the card opens, and the section is there only
  when there are some, with how many.
- Custom Fields: only on a card that has a custom field value or ticked
  Custom Fields in "+ Add" - which is now how a card with none gets the
  section and its menu.
- The "+ Add" popup moves by its title bar (and by the drag handle, shown
  only while drag handles are on, for every movable popup), has maximize /
  minimize beside its close button, is wide enough for the pane's labels
  and high enough for every menu entry, and a pane opened shows its ticks
  without animating them - only the clicked one animates.

Thanks to xet7 !
2026-10-25 15:11:03 +02:00
Lauri Ojansivu 0782bda994 CHANGELOG: Opened card shows only the fields it has data in, with + Add for the rest; archive one release to old-CHANGELOG.
Thanks to xet7 !
2026-10-25 14:00:30 +02:00
Lauri Ojansivu 6fe0a7ef94 Opened card shows only the fields it has data in, and "+ Add" under the cover adds the rest, in the Admin Panel's left menu and pane.
An opened card shows only the fields it has data in, plus its
description, attachments and comments, empty or not, and its cover when
it has one. "+ Add", under the cover, opens a popup with the Admin
Panel's own left menu and pane title: the sections (Date, Members,
Labels, Description, Checklists, Attachments, Time, Custom Fields,
Voting, Scrum settings, Comments, Card) on the left, the open section's
fields on the right - the items of Board Settings / Card - each with a
checkbox. Ticking an empty field shows it on this card; unticking a field
hides it from this card, also when it has data. The card keeps its
choices in shownFields and hiddenFields, which accept only field keys.

Board Settings / Card still decides what a card CAN show: a field turned
off there is on no card and not offered. The opened card and its section
templates get their own currentBoard, the card's view of the board with
the flags of the fields it does not show reading false, so the existing
template checks apply without being edited; the minicard is unchanged.

The design is docs/Features/Cards/Card-Fields.md. Seeded e2e cards show
every field, as the specs written before expect.

Thanks to xet7 !
2026-10-25 14:00:05 +02:00
Lauri Ojansivu 40dbb829c9 CHANGELOG: Drawing custom colors kept with the account when signed in.
Thanks to xet7 !
2026-10-25 12:10:05 +02:00
Lauri Ojansivu 91c12ebcce Drawing: custom colors are kept with the user's account when signed in, and in the browser on a public board without signing in.
The custom colors added to the Drawing popup's palette are stored in the
user's profile (profile.drawingColors, through the new setDrawingColors
method), so they follow the user to every browser. The method takes only
#rrggbb codes, each once, at most 16, and refuses a caller who is not
signed in; the schema holds only #rrggbb. Someone drawing on a public board
without signing in keeps them in this browser, as before.

Thanks to xet7 !
2026-10-25 12:09:52 +02:00
Lauri Ojansivu c486518f03 CHANGELOG: Drawing on the whole window with zoom and any color; Romanian, Tamazight and Urdu in their own languages.
Thanks to xet7 !
2026-10-25 08:56:46 +02:00
Lauri Ojansivu d5d027a6f2 Drawing: the whole window to draw on, growing from its corner, with zoom, any color and Save above it; Drawing in Add Card's Search / Template / Drawing / More options.
The drawing area takes all the room the popup has: a new drawing is as
wide and high as that room, instead of a fixed 800 x 500 in the middle.
A handle at its bottom-right corner (bottom-left right to left) makes it
larger, or smaller without cutting off a line, and the magnifier buttons
zoom it, scrolling when it no longer fits. A drawing keeps its size when
saved; older ones are 800 x 500. Save is above the drawing, at the end of
the tools.

Colors: clicking a color opens a color wheel, a brightness slider and the
HTML color code; custom colors are added to the palette, kept in this
browser; the eyedropper picks from the screen where the browser can
(EyeDropper API) and from the drawing elsewhere. Any #rrggbb is a color,
nothing else, so a color never becomes anything else in the SVG.

Add Card: Drawing is in the row Search / Template / Drawing / More
options, for one card, and opens the new card's drawing on the whole
window, saved as an SVG attachment.

New strings in every language. Low confidence: tlh, vo, la, chr, iu, kl,
nah, ay, qu, gn, zgh, tig, wal, om, bo, dz, bua, sah, cv, csb, szl, hsb,
rup, lld, fur, nap, scn, sc, co, ve-CC, ve-PP, wa, gv, kw, rm, ak, bm, ee,
ff, lg, rn, rw, ss, ts, ve, nso, tn, st, ny, sn, nd, wo, fj, sm, to, bi,
tpi, haw, pap, ks, uz-AR, ace, wa-RR, ary, yi, wuu-Hans.

Thanks to xet7 !
2026-10-25 08:56:20 +02:00
Lauri Ojansivu d3dead539b Translations: Romanian, Tamazight and Urdu in their own languages.
Romanian (ro 158 values, ro-RO 179): about 160 values each were Italian
("Sposta in cima", "rosso", "ti ha inviato un invito"), with some Spanish
("disparador", "carril") and French ("Connexion", "Erreur"); also cedilla
ş ţ replaced with comma-below ș ț.

Standard Moroccan Tamazight (zgh, 344 values): about 100 values were
Arabic and about 230 Kabyle or French in Latin letters; all now in
Tifinagh. Values already in Tifinagh, including reviewed ones with a
Latin product name, are kept as they were.

Urdu (ur, 2389 values): most of the file was English spelled letter by
letter in Urdu script ("منتقل کو توپ" for Move to Top - توپ is a cannon,
"سیزے" for Size) or English word order word for word; rewritten in
natural Urdu, with the stray "اردو:" prefixes removed.

Search keywords in these four locales are one word of letters, as the
query parser reads them; the underscore, space and zero-width non-joiner
forms could not be typed as operators.

Every placeholder matches English. tests/wrongLanguageCorrections20261025
pins each language, the placeholders and the search keywords; the guards
that pinned the old Arabic and Kabyle values say why they changed.

Low confidence: much of zgh (technical and statistics terms), Urdu
technical and Blockly terms, Romanian search keywords (culoar, termen,
verificare, câmppersonalizat).

Thanks to xet7 !
2026-10-25 08:27:47 +02:00
Lauri Ojansivu da4a0cf013 CHANGELOG: The Drawing lasso: select lines to move, resize, group and ungroup, and change their layer.
Thanks to mimZD and xet7 !
2026-10-25 07:40:48 +02:00
Lauri Ojansivu 1c26e1bcd0 Drawing: a lasso that selects lines to move, resize, group and ungroup, and to bring to front or back or one layer up or down.
A loop drawn with the Lasso selects every line wholly inside it, and a
grouped line brings its whole group. The selection moves together from
inside its box, stopping at the edge; resizes from the handle at its corner,
line widths to the nearest allowed one; groups and ungroups, the group kept
with the saved drawing; and goes to the front, the back, or one layer up or
down. Undo now steps back through every change, a move or a resize being one
step.

New strings in every language. Low confidence: ace, ak, ay, bm, bo, dz, br,
bua, chr, csb, cv, ee, ff, fj, fur, gn, gv, haw, hsb, ig, iu, kl, ks, kw, lg,
lld, nah, nap, nd, ss, ts, ve, ve-CC, ve-PP, qu, rm, rup, sah, sc, scn, se,
szl, tig, tlh, vo, wa, wa-RR, wal, wo, yi, zgh, uz-AR, ja-HI, om, sm, to, tpi,
bi, mi.

Thanks to xet7 !
2026-10-25 07:40:16 +02:00
Lauri Ojansivu 0390468e07 CHANGELOG: An SVG of any size is sanitized whole, streaming.
Thanks to xet7 !
2026-10-25 07:24:13 +02:00
Lauri Ojansivu e748559838 SVG of any size is sanitized whole, streaming: on upload, on validation and on every serve.
A streaming tokenizer (models/lib/svgSanitizer.js) replaces the regular
expressions that read an SVG whole: it removes script, foreignObject,
iframe/object/embed, SMIL set/animate, event handlers, javascript:/vbscript:
URIs after decoding character references, dangerous CSS, DOCTYPE/ENTITY XML
loops and processing instructions, wherever they are and however the file is
split into chunks, with bounded memory. UTF-16 is decoded and checked.

Upload sanitizing streams to a temporary file and renames it; validation
checks the whole SVG instead of rejecting any over its 1 MB scan; serving
pipes through the sanitizer with no 8 MB fallback to an unsanitized
download.

Thanks to xet7 !
2026-10-25 07:23:31 +02:00
Lauri Ojansivu 9e9e1e271f CHANGELOG: Drawing with a pen from Add Card and Add attachment, saved as SVG; the movable, maximizable Drawing popup; SVG as an image again.
Thanks to mimZD and xet7 !
2026-10-25 06:33:03 +02:00
Lauri Ojansivu 54726264d3 Drawing: a new drawing from Add Card and Add attachment, saved as SVG; an eraser and palm rejection.
#6802 asks for handwriting with a pen, saved as a card attachment, and
started while the card is created:

- Add attachment / Drawing, and Add Card's Drawing button (shown while
  the composer adds ONE card: the card is added, opened, and the drawing
  starts on it), open a NEW drawing on the whole window - the Drawing
  popup maximized, without the card's earlier drawings - saved as an SVG
  attachment of the card, its lines kept on the card for editing.
- The eraser takes away every line it touches (measured to the line's
  segments, its width included); Clear now has the trash icon.
- Palm rejection: once a pen has drawn, a touch does not, and while one
  pointer draws no other starts a line. A failed pointer capture no
  longer stops a line from starting.
- A Drawing popup opened from the card menu, which replaces the menu in
  the same popup, now opens maximized when it was left maximized.

The openers live in client/lib/newDrawing.js, which touches no template:
importing the popup's own module from attachments.js ran it before
cardDetails.jade had defined the template, and the client did not start.
Eraser is in every language. docs/Features/Cards/Drawing.md describes
it all. tests/cardDrawingPen.test.cjs tests the eraser and palm rejection
as pure functions and pins the new flows;
tests/playwright/specs/card-drawing-pen.e2e.js draws and saves both new
drawings to SVG attachments, erases a line and rejects a palm in a
browser.

Thanks to mimZD and xet7 !

Fixes #6802.
2026-10-25 06:32:28 +02:00
Lauri Ojansivu 0afb1336db SVG is uploaded and shown as an image: an attachment, a cover, a board background, an avatar.
Since JVN#74210258 (stored XSS) an SVG upload was refused and any stored
SVG was served as a download, so an SVG could not be a card's picture.
Every uploaded SVG is already sanitized on the server before it is
stored - no script, event handler, javascript: URI, foreignObject or
DOCTYPE/ENTITY XML loop (models/lib/sanitizeUploadedFile.js) - so the
refusal goes, for attachments and avatars.

Serving it inline is made safe in one place, server/lib/svgResponse.js,
used by every route that sends file bytes (universalFileServer.js for
attachments, avatars and legacy files, avatarServer.js,
legacyAttachments.js and httpStream.js): the WHOLE file is sanitized
again before it is sent, whatever is stored, and sent as image/svg+xml
under a Content-Security-Policy with default-src 'none' and sandbox, no
script, X-Frame-Options DENY and nosniff, so even opened directly as a
page it runs nothing. A download is sanitized too; a range request gets
the whole file; an SVG over 8 MB stays a sandboxed download. A file whose
active markup is removed on the way out is logged to Admin Panel /
Problems like the stream sanitizer's, and one removed at upload trips
the existing sanitize.dangerous-content canary.

tests/svgServing.test.cjs runs the real sanitizer on a crafted SVG
(script, onload, javascript:, DOCTYPE/ENTITY, foreignObject/iframe) and
checks the body and headers, the download, the oversized case, and that
no file-serving route in server/routes or httpStream.js sends bytes
without the SVG path, and that nothing else sends image/svg+xml;
tests/playwright/specs/svg-attachment.e2e.js uploads one, checks what is
served, that it shows in the card without running anything, and makes
it the cover and the board background.

Thanks to xet7 !
2026-10-25 06:32:25 +02:00
Lauri Ojansivu 4bea319239 Board Settings / Card calls the Whiteboard row Drawing, in its existing translations.
The row shows or hides the Drawing popup's Open as whiteboard button, and
is now named after the card menu's Drawing it belongs to, with that
entry's translations (cardDrawingPopup-title) and its pencil icon.

Thanks to mimZD and xet7 !
2026-10-25 05:41:29 +02:00
Lauri Ojansivu c11976a2e1 The Drawing popup moves by its title bar or a drag handle, and maximizes.
The card's Drawing popup stayed where it opened. Like the opened card it
is now dragged by its title bar or the drag handle beside the title, and
has the opened card's maximize / minimize button: maximized it fills the
window, and minimizing puts it back where it was dragged or resized to; a
maximized popup is not dragged; the drawing keeps its 800 x 500 shape so
the pen lands under the pointer; a popup left maximized opens maximized
again. The shared popup offers both by name (MOVABLE_POPUPS,
MAXIMIZABLE_POPUPS in popup.js), Board View settings staying movable.

The three new strings, Drag to move, Maximize and Minimize, are in every
language. tests/popupMoveMaximize.test.cjs pins the allow-lists, the
header controls, the restored place, the drawing's shape and the strings
in every locale; tests/playwright/specs/popup-drawing-window.e2e.js drags,
maximizes and minimizes it in a browser.

Thanks to mimZD and xet7 !
2026-10-25 05:40:46 +02:00
Lauri Ojansivu c6dfb95002 CHANGELOG: what FerretDB's changes since v12.64 mean for WeKan, and the SQLite conversion at first start.
The oldest release moves to old-CHANGELOG/2026/10.md to stay under the
size budget.

Thanks to xet7 !
2026-10-25 04:48:27 +02:00
Lauri Ojansivu 2c90b26401 CHANGELOG: the speed test report escapes a backslash before the pipe (CodeQL #593-#595).
Thanks to GitHub CodeQL and xet7 !
2026-10-25 04:29:16 +02:00
Lauri Ojansivu a90576bafa The speed test report escapes a backslash before the pipe in a table cell.
CodeQL js/incomplete-sanitization, alerts #593, #594 and #595:
docs/Databases/FerretDB/1/SpeedTest/report.cjs escaped `|` as `\|` in
Markdown table cells but left a backslash alone, so a value ending in a
backslash turned the added one into a literal backslash and its pipe
split the cell. One mdCell() helper escapes the backslash first, then the
pipe, and turns a line break into a space; it is also used for the
database's name and the query's title, which were not escaped at all.
tests/speedTestReportCells.test.cjs checks the escaping, that no value
can add a column to its row, and that nothing in the tree escapes a pipe
without the backslash. The report is generated from local speed test
results at build time, so there is no attempt to record in Admin Panel /
Problems.

Thanks to GitHub CodeQL and xet7 !
2026-10-25 04:29:05 +02:00
Lauri Ojansivu 4c8531589b CHANGELOG: conformance checks the $or, $elemMatch and long $in shapes.
Thanks to xet7 !
2026-10-25 03:59:43 +02:00
Lauri Ojansivu 18878f6f29 Conformance: $or with $elemMatch, an $or of array fields in an $and, and a long $in.
The shapes FerretDB's PostgreSQL and MySQL backends now answer in the
database - the visible boards and the due cards of the dueCards
publication, and an $in longer than the 32 values MySQL compares with
JSON functions - with negatives that must stay in Go (an operator in an
$elemMatch, a regex branch, a missing field, a long list of numbers).
SQLite, PostgreSQL, MySQL and MariaDB answer all 115 cases as MongoDB 8.

Thanks to xet7 !
2026-10-25 03:59:35 +02:00
Lauri Ojansivu ac8889e61c CHANGELOG: @azure/msal-node 7.1.1 by an npm override, for GHSA-xcx6-jx8p-x3h5.
Thanks to xet7 !
2026-10-25 03:27:26 +03:00
Lauri Ojansivu 31332bfed4 Override @azure/msal-node to 7.1.1 for GHSA-xcx6-jx8p-x3h5.
npm audit reported two high severity issues: @azure/msal-node
>= 5.0.0-beta.0, < 7.1.0 may disclose authorization code flow parameters
to an attacker-controlled endpoint, and @azure/identity depends on it.
It reaches WeKan through @tweedegolf/sab-adapter-azure-blob, whose
@azure/identity (4.14.0, the newest, and 4.15.0-beta.1) asks for
msal-node ^6.0.0, so `npm audit fix --force` reported "up to date" and
changed nothing. package.json overrides it to ^7.1.1. Its only breaking
change since 6 removed responseMode from acquireTokenInteractive, which
@azure/identity never passes; DefaultAzureCredential, ManagedIdentity and
ClientSecretCredential construct, and a token request reaches Microsoft
Entra and fails as an authentication error for a made-up tenant. WeKan's
Azure Blob storage uses DefaultAzureCredential, not the authorization
code flow. The lock file also catches up with the installed
@azure/identity 4.14.0. npm audit: 0 vulnerabilities.
tests/msalNodeAdvisory.test.cjs pins the override and that no locked copy
is in the vulnerable range.

Thanks to xet7 !
2026-10-25 03:27:14 +03:00
Lauri Ojansivu d906733f60 CHANGELOG: notification failures say which check failed; the oldest release moves to old-CHANGELOG/2026/10.md to stay under the size budget.
Thanks to xet7 !
2026-10-25 03:18:51 +03:00
Lauri Ojansivu bf7520f972 Activity notification failures say which check failed.
"Activity notification delivery incomplete; pending intent retained" was
logged for nearly every activity - 1,080 times in one test-server run,
and on a production server one line after another - and never said why:
the .catch() dropped the error. It and the recovery and both cleanup
lines now carry the failure's code (activity-notification-intent-invalid,
sync-busy, ...). Only a code is logged as it is; any other error gives its
name and numeric or short code, never its message, which may carry an
address or a card title. tests/notificationFailureReason.test.cjs pins
that, and fails when a notification failure line in server/ drops its
reason again.

Thanks to xet7 !
2026-10-25 03:18:15 +03:00
Lauri Ojansivu d5395fbb4c CHANGELOG: dependency updates, database test changes and the new speed test results in Upcoming.
Thanks to dependabot and xet7 !
2026-10-25 01:55:14 +03:00
Lauri Ojansivu e81d17da80 Speed test results: SQLite, PostgreSQL, MySQL and MariaDB with FerretDB b6c494e9.
Rerun of the four FerretDB v1 backends at twice the production data,
with the MySQL/MariaDB index reads and VIRTUAL index columns, the
multikey side tables for MySQL/MariaDB, the PostgreSQL GIN membership
index and SQLite sorting in the database. MongoDB 6-9 and FerretDB 2
keep their rows from the same directory. Every database returned the
same data as MongoDB.

All Boards count went from 8-9 s to 159 ms on PostgreSQL and from
9.4 s to 1.1 s on MySQL; adding the 44 indexes took 172 s on MySQL and
901 s on MariaDB instead of 20 and 40 minutes. This build is before
the fixes for SQLite's People page (the database sort read its key
three times per row), MySQL/MariaDB All Boards with a starred board, and
Due Cards' $and on PostgreSQL, MySQL and MariaDB, which a rerun now in
progress measures.

Thanks to xet7 !
2026-10-25 01:53:58 +03:00
Lauri Ojansivu 56292d0a0e Merge remote-tracking branch 'origin/main' 2026-10-25 01:53:15 +03:00
Lauri Ojansivu 494cbee598 Conformance: an equality and an $in on an indexed field inside an $and.
WeKan's due cards query wraps its board ids in an $and, which FerretDB's
PostgreSQL, MySQL and MariaDB backends now push down branch by branch;
both shapes answer as on MongoDB 8 on every backend.

Thanks to xet7 !
2026-10-25 01:47:24 +03:00
Lauri Ojansivu 47dcbb5dd2 Merge pull request #6804 from wekan/dependabot/npm_and_yarn/npm_and_yarn-2f01a27508
Bump the npm_and_yarn group across 1 directory with 2 updates
2026-10-25 01:25:50 +03:00
Lauri Ojansivu 353f01a03a Merge pull request #6803 from wekan/dependabot/github_actions/actions/setup-python-7.1.0
Bump actions/setup-python from 7.0.0 to 7.1.0
2026-10-25 01:25:35 +03:00
dependabot[bot] 164aaa03c9 Bump the npm_and_yarn group across 1 directory with 2 updates
Bumps the npm_and_yarn group with 2 updates in the / directory: [fast-uri](https://github.com/fastify/fast-uri) and [undici](https://github.com/nodejs/undici).


Updates `fast-uri` from 3.1.8 to 3.1.9
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9)

Updates `undici` from 7.30.0 to 7.30.1
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.30.0...v7.30.1)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.9
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: undici
  dependency-version: 7.30.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-24 22:08:48 +00:00
dependabot[bot] e2efc8ebf9 Bump actions/setup-python from 7.0.0 to 7.1.0
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 7.0.0 to 7.1.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/5fda3b95a4ea91299a34e894583c3862153e4b97...6d4fd22b20465ccc0f7354476588e8df7c9ca0e0)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-24 20:33:17 +00:00
Lauri Ojansivu 9d2b64273b db-conformance.sh keeps the containers of a run that is still going.
At start it removed every wekan-conformance-db-* container and network as
left behind by an interrupted run, including those of a run still in
progress: a speed test beside a conformance run lost its MySQL in the
middle of Due Cards ("connection refused") and its MariaDB while
loading, and both results were lost. Containers and networks now carry
the process id of their run as a label, and one whose run is alive (a
db-conformance.sh process) is kept; one without the label, of a run that
ended, or whose pid now belongs to something else is removed as before.
tests/dbConformanceStaleContainers.test.cjs runs the script's functions
against a fake docker.

Thanks to xet7 !
2026-10-24 21:17:45 +03:00
Lauri Ojansivu e9fa734853 Conformance: indexed equality, array membership and mixed-type sorting.
Three cases for what FerretDB now answers from indexes or in the
database: an equality or $in on an indexed field (inside an array, a
value differing only by case, special characters, numbers, a long
string), membership in an indexed array of documents in every shape the
array can take and after writes, and a sort over every simple type with
ties, null and missing fields, with and without a limit, then with an
array and a document among the values. SQLite, PostgreSQL, MySQL and
MariaDB answer all 114 cases as MongoDB 8 does.

Thanks to xet7 !
2026-10-24 21:17:13 +03:00
Lauri Ojansivu b3233e5665 CHANGELOG: the import page guard and the Tigre plural.
Thanks to mimZD and xet7 !
2026-10-24 21:03:36 +03:00
Lauri Ojansivu 52bcd74efa Tigre: the import size message uses the attested plural for files.
import-too-large used the provisional ፈይላት, which
tests/tigreCorpusAttestedPlurals.test.cjs rejects in favour of the
corpus-attested ፋይላት used everywhere else in the Tigre file.

Thanks to xet7 !
2026-10-24 21:03:19 +03:00
Lauri Ojansivu 132fecbfd8 The import page guard knows the checked tick is placed physically now.
tests/importPage.test.cjs still required the checked box's 17px label gap
to be margin-inline-end and no margin-right anywhere, which #6786 changed
on purpose: the tick is placed with left/margin-right so it stays on its
square right-to-left, and a [dir="rtl"] rule moves the label gap to the
left. The guard now pins that, and the unchecked box's logical gap; the
comments in import.css name the tick's physical offsets.

Thanks to mimZD and xet7 !
2026-10-24 21:03:19 +03:00
Lauri Ojansivu ae2bb47d3a CHANGELOG: resizing a list follows the pointer right-to-left (#6801).
Thanks to mimZD and xet7 !
2026-10-24 20:37:07 +03:00
Lauri Ojansivu 80208a7141 Resizing a list follows the pointer in right-to-left languages too.
The resize handle is on the list's inline end, its left edge in a
right-to-left language, but the width was the start width plus the
pointer's movement: dragging the handle out of the list narrowed it,
and dragging it in widened it. The movement is now counted with the
list's own direction, as the board sidebar and the left menus already
do. tests/rtlListResize.test.cjs pins it and requires every width
resizer to flip right-to-left; a Playwright spec drags the handle out
and back in, in both directions.

Thanks to mimZD and xet7 !

Fixes #6801.
2026-10-24 20:36:44 +03:00
Lauri Ojansivu 16b9b14898 Merge remote-tracking branch 'origin/main' 2026-10-24 20:30:17 +03:00
Lauri Ojansivu 6d68e9a6ea CHANGELOG: a checked checkbox sits on its own square right-to-left (#6786).
Thanks to mimZD and xet7 !
2026-10-24 20:13:32 +03:00
Lauri Ojansivu 6bb926af19 A checked checkbox sits on its own square in right-to-left languages too.
#6786's first fix drew the tick with physical borders, so it is a check mark
and not "<" right-to-left. But where it sits was still logical: the tick is a
box narrowed from 13px to 7px and rotated about its bottom-right corner, placed
with inset-inline-start: -3px and margin-inline-end: 6px. A right-to-left page
started the narrowed box at the square's RIGHT edge and pushed it further
right, so the tick sat beside its box - "out of box" in the issue's Admin Panel
screenshots. Measured in Chromium, the right-to-left tick spanned 303-323px
beside a square at 291-308px.

The shared tick, the Admin Panel's, the sidebar's and the Clean themes' are
placed with left and margin-right now: 291-311px, on the square, at the same
place as left-to-right, and 7 + 4 + 6 = 17px keeps the label where it was. The
import page, whose rows add a gap before the label, keeps that gap on the
reading side with a right-to-left rule. Toggle switches and the board list's
corner box, which are not this tick, still mirror.

tests/rtlCheckMark.test.cjs fails if any rule that draws a tick places it with
a logical offset or margin; tests/rtl.test.js allows physical margins and a
left offset only inside checked-state rules; the Playwright spec measures that
the tick is on its square in both directions and the label does not move.

Thanks to mimZD and xet7 !

Fixes #6786.
2026-10-24 20:12:50 +03:00
github-actions[bot] 9a4719b67a Bump versions for v12.64 2026-10-24 16:32:58 +00:00
Lauri Ojansivu 76f8d3d3a6 Prepare v12.64 release 2026-10-24 19:31:47 +03:00
Lauri Ojansivu bd049075e1 CHANGELOG: database speed test, Due Cards, FerretDB 2 compose file.
Thanks to xet7 !
2026-10-24 18:19:21 +03:00
Lauri Ojansivu 70485c38df A database speed test: the common WeKan queries on every database, twice a production WeKan of data.
docs/Databases/FerretDB/1/SpeedTest/speedtest.sh (build.sh and build.bat ->
Tests -> Database speed test) is releases/db-conformance.sh with
WEKAN_DB_SUITE=speedtest: the same FerretDB built from source and the same
databases started the same way - SQLite, PostgreSQL, MySQL and MariaDB through
FerretDB v1, MongoDB 6, 7, 8 and 9, and now FerretDB 2 with PostgreSQL and
DocumentDB.

data.cjs generates, deterministically, twice the users, boards, lists,
swimlanes and cards of a production instance (615,070 cards), skewed the way
real ones are; indexes.cjs creates the indexes WeKan creates, read from its
ensureIndex calls; queries.cjs asks 18 common WeKan queries as WeKan's own code
builds them; report.cjs writes Results.md - a row per database, fastest at the
top, a column per query - and fails when a database answered or stored
anything differently from MongoDB. README.md describes it, what it found, what
was improved and what could still be.

db-conformance.sh: WEKAN_DB_BACKENDS runs only the named databases (to repeat
one into an earlier run's WEKAN_LOGDIR), containers are removed with their
volumes, SQLite's data is under .tools/tmp, and FerretDB 2 is a backend of the
speed test, and of the conformance run with WEKAN_CONFORMANCE_FERRETDB2=1. The
conformance catalogue gains a case of writes by string _id and _ids differing
only by case, which found that FerretDB on MariaDB 12.3 lost them.

tests/dbSpeedTest.test.cjs checks the data, the index list, the queries, the
report's order and that a difference fails it, the wiring and the Due Cards
board scope.

Thanks to xet7 !
2026-10-24 18:18:15 +03:00
Lauri Ojansivu f9e1e90131 The FerretDB 2 compose file starts with the current DocumentDB image.
ghcr.io/ferretdb/postgres-documentdb:17 installs pg_cron, which may only be
created in the database cron.database_name names - postgres. With POSTGRES_DB
ferretdb the DocumentDB extension failed to install and the server never
started. POSTGRES_DB and FerretDB's URL use postgres now.

Thanks to xet7 !
2026-10-24 18:17:14 +03:00
Lauri Ojansivu c64b3a3b1d Due Cards reads only the ids and members of the boards it may show, and asks for a plain boardId list.
The publication fetched every board the user may see - every public board of
the instance among them, whole - to read its _id and members. It asks for those
two fields only. And with no assigned-only board, the usual case, its board
scope is { boardId: { $in } } instead of an $or with an empty second branch,
which MongoDB and FerretDB both answer from the { boardId, archived, type,
dueAt } index. A board where the user is assigned-only still shows only the
user's assigned cards. On the database speed test (615,070 cards) Due Cards on
FerretDB + SQLite went from 34 s to 12.8 s, with FerretDB's own changes.

Thanks to xet7 !
2026-10-24 18:17:14 +03:00
Lauri Ojansivu f1e447dd36 The Sandstorm package-import guard escapes the whole package name in its pattern.
tests/incompleteEscaping.test.cjs refuses a RegExp built from a value that only some metacharacters were escaped in; the guard escaped only '-'.

Thanks to xet7 !
2026-10-24 18:17:14 +03:00
Lauri Ojansivu e76b05b12c CHANGELOG: prebuilt SQLite-only FerretDB on every platform.
Moves the oldest release in CHANGELOG.md to old-CHANGELOG/2026/10.md to keep it under the size budget.

Thanks to xet7 !
2026-10-23 21:23:08 +03:00
Lauri Ojansivu 89da0e5be7 Every platform runs the prebuilt SQLite-only ferretdb-sqlite binary from wekan/FerretDB.
wekan/FerretDB now releases ferretdb-sqlite-<arch> beside ferretdb-<arch>: the
same FerretDB with only the SQLite handler, about 5 MB smaller, and SQLite is
all a WeKan bundle, image, snap or .spk runs. releases/ferretdb-asset.sh is the
one place that chooses: the newest release that has ferretdb-sqlite-<arch>,
and, until one does, the full ferretdb-<arch> of the newest release, which
has the SQLite handler too. It says in the log which one it took and why,
downloads it, verifies it against its .sha256sum, and returns FERRET_ASSET,
FERRET_TAG, FERRET_URL and FERRET_SHA for the provenance table (also through
$GITHUB_ENV, cached per job so the check and embed steps agree).

Every FerretDB download goes through it: the amd64, arm64, win64, win-arm64,
win32, mac-arm64, mac-x64 and extra-architecture jobs of release-all.yml (the
Docker images, snap, AppImage, Flatpak, single-file and macOS/Windows
packages are made from those bundles), repack-bundle-for-arch.sh for
release-all-missing.yml, bsd.yml, check-arch-binaries.sh,
build-release-bundle.sh and test-download-urls.sh. require-binaries.sh checks
the resume-login floor against the release the helper chose. The provenance
rows record that release and URL instead of /latest/.

No release workflow builds FerretDB: sandstorm-src/build-deps.sh takes the
prebuilt ferretdb-sqlite-amd64 by default (FERRETDB_SOURCE=download), and
sandstorm.yml and release-all.yml's build-sandstorm no longer set
FERRETDB_SOURCE=build; building from source stays a local option. The default
docker-compose.yml downloads ferretdb-sqlite-<arch> (FERRETDB_BINARY, falling
back to ferretdb-<arch> for a release without it); the PostgreSQL, MySQL,
MariaDB and SAP HANA compose files keep the full binary.

tests/ferretdbSqliteAsset.test.cjs drives the helper against a fake GitHub
(newest, older and no release with the SQLite-only binary, .exe names, a
pinned tag, FERRETDB_VARIANT=full, checksum match and mismatch, a missing
platform, bad names) and checks that every download site uses it, that no
workflow builds FerretDB and that only the SQLite compose file takes the
SQLite-only binary. The tests that pinned the old URLs follow the helper.

Thanks to xet7 !
2026-10-23 21:22:36 +03:00
Lauri Ojansivu 2da820f8ff CHANGELOG: smaller Sandstorm .spk, collection2 before the models.
Thanks to xet7 !
2026-10-23 19:35:00 +03:00
Lauri Ojansivu e413cd4720 The Sandstorm .spk leaves out every login method but Sandstorm's, and FerretDB's other database handlers.
A grain has one login (Sandstorm's), one database (FerretDB on SQLite) and one
mailbox (the grain's own address), but the .spk carried every login package
WeKan has and a FerretDB with the PostgreSQL, MySQL and SAP HANA handlers.

releases/sandstorm-slim-spk.sh, used by sandstorm.yml, release-all.yml's
build-sandstorm job and build.sh's "Make the .spk package", builds with the
package profile of releases/sandstorm-slim.mjs - accounts-password, 2FA,
passwordless, lockout, OAuth2/OIDC, CAS, LDAP, SAML and the seven social
logins removed from a copy of .meteor/packages for that one build, then
restored - trims the bundle for a linux/x64 grain, verifies none of those
packages got in and packs with spk pack (meteor-spk pack rebuilds and would
undo the trim). sandstorm-src/build-deps.sh builds FerretDB from source with
ferretdb_no_postgresql,ferretdb_no_mysql.

Under Sandstorm the Admin Panel hides the E-mail, Login, Locked users, SAML,
LDAP, OAuth2, CAS, Header login, OAuth providers and Passwordless panes,
Attachments / Database migration, the Email-to-card IMAP and webhook settings,
the New user button and the password fields; a hidden pane opened by URL lands
on the first visible one. POST /users/login and /users/register answer 404
sandstorm-sso-only, database migration is refused and the IMAP/webhook
settings are not stored. Without accounts-password the server creates
Accounts.emailTemplates with the package's defaults and marks useraccounts
initialised, which otherwise stopped the first grain.

Against wekan-12.63-sandstorm.spk, built from the same source: 181,307,364
instead of 185,271,476 bytes (-3.8 MiB, -2.1 %), unpacked 26.7 MiB smaller.
Docker, Snap and the bundles build from the full package list, and a test fails
if that list is slimmed. Tested on a local Sandstorm 0.308: a grain starts, a
board, a list and a card are created, and the removed options are not shown.
tests/sandstormSlim.test.cjs and tests/playwright/specs/sandstorm-slim.e2e.js
pin it. Design: docs/Platforms/FOSS/Container/Sandstorm/Smaller-wekan-spk.md.

Thanks to xet7 !
2026-10-23 19:34:34 +03:00
Lauri Ojansivu 2699f4deea Collection2 is loaded before the models, so every write is cleaned and given its defaults.
Collection2's attachSchema lives in the package's lazy main module, which only
aldeed:schema-index loads, through an asynchronous Collection2.load(). Nothing
waited for it: the models attached their schemas with whichever attachSchema
existed first - collection2's, or the bare shim in imports/collectionHelpers.js,
which stores the schema and never cleans a write. The full build happened to
load collection2 first. A build with fewer packages (the Sandstorm .spk) reached
the models sooner, so a new board's swimlane was stored without archived, sort
and type, and the board opened empty. server/main.js now loads
meteor/aldeed:collection2/main before the models in every build.

Thanks to xet7 !
2026-10-23 19:34:12 +03:00
github-actions[bot] a463937cce Bump versions for v12.63 2026-10-22 17:04:47 +00:00
Lauri Ojansivu e1a75d0311 Prepare v12.63 release 2026-10-22 20:00:47 +03:00
Lauri Ojansivu 819ee786ce Merge remote-tracking branch 'origin/main' 2026-10-22 20:00:13 +03:00
Lauri Ojansivu be0d8d9b9c CHANGELOG: copying or linking a card carries only its new board's custom field values (#6800).
Thanks to Raymond-Voogt and xet7 !
2026-10-22 19:58:32 +03:00
Lauri Ojansivu 63ebdb9072 Copying or linking a card carries only the custom field values of its new board.
A card made from a card template, linked from another board, or copied - and
a subtask copied with it - took its whole customFields array along: values of
another board's fields, of deleted fields, and entries with no field id. The
admin-only field guard could not match those to a field of the new card's
board, refused the whole card and recorded AdminFieldBleed against the user.

copy(), its subtask copies and link() now keep only the values of fields
defined on the destination board (models/lib/customFieldsForBoard.cjs), read
from the collection rather than the cache, just before the insert. An
admin-only value of the destination board is kept, so the guard still decides
it. tests/copyForeignCustomFields.test.cjs fails on the code before the fix,
and fails on any card cloned from another and inserted without the filter.

Thanks to Raymond-Voogt and xet7 !

Fixes #6800,
2026-10-22 19:58:16 +03:00
Lauri Ojansivu 6f9454656e The regex-injection guard knows the database connection-lost pattern.
models/lib/databaseErrors.js builds the connection-lost rule's pattern from
CONNECTION_LOST.source (#6799), a constant regex of the same file, so no
caller value is spliced into it. Listed with that reason, as CARD_URL_RE.source
already is.

Thanks to xet7 !
2026-10-22 19:58:16 +03:00
github-actions[bot] c9827049a1 Bump versions for v12.62 2026-10-22 16:33:46 +00:00
Lauri Ojansivu b43c36890c Prepare v12.62 release 2026-10-22 19:32:34 +03:00
Lauri Ojansivu f4c67c6df9 CHANGELOG: refused edits no longer disable accounts (#6798), database pod restart is not a crash (#6799).
Thanks to Raymond-Voogt and xet7 !
2026-10-22 19:31:08 +03:00
Lauri Ojansivu f37204b1c3 A MongoDB pod restarting is a lost connection, not a critical crash.
In Kubernetes a restarting MongoDB pod's DNS name does not resolve for a
few seconds, and the driver throws "getaddrinfo ENOTFOUND
wekan-mongodb-0...". No rule in models/lib/databaseErrors.js knew it, so
it was not transient, and server/lib/uptimeWatch.js recorded every
unhandled rejection as a critical CrashBleed although 00processErrors.js
kept WeKan running: nine "crashes" while WeKan reconnected by itself.

connection-lost now matches getaddrinfo ENOTFOUND and EAI_AGAIN, but only
for the DATABASE's own host - from the caller, or from MONGO_URL and
MONGO_OPLOG_URL (every host of a replica set, mongodb+srv too) - so a
webhook or mail host that does not resolve is still not a database
problem. The crash recorder skips what isTransientDatabaseError already
handles, for both uncaught exceptions and unhandled rejections.

tests/databaseDnsRestart.test.cjs parses connection strings, runs both
guards' decision on the database host and on other hosts, and runs the
crash recorder's own listeners: a database coming back records nothing,
a real crash and a foreign DNS failure still do. It fails on the code
before this commit.

Thanks to Raymond-Voogt and xet7 !

Fixes #6799,
2026-10-22 19:30:41 +03:00
Lauri Ojansivu 425b4ddea7 An ordinary refused edit on one's own board no longer disables the account.
Accounts were disabled for members working on their own board; one
instance lost four in three days, each user then refused at login 81
times.

- A Worker may move a card (#3189) and tick a checklist item (#3307),
  but canUpdateCard and the checklist item rule asked
  canEditCardOrLinkedCard first, which recorded authz.linked-write (high,
  blocked) before the Worker carve-out allowed the change, and that
  record disables the account. Both now ask without recording first, and
  record only a change that is still refused after the carve-outs.
- A read-only or comment-only member whose screen still offered an edit
  (a role changed while the board was open) was refused with the same
  record. authz.linked-write keeps being recorded and the write keeps
  being refused, but it no longer disables the account (blocksAccount:
  false, as ssrf.webhook since #6782).

tests/workerNoSelfBlock.test.cjs runs canUpdateCard and the checklist
item rule from their own source: a Worker's move and tick are allowed
with nothing recorded, other refused edits are still recorded, the
catalog entry never blocks through blockOnSecurityEvent's own decision,
and no rule with a Worker carve-out records before it. It fails on the
code before this commit. boardRoleViews follows the two-step check.

Thanks to Raymond-Voogt and xet7 !

Fixes #6798,
2026-10-22 19:28:49 +03:00
Lauri Ojansivu 368b90d650 Merge remote-tracking branch 'origin/main' 2026-10-22 19:26:24 +03:00
Lauri Ojansivu e417720f8a CHANGELOG: decluttered card and Card Settings (#6796), list move history checksum (#6797).
Thanks to mueschel, Raymond-Voogt and xet7 !
2026-10-22 19:15:51 +03:00
Lauri Ojansivu 76f9d41bf4 A list move with no swimlane no longer fails its own history checksum.
Admin Panel / Problems / Security showed HistoryIntegrity (critical)
81,833 times. A list bound to no one swimlane has swimlaneId '', and the
legacy history append hashed the row with '' and then inserted it
through collection2's default clean, which removes empty strings, after
which the schema's defaultValue stored null: every such list move failed
its own checksum at every audit and was reported as tampering. The
stored-chain path already inserted with { removeEmptyStrings: false,
trimStrings: false }; the legacy path now does too.

Rows already stored that way verify: a null where '' was hashed is
accepted in the id and label fields (boardId, swimlaneId, listId, cardId,
group, batchId, restoredFromId, restoredByUserId) and nowhere else, so a
value replaced by null, a different value, or any change to content, user
or entity still fails.

tests/historyEmptyStringChecksum.test.cjs appends through a collection
that cleans like collection2, verifies already-cleaned rows, fails every
real change, and fails on any ChangeHistory insert in the tree that would
clean or trim; it fails on the code before this commit.

Thanks to Raymond-Voogt and xet7 !

Fixes #6797,
2026-10-22 19:15:36 +03:00
Lauri Ojansivu 2c520ef382 Declutter the opened card, attachments too, and fit Card Settings on the screen.
#6796: an opened card gave most of its height to controls with nothing in
them. Stickers, Location, Dependencies and List already have Show on Card
rows, and Date Format is in Member Settings; what was left:

- A field's add button sits on its title's line (Labels, Stickers,
  Location, the dates, Members, Assignee, Creator, Requested and Assigned
  By, Dependencies); what a field holds follows and wraps. Requested and
  Assigned By put their two buttons side by side.
- Checklists: the controls under the list share a line, and the bottom
  "+" is drawn once there is a checklist; before that it is the title
  line's.
- Attachments: the upload rules ("If filename is invalid, ...", size,
  types) are in the upload popup, not body text above every list. "+" is
  a small button. A file with nothing to preview gets a small file button
  that still opens the preview, instead of a 144x96 box with "txt" in it,
  and the name has a line of its own, so a long one is not hidden behind
  the buttons.
- Two new Board Settings / Card / Show on Card rows, Attachments: Preview
  and Attachments: Details (uploader and date), both on by default, built
  from existing translations.

Board Settings / Card is a centred panel as wide (up to 1800px) and tall
as the window, and each list spreads into as many 10em columns as fit,
read top to bottom: on 1920x1080 nothing is scrolled, on 1366x768 32px.

Its rows are dragged by the handle, the icon or the label when drag
handles are on, and by the icon or the label, with no handle drawn, when
they are off. With handles on only the handle dragged, and the rows were
locked to vertical moves, which the columns made unusable.

tests/declutterCard6796.test.cjs runs the tile decision and the popup
geometry, and checks the gates, the CSS and the drag wiring;
tests/playwright/specs/declutter-card-6796.e2e.js checks the add buttons
on their title's line, the compact attachment, the rules in the upload
popup, Attachments: Details, the new rows saving, no scroll at 1920x1080,
and a drag by label and handle with handles on and by label with them
off. cardSettingsCoverage and narrowWindowLayout follow the new rules.

Thanks to mueschel and xet7 !

Fixes #6796,
2026-10-22 19:15:36 +03:00
github-actions[bot] 3c88e5b13f Bump versions for v12.61 2026-10-22 12:37:41 +00:00
Lauri Ojansivu 8dbc79b89c Prepare v12.61 release 2026-10-22 15:36:25 +03:00
Lauri Ojansivu faa66aecc4 listSortIndexes test escapes every regular expression character of a name.
It escaped only . and $ when building a pattern from an identifier, which
tests/incompleteEscaping.test.cjs reports anywhere in the tree.

Thanks to xet7 !
2026-10-22 15:32:56 +03:00
Lauri Ojansivu 9273d3eb13 CHANGELOG: list sort indexes and wrapped connection errors in Admin Panel / Problems (#6794, #6795).
Thanks to Raymond-Voogt and xet7 !
2026-10-22 15:18:38 +03:00
Lauri Ojansivu a9e680d4b2 A lost database connection inside a validation error is a connection problem.
While MongoDB restarted, Admin Panel / Problems showed
"Exception while invoking method 'usersHeartbeat' ValidationError:
Failed validation Socket 'connect' timed out" as a schema bug, telling
the admin to look for an empty required field and report a WeKan bug.
collection2 wraps whatever failed during a write's validation in
"ValidationError: Failed validation", an unreachable database included;
the schema rule came first, and the driver's connect timeout matched no
rule at all. The same classification decides in 00startupResilience.js
and 00processErrors.js whether an error is transient, so it was fatal at
startup too.

schema-validation now yields when the same message carries a lost
connection, a timeout, a lock or too many connections. connection-lost
also knows the MongoDB driver's own errors: Socket '...' timed out,
MongoNetworkTimeoutError, MongoNetworkError, Server selection timed out,
MongoServerSelectionError, PoolClearedError, NotWritablePrimary and
"not primary" (not SQL's "not primary key").

tests/databaseErrorsWrappedCause.test.cjs runs both guards'
isTransientDatabaseError from their own source on the reported error,
puts every transient cause behind three wrappers and requires its own
rule each time, and keeps a real schema failure a fatal schema failure.

Thanks to Raymond-Voogt and xet7 !

Fixes #6795,
2026-10-22 15:18:30 +03:00
Lauri Ojansivu 058a2dcbce List sort indexes are made from key objects, not from "-modifiedAt" strings.
Admin Panel / Problems showed "Created the missing index "-modifiedAt" on
lists" at every start, from every pod. server/models/users.js passed the
list sort options ('-modifiedAt', 'title', ...) to ensureIndex as
strings: the driver read each as a field name, so lists got indexes on
fields named "-modifiedAt", "-title" and "-sort" (on no document, kept up
to date on every list write), and ensureIndex compared a string's JSON
with an index key object's, which never matched, so all six were
re-created and reported every time.

models/lib/indexKeys.cjs turns the sort options into one single-field
index per field ({ modifiedAt: 1 }, { title: 1 }, { sort: 1 }; one serves
both directions) and finds the stray single-field "-x" indexes, which
startup now drops; a compound index containing such a field is left
alone. ensureIndex turns a string key into the key it means, with a
warning, instead of indexing a field named after it.

tests/listSortIndexes.test.cjs uses the reporter's real index list, runs
ensureIndex itself against a fake collection (a string key creates the
real index once and reports it once), and fails on any ensureIndex call
in the tree whose key is not an index key object.

Thanks to Raymond-Voogt and xet7 !

Fixes #6794,
2026-10-22 15:16:50 +03:00
Lauri Ojansivu 660846dd3c Merge remote-tracking branch 'origin/main' 2026-10-22 15:06:31 +03:00
Lauri Ojansivu e1f8d93d70 CHANGELOG: newest npm dependencies, Blockly 13.4 toolbox search in every language, core26 snap Caddy, ca@valencia.
Thanks to translators and xet7 !
2026-10-22 06:37:58 +03:00
Lauri Ojansivu 6a37160bc8 A saved language with '@' resolves: ca@valencia shows Valencian, not English.
The Valencian file is ca@valencia.i18n.json, but its registry tag is
ca-valencia, and resolveTag and the browser-language matcher only treated
'_' as '-'. A profile saved as the file's name - which is what
tests/playwright/specs/rules-blocks.e2e.js does - resolved to nothing and
the page stayed in English; that case failed on the commit before the
npm updates too. Both now compare through one normalizeLanguageTag
('_' and '@' as '-'), exported from imports/i18n/browserLanguage.js.
tests/browserLanguageFallback.test.cjs uses that function rather than its
own copy of the old rule, checks ca@valencia, and requires every locale
FILE name to resolve to a registry language.

Thanks to xet7 !
2026-10-22 06:34:36 +03:00
Lauri Ojansivu 189adaee52 Update imapflow, mailparser, pdfjs-dist, isomorphic-git and pretty-ms, with runtime tests.
imapflow 2.1.2 -> 2.8.0 and mailparser 3.9.31 -> 3.12.2 (email to card):
bug fixes, and mailparser no longer turns a decoded display name into an
address. pdfjs-dist 6.2.108 -> 6.4.299 (attachment text search),
isomorphic-git 1.42.4 -> 1.44.3 (GitHub wiki import), pretty-ms 9 -> 10
(upload time estimate; its breaking changes are in colonNotation, which
WeKan does not use, and the output it shows is unchanged).

Each now has a test that RUNS it the way WeKan calls it, so the next
update that moves an API fails in seconds:
- imapflowMailparserRuntime: the poller's connect, mailbox lock, unseen
  search, fetchOne, simpleParser and \Seen flag against
  server/lib/tests/fakeImapServer.js; a wrong password is refused, and an
  encoded address in a display name stays a name.
- pdfjsTextRuntime: documentGif.js's legacy build, worker, fonts, CMaps
  and getTextContent on a PDF with known text; a non-PDF is refused.
- isomorphicGitRuntime: githubWiki.js's log, recursive readTree and
  readBlob on a two-commit repository; a missing object is an error.

npm audit reports nothing. The e2e run against a build with these:
rules-blocks (147 languages), attachments, both email specs, and the
security specs pass, except two rules-blocks cases that fail the same way
on a build of the commit before this one.

Thanks to developers of dependencies and xet7 !
2026-10-22 06:34:36 +03:00
Lauri Ojansivu 8a00898252 Update the small npm dependencies, and translate Blockly 13.4's toolbox search.
Within their ranges or pins: @aws-sdk/client-s3 and lib-storage 3.1155.0,
@fastify/busboy 3.2.4, the three @tweedegolf/sab-adapter-* 3.0.5/3.0.3,
blockly 13.4.0, dompurify 3.4.17, file-type 22.2.0, filesize 11.0.28,
i18next 26.5.4, meteor-node-stubs 1.2.32 (pbkdf2 is still 3.1.7 after
the postinstall patch), wasmagic 1.0.12, and for development
@rspack/core 2.2.9, @typescript-eslint/* 8.72.0 and puppeteer 25.15.0.
The lockfile is written by npm 12.2.1, the Dockerfile's. npm audit
reports nothing.

Blockly 13.4 adds a search box to the toolbox, with five new messages.
releases/translations/import-blockly.cjs added them to every locale, and
they are translated directly into all 230 locale files, from each file's
own words for "block" and "search"; %1 is kept in every count. The
importer also replaced Telugu's literal blockly-LOGIC_NULL "null" with a
word meaning "invalid"; it is "null" again, as
tests/vietnameseBulgarianCompletion.test.cjs requires. Corrected while
there: Polish "Szukaj blokad" (search for blockades) from Blockly's own
catalog is "Szukaj bloków", Arabic's singular count drops a stray "(1)",
and Odia's "no-results" had English mixed into it.

Low confidence, for a speaker to correct: ace, ak, an, ary, ay, bi, bm,
bo, bua, chr, co, csb, cv, dz, ee, ff, fj, gn, gv, ha, haw, hsb, ig, iu,
kl, kok, ks, kw, la, lg, lld, mg, mi, nah, nap, nd, nso, ny, om, pap, qu,
rn, rup, rw, sah, sc, se, sm, sn, so, ss, st, szl, tig, tlh, to, tpi,
ts, uz-AR, ve, ve-CC (Venetian), ve-PP (Veps), vo, wa, wa-RR (Waray),
wal, wo, xh, zgh and zu.

All 1988 node suites pass except tigreCorpusAttestedPlurals, which
fails on the commit before this one too.

Thanks to developers of dependencies, translators and xet7 !
2026-10-22 06:01:28 +03:00
Lauri Ojansivu 92001bcfd9 vmactions/openbsd-vm 1.4.8 -> 1.4.9, and actions/setup-node v7.1.0 everywhere.
openbsd-vm 1.4.9 adds OpenBSD 8.0 as the VM's default release and fixes a
non-root user's writes to the NFS share; the BSD bundles keep release 7.8
from releases/bsd/targets.json, so they are still built for 7.8. The three
setup-node@v6 steps in the disabled Playwright workflow are pinned to
v7.1.0 by commit, like the fifteen in the active workflows.

Thanks to xet7 !
2026-10-22 05:23:36 +03:00
Lauri Ojansivu 2ad5999bc7 Snap Caddy: core26 gets the fixed part, and both fall back to 2.11.7.
snapcraft-core26.yaml still had the caddy part snapcraft.yaml had before
v10.71: the version from api.github.com, which answers 403 to a busy CI
runner and, under snapcraft's pipefail, ended the part before its 2.8.4
fallback; a plain curl with no retry and no fallback for a CPU the newest
Caddy does not publish; and a default Caddyfile that proxied to 3000,
where WeKan does not listen when Caddy is on (it listens on
CADDY_BIND_PORT, 3001). Both snaps build from snap-src, so core26 now has
snapcraft.yaml's part unchanged, and both pin 2.11.7 as the fallback.

tests/releaseSnapArches.test.cjs read snapcraft.yaml only, which is how
the core26 copy was missed; it now checks every snapcraft*.yaml, and the
default Caddyfile's port. With the old core26 file it fails.

Thanks to xet7 !
2026-10-22 05:23:36 +03:00
github-actions[bot] 770acdcee6 Bump versions for v12.60 2026-10-22 02:01:02 +00:00
Lauri Ojansivu d30f1dffea Prepare v12.60 release 2026-10-22 04:59:51 +03:00
Lauri Ojansivu cf55c52c64 CHANGELOG: failed imports and exports block only attempts, crypto keys for dynamic-import and meteor shell (CodeQL 591).
Thanks to GitHub CodeQL and xet7 !
2026-10-22 04:57:17 +03:00
Lauri Ojansivu 6efd39a70e dynamic-import and meteor shell keys come from the crypto RNG.
GitHub CodeQL alert 591 (js/insecure-randomness, CWE-338), in the
dynamic-import fork: the key that lets a request read the SERVER's
dynamic modules (?key= on /__meteor__/dynamic-import/fetch) came from
Math.random(), whose xorshift128+ state can be recovered from earlier
outputs, and was compared with ===. meteor shell's connection key, which
runs code in the server process, was made and compared the same way.
Meteor 3.5.3 and 3.6-rc.0 both have this.

Both forks now use crypto.randomBytes (160 and 128 bits) and compare with
crypto.timingSafeEqual. tests/meteorForkRandomKeys.test.cjs runs both, and
fails on any Math.random() in the forks and on a key, secret, token,
nonce, salt, password or code made from Math.random() anywhere in the
tree. A wrong key is not recorded in Admin Panel / Problems: another
WeKan instance behind the same load balancer legitimately sends its own.

Thanks to GitHub CodeQL and xet7 !
2026-10-22 04:56:47 +03:00
Lauri Ojansivu 567f99227b A failed import or export disables an account only when it was an attempt.
secureTransfer recorded every throw as a high "blocked" event naming the
user, and that disables the account. sanitize-html 2.18.0 threw
RangeError on nested <iframe>, so a card planted with the payload locked
out the next member who exported the board; any library fault would do
the same. The transfer boundary now marks its own refusals
(TransferBoundaryError: a cycle, a non-plain object, a size limit), and
models/lib/transferFailure.cjs records only those as blocked - high, and
medium for a size limit as before. Anything else is ImportExportFailure:
detected, still naming whose import or export it was, and never blocking
an account.

tests/transferFailure.test.cjs drives real boundary refusals and a
sanitizer that throws through blockOnSecurityEvent's own decision; with
the old secureTransfer it fails. It also fails if the boundary throws a
plain Error or anything but secureTransfer calls it.

Thanks to xet7 !
2026-10-22 04:56:40 +03:00
Lauri Ojansivu 42965e133b Merge remote-tracking branch 'origin/main' 2026-10-22 04:49:09 +03:00
Lauri Ojansivu 182b6072d0 CHANGELOG: sanitize-html 2.18.1, nested iframes no longer crash imports and exports.
Thanks to xet7 !
2026-10-22 04:33:30 +03:00
Lauri Ojansivu d89bcab8e0 Tests: nested iframes no longer crash the import/export sanitizer.
sanitize-html 2.18.0 re-sanitized the fallback of a discarded <iframe>
with no recursion limit (GHSA-c9rx-x947-fp3h, CWE-674). WeKan's server
sanitizer discards every tag, so a few kilobytes of nested <iframe> threw
RangeError in secureTransfer, on every import and export - and
secureTransfer records a throw as a high blocked event naming the user,
which disables that account. A card planted with the payload locked out
the next member who exported the board. 96ed9ad202 moved to 2.18.1.

tests/sanitizeHtmlNestedIframe.test.cjs runs WeKan's own sanitizeInput
options on three nested-iframe payloads, checks the installed and locked
versions, and fails if anything else imports sanitize-html; with 2.18.0
it fails with RangeError. The Playwright spec exports and imports a board
holding the payload and checks that nobody is locked out.

Thanks to xet7 !
2026-10-22 04:33:04 +03:00
Lauri Ojansivu 96ed9ad202 Updated sanitize-html.
Thanks to developers of dependencies !
2026-10-22 04:29:10 +03:00
github-actions[bot] b5cccd7584 Bump versions for v12.59 2026-10-22 01:18:15 +00:00
Lauri Ojansivu e5d186c2f0 Prepare v12.59 release 2026-10-22 04:17:02 +03:00
Lauri Ojansivu 1624f44ffd CHANGELOG: Meteor 3.5.3's six security fixes on Meteor 3.6-rc.0.
Thanks to Meteor developers, Vext Labs and xet7 !
2026-10-22 04:12:43 +03:00
Lauri Ojansivu e0189bc53b Meteor 3.5.3's six security fixes on Meteor 3.6-rc.0.
Meteor published the same six fixes as 3.3.3, 3.4.2 and 3.5.3 on
2026-10-16. 3.6-rc.0, which WeKan runs, does not have them: its tag is
behind 3.5.3 by exactly the 15 security commits, and neither it nor
Meteor's devel branch has the fixed code. Going back to 3.5.3 would also
mean Node.js 24 and Rspack 1, so WeKan carries the fixes in packages/,
where a package overrides the release's own: 3.6-rc.0's allow-deny,
ddp-server, dynamic-import, facebook-oauth, google-oauth and shell-server
with Meteor's 3.5.3 patch applied unchanged, each marked with a _1 wrap
number. ddp-server leaves out uWebSockets.js, as Meteor's published
package does; it is only for the opt-in uws transport and came from a git
URL.

- DDP: a message named __proto__, toString or not a string, and an async
  handler that rejected, crashed the server.
- allow/deny: a top-level __proto__ in an update was missing from the field
  list the validators saw.
- dynamic import(): requests are bounded in size (413) and complexity
  (400).
- Google and Facebook direct-token login: a token issued to another
  application was accepted; identity now comes from the provider, and a
  Facebook verification error no longer carries the app secret.
- meteor shell: its history and compile cache are private.
- Deploy discovery is in meteor-tool, which an app cannot replace; WeKan is
  never deployed with meteor deploy, and a test keeps it so.

Refused attempts are in Admin Panel / Problems as Meteor DDP, Meteor
allow-deny, Meteor dynamic-import and Meteor OAuth, through a hook the
packages call (server/lib/meteorSecurityHook.js); none blocks an account.
An oversized import() body, an unknown ordinary DDP name and a network
failure are refused without a row, because ordinary use can reach them.

tests/meteor353SecurityFixes.test.cjs runs each fix's code cut out of the
fork and searches the tree for the shape of each fault; all of them fail on
the unfixed 3.6-rc.0 packages. It also fails when .meteor/release moves
on, because a fork would silently keep 3.6-rc.0's code.
tests/playwright/specs/meteor-353-security.e2e.js sends each attack to a
running WeKan built with the forks. The forked files are recorded in the
source audit baseline, and the suites that check WeKan's own methods and
subscriptions skip Meteor's package tests.

Thanks to Meteor developers, Vext Labs and xet7 !
2026-10-22 04:12:13 +03:00
Lauri Ojansivu 8a21e5639b CHANGELOG: every workflow's jobs that run after a cancel end on their own.
Thanks to xet7 !
2026-10-22 03:07:20 +03:00
Lauri Ojansivu 94e1749977 Every workflow's jobs that run after a cancel end on their own.
Cancel workflow does not cancel a job whose if: is still true, and always()
is true in a cancelled run. tests/releaseCancelSafeJobs.test.cjs checked
only release-all.yml; it now fails on a job on always() in any workflow that
is a reusable-workflow call or has no timeout of 60 minutes or less. Every
such job today (release-notes, done, and the AppImage, Flatpak and Mac
summaries) is bounded. node-patches had eleven five-hour resume stages
called this way, and Cancel workflow did not stop them.

Thanks to xet7 !
2026-10-22 03:07:12 +03:00
Lauri Ojansivu be0da24e21 Merge remote-tracking branch 'origin/main' 2026-10-22 02:50:27 +03:00
Lauri Ojansivu 6e1341a2a8 CHANGELOG: release recovery waits out GitHub API errors.
Thanks to xet7 !
2026-10-22 02:39:33 +03:00
Lauri Ojansivu e304e1948a Release recovery waits out GitHub API errors instead of dying on one.
The Recover job of "Release All missing" died after one HTTP 502 from the
GitHub API while `gh run watch --exit-status` listed the rerun's jobs, so
plan and every build after it were skipped. releases/retry-failed-release.py
now polls `gh run view`, retries up to ten API errors in a row, waits for
the new attempt rather than reading the old attempt's failure, and stops
only on the rerun's own conclusion. tests/retryFailedRelease.test.py pins
the 502 case, a failed rerun, a lasting outage and the stale attempt, and
that no `gh run watch` is left.

Thanks to xet7 !
2026-10-22 02:39:18 +03:00
github-actions[bot] 70ede2148a Bump versions for v12.58 2026-10-21 15:29:38 +00:00
Lauri Ojansivu aa26a6adaa Prepare v12.58 release 2026-10-21 18:28:16 +03:00
Lauri Ojansivu 914d95816a CHANGELOG: board to template, large Jira imports, comments follow a moved card, CodeQL alert 590.
Thanks to fabiosalles, GitHub CodeQL and xet7 !
2026-10-21 15:31:40 +03:00
Lauri Ojansivu 696b5b073f Comments, reactions, text notes and attachments follow a card moved to another board.
The board publication finds a board's comments, comment reactions, text
notes, checklists, checklist items and attachments by the board id each
row carries (#6480), but a card that changed board re-homed only its
checklists and checklist items. Its comments stayed published to the
board it left, and came back when the card was moved back. Attachments
followed only when Card.move() ran on the server, and the REST card
move, a hook-less write, re-homed nothing at all.

One list (models/lib/cardChildrenBoard.js) now names every such
collection, and one helper (server/lib/cardChildrenBoardSync.js)
re-homes them from the Cards.after.update hook, the REST move and the
durable rule move. The startup schema upgrade step card-children-boardid
moves rows that a move already left behind to their card's board.

Thanks to fabiosalles and xet7 !

Fixes #6793.
2026-10-21 15:30:41 +03:00
Lauri Ojansivu 14a43da557 Large Jira exports import, and an existing board can be saved as a board template.
From IRC: a Jira import of 25 issues worked and one of 858 failed. The
import boundary refused any document over 250,000 values, and a Jira
search export carries some 250 per issue even without comments - mostly
null custom fields, avatar URLs and status categories - so it stopped at
under 1,000 issues, about 6 MB. The refusal was a plain Error, which the
import page showed as "Internal server error". The cap is now four
million values; a file over it is refused as import-too-large, which the
page translates, with the limit as the reason. The import deadline was a
flat two minutes whatever the file held; it is now WEKAN_IMPORT_TIMEOUT_MS
plus half a second per record (models/lib/importDeadline.js), on the
server and in the import page's watchdog alike. The Jira creator also
collected the file's issue keys once per issue, which was quadratic.

"Is there a way to convert an existing board into a template?" There was
not: a board template could only be built inside the Templates board.
Board menu / Save as Template (saveBoardAsTemplate) copies the board as a
template board, private with the creator as its one member, and adds the
linked card in the user's Templates / Board Templates swimlane that makes
it a template. Board admins only, as Copy Board, and not offered on a
board that already is a template.

The new import-too-large string is translated into every language; the
low-confidence ones are ace, ak, an, ay, bm, bo, bua, chr, csb, cv, dz, ee,
ff, fj, fo, fur, gn, gv, haw, hsb, iu, kl, kok, ks, kw, lg, lld, nah, nap,
nd, rm, rup, sah, sc, scn, se, ss, tig, tlh, to, ve, ve-PP, ve-CC, vo, wa,
wa-RR, wal, wo, wuu-Hans and zgh.

Thanks to xet7 !

Fixes #6792.
2026-10-21 15:30:32 +03:00
Lauri Ojansivu e18794fa46 Invite-by-email e2e escapes the address completely, and a partial character class escape is found anywhere.
CodeQL alert 590 (js/incomplete-sanitization): the #6788 e2e put the
stored address into a $regex with only "." escaped, so a backslash in the
address could cancel the escape. It now uses escape-string-regexp, the
same module the server matches addresses with.

tests/incompleteEscaping.test.cjs only knew single-character patterns
and so missed `.replace(/[.]/g, '\\.')`. It now also reports a character
class without a backslash replaced by a backslash escape, and found this
line before it was fixed.

Thanks to GitHub CodeQL and xet7 !
2026-10-21 14:59:55 +03:00
Lauri Ojansivu 55f1ef7852 Merge pull request #6791 from wekan/dependabot/npm_and_yarn/npm_and_yarn-04be66158d
Bump markdown-it from 15.0.2 to 15.0.3 in the npm_and_yarn group across 1 directory
2026-10-21 14:46:25 +03:00
dependabot[bot] c927b901e8 Bump markdown-it
Bumps the npm_and_yarn group with 1 update in the / directory: [markdown-it](https://github.com/markdown-it/markdown-it).


Updates `markdown-it` from 15.0.2 to 15.0.3
- [Changelog](https://github.com/markdown-it/markdown-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/15.0.2...15.0.3)

---
updated-dependencies:
- dependency-name: markdown-it
  dependency-version: 15.0.3
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-21 01:15:42 +00:00
github-actions[bot] 647cbd111f Bump versions for v12.57 2026-10-20 15:04:19 +00:00
Lauri Ojansivu 09db4b64fa Prepare v12.57 release 2026-10-20 18:03:05 +03:00
Lauri Ojansivu 4d8df83fdd Admin Panel validation audit lists the Retention pane.
tests/adminPanelDocsMatchMenu.test.cjs requires docs/Features/Admin-Panel/Validation.md
to inventory every live pane; Attachments / Retention (#6789) was missing
from it. It also crashed on the commit before this one, whose test run was
read by its last line only.

Thanks to xet7 !
2026-10-20 14:05:36 +03:00
Lauri Ojansivu 5c2fc294f3 Tests and docs follow this round's changes: Retention documented, a real regex escape, the snap guards.
The full node suite found three breakages in this round's own work:
- adminPanelDocsMatchMenu: Admin Panel / Attachments / Retention was in the
  product and not in docs/Features/Admin-Panel/Attachments/README.md. It has
  its section and its row in the URL table now.
- incompleteEscaping: the test from the #6788 patch hand-wrote a regex escape.
  It now uses the same escape-string-regexp package the server imports.
- releaseSnapArches: two guards read the Launchpad step's text. infra_failure()
  (the v12.56 Launchpad-outage retries) also reads snapcraft-wekan-*.txt
  before the loop, and the fixed `sleep 60` became `sleep "$pause"`. The
  guards now look for the log printing after the no-snap message, and for the
  pause variable, and say why. These two had been failing since that change.
  An earlier run's report that this suite was only "load-related" was wrong:
  the FAIL lines had been filtered out of the output that was read.

Thanks to xet7 !
2026-10-20 14:05:13 +03:00
Lauri Ojansivu 4b95f7f2cc CHANGELOG: attachment retention, backup status, settings on every platform and in the Admin Panel, member invite by email.
Thanks to Raymond-Voogt and xet7 !
2026-10-20 13:50:44 +03:00
Lauri Ojansivu 718fdb5dac Newest features' settings: on every release platform and in the Admin Panel, and kept that way.
An audit of every environment variable the code reads found 30 that are new
since v12.00. Eleven were already Admin Panel settings and on every platform
the release workflows build. Of the rest:

Now Admin Panel settings, through models/lib/authConfigCatalog.js (a value
there wins, an empty field falls back to the variable):
- Email to card limits, beside its own Admin Panel settings:
  INBOUND_IMAP_POLL_SECONDS, INBOUND_EMAIL_PER_ADDRESS_PER_HOUR,
  INBOUND_EMAIL_MAX_BYTES, INBOUND_EMAIL_MAX_ATTACHMENT_BYTES and
  EMAIL_VIEW_SENDS_PER_HOUR. They were module-level constants, read before
  the Admin Panel values loaded; they are read when used now.
  createRateLimiter takes a limit function, and a changed poll interval
  restarts the IMAP poller.
- ACCOUNTS_COOKIE_REFRESH_RATE_LIMIT in People / Login, like the login
  expiry beside it, applied from the next start.
- Windows SSO (WINDOWS_SSO_ENABLED, _DOMAINS, _ACTIVE_DIRECTORY,
  _MERGE_EXISTING_USERS) in People / Login. It was already read through
  authEnv but had no form. Being Windows-only, its catalog section names
  start-wekan.bat as its one platform.

Listed on every platform (Dockerfile, the docker-compose files, start-wekan.sh
and .bat, the snap config and help), where none of these had been: the
variables above, INBOUND_EMAIL_WEBHOOK_SECRET (its own Admin Panel page
already has it), and the per-process switches that stay environment only -
INBOUND_IMAP_DISABLED (one pod of a multi-pod deployment), WEKAN_DISK_MONITOR,
WEKAN_DISK_WATCH_PATHS, WEKAN_RAM_MONITOR and WEKAN_IMPORT_RUN_STALE_MS.
Left unlisted on purpose: NEXTCLOUD_EXAPP, NEXTCLOUD_URL and
WEKAN_NEXTCLOUD_PROXY_KEY (set by Nextcloud's AppAPI), SANDSTORM_MAILDIR (set
by Sandstorm). DEFAULT_AVATAR_URL keeps no Admin Panel UI, as decided in #824.

tests/envVarCoverage.test.cjs keeps it so. It fails for a variable that is in
no Admin Panel catalog, OWN_PANE or ENV_ONLY with its reason; the variables
read before v12.00 are a frozen LEGACY list that can only shrink. An
environment-only or own-page setting a user sets must be on every platform,
and stale entries fail. tests/authConfigCatalog.test.cjs honours a section's
platform list. tests/adminPanelNewSettings.test.cjs pins the reads and the
forms. tests/cookieRefreshRateLimit.test.cjs's guard now expects the Admin
Panel value, and says why. tests/playwright/specs/admin-new-env-settings.e2e.js
opens the forms.

Thanks to xet7 !
2026-10-20 13:50:10 +03:00
Lauri Ojansivu 06b7ce994a Backup status: every run recorded, failures and overdue backups shown, old local backups pruned - in Admin Panel too.
The result of an Admin Panel backup lived only in the memory of the server
process that ran it. After a restart, or on another pod of a multi-pod
deployment, the Backup pane said "idle", so nobody could see whether last
night's backup had worked. There was no history, no warning when a scheduled
backup failed or did not run, and local backups were never cleaned up.

- Every run, scheduled or "Backup now", is recorded in backupRuns: start and
  end, who or the schedule, scope, storage, status, size, duration and error.
  Records are kept 400 days. A run still "running" after 24 hours counts as
  failed, and a scheduled run skipped for another backup is recorded as failed.
- Admin Panel / Problems / Backup shows the last success (overdue when its
  period has passed), the last failure with its error, the next run and the
  last 30 runs. The same status is above the schedule in Attachments / Backup.
- Problems / Summary lists "Backup failed" and "Backup overdue". /metrics adds
  wekan_backup_last_success_timestamp_seconds,
  wekan_backup_last_run_timestamp_seconds, wekan_backup_last_run_success and
  wekan_backup_overdue for alerting.
- BACKUP_KEEP_DAYS: after a successful backup, local backups of the same scope
  older than N days are deleted. The newest is always kept, and only a
  directory inside the backup root holding a backup.zip is removed.

From the patch by Raymond-Voogt, and in Admin Panel as well:
- BACKUP_KEEP_DAYS is the catalog's backup section
  (models/lib/authConfigCatalog.js), overridable beside the schedule in
  Admin Panel / Attachments / Backup and resolved through authEnv at every
  backup.
- It is listed on every platform: the compose files, start-wekan.sh and .bat,
  the snap config and help, and the Dockerfile, which the patch's version
  had missed. Its platform hunks, written before #6789, were re-applied by
  hand.

tests/backupRuns.test.cjs (21): the run records, overdue and pruning against a
real directory, and the Admin Panel override, including that nothing reads the
variable around it. tests/authConfigCatalog.test.cjs checks every platform.
admin-attachment-retention.e2e.js checks the Backup pane shows the field.

Thanks to Raymond-Voogt and xet7 !

Fixes #6790.
2026-10-20 13:30:00 +03:00
Lauri Ojansivu dcf91e4471 Attachment retention: delete card attachments after N days, purge deleted ones after M days - in Admin Panel too.
Organisations that must not keep files forever (storage limits, GDPR data
minimisation) had only scripts working on MongoDB directly, which leave card
references behind, do not know which storage holds a file, and break on
upgrades. Soft-deleted attachments were also kept forever.

A nightly job (server/attachmentRetention.js, rules in
models/lib/attachmentRetention.js), off unless configured:
- ATTACHMENTS_RETENTION_DAYS soft-deletes card attachments older than N days
  through the card's own Delete (cover unset, activity, card history Restore).
- ATTACHMENTS_RETENTION_MODE: uploaded (default), or card-inactive, which also
  requires the card to have had no activity for as long.
- ATTACHMENTS_PURGE_DELETED_AFTER_DAYS removes attachments soft-deleted at
  least M days ago for good, file included, through Attachments.removeAsync
  so every storage backend is cleaned. The write re-checks deletedAt, so one
  restored meanwhile stays.
- ATTACHMENTS_RETENTION_DRY_RUN only logs; ATTACHMENTS_RETENTION_MAX_PER_RUN
  caps each step per night.
Never touched: attachments without a card, template boards and cards, and
attachments with an unknown upload date.

From the patch by Raymond-Voogt, and in Admin Panel as well:
- The five settings are a section of models/lib/authConfigCatalog.js.
  Admin Panel / Attachments / Retention (/admin/attachments/retention) draws
  the shared override form on it: a value there wins, an empty field falls
  back to the variable, and the source in effect is shown.
- The job resolves them through authEnv on every run, so a change needs no
  restart.
- The new menu label attachment-retention is translated in all 246 locales.
- The catalog's platform test then found that the patch had missed the
  Dockerfile and the snap help; both list all five now, beside
  docker-compose, start-wekan and the snap config.

tests/attachmentRetention.test.cjs (14) covers the rules and the Admin Panel
wiring; negative: the job never reads the environment around the Admin Panel.
tests/authConfigCatalog.test.cjs checks every platform lists each variable.
tests/playwright/specs/admin-attachment-retention.e2e.js opens the pane, and
checks that an ordinary user cannot.

Thanks to Raymond-Voogt and xet7 !

Fixes #6789.
2026-10-20 13:28:17 +03:00
Lauri Ojansivu 193fb449bd Adding a board member: email invite is case-insensitive, and search filters before it limits.
A board member who was not a board admin could not add a colleague from
Board / Members / +.

- inviteUserToBoard matched the typed email address exactly. LDAP and OIDC
  store an address as the directory spells it ("Jan.Jansen@Example.com"), so
  "jan.jansen@example.com" found nobody, and WeKan tried to create a new
  account instead: "This user is not created" with registration off,
  otherwise a raw 403 "Email already exists". The match is now
  case-insensitive, anchored and escaped.
- searchUsers read five users and filtered afterwards: disabled accounts
  (which the invite then refuses, #1894) and, for non-admins, people outside
  the user's organization or team (#6116). Five non-matching users ahead of
  the colleague hid them. It reads 50, filters, then returns 5.

From the patch by Raymond-Voogt. tests/inviteMemberEmailCase.test.cjs pins
both, including that the pattern is anchored and its dot literal.
tests/playwright/specs/invite-member-email-case.e2e.js covers:
- an invite with a lower-cased address adds the stored colleague, and no
  second account is made;
- negative: a similar but different address is not taken for them;
- the search returns the colleague past six disabled matches, offering none
  of them.

Thanks to Raymond-Voogt and xet7 !

Fixes #6788.
2026-10-20 13:24:14 +03:00
Lauri Ojansivu 13dfa18e8b Merge remote-tracking branch 'origin/main' 2026-10-20 13:15:20 +03:00
Lauri Ojansivu 31c5ad3727 CHANGELOG: the Security report's false alarms fixed, its labels translated.
Thanks to xet7 !
2026-10-20 10:37:28 +03:00
Lauri Ojansivu 3d703965c4 Admin Panel / Problems: "IPv6 location" and "Instrumentation" are translated.
The Security report in Finnish read "Sijainti" beside the English "IPv6
location", and the menu row and title bar of the Instrumentation report were
English in every language.

- The IPv6 location column is the translated 'location' with the fixed
  technical suffix " (IPv6)" - "Sijainti (IPv6)" - through a new optional
  labelSuffix on table headers, so no new key is needed.
- instrumentationReportTitle names the report in the menu row and in its
  title bar, which tests/adminUrls.test.cjs requires to say the same words.
  It is translated into all 246 locale files, after instrumentation-other
  in en.i18n.json's key order. French uses "Instrumentation logicielle",
  because the bare word is spelled exactly like the English one.

Low-confidence translations, written from the language's general
vocabulary for "measuring tools" where the technical term has no
established form: ak, ay, bm, bua, chr, cv, ee, ff, fj, gn, gv, ha, haw, ig,
iu, kl, kw, lg, mi, nah, nd, ny, om, qu, rn, rw, sah, se, sm, sn, ss, st,
tig, tlh, to, tpi, ts, ve, vo, wal, wo, zgh.

tests/addressReportColumns.test.cjs checks that every address column header
is translated. The admin-office-location UI spec expects "Location (IPv6)".

Thanks to xet7 !
2026-10-20 10:37:03 +03:00
Lauri Ojansivu 8a23306c2e Locations from Cloudflare keep their letters: "Dinkelsbühl", not "Dinkelsbühl".
Admin Panel / Problems / Security showed a sign-in from "Dinkelsbühl, DE".
Cloudflare sends cf-ipcity as raw UTF-8 bytes, and Node hands HTTP header
values over as Latin-1, one character per byte, so every city with a letter
outside ASCII was stored garbled.

geoHeaders reads a header value made only of byte-sized characters as UTF-8
when its bytes are valid UTF-8, before the existing percent-decoding. Real
Latin-1 that is not valid UTF-8, plain ASCII and percent-encoded names are
left as they were. Rows already stored keep the old text until their place is
seen again.

tests/geoHeadersUtf8.test.cjs: Dinkelsbühl, Zürich, Kraków, Москва, 東京, and
an end-to-end HTTP request carrying the UTF-8 bytes. Negatives: ASCII, a
percent-encoded city and genuine Latin-1 are unchanged. It fails without the
fix.

Thanks to xet7 !
2026-10-20 10:24:32 +03:00
Lauri Ojansivu 52f127c0a5 StorageBleed: a clean file-integrity scan is not a security event.
Admin Panel / Problems / Security showed StorageBleed, "checked 12144 files in
900s; 0 changed, 0 with no record saying why; stopped early: this run has
used its time", 41 times: the background scan reported its own summary on
every run. A clean scan is the scan working, and the Security report is for
attempts. A summary that has nothing to sum up hides the row that matters.

The summary is recorded only when the scan found a changed or missing file,
beside those findings, which are still recorded one by one. A clean run,
including one that ran out of time and continues next time, is written to
the server log as "[file-integrity] ...".

tests/fileIntegrity.test.cjs pins the condition. Negatives: no unconditional
summary record remains, and changed and missing files are still recorded.

Thanks to xet7 !
2026-10-20 10:23:47 +03:00
Lauri Ojansivu c7c541ccc5 FileNameBleed: a percent-encoded name is not an exploit, and opening a file is not an upload.
Admin Panel / Problems / Security showed FileNameBleed, "uploaded a filename
carrying an exploit pattern; URL-encoded name", for a user who had only
opened a board of 2019 photos with Cyrillic names. Reading each file
repaired its stored percent-encoded name (the FileBleed "fileRead" rows,
correct and recorded once per file). The repair's reasons were then passed to
the upload canary, where "URL-encoded name" counted as dangerous. Browsers and
older WeKan percent-encode every non-ASCII name, so every such name tripped it.

- "URL-encoded name" is no longer a dangerous reason.
- What an encoding could hide is named from the decoded name instead:
  sanitizationReasons() now reports "path traversal" and "null byte". Before,
  "URL-encoded name" was the only thing an encoded "../../etc/passwd" tripped;
  it now trips the path-traversal canary under its own name, which
  DANGEROUS_REASON already listed but no reason ever produced.
- A name repaired while a file is read (source fileRead) never trips the upload
  canary: the repair is still recorded, but it is not an attempt.

tests/filenameCanaryReasons.test.cjs loads the real module, with its server
dependencies stubbed:
- the Cyrillic name trips nothing;
- negatives: an encoded traversal and an encoded null byte still trip, and
  markup in a name uploaded now still trips;
- a read-time repair is recorded without a canary.

Thanks to xet7 !
2026-10-20 10:23:16 +03:00
Lauri Ojansivu 4f3ec0afa3 History integrity: a row is hashed as the database stores it, so an unset value is not "tampering".
Admin Panel / Problems / Security reported HistoryIntegrity checksum-mismatch,
severity critical, 89472 times, on a board's history row nobody had touched.
Meteor connects to MongoDB/FerretDB with ignoreUndefined, so a content
property that is undefined is dropped when the row is stored. Writers record
such content routinely, for example `deletedAt: list.deletedAt` for a list
that was never deleted. The row's hash covered `"deletedAt":{"$undefined":true}`,
the stored row has no deletedAt, and every background audit found the row
failing its own checksum and reported it again.

- hashHistoryRow() hashes previousContent and newContent as stored: undefined
  properties dropped, undefined array elements as null, as the driver does.
  New rows verify as the database returns them.
- Rows written before this were hashed with the undefined keys. If the normal
  check fails, the keys present in the other side of the change and absent
  here are re-added as undefined, which reproduces exactly what was hashed;
  nothing else is accepted.

tests/changeHistoryIntegrity.test.cjs stores rows through BSON with
ignoreUndefined, exactly as the driver does: a new row and a pre-fix row both
verify, and a changed value, a removed real value, a change on the other side
and an added key are still caught. The test fails without the fix.

A row that still reports checksum-mismatch after this release changed in a
way an unset value cannot explain, and is worth looking at.

Thanks to xet7 !
2026-10-20 10:21:43 +03:00
Lauri Ojansivu 363cd22916 Launchpad retry: match git's transfer error as 'RPC failed;', not by the word curl.
tests/releaseDownloads.test.cjs flags every workflow line that names curl
as a download that must go through releases/fetch.sh; infra_failure()'s grep
pattern named it only as text in git's error. 'RPC failed;' identifies that
error on its own. tests/snapLaunchpadInfraRetry.test.cjs still runs the
function against v12.56's 'RPC failed; curl 56' log.

Thanks to xet7 !
2026-10-20 10:12:08 +03:00
Lauri Ojansivu e73338e54b CHANGELOG: Bun smoke-test MongoDB waited for, Launchpad outages retried longer.
Thanks to xet7 !
2026-10-20 09:58:08 +03:00
Lauri Ojansivu 727e96e527 Snap on Launchpad: an outage there gets more attempts and longer pauses, not three strikes.
v12.56's ppc64el snap failed after 20 minutes of its five-hour budget. Attempt
1's Launchpad builder lost its TLS connection while cloning our source ("RPC
failed; curl 56 GnuTLS recv error ... fatal: early EOF"). Attempts 2 and 3
followed a minute apart each, and both met Launchpad's own "HTTP Error 503:
Service Unavailable" / "Error: Timeout". s390x, riscv64 and armhf each lost an
attempt the same night and recovered.

infra_failure() recognises an attempt that failed on Launchpad rather than on
WeKan: an HTTP 5xx or timeout from its API, or, in that attempt's own build
log, a dropped git/TLS transfer or the build farm's package proxy failing.
Such an attempt earns another one, up to six, after a pause of 5, 10, 15...
minutes, never running past the shared deadline. Every other failure keeps
three attempts and the 60s pause; a missing stage-package is not retried
longer.

tests/snapLaunchpadInfraRetry.test.cjs runs the workflow's own infra_failure()
against logs shaped like v12.56's, and against the negatives: a missing
stage-package, an earlier attempt's log, a 4xx.

Thanks to xet7 !
2026-10-20 09:57:54 +03:00
Lauri Ojansivu 294a9016d3 Bun bundles: the smoke-test MongoDB is waited for, and a lost one shown and replaced.
v12.56's Bun linux-x64 bundle failed its live smoke test: WeKan stopped with
"Topology is closed", because nothing listened on 127.0.0.1:27019. The
smoke-test MongoDB had been started with `docker run -d --rm` and never
checked, so a container that had died left no log behind, and the job could
not say why. linux-arm64 passed in the same run, and linux-x64 in v12.53, so
this happens only sometimes.

- bun.yml starts the container without --rm, and waits until it answers
  a ping. If it does not answer within 60s, its state and log are printed.
- build-target.sh checks the port again right before it starts WeKan, with
  the new releases/bun/wait-mongo.mjs (a plain TCP check, nothing to install).
  The database holds only this test's data, so if it is gone, its log is
  printed and a new one is started once. A second failure fails the job.

tests/bunSmokeMongo.test.cjs: URL parsing, the wait succeeding and timing
out, the workflow step and the single replacement.
buildScriptParity lists wait-mongo.mjs with the other Bun target-build
internals.

Thanks to xet7 !
2026-10-20 09:56:00 +03:00
github-actions[bot] b160d355ff Bump versions for v12.56 2026-10-20 00:54:06 +00:00
Lauri Ojansivu 3ae98f3059 Prepare v12.56 release 2026-10-20 03:52:54 +03:00
Lauri Ojansivu 84bf7d2dd7 Merge remote-tracking branch 'origin/main' 2026-10-20 03:52:25 +03:00
Lauri Ojansivu 78c453a66d CHANGELOG: #6783 also needed the People table's row projection.
Thanks to Raymond-Voogt and xet7 !
2026-10-19 20:21:40 +03:00
Lauri Ojansivu c4376f92a5 People: every row's Last active reaches the table, not only the publication.
The UI test of #6783 (inactive-users-last-active.e2e.js) still found "-" in
another user's row after the people publication started sending
lastConnectionDate. The table builds its rows from a client-side Users.find
with its own field list (peopleDocs), which left the date out. The row's
lastActiveAt read that copy before the full user document, so the date never
reached the cell. The projection keeps the field now. The two presence
helpers read the whole cached user, as userData() already does, so a field
missing from the projection cannot hide them again.

Verified with Playwright on Chromium against the dev server: the spec failed
before this change and passes with it. tests/lastActive.test.cjs pins both
halves and fails without this commit.

Thanks to Raymond-Voogt and xet7 !

Fixes #6783.
2026-10-19 20:21:32 +03:00
Lauri Ojansivu f1b183ac2a rtl test: a check mark's border-left/-right is the one physical side allowed.
A check mark is not mirrored in a right-to-left language (#6786), so the
tick's rule uses border-right. The guard allows border-left/-right only inside
a rule whose selector is a checked state, the same definition
tests/rtlCheckMark.test.cjs uses. A border anywhere else, and any margin,
padding or float inside such a rule, is still refused.

Thanks to mimZD and xet7 !
2026-10-19 17:55:10 +03:00
Lauri Ojansivu faa56aa1a8 docker-compose.yml lists USERS_DISABLE_INACTIVE_AFTER_DAYS where every backend's compose file does.
Its wekan service must match the other compose files line for line
(tests/dockerComposeBackends.test.cjs): the setting moves from beside
ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS to after LOGOUT_ON_MINUTES, with the
Admin Panel note the others carry.

Thanks to Raymond-Voogt and xet7 !
2026-10-19 17:55:10 +03:00
Lauri Ojansivu 912d414fdf CHANGELOG: inactive accounts disabled, LDAP sync filter, Last active for everyone, RTL check marks, restored list swimlanes kept.
Thanks to Raymond-Voogt, mimZD and xet7 !
2026-10-19 17:42:08 +03:00
Lauri Ojansivu 3469f84c78 Restored list swimlanes stay restored after a WeKan upgrade.
Admin Panel / Problems / "Lists missing their swimlane" -> Restore binds each
list back to the swimlane positionHistory records it was created in. After
every new WeKan release, the startup schema upgrade re-checks old data, and
its merge-per-swimlane-lists step - written for the v7.98 era, which stamped
lists to the default swimlane and duplicated them per swimlane - took those
lists for that era's damage. It ran when a board had two lists of one title
or one list with cards in another swimlane, and then un-bound every list on
the board, the restored ones included. So after each upgrade the count came
back to exactly the same number.

A list bound to the very swimlane it was created in, with none of its cards
in another swimlane, is per-swimlane by design: it is what Restore puts back,
and not a v7.98 stamp, which was applied after the list existed. The step now
leaves those lists out of its check, its merge and its un-binding, so a board
of only such lists does not trigger it. Stamped lists, lists whose cards are
in other swimlanes, and lists bound anywhere but their record are handled as
before. Lists already un-bound by an earlier upgrade are restored by pressing
Restore once more, and now stay.

tests/schemaUpgradeSteps.test.cjs: the issue's board (restored lists kept, a
stamp on the same board still un-bound), a by-design board not triggering the
step, and the two negatives. The first fails without the fix.

Thanks to mimZD and xet7 !

Fixes #6787.
2026-10-19 17:41:32 +03:00
Lauri Ojansivu b4bcded492 A checked checkbox is a check mark in right-to-left languages too, not "<".
The tick is a box rotated 40 degrees, drawn by its bottom and side borders,
and the side was border-inline-end. A right-to-left page puts that on the
left, so the rotated corner opened the other way and every checked checkbox
showed "<" - the Admin Panel's bounced-email table in Persian, in the issue.
A check mark is not mirrored in a right-to-left language, so all 25 rules
that draw one - the shared materialCheckBox, checklists, subtasks, the
sidebar, the Admin Panel and every board theme's colour - use border-right
and border-left now. Where the box sits stays logical.

tests/rtlCheckMark.test.cjs fails if any checked state anywhere in client/
draws its tick with a logical border. tests/playwright/specs/rtl-check-mark.e2e.js
compares the computed borders in ltr and rtl. customFieldsSectionMenu's guard
now expects border-right, and its comment says why.

Thanks to mimZD and xet7 !

Fixes #6786.
2026-10-19 17:37:53 +03:00
Lauri Ojansivu b933a69cd4 People shows everyone's Last active; inactive users can be disabled; LDAP sync honours its search filter.
Admin Panel / People showed "Last active" as "-" for every user but the
administrator: the people publication did not carry lastConnectionDate.

USERS_DISABLE_INACTIVE_AFTER_DAYS disables, once a night, accounts that have
not used WeKan for that many days, and revokes their logins. Admins are
never disabled. An account with no recorded activity starts its count on the
first run rather than from createdAt, and re-enabling an account restarts it.
The setting is in Admin Panel / People / Login as well as in the
environment, and the job resolves it every night, so a change there needs no
restart. Every platform's settings file lists it.

LDAP background sync looked a user up by id without LDAP_USER_SEARCH_FILTER,
so an account the filter excludes - one disabled in Active Directory - could
not log in but was still "found". It was never disabled, and a manual
disable was undone on the next run. The filter is now ANDed into the lookup.
An account disabled for inactivity is not re-enabled by the sync.

From the patch by Raymond-Voogt, with the setting moved into the Admin
Panel's login settings. Tests: tests/inactiveUsers.test.cjs,
tests/ldapBackgroundSync.test.cjs, tests/ldapUserIdFilter.test.cjs,
tests/authConfigCatalog.test.cjs and
tests/playwright/specs/inactive-users-last-active.e2e.js.

Thanks to Raymond-Voogt and xet7 !

Fixes #6783,
Fixes #6784,
Fixes #6785.
2026-10-19 17:35:51 +03:00
github-actions[bot] e4d025e0b4 Bump versions for v12.55 2026-10-18 20:52:17 +00:00
Lauri Ojansivu 28163c90d1 Prepare v12.55 release 2026-10-18 23:51:09 +03:00
Lauri Ojansivu 2ca5d06709 Merge remote-tracking branch 'origin/main' 2026-10-18 23:50:24 +03:00
Lauri Ojansivu 506067b4a1 CHANGELOG: 32-bit ARM single files injected in a 64-bit process, each musl AppImage built once.
Thanks to xet7 !
2026-10-18 23:35:30 +03:00
Lauri Ojansivu 2cbb038719 AppImage: each musl AppImage built once, and every job has its own name.
release-all's glibc "AppImages" call passed no `only`, so it built the musl
rows too, and both groups had an "AppImage x86_64" and an "AppImage aarch64".
Two jobs of one name write to one directory of the run's log archive, whose
files then ask to be overwritten or renamed when it is unpacked.

- The glibc call builds x86_64, aarch64, i686 and armhf only. The musl pair
  stays with the "musl AppImages" call, which waits for the musl bundles.
- The job is named by its file's architecture, so it is "AppImage
  x86_64-musl", which the publish step's "see the AppImage <arch> build job's
  log" names too.
- The publish step checks only the architectures its own run builds. The musl
  run warned that the four glibc AppImages were missing.

tests/muslBundles.test.cjs checks that no two matrix rows share a name, and
that each row is built by exactly one call.

Thanks to xet7 !
2026-10-18 23:35:14 +03:00
Lauri Ojansivu 29dfe94886 Single files: a 32-bit ELF target's blob is injected in a 64-bit process.
v12.54's armv7 single file failed with std::bad_alloc on all three attempts
of the retry added for armhf in v12.53, so the failure is not intermittent.
--build-sea injects with LIEF inside the Node.js that runs it, and a 32-bit
target's blob must be written by a 32-bit Node.js. The executable, the
180 MiB archive and LIEF's copies of both do not fit that process's address
space.

For a 32-bit ELF target the i386 Node.js now writes only the blob
(--experimental-sea-config: the same blob, no LIEF), and the new
releases/single-file/sea-inject-elf.py injects it with LIEF 1.0.0 from PyPI,
the version Node.js vendors. It ports InjectIntoELF() and MarkSentinel()
from src/node_sea_bin.cc step for step. Its output is byte-identical to
--build-sea's, for a non-PIE x64 Node.js and for the PIE node-armv7. win32
(PE) and 64-bit targets keep --build-sea. The retry is removed.

tests/singleFileSeaInject.test.cjs checks the split and the workflow's LIEF.
Where a Python with LIEF 1.0.0 is available it builds both ways, compares
them byte for byte, runs the result, and checks that an executable that
already carries a blob is refused.

Thanks to xet7 !
2026-10-18 23:35:14 +03:00
github-actions[bot] 734e81fb8c Bump versions for v12.54 2026-10-18 15:57:36 +00:00
Lauri Ojansivu 81e609cb69 Prepare v12.54 release 2026-10-18 18:56:32 +03:00
Lauri Ojansivu f166786e7a Merge remote-tracking branch 'origin/main' 2026-10-18 18:55:54 +03:00
Lauri Ojansivu 1a6a96b667 nodeRuntimeDependency: the glibc libatomic requirement still reaches the AppImage check past the musl NEEDED block.
Thanks to xet7 !
2026-10-18 18:52:49 +03:00
Lauri Ojansivu 939addc77a CHANGELOG: Android bundles, the armhf single file and the x86_64 musl AppImage build again.
Thanks to xet7 !
2026-10-18 18:49:48 +03:00
Lauri Ojansivu df5560ad51 musl bundles carry every library their Node.js needs, libatomic too.
Every x86_64 musl AppImage of v12.53 failed to start in a bare Alpine:
"Error loading shared library libatomic.so.1". node-runtime held only
libstdc++ and libgcc_s; the build container had libatomic through g++, so the
bundle's own smoke test passed.

build-bundle.sh now copies every NEEDED library of bundle/node except musl's
libc, and AppImage.yml checks each is in node-runtime before packing.

Thanks to xet7 !
2026-10-18 18:49:28 +03:00
Lauri Ojansivu fa0c03437c Single files: try --build-sea again when it runs out of memory.
The v12.53 armhf single file failed with std::bad_alloc in --build-sea. A
32-bit target is injected by the 32-bit i386 Node.js, and the executable,
the 180 MiB archive and LIEF's copies of both come near its 4 GiB address
space. armv7 and armv6, same archive and host, built in the same run, and
armhf built in v12.52, so the failure is intermittent.

buildSea() runs it up to three times when, and only when, it fails with
std::bad_alloc, removing the partial output between attempts. Any other
failure stops at once.

Thanks to xet7 !
2026-10-18 18:49:28 +03:00
Lauri Ojansivu 519869b561 Android bundles: preload termux-exec, so bcrypt's install script runs.
Every Android bundle of v12.53 stopped in npm-rebuild.js: "sh: 1:
node-gyp-build: not found". Termux has no /usr/bin/env, so a
"#!/usr/bin/env node" script runs only through termux-exec, an LD_PRELOAD
library that Termux's login exports. The workflow starts bash directly,
without login.

build-bundle.sh exports termux-exec's library the way login does, newest
generation first, and keeps it only if a program still starts with it.

Thanks to xet7 !
2026-10-18 18:49:28 +03:00
github-actions[bot] 8a67ca34d6 Bump versions for v12.53 2026-10-18 11:34:09 +00:00
Lauri Ojansivu 9ba22aaca7 Prepare v12.53 release 2026-10-18 14:33:00 +03:00
Lauri Ojansivu bc0991e06f CHANGELOG: #6782, a card move with an outgoing webhook keeps the mover's account.
Thanks to do4me and xet7 !
2026-10-18 14:29:09 +03:00
Lauri Ojansivu b01c233269 Moving a card with an outgoing webhook no longer counts as HookBleed or disables the mover's account.
The activity hook delivered webhooks through Meteor.call('outgoingWebhooks'),
and that method trusted a call without this.connection as the server's own.
Meteor gives a server-side Meteor.call the userId AND connection of the method
it runs in (ddp-server applyAsync), and a card moved from a browser runs in
that browser's method. So the server's own act-moveCard delivery arrived as a
client forging one: it was recorded as ssrf.webhook-forge (high) with the
mover's userId, and blockOnSecurityEvent set loginDisabled.

- deliverActivityWebhook() in server/notifications/outgoing.js is the server's
  delivery: a function, not a method, so no client reaches it and no inherited
  context changes it. It reads the integration again (enabled) and names the
  activity's actor. server/models/activities.js calls it through safeDeliver.
- The outgoingWebhooks method treats every call as a client's: only the
  card-opened notification, rebuilt from the card, as before for clients.
- Server deliveries no longer need the actor to be a member of the hook's
  board, which also skipped a global webhook ('_global' is no board) and an
  activity with no method behind it (REST, timers).
- securityLog.record() now passes the catalog key and blocksAccount to the
  block decision. The stored row carries neither, so every 'high' blocked row
  disabled whoever it named: a hook refused for its own internal address
  disabled the mover too, and ssrf.redirect/ssrf.attachment's
  blocksAccount: false never applied. ssrf.webhook is now blocksAccount:
  false - the person whose move sent it did not write the hook's URL.

tests/hookBleedServerDelivery.test.cjs: the move delivered with nothing
logged, in an inherited context too; a forged call still refused, logged and
blocking; the card-opened notification; failing and internal hooks blocking
nobody, through record() itself; and a whole-tree scan that no server code
calls a this.connection-gated method from inside a client's method without
clearing the invocation. tests/playwright/specs/hook-bleed.e2e.js moves a
card through the Move dialog with a webhook enabled.

Thanks to do4me and xet7 !

Fixes #6782.
2026-10-18 14:28:19 +03:00
github-actions[bot] 3c9f4ba045 Bump versions for v12.52 2026-10-17 23:49:33 +00:00
Lauri Ojansivu e0f8447524 Prepare v12.52 release 2026-10-18 02:48:13 +03:00
Lauri Ojansivu 6809149ef1 CHANGELOG: the nine dependabot updates, checked, and the guards that follow them.
Thanks to dependabot and xet7 !
2026-10-18 02:44:47 +03:00
Lauri Ojansivu 53729d47f5 Guards follow the merged dependabot updates: freebsd-vm 1.5.9, qrcode-generator 2.0.4.
bsdBundles pinned bsd.yml's FreeBSD VM to the old commit; it now reads the
pin and requires node-patches to use the same one, which it now does.
sqrl required qrcode-generator 1.4.4 exactly; it now requires an exact pin,
and 2.0.4 was checked to give sqrlQrSvg's call the same SVG.

Thanks to dependabot and xet7 !
2026-10-18 02:44:40 +03:00
Lauri Ojansivu f063f5043f Merge pull request #6781 from wekan/dependabot/github_actions/vmactions/freebsd-vm-1.5.9
Bump vmactions/freebsd-vm from 1.5.5 to 1.5.9
2026-10-17 23:48:29 +03:00
Lauri Ojansivu 71e942b146 Merge pull request #6780 from wekan/dependabot/github_actions/actions/setup-python-7.0.0
Bump actions/setup-python from 6.3.0 to 7.0.0
2026-10-17 23:48:15 +03:00
Lauri Ojansivu e365a57649 Merge pull request #6779 from wekan/dependabot/npm_and_yarn/i18next-26.4.5
Bump i18next from 26.4.2 to 26.4.5
2026-10-17 23:47:59 +03:00
Lauri Ojansivu 372192a28d Merge pull request #6778 from wekan/dependabot/npm_and_yarn/swc/core-1.16.18
Bump @swc/core from 1.16.13 to 1.16.18
2026-10-17 23:46:57 +03:00
Lauri Ojansivu 9340b25c03 Merge pull request #6777 from wekan/dependabot/npm_and_yarn/temml-0.14.0
Bump temml from 0.13.5 to 0.14.0
2026-10-17 23:46:42 +03:00
Lauri Ojansivu dbf50d67bd Merge pull request #6776 from wekan/dependabot/npm_and_yarn/tests/playwright/playwright/test-1.64.1
Bump @playwright/test from 1.63.0 to 1.64.1 in /tests/playwright
2026-10-17 23:46:24 +03:00
Lauri Ojansivu b022403a55 Merge pull request #6775 from wekan/dependabot/npm_and_yarn/qrcode-generator-2.0.4
Bump qrcode-generator from 1.4.4 to 2.0.4
2026-10-17 23:46:11 +03:00
Lauri Ojansivu 67d0e4fd94 Merge pull request #6774 from wekan/dependabot/github_actions/actions/download-artifact-8.0.2
Bump actions/download-artifact from 7.0.0 to 8.0.2
2026-10-17 23:45:57 +03:00
Lauri Ojansivu ed1aae7cf0 Merge pull request #6773 from wekan/dependabot/npm_and_yarn/rspack/cli-2.2.9
Bump @rspack/cli from 2.2.8 to 2.2.9
2026-10-17 23:45:41 +03:00
Lauri Ojansivu 161f213de0 Merge remote-tracking branch 'origin/main' 2026-10-17 23:44:43 +03:00
dependabot[bot] bdb6e4f875 Bump vmactions/freebsd-vm from 1.5.5 to 1.5.9
Bumps [vmactions/freebsd-vm](https://github.com/vmactions/freebsd-vm) from 1.5.5 to 1.5.9.
- [Release notes](https://github.com/vmactions/freebsd-vm/releases)
- [Commits](https://github.com/vmactions/freebsd-vm/compare/v1.5.5...c46abacb49f09938ca4e1702d15d836285d694cc)

---
updated-dependencies:
- dependency-name: vmactions/freebsd-vm
  dependency-version: 1.5.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-17 20:33:40 +00:00
dependabot[bot] 4afb4cb0f4 Bump actions/setup-python from 6.3.0 to 7.0.0
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.3.0 to 7.0.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/ece7cb06caefa5fff74198d8649806c4678c61a1...5fda3b95a4ea91299a34e894583c3862153e4b97)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-17 20:33:37 +00:00
dependabot[bot] 04639dd871 Bump i18next from 26.4.2 to 26.4.5
Bumps [i18next](https://github.com/i18next/i18next) from 26.4.2 to 26.4.5.
- [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.4.2...v26.4.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-17 20:33:26 +00:00
dependabot[bot] 330cd9dab0 Bump @swc/core from 1.16.13 to 1.16.18
Bumps [@swc/core](https://github.com/swc-project/swc/tree/HEAD/packages/core) from 1.16.13 to 1.16.18.
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/commits/v1.16.18/packages/core)

---
updated-dependencies:
- dependency-name: "@swc/core"
  dependency-version: 1.16.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-17 20:33:18 +00:00
dependabot[bot] 034e5ea441 Bump temml from 0.13.5 to 0.14.0
Bumps [temml](https://github.com/ronkok/Temml) from 0.13.5 to 0.14.0.
- [Release notes](https://github.com/ronkok/Temml/releases)
- [Changelog](https://github.com/ronkok/Temml/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ronkok/Temml/compare/v0.13.5...v0.14.0)

---
updated-dependencies:
- dependency-name: temml
  dependency-version: 0.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-17 20:33:00 +00:00
dependabot[bot] 7ffe87a677 Bump @playwright/test from 1.63.0 to 1.64.1 in /tests/playwright
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.63.0 to 1.64.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.1)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.64.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-17 20:32:54 +00:00
dependabot[bot] ac97edb4aa Bump qrcode-generator from 1.4.4 to 2.0.4
Bumps [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) from 1.4.4 to 2.0.4.
- [Release notes](https://github.com/kazuhikoarase/qrcode-generator/releases)
- [Commits](https://github.com/kazuhikoarase/qrcode-generator/compare/js1.4.4...js2.0.4)

---
updated-dependencies:
- dependency-name: qrcode-generator
  dependency-version: 2.0.4
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-17 20:32:52 +00:00
dependabot[bot] 43a646b19e Bump actions/download-artifact from 7.0.0 to 8.0.2
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7.0.0 to 8.0.2.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...9000827ccba6bdab643e8b6fd33ac0654aef8333)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-17 20:32:46 +00:00
dependabot[bot] b0ac3b0629 Bump @rspack/cli from 2.2.8 to 2.2.9
Bumps [@rspack/cli](https://github.com/web-infra-dev/rspack/tree/HEAD/packages/rspack-cli) from 2.2.8 to 2.2.9.
- [Release notes](https://github.com/web-infra-dev/rspack/releases)
- [Commits](https://github.com/web-infra-dev/rspack/commits/v2.2.9/packages/rspack-cli)

---
updated-dependencies:
- dependency-name: "@rspack/cli"
  dependency-version: 2.2.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-17 20:32:45 +00:00
Lauri Ojansivu 95a1478c8c CHANGELOG: Android, musl and single-file release builds.
Thanks to xet7 !
2026-10-17 23:25:30 +03:00
Lauri Ojansivu c316616ef8 Single files: skip, with the reason, a Node.js LIEF cannot inject; no error for a target not built.
v12.51's i386 single file stopped with std::bad_alloc and freebsd-x64's with
"Failed to relocate ELF program headers": the i386 Node.js is a PIE with
R_386_32 relocations LIEF does not adjust, and the FreeBSD one a non-PIE with
two bss-like segments. releases/single-file/sea-inject.mjs reads that from
the ELF before anything is built, build.mjs exits 3 with the reason, and
single-file.yml turns it into a notice. wekan/node-patches now builds both
the way the injection needs.

A target that was not built leaves a one-line artifact saying why, so its
Start job no longer logs "Artifact not found" errors and publish does not
warn about it as unstarted.

Thanks to xet7 !
2026-10-17 23:24:49 +03:00
Lauri Ojansivu bce305029a musl bundles carry libstdc++ and libgcc_s, so their Node.js starts on a bare Alpine.
Every musl AppImage of v12.51 died at once in Alpine: "Error relocating
.../bundle/node: _ZTVN10__cxxabiv117__class_type_infoE: symbol not found".
The musl Node.js is linked against GCC's C++ runtime, which musl.yml's own
Alpine had installed for building, and a user's Alpine does not. The bundle's
node-runtime now holds that Alpine's libstdc++.so.6 and libgcc_s.so.1 with a
license notice, start-wekan.sh puts it on the library path as for libatomic,
and the AppImage workflow requires both files of a musl bundle.

Thanks to xet7 !
2026-10-17 23:24:49 +03:00
Lauri Ojansivu 0670cc65a3 Android bundles: hand the checkout to Termux's user, so npm may chmod.
Every Android bundle of v12.51 stopped with EPERM: npm chmods each package's
bin when it installs (uuid/dist/bin/uuid), and only a file's owner may chmod
it - a+rwX on the bind mount was not enough. android.yml reads the uid of
Termux's user from the image, chowns the checkout to it before the rebuild,
and takes it back before packaging. An unreadable uid stops the job.

Thanks to xet7 !
2026-10-17 23:24:49 +03:00
github-actions[bot] 95c99e1b5e Bump versions for v12.51 2026-10-17 15:34:41 +00:00
Lauri Ojansivu c1cb54eb3b Prepare v12.51 release 2026-10-17 18:33:31 +03:00
Lauri Ojansivu 3eee7d54df CHANGELOG: CodeQL alert #588, Apple Glass Pastel as Liquid Glass.
Thanks to GitHub CodeQL and xet7 !
2026-10-17 17:56:05 +03:00
Lauri Ojansivu 6090be4dfd Fix GitHub CodeQL alert #588: remove template declarations to a fixed point.
js/incomplete-multi-character-sanitization in
tests/templatesAreRendered.test.cjs: the test removed template declarations
and Template.<name>.helpers/events/onCreated/onRendered/onDestroyed from the
source it scans with a single replace each, so removing one could splice its
neighbours into another (and CodeQL read the remains as a possible on...
attribute). Both removals now repeat until nothing changes, the fix used for
alerts #545 and #570-#582.

tests/tagStrippingFixedPoint.test.cjs keeps the shape out of the whole tree:
it now also finds a multi-character removal of an on<Capital> name outside a
fixed-point loop, and proves it catches alert #588's code and passes the
loop. The code is a test's source scan, never run on user input, so nothing
reaches Admin Panel -> Problems.

Thanks to GitHub CodeQL and xet7 !
2026-10-17 17:55:41 +03:00
Lauri Ojansivu 0c49f7f51b Apple Glass Pastel is Liquid Glass, and every text on it reads.
The theme follows Apple's Liquid Glass: the two header bars, swimlane
headers, lists, minicards, the opened card, the sidebar and popups are
frosted glass over the pastel mesh - a translucent fill,
blur(24px) saturate(180%), a rim of light along the edges (inset highlights,
brightest on top), a diagonal sheen and larger rounded corners. The header
bars are glass now instead of solid blue, with dark text; blue is kept for
the primary action, glossy, and for what is active. Reduce transparency, or
a browser without backdrop-filter, gets near-opaque surfaces.

A new UI test, theme-appleglasspastel-contrast.e2e.js, measures every text
and icon against what is really painted behind it - it hides the text,
screenshots, and reads the pixels under each box - to WCAG AA (4.5:1 text,
3:1 large text and icons), on the board, the opened card, the sidebar, the
board menu and All Boards, at laptop and phone size. It found and the theme
now fixes: white header buttons, icons, username and the stock logo on the
glass; the sidebar hamburger forced white by boardBody.js's accessibility
style; grey #808080 sidebar headings (3.8:1); #a6a6a6 icons (2.4:1); faint
Add Card; white text on the pale-blue hovered menu row; and the list
headers list.css pins grey with !important.

The header guards headerBars and themeAccents now compare a bar's whole
background, so a glass gradient counts, and list appleglasspastel as a glass
header whose accent is its primary blue.

Thanks to xet7 !
2026-10-17 17:55:34 +03:00
Lauri Ojansivu 321e3d7cbf CHANGELOG: Whiteboard; TODO Later is empty again.
Thanks to xet7 !
2026-10-17 16:57:33 +03:00
Lauri Ojansivu 390007e380 Whiteboard: a card's drawing full screen, that several people draw on at once.
Card menu -> Drawing -> Open as whiteboard opens the drawing under the header
bar, over the opened card, with the drawing's and Red Strings' own tools -
the seven colours and four widths, Undo, Clear and Save picture. The 800 x
500 drawing is scaled to the screen, so a line lands in the same place on a
phone and on a 55" screen; Escape or x closes it.

Several people draw at the same time: each line is sent when the pen lifts
as one atomic $push into that drawing (cards.whiteboardAddStroke), with its
own id and its author, and the card's publication brings it to everyone who
has the drawing open; the line stays drawn locally until it comes back.
Undo and Clear take away only the caller's own lines, Save picture writes
only the drawing's PNG, so nothing anybody else is drawing is overwritten.
Who may draw is who may edit the card; the others watch. A Board Settings /
Card row, Whiteboard, hides it on a board, and the Admin Panel's Card fields
everywhere. The drawing popup's PNG upload is shared with Save picture.
Translated in every language.

Thanks to xet7 !
2026-10-17 16:57:12 +03:00
Lauri Ojansivu d9bb7b4f4f docs/Features: Cards / Whiteboard, a drawing full screen that several people draw on at once.
Thanks to xet7 !
2026-10-17 16:27:33 +03:00
Lauri Ojansivu 813c313b1b CHANGELOG: checklist from description, action items, search phrases; TODO Later keeps the whiteboard.
Thanks to xet7 !
2026-10-17 16:26:17 +03:00
Lauri Ojansivu 3cb089b8ca Checklist from description, action items, and search phrases.
The last three of the features other kanban apps call AI, done with plain
code (docs/Features/General/Assistants/Assistants.md):

- Checklists -> From description: the list lines of a card's description
  ("- ", "* ", "1. ", task boxes; never inside code blocks) previewed, then
  made into a checklist - ticked where the line was [x] - or into subtasks
  through addSubtaskCard.
- Create cards from action items, on a comment and in the card menu for its
  description: "TODO: ...", "- [ ] ..." and "@user ..." lines become cards
  at the top of the card's list, linking back to it, with the mentioned
  user as member when they are an active board member.
- Global search understands plain phrases in the reader's language - "my
  overdue cards", "due this week", "unassigned cards" and seven more - and
  runs them as its operators, saying which; a query with an operator in it
  is never rewritten.

Checklist from description and Action items are Show on Card rows of Board
Settings / Card and of the Admin Panel's Card fields; Search phrases is an
Admin Panel / Settings / Visibility / Features tick. Translated in every
language.

Thanks to xet7 !
2026-10-17 16:25:51 +03:00
Lauri Ojansivu 9fa831fb0a Merge remote-tracking branch 'origin/main' 2026-10-17 16:11:10 +03:00
Lauri Ojansivu 777437bc6a CHANGELOG: Summary, Since your last visit, At risk, Suggested members, Forecast.
Thanks to xet7 !
2026-10-17 15:52:51 +03:00
Lauri Ojansivu 083c863fb7 Summary, Since your last visit, At risk, Suggested members and Forecast.
Five more of the features other kanban apps call AI, done with plain code
(docs/Features/General/Assistants/Assistants.md):

- Board menu -> Summary: what each person did in the last day or week -
  done, moved, added, commented - and what they have overdue, from the
  activities and History; Copy as text gives Markdown with card links.
- Board menu -> Since your last visit (N): what others changed since the
  visit before this one, by card. The visit is kept in profile.boardVisits,
  at most 500 boards.
- At risk: a weighted score of overdue, due soon with little done, blocked,
  idle in a middle list and sent back; a triangle on the minicard and the
  reasons in the opened card.
- Suggested members in the Members and Assignees popups, from who handles
  the same labels, list and swimlane.
- Forecast done date: the 50th and 85th percentile of the board's cycle
  times, counting only the cards that took at least as long as this one
  has been open. The server sends the times once per board, numbers only.

Summary and Since your last visit are features of Admin Panel / Settings /
Visibility / Features; At risk, Suggested members and Forecast are rows of
Board Settings / Card and of the Admin Panel's Card fields. Every server
answer applies the board's read rules and the assigned-only role.
Translated in every language.

The guards of the earlier helpers are brought up to date as well: the date
words of models/lib/titleDueDate.js are a reviewed RegExp site,
server/methods/ruleSuggestions.js is a board-admin-only path, the Rules
page's list branch carries the suggestions, and Tigre's new strings use its
own word for a board.

Thanks to xet7 !
2026-10-17 15:52:26 +03:00
Lauri Ojansivu 9a6b3e8859 CHANGELOG: due date from the title, similar cards, rule suggestions; TODO Later gains the whiteboard.
Thanks to xet7 !
2026-10-17 14:43:43 +03:00
Lauri Ojansivu 2211b24119 Rule suggestions from what people on a board keep doing by hand.
Board menu -> Rules shows Suggested rules above the rule list: when the
same person does one thing to a card and, within two minutes, another, that
pair is counted once per card, and a pair seen on three cards in the last 30
days is offered as a rule (moved to a list -> label, member or archive;
added to a list -> label or member; label added -> member). A pair a rule
already does is not offered. Create goes through rules.createRule with the
same trigger and action the wizard builds; Dismiss hides one suggestion for
that admin on that board (profile.dismissedRuleSuggestions).

Only board admins get suggestions, and Admin Panel / Settings / Visibility /
Features has a Rule suggestions tick that turns them off, on the server too.
Translated in every language.

Thanks to xet7 !
2026-10-17 14:42:59 +03:00
Lauri Ojansivu f8267d704e Due date from the card title, and similar cards and possible duplicates.
Two of the features other kanban apps call AI, done with plain code
(docs/Features/General/Assistants/Assistants.md):

- A due date typed at the end of a new card's title ("... tomorrow 14:00",
  "huomenna klo 14", "friday 9:30", "in 3 days", "23.10.") becomes the card's
  due date, read with the browser's own Intl tables so every language works.
  A hint under the field shows the date, and its x keeps the words. The
  opened card's title reads it too, but only when the title changed.
- Similar cards: letter triples and the Dice coefficient (letter pairs for
  scripts without spaces). The add-card form lists up to three close cards,
  marking a possible duplicate, and the opened card has a Similar cards
  section. The server method applies the board's read rules and the
  assigned-only role.

Both are rows of Board Settings / Card and of Admin Panel / Settings /
Visibility / Features / Card fields, so a board admin or a site admin can
hide them. Translated in every language.

Thanks to xet7 !
2026-10-17 14:39:43 +03:00
Lauri Ojansivu e76f12bcdd docs/Features: guides for the eleven helpers that need no AI, filed by menu, and how each is hidden.
Thanks to xet7 !
2026-10-17 13:57:56 +03:00
Lauri Ojansivu 6f6ae210c6 CHANGELOG: TODO Later lists the features other kanban apps call AI, to be done with plain code.
Thanks to xet7 !
2026-10-17 13:52:35 +03:00
github-actions[bot] 0d726aa725 Bump versions for v12.50 2026-10-17 03:44:38 +00:00
Lauri Ojansivu 5cadb33b1d Prepare v12.50 release 2026-10-17 06:43:30 +03:00
Lauri Ojansivu 31de4b40c5 CHANGELOG: Instrumentation bars by area.
Thanks to xet7 !
2026-10-17 06:04:05 +03:00
Lauri Ojansivu 2b8a33022b Problems / Instrumentation: the whole picture as one bar per area of WeKan.
The page listed up to 200 methods and publications on 11 pages. Above the
table there is now one bar per area - Settings, Login, People, Boards,
Cards, Lists, Notifications, Attachments, Problems and the rest - its length
the area's calls against the busiest area's, its red end the share that
failed, with the calls, the average duration and the errors beside it.
Clicking an area shows only its operations in the table; clicking it again
shows all.

models/lib/instrumentationAreas.js groups an operation by the first area
whose pattern matches its name, and weights an area's average duration by
completed calls. tests/instrumentationAreas.test.cjs checks the grouping
against all of WeKan's 614 methods and publications - "Other" holds three -
and the summary arithmetic. The labels are existing translation keys, apart
from the new instrumentation-other ("Other"), translated into every
language. The Finnish "cards" heading read "Korttia" (the form after a
number) in every place it is used as a heading or label; it is "Kortit".

The bars are HTML and CSS, one grid with subgrid rows so names, bars and
numbers line up; the viewport matrix checks the page at every device size.

Thanks to xet7 !
2026-10-17 06:03:55 +03:00
Lauri Ojansivu d9e91cb59a CHANGELOG: Shared labels colour picker, iPhone Duo, the add-list form's spacing.
The file was over its size budget; releases/changelog-archive.mjs moved its
oldest release to old-CHANGELOG/2026/10.md.

Thanks to xet7 !
2026-10-17 05:33:04 +03:00
Lauri Ojansivu 8c3e80240a Shared labels: the board labels' colour picker, at the size it has on a card.
Admin Panel / Settings / Shared labels chose a colour from a dropdown of
colour names. The board label popup's picker - the palette swatches and the
custom colour wheel - is its own template now, labelColorPicker, and the
popup and every Shared labels row use it; a custom colour is saved as its
hex, which the server already accepted. A pick reaches the row through an
object with set(), because Spacebars calls a function argument instead of
passing it.

The swatches grow to fill their row, so on the wide admin page their width
followed the window. There they have the size they have in an opened card's
label popup, 54 x 33px, at every window width.

tests/sharedLabelsColorPicker.test.cjs pins the shared picker and the size;
tests/playwright/specs/shared-labels-color-picker.e2e.js picks a swatch and a
custom colour, saves both, and measures the swatches at 1366, 1920 and
2560px.

Thanks to xet7 !
2026-10-17 05:32:38 +03:00
Lauri Ojansivu 85ab1b79a3 "or Template or link a list": the space before the second "or", and a clean Jade build.
The add-list form's alternatives are one span.quiet each in .edit-controls,
a flex row; a flex item drops the space its text begins with, so it read
"or Templateor link a list from another board". Adjacent .quiet spans get a
margin between them instead.

Every dev-server start also warned "missing space before text for line 26"
of editor.jade: inlineViewer had a bare {{> UI.contentBlock }} line under
+viewer. It is +Template.contentBlock, as elsewhere.
tests/jadeCompilesClean.test.cjs compiles every client template with the
build's own Jade compiler and fails on any warning or error.

Thanks to xet7 !
2026-10-17 05:15:00 +03:00
Lauri Ojansivu 0242249a1c Viewport matrix: the iPhone Duo, open and closed, instead of a Galaxy Fold.
The foldable in the screen-size test was a Galaxy Z Fold 7 standing in for
the iPhone Duo. Apple's specs (apple.com/iphone-duo/specs) give its inner
7.6" display as 1878 x 2670 px at 430 ppi and its outer 5.4" display as
1398 x 2034 px at 460 ppi; at the 3x of every current iPhone that is
626 x 890 and 466 x 678 CSS pixels, and the screenshots come out at exactly
Apple's pixel sizes. Both displays are tested upright and on their side with
the iPhone user agent and touch; all 40 checks pass.

Thanks to xet7 !
2026-10-17 04:53:54 +03:00
Lauri Ojansivu d4c7167928 CHANGELOG: the new UI checked and fixed at ten screen sizes.
Thanks to xet7 !
2026-10-17 04:06:00 +03:00
Lauri Ojansivu e34a488617 Phone, foldable, tablet, laptop and 55" touchscreen: the new UI checked upright and on its side.
tests/playwright/specs/viewport-matrix.e2e.js opens WeKan as an iPhone 15,
an unfolded Galaxy Z Fold 7, an iPad Pro 11, a 1366x768 laptop and a 55"
4K touch panel, each upright and on its side, and checks the board header,
board menu -> Original positions, Problems -> Memory Usage and Disk usage,
the Table, Calendar Mode, Time, Timeline and Map views, and dragscroll by
mouse or by finger. It measures that icons and texts are on screen and not
drawn in the colour behind them, that the page never scrolls sideways, that
every column can be reached and stays readable; screenshots of each step go
to .tools/log/viewports/. What it found, fixed:

- iPhone: the header bar was width: 100% plus its side padding as a content
  box, 24px wider than the screen, and the sidebar hamburger sat past the
  right edge. It measures its padding inside now.
- Admin Panel left menu on a phone: the list's disc bullets showed as a dot
  after every entry, and a rule between groups shrank to a dash beside an
  entry. No bullets, and each group starts a row of its own.
- Admin tables: 13 equal columns were 25px each on a phone and 42px on an
  upright iPad, every header one letter per line. Up to 1024px a column keeps
  a readable floor and the table scrolls sideways in its wrapper.
- Table view: the title-wrap and swimlane-group toggles were white icons on
  white - two empty squares - stretched to 72px by the global button padding.
  And on an upright iPad or foldable, Members, Labels and the date columns lay
  past the edge of an overflow-hidden board with nothing to scroll; the view
  scrolls sideways itself now.

A swipe that starts beside a list's resize handle resizes the list, as it
should, so the dragscroll check starts where a person pans. The file runs in
Chromium only: device emulation and the touch swipe exist only there.

Thanks to xet7 !
2026-10-17 04:05:49 +03:00
Lauri Ojansivu 0b6942687f diskMonitor: name the watched path, not an archive entry.
fixedVulnerabilityClasses reads entry.path as an archive entry and asks for
a containment check; the disk monitor's loop variable was only named that.

Thanks to xet7 !
2026-10-17 01:35:19 +03:00
Lauri Ojansivu f819932e60 CHANGELOG: Memory and Disk usage, Original positions, board view fixes, shared code.
Thanks to xet7 !
2026-10-17 01:24:22 +03:00
Lauri Ojansivu b9e28a8fbb Original positions: a right-sidebar view from the board menu, in every language.
Where each swimlane, list and card was put when it was made has been
recorded on creation for a long time, and shown by nothing: the two
templates for it were drawn nowhere, every string was English and they
printed raw ids, and the client helpers beside them were never called.

Board menu → Original positions (after Archive, open to everyone who sees
the board) opens originalPositionsSidebar: Swimlanes, Lists and Cards, each
by the name it has now, Moved when it is no longer where it was put, where
it was put by the names those have now, the title it was made with when that
changed, and when. The server already narrows the history to what the
reader may see, assigned-only roles included.

The text is existing translation keys plus one new one, original-positions,
translated into every language. models/lib/originalPositions.js builds the
groups and is tested without Meteor; the old templates, the per-item
component and the uncalled helpers are removed, and the design page is now
the feature's guide.

Thanks to xet7 !
2026-10-17 01:23:15 +03:00
Lauri Ojansivu b6a3d98736 Admin Panel / Problems: Memory Usage and Disk usage reports.
The RAM and Disk usage pages were designs that no code carried out. Both
monitors now run beside the CPU one and, like it, record only the start and
the end of a sustained high-usage period:

- Memory: RAM from /proc/meminfo's MemAvailable - os.freemem() is MemFree,
  which counts the page cache as used and shows a healthy server as full -
  and swap, which alone also starts a period. Without /proc/meminfo it uses
  what Node reports. Settings WEKAN_RAM_* and WEKAN_SWAP_HIGH_PERCENT.
- Disk: each filesystem WeKan writes to - the files root, its temp directory
  for uploads and the snap's data directory - measured once per device, with
  an episode of its own. WEKAN_DISK_WATCH_PATHS replaces the paths.
- Each report has a live line above its rows and a URL
  (/admin/problems/ram, /admin/problems/disk). The menu and titles use the
  existing memory-usage and sandstorm-disk-usage keys, which every language
  translates, and the Summary names the cpu, ram and disk streams.

Observe and report only, as designed. The three monitors share one event
recorder (server/lib/resourceLog.js) and one intEnv (models/lib/intEnv.js),
which three files had each defined. The pure halves are tested without
Meteor, and resourceMonitorsWiring checks the wiring and that no copy of
either shared piece returns.

Thanks to xet7 !
2026-10-17 01:14:16 +03:00
Lauri Ojansivu c046afa807 Guides for the Table, Calendar Mode, Time, Timeline and Map views.
Each was described only in Board Settings / Board View. Their guides are
written from the templates and the server code: what each shows, every
control, what can be changed and who may change it, where its settings
live, and the files and tests behind it. Board-Header/README.md links them,
boardViewSettings fails when a Board View entry has no guide of its own, and
TODO Later loses the gap and the code-to-combine item that is done.

Thanks to xet7 !
2026-10-17 01:02:44 +03:00
Lauri Ojansivu 0fff9d4828 Board view fixes, and failed calls reported in the reader's language.
- Calendar, Calendar Mode and Multi Board Calendar showed archived cards
  once the Archive sidebar had loaded them; every calendar selector leaves
  them out now.
- Timeline offered "Restore to this state" to every viewer, and a refused
  restore vanished, since nothing awaited it. The button is shown to those
  who may change the card, and a refusal is reported.
- Table: a swimlane header row spanned a fixed 11 columns, one short with
  all four date columns and too wide on a phone; it spans the columns the
  table has. Its add-card error was an English sentence.
- Map showed the raw "upload-failed" for a failed upload; it is translated.
- client/lib/errorMessage.js turns a failed call into a message in the
  reader's language: the translation when the error code is a translation
  key, otherwise the translated "Something went wrong" with the server's
  reason after it. 25 alert() calls with their own English fallback use it.

tests/errorMessageI18n.test.cjs runs the helper and fails on an alert()
that falls back to English again; calendarFilter tests the archived cards
for all four selectors.

Thanks to xet7 !
2026-10-17 01:01:34 +03:00
Lauri Ojansivu 483f3cd538 The file integrity scan waits for a quiet CPU again.
fileIntegrityScan.js asked cpuMonitor for currentCpuPercent, which it never
exported, so cpuPercent() was always NaN: the hourly scan never skipped a
busy hour and every finding recorded cpuPercent NaN. It reads
getCurrentCpu().percent now.

Five files imported names their modules do not export - UserAvatar,
AttachmentStorage and Settings as a named import - none used; they are
removed. tests/namedImportsExist.test.cjs checks every named import and
destructured require of an app module against what that module exports.

Thanks to xet7 !
2026-10-17 00:56:00 +03:00
Lauri Ojansivu 5908fab5de The Calendar and Multi Board Calendar views share their calendar options.
Both views wrote out the same FullCalendar options - first day of the week
and the week numbers that follow it, time formats, the one-row toolbar,
translated button labels, height, locale and direction - about fifty lines
each. models/lib/calendarBaseOptions.js builds them once, given the
translation, the user's first day and the display options, and each view
adds only what differs: dragging and selecting, navLinks, business hours
and its events.

The tests that read the toolbar or the direction from boardBody.js read the
shared module, and the Multi Board Calendar runtime test runs it for real.

Thanks to xet7 !
2026-10-17 00:54:02 +03:00
Lauri Ojansivu d444a76af2 One list drag-and-drop setup for a swimlane and the Lists view.
Template.swimlane and Template.listsGroup each built the same jQuery UI
sortable for their row of lists - options, placeholder sizing, dragscroll
pause and the autorun that follows the drag-handle and permission changes -
two copies of about fifty lines. initListsSortable builds it once and, as
listsGroup already did, replaces a sortable already on the element.

The guards that counted three list sortables count the shared one and check
that both templates call it; each is still gated by canModifyBoard().

Thanks to xet7 !
2026-10-17 00:51:54 +03:00
Lauri Ojansivu 9a73bf9173 Describe each card date once for its card and minicard badge.
Received, Start, Due and End were each written twice in cardDate.js, once
for the opened card (card*Date) and once for the minicard (minicard*Date):
the same field, the same colouring, the same title and the same editor,
eight copies of about 40 lines. CARD_DATE_KINDS describes each date once
and both templates are built from it; only the due date counts down, as
before. cardDate.js goes from 596 to 410 lines.

The tests that matched each template's own helpers block read the
CARD_DATE_KINDS entry instead, and still fail if Received, Start or End
gains the countdown.

Thanks to xet7 !
2026-10-17 00:50:41 +03:00
Lauri Ojansivu dd9832ce7e CHANGELOG: TODO Later lists what is built but unused, designed, undocumented or to combine.
Thanks to xet7 !
2026-10-17 00:48:11 +03:00
Lauri Ojansivu 0d3d0a407b CHANGELOG: cloning, visitor Scrum views, removed duplicate code, docs examples.
Thanks to xet7 !
2026-10-17 00:25:51 +03:00
Lauri Ojansivu a6aa3fdfb7 One state helper for the board's organization and team pickers.
The members widget and the add/remove organization and team popups of the
Right sidebar each built the same onCreated state by hand - error and
loading flags, a paged subscription and setError/setLoading/isLoading - five
copies of about 25 lines. initGroupPickerState builds it once; each picker
keeps the publications it subscribed to before.

Thanks to xet7 !
2026-10-17 00:24:59 +03:00
Lauri Ojansivu 73e815b4f4 Share the fixed list width helpers between the list and its header.
#5729's fixed (same) width for all lists is read by list.js and turned on
from the list header menu in listHeader.js, and each carried the same five
localStorage/profile helpers. They are client/lib/fixedListWidth.js now,
with the same storage keys, so a viewer's saved choice still applies.
tests/fixedListWidthShared.test.cjs fails if a client file grows its own
copy again.

Thanks to xet7 !
2026-10-17 00:23:19 +03:00
Lauri Ojansivu a896f3ed8c A visitor's Scrum board view opens as chosen, not as Swimlanes.
Without an account the browser keeps the board view, and client/lib/utils.js
had two hand-written copies of the view list for it: setBoardView stored
any of them, but storedBoardView's copy lacked Product Backlog, Sprints,
Sprint Report and Velocity, so those opened as Swimlanes after the reload.
Both now ask isKnownBoardView, i.e. BOARD_VIEWS, which removes the two
lists (about a hundred lines) and the drift with them.

The guards that counted view names in utils.js check the isKnownBoardView
calls instead, and boardViewMenu fails if a hand-written list returns.

Thanks to xet7 !
2026-10-17 00:20:32 +03:00
Lauri Ojansivu 7dc5399dc7 Remove code that a second copy replaced, and templates nothing draws.
- models/cards.js defined assignMember ... toggleAssigner twice in one
  Cards.helpers({}); in an object literal the last key wins, so the first
  twelve methods (about a hundred lines, with their own linked-board
  branches) never ran. models/users.js had two copies of the swimlane-height
  storage helpers, and the header's event map an empty
  'click .js-open-bookmarks' the real handler replaced. Each first copy is
  removed; what runs is unchanged.
- An Admin Panel attachment settings pane: attachmentSettings.jade (four
  templates) and about 300 lines of settingBody.js for S3, transfer limits
  and storage paths. No menu opened it and no template drew it; Admin Panel /
  Attachments (attachments.js) is the one in use.
- Templates drawn by nothing: dateBadge, dateCustomField and
  cardCustomFieldDate with its JS, orgAvatar and teamAvatar, the three
  *ModalTitle templates of My Cards, Due Cards and Global Search, boardLists,
  boardCreateRulePopup, addMemberPopupTest, and subtasksItems with the two
  templates only it used.
- CSS repeated word for word: accessibility.css opened with a copy of
  myCards.css's first 74 lines; list.css repeated its 768px collapsed-list
  block at 1024px and 1200px, which 768px already covers; boardHeader.css
  repeated its .mobile-mode rules inside a 801px media query. The later
  copies decided nothing, so the result is the same.

tests/noDuplicateObjectKeys.test.cjs parses every app and package file and
fails on an object literal that defines a key twice, and
tests/templatesAreRendered.test.cjs fails on a template nothing draws,
apart from runtime-built names and three listed exceptions. Tests that read
the removed copies now read the code that runs.

Thanks to xet7 !
2026-10-17 00:19:00 +03:00
Lauri Ojansivu 7e29641111 Await the member mapping in cloneBoard.
getMembersToMap (models/wekanmapper.js) is async, and cloneBoard kept its
Promise and called membersMapping.forEach on it, so every clone through
that method stopped with "membersMapping.forEach is not a function". The
board menu clones with copyBoard, so this was the cloneBoard method only.

WekanCreator carried an identical getMembersToMap that nothing called; it
is removed, leaving the one in wekanmapper.js.

tests/asyncResultAwaited.test.cjs resolves, file by file, every variable
assigned from an async function - declared there or imported async - and
fails when it is then used as an array or object without await.

Thanks to xet7 !
2026-10-16 23:54:39 +03:00
Lauri Ojansivu 11babe27f2 Docs: snap keys, setting guide and Docker examples that match this WeKan.
- Three snap set lines named keys the snap does not have, which snapd
  stores without a word: MONGO_OPLOG_URL (the key is mongo-oplog-url),
  oauth-enabled (oauth2-enabled) and "snap set wekan set disable-mongodb".
  tests/docsSnapKeysExist.test.cjs checks every snap set in the docs
  against the KEY_ names in snap-src/bin/config.
- Adding-new-Snap-settings-to-code.md used setting names that do not
  exist (LDAP_ENABLED, LDAP_SEARCH_FILTER, a wrong KEY_ line), the edge
  branch and Travis CI. It now follows the real config and wekan-help
  entries, says that a login setting goes through the Admin Panel catalog
  and authEnv(), and lists the other platform files a setting belongs in.
- The LDAP and Traefik Docker examples ran MongoDB 3.2/4.0 with
  --smallfiles, which MongoDB 4.2 removed, and WeKan images from 2018. They
  use mongo:7 and ghcr.io/wekan/wekan:latest; Traefik stays at v1.7, since
  that guide's labels and traefik.toml are v1 syntax.
- The Windows page no longer sets RICHER_CARD_EDITOR, which WeKan does not
  read, and points to build.bat for building from source.

Thanks to xet7 !
2026-10-16 23:52:46 +03:00
Lauri Ojansivu 2e3cff4d7d CHANGELOG: LockoutBleed follow-up, loaded server features, notification attachments, docs by menu.
Thanks to xet7 !
2026-10-16 23:46:42 +03:00
Lauri Ojansivu 262f9194fc Show notification attachments, and translate six labels that showed their key.
The notifications drawer subscribed to 'notificationsAttachments', while the
server publishes 'notificationAttachments'. Meteor reports nothing for an
unknown subscription, so it never became ready and an attachment named in a
notification was never sent to the drawer.

Six template keys did not exist in en.i18n.json, so the page showed the key
itself: the Due group of the sidebar filter ("dueAt"), the Scrum sprint form
("end", "no-cards", "custom-field"), the Jira estimate field on import, the
passkey trash icon ("remove") and two webhook URL fields ("URL"). Each now
uses the existing key with that wording, so every language that already
translates it shows it translated.

tests/subscriptionsArePublished.test.cjs checks every subscribe() in the
tree against every publication, and tests/templateI18nKeysExist.test.cjs
every literal key of every template against en.i18n.json.

Thanks to xet7 !
2026-10-16 23:45:58 +03:00
Lauri Ojansivu b7589737f5 File docs/Features the way the WeKan menus are.
A feature's guide now sits under the menu it is used from, read from the
templates rather than from where somebody once put it: Header-Bar,
All-Boards, Pages, Board-Header (search and every Board View), Board-Sidebar
(Board Settings, Filter, Members, Labels, Archive), Rules, Swimlanes, Lists,
Cards, Member-Settings, Admin-Panel and Login-Page. Topics used from several
menus (editor, history, theme, translations, email, troubleshooting) are in
General.

- Each area has a README mapping its menu entries to their guides and to the
  template or server module that implements them. Board-Header/README.md
  lists all 40 Board View entries with the template boardBody.jade renders;
  five views (Table, Calendar Mode, Time, Timeline, Map) have no guide of
  their own yet and say so.
- Board-View.md listed 25 views while BOARD_VIEWS has 40; it now lists all
  of them with their English labels.
- Pages keep their file names. Where a released changelog links an old path,
  a "# Moved" page stays there and points to the new one; test-menu.sh skips
  folders holding only those.
- Undo was filed under Login; it is General/Undo now.
- Every link in docs, code comments, scripts and tests follows the move;
  docsLinksResolve checks them all.

Tests that read a doc follow it to its new place. boardViewSettings now fails
when Board-View.md or Board-Header/README.md differs from BOARD_VIEWS, or
names a template boardBody.jade does not render.

Thanks to xet7 !
2026-10-16 23:45:35 +03:00
Lauri Ojansivu 70dcc2a134 Remove files nothing uses, and point their tests at the code that runs.
Found by following every import, re-export and require from client/main.js
and server/main.js (meteor.mainModule: only what that reaches is loaded) and
each local package's package.js:

- packages/wekan-fullcalendar/fullcalendar/ (88 files) and update.sh: the
  vendored FullCalendar v3 and the script that updated it as a git
  submodule; the package takes FullCalendar 5 from npm (Npm.depends).
- packages/wekan-fontawesome/fontawesome-free: js/, sprites/, metadata/,
  attribution.js and the .min.css copies; package.js adds only the plain CSS
  and the webfonts. Its package.json no longer names the removed files.
- models/lib/propagateMembers.js and models/lib/teamBoardMemberSync.js:
  replaced on 2026-10-14 by models/lib/groupBoardMembership.js (memberships
  that record their team or org, #4178), which the server uses. Their tests
  exercised the old modules; the same scenarios now run against planGain /
  groupChanges, and the wiring checks are kept.
- models/lib/recoveryPlan.js and its test: a model of the SQLite recovery
  policy that nothing ran; releases/ferretdb/sqlite-recovery.mjs carries it
  out and tests/sqliteVerifiedRecovery.test.cjs tests that. The Recovery
  design page says so.
- packages/wekan-ldap/server/configResolver.js: a vendored copy nothing
  imported - the LDAP package reads its settings through the app's
  accessor. Its sync test is now a check that no package carries such a
  copy; three comments that sent readers to it explain the reason instead.

docs/Design/TeamsOrganizations.md also said losing a team removes nothing;
since #4178 a team takes back what it gave.

Thanks to xet7 !
2026-10-16 22:54:48 +03:00
Lauri Ojansivu 3f3a61df0c Load three finished server features that were never imported.
package.json sets meteor.mainModule (since 2026-03-22), so the server runs
only what an import chain from server/main.js reaches. Three modules added
after that were never imported, so they never ran, while their tests - which
read the file - passed:

- server/loginTimingNormalization.js: the DDP `login` half of the LockoutBleed
  timing fix (GHSA-2g94-9x3m-hv37). Only the REST login was equalized, so the
  web login's response time still told whether a username exists.
- server/cardRecurrenceSchedule.js: the hourly job behind a card's
  Recurrence setting; the setting was saved and nothing ever recurred.
- server/productNameCache.js: the Admin Panel product name for the snap's
  pages shown while the database is down.

They are imported in server/imports.js beside their related modules. The
Meteor internals the login handler uses (Accounts._loginHandlers,
_checkPasswordAsync, _findUserByQuery) exist in this Meteor release.

tests/serverModulesLoaded.test.cjs follows the import chain from
server/main.js and fails when any server module that registers something at
load time - a startup hook, methods, publications, a cron job, a login
handler or a route - is not reached; it fails on the old imports.js.

Thanks to xet7 !
2026-10-16 22:35:18 +03:00
github-actions[bot] 615d0bb0b8 Bump versions for v12.49 2026-10-16 12:26:53 +00:00
Lauri Ojansivu 68f4e21f98 Prepare v12.49 release 2026-10-16 15:25:40 +03:00
Lauri Ojansivu b2705905e9 CHANGELOG: custom roles apply in every board view.
Thanks to xet7 !
2026-10-16 15:23:24 +03:00
Lauri Ojansivu 1020cd7e86 Custom roles apply in every board view: verified, tested and documented.
A custom role from Admin Panel -> People -> Roles reaches every board view
through the same two questions as a built-in role: what the member may do is
asked of the capability table, which answers from the custom role, and which
cards they see is asked of the member's flags, which the custom role writes.
fallbackRoleFor() picks only among built-in roles that see no more cards, so
a role whose "Sees all cards" is off always mirrors an assigned-only role and
one with it on never does. The flags are written when a member gets the
role, rewritten on every board when the role is saved, replaced with the
custom role by setMemberPermission, and kept when the role is deleted; no
other code writes them.

tests/boardRoleViews.test.cjs builds a member for all 32 capability
combinations as the server writes it and checks that the card scope, the
card rule, REST reads and writes, button rules and the real Email View
selector narrow them exactly when the role does not see all cards, and that
nothing sets a role flag without the custom role following.
server/lib/tests/customBoardRoles.tests.js turns "Sees all cards" off and on
for a role in use and checks its member becomes assigned-only and back
(meteor test; not run here). docs/Features/Members/Roles.md explains it in
"Custom roles in every board view".

Thanks to xet7 !
2026-10-16 15:23:16 +03:00
Lauri Ojansivu 7dbde2ea83 CHANGELOG: document how board roles apply to every board view.
Thanks to xet7 !
2026-10-16 15:19:33 +03:00
Lauri Ojansivu 63117b61d9 Docs: how board roles apply to every board view.
docs/Features/Members/Roles.md gains "Roles in every board view": a role is
the member's on the board, not the view's, so every view, the API and every
write ask the same code. A table names each of the 40 board views and the
server source it reads - the board publication, the table page, Scrum, board
status, activities, the Email and Chat views - and what an assigned-only
member gets from it; the board report charts are refused to them, as exports
are, because they are computed from every card. Then search, All Boards,
notifications, position and change history, exports, REST and writes, what
is recorded in Admin Panel -> Problems, and the tests that keep a new view
from skipping the rule. The old "Which cards they see" paragraph, which
named only the board publications, points to it.

Thanks to xet7 !
2026-10-16 15:19:26 +03:00
Lauri Ojansivu 486b2b3034 CHANGELOG: board role permissions apply to every board view (AssignedBleed follow-up).
Thanks to crypto-nidh, binary-lover and xet7 !
2026-10-16 13:04:34 +03:00
Lauri Ojansivu 5b2ed17e6b Board role permissions apply to every board view and every path to a card.
AssignedBleed follow-up, GHSA-r4gm-mjj9-g3x8. An assigned-only member
(isNormalAssignedOnly, isCommentAssignedOnly, isReadAssignedOnly, and custom
roles through those flags) sees only the cards assigned to them. The Board
Email View checked board.isVisibleBy() alone, so it published the title,
description and email metadata of every card in a list or label, counted
them, and searched them. It now merges assignedOnlyCardScope into its folder
and search selector for the page and the count, and a reply on another card
is refused.

An audit of every path that returns or changes a card found the same shape -
"a member of the board, so every card of it" - elsewhere, all fixed here:

- reads: the board publication's ancestor cards, linked source cards with
  their subtasks and dependency targets, and comment reactions; All Boards
  tile counts; a board's activities from linked boards; the Chat view and
  the All Boards feed, which also let card MEMBERS through (assignees is the
  rule); My Attachments' card; GET /api/user/cards; position history of a
  card; the cross-board subtask picker; Scrum rollover counts; and the
  notification publications, which kept sending the full documents of cards
  named in old notifications - also to members removed from the board;
- writes on a card the member cannot see: the canonical
  canEditCardOrLinkedCard / editableCardIds (every Cards.allow update,
  checklists, items, attachments, text notes, custom fields, the shared
  description, card methods) now ask the card - the role's `write` is a
  board answer, and the card is read when a caller passed it without
  assignees; DDP and REST comments, button rules on a card, checklist moves,
  issue state, watching, the card-opened webhook, moveBoardObjects,
  moveSwimlane and attachment moves;
- REST writes: checkBoardWriteAccess refuses assigned-only members, as
  checkBoardAccess already does for reads - those routes act on any card,
  list or swimlane by id. The GHSA-f396 canary still fires for a role
  without the write capability.

A refusal no view would produce is recorded under AssignedBleed in Admin
Panel -> Problems (authz.assigned, medium: never disables); opening a view,
searching and REST calls are narrowed or refused without a record.

tests/boardRoleViews.test.cjs runs the Email View selector, pins every path
above, and fails when any server file that serves card data has no role
rule and no stated exemption; the "assignee or card member" rule is
rejected anywhere in server/ and models/. server/lib/tests/editableCardIds
adds an assigned-only case for both edit checks (meteor test; not run here).
Seven guards that pinned the old lines now pin the new ones.

Thanks to crypto-nidh, binary-lover and xet7 !
2026-10-16 13:03:34 +03:00
github-actions[bot] 83e39fc8d9 Bump versions for v12.48 2026-10-15 20:30:17 +00:00
Lauri Ojansivu f89ce1950c Prepare v12.48 release 2026-10-15 23:28:46 +03:00
Lauri Ojansivu 303b4a09b0 CHANGELOG: release workflow hardening and the bandit findings, found by Qlty.
Thanks to Qlty and xet7 !
2026-10-15 23:20:15 +03:00
Lauri Ojansivu ee2a657977 Python: the five bandit findings Qlty reports.
- B324 docs/Features/Login/ldap-sync/ldap-sync.py: the MD5 of an LDAP photo
  only notices a changed photo, so it says usedforsecurity=False.
- B202 tests/nextcloud/bootstrap.py: the downloaded WeKan release zip is
  extracted only after every member is checked to land inside the target
  (zipfile also refuses '..' and absolute names; now it is explicit).
- B102 tests/backfill-charts-pagination.py: exec of this repository's own
  backfill-charts.sh planner under test, in an empty namespace with the
  network patched out - kept, and marked with its reason.
- B108 tests/release-telemetry.py: '/tmp/check-telemetry.py' is a fake
  __file__ for the path logic; nothing is written there - marked.
- B106 tests/ucsRelease.test.py: the fake portal's expected value is a test
  fixture, passed through a variable rather than as a password literal.

bandit reports none of the five now; the three tests pass.

Thanks to Qlty and xet7 !
2026-10-15 23:19:57 +03:00
Lauri Ojansivu 7f7cc63cde GitHub Actions: no expressions in scripts, actions pinned, least privilege.
Qlty (zizmor) security findings in the workflows that build and publish
every release:

- template-injection (128): ${{ ... }} pasted into a run: script is expanded
  before the shell sees it, so a crafted workflow_dispatch input, tag or step
  output becomes shell code. 251 expressions in 13 workflows now reach their
  scripts through the step's env: and are used as quoted shell variables
  ($env:NAME in the PowerShell steps); ${{ env.X }} is plain $X. Where a
  value sat in single quotes it is double-quoted now, and six unquoted ones
  are quoted.
- unpinned-uses (20 in Qlty, 160 with current zizmor): every action is
  pinned to a commit, with its version as a comment for Dependabot.
  actions/dependency-review-action@v5 was a branch; it is v5.0.0.
- excessive-permissions (8): AppImage, Flatpak, mac, windows,
  release-all and release-all-missing grant contents: read at the workflow
  level, and each job that writes (a release asset, a git push, the docker
  push, gh run rerun) is granted that on the job. Nothing used actions:
  write except recover, which keeps it.
- artipacked (47 -> 13): actions/checkout gets persist-credentials: false,
  except in the jobs that push with the checkout's credentials, directly or
  through releases/version.sh, release-website.sh, release-charts.sh and
  backfill-charts.sh.

actionlint with shellcheck reports 50 notes, the 56 before minus the six
quoted. Every PowerShell step parses (pwsh 7.6.7). Not run on GitHub.

tests/workflowHardening.test.cjs fails on an expression in a run script,
an unpinned action, a workflow-level write grant in a multi-job workflow,
or a checkout keeping credentials outside the pushing jobs, and runs
zizmor when it is installed. Eleven guards that pinned the old text, or ran
the extracted scripts with the expressions substituted, now pin the env
form and pass the values as the step's environment.

Thanks to Qlty and xet7 !
2026-10-15 23:19:50 +03:00
Lauri Ojansivu 4ea6f5b90c CHANGELOG: AssignedBleed follow-up, assigned-only members' file and document reads.
Thanks to crypto-nidh and xet7 !
2026-10-15 22:42:40 +03:00
Lauri Ojansivu 7f3428db8e Keep assigned-only members to their own cards' files on every read path.
AssignedBleed follow-up, GHSA-3cgr-47wm-9cc9. An assigned-only member
(isNormalAssignedOnly, isCommentAssignedOnly, isReadAssignedOnly) sees only
the cards assigned to them. 1a218ead02 applied that to the attachment API,
but six other paths asked only for board membership, which such a member
has:

- the /cdn/storage/attachments and /cfs/files/attachments downloads
  (universalFileServer isAuthorizedForBoard) served any card's file;
- Attachments.protected, ostrio:files' own download check, likewise;
- the legacy /cfs/files/attachments route likewise;
- searchAttachmentDocumentText matched the extracted text of every card's
  PDF/DOCX/XLSX/PPTX on the board and returned snippets with their
  attachment and card ids - no id needed, and it supplied the ids for the
  downloads;
- legacyBoardAttachments without a cardId published every legacy
  attachment record of the board;
- linkedListCards published every card of the source list.

The downloads now also ask mayReadBoardAttachment(), which names the
refusing path in Admin Panel -> Problems (authz.assigned, medium: never
disables). The document search and the legacy publication are narrowed to
the member's cards (assignedOnlyCardIds); asking the publication for a card
not assigned to them is refused and recorded, as card details always pass
the open card. Linked-list cards take the source board's
assignedOnlyCardScope; searching and viewing a linked list are ordinary
use, so those are not recorded.

The board's own files - background (sidebar or API upload) and map image -
carry no card and are part of the board every member sees; they stay
readable, where the attachment API refused them to an assigned-only
member. Any other card-less file is still refused.

tests/assignedOnlyReads.test.cjs pins each path, the board-file rule and
the refusal source, and fails when any file in server/ or models/ that
serves attachment or document data lacks the rule without a stated
exemption. The Playwright spec checks both downloads refuse the hidden
card's file but not the member's own, that the search finds only the
member's card, and that the owner is unaffected. Four source guards that
pinned the old lines now pin the new ones.

Thanks to crypto-nidh and xet7 !
2026-10-15 22:42:14 +03:00
Lauri Ojansivu ceec2e9c5a CHANGELOG: Caddyfile examples are Caddy 2, and the snap's Caddy proxies to WeKan's port.
Thanks to Group-Deed and xet7 !
2026-10-15 22:06:02 +03:00
Lauri Ojansivu 37d2397603 Caddyfile examples are Caddy 2, and the snap's Caddy proxies to where WeKan listens.
The snap runs Caddy 2, and migrate-caddyfile-v1-to-v2.sh treats a Caddyfile
that `caddy adapt` rejects as Caddy v1: it moves it to Caddyfile.v1.bak-<n>
and, when it cannot convert it, writes the default in its place - on every
start. 19 Caddyfile examples in the docs were Caddy v1 (`proxy / localhost:3001
{ websocket transparent }`, `browse`, `fastcgi`, `tls self_signed`, `ext`,
v1 `log`/`errors`, `root /path`), so a file copied from the snap install page
was renamed each time it was put back. All are now Caddy 2 (reverse_proxy,
file_server browse, php_fastcgi, tls internal, try_files, log output) and pass
`caddy adapt` with Caddy 2.11.7. Also:

- `http://X https://X { tls ... }` became `X {`: Caddy 2 refuses TLS on the
  http address and redirects to https by itself;
- the Windows examples `load` the certs folder - Caddy 2's load takes folders;
- the Docker Meteor3 Caddyfile gave the Let's Encrypt and CloudFlare variants
  of one site the same host, which Caddy 2 refuses as ambiguous; each has its
  own host now;
- Caddy.md's logs example and its systemd unit, installer and setcap are
  Caddy 2 (`caddy run --config`, no getcaddy.com, no -conf/-agree/-email).

The fallback template the migration writes proxied to localhost:3000. With
Caddy enabled the snap runs WeKan on caddy-bind-port (default 3001), so a
migrated site pointed at nothing. Both templates now read the snap settings
from caddy-control's environment - `:{$CADDY_PORT:8080}` and `reverse_proxy
localhost:{$CADDY_BIND_PORT:3001}` - so `snap set wekan port=...` reaches
Caddy on the restart the configure hook already does; that hook's sed lines
only ever matched Caddy v1 lines. The commented TLS example in the template
had `protocols h1` inside a site and a stray `}`, so uncommenting it failed;
it uses `alpn http/1.1` now.

tests/caddyfileExamplesV2.test.cjs finds every Caddyfile in the docs and the
snap, fails on Caddy v1 syntax and on a Caddy 1 command line, checks the
templates' ports, and with a Caddy 2 binary runs `caddy adapt` on all 30.
snapStagePackages.test.cjs pinned the wrong port 3000 and now pins 3001.

Thanks to Group-Deed and xet7 !
2026-10-15 22:05:49 +03:00
Lauri Ojansivu 43be0345e6 CHANGELOG: LDAP background sync imports users and keeps its interval.
Thanks to tromlet and xet7 !
2026-10-15 20:46:03 +03:00
Lauri Ojansivu a944be1817 LDAP background sync imports users again, and keeps the interval it is given.
After the 12.47 fix, #6771 comment 6197931378 showed the background sync
running and importing nobody: "filter": "(&(&(objectclass=person))(uid=\2a))",
"Search result count 0". importNewUsers() asked for every user with the string
'*', and the RFC 4515 escaping added for login names on 2026-09-27
(679a8b3499) turned the wildcard into a literal asterisk. The wildcard is now
reachable only through ALL_LDAP_USERS (userSearchValue.js), a Symbol that a
login request cannot carry, so a typed '*' is still escaped.

The sync also ran far more often, or less often, than set: later.js is a
calendar, and every(n).minute() is "minutes 0, n, 2n ... of each hour", an
interval only when n divides 60. 2 hours and 1 day both became hourly, 90 s
became every minute, and 3600 (seconds, typed as milliseconds) every 4
seconds. Milliseconds now become an interval later keeps exactly - seconds
and minutes dividing 60, hours dividing 24, whole days - pinned to the start
of the unit; anything else is rounded to the nearest one with a warning, and
an interval under a minute is warned about as probably seconds.

tests/ldapImportAllUsers.test.cjs reproduces the reported filter and checks
the wildcard, the still-escaped typed '*', and that no code searches the
string '*'. tests/ldapSyncSchedule.test.cjs checks every interval up to 36
hours reaches later only as an every(n) it keeps, and with the real later.js
that 1 min ... 1 week keep their spacing.

Thanks to tromlet and xet7 !
2026-10-15 20:45:49 +03:00
Lauri Ojansivu bd7f9d2848 Merge remote-tracking branch 'origin/main' 2026-10-15 16:39:50 +03:00
Lauri Ojansivu 62166680f1 CHANGELOG: Windows and NetBSD bundle builds.
Thanks to xet7 !
2026-10-15 16:37:36 +03:00
Lauri Ojansivu fa62479245 NetBSD bundle: node-gyp finds pkgsrc's Python.
The NetBSD x64 bundle of v12.47 stopped in npm install: argon2's
native build ran node-gyp, which looks for `python3` or `python`, and
pkgsrc's python313 installs only /usr/pkg/bin/python3.13 - "You need to
install the latest version of Python". OpenBSD's package provides
python3, so only NetBSD failed.

releases/bsd/build-bundle.sh now, when neither is on PATH and PYTHON is
not set, hands node-gyp the versioned interpreter it finds
(/usr/pkg/bin/python3.N, skipping python3.N-config) through PYTHON and
npm_config_python, before the npm install; with no Python at all it
stops with an ::error:: naming the package. Simulated locally with a
pkgsrc-shaped directory and no python3 on PATH; with python3 present
nothing changes. tests/releaseScriptsUtf8.test.cjs pins the lookup and
its place before the install.

Thanks to xet7 !
2026-10-15 16:37:18 +03:00
Lauri Ojansivu 11f97d016e Windows builds: the release scripts read and write text as UTF-8.
All three Windows bundle builds of v12.47 stopped at the risk audit:
"Automated risk indicators found: server/lib/localizeAvatar.js: new URL
origin http://127.0.0.1". The file had not changed. risk-audit.py read
its baseline with a bare read_text(), which on a Windows runner decodes
cp1252, so the baseline's "http://127.0.0.1/…" (a comment in that file)
became "http://127.0.0.1/…" and no longer matched the source. With a
non-UTF-8 default on Linux (LC_ALL=C, -X utf8=0) the old script misread
the baseline the same way and counted all 16688 files as changed; the
fixed one reads it as the build does.

Every text read and write in releases/*.py now names UTF-8: the risk
audit, the telemetry check, remote-release.py (which also reads and
rewrites CHANGELOG.md, with its ®), the dependency, UCS, Nextcloud, Bun,
Mac and Snap scripts, and the GITHUB_ENV/GITHUB_OUTPUT writes.
tests/releaseScriptsUtf8.test.cjs fails on any read_text(), write_text()
or text-mode open() in releases/ without an encoding, multi-line calls
included; tests/bun-release.py follows the checksum write's new
argument. Every Python and node test of these scripts passes.

Thanks to xet7 !
2026-10-15 16:37:18 +03:00
github-actions[bot] 15f1811c97 Bump versions for v12.47 2026-10-15 13:12:13 +00:00
Lauri Ojansivu d390aba29c Prepare v12.47 release 2026-10-15 16:11:02 +03:00
Lauri Ojansivu 83c2ca9985 Three guards follow the restore and 6.09 test changes.
- sidebarArchives.js imports its own template: cardDetails.js now
  imports it for restoreArchivedCard, and a file that registers on a
  template it does not import may run before the template exists
  (clientBundleImports).
- The 6.09 restore spec makes its test ObjectIds with crypto.randomBytes,
  not Math.random (codeqlInsecureRandomness).
- Tigre's new restore strings say ወረቀት ካርድ for card and ምዱድ for board,
  the file's own words, not the bare Tigrinya ካርድ and ሰሌዳ.

Thanks to xet7 !
2026-10-15 13:29:53 +03:00
Lauri Ojansivu ee9ff3bedb CHANGELOG: Restore from Archive into a full list (#6772).
Thanks to jullbo and xet7 !
2026-10-15 13:16:22 +03:00
Lauri Ojansivu b9edbcc858 Restore from Archive into a full list asks for another list, instead of doing nothing.
Restoring an archived card whose list had reached a hard WIP limit did
nothing at all: the Restore handlers checked canBeRestored() and, when it
said no, returned without a word or a method call, and Restore all
skipped such cards just as quietly.

- Restore, in the Archive sidebar and in an open archived card, now goes
  through one function (restoreArchivedCard): a list that is full - or
  gone - opens the restore-to-list popup, which says the list has reached
  its WIP limit and offers only lists with room; with none left it says
  so instead of offering a list that would fail again.
- Restore all restores every card that has room and then says how many
  it left out and why. Its popup opens against the link kept before the
  loop: after the awaits the browser has cleared evt.currentTarget.
- canBeRestored() decides with models/lib/wipRestore.js, which also
  blocks a list that is OVER its hard limit: the old `===` let a card
  into a list whose limit had been lowered below its count. A soft limit
  still only warns.

Tests: tests/wipRestore.test.cjs (the rule, over-limit and soft/disabled
negatives, both Restore paths, Restore all's count, and a scan that no
client code skips a blocked restore without a word);
restore-wip-limit.e2e.js against the production bundle (restore into
another list with the full one not offered, Restore all restoring what
has room and naming the rest, a soft limit restoring at once), 12 of 12
over three repeats with the existing archive restore test. The four new
strings are translated in every locale.

Thanks to jullbo and xet7 !

Fixes #6772.
2026-10-15 13:16:14 +03:00
Lauri Ojansivu 9e1d5f65bd LDAP: the sync interval crash on Snap 12.46 is fixed (#6771).
"[uncaughtException] WeKan is stopping: TypeError: string.toLowerCase is
not a function" from later.parse.text, called by wekan-ldap's sync
schedule, with the snap restarting in a loop - the report on Alma Linux
8.10 with LDAP sign-in. A numeric LDAP_BACKGROUND_SYNC_INTERVAL reached
later's text parser as a Number; 7dfd942ff6 reads milliseconds or
later.js text and never throws, and tests/ldapSyncSchedule.test.cjs
reproduces this exact error with the real later.js.

Thanks to tromlet and xet7 !

Fixes #6771.
2026-10-15 12:54:31 +03:00
Lauri Ojansivu 025630b552 Tests: a board restored from WeKan 6.09 loads.
The upgrade report (one board with attachments loading forever after a
6.09 to 10.85 dump and restore) came without the board's data. The
fixture tests/fixtures/restored/wekan-6.09-board.json is that board's
SHAPE instead, derived from `git show v6.09:models/...`: CollectionFS
attachments (cfs.attachments.filerecord with its GridFS file and chunk),
a card cover pointing at a filerecord, lists with swimlaneId '', members
without the newer role flags, customFields by boardIds, checklists,
comments and activities as 6.09 wrote them.

restored-609-board.e2e.js restores it into the test database as it is
and requires the board to load: the board publication becomes ready with
the board and its cards, and the page shows its lists and cards, not the
spinner or the "could not be loaded" message. It passes against the
production bundle - and also against a bundle built without the client
cover fix, so neither the 6.09 data shape nor that fault is what hung the
reported board; what did is particular to that board's data, which the
board's page now names through the server log.

Thanks to xet7 !
2026-10-15 04:34:03 +03:00
Lauri Ojansivu defb110b18 CHANGELOG: search results of the search typed; browser specs on the bundle.
Thanks to xet7 !
2026-10-15 04:14:21 +03:00
Lauri Ojansivu 49344f32f4 Email view and Public Boards: a search shows its own results, never the last one's.
Found running the Email view's browser test against the production
bundle: once in eight runs, searching for text that matches nothing still
listed the previous search's card. Both views subscribe in an autorun, so
a new search's subscription starts before the old one stops, and both
published their page's ids into ONE shared index document; DDP's merge
box shows the older subscription's value while both exist, so whenever
the old one stopped late the new search showed the old rows.

Each request now has its own index, keyed by its arguments
(models/lib/reportPageIndex.js reportPageKey), built the same way on the
server and the client (emailViewPageArgs normalizes the Email view's
folder, page and search), and each view reads only the index of the
request it made. Admin Panel / Problems stops its subscription before the
next one, so its requests never overlap and keep a fixed id.

Tests: tests/reportPageKey.test.cjs (one id per request, server and
client agree, no autorun view reads a shared id); emailView and
publicBoardsPage pin the keyed ids. Against a rebuilt bundle the Email
view test passed 8 of 8, and with the Public Boards, Chat, Version
Control and import specs 30 of 30.

Thanks to xet7 !
2026-10-15 04:14:10 +03:00
Lauri Ojansivu 160110d1b3 Tests: the Email and Chat browser specs run against the production bundle.
Run against a freshly built bundle, as build.sh's browser run does, two
faults in the spec itself showed:

- Email view: Compose sends real mail, but the spec started no mail sink,
  so Send answered "The email could not be sent" in any run without an
  SMTP server. It catches the mail locally now, as the other mail specs do
  (helpers/smtpSink.js on WEKAN_TEST_SMTP_PORT), and checks that the
  message reached the recipient under its subject.
- Chat view: after page.reload() the new connection signs in again from
  the login cookie, and openView() called setBoardView before that, so on
  a fast server it failed with "Must be logged in". It waits for the
  sign-in, and a refused call names its code and reason instead of the
  minified "r".

Against the bundle: the Email, Chat, Version Control attachments and
import Attachment files specs pass (Chat 3 of 3 repeated, Email 5 of 5).

Thanks to xet7 !
2026-10-15 03:59:46 +03:00
Lauri Ojansivu 66b0952687 CHANGELOG: LDAP sync interval, attachment rename.
Thanks to B2RN and xet7 !
2026-10-15 03:28:19 +03:00
Lauri Ojansivu 515e63d322 Attachments: renaming one without extensionWithDot keeps its extension.
Running the new browser tests showed it: the rename popup appended
this.extensionWithDot, which a migrated attachment can lack, so the file
became "architectureundefined". It falls back to the extension now.

The browser tests were run against a local source-mode server: the
Version Control attachments spec (which now asks for the storage paths
as an administrator, for that call only) and the import Attachment files
spec pass; the Chat channels spec expects no unread message once the card
was opened in the middle, which reads it.

Thanks to xet7 !
2026-10-15 03:17:49 +03:00
Lauri Ojansivu 7dfd942ff6 LDAP: a sync interval in milliseconds no longer stops WeKan.
"[uncaughtException] WeKan is stopping: TypeError: string.toLowerCase is
not a function at later.parse.text ... at Object.schedule
(wekan-ldap/server/sync.js)", in a snap restart loop. The docs give
LDAP_BACKGROUND_SYNC_INTERVAL in milliseconds, settings_get() turns a
numeric value into a Number, and the schedule handed it straight to
later's text parser, which threw from a timer. Text later could not read
did not throw, but made a schedule that never ran - as a quoted
'every 1 mins' from a compose file did, the second report in #2354.

packages/wekan-ldap/server/syncSchedule.js reads the setting and never
throws: milliseconds (a Number or digits) become that interval, later.js
text is read as before, one pair of surrounding quotes is removed, unset
keeps the original default (recur().on(0).minute()), and anything
unreadable logs why and uses the default. The docs, the snap description
and the compose files say both forms work, instead of "leave this unset
so it does not crash".

Tests: tests/ldapSyncSchedule.test.cjs reproduces the crash with the real
later.js from the build, schedules 3600000 ms an hour apart and 10000 ms
ten seconds apart, reads quoted text, keeps the default, never throws on
any value a setting can hold, and fails if any server file hands a setting
to parser.text directly.

Thanks to B2RN and xet7 !

Fixes #2354.
2026-10-15 03:17:49 +03:00
Lauri Ojansivu fae5886319 CHANGELOG: every node suite passes again.
Thanks to xet7 !
2026-10-15 02:58:53 +03:00
Lauri Ojansivu 75534b5f1a Translations: the last thirteen failing guards, fixed on both sides.
The guards that failed before this release's translation work, each
decided by which side is right:

- The locale was wrong: Bislama's email recovery strings say sanem, the
  word the guard pins; Arabic-script Uzbek writes "e" as ې (26 words);
  Romansh says "chaschuna ina perdita da datas", names its own Menu and
  uses one word for the template container; Tigre's card is ወረቀት ካርድ,
  never the bare Tigrinya ካርድ (six strings); Wolaytta's permanent-delete
  label still held English words, now in the file's own Wolaytta; and
  Tamazight's operator-assignee is the portable search keyword again -
  the 2026-10-13 label sweep had translated a parser keyword, so that
  correction is removed and the earlier one stands.
- The guard was stale: Acehnese settled on kad for card on 2026-10-14,
  so the example cards use the file's own word; Uzbek key names may be
  translated as long as the printed label stays beside them; the README's
  coverage count is 234 now; the Transifex pull test hands its own node to
  the script it runs.
- The last English values of nine locales are their own words or settled
  terms (Neapolitan and Volapük "No", Frisian and Flemish "is", Breton
  "Me", Walloon "Pause", Venetian "Sprint", Veps "minus"), recorded as
  reviewed shared words, so the completeness guards and --missing agree:
  0 languages with untranslated strings.

Thanks to xet7 !
2026-10-15 02:58:42 +03:00
Lauri Ojansivu ccc65e7d89 CHANGELOG: every language translated; TODO Later is empty.
Thanks to xet7 !
2026-10-15 02:22:55 +03:00
Lauri Ojansivu d085a30f2d Translations: the last English strings of 43 locales.
Keyboard key names, Blockly's math names, Scrum roles and the S3 and
webhook labels, which the earlier passes had kept in English: the
established locales translate them (Finnish Välilyönti, German
Leertaste), so these do too. Breton keeps the reviewed "Me", Walloon the
reviewed "Pause", Venetian its "Sprint", and Veps writes minus without
the Finnish long vowel. Low confidence for the key names of Nahuatl,
Buryat, Cherokee, Klingon, Tibetan, Dzongkha, Wolaytta and Tigre.

What stays equal to English is the language's own word (Neapolitan
"No", Frisian "is", Volapük "if"), or in Blockly an operating system,
sin/cos/tan, and key labels the language's keyboards print.

Thanks to xet7 !
2026-10-15 02:22:25 +03:00
Lauri Ojansivu 7cbbec8836 Translations: the remaining strings of 12 more locales, Blockly included.
Greenlandic, Kashmiri, Nahuatl, Tigre, Tibetan, Dzongkha, Oromo,
Wolaytta, Northern Sami, Yakut, Inuktitut and Cherokee: every value still
equal to English, translated directly from each language's existing
wording, placeholders checked against English, only English placeholders
filled. Low confidence for Nahuatl, Wolaytta, Yakut, Inuktitut and
Cherokee; a speaker's Transifex translation replaces any of them.

Thanks to xet7 !
2026-10-15 02:14:29 +03:00
Lauri Ojansivu d698d6a1d6 Translations: the remaining strings of 20 more locales, Blockly included.
Translated directly from each language's existing wording, placeholders
checked against English, only English placeholders filled: Tamazight,
Volapük, Acehnese, Wolof, Fijian, Guarani, Veps, Quechua, Chichewa,
Northern Ndebele, Tigrinya, Samoan, Aymara (its last string, the S3
bucket, too), Fula, Bambara, Tswana, Kirundi, Swati, Chuvash and Buryat.
Low confidence for Volapük, Veps, Buryat and Tamazight; a speaker's
Transifex translation replaces any of them. The Veps guard records "ei
%1" for Blockly's "not": Veps negates with ei, as Finnish does.

Thanks to xet7 !
2026-10-15 02:01:26 +03:00
Lauri Ojansivu 7c6a94c734 Translations: the remaining strings of 50 locales, Blockly included.
Every value still equal to English in these locales, translated directly
from each language's existing wording and kanban terms, Blockly's
editor messages included; placeholders checked against English before
anything was written, and only English placeholders filled, so no human
translation is replaced.

All of Klingon, Ewe, Walloon, Luganda, Tongan, Tsonga, Venda, Kinyarwanda,
and the remaining strings of Bashkir, Pashto, Cantonese, Igbo, Xhosa,
Bhojpuri, Romansh, Maori, Breton, Sorani, Faroese, Northern Sotho,
Asturian, Amharic, Akan, Kyrgyz, Sicilian, Burmese, Hausa, Yoruba,
Frisian, Kurdish, Cornish, Uyghur, Aromanian, Sinhala, Sindhi, Manx,
Neapolitan, Assamese and their regional variants. Low confidence for
the constructed and smallest of them (Klingon, Venda, Tongan, Manx,
Cornish, Aromanian); a speaker's Transifex translation replaces any of
them. Walloon writes tangent as tandjinte, not the Waray-shared French.

Thanks to xet7 !
2026-10-15 01:45:28 +03:00
Lauri Ojansivu 0a58d618ce Tests: archiveSection allows the board subscription's onStop.
The board page passes an onStop callback since a failed load is shown;
the guard still pins that it asks for the live lists (false).

Thanks to xet7 !
2026-10-15 01:38:13 +03:00
Lauri Ojansivu 3c92954ab9 CHANGELOG: import attachment files, board load errors; TODO Later down to translations.
Thanks to xet7 !
2026-10-15 01:28:42 +03:00
Lauri Ojansivu 71b862c28c Tests: two guards follow the Chat view and the live-test log fixes.
customBoardRoles pinned Chat's comment check on the open board; the Chat
view opens a card of any board now, so it asks the card's board, and
the guard says so. liveTestsNoSecretLogs built a RegExp escaping only
`$`; it escapes every metacharacter (incompleteEscaping).

Thanks to xet7 !
2026-10-15 01:27:40 +03:00
Lauri Ojansivu f4126b9514 Translations: attachment files on the import page, a board that failed to load.
The five strings in every locale, translated directly from each
language's existing wording; English variants keep English. Bosnian
uses tabla for board and Tigre its established word for name, as their
guards require.

Thanks to xet7 !
2026-10-15 01:27:40 +03:00
Lauri Ojansivu 31615d7f98 Board: a failed load says so; 6.09 attachments no longer break covers.
An upgrade report: after a 6.09 to 10.85 dump and restore, one board with
attachments showed only the loading animation. The data is not
available, but the code had three faults on that path:

- The board page waited for its publication to be ready and had no
  onStop, so a publication that stopped with an error left the spinner
  up forever. It now shows "This board could not be loaded", pointing at
  the server log, which names the cause.
- The client cache fell back to the legacy (CollectionFS) attachment
  lookup, an async stub on the client: its Promise was taken for an
  attachment, so a 6.09 card's coverId became a cover whose link()
  threw, and the plural lookup handed callers a Promise for an array.
  The client no longer calls it; the server cache awaits the real one.
- The legacy attachments publication called startsWith() on a record's
  type, which throws when a 6.09 record holds something other than a
  string; it is read as a string.

Tests: boardRefreshReadiness drives the error path (shown, cleared on
another board, ignored from a board left behind); legacyBoardLoad pins
that no client cache calls an async lookup and that the server awaits
every one. The board itself is not reproduced: that needs the data.

Thanks to xet7 !
2026-10-15 01:27:37 +03:00
Lauri Ojansivu 51238ed8ad Import: attachment files of Jira, Nextcloud Deck, OpenProject, Asana and GitLab.
Their exports name attachments without carrying the files, so the files
were reported as a loss. Each service documents where a file is
downloaded, behind the same sign-in as its API:

- Jira: fields.attachment[].content (/rest/api/3/attachment/content/<id>),
  Basic email:API token; Jira's redirect to its media store is followed
  without the sign-in.
- Deck: /index.php/apps/deck/api/v1.0|v1.1/boards/../stacks/../cards/..
  /attachments/<id>, Basic user:app password, OCS-APIRequest: true.
- OpenProject: _links.downloadLocation, relative to the instance, Basic
  apikey:<key>.
- Asana: GET /attachments/<gid>?opt_fields=download_url with a personal
  access token, then the signed download_url without it; a file the
  export embeds (export_boards.pl) is taken as it is; one kept on Google
  Drive and the like is a link, reported.
- GitLab: a private project's /uploads/ link through
  /api/v4/projects/<id>/uploads/<secret>/<file> (17.4+), Bearer token.

The import page asks for the address, user and token under "Attachment
files" for these five (the REST import takes the same as attachmentAuth).
They go with that one import: held beside the creator in a WeakMap, never
stored or logged, the token field emptied on Import, and sent ONLY to
the origin the member typed - an export names any URL it likes. Every
download is the import's SSRF-guarded fetch, which validates each hop
and drops Authorization on a cross-origin redirect. A file that cannot
be fetched is on the loss report with its reason. Unticking Attachments
leaves them out (Jira's per-issue field is pruned too).

Zenkit is left out: it publishes no way to download a file.

Tests: importAttachmentAuth.test.cjs (each service's request, the
sign-in only to its origin, malformed sign-ins, never stored or
logged), the parser tests pin the attachment lists, a Meteor test
imports an embedded Asana file onto its card and shows a Jira file is
reported without a sign-in and that the sign-in never reaches an address
the guard refuses, and import-attachment-auth.e2e.js the page (not run
here). Not run against the live services.

Thanks to xet7 !
2026-10-15 01:27:26 +03:00
Lauri Ojansivu f95a891c06 CHANGELOG: Version Control view attachments.
Thanks to xet7 !
2026-10-15 00:51:15 +03:00
Lauri Ojansivu 132b341b8d Version Control view: Attachments have the card's controls, and open in the viewer.
The Attachments tab listed each file as a link to a new browser tab. A
file the attachment viewer can show now opens there on a click on its
thumbnail or name, with next and previous walking the tab's files, and
every file has the controls of an opened card's attachments: view,
download, rename, delete (the soft delete, behind the same confirmation)
and the cover / background menu, the writes for the same members as the
card's gallery.

Thanks to xet7 !
2026-10-15 00:51:09 +03:00
Lauri Ojansivu 7c1cffe4b6 CHANGELOG: Jira closed sprints; TODO Later without them.
Thanks to xet7 !
2026-10-15 00:46:51 +03:00
Lauri Ojansivu cc0b8c60ba Jira import: closed sprints, rebuilt from the issues.
Jira's sprint report has no documented API, so a closed sprint was
reported and skipped. It is now imported as closed, with its start and
close snapshots rebuilt from documented issue data: the Sprint field,
status and resolution at the sprint's start and completion dates, from
each issue's change history (expand=changelog), or from the issues as
they are now when there is none. The snapshots are marked partial. A
closed sprint with no start or completion date is still reported.

Thanks to xet7 !
2026-10-15 00:46:39 +03:00
Lauri Ojansivu 51e1a5c04d CHANGELOG: Chat view card in the middle, carets on folded panes.
Thanks to xet7 !
2026-10-15 00:38:04 +03:00
Lauri Ojansivu 230badf48e Chat view: a card from any board opens in the middle; folded panes keep their carets.
- Clicking a card in the channel tree took the member to that card's
  board. It now opens the card in the middle pane with its conversation
  beside it, whatever board it is on, and the page stays where it is. The
  card's conversation comes from a publication of its own
  (chatViewCardComments) with the tree's limits - a board the member sees,
  a role that reads comments, only their cards on an assigned-only board -
  and its writers' names; the comment box follows the card's own board.
- Folding the Channels or Comments pane left only an icon: the double
  caret stays now, pointing the way the pane opens, mirrored with the
  others right to left.

Tests: chatView.test.cjs (no navigation, the subscription, the card's
board for commenting, the publication's limits, the carets),
email-chat-views.e2e.js (another board's card in the middle without
leaving the page, carets visible on folded panes).

Thanks to xet7 !
2026-10-15 00:38:03 +03:00
Lauri Ojansivu 60317a8501 CHANGELOG: CodeQL #586, Jira sync from a Sandstorm grain, the closed issues.
Thanks to xet7 !
2026-10-15 00:33:59 +03:00
Lauri Ojansivu 1dc5b82369 Sandstorm: Jira List Sync works from a grain.
It was recorded as impossible: Jira signs in only with an Authorization
header, and the bridge proxy takes that header for the grain's grant. But
Sandstorm's HTTP driver can keep the sign-in in the grant itself
(shell/imports/server/drivers/external-ui-view.js in the Sandstorm
source): the member connects the Powerbox to "any address" and enters the
Jira address with the credentials in it - https://EMAIL:API-TOKEN@site
(the e-mail's @ as %40), or https://jira.example.org#TOKEN for a Server /
Data Center token - and Sandstorm moves them into the grant and adds
Authorization: Basic (or Bearer) to every request the grain sends through
it. WeKan never sees them.

- egress.mjs syncApiRoot grants a Jira list at its own address, and the
  Powerbox request asks for Basic authentication
  (ApiSession.PowerboxTag.authentication; apiSessionDescriptor takes it).
- In a grain the Jira fetcher sends no Authorization of its own, and a
  Jira source needs no WeKan token there: a marker credential says the
  grant holds the sign-in. Only for Jira - GitLab sends its token in the
  query, where a marker would reach GitLab.
- The List Sync popup tells a Jira list in a grain what to enter in the
  Powerbox, translated in all locales; the Sandstorm docs say how, and
  that the same #token form may let a private GitHub, Gitea or Forgejo
  through (not tried yet).
- TODO Later drops "Not possible from a Sandstorm grain", its last
  category naming an issue; changelogFormat no longer needs one.

Not run in a real grain here. Tests: sandstormSyncEgress.test.mjs (the
root and the Basic descriptor, and an address with credentials in it
still refused), sandstormJiraGrant.test.cjs (the server, the fetcher,
the marker only for Jira, the popup and the docs).

Thanks to xet7 !
2026-10-15 00:33:30 +03:00
Lauri Ojansivu de95789e45 Live tests: no log line carries anything from a login (CodeQL #586).
GitHub CodeQL js/clear-text-logging: releases/live-tests/basecamp-import.mjs
logged the Basecamp account id read from WEKAN_LIVE_BASECAMP_ACCOUNT. The
live tests run with the maintainer's own logins and keep a log under
.tools/log/, so nothing from a login or its configuration belongs in it.
The same shape was in two more tests and is gone there too: Planka logged
its configured address, and the GitLab write-back test its GitLab address
and scratch project path, also in the message that says it is skipped.
Each now logs what it does in fixed words.

These are human-run scripts, not part of the WeKan server, so there is no
attempt for Admin Panel -> Problems to record.

tests/liveTestsNoSecretLogs.test.cjs follows every value read from the
environment, a login or the credentials into the names it lands in, and
fails on any log line of any live test that interpolates one; a negative
case pins the reported shape.

Thanks to GitHub CodeQL and xet7 !
2026-10-15 00:25:21 +03:00
Lauri Ojansivu aad7e1414a TODO Later: no speaker review, no formats without documentation.
Maintainer decision 2026-10-15:

- Translating from dictionaries and each language's own wording is
  enough; speakers review it later if they have the time. The category
  listing terminology for a speaker to check is removed.
- Formats with no published documentation are not taken on: Restyaboard,
  YouTrack, Airtable, Leantime, Things 3, and Taskcafe (an archived
  project whose GraphQL API has no documentation beyond its source).
  Attachment contents from Jira, Deck, OpenProject, Asana, Zenkit and
  GitHub/GitLab stay: each documents its attachment API.

Thanks to xet7 !
2026-10-15 00:15:56 +03:00
Lauri Ojansivu 0d68c3536b Close #4790: User Filter is covered by what WeKan has.
#4790 was a collection of ideas ("I don't know what use cases it would
cover"), and a split into six new feature requests was prepared on
2026-09-29. Checked against the code, they describe what WeKan already
does under other names, so nothing needs filing:

- labels on organizations, teams, people and boards: organizations and
  teams are the groups of people; boards are grouped into All Boards
  workspaces and have their colours;
- a label from an LDAP group: LDAP groups sync as organizations and teams
  (#4737), with LDAP_SYNC_ADMIN_GROUPS and LDAP_SYNC_GROUP_ROLES;
- adding or keeping users out of boards by label: a board is shared with
  an organization or a team, and a private board not shared with one
  keeps it out;
- a board role for an LDAP group: the group syncs as a team, and the team
  is shared to the board with its role;
- finer roles: custom board roles (#3022, closed);
- users by e-mail domain: done (orgAutoAddByDomain.js, #5850, #4178);
- rules by label, meteor-roles and multitenancy were thoughts in the
  comments, not requests.

The prepared split (docs/Features/User-Filter-4790-Split.md) is removed,
TODO Later no longer waits on it, and docs/Design/TeamsOrganizations.md
says how its rows are covered - also that custom roles exist now.

Thanks to xet7 !

Fixes #4790.
2026-10-15 00:07:39 +03:00
Lauri Ojansivu 99d4ee4d91 Merge remote-tracking branch 'origin/main' 2026-10-14 23:56:55 +03:00
Lauri Ojansivu 707021fee2 TODO Later: only what is not built yet; close the issues that are.
What is built leaves the backlog, also where it is not yet tested against a
live service:

- #5758 Windows SSO: built in a594a58117 (v12.46), for a Windows host in a
  domain; the handshake was not run here.
- #2698 GitLab integration: GitLab sync and import (2026-09-30), and the
  write-back of edits, comments, closing and reopening in d6c1d6fce1
  (v12.46), with a live test in build.sh / build.bat -> Tests -> Live
  tests; GitLab login to WeKan through OAuth2 is documented, and card
  numbers with board keys give the #number references.
- #337 accessibility: move buttons, focus trap, landmarks, labels and
  every icon-only control named and guarded; a screen-reader pass is
  testing, not a missing part.
- #2208 Sandstorm: everything that can work from a grain is built (email
  sending, outgoing webhooks, list sync, the REST API through Sandstorm);
  Jira sync from a grain cannot, and stays recorded as such.

Also out: the continuous backup's live-account tests, the SMTP, browser
and identity-provider test matrix, the UCS / Nextcloud ExApp variable
check, the History switch (decided on 2026-10-08), Zenkit's unverified
export, the translation batch history, and the importers built on
2026-10-14 (Planka, Microsoft To Do, KanbanFlow, Basecamp). Left: Jira
closed sprints, the #4790 split to file, translations and their
terminology review, the attachment-board report, the formats not read
yet, and the Sandstorm limits.

changelogFormat's count of categories that link issues follows the
smaller backlog; it still fails on an issue named without its link.

Thanks to nikalasmd, ndarilek and xet7 !

Fixes #5758,
Fixes #2698,
Fixes #337,
Fixes #2208.
2026-10-14 23:56:19 +03:00
github-actions[bot] 6466686289 Bump versions for v12.46 2026-10-14 20:50:19 +00:00
Lauri Ojansivu 8263ca936d Prepare v12.46 release 2026-10-14 23:49:01 +03:00
Lauri Ojansivu 857b6db4f4 CHANGELOG: the guard fixes after the importers and the feed.
Thanks to xet7 !
2026-10-14 23:23:14 +03:00
Lauri Ojansivu a6a1d3c1a6 Fix three guards the last commits tripped.
- The activity feed's new stylesheet was not imported, so it would not
  have been in the client bundle (clientBundleImports); client/features/
  main.js imports it beside the feed's template.
- The Basecamp and Microsoft To Do importers stripped HTML tags in one
  pass, which a crafted body survives ("<<b>b>" leaves "<b>"); each
  removal now repeats until nothing changes (tagStrippingFixedPoint).
- renderedTitles escaped only the first "." of a field name; it escapes
  every special character now (noIncompleteRegExpEscaping).

Thanks to xet7 !
2026-10-14 23:23:07 +03:00
Lauri Ojansivu d27dbe651f CHANGELOG: API importers, Chat channels, the History fork and the views that showed raw text.
The Upcoming section's In short covers them; TODO Later drops the third
round of decisions, all of which are built now.

Thanks to xet7 !
2026-10-14 23:09:31 +03:00
Lauri Ojansivu 2ee3e83793 Chat view: channels are the cards with a conversation, on every board, with unread counts.
The Chat view's tree listed the open board's swimlanes, lists and cards,
and every other board as a link that left for that board. Now:

- The tree is workspaces > boards > swimlanes > lists > cards, holding
  the cards that have a conversation on every board the member sees
  (server/chatView.js chatViewChannels). Every board without one is under
  Other boards; its first message moves it up to its place.
- Each level has a caret before its icon - after it, mirrored, in a
  right-to-left language - and folds open and shut with a slide (none
  for a member who asked for reduced motion). Clicking a board folds it;
  it no longer leaves the Chat view. The board the member is on, its
  workspaces, swimlanes and lists start open; what they open or close is
  kept in their browser.
- The board the member is on shows all its cards, from the board's own
  data, so a conversation can start on any card and a dragged card moves
  in the tree at once; its button shows only the cards with messages.
  Another board opened from Other boards shows all its cards
  (chatViewBoardChannels) for the same reason.
- Every level shows the messages this member has not read: others'
  messages after they last opened the card, all of them on a card never
  opened; opening the card - or a message arriving while it is open -
  reads it.
- Every name, and the chat's header, renders as Markdown (inlineViewer).
- Only what the member may read: boards they see in All Boards, only
  their own cards on an assigned-only board, no conversation where their
  role reads no comments.

Tests: tests/chatView.test.cjs (the tree, Other boards, a first message
moving a board up, unread sums, the full board, carets, folding rows,
no navigation), server/lib/tests/chatViewChannels.tests.js (unread
counts, never my own, the role and assigned-only limits, a board I do
not see refused), email-chat-views.e2e.js (folding without leaving the
view, the move up, unread badges, Markdown names). New strings
translated in all locales.

Thanks to xet7 !
2026-10-14 23:08:22 +03:00
Lauri Ojansivu 2cc6b93ed9 Version Control, the activity feed and the email list render titles as Markdown.
Titles, names and labels are Markdown in WeKan - links, emphasis, emoji -
but Board View / Version Control showed "Azure [issues](https://...)"
and the like as text, the all-boards activity feed did the same with the
cards and boards it names, and the email view's sidebar showed its lists
and labels raw ("Demo + Wishes 🌈").

inlineViewer (client/components/main/editor.jade) is the viewer itself -
Markdown, emoji, mentions, and the Admin Panel's security settings for
links and code - laid out inline at the size of the line it is in. These
views use it for every issue, milestone, label, release, wiki page and
board title, the activity sentence and its card and board, and the email
folders and row labels. An attachment's file name in its download link
stays a file name.

A rendered title can hold a link, and a link inside a link is not one:
the titles and labels that open or filter are spans with role=link now,
which Enter and Space open too, and a click on a link inside a title
opens that link instead.

tests/renderedTitles.test.cjs fails on any title-like value interpolated
as text in these views and pins the role=link spans.

Thanks to xet7 !
2026-10-14 23:07:30 +03:00
Lauri Ojansivu f12b718d81 Activity feed: every sentence names what it is about.
The all-boards activity feed showed "checked __checklistItem__ of
checklist __checklist__", "from list __oldList__ at swimlane
__oldSwimlane__", "added attachment __attachment__", an empty "at
swimlane", and raw names such as "removedChecklistItem": it filled only
the card, list and board of the notification sentence.

- server/activityFeed.js reads the ids the sentence needs and looks the
  names up a batch at a time: swimlanes, old list and swimlane, checklist
  and item, attachment, label, member and assignee, comment, old board.
  Never a field's value (an admin-only custom field stays out), a
  comment's text only for a member who may read comments, and another
  board only when the member sees it too.
- A rename or a restore names its new title itself, and a swimlane's
  activity keeps the swimlane's id in listId; activityTitleParams fills
  those for the feed and the notifications alike.
- A placeholder nothing could fill - a deleted checklist item - is left
  out instead of shown as __name__.
- Ten kinds of activity had no sentence at all, in the feed or in
  notifications: removedChecklistItem, moveCardBoard, deleteBoard, the
  board, list and swimlane renames and restores, joinAssignee and
  unjoinAssignee. They have one now, translated in all locales; the
  restores, moves and checklist ones join their notification groups.

tests/activityFeed.test.cjs fills every name, refuses comments and
hidden boards, and fails on any stored activity type without a
sentence; activity-feed-sentences.e2e.js checks the feed shows the names
and no __placeholder__.

Thanks to xet7 !
2026-10-14 22:57:31 +03:00
Lauri Ojansivu 46e30f01e4 Import: Planka, Microsoft To Do, KanbanFlow, Basecamp, Google Tasks and Google Keep from their APIs.
Decided on 2026-10-14. On the import page each of the six asks for what
its service needs - a self-hosted Planka its address, username and
password; the others an access token (Basecamp also its account id) -
lists what that account can import, and fetches the chosen board, list
or project. The credentials go with each call and are used for that
call's requests only; they are never stored or logged, and the page
clears secret fields once a fetch has used them. The fetched JSON fills
the import text box and the import goes on as for a pasted export: the
service's parser (models/lib/<key>ApiFormat.js) into the generalized
importer, with the members step and the loss report. The same JSON can
be pasted, or posted to POST /api/boards/import/<key>.

- server/apiImport.js: apiImportList and apiImportFetch, signed in only,
  off with the Admin Panel's import switch, not in Sandstorm, rate
  limited; credentials checked against the service's own fields
  (models/lib/apiImportServices.js).
- server/lib/apiImportRequest.js: every request through the SSRF guard -
  pinned address, no redirects, bounded in time and size - so a Planka
  address cannot point the server at its own network; an administrator
  can allow such a host with API_IMPORT_ALLOWED_PRIVATE_HOSTS. An error
  names only the origin and the status.
- models/lib/apiImport/<key>.js: each service's client, plain Node with
  the request injected, paging bounded and the token only in a header.
- Each has a docs page under docs/Features/ImportExport/, listed in the
  coverage index and External-Tools.md.

Tests: tests/<key>ApiImport.test.cjs (client with a fake request, every
parsed field, wrong shapes), tests/apiImport.test.cjs (credentials, the
gate, the wiring, the shapes refused), api-import.e2e.js (the form for
every service, fetched JSON imported through the page, refusals incl. a
private address). Live tests, read only, run from build.sh / build.bat ->
Tests -> Live tests with your own accounts (releases/live-tests/README.md);
they were not run here. New strings translated in all locales.

Also: the release risk baseline allows the services' API addresses, and
the guards that count import creators, list the import sources and pin
the platform fetch() calls know the new ones.

Thanks to xet7 !
2026-10-14 22:57:15 +03:00
Lauri Ojansivu 5fee137f01 Tests: three guards follow the History fork and GitLab write-back fixes.
- syncHistoryAdmission pinned the line that walked successors to the
  chain's tip inside appendSyncHistoryRow; 66dd9b0938 moved that walk to
  historyChainTip and made the append take turns, so it pins those.
- The write-back's injectable GitLab read was a parameter named fetch,
  which syncBleed's "no new platform fetch()" guard rightly could not
  tell from the real one; it is readIssues now.
- syncClosedByWeKan, the card field the write-back added, is in the card
  e-mail field inventory as internal provenance, excluded like the other
  sync fields.

Thanks to xet7 !
2026-10-14 22:56:34 +03:00
Lauri Ojansivu 66dd9b0938 History: two changes at once no longer fork a board's History chain.
Admin Panel -> Problems showed HistoryIntegrity, critical, "history-fork",
89,062 times. Each History row names the row before it, and the legacy
append (every board not migrated to the coordinated chain) read "the
newest row" and inserted after it as two separate steps: two changes
saved at the same moment on one board - two members, or one action whose
hooks record twice at once - both named the same row. Nothing was
tampered with, but the audit called it tampering, and undo, redo and
restore were refused for both rows. The writer gate around the legacy
append counts its writers for a migration; it does not make them take
turns.

- models/lib/historyLegacyAppend.js: a board's appends take turns in
  this server, and each links after the chain's tip - the newest row,
  then its successors, as Sync History already did - so a createdAt tie
  does not fork either. The ordinary recorder, the Scrum restore writer
  and Sync's legacy append all use it. Several servers writing one board
  at the same moment can still race; the coordinated chain is the answer
  to that.
- The hourly audit reported every broken row again every hour, which is
  where 89,062 came from; each finding is now reported once
  (historyIntegrityReported). It reads the History board by board instead
  of all at once, and a fork of two intact rows saved within a minute of
  each other is reported as what it is - integrity.historyConcurrentFork,
  medium, "a History writer race, not tampering" - while anything else
  stays critical.

Existing forks stay as they are (a row's hash cannot be changed), and
undo of their two rows is still refused.

tests/historyLegacyAppend.test.cjs: the old append forks under
concurrency (negative control), twenty appends at once on two boards
make two straight chains, a createdAt tie follows the chain, a failed
append does not stop the next, the audit tells a race from a changed or
late row, and no append in models/ or server/ links by "newest" without
taking turns.

Thanks to xet7 !
2026-10-14 22:39:36 +03:00
Lauri Ojansivu 5481268558 Email view: subjects and previews render Markdown.
Board View / Email showed a card's title and description as text: "# Demo
card 👍", "**Markdown**", raw <font> tags and links, where every
other view renders them. The row's subject and preview now go through
the viewer like every card title and description in WeKan, so the Admin
Panel's settings for links and code apply to them as everywhere.

The preview was the description squeezed to one line and cut at 120
characters, which cut inside a link or a tag; it is now the start of the
message, the From line left out, cut at a line end (previewMarkdown),
and the row shows two lines of it. Headings and paragraphs in a row read
at the row's size.

tests/emailView.test.cjs pins previewMarkdown and that neither cell is
interpolated raw; email-chat-views.e2e.js checks the bold, the link and
that no ** or ]( is left in the row.

Thanks to xet7 !
2026-10-14 22:36:21 +03:00
Lauri Ojansivu fc24caadcc CHANGELOG: GitLab write-back, and the oauthProviders guard.
TODO Later's third-round block keeps only the API importers.

Thanks to xet7 !
2026-10-14 22:08:54 +03:00
Lauri Ojansivu d6c1d6fce1 List Sync: write WeKan's changes back to GitLab.
#2698. A list synced from GitLab gets a Sync option of its own, off by
default: Write changes back to GitLab. With it, every Sync run first
sends what members did on the list's cards, then reads GitLab, so the
read finds both sides agreeing:

- a title or description edited in WeKan, when the issue still has the
  text both sides last agreed on - the local-only edit the reconcile
  already keeps. The Source footer Sync adds is not sent; when both sides
  changed, nothing is written and it stays a conflict to resolve;
- a card archived in WeKan closes its open issue, and restoring a card
  WeKan closed reopens it (syncClosedByWeKan); an issue GitLab closed is
  never reopened by WeKan;
- a comment written in WeKan becomes a note, and keeps the note's id
  (syncCommentId) so the comments sync does not bring it back twice.

Only what happens after it was switched on is sent (writeBackSince), at
most 100 writes a run. Every write is fenced by the list's Sync
reservation, goes through the same address checks as the reads (no
redirects, PUT or POST to an issue or its notes only), and records on
the WeKan side only while the card or comment is still as planned. A
refused write is shown in the Sync popup (syncWriteBackError, beside
syncSource because the run's list writes match syncSource whole) and the
read sync still runs. Not in Sandstorm, whose bridge only reads.

Tests: tests/listSyncWriteBack.test.cjs (the plan with GitLab's shapes,
the parser's footer, every negative - conflict, legacy card, old
archive or comment, foreign card, issue GitLab closed - and the wiring),
server/lib/tests/listSyncWriteBack.tests.js (end to end through
syncOneList against a fake GitLab). The live test,
releases/live-tests/gitlab-write-back.mjs, runs from build.sh / build.bat
-> Tests -> Live tests with the glab login releases/mirror.sh uses, on one
scratch issue in a private project of that account; it is human-run and
was not run here. releases/live-tests/run.mjs runs every live test and
skips a service with no login.

Also: the English variants get the SQRL and Windows sign-in strings
(their key order follows en.i18n.json); buildScriptParity accounts for
the scripts CI runs inside their own environments; new strings
translated in all locales.

Thanks to xet7 !
2026-10-14 22:08:34 +03:00
Lauri Ojansivu b38532ed72 Tests: oauthProviders follows the translated login-error list.
a539429fce made the sign-in page translate SQRL's sqrl-only and
sqrl-hardlock refusals the way it already translated
oauth-account-conflict, by one list of codes, so the guard's pin on the
old single comparison failed. It now pins the list - the conflict among
the codes, matched on err.error or the reason, shown as text - so the
behaviour it protects is the same.

Thanks to xet7 !
2026-10-14 22:08:19 +03:00
Lauri Ojansivu e18f6db741 CHANGELOG: SQRL locks, Windows SSO, faster Scrum batch undo, #6549 docs.
The Upcoming section opens with them and the Bosnian and Tsonga
terminology; TODO Later keeps only the two decided items still to build
(GitLab write-back, the API importers) and drops the finished batch
undo/redo entry.

Thanks to xet7 !
2026-10-14 21:47:06 +03:00
Lauri Ojansivu a594a58117 Login: Windows single sign-on with Kerberos or NTLM.
#5758. On a WeKan running on Windows in a domain, members already signed
in to Windows can sign in with "Sign in with Windows", without a
password: /sso/windows runs the browser's Negotiate handshake with
node-expose-sspi, WeKan finds or creates the member, and the browser
comes back to /sign-in with a one-time token (random, kept as its
SHA-256, one minute, used once) that the page signs in with through a
login handler - so every login check applies, deactivated accounts and
SQRL's sqrlonly included.

- WINDOWS_SSO_ENABLED=true turns it on, on Windows only.
- WINDOWS_SSO_DOMAINS lists the domains allowed; empty means nobody, and
  machine accounts never.
- WINDOWS_SSO_ACTIVE_DIRECTORY=true takes name and e-mail from AD.
- WINDOWS_SSO_MERGE_EXISTING_USERS=true signs in to the account with the
  same username or e-mail; otherwise a new account is made beside a
  local one of the same name, since a local alice is not necessarily the
  domain's alice. A Windows account signs in to the account it linked to
  before.

node-expose-sspi is Windows-only and not a dependency every platform
downloads: the admin installs it into the bundle, and WeKan looks for it
there (docs/Features/Login/WindowsSSO.md; start-wekan.bat has the
variables).

Built without a Windows domain to test against, as decided: the
decisions - names, domains, profile, which member - are tested in plain
Node in tests/windowsSso.test.cjs, with negatives for every malformed or
foreign name, an empty allowlist and a same-named local account; the
handshake and the server wiring are pinned from source. New strings
translated in all locales.

Thanks to nikalasmd and xet7 !
2026-10-14 21:46:17 +03:00
Lauri Ojansivu 5e671c7034 Scrum: batch undo and redo of a large sprint is 2.6 times faster.
Undoing a 10,500-card sprint close took 282 s and redoing it 578 s, about
27 ms a card: each record of a History row cost some thirty database
round trips - a read per record in the preflight, two ownership checks
(two reads each) around every read, the same again around a readback
after every write, and the whole row read back record by record twice
more at the end.

Now a row's records are read in one query per type (readTargets), and
inspectScrumHistoryWrites takes that as readMany, bracketed by one
ownership check before and one after: the worker's claim and the source
row only ever go from current to displaced, so current before and after
the read means current throughout it. Every write keeps its ownership
check right before it, after the read it is computed from; the readback
after a write confirms the value landed and leaves ownership to the next
write's fence and the batch's final readback. A sprint or release that
thousands of cards name is looked up once, not once a card. The
permission check stays per card.

The 10,500-card start / close / undo / redo test went from 14 min 27 s
to 6 min 7 s on the same machine; all 18 Scrum tests pass.
tests/scrumHistoryWriteState.test.cjs pins one read and three checks
for a bracketed read, ownership lost during it, a short answer, a missing
record and identities checked before any read (negative), and that every
write in the path is still fenced after its read.

Thanks to xet7 !
2026-10-14 21:45:54 +03:00
Lauri Ojansivu 03b83c14c1 Docs: how WeKan, Rocket.Chat and a G Suite SAML login chain together.
WeKan -> Rocket.Chat (OAuth2) -> Google (SAML) is three sign-ins in a row,
and with the popup login style the chain broke whenever Rocket.Chat was
not signed in yet: its SAML login left the authorize page the popup was
waiting on, so WeKan never got its code, although Google and Rocket.Chat
both logged a successful login. Logging in to Rocket.Chat first only
avoided the detour.

docs/Features/Login/OAuth2.md now says how to configure the chain: the
redirect login style (the default now; oauth2-login-style /
OAUTH2_LOGIN_STYLE / Admin Panel), Rocket.Chat's SAML entry point and
issuer, Google's NameID as the e-mail address, the exact
/_oauth/oidc redirect URI, https everywhere, how to read the logs, and the
direct alternatives (Google OIDC, WeKan's own SAML).

tests/oauth2RocketChatSamlChainDocs.test.cjs pins that redirect stays the
default in the server and the snap, and that the documented section names
the settings - and never recommends popup.

Thanks to timmy-mac and xet7 !

Fixes #6549.
2026-10-14 21:45:44 +03:00
Lauri Ojansivu a539429fce SQRL: sqrlonly and hardlock lock an account to SQRL.
The member's SQRL client sends both options with every sign-in, and WeKan
now follows them (it used to say they were not supported):

- sqrlonly: every other way of signing in is refused - password, OIDC,
  LDAP, SAML, CAS, passkey, Nextcloud (validateLoginAttempt), the REST
  /users/login and header login, which mint their tokens without it. A
  resume token stays valid; a session already open stays open.
- hardlock: no password-reset e-mail is sent (the answer is the same as
  for a sent one), a reset link sent before does not sign in, and the
  member cannot change their e-mail address.

An ident records the options only once it succeeds; a flag the client
leaves out is switched off. Neither applies while the identity is disabled
from the SQRL client (the emergency stop), nor while the admin has SQRL
off, and an admin can still change the e-mail, set a password and create
a token: those are the ways back. Member Settings / SQRL shows which locks
are on; the sign-in page and the e-mail change say why they refused.

tests/sqrl.test.cjs drives both options through the protocol with a real
Ed25519 client (set, cleared, never by a query, a refused or a forged
ident), the refusal decision for every login type, and pins every path
that signs a member in - and lists every file that mints a login token,
so a new one cannot skip the check. New strings translated in all
locales.

Thanks to xet7 !
2026-10-14 21:45:34 +03:00
Lauri Ojansivu 0502261090 Translations: Bosnian says Tabla for board, Tsonga keeps archive apart from storage.
Bosnian used both ploča and tabla for a board. Every board string now uses
tabla and its cases (genitive plural tabli); ploča stays only where it
means the dashboard (kontrolna ploča) or the Admin Panel. The board
search operator abbreviation is tb, because t is already traka
(swimlane).

Tsonga used vuhlayiselo for both the Archive and storage. The Archive is
now "vuhlayiselo bya swa khale", while storage stays "vuhlayiselo" and the
database "vuhlayiselo bya data". The archive popups ask their question.

Bosnian and Croatian s3-port translated (S3 port).

The audited-corrections rows, the Bosnian audit test and the archive
filter guard follow the decision. wrongLanguageCorrections20261014 pins
Tabla, the tb abbreviation, the dashboard and Admin Panel exceptions, and
the Tsonga archive word wherever English says archive.

Thanks to xet7 !
2026-10-14 21:26:28 +03:00
Lauri Ojansivu 84c6d2db7f Merge remote-tracking branch 'origin/main' 2026-10-14 21:13:44 +03:00
Lauri Ojansivu ddcdd295bb TODO Later: the third round of design decisions.
GitLab write-back with live tests in the build menus (#2698), Windows SSO
(#5758), documenting the Rocket.Chat SAML chain (#6549), SQRL's sqrlonly
and hardlock (#2460), faster batch undo/redo, API importers for Planka,
Microsoft To Do, KanbanFlow, Basecamp, Google Tasks and Google Keep, and
the Bosnian and Tsonga terms.

Thanks to xet7 !
2026-10-14 21:11:33 +03:00
github-actions[bot] c4c8484738 Bump versions for v12.45 2026-10-14 18:00:50 +00:00
Lauri Ojansivu 7ee94d47d8 Prepare v12.45 release 2026-10-14 20:59:39 +03:00
Lauri Ojansivu ea9007d8a8 Scrum: the 10,500-card test gets the time it takes, measured.
It timed out in every full Meteor run. Profiled at full size it passes, in
15 min 25 s: start and close 2 s, the rollover 36 s, the undo batch 282 s
and the redo 578 s - past the 15 minutes it had. The next test, which
interrupts a rollover, then met this one's unfinished writes and failed
too. The test has 30 minutes now, with the measurements beside the
limit, and the Scrum snapshot rows group passes (3 passing, 14 min).

TODO Later now records the real follow-up: batch undo/redo writes about
27 ms a card.

Thanks to xet7 !
2026-10-14 20:10:07 +03:00
Lauri Ojansivu 686995c204 CHANGELOG: the release build fixes in Upcoming.
musl, Android, FreeBSD, the Windows single files, Bun and the Quay copy,
grouped as Release builds under the bug fixes, with In short updated.

Thanks to xet7 !
2026-10-14 19:36:35 +03:00
Lauri Ojansivu ce3d4310d5 Docker: the Quay copy retries a dropped connection.
v12.44's copy to Quay failed on "stream error: ... PROTOCOL_ERROR;
received from peer" - an HTTP/2 reset on the way, not a refusal. A
transport error is retried up to four times; a refusal still fails at
once. tests/releaseDockerRegistryOutage.test.cjs runs the step with a
docker that resets twice, one that refuses, and one that never recovers.

Thanks to xet7 !
2026-10-14 19:28:27 +03:00
Lauri Ojansivu 41399b7518 Bun bundles: FreeBSD's libvips, a retried download, and LF checksum files.
- FreeBSD: sharp 0.35.5's source refuses libvips older than 8.18.7, and
  FreeBSD packages 8.18.6 (latest and quarterly alike), so both FreeBSD
  Bun builds stopped at that #error. A user's FreeBSD runs the packaged
  libvips through the same soname whatever was compiled against, so a
  patch-release gap is accepted with a notice; a larger one still stops.
- windows-x64: one HTTP 500 from the CDN failed the asset download; a
  server error is now retried, a 4xx is not.
- The .sha256sum files were written in Windows text mode, CRLF, and the
  windows-arm64 Bun single file then looked for "<zip>\r". They are LF on
  every platform now, and the reader strips a CR from the ones already
  published.

Thanks to xet7 !
2026-10-14 19:28:27 +03:00
Lauri Ojansivu 0966a90d39 Windows single files start: Meteor's paths, and 32-bit targets built by a 32-bit Node.js.
The win64, win-arm64 and win32 single files stopped at once since v12.41,
for two reasons:

- win64 and win-arm64: Node.js mounts the archive at \\.\NUL\vfs\<n>, and
  Meteor's mini-files.js turns a path beginning with a backslash into
  C:\... - so Npm.require looked in the wrong place and the server
  stopped with "Cannot find module 'denque'". The single file now carries
  mini-files.js patched to keep a UNC or device path (two leading
  separators) as it is; drive paths are unchanged, and the build stops if
  Meteor's code is not the code the patch was written for. It also makes
  a bundle runnable from a \\server\share.
- win32: the SEA blob stores its lengths as size_t, so a blob written by
  the x64 Node.js on the build host is unreadable to a 32-bit node.exe
  (createZipProvider got no archive). build.mjs now refuses a target whose
  pointer width differs from its builder's, and single-file.yml builds
  win32, i386 and 32-bit ARM with the release's i386 Node.js, checksum
  verified - the i386 and ARM ones had the same fault but no runner starts
  them.

tests/singleFile.test.cjs runs Meteor's path functions on Windows path
semantics before and after the patch, refuses win32 from x64, and builds
and starts a real single executable with the patched file.

Thanks to xet7 !
2026-10-14 19:28:27 +03:00
Lauri Ojansivu 0d23746441 FreeBSD bundles start: cpu-exec is used only where it can run.
start-wekan.sh ran every binary through cpu-exec, a #!/bin/bash script for
Linux's /proc/cpuinfo and qemu-user. FreeBSD has no /bin/bash, so it
failed "cpu-exec: not found" and WeKan never answered (the FreeBSD
bundles since v12.41). It is now used only on Linux with a /bin/bash -
the same holds for Alpine without bash and for Termux - and the BSD
bundles drop it, as the Windows and macOS ones already do.

Thanks to xet7 !
2026-10-14 19:28:14 +03:00
Lauri Ojansivu 5331e06199 musl and Android bundles: sharp gets its platform package where it is.
Both builds failed every release since v12.41. sharp is an application
package (programs/server/npm/node_modules), but the scripts removed its
glibc platform packages from programs/server/node_modules, where they are
not, and the npm install there never touches the application's packages.

- musl: releases/platform-native-packages.mjs and check-addon-arch.mjs
  take --libc musl: the glibc packages go and sharp's
  @img/sharp-linuxmusl-<arch> and canvas's -musl package arrive,
  sha512-checked. "Cannot find module 'sharp'" ended the job, from a
  require() run in programs/server; it runs in programs/server/npm now.
- Android: the glibc packages go from the right directory, and sharp is
  built from source against Termux's libvips in its own directory; should
  that fail it runs as WebAssembly, with a warning. Termux's user does not
  own the bind mount, and "chmod: ... Operation not permitted" stopped
  v12.43 and v12.44 - only what is not already executable is chmodded.

tests/platformNativePackages.test.cjs installs the musl packages through
the real script; muslBundles and androidBundles pin the paths, the
--libc musl call and that nothing chmods unconditionally.

Thanks to xet7 !
2026-10-14 19:28:14 +03:00
Lauri Ojansivu 52b4c87127 TODO Later: the 10,500-card Scrum test, with what was measured.
It times out in every full Meteor run, before this round as well; the
profile at 3,000 cards and why it cannot simply run smaller are recorded
so the next step - profiling the board data read and the rollover at full
size - starts from there.

Thanks to xet7 !
2026-10-14 18:58:39 +03:00
Lauri Ojansivu 25d906d93c attachmentApi test: the upload-limit refusal no longer times out.
It failed in every full Meteor run, before this round too, at Mocha's two
seconds. Timing the handler showed it refuses in 2 ms; the time was its
console.error of the refusal - the first error stack of the run, whose
printing loads the server bundle's source map. The test now captures that
log (and checks it was written) instead of printing it, and the group runs
in under half a second.

Thanks to xet7 !
2026-10-14 18:53:37 +03:00
Lauri Ojansivu 60bd70a636 Merge remote-tracking branch 'origin/main' 2026-10-14 18:37:50 +03:00
Lauri Ojansivu 0440454675 CHANGELOG: Tsonga, Bosnian and Aromanian in Upcoming.
Thanks to xet7 !
2026-10-14 18:34:21 +03:00
Lauri Ojansivu ac2410fead Tsonga, Bosnian and Aromanian: the values left for a speaker, translated.
- Tsonga: 618 values were the filler "Hi Xitsonga: mhaka", and 831 more
  were Xitsonga words padded with a repeated "mhaka" wherever the
  generator had no word ("engetela bodo swirho mhaka ku suka mhaka mhaka
  nhlangano"). All are translated from the English, in the file's own
  words (bodo, khadi, nxaxamelo, ndlela, xirho, nhlangano, ntlawa); the
  planning-poker numbers and search symbols are literal again.
- Bosnian: the 748 values that were Serbian, transliterated in v12.44, are
  ijekavian Bosnian now (premjestio, Neraspoređene, Historija, sedmica),
  with the board, card and list words of the rest of the file instead of
  the court-file metaphor they came with (spisi, predmet, postupak).
  Several mistranslations went with it (Export card said convert, Edit WIP
  limit said remove it).
- Aromanian "calendar" replaces the Italian "Calendario"; it is spelled as
  in English, so it joins Aromanian's list of such words.

Low confidence, for a speaker to correct: Tsonga throughout, especially
colour names, migration (ku rhurhisa), backup, lock/unlock, "Xiluva" for
the letter greeting Dear, and Vuhlayiselo used for both storage and
archive; Bosnian ploča where the board title itself says Tabla.

tests/wrongLanguageCorrections20261014.test.cjs fails on any Tsonga filler
or padding, any ekavian form in the Bosnian file, and a lost placeholder
in either.

Thanks to xet7 !
2026-10-14 18:34:09 +03:00
Lauri Ojansivu dc18a2eb00 CHANGELOG: the Sync notification plan fix in Upcoming.
Thanks to xet7 !
2026-10-14 17:49:00 +03:00
Lauri Ojansivu e85eaa3ad3 Sync notifications are planned again with the clear e-mail layout.
v12.44 made the clear HTML layout the default for notification e-mail
(#4434), and with it every e-mail job carries `text`, its plain-text part.
The Sync notification plan validator accepted exactly seven job fields, so
it refused every plan: a rule or Sync run's notifications failed with
sync-notification-plan-invalid. groupKey and deliverAt, from non-default
delivery settings, were refused the same way before that.

The validator now takes what emailOutbox.enqueue takes - the seven fields
and the three optional ones, checked as enqueue checks them - so a plan
never holds a job its replay would refuse. tests/syncNotificationPlan
.test.cjs plans a job with all three and refuses bad ones, and pins that
the optional list is exactly enqueue's parameters; the full Meteor run
found it (Shared notification preparation, Stored Sync notification
delivery).

Thanks to xet7 !
2026-10-14 17:48:45 +03:00
Lauri Ojansivu 5f78baa324 CHANGELOG: the attachment viewer's alt text in Upcoming, bug fixes before translations.
Thanks to xet7 !
2026-10-14 17:34:07 +03:00
Lauri Ojansivu a530a3c673 Accessibility: the attachment viewer's picture has alt text.
The one image in the templates without alt was the attachment viewer's:
it is "Preview" until a picture opens, and then the file's name.
tests/iconOnlyControlLabels.test.cjs now also fails on any img without
alt. Part of #337.

Thanks to ndarilek and xet7 !
2026-10-14 17:33:48 +03:00
Lauri Ojansivu 439add85f1 CHANGELOG: a new Upcoming for the Latvian and other language fixes.
v12.44 shipped while they were in progress; the rest of the wrong-language
replacements and the two guard updates after it go in a new Upcoming
section, as the release rules say.

Thanks to xet7 !
2026-10-14 17:33:07 +03:00
Lauri Ojansivu c11fd215c4 problemsMenuOrder and sandstormWebhookEgress follow two v12.43 changes.
Both failed before this round, on changes that were deliberate:

- The activity notification recovery list (#6768) has Retry all and Cancel
  older as toolbar buttons, written `actions: [{ id, labelKey }]` like the
  menu's panes; the guard now leaves toolbar actions out and still
  requires every menu pane to render.
- Webhooks may reach internal hosts a site admin lists (b4d2604e61):
  off Sandstorm, webhookFetch is fetchSafe except for those hosts, which
  internalWebhookHosts.test.cjs pins; the guard now matches that branch
  and still requires fetchSafe for everything else.

Thanks to xet7 !
2026-10-14 17:32:51 +03:00
Lauri Ojansivu e0649b0cfb Finish the wrong-language replacements: Latvian, and the same words elsewhere.
The first half shipped in v12.44 with "Updated translations"
(b7346d2429): Spanish Save is Guardar, the Bosnian file is in Latin
script, Romanian's Italian words are Romanian, and single values in a
dozen locales. This finishes it:

- Latvian: 225 values were Lithuanian; they are Latvian now, in the
  file's own words (dēlis, kartiņa, darbvieta, migrācija). Low confidence:
  fuksīns, the Monte Carlo note's statistics wording, Teksta sagatave.
- The same wrong words under other keys: Macedonian Настройки in the All
  Boards menu, the Persian settings typo there too, Cantonese 依戀 in the
  attachment search operator, and Occitan "mot de Santa-Clara" for
  password in six values (senhal).

tests/wrongLanguageCorrections20261014.test.cjs now also fails on any
Lithuanian left in the Latvian file and checks its placeholders. Left for
later, with a speaker: the Tsonga file's 596 "Hi Xitsonga: mhaka" filler
values, Aromanian "Calendario", and Bosnian ekavian forms.

Thanks to xet7 !
2026-10-14 17:32:45 +03:00
2714 changed files with 320063 additions and 435561 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"
}
}
+95 -31
View File
@@ -81,8 +81,9 @@ on:
default: ''
type: string
# Read-only by default; the job that writes is granted it below.
permissions:
contents: write
contents: read
concurrency:
group: wekan-appimage-${{ inputs.tag || github.ref }}
@@ -97,19 +98,23 @@ jobs:
tag: ${{ steps.find.outputs.tag }}
version: ${{ steps.find.outputs.version }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- id: find
env:
GH_TOKEN: ${{ github.token }}
INPUT_TAG: ${{ inputs.tag }}
GITHUB_CTX_REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
tag="${INPUT_TAG}"
# --repo on every gh call, whether or not the job happens to have
# checked out: a call that relies on an inferred remote breaks the day
# somebody removes the checkout, and the error it gives - "fatal: not
# a git repository" - names neither gh nor the workflow.
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
[ -n "$tag" ] || tag="$(gh release list --repo "${GITHUB_CTX_REPOSITORY}" \
--limit 1 --json tagName --jq '.[0].tagName')"
if [ -z "$tag" ]; then
echo "::error::No release to wrap, and no tag was given."
@@ -122,7 +127,13 @@ jobs:
echo "Wrapping the bundles from $tag (version $version)"
build:
name: AppImage ${{ matrix.appimage_arch }}
permissions:
contents: write
# The file's architecture, -musl included: two jobs of one name write their
# logs to one directory of the run's log archive, and its files then ask
# to be overwritten when it is unpacked (v12.54: AppImage x86_64 and
# aarch64, twice each).
name: AppImage ${{ matrix.name_arch || matrix.appimage_arch }}
needs: release
runs-on: ${{ matrix.runs_on }}
# A 32-bit job that cannot run on the runner it was given must not fail the
@@ -189,7 +200,9 @@ jobs:
NAME_ARCH: ${{ matrix.name_arch || matrix.appimage_arch }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
if: ${{ env.BUILD_THIS == 'true' }}
# An AppImage only WRAPS an existing release bundle
@@ -204,17 +217,21 @@ jobs:
if: ${{ env.BUILD_THIS == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
NEEDS_RELEASE_VERSION: ${{ needs.release.outputs.version }}
MATRIX_BUNDLE_ARCH: ${{ matrix.bundle_arch }}
GITHUB_CTX_REPOSITORY: ${{ github.repository }}
NEEDS_RELEASE_TAG: ${{ needs.release.outputs.tag }}
run: |
set -euo pipefail
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
zip="wekan-${NEEDS_RELEASE_VERSION}-${MATRIX_BUNDLE_ARCH}.zip"
# Read the complete response before searching: grep -q can close a
# pipe early and turn a present asset into a SIGPIPE failure.
assets=$(gh release view --repo "${{ github.repository }}" '${{ needs.release.outputs.tag }}' \
assets=$(gh release view --repo "${GITHUB_CTX_REPOSITORY}" "${NEEDS_RELEASE_TAG}" \
--json assets --jq '.assets[].name')
if grep -Fxq "$zip" <<< "$assets"; then
echo "OK: $zip is on ${{ needs.release.outputs.tag }}; building its AppImage."
echo "OK: $zip is on ${NEEDS_RELEASE_TAG}; building its AppImage."
else
echo "::notice::$zip is not on ${{ needs.release.outputs.tag }} yet - skipping this AppImage (it can only wrap a bundle that exists)."
echo "::notice::$zip is not on ${NEEDS_RELEASE_TAG} yet - skipping this AppImage (it can only wrap a bundle that exists)."
echo "BUILD_THIS=false" >> "$GITHUB_ENV"
fi
@@ -222,17 +239,36 @@ jobs:
if: ${{ env.BUILD_THIS == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
NEEDS_RELEASE_VERSION: ${{ needs.release.outputs.version }}
MATRIX_BUNDLE_ARCH: ${{ matrix.bundle_arch }}
NEEDS_RELEASE_TAG: ${{ needs.release.outputs.tag }}
GITHUB_CTX_REPOSITORY: ${{ github.repository }}
MATRIX_MUSL: ${{ matrix.musl }}
run: |
set -euo pipefail
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
gh release download '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --pattern "$zip" --clobber
zip="wekan-${NEEDS_RELEASE_VERSION}-${MATRIX_BUNDLE_ARCH}.zip"
gh release download "${NEEDS_RELEASE_TAG}" \
--repo "${GITHUB_CTX_REPOSITORY}" --pattern "$zip" --clobber
unzip -q "$zip"
# A zip that exists is not a bundle that runs; these are the two files
# the AppImage is built around.
# A musl bundle has no node-runtime: its Node.js needs no glibc libraries.
# A musl bundle's node-runtime holds GCC's C++ runtime, which its
# Node.js needs and a bare Alpine lacks; a glibc one holds libatomic.
required="bundle/main.js bundle/node bundle/ferretdb bundle/start-wekan.sh"
[ '${{ matrix.musl }}' = true ] || required="$required bundle/node-runtime/libatomic.so.1"
if [ "${MATRIX_MUSL}" = true ]; then
required="$required bundle/node-runtime/libstdc++.so.6 bundle/node-runtime/libgcc_s.so.1"
else
required="$required bundle/node-runtime/libatomic.so.1"
fi
# And every other library a musl Node.js needs: x86-64's also needs
# libatomic, and the v12.53 x86_64 musl AppImage did not start in a
# bare Alpine without it.
if [ "${MATRIX_MUSL}" = true ]; then
for lib in $(readelf -dW bundle/node | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p'); do
case "$lib" in libc.musl-*|ld-musl-*|libc.so) continue ;; esac
required="$required bundle/node-runtime/$lib"
done
fi
for f in $required; do
if [ ! -e "$f" ]; then
echo "::error::$zip has no $f - it is not a runnable WeKan bundle."
@@ -245,10 +281,13 @@ jobs:
- name: Build the AppDir
if: ${{ env.BUILD_THIS == 'true' }}
env:
NEEDS_RELEASE_VERSION: ${{ needs.release.outputs.version }}
MATRIX_APPIMAGE_ARCH: ${{ matrix.appimage_arch }}
run: |
set -euo pipefail
version='${{ needs.release.outputs.version }}'
arch='${{ matrix.appimage_arch }}'
version="${NEEDS_RELEASE_VERSION}"
arch="${MATRIX_APPIMAGE_ARCH}"
rm -rf AppDir
mkdir -p AppDir/usr/lib/wekan AppDir/usr/share/applications \
AppDir/usr/share/icons/hicolor/512x512/apps
@@ -366,13 +405,20 @@ jobs:
APPRUN
sed -i "s/@APPIMAGE_ARCH@/$arch/" AppDir/AppRun
chmod +x AppDir/AppRun
# The AppImage runs as whoever starts it, not as this runner, so
# everything in it must be readable by all. v12.69's FerretDB and
# mongo-tools were 0711 and appimagetool refused to pack them.
chmod -R a+rX AppDir
- name: Pack the AppImage
if: ${{ env.BUILD_THIS == 'true' }}
env:
NEEDS_RELEASE_VERSION: ${{ needs.release.outputs.version }}
MATRIX_APPIMAGE_ARCH: ${{ matrix.appimage_arch }}
run: |
set -euo pipefail
version='${{ needs.release.outputs.version }}'
arch='${{ matrix.appimage_arch }}'
version="${NEEDS_RELEASE_VERSION}"
arch="${MATRIX_APPIMAGE_ARCH}"
name="WeKan-${version}-${NAME_ARCH}"
curl -fSL --retry 5 --retry-delay 10 -o appimagetool \
@@ -399,9 +445,13 @@ jobs:
- name: Check that it starts
id: smoke
if: ${{ env.BUILD_THIS == 'true' }}
env:
NEEDS_RELEASE_VERSION: ${{ needs.release.outputs.version }}
MATRIX_MUSL: ${{ matrix.musl }}
MATRIX_APPIMAGE_ARCH: ${{ matrix.appimage_arch }}
run: |
set -euo pipefail
version='${{ needs.release.outputs.version }}'
version="${NEEDS_RELEASE_VERSION}"
app="dist/WeKan-${version}-${NAME_ARCH}.AppImage"
# An AppImage that packs is not an AppImage that runs. Start it, wait
# for WeKan to answer on its port, then stop it - so a broken bundle
@@ -410,7 +460,7 @@ jobs:
# A musl AppImage runs its musl Node.js, which an Ubuntu runner cannot,
# so it is started where it is meant to run: inside Alpine.
if [ '${{ matrix.musl }}' = true ]; then
if [ "${MATRIX_MUSL}" = true ]; then
docker run --rm --network host -v "$PWD:/work" -w /work -e APP="$app" alpine:3.23 sh -ec '
apk add --no-cache curl >/dev/null
export WRITABLE_PATH=/work/smoke-data PORT=8080
@@ -436,7 +486,7 @@ jobs:
# AppDir is the exact tree just packed, so this also avoids extracting
# the large image merely to make the capability decision.
if ! LD_LIBRARY_PATH="$PWD/AppDir/usr/lib/wekan/bundle/node-runtime${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" AppDir/usr/lib/wekan/bundle/node --version >/dev/null 2>&1; then
echo "::warning::this runner cannot execute the bundled ${{ matrix.appimage_arch }} Node binary, so the AppImage was packed but not started. It is uploaded unchecked."
echo "::warning::this runner cannot execute the bundled ${MATRIX_APPIMAGE_ARCH} Node binary, so the AppImage was packed but not started. It is uploaded unchecked."
exit 0
fi
@@ -457,7 +507,7 @@ jobs:
fi
echo "WeKan answered on port 8080."
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: ${{ env.BUILD_THIS == 'true' }}
with:
name: appimage-${{ matrix.name_arch || matrix.appimage_arch }}
@@ -476,6 +526,8 @@ jobs:
if: ${{ always() && env.BUILD_THIS == 'true' && steps.smoke.outcome == 'success' && inputs.publish != false }}
env:
GH_TOKEN: ${{ github.token }}
GITHUB_CTX_REPOSITORY: ${{ github.repository }}
NEEDS_RELEASE_TAG: ${{ needs.release.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -486,7 +538,7 @@ jobs:
fi
# --repo inside the script, so gh never has to infer the repository
# from a git remote. Retried, bounded, and verified by name and size.
bash releases/github-release-upload.sh "${{ github.repository }}" '${{ needs.release.outputs.tag }}' "${files[@]}"
bash releases/github-release-upload.sh "${GITHUB_CTX_REPOSITORY}" "${NEEDS_RELEASE_TAG}" "${files[@]}"
# The last job no longer uploads anything: each build job above attached its
# own AppImage as soon as it was built and checked. This one only says, at the
@@ -502,23 +554,35 @@ jobs:
- name: Say what was built, and what was not
env:
GH_TOKEN: ${{ github.token }}
NEEDS_RELEASE_VERSION: ${{ needs.release.outputs.version }}
GITHUB_CTX_REPOSITORY: ${{ github.repository }}
NEEDS_RELEASE_TAG: ${{ needs.release.outputs.tag }}
NEEDS_BUILD_RESULT: ${{ needs.build.result }}
INPUTS_ONLY: ${{ inputs.only }}
run: |
set -euo pipefail
version='${{ needs.release.outputs.version }}'
gh release view --repo "${{ github.repository }}" '${{ needs.release.outputs.tag }}' \
version="${NEEDS_RELEASE_VERSION}"
# The architectures THIS run builds: the musl run asked after the
# glibc AppImages, which another run attaches, and warned of all
# four as missing (v12.54).
wants="x86_64 aarch64 i686 armhf x86_64-musl aarch64-musl"
if [ -n "${INPUTS_ONLY}" ]; then
wants="$(python3 -c 'import json, sys; print(" ".join(json.loads(sys.argv[1])))' "${INPUTS_ONLY}")"
fi
gh release view --repo "${GITHUB_CTX_REPOSITORY}" "${NEEDS_RELEASE_TAG}" \
--json assets --jq '.assets[].name' > published.txt
if grep -qE "^WeKan-${version}-.*\.AppImage$" published.txt; then
# Every architecture the matrix builds. A missing one WARNS rather
# than fails: the 32-bit pair is allowed not to build, and an
# architecture whose base bundle was never published has nothing to
# wrap. What must not happen silently is one going missing unnoticed.
for want in x86_64 aarch64 i686 armhf x86_64-musl aarch64-musl; do
for want in $wants; do
for f in "WeKan-${version}-${want}.AppImage" "WeKan-${version}-${want}.AppImage.sha256sum"; do
grep -qxF "$f" published.txt || \
echo "::warning::$f is not on ${{ needs.release.outputs.tag }} - see the AppImage $want build job's log"
echo "::warning::$f is not on ${NEEDS_RELEASE_TAG} - see the AppImage $want build job's log"
done
done
echo "AppImages on $(gh release view --repo "${{ github.repository }}" '${{ needs.release.outputs.tag }}' --json url --jq .url):"
echo "AppImages on $(gh release view --repo "${GITHUB_CTX_REPOSITORY}" "${NEEDS_RELEASE_TAG}" --json url --jq .url):"
grep -E "^WeKan-${version}-.*\.AppImage" published.txt || true
else
# No AppImage was attached. That is only a FAILURE if there was a base
@@ -529,10 +593,10 @@ jobs:
grep -qx "wekan-${version}-${arch}.zip" published.txt && base_present=true
done
if [ "$base_present" = false ]; then
echo "::notice::No base bundle (wekan-${version}-{amd64,arm64}.zip) is on ${{ needs.release.outputs.tag }} yet, so there is nothing to wrap - skipping. release-all.yml builds the AppImages once the base bundles are published."
echo "::notice::No base bundle (wekan-${version}-{amd64,arm64}.zip) is on ${NEEDS_RELEASE_TAG} yet, so there is nothing to wrap - skipping. release-all.yml builds the AppImages once the base bundles are published."
exit 0
fi
if [ "${{ needs.build.result }}" = "cancelled" ]; then
if [ "${NEEDS_BUILD_RESULT}" = "cancelled" ]; then
echo "::warning::The run was cancelled before any AppImage finished building; nothing was attached."
exit 0
fi
+46 -21
View File
@@ -83,8 +83,9 @@ on:
default: ''
type: string
# Read-only by default; the job that writes is granted it below.
permissions:
contents: write
contents: read
concurrency:
group: wekan-flatpak-${{ inputs.tag || github.ref }}
@@ -99,17 +100,21 @@ jobs:
tag: ${{ steps.find.outputs.tag }}
version: ${{ steps.find.outputs.version }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- id: find
env:
GH_TOKEN: ${{ github.token }}
INPUT_TAG: ${{ inputs.tag }}
GITHUB_CTX_REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
tag="${INPUT_TAG}"
# --repo on every gh call, whether or not the job happens to have
# checked out: one that relies on an inferred remote breaks the day
# somebody removes the checkout.
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
[ -n "$tag" ] || tag="$(gh release list --repo "${GITHUB_CTX_REPOSITORY}" \
--limit 1 --json tagName --jq '.[0].tagName')"
if [ -z "$tag" ]; then
echo "::error::No release to wrap, and no tag was given."
@@ -121,6 +126,8 @@ jobs:
echo "Wrapping the bundles from $tag (version $version)"
build:
permissions:
contents: write
name: flatpak ${{ matrix.flatpak_arch }}
needs: release
runs-on: ${{ matrix.runs_on }}
@@ -147,7 +154,9 @@ jobs:
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.flatpak_arch) }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
if: ${{ env.BUILD_THIS == 'true' }}
- name: Install flatpak
@@ -164,11 +173,15 @@ jobs:
if: ${{ env.BUILD_THIS == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
NEEDS_RELEASE_VERSION: ${{ needs.release.outputs.version }}
MATRIX_BUNDLE_ARCH: ${{ matrix.bundle_arch }}
NEEDS_RELEASE_TAG: ${{ needs.release.outputs.tag }}
GITHUB_CTX_REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
gh release download '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --pattern "$zip" --clobber
zip="wekan-${NEEDS_RELEASE_VERSION}-${MATRIX_BUNDLE_ARCH}.zip"
gh release download "${NEEDS_RELEASE_TAG}" \
--repo "${GITHUB_CTX_REPOSITORY}" --pattern "$zip" --clobber
unzip -q "$zip"
for f in bundle/main.js bundle/node bundle/node-runtime/libatomic.so.1 bundle/ferretdb bundle/start-wekan.sh; do
if [ ! -e "$f" ]; then
@@ -186,10 +199,13 @@ jobs:
# export it into a repository.
- name: Build the Flatpak
if: ${{ env.BUILD_THIS == 'true' }}
env:
NEEDS_RELEASE_VERSION: ${{ needs.release.outputs.version }}
MATRIX_FLATPAK_ARCH: ${{ matrix.flatpak_arch }}
run: |
set -euo pipefail
version='${{ needs.release.outputs.version }}'
arch='${{ matrix.flatpak_arch }}'
version="${NEEDS_RELEASE_VERSION}"
arch="${MATRIX_FLATPAK_ARCH}"
rm -rf build-dir repo
flatpak build-init --arch="$arch" build-dir \
@@ -273,13 +289,16 @@ jobs:
- name: Make the single-file bundle
id: bundle
if: ${{ env.BUILD_THIS == 'true' }}
env:
NEEDS_RELEASE_VERSION: ${{ needs.release.outputs.version }}
MATRIX_FLATPAK_ARCH: ${{ matrix.flatpak_arch }}
run: |
set -euo pipefail
version='${{ needs.release.outputs.version }}'
name="WeKan-${version}-${{ matrix.flatpak_arch }}"
version="${NEEDS_RELEASE_VERSION}"
name="WeKan-${version}-${MATRIX_FLATPAK_ARCH}"
mkdir -p dist
flatpak build-bundle repo "dist/${name}.flatpak" \
--arch='${{ matrix.flatpak_arch }}' io.github.wekan.WeKan stable
--arch="${MATRIX_FLATPAK_ARCH}" io.github.wekan.WeKan stable
# Checksum keeps the .flatpak extension in its name, matching the zip
# convention and what releases/expected-assets.sh looks for; dropping it
# meant "what is missing" never found the checksum and rebuilt the
@@ -289,14 +308,14 @@ jobs:
sha256sum "${name}.flatpak" > "${name}.flatpak.sha256sum" )
ls -lh dist
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: ${{ env.BUILD_THIS == 'true' }}
with:
name: flatpak-${{ matrix.flatpak_arch }}
path: dist/
if-no-files-found: error
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: ${{ env.BUILD_THIS == 'true' }}
with:
name: flatpak-repo-${{ matrix.flatpak_arch }}
@@ -316,6 +335,8 @@ jobs:
if: ${{ always() && env.BUILD_THIS == 'true' && steps.bundle.outcome == 'success' && inputs.publish != false }}
env:
GH_TOKEN: ${{ github.token }}
GITHUB_CTX_REPOSITORY: ${{ github.repository }}
NEEDS_RELEASE_TAG: ${{ needs.release.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -325,7 +346,7 @@ jobs:
exit 1
fi
# Retried, bounded, and verified by name and size from the release side.
bash releases/github-release-upload.sh "${{ github.repository }}" '${{ needs.release.outputs.tag }}' "${files[@]}"
bash releases/github-release-upload.sh "${GITHUB_CTX_REPOSITORY}" "${NEEDS_RELEASE_TAG}" "${files[@]}"
# This job no longer attaches the bundles: each build job above attached its
# own as soon as it was built. What is left for the end is what needs BOTH
@@ -343,9 +364,11 @@ jobs:
# the workspace - so it has to happen before the artifacts are downloaded
# into it. Without the checkout the step fails with "No such file or
# directory", which is how the v10.88 run ended.
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
# A cancelled run may have uploaded none; the next step says so.
continue-on-error: true
with:
@@ -354,11 +377,13 @@ jobs:
merge-multiple: true
- name: Say what was built, and what was not
env:
NEEDS_BUILD_RESULT: ${{ needs.build.result }}
run: |
set -euo pipefail
ls -lh assets 2>/dev/null || true
if ! ls assets/*.flatpak >/dev/null 2>&1; then
if [ "${{ needs.build.result }}" = "cancelled" ]; then
if [ "${NEEDS_BUILD_RESULT}" = "cancelled" ]; then
echo "::warning::The run was cancelled before any Flatpak bundle finished building."
exit 0
fi
@@ -371,7 +396,7 @@ jobs:
done
# ---- the repository -------------------------------------------------
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: ${{ inputs.publish_repo != false }}
with:
pattern: flatpak-repo-*
@@ -443,7 +468,7 @@ jobs:
TXT
du -sh flatpak-repo
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: ${{ inputs.publish_repo != false }}
with:
name: flatpak-repository
+16 -7
View File
@@ -49,7 +49,7 @@ jobs:
tag: ${{ steps.plan.outputs.tag }}
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- id: plan
@@ -86,7 +86,7 @@ jobs:
TAG: ${{ needs.plan.outputs.tag }}
TARGET: ${{ matrix.target }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -136,8 +136,17 @@ jobs:
run: |
# Termux's entrypoint clears the Docker environment when it switches
# to its unprivileged user, so the values go after the switch (as
# bun.yml does), and that user needs a writable bind mount.
# bun.yml does), and that user needs a writable bind mount. Writable
# is not enough: npm chmods every package's bin when it installs, and
# only a file's OWNER may chmod it (EPERM on uuid/dist/bin/uuid,
# v12.51), so the checkout is handed to that user. Package takes the
# bundle back.
chmod -R a+rwX .
owner="$(docker run --rm "termux/termux-docker:$TERMUX_ARCH" sh -c 'echo "$(id -u):$(id -g)"' | tr -d '\r' | tail -n1)"
case "$owner" in
*[0-9]:[0-9]*) sudo chown -R "$owner" . ;;
*) echo "::error::Could not read the uid of Termux's user (got: $owner)."; exit 1 ;;
esac
docker run --rm --security-opt seccomp=unconfined -v "$PWD:/work" -w /work \
"termux/termux-docker:$TERMUX_ARCH" env "ARCH=$ARCH" DO_NOT_TRACK=1 bash -ec '
apt-get update
@@ -150,13 +159,13 @@ jobs:
VERSION: ${{ steps.fetch.outputs.version }}
run: |
set -euo pipefail
sudo chown -R "$(id -u):$(id -g)" bundle
sudo chown -R "$(id -u):$(id -g)" .
out="wekan-${VERSION}-${TARGET}.zip"
zip -qry "$out" bundle
sha256sum "$out" > "$out.sha256sum"
ls -la "$out" "$out.sha256sum"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: steps.fetch.outputs.skip != 'true'
with:
name: android-${{ matrix.target }}
@@ -172,10 +181,10 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: android-*
path: artifacts
+15 -14
View File
@@ -54,7 +54,7 @@ jobs:
tag: ${{ steps.plan.outputs.tag }}
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- id: plan
@@ -90,7 +90,7 @@ jobs:
TAG: ${{ needs.plan.outputs.tag }}
TARGET: ${{ matrix.target }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -131,13 +131,13 @@ jobs:
bash releases/fetch.sh -o npm.tar.xz "https://nodejs.org/dist/${NODE_FULL}/node-${NODE_FULL}-linux-x64.tar.xz"
tar -xJf npm.tar.xz --strip-components=4 -C bsd-tools/npm "node-${NODE_FULL}-linux-x64/lib/node_modules/npm"
rm -f npm.tar.xz
ferret="https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-${FERRETDB_ARCH}"
bash releases/fetch.sh -o bundle/ferretdb "$ferret"
if bash releases/fetch.sh --optional -o ferretdb.sha256sum "${ferret}.sha256sum"; then
echo "$(awk '{print $1; exit}' ferretdb.sha256sum) bundle/ferretdb" | sha256sum -c -
else
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-${FERRETDB_ARCH}, so it is unverified."
fi
# The prebuilt SQLite-only ferretdb-sqlite-<bsd>-<arch> from the newest
# wekan/FerretDB release that has it (the full ferretdb-<bsd>-<arch> of
# the newest release until one does), verified against its .sha256sum;
# releases/ferretdb-asset.sh says in the log which one it took.
ferret_meta="$(bash releases/ferretdb-asset.sh "$FERRETDB_ARCH" -o bundle/ferretdb)" \
|| { echo "::error::FerretDB for ${FERRETDB_ARCH} could not be put in the bundle; the lines above say why."; exit 1; }
eval "$ferret_meta"
# The inherited amd64 Linux MongoDB Database Tools are the wrong OS
# here: replaced by this BSD's, or removed when there are none.
rc=0; bash releases/fetch-mongo-tools.sh bundle "$FERRETDB_ARCH" || rc=$?
@@ -147,7 +147,7 @@ jobs:
- name: Build and start in FreeBSD
if: steps.node.outputs.skip != 'true' && matrix.os == 'freebsd'
uses: vmactions/freebsd-vm@f0552d3b69211736abd97f02ff3d4674c56b73b1
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc
with:
release: ${{ matrix.release }}
arch: ${{ matrix.vm_arch }}
@@ -158,7 +158,7 @@ jobs:
- name: Build and start in OpenBSD
if: steps.node.outputs.skip != 'true' && matrix.os == 'openbsd'
uses: vmactions/openbsd-vm@v1
uses: vmactions/openbsd-vm@f1ca8bc8ecc5b6ef7218e5b914836e067e1546d3 # v1.4.9
with:
release: ${{ matrix.release }}
arch: ${{ matrix.vm_arch }}
@@ -174,7 +174,7 @@ jobs:
- name: Build and start in NetBSD
if: steps.node.outputs.skip != 'true' && matrix.os == 'netbsd'
uses: vmactions/netbsd-vm@v1
uses: vmactions/netbsd-vm@c8a0d7ddb84619a7a8cc4e652efa7950b3fd9f92 # v1.5.2
with:
release: ${{ matrix.release }}
sync: rsync
@@ -194,6 +194,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PUBLISH: ${{ inputs.publish }}
MATRIX_OS: ${{ matrix.os }}
run: |
set -euo pipefail
version="${TAG#v}"
@@ -204,7 +205,7 @@ jobs:
for want in bundle/main.js bundle/node bundle/ferretdb bundle/start-wekan.sh; do
grep -q " $want$" zip-listing.txt || { echo "::error::$zip has no $want."; exit 1; }
done
if [ "${{ matrix.os }}" = freebsd ]; then
if [ "${MATRIX_OS}" = freebsd ]; then
grep -q " bundle/rc.d/wekan$" zip-listing.txt || { echo "::error::$zip has no bundle/rc.d/wekan (#2662)."; exit 1; }
fi
sha256sum "$zip" > "$zip.sha256sum"
@@ -213,7 +214,7 @@ jobs:
bash releases/github-release-upload.sh "$GITHUB_REPOSITORY" "$TAG" "$zip" "$zip.sha256sum"
fi
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: steps.node.outputs.skip != 'true'
with:
name: bundle-${{ matrix.target }}
+8 -5
View File
@@ -50,7 +50,7 @@ jobs:
tag: ${{ steps.plan.outputs.tag }}
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- id: plan
@@ -88,7 +88,7 @@ jobs:
run:
shell: bash
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.plan.outputs.tag }}
@@ -107,6 +107,9 @@ jobs:
exit 0
fi
[ -f "$zip.sha256sum" ] || { echo "::error::$zip has no published .sha256sum; a single file is never built from an unverified bundle."; exit 1; }
# A .sha256sum written on Windows before releases/bun/assets.py wrote LF
# (v12.44 windows-arm64) ends in CRLF; the line is the same without it.
tr -d '\r' < "$zip.sha256sum" > "$zip.sha256sum.lf" && mv "$zip.sha256sum.lf" "$zip.sha256sum"
if command -v sha256sum >/dev/null; then sha256sum -c "$zip.sha256sum"; else shasum -a 256 -c "$zip.sha256sum"; fi
# Only the bundle's own Bun, to build with - the ZIP itself is
# embedded as it is.
@@ -138,7 +141,7 @@ jobs:
docker run --rm -v "$PWD:/work" -w /work -e ZIP -e BUN -e OUT -e VERSION -e TARGET \
alpine:3.23 sh -ec 'apk add --no-cache bash coreutils libstdc++ libgcc && bash releases/single-file/bun/build-and-check.sh'
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: steps.fetch.outputs.skip != 'true'
with:
name: bun-single-file-${{ matrix.target }}
@@ -154,10 +157,10 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: bun-single-file-*
path: artifacts
+37 -15
View File
@@ -55,7 +55,7 @@ jobs:
matrix: ${{ steps.plan.outputs.matrix }}
count: ${{ steps.plan.outputs.count }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- id: plan
@@ -74,19 +74,19 @@ jobs:
steps:
# Keep workflow helpers from this workflow revision, and the app from the
# exact release tag. Never put current main's code on an older release.
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: build-tools
persist-credentials: false
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: refs/tags/${{ needs.plan.outputs.tag }}
path: app
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '26'
- uses: oven-sh/setup-bun@v2
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: ${{ needs.plan.outputs.bun_version }}
- name: Prepare isolated Meteor with ESM support
@@ -95,7 +95,7 @@ jobs:
mkdir -p releases/bun
cp -R ../build-tools/releases/bun/. releases/bun/
bash releases/bun/prepare-meteor.sh
- uses: actions/cache@v6
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.meteor
@@ -103,7 +103,7 @@ jobs:
app/.tools/meteor-bun/dev_bundle
app/.tools/meteor-bun/.babel-cache
app/.tools/meteor-bun/packages/*/.npm
key: bun-meteor-${{ runner.os }}-${{ hashFiles('app/.meteor/release', 'app/.meteor/versions', 'build-tools/releases/bun/meteor-3.6-rc.0.patch') }}
key: bun-meteor-${{ runner.os }}-${{ hashFiles('app/.meteor/release', 'app/.meteor/versions', 'build-tools/releases/bun/meteor-3.6-rc.1.patch') }}
- name: Build the release's ESM application once
working-directory: app
env:
@@ -118,7 +118,7 @@ jobs:
git rev-parse HEAD > ../esm-output/bundle/wekan-commit.txt
node --test .tools/meteor-bun/scripts/ci/__tests__/*.node-test.js
tar -cf - -C ../esm-output bundle | gzip -1 > ../esm-bundle.tar.gz
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: bun-esm-source
path: esm-bundle.tar.gz
@@ -148,13 +148,13 @@ jobs:
run:
shell: bash
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: bun-esm-source
- uses: actions/setup-python@v6
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: '3.13'
- name: Unpack ESM and fetch verified target Bun
@@ -162,7 +162,7 @@ jobs:
tar -xzf esm-bundle.tar.gz
export WEKAN_COMMIT="$(cat bundle/wekan-commit.txt)"
python3 releases/bun/assets.py fetch "$BUN_TAG" "$TARGET"
- uses: actions/setup-node@v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
if: matrix.kind == 'native'
with:
node-version: '26'
@@ -172,11 +172,33 @@ jobs:
run: |
mkdir -p .tools/tmp
export TMPDIR="$PWD/.tools/tmp"
# Where releases/tools-env.sh, which build-target.sh loads, looks for
# them. Installed in ~/.cache instead, the smoke test found no browser
# (linux-arm64, v12.66).
export PLAYWRIGHT_BROWSERS_PATH="$PWD/.tools/ms-playwright"
npm ci --prefix tests/playwright --ignore-scripts
tests/playwright/node_modules/.bin/playwright install --with-deps chromium
- name: Start an isolated MongoDB for Linux HTTP and DDP tests
if: matrix.kind == 'native' && matrix.platform == 'linux'
run: docker run -d --rm --name bun-smoke-mongo -p 127.0.0.1:27019:27017 mongo:8
run: |
# Not --rm: a container that dies must leave its log behind. v12.56
# linux-x64 found nothing on 27019 ("Topology is closed") and there
# was no way to see why. Wait until it answers, or show why not.
# 9.0.4, not mongo:8: on the runner's 6.19+ kernel mongo:8 refused to
# start ("Linux kernel versions 6.19 and newer has a known
# incompatibility", v12.66). 9.0.4 recognises Ubuntu kernels that
# have the fix; see docs/Databases/Supported.md.
docker run -d --name bun-smoke-mongo -p 127.0.0.1:27019:27017 mongo:9.0.4
for i in $(seq 1 60); do
if docker exec bun-smoke-mongo mongosh --quiet --eval 'db.runCommand({ ping: 1 }).ok' 2>/dev/null | grep -qx 1; then
echo "MongoDB answers on 127.0.0.1:27019 after ${i}s."; exit 0
fi
sleep 1
done
echo "::error::the smoke-test MongoDB did not answer within 60s; its state and log follow."
docker ps -a --filter name=bun-smoke-mongo
docker logs bun-smoke-mongo 2>&1 | tail -n 80
exit 1
- name: Native Linux, macOS or Windows build and smoke tests
if: matrix.kind == 'native'
env:
@@ -215,7 +237,7 @@ jobs:
bash releases/bun/build-target.sh'
- name: FreeBSD VM build and smoke tests
if: matrix.kind == 'freebsd'
uses: vmactions/freebsd-vm@v1
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: '14.5'
arch: ${{ matrix.vm_arch }}
@@ -232,7 +254,7 @@ jobs:
[ "${#files[@]}" -eq 1 ]
test -s "${files[0]}"
test -s "${files[0]}.sha256sum"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always() && steps.ready.outcome == 'success'
with:
name: wekan-bun-${{ matrix.target }}
+4 -2
View File
@@ -10,6 +10,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: 'Dependency Review'
uses: actions/dependency-review-action@v5
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
+6 -3
View File
@@ -33,7 +33,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The Dockerfile DOWNLOADS the prebuilt release zip that release-all.yml
# publishes (and `exit 8`s if it is missing), so this image can only be
@@ -47,6 +49,7 @@ jobs:
id: rel
env:
GH_TOKEN: ${{ github.token }}
GITHUB_CTX_REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
VERSION="$(sed -n 's/^ARG VERSION=//p' Dockerfile | head -1)"
@@ -55,7 +58,7 @@ jobs:
# at the first match while gh is still writing; gh gets SIGPIPE, pipefail
# makes the pipeline fail, and a PUBLISHED asset was reported missing - the
# v12.39 Mac apps were skipped that way, with the bundles already attached.
assets="$(gh release view "v${VERSION}" --repo "${{ github.repository }}" \
assets="$(gh release view "v${VERSION}" --repo "${GITHUB_CTX_REPOSITORY}" \
--json assets --jq '.assets[].name' 2>/dev/null || true)"
if grep -qx "$asset" <<< "$assets"; then
echo "build=yes" >> "$GITHUB_OUTPUT"
@@ -69,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 }}
+23 -12
View File
@@ -62,6 +62,7 @@ jobs:
- name: Check DOCKERHUB_AUTH is configured and works
env:
DOCKERHUB_AUTH: ${{ secrets.DOCKERHUB_AUTH }}
INPUT_IMAGE: ${{ inputs.image }}
run: |
set -uo pipefail
if [ -z "${DOCKERHUB_AUTH:-}" ]; then
@@ -79,16 +80,19 @@ jobs:
user="${creds%%:*}"
token="${creds#*:}"
if ! printf '%s' "$token" | docker login docker.io -u "$user" --password-stdin; then
echo "::error::docker-variant: DOCKERHUB_AUTH was REJECTED by docker.io (login failed as user '$user'). The token is wrong, expired, or lacks push rights for wekanteam/${{ inputs.image }}."
echo "::error::docker-variant: DOCKERHUB_AUTH was REJECTED by docker.io (login failed as user '$user'). The token is wrong, expired, or lacks push rights for wekanteam/${INPUT_IMAGE}."
exit 1
fi
echo "docker-variant: DOCKERHUB_AUTH works for docker.io (user '$user')."
- name: Work out which version to publish
id: v
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_IMAGE: ${{ inputs.image }}
run: |
set -euo pipefail
version="${{ inputs.version }}"
version="${INPUT_VERSION}"
if [ -z "$version" ]; then
# The newest release of wekan/wekan, asked of GitHub rather than guessed.
version="$(curl -sSL -H 'Accept: application/vnd.github+json' \
@@ -101,16 +105,20 @@ jobs:
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Publishing wekanteam/${{ inputs.image }} from wekanteam/wekan:v$version"
echo "Publishing wekanteam/${INPUT_IMAGE} from wekanteam/wekan:v$version"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Retag wekanteam/wekan as wekanteam/${{ inputs.image }}
env:
STEP_V_VERSION: ${{ steps.v.outputs.version }}
INPUT_IMAGE: ${{ inputs.image }}
INPUT_ALSO_LATEST: ${{ inputs.also_latest }}
run: |
set -euo pipefail
src="wekanteam/wekan:v${{ steps.v.outputs.version }}"
dst="wekanteam/${{ inputs.image }}:v${{ steps.v.outputs.version }}"
src="wekanteam/wekan:v${STEP_V_VERSION}"
dst="wekanteam/${INPUT_IMAGE}:v${STEP_V_VERSION}"
# The source must exist and be the full multi-arch manifest, or the
# variant tag would quietly cover fewer architectures than wekan does.
@@ -128,8 +136,8 @@ jobs:
done
tags=(-t "$dst")
if [ "${{ inputs.also_latest }}" = "true" ]; then
tags+=(-t "wekanteam/${{ inputs.image }}:latest")
if [ "${INPUT_ALSO_LATEST}" = "true" ]; then
tags+=(-t "wekanteam/${INPUT_IMAGE}:latest")
fi
# imagetools create copies the MANIFEST, so the variant tag points at the
# very digests the release built and verified. Nothing is rebuilt.
@@ -149,12 +157,15 @@ jobs:
- name: Job result
if: always()
env:
JOB_STATUS: ${{ job.status }}
INPUT_IMAGE: ${{ inputs.image }}
run: |
case "${{ job.status }}" in
case "${JOB_STATUS}" in
success) mark='OK ' ;;
failure) mark='FAILED ' ;;
cancelled) mark='CANCELLED' ;;
*) mark='${{ job.status }}' ;;
*) mark="${JOB_STATUS}" ;;
esac
echo "$mark docker-variant wekanteam/${{ inputs.image }}"
echo "$mark docker-variant wekanteam/${{ inputs.image }}" >> "$GITHUB_STEP_SUMMARY"
echo "$mark docker-variant wekanteam/${INPUT_IMAGE}"
echo "$mark docker-variant wekanteam/${INPUT_IMAGE}" >> "$GITHUB_STEP_SUMMARY"
+5 -2
View File
@@ -16,7 +16,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The Dockerfile does not build WeKan from source — it DOWNLOADS the prebuilt
# release zip (wekan-<VERSION>-amd64.zip) that release-all.yml publishes, and
@@ -31,6 +33,7 @@ jobs:
id: rel
env:
GH_TOKEN: ${{ github.token }}
GITHUB_CTX_REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
VERSION="$(sed -n 's/^ARG VERSION=//p' Dockerfile | head -1)"
@@ -39,7 +42,7 @@ jobs:
# at the first match while gh is still writing; gh gets SIGPIPE, pipefail
# makes the pipeline fail, and a PUBLISHED asset was reported missing - the
# v12.39 Mac apps were skipped that way, with the bundles already attached.
assets="$(gh release view "v${VERSION}" --repo "${{ github.repository }}" \
assets="$(gh release view "v${VERSION}" --repo "${GITHUB_CTX_REPOSITORY}" \
--json assets --jq '.assets[].name' 2>/dev/null || true)"
if grep -qx "$asset" <<< "$assets"; then
echo "build=yes" >> "$GITHUB_OUTPUT"
+9 -4
View File
@@ -34,8 +34,9 @@ on:
default: ''
type: string
# Read-only by default; the job that writes is granted it below.
permissions:
contents: write
contents: read
concurrency:
group: wekan-mac-app-${{ inputs.tag || github.ref }}
@@ -65,6 +66,8 @@ jobs:
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
build:
permissions:
contents: write
name: Mac app ${{ matrix.arch }}
needs: release
runs-on: ${{ matrix.runner }}
@@ -82,7 +85,9 @@ jobs:
env:
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.arch) }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
if: ${{ env.BUILD_THIS == 'true' }}
- name: Download and verify the native macOS bundle
if: ${{ env.BUILD_THIS == 'true' }}
@@ -153,14 +158,14 @@ jobs:
"$PWD/.tools/tmp/mac-smoke.log"
- name: Keep Mac smoke log
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: ${{ always() && env.BUILD_THIS == 'true' && steps.bundle.outputs.available == 'true' }}
with:
name: mac-smoke-${{ matrix.arch }}
path: .tools/tmp/mac-smoke.log
if-no-files-found: ignore
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: ${{ env.BUILD_THIS == 'true' && steps.bundle.outputs.available == 'true' }}
with:
name: mac-app-${{ matrix.arch }}
+11 -17
View File
@@ -32,24 +32,17 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.inputs.ref || 'main' }}
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Use release npm version
run: bash releases/use-release-npm.sh
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
# Same as sandstorm.yml: Ubuntu 24.04's AppArmor blocks the unprivileged user
# namespaces the Sandstorm install relies on; relax it so the install (whose
# sandstorm-http-bridge build-deps.sh copies in) can run on the runner.
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
- name: Install system dependencies
run: |
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" p7zip-full wget curl libatomic1 build-essential g++ make python3 unzip xz-utils git zip
@@ -58,13 +51,13 @@ jobs:
# build-deps.sh's [bridge] step copies the modern
# /opt/sandstorm/latest/bin/sandstorm-http-bridge into the deps, so Sandstorm
# must be installed first — same ordering as sandstorm.yml.
run: curl https://install.sandstorm.io | sudo bash -s -- -d
run: curl https://install.sandstorm.org | sudo bash -s -- -d
- name: Assemble modern meteor-spk.deps (Node 26 + FerretDB + Mongo 3.x CLIs)
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.io; build-deps.sh swaps in
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.org; build-deps.sh swaps in
# Node 26, FerretDB, the Mongo 3.x CLIs, the migration launcher/importer, keeps
# niscud, and swaps in the modern sandstorm-http-bridge. Produces
# $HOME/projects/meteor-spk/meteor-spk-0.6.0 (the meteor-spk tool + deps).
# $GITHUB_WORKSPACE/.tools/projects/meteor-spk/meteor-spk-0.6.0 (the meteor-spk tool + deps).
run: bash sandstorm-src/build-deps.sh
- name: Zip the meteor-spk bundle
@@ -73,7 +66,7 @@ jobs:
set -euo pipefail
STAMP="$(date +'%Y-%m-%d_%H_%M_%S')"
ZIP="meteor-spk-${STAMP}.zip"
BUNDLE_DIR="$HOME/projects/meteor-spk"
BUNDLE_DIR="$GITHUB_WORKSPACE/.tools/projects/meteor-spk"
if [ ! -d "$BUNDLE_DIR/meteor-spk-0.6.0" ]; then
echo "::error::Expected meteor-spk bundle at $BUNDLE_DIR/meteor-spk-0.6.0 was not produced by build-deps.sh."
exit 1
@@ -87,7 +80,7 @@ jobs:
ls -la "$GITHUB_WORKSPACE/$ZIP"
echo "zip=$ZIP" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ steps.zip.outputs.zip }}
path: ${{ steps.zip.outputs.zip }}
@@ -97,6 +90,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
STEP_ZIP_ZIP: ${{ steps.zip.outputs.zip }}
run: |
set -euo pipefail
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || true)"
@@ -104,5 +98,5 @@ jobs:
echo "::warning::No existing GitHub Release found; the meteor-spk bundle is available only as a workflow artifact."
exit 0
fi
echo "Uploading ${{ steps.zip.outputs.zip }} to the newest release: $TAG"
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "$TAG" "${{ steps.zip.outputs.zip }}" --clobber
echo "Uploading ${STEP_ZIP_ZIP} to the newest release: $TAG"
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "$TAG" "${STEP_ZIP_ZIP}" --clobber
+6 -6
View File
@@ -48,7 +48,7 @@ jobs:
tag: ${{ steps.plan.outputs.tag }}
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- id: plan
@@ -82,7 +82,7 @@ jobs:
TAG: ${{ needs.plan.outputs.tag }}
TARGET: ${{ matrix.target }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -130,7 +130,7 @@ jobs:
run: |
docker run --rm -v "$PWD:/work" -w /work -e ARCH \
alpine:3.23 sh -ec '
apk add --no-cache bash coreutils curl file python3 make g++ linux-headers libstdc++ libgcc
apk add --no-cache bash coreutils curl file python3 make g++ linux-headers binutils libstdc++ libgcc libatomic
bash releases/musl/build-bundle.sh "$ARCH"'
- name: Package
@@ -145,7 +145,7 @@ jobs:
sha256sum "$out" > "$out.sha256sum"
ls -la "$out" "$out.sha256sum"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: steps.fetch.outputs.skip != 'true'
with:
name: musl-${{ matrix.target }}
@@ -161,10 +161,10 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: musl-*
path: artifacts
+7 -6
View File
@@ -41,7 +41,9 @@ jobs:
echo 'ok=true' >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
if: steps.guard.outputs.ok == 'true'
- name: Prepare temporary directory and validate release
@@ -56,7 +58,7 @@ jobs:
- name: Checkout the ExApp definition
if: steps.guard.outputs.ok == 'true'
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: wekan/nextcloud
token: ${{ secrets.WEKAN_REPO_TOKEN }}
@@ -70,13 +72,13 @@ jobs:
- name: Set up QEMU
if: steps.guard.outputs.ok == 'true' && steps.plan.outputs.skip != 'true'
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
with:
platforms: arm64
- name: Set up Docker Buildx
if: steps.guard.outputs.ok == 'true' && steps.plan.outputs.skip != 'true'
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Publish the missing versioned ExApp image
if: steps.guard.outputs.ok == 'true' && steps.plan.outputs.skip != 'true'
@@ -128,8 +130,7 @@ jobs:
if jq -e --arg name "$archive" '.assets | any(.name == $name)' "$task_dir/assets.json" >/dev/null; then
gh release download --repo wekan/wekan "v$VERSION" --pattern "$archive" --dir "$task_dir"
else
tar --exclude=.git --exclude=.tools --exclude=.env --exclude=node_modules \
-czf "$task_dir/$archive" --transform "s,^\.$,${APP_ID},;s,^\./,${APP_ID}/," -C exapp .
releases/nextcloud-pack.sh exapp "$APP_ID" "$task_dir/$archive"
new_archive=true
fi
python3 releases/nextcloud-release.py --version "$VERSION" --archive "$task_dir/$archive" --app-id "$APP_ID"
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v7
- name: Setup Node 26
uses: actions/setup-node@v6
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '26'
cache: 'npm'
@@ -92,7 +92,7 @@ jobs:
- uses: actions/checkout@v7
- name: Setup Node 26
uses: actions/setup-node@v6
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '26'
cache: 'npm'
@@ -220,7 +220,7 @@ jobs:
- uses: actions/checkout@v7
- name: Setup Node 26
uses: actions/setup-node@v6
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '26'
cache: 'npm'
+55 -25
View File
@@ -57,9 +57,9 @@ on:
required: false
type: string
# Read-only by default; the job that writes is granted it below.
permissions:
contents: write
actions: write
contents: read
env:
DO_NOT_TRACK: '1'
@@ -74,10 +74,15 @@ concurrency:
jobs:
recover:
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
timeout-minutes: 360
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check dependency risk indicators before recovery
run: python3 releases/remote-release.py --audit
- name: Retry failed jobs of the matching full release
@@ -92,6 +97,12 @@ jobs:
plan:
needs: recover
# The planner runs whatever recover's rerun ended with. A rerun that still
# fails - Launchpad answering 503, a fault that is in the tagged code and
# so in every rerun of it (v12.68's OpenBSD start) - used to skip the
# planner and every build after it, so not even the assets that COULD be
# filled were. recover stays red and names the jobs; the rest goes on.
if: ${{ !cancelled() }}
timeout-minutes: 15
name: what the release is missing
runs-on: ubuntu-latest
@@ -106,7 +117,9 @@ jobs:
manual: ${{ steps.plan.outputs.manual }}
total: ${{ steps.plan.outputs.total }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Check dependency risk indicators before release work
shell: bash
@@ -119,10 +132,11 @@ jobs:
- id: plan
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
V='${{ inputs.version }}'
V="${INPUT_VERSION}"
V="${V#v}"
if [ -z "$V" ]; then
V="$(gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" --json tagName --jq .tagName)"
@@ -189,16 +203,21 @@ jobs:
- name: Say plainly what this run will not do
if: ${{ steps.plan.outputs.manual != '' }}
env:
STEP_PLAN_VERSION: ${{ steps.plan.outputs.version }}
STEP_PLAN_MANUAL: ${{ steps.plan.outputs.manual }}
run: |
echo "::error::v${{ steps.plan.outputs.version }} still lacks ${{ steps.plan.outputs.manual }} after recovery. Inspect the matching full-release run; older unnamed runs need their failed jobs retried manually."
echo "::error::v${STEP_PLAN_VERSION} still lacks ${STEP_PLAN_MANUAL} after recovery. Inspect the matching full-release run; older unnamed runs need their failed jobs retried manually."
exit 1
# The six architectures nobody else builds for, repacked from the PUBLISHED
# amd64 bundle - which is what makes this possible without a Meteor build.
extra-arches:
permissions:
contents: write
name: ${{ matrix.arch }}
needs: plan
if: ${{ needs.plan.outputs.extra != '[]' }}
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.extra != '[]' }}
runs-on: ubuntu-24.04
timeout-minutes: 300
strategy:
@@ -227,15 +246,20 @@ jobs:
steps:
- name: Skip the architectures this release already has
id: want
env:
NEEDS_PLAN_EXTRA: ${{ needs.plan.outputs.extra }}
MATRIX_ARCH: ${{ matrix.arch }}
run: |
if printf '%s' '${{ needs.plan.outputs.extra }}' | jq -e --arg a '${{ matrix.arch }}' 'index($a)' >/dev/null; then
if printf '%s' "${NEEDS_PLAN_EXTRA}" | jq -e --arg a "${MATRIX_ARCH}" 'index($a)' >/dev/null; then
echo "build=yes" >> "$GITHUB_OUTPUT"
else
echo "build=no" >> "$GITHUB_OUTPUT"
echo "v${VERSION} already has ${{ matrix.arch }}; nothing to do."
echo "v${VERSION} already has ${MATRIX_ARCH}; nothing to do."
fi
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Audit telemetry source before release work
shell: bash
@@ -248,7 +272,7 @@ jobs:
- name: Set up QEMU for ${{ matrix.arch }} emulation
if: ${{ steps.want.outputs.build == 'yes' }}
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
# Before the download, because actions/checkout cleans the workspace. if: ${{ steps.want.outputs.build == 'yes' }}
@@ -301,9 +325,10 @@ jobs:
if: ${{ always() && steps.want.outputs.build == 'yes' && steps.repack.outcome == 'success' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MATRIX_ARCH: ${{ matrix.arch }}
run: |
set -euo pipefail
asset="wekan-${VERSION}-${{ matrix.arch }}.zip"
asset="wekan-${VERSION}-${MATRIX_ARCH}.zip"
# Retried, bounded, and verified by name AND size from the release
# side - an upload that reports success and leaves no asset behind is
# the failure nobody notices until a download 404s.
@@ -312,7 +337,7 @@ jobs:
bun:
name: Missing Bun runtime bundles
needs: plan
if: ${{ needs.plan.outputs.bun != '[]' }}
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.bun != '[]' }}
uses: ./.github/workflows/bun.yml
permissions:
contents: write
@@ -326,7 +351,7 @@ jobs:
appimage:
name: AppImages
needs: plan
if: ${{ needs.plan.outputs.appimage != '[]' }}
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.appimage != '[]' }}
uses: ./.github/workflows/AppImage.yml
permissions:
contents: write
@@ -338,7 +363,7 @@ jobs:
mac:
name: Mac apps
needs: plan
if: ${{ needs.plan.outputs.mac != '[]' }}
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.mac != '[]' }}
uses: ./.github/workflows/mac.yml
permissions:
contents: write
@@ -350,7 +375,7 @@ jobs:
flatpak:
name: Flatpaks
needs: plan
if: ${{ needs.plan.outputs.flatpak != '[]' }}
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.flatpak != '[]' }}
uses: ./.github/workflows/Flatpak.yml
permissions:
contents: write
@@ -362,7 +387,7 @@ jobs:
windows-exe:
name: Windows single EXE
needs: plan
if: ${{ needs.plan.outputs.windows != '[]' }}
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.windows != '[]' }}
uses: ./.github/workflows/windows.yml
permissions:
contents: write
@@ -406,11 +431,11 @@ jobs:
timeout-minutes: 30
name: Helm chart
needs: plan
if: ${{ !cancelled() && needs.plan.outputs.version != '' }}
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.version != '' }}
runs-on: ubuntu-latest
steps:
- name: Checkout wekan (for releases/release-charts.sh)
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: wekan
@@ -452,7 +477,7 @@ jobs:
- name: Checkout wekan/charts (main + gh-pages)
if: steps.check.outputs.skip != 'true'
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: wekan/charts
token: ${{ secrets.WEKAN_REPO_TOKEN }}
@@ -461,12 +486,13 @@ jobs:
- name: Install Helm
if: steps.check.outputs.skip != 'true'
uses: azure/setup-helm@v5
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1
- name: Publish the chart for this release
if: steps.check.outputs.skip != 'true'
env:
CHARTS_DIR: ${{ github.workspace }}/charts
NEEDS_PLAN_VERSION: ${{ needs.plan.outputs.version }}
run: |
set -euo pipefail
cd charts
@@ -476,16 +502,18 @@ jobs:
git branch --track gh-pages origin/gh-pages 2>/dev/null || true
git checkout main
cd ..
./wekan/releases/release-charts.sh "${{ needs.plan.outputs.version }}"
./wekan/releases/release-charts.sh "${NEEDS_PLAN_VERSION}"
- name: Job result
if: always()
env:
JOB_STATUS: ${{ job.status }}
run: |
case "${{ job.status }}" in
case "${JOB_STATUS}" in
success) mark='OK ' ;;
failure) mark='FAILED ' ;;
cancelled) mark='CANCELLED' ;;
*) mark='${{ job.status }}' ;;
*) mark="${JOB_STATUS}" ;;
esac
echo "$mark charts" >> "$GITHUB_STEP_SUMMARY"
@@ -501,7 +529,9 @@ jobs:
if: ${{ always() && needs.plan.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Audit telemetry source before release work
shell: bash
run: python3 ./releases/check-telemetry.py --source .
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -21,9 +21,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Configure Git
run: |
@@ -31,6 +32,6 @@ jobs:
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.7.0
uses: helm/chart-releaser-action@df287104677278f869ca5480bd260358397cc3f9 # v1.8.0
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+10 -10
View File
@@ -38,19 +38,16 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Use release npm version
run: bash releases/use-release-npm.sh
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
- name: Install system dependencies
run: |
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" p7zip-full wget curl libatomic1 build-essential g++ make python3 unzip xz-utils git
@@ -58,12 +55,12 @@ jobs:
- name: Install Sandstorm (dev, unattended)
# Provides the `spk` command used to publish, and the sandstorm-http-bridge that
# build-deps.sh copies into the meteor-spk deps.
run: curl https://install.sandstorm.io | sudo bash -s -- -d
run: curl https://install.sandstorm.org | sudo bash -s -- -d
- name: Assemble modern meteor-spk.deps (same as sandstorm.yml)
run: |
bash sandstorm-src/build-deps.sh
echo "$HOME/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
echo "$GITHUB_WORKSPACE/.tools/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
- name: Restore the Sandstorm signing keyring
env:
@@ -143,4 +140,7 @@ jobs:
fi
echo "Publishing $SPK with $SPK_BIN ..."
# Same command as releases/release-sandstorm.sh: spk publish wekan-<VERSION>.spk
"$SPK_BIN" publish "$SPK"
# Publish to apps.sandstorm.org
"$SPK_BIN" publish --webkey=https://api-b72c46193b266fd6c33e1299dccc9bfa.oasis.sandstorm.org#iCDu7VYl4qZxVbDLXLIE2XSU0bEadaBgApEMqgCkLmP "$SPK"
# Publish to apps.sandstorm.io
"$SPK_BIN" publish --webkey=https://alpha-api.sandstorm.io/#Rs-0TT13YrNSbv7Fiz5K9bBkLaJn3E5TB0PU1GSn1HE "$SPK"
+29 -21
View File
@@ -5,6 +5,11 @@ name: Build Sandstorm .spk
# Release as an attachment, so you can download it, test whether the Sandstorm
# build works, and see any errors — without running a full release.
#
# It builds only what a grain runs (docs/Platforms/FOSS/Container/Sandstorm/
# Smaller-wekan-spk.md): WeKan with the Sandstorm package profile - Sandstorm
# login only, no password/OAuth2/OIDC/CAS/LDAP/SAML packages - FerretDB from
# source with only its SQLite handler, and the deps meteor-spk.deps needs.
#
# gh workflow run sandstorm.yml
#
# EXPERIMENTAL in CI (same caveats as the build-sandstorm job in release-all.yml):
@@ -32,28 +37,19 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 120
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Build the dispatched ref so a fix branch can be tested before it is
# merged to main; defaults to main for a plain `gh workflow run`.
ref: ${{ github.event.inputs.ref || 'main' }}
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Use release npm version
run: bash releases/use-release-npm.sh
- name: Enable unprivileged user namespaces (Ubuntu 24.04 restricts them)
# Ubuntu 24.04's AppArmor defaults kernel.apparmor_restrict_unprivileged_userns=1,
# which blocks the unprivileged user namespaces that the Sandstorm install and
# the spk supervisor rely on (the "fragile part" noted above). Relax it so
# `install.sandstorm.io` and meteor-spk can create sandboxes on the runner.
# `|| true`: on kernels/runners without these knobs, setting them is a no-op.
run: |
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
sudo sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
- name: Install system dependencies and Meteor
run: |
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" p7zip-full wget curl libatomic1 build-essential g++ make python3 unzip xz-utils git qemu-user-static
@@ -64,17 +60,24 @@ jobs:
# Installed BEFORE assembling the deps: build-deps.sh's [bridge] step copies
# the modern /opt/sandstorm/latest/bin/sandstorm-http-bridge over the ancient
# bundled one, so the Sandstorm install must be in place first.
run: curl https://install.sandstorm.io | sudo bash -s -- -d
run: curl https://install.sandstorm.org | sudo bash -s -- -d
- name: Assemble modern meteor-spk.deps (Node 26 + FerretDB + Mongo 3.x CLIs)
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.io (has mongod 3.0 +
- name: Assemble modern meteor-spk.deps (Node 26 + SQLite-only FerretDB + Mongo 3.x CLIs)
# Base = upstream meteor-spk 0.6.0 from dl.sandstorm.org (has mongod 3.0 +
# niscud). build-deps.sh swaps in Node 26, adds FerretDB + the Mongo 3.x
# CLIs + the migration launcher/importer, keeps niscud, and swaps in the
# modern sandstorm-http-bridge from the Sandstorm install above. No dependency
# on the retired releases.wekan.team or the old projects.7z.
# FerretDB is the PREBUILT SQLite-only ferretdb-sqlite-amd64 from the newest
# wekan/FerretDB release that has it (the full ferretdb-amd64 of the newest
# release until one does), picked by releases/ferretdb-asset.sh - nothing
# is compiled here (docs/Platforms/FOSS/Container/Sandstorm/Smaller-wekan-spk.md).
env:
FERRETDB_SOURCE: download
GH_TOKEN: ${{ github.token }}
run: |
bash sandstorm-src/build-deps.sh
echo "$HOME/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
echo "$GITHUB_WORKSPACE/.tools/projects/meteor-spk/meteor-spk-0.6.0" >> "$GITHUB_PATH"
- name: Restore the Sandstorm signing keyring
env:
@@ -90,7 +93,7 @@ jobs:
chmod 600 "$HOME/.sandstorm-keyring"
echo "Restored ~/.sandstorm-keyring ($(wc -c < "$HOME/.sandstorm-keyring") bytes)."
else
echo "::error::SANDSTORM_KEYRING secret is not set. meteor-spk pack signs the .spk with the WeKan Sandstorm app's private key, which lives in this keyring, so packing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
echo "::error::SANDSTORM_KEYRING secret is not set. spk pack signs the .spk with the WeKan Sandstorm app's private key, which lives in this keyring, so packing cannot proceed without it. Add the base64 of your ~/.sandstorm-keyring file as the SANDSTORM_KEYRING repository secret (e.g. 'base64 -w0 ~/.sandstorm-keyring')."
exit 1
fi
@@ -112,7 +115,11 @@ jobs:
set -euo pipefail
STAMP="$(date +'%Y_%m_%d-%H_%M_%S')"
SPK="wekan-sandstorm-${STAMP}.spk"
meteor-spk pack "$SPK"
# The Sandstorm package profile, the linux/x64 sockjs trim and a
# `spk pack` of the trimmed trees (not `meteor-spk pack`, which would
# rebuild the bundle and undo the trim). The script restores
# .meteor/packages and .meteor/versions when it ends.
bash releases/sandstorm-slim-spk.sh "$SPK"
# Report the packed size and warn if it exceeds Cloudflare's 100 MB upload
# cap (a proxied browser upload to a Cloudflare-fronted Sandstorm 413s above
# this; install via a DNS-only host / SSH tunnel, or trim further).
@@ -124,7 +131,7 @@ jobs:
ls -la "$SPK"
echo "spk=$SPK" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ steps.build.outputs.spk }}
path: ${{ steps.build.outputs.spk }}
@@ -134,6 +141,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
STEP_BUILD_SPK: ${{ steps.build.outputs.spk }}
run: |
set -euo pipefail
TAG="$(gh release list --limit 1 --json tagName --jq '.[0].tagName' 2>/dev/null || true)"
@@ -141,5 +149,5 @@ jobs:
echo "::error::No existing GitHub Release found to attach the .spk to."
exit 1
fi
echo "Uploading ${{ steps.build.outputs.spk }} to the newest release: $TAG"
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "$TAG" "${{ steps.build.outputs.spk }}" --clobber
echo "Uploading ${STEP_BUILD_SPK} to the newest release: $TAG"
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "$TAG" "${STEP_BUILD_SPK}" --clobber
+86 -15
View File
@@ -62,7 +62,7 @@ jobs:
matrix: ${{ steps.plan.outputs.matrix }}
any: ${{ steps.plan.outputs.any }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- id: plan
@@ -110,7 +110,7 @@ jobs:
TAG: ${{ needs.plan.outputs.tag }}
TARGET: ${{ matrix.target }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ needs.plan.outputs.tag }}
@@ -144,28 +144,91 @@ jobs:
# The SEA blob format belongs to one Node.js version: the builder runs
# on the same version the bundle embeds (build.mjs checks it too).
- uses: actions/setup-node@v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
if: steps.fetch.outputs.skip != 'true'
with:
node-version: ${{ steps.fetch.outputs.node_version }}
# A 32-bit target's SEA blob must be written by a 32-bit Node.js: the blob
# stores its lengths as size_t (build.mjs refuses otherwise). The i386
# bundle's Node.js, of the same version, writes it for every 32-bit
# target - win32, i386 and 32-bit ARM read the same little-endian format.
- name: A 32-bit Node.js to build a 32-bit target
id: host32
if: steps.fetch.outputs.skip != 'true' && contains(fromJSON('["win32","i386","armv7","armhf","armv6"]'), matrix.target)
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.fetch.outputs.version }}
run: |
set -euo pipefail
zip="wekan-${VERSION}-i386.zip"
mkdir -p host32
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "$zip" --pattern "$zip.sha256sum" --dir host32 \
|| { echo "::error::$TARGET needs the i386 bundle's Node.js to build, and $TAG has no $zip."; exit 1; }
(cd host32 && sha256sum -c "$zip.sha256sum" && unzip -q "$zip" 'bundle/node' 'bundle/node-runtime/*' && rm -f "$zip")
sudo dpkg --add-architecture i386
sudo apt-get update -qq
sudo apt-get install -y -qq libc6:i386 libstdc++6:i386 >/dev/null
host="$PWD/host32/bundle/node"
[ -d host32/bundle/node-runtime ] && echo "libs=$PWD/host32/bundle/node-runtime" >> "$GITHUB_OUTPUT"
echo "node=$host" >> "$GITHUB_OUTPUT"
# A 32-bit ELF target's blob is injected in a 64-bit process, by
# LIEF 1.0.0 - the version Node.js vendors - not inside this 32-bit
# Node.js, which ran out of memory doing it (build.mjs, seaSplit).
if [ "$TARGET" != win32 ]; then
python3 -m venv "$RUNNER_TEMP/lief"
"$RUNNER_TEMP/lief/bin/python" -m pip install --quiet 'lief==1.0.0'
echo "python=$RUNNER_TEMP/lief/bin/python" >> "$GITHUB_OUTPUT"
fi
- name: Build the single file
id: build
if: steps.fetch.outputs.skip != 'true'
env:
VERSION: ${{ steps.fetch.outputs.version }}
HOST_NODE: ${{ steps.host32.outputs.node }}
HOST_LIBS: ${{ steps.host32.outputs.libs }}
SEA_PYTHON: ${{ steps.host32.outputs.python }}
run: |
set -euo pipefail
case "$TARGET" in win*) ext=.exe ;; *) ext= ;; esac
out="wekan-${VERSION}-${TARGET}-single-file${ext}"
node releases/single-file/build.mjs --bundle bundle --target "$TARGET" --version "$VERSION" --out "$out"
ls -la "$out" "$out.sha256sum"
# The i386 Node.js finds its own libraries; the libraries are ignored by
# the x64 Node.js that runs build.mjs (another architecture).
# Exit 3: this Node.js is one LIEF cannot inject the SEA into
# correctly (releases/single-file/sea-inject.mjs says which and why).
# That is a missing single file, not a failed release.
rc=0
LD_LIBRARY_PATH="${HOST_LIBS:-${LD_LIBRARY_PATH:-}}" \
node releases/single-file/build.mjs --bundle bundle --target "$TARGET" --version "$VERSION" --out "$out" \
${HOST_NODE:+--host-node "$HOST_NODE"} 2> build.err || rc=$?
cat build.err >&2
rm -rf bundle
if [ "$rc" = 3 ]; then
reason="$(sed -n 's/^single-file build: //p' build.err | head -n1)"
echo "::notice::$reason"
printf '%s\n' "$reason" > "single-file-skipped-$TARGET.txt"
rm -f build.err; exit 0
fi
rm -f build.err
[ "$rc" = 0 ] || exit "$rc"
ls -la "$out" "$out.sha256sum"
- uses: actions/upload-artifact@v7
if: steps.fetch.outputs.skip != 'true'
# A target that was not built still leaves an artifact - one line saying
# why - so its Start job finds something to read instead of logging an
# "Artifact not found" error for a release that is fine.
- name: Say why there is no single file
if: steps.fetch.outputs.skip == 'true'
run: |
[ -f "single-file-skipped-$TARGET.txt" ] || echo "$TARGET: this release has no bundle to build it from." > "single-file-skipped-$TARGET.txt"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: single-file-${{ matrix.target }}
path: wekan-*-${{ matrix.target }}-single-file*
path: |
wekan-*-${{ matrix.target }}-single-file*
single-file-skipped-${{ matrix.target }}.txt
if-no-files-found: error
retention-days: 3
compression-level: 0
@@ -186,7 +249,7 @@ jobs:
steps:
- name: Get the single file
id: get
uses: actions/download-artifact@v7
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
continue-on-error: true
with:
name: single-file-${{ matrix.target }}
@@ -194,12 +257,18 @@ jobs:
- name: Is there one to start here?
id: what
env:
STEP_GET_OUTCOME: ${{ steps.get.outcome }}
MATRIX_SMOKE: ${{ matrix.smoke }}
run: |
set -euo pipefail
if [ "${{ steps.get.outcome }}" != success ]; then
if [ "${STEP_GET_OUTCOME}" != success ]; then
echo "run=false" >> "$GITHUB_OUTPUT"; echo "::notice::$TARGET was not built this release."; exit 0
fi
if [ "${{ matrix.smoke }}" = none ]; then
if [ -f "sf/single-file-skipped-$TARGET.txt" ]; then
echo "run=false" >> "$GITHUB_OUTPUT"; echo "::notice::$(cat "sf/single-file-skipped-$TARGET.txt")"; exit 0
fi
if [ "${MATRIX_SMOKE}" = none ]; then
echo "run=false" >> "$GITHUB_OUTPUT"
echo "::notice::GitHub has no runner for $TARGET: the single file is published without being started (its bundle was tested where it was built)."
mkdir -p ok && echo "$TARGET untested" > "ok/$TARGET"; exit 0
@@ -236,7 +305,7 @@ jobs:
ls wekan-files wekan-files/bin
echo "WeKan answered HTTP 200 from the $TARGET single file."
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
if: steps.what.outputs.run == 'true' && matrix.target == 'amd64'
with:
persist-credentials: false
@@ -271,7 +340,7 @@ jobs:
[ -f wekan-files/logs/wekan.log ] && tail -n 60 wekan-files/logs/wekan.log || true
mkdir -p ok && echo "$TARGET started" > "ok/$TARGET"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: success() && (steps.what.outputs.run == 'true' || matrix.smoke == 'none')
with:
name: single-file-ok-${{ matrix.target }}
@@ -289,10 +358,10 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
pattern: single-file-*
path: artifacts
@@ -307,6 +376,8 @@ jobs:
for dir in artifacts/single-file-*; do
target="${dir#artifacts/single-file-}"
case "$target" in ok-*) continue ;; esac
# Not built, and its build job said why in a notice already.
[ -f "$dir/single-file-skipped-$target.txt" ] && continue
if [ ! -d "artifacts/single-file-ok-$target" ]; then
echo "::warning::$target: not published - it did not start on its runner."
continue
+6 -3
View File
@@ -41,7 +41,9 @@ jobs:
echo 'enabled=true' >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
if: steps.config.outputs.enabled == 'true'
- name: Resolve published WeKan and FerretDB versions
@@ -88,11 +90,12 @@ jobs:
- name: Checkout the app definition
if: steps.config.outputs.enabled == 'true' && steps.portal.outputs.skip != 'true'
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: wekan/univention
ref: master
path: univention
persist-credentials: false
- name: Prepare versioned compose and migration hook
id: prepare
@@ -112,7 +115,7 @@ jobs:
- name: Keep the exact submitted app definition
if: steps.upload.outputs.result == 'uploaded'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ucs-${{ env.VERSION }}
path: .tools/ucs-release/component
+12 -5
View File
@@ -38,14 +38,14 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout newest WeKan content
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: wekan/wekan
ref: ${{ inputs.source_ref }}
path: source
- name: Checkout wekan/${{ matrix.repository }}
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: wekan/${{ matrix.repository }}
token: ${{ secrets.WEKAN_REPO_TOKEN }}
@@ -55,20 +55,27 @@ jobs:
# history of these repositories is ~160 MB each and is not read.
- name: Copy WeKan and restore ${{ matrix.package }} package identities
env:
MATRIX_PACKAGE: ${{ matrix.package }}
MATRIX_TITLE: ${{ matrix.title }}
run: |
source/releases/prepare-variant-repo.sh \
source target "${{ matrix.package }}" "${{ matrix.title }}"
source target "${MATRIX_PACKAGE}" "${MATRIX_TITLE}"
- name: Commit and push wekan/${{ matrix.repository }}
working-directory: target
env:
MATRIX_REPOSITORY: ${{ matrix.repository }}
INPUT_SOURCE_REF: ${{ inputs.source_ref }}
MATRIX_PACKAGE: ${{ matrix.package }}
run: |
set -euo pipefail
git config user.name 'wekan-release-bot'
git config user.email 'release@wekan.team'
git add -A
if git diff --cached --quiet; then
echo "wekan/${{ matrix.repository }} already matches ${{ inputs.source_ref }}."
echo "wekan/${MATRIX_REPOSITORY} already matches ${INPUT_SOURCE_REF}."
exit 0
fi
git commit -m "Sync newest WeKan as ${{ matrix.package }}"
git commit -m "Sync newest WeKan as ${MATRIX_PACKAGE}"
git push origin HEAD:main
+41 -21
View File
@@ -46,8 +46,9 @@ on:
default: true
type: boolean
# Read-only by default; the job that writes is granted it below.
permissions:
contents: write
contents: read
concurrency:
group: wekan-windows-exe-${{ inputs.tag || github.ref }}
@@ -63,15 +64,19 @@ jobs:
version: ${{ steps.find.outputs.version }}
available: ${{ steps.find.outputs.available }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- id: find
env:
GH_TOKEN: ${{ github.token }}
INPUT_TAG: ${{ inputs.tag }}
GITHUB_CTX_REPOSITORY: ${{ github.repository }}
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
tag="${INPUT_TAG}"
[ -n "$tag" ] || tag="$(gh release list --repo "${GITHUB_CTX_REPOSITORY}" \
--limit 1 --json tagName --jq '.[0].tagName')"
[ -n "$tag" ] || { echo "::error::No release to wrap."; exit 1; }
version="${tag#v}"
@@ -80,7 +85,7 @@ jobs:
# at the first match while gh is still writing; gh gets SIGPIPE, pipefail
# makes the pipeline fail, and a PUBLISHED asset was reported missing - the
# v12.39 Mac apps were skipped that way, with the bundles already attached.
assets="$(gh release view "$tag" --repo "${{ github.repository }}" --json assets \
assets="$(gh release view "$tag" --repo "${GITHUB_CTX_REPOSITORY}" --json assets \
--jq '.assets[].name')"
if grep -qx "wekan-${version}-win64.zip" <<< "$assets"; then
available=true
@@ -92,28 +97,35 @@ jobs:
echo "available=$available" >> "$GITHUB_OUTPUT"
build:
permissions:
contents: write
name: self-extracting win64 EXE
needs: release
if: ${{ needs.release.outputs.available == 'true' }}
runs-on: windows-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Fetch and verify the published win64 bundle
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
NEEDS_RELEASE_VERSION: ${{ needs.release.outputs.version }}
GITHUB_CTX_REPOSITORY: ${{ github.repository }}
NEEDS_RELEASE_TAG: ${{ needs.release.outputs.tag }}
run: |
$ErrorActionPreference = 'Stop'
$zip = 'wekan-${{ needs.release.outputs.version }}-win64.zip'
$assets = gh release view --repo '${{ github.repository }}' `
'${{ needs.release.outputs.tag }}' --json assets --jq '.assets[].name'
$zip = "wekan-$($env:NEEDS_RELEASE_VERSION)-win64.zip"
$assets = gh release view --repo $env:GITHUB_CTX_REPOSITORY `
$env:NEEDS_RELEASE_TAG --json assets --jq '.assets[].name'
if ($assets -notcontains $zip) {
throw "$zip is not on ${{ needs.release.outputs.tag }}. The single EXE can only wrap a published win64 bundle."
throw "$zip is not on $($env:NEEDS_RELEASE_TAG). The single EXE can only wrap a published win64 bundle."
}
gh release download '${{ needs.release.outputs.tag }}' `
--repo '${{ github.repository }}' --pattern "$zip*" --clobber
gh release download $env:NEEDS_RELEASE_TAG `
--repo $env:GITHUB_CTX_REPOSITORY --pattern "$zip*" --clobber
# The ZIP is the payload, so it is checked as a whole against the
# checksum published beside it rather than unpacked here.
if (Test-Path "$zip.sha256sum" -PathType Leaf) {
@@ -145,7 +157,7 @@ jobs:
python releases/check-telemetry.py --archive $zip
if ($LASTEXITCODE -ne 0) { throw 'Telemetry audit rejected the Windows bundle.' }
- uses: actions/setup-node@v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 26
- name: Use release npm version
@@ -156,13 +168,15 @@ jobs:
# The launcher unpacks these and nothing else. The list is computed from
# the archive, not written by hand, so a bundle that gains an addon or a
# database tool does not silently lose it at runtime.
env:
NEEDS_RELEASE_VERSION: ${{ needs.release.outputs.version }}
run: |
$ErrorActionPreference = 'Stop'
node releases/append-windows-payload.mjs `
--manifest "wekan-${{ needs.release.outputs.version }}-win64.zip" `
--manifest "wekan-$($env:NEEDS_RELEASE_VERSION)-win64.zip" `
--header wekan-real-files.h
- uses: ilammy/msvc-dev-cmd@v1
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0
- name: Compile the native launcher
shell: cmd
@@ -174,9 +188,11 @@ jobs:
- name: Append the bundle and its trailer to the launcher
shell: pwsh
env:
NEEDS_RELEASE_VERSION: ${{ needs.release.outputs.version }}
run: |
$ErrorActionPreference = 'Stop'
$version = '${{ needs.release.outputs.version }}'
$version = $env:NEEDS_RELEASE_VERSION
New-Item -ItemType Directory -Force dist | Out-Null
node releases/append-windows-payload.mjs `
--launcher wekan-launcher.exe `
@@ -240,9 +256,11 @@ jobs:
# loop still answers on port 8080 eventually and a poll-until-ready
# check calls it healthy. This one fails on the restart line and on
# a SyntaxError, so a bundle Node.js cannot load can no longer ship.
env:
NEEDS_RELEASE_VERSION: ${{ needs.release.outputs.version }}
run: |
$ErrorActionPreference = 'Stop'
$version = '${{ needs.release.outputs.version }}'
$version = $env:NEEDS_RELEASE_VERSION
# Run from its own directory: the EXE unpacks ~700 MB beside itself
# and dist/ is what gets uploaded as the release artifact.
New-Item -ItemType Directory -Force smoke | Out-Null
@@ -324,7 +342,7 @@ jobs:
Start-Once 'second' 120
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: windows-single-exe-win64
path: dist/
@@ -341,6 +359,8 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GITHUB_CTX_REPOSITORY: ${{ github.repository }}
NEEDS_RELEASE_TAG: ${{ needs.release.outputs.tag }}
run: |
set -euo pipefail
shopt -s nullglob
@@ -350,6 +370,6 @@ jobs:
exit 1
fi
# Retried, bounded, and verified by name and size from the release side.
bash releases/github-release-upload.sh '${{ github.repository }}' '${{ needs.release.outputs.tag }}' "${files[@]}"
gh release view --repo '${{ github.repository }}' \
'${{ needs.release.outputs.tag }}' --json url --jq .url
bash releases/github-release-upload.sh "${GITHUB_CTX_REPOSITORY}" "${NEEDS_RELEASE_TAG}" "${files[@]}"
gh release view --repo "${GITHUB_CTX_REPOSITORY}" \
"${NEEDS_RELEASE_TAG}" --json url --jq .url
+27 -27
View File
@@ -6,12 +6,12 @@
meteor-base@1.5.2
# Build system
ecmascript@0.19.2-rc360.0
standard-minifier-js@3.3.0-rc360.0
rspack@1.4.0-rc360.0
ecmascript@0.19.2-rc360.1
standard-minifier-js@3.3.0-rc360.1
rspack@1.4.0-rc360.1
# Declared explicitly because server/modernBrowsers.js imports it, to say that
# Yandex Browser is modern and must not be served the legacy bundle (#6557).
modern-browsers@0.2.4-rc360.0
modern-browsers@0.2.4-rc360.1
blaze@3.0.2
@@ -19,11 +19,11 @@ blaze@3.0.2
aldeed:collection2
aldeed:schema-index@4.0.0-beta.5
reywood:publish-composite
mongo@2.5.2-rc360.0
mongo@2.5.2-rc360.1
# Account system
accounts-password@3.3.2-rc360.0
accounts-2fa@3.1.1-rc360.0
accounts-password@3.3.2-rc360.1
accounts-2fa@3.1.1-rc360.1
accounts-express@1.0.0
communitypackages:core
communitypackages:unstyled
@@ -39,19 +39,19 @@ wekan-oidc
wekan-accounts-oidc
# Utilities
check@1.5.1-rc360.0
random@1.2.3-rc360.0
reactive-dict@1.3.3-rc360.0
session@1.2.4-rc360.0
tracker@1.3.5-rc360.0
check@1.5.1-rc360.1
random@1.2.3-rc360.1
reactive-dict@1.3.3-rc360.1
session@1.2.4-rc360.1
tracker@1.3.5-rc360.1
audit-argument-checks@1.0.8
# UI components
ostrio:i18n
reactive-var@1.0.13
shell-server@0.7.1-rc360.0
email@3.2.1-rc360.0
dynamic-import@0.7.5-rc360.0
shell-server@0.7.2-rc360.1
email@3.2.1-rc360.1
dynamic-import@0.8.1-rc360.1
wekan-markdown
quave:synced-cron
ostrio:cookies
@@ -59,21 +59,21 @@ ostrio:files
# lmieulet:meteor-coverage
meteortesting:mocha@3.0.0
matb33:collection-hooks
service-configuration@1.3.6-rc360.0
service-configuration@1.3.6-rc360.1
minifier-css@2.0.2
ejson@1.2.1-rc360.0
logging@1.3.7-rc360.0
ejson@1.4.1-rc360.1
logging@1.3.7-rc360.1
wekan-fullcalendar
wekan-fontawesome
accounts-google@1.4.2-rc360.0
accounts-github@1.5.2-rc360.0
accounts-facebook@1.3.5-rc360.0
accounts-twitter@1.5.3-rc360.0
accounts-meteor-developer@1.5.2-rc360.0
accounts-weibo@1.4.2-rc360.0
accounts-meetup@1.5.2-rc360.0
accounts-passwordless@3.1.2-rc360.0
instrumentation@0.0.1-rc360.0
accounts-google@1.4.2-rc360.1
accounts-github@1.5.2-rc360.1
accounts-facebook@1.3.5-rc360.1
accounts-twitter@1.5.3-rc360.1
accounts-meteor-developer@1.5.2-rc360.1
accounts-weibo@1.4.2-rc360.1
accounts-meetup@1.5.2-rc360.1
accounts-passwordless@3.1.2-rc360.1
instrumentation@0.0.1-rc360.1
# Prevent Meteor build/run tools from sending application package statistics.
package-stats-opt-out@1.0.8
+1 -1
View File
@@ -1 +1 @@
METEOR@3.6-rc.0
METEOR@3.6-rc.1
+74 -74
View File
@@ -1,130 +1,130 @@
accounts-2fa@3.1.1-rc360.0
accounts-base@3.4.0-rc360.0
accounts-2fa@3.1.1-rc360.1
accounts-base@3.4.0-rc360.1
accounts-express@1.0.0
accounts-facebook@1.3.5-rc360.0
accounts-github@1.5.2-rc360.0
accounts-google@1.4.2-rc360.0
accounts-meetup@1.5.2-rc360.0
accounts-meteor-developer@1.5.2-rc360.0
accounts-oauth@1.4.8-rc360.0
accounts-password@3.3.2-rc360.0
accounts-passwordless@3.1.2-rc360.0
accounts-twitter@1.5.3-rc360.0
accounts-weibo@1.4.2-rc360.0
accounts-facebook@1.3.5-rc360.1
accounts-github@1.5.2-rc360.1
accounts-google@1.4.2-rc360.1
accounts-meetup@1.5.2-rc360.1
accounts-meteor-developer@1.5.2-rc360.1
accounts-oauth@1.4.8-rc360.1
accounts-password@3.3.2-rc360.1
accounts-passwordless@3.1.2-rc360.1
accounts-twitter@1.5.3-rc360.1
accounts-weibo@1.4.2-rc360.1
aldeed:collection2@4.2.2
aldeed:schema-index@4.0.0
aldeed:simple-schema@1.13.1
allow-deny@2.1.1-rc360.0
allow-deny@2.1.2-rc360.1_1
audit-argument-checks@1.0.8
autoupdate@2.0.2-rc360.0
babel-compiler@7.15.2-rc360.0
autoupdate@2.0.2-rc360.1
babel-compiler@7.15.2-rc360.1
babel-runtime@1.5.2
base64@1.0.15-rc360.0
binary-heap@1.0.14-rc360.0
base64@1.0.15-rc360.1
binary-heap@1.0.14-rc360.1
blaze@3.0.3
blaze-tools@2.0.1
boilerplate-generator@2.2.0-rc360.0
boilerplate-generator@2.2.0-rc360.1
caching-compiler@2.0.1
caching-html-compiler@2.0.1
callback-hook@1.8.1-rc360.0
check@1.5.1-rc360.0
callback-hook@1.8.1-rc360.1
check@1.5.1-rc360.1
communitypackages:core@2.0.3
communitypackages:flow-routing@2.1.0
communitypackages:unstyled@2.0.1
core-runtime@1.0.0
ddp@1.4.3-rc360.0
ddp-client@3.4.2-rc360.0
ddp-common@1.4.6-rc360.0
ddp-rate-limiter@1.3.1-rc360.0
ddp-server@3.4.1-rc360.0
diff-sequence@1.1.4-rc360.0
dynamic-import@0.7.5-rc360.0
ecmascript@0.19.2-rc360.0
ddp@1.4.3-rc360.1
ddp-client@3.4.2-rc360.1
ddp-common@1.4.6-rc360.1
ddp-rate-limiter@1.3.1-rc360.1
ddp-server@3.6.0-rc360.1_1
diff-sequence@1.2.1-rc360.1
dynamic-import@0.8.1-rc360.1_1
ecmascript@0.19.2-rc360.1
ecmascript-runtime@0.8.3
ecmascript-runtime-client@0.13.0
ecmascript-runtime-server@0.11.1
ejson@1.2.1-rc360.0
email@3.2.1-rc360.0
ejson@1.4.1-rc360.1
email@3.2.1-rc360.1
es5-shim@4.8.1
facebook-oauth@1.11.7-rc360.0
facts-base@1.0.3-rc360.0
fetch@0.2.1-rc360.0
geojson-utils@1.0.13-rc360.0
github-oauth@1.4.3-rc360.0
google-oauth@1.4.6-rc360.0
facebook-oauth@1.12.1-rc360.1_1
facts-base@1.0.3-rc360.1
fetch@0.2.1-rc360.1
geojson-utils@1.0.13-rc360.1
github-oauth@1.4.3-rc360.1
google-oauth@1.5.1-rc360.1_1
hot-code-push@1.0.5
html-tools@2.0.1
htmljs@2.0.2
id-map@1.2.1-rc360.0
instrumentation@0.0.1-rc360.0
inter-process-messaging@0.1.4-rc360.0
id-map@1.2.1-rc360.1
instrumentation@0.0.1-rc360.1
inter-process-messaging@0.1.4-rc360.1
jquery@3.0.2
lai:collection-extensions@1.0.0
localstorage@1.2.1
logging@1.3.7-rc360.0
logging@1.3.7-rc360.1
matb33:collection-hooks@2.1.0
meetup-oauth@1.1.4-rc360.0
meteor@2.3.2-rc360.0
meetup-oauth@1.1.4-rc360.1
meteor@2.3.2-rc360.1
meteor-base@1.5.2
meteor-developer-oauth@1.3.4-rc360.0
meteor-developer-oauth@1.3.4-rc360.1
meteortesting:browser-tests@1.8.0
meteortesting:mocha@3.4.0
meteortesting:mocha-core@8.2.0
minifier-css@2.0.2
minifier-js@3.3.2-rc360.0
minimongo@2.2.1-rc360.0
modern-browsers@0.2.4-rc360.0
minifier-js@3.3.2-rc360.1
minimongo@2.2.1-rc360.1
modern-browsers@0.2.4-rc360.1
modules@0.20.3
modules-runtime@0.13.2
mongo@2.5.2-rc360.0
mongo@2.5.2-rc360.1
mongo-decimal@0.2.0
mongo-dev-server@1.1.1
mongo-id@1.0.10-rc360.0
npm-mongo@6.16.3
oauth@3.0.5-rc360.0
oauth1@1.5.4-rc360.0
mongo-id@1.0.10-rc360.1
npm-mongo@6.18.1-rc360.1
oauth@3.0.5-rc360.1
oauth1@1.5.4-rc360.1
oauth2@1.3.3
observe-sequence@2.0.1
ordered-dict@1.2.1-rc360.0
ordered-dict@1.2.1-rc360.1
ostrio:cookies@2.9.2
ostrio:cstorage@4.0.1
ostrio:files@3.0.1
ostrio:flow-router-extra@3.15.3
ostrio:i18n@3.2.1
package-stats-opt-out@1.0.8
promise@1.0.1-rc360.0
promise@1.0.1-rc360.1
quave:synced-cron@2.4.0
raix:eventemitter@1.0.0
random@1.2.3-rc360.0
rate-limit@1.2.1-rc360.0
random@1.2.3-rc360.1
rate-limit@1.2.1-rc360.1
react-fast-refresh@0.3.0
reactive-dict@1.3.3-rc360.0
reactive-dict@1.3.3-rc360.1
reactive-var@1.0.13
reload@1.3.3-rc360.0
retry@1.1.2-rc360.0
reload@1.3.3-rc360.1
retry@1.1.2-rc360.1
reywood:publish-composite@1.9.0
routepolicy@1.1.3-rc360.0
rspack@1.4.0-rc360.0
service-configuration@1.3.6-rc360.0
session@1.2.4-rc360.0
sha@1.0.11-rc360.0
shell-server@0.7.1-rc360.0
socket-stream-client@0.7.2-rc360.0
routepolicy@1.1.3-rc360.1
rspack@1.4.0-rc360.1
service-configuration@1.3.6-rc360.1
session@1.2.4-rc360.1
sha@1.0.11-rc360.1
shell-server@0.7.2-rc360.1_1
socket-stream-client@0.7.2-rc360.1
spacebars@2.0.1
spacebars-compiler@2.0.1
standard-minifier-js@3.3.0-rc360.0
standard-minifier-js@3.3.0-rc360.1
templating@1.4.5
templating-compiler@2.0.1
templating-runtime@2.0.2
templating-tools@2.0.1
tools-core@1.4.0-rc360.0
tracker@1.3.5-rc360.0
twitter-oauth@1.3.5-rc360.0
typescript@5.11.2-rc360.0
url@1.3.7-rc360.0
webapp@2.3.1-rc360.0
webapp-hashing@1.1.3-rc360.0
weibo-oauth@1.3.4-rc360.0
tools-core@1.4.0-rc360.1
tracker@1.3.5-rc360.1
twitter-oauth@1.3.5-rc360.1
typescript@5.11.2-rc360.1
url@1.3.7-rc360.1
webapp@2.3.1-rc360.1
webapp-hashing@1.1.3-rc360.1
weibo-oauth@1.3.4-rc360.1
wekan-accounts-cas@0.2.0
wekan-accounts-lockout@1.1.0
wekan-accounts-oidc@1.0.10
+73 -12
View File
@@ -35,7 +35,7 @@ contributor — that offer is the mistake, not the rule.
The maintainer's half is the release flow, not taste. Releases here are frequent —
several a day when a fault is being chased — and `releases/release-all.sh` cuts one
from whatever is on `main`, taking the version from the `# Upcoming WeKan ® release`
section of `CHANGELOG.md`. Work parked on a branch is work that is not in the next
section of `CHANGELOG-big.md`. Work parked on a branch is work that is not in the next
release, and `tests/changelogEntriesBelongToTheirRelease.test.cjs` fails when a
released section links a commit that release does not contain.
@@ -340,7 +340,7 @@ directly after the merge.
the two patch repositories under `.tools/` — node-patches and mongo-tools-patches.
- Directory structure:
- `wekan` — this repo (https://github.com/wekan/wekan); see
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG.md` at root. It is at
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG-big.md` and `CHANGELOG.md` at root. It is at
`~/repos/wekan` on Linux, `~/Documents/repos/wekan` on macOS and
`Downloads\repos\wekan` on Windows. The maintainer commits on `main`; a
contributor works on a branch and opens a pull request — see "Where this
@@ -414,7 +414,7 @@ directly after the merge.
| Repository | File | Format |
| --- | --- | --- |
| `wekan/wekan` | `CHANGELOG.md` | the WeKan format this section describes: `# Status` (Platforms/Version/TODO Later nested as `<details>`), then `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` sections of `<details>` entries whose `<summary>` links the commit |
| `wekan/wekan` | `CHANGELOG-big.md` (and the generated `CHANGELOG.md`, below) | the WeKan format this section describes: `# Status` (Platforms/Version/TODO Later nested as `<details>`), then `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` sections of `<details>` entries whose `<summary>` links the commit |
| `wekan/node-patches` | `CHANGELOG.md` | the same WeKan format, with `# Upcoming node-patches release` |
| `wekan/mongo-tools-patches` | `CHANGELOG.md` | the same WeKan format, with `# Upcoming mongo-tools-patches release` |
| `wekan/FerretDB` | `CHANGELOG.md` | **upstream FerretDB's** format, not WeKan's: `## [v1.48.0](tag URL) (YYYY-MM-DD)` and `### New Features 🎉` / `### Fixed 🐛` / `### Other Changes 🤖` bullets ending `by @xet7. Thanks to xet7.` |
@@ -426,6 +426,52 @@ directly after the merge.
out and would break the tooling that parses them. When this file and the file being
edited disagree, **the file being edited wins** — and everything below in this
section is about the WeKan format specifically.
- **WeKan has TWO changelogs, and only one is written.** `CHANGELOG-big.md` is
the changelog: `# Status`, `# Upcoming WeKan ® release` and every release in
full, in the `<details>` format below - every rule in the rest of this section
is about it, and every entry, fix and release goes into it. `CHANGELOG.md` is
the SHORT changelog, generated from it by `node releases/changelog-short.mjs`
and never edited by hand:
| | `CHANGELOG.md` |
| --- | --- |
| Status, TODO Later | none |
| First | the unreleased `# Upcoming WeKan ® release`, when there is one: its **In short** paragraph only, then `[All changes so far](CHANGELOG-big.md#upcoming-wekan--release)` |
| Below it | the five newest `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` |
| Per release | its **In short** paragraph only, then `[All changes in v…](CHANGELOG-big.md#anchor)` |
| Markup | plain Markdown: no `<details>`, `<summary>`, `</summary>` or `</details>`, and every HTML link a Markdown link |
It exists because the big file is for whoever writes and audits a release,
and most readers - GitHub's front page, the Sandstorm App Market
(`sandstorm-pkgdef.capnp` embeds it), the Helm chart's changelog link - want
what changed lately, at a glance. `releases/release-all.sh` regenerates it
right after naming the new release, so it is in the same release commit, and
`releases/changelog.sh` does the same; `tests/changelogShort.test.cjs` fails
when it is not exactly what the generator writes. So: write
`CHANGELOG-big.md`, run the generator, commit both - and never edit
`CHANGELOG.md` to fix something, because the next run overwrites it.
- **"Update wekan changelog" means both files, in this order:**
1. Find what is not in the changelog yet: `git log` since the newest commit
`CHANGELOG-big.md`'s Upcoming section (or, when it has none, the newest
release's `Prepare v… release` commit) already covers, by anybody - a
merged pull request and the maintainer's own commits alike.
2. Check whether a release was cut since the last update (`release-all.sh`
renames Upcoming to `# v<NEW> …` and opens no new one). If there is no
`# Upcoming WeKan ® release` heading, add it from
[docs/DeveloperDocs/Changelog-Upcoming-Template.md](docs/DeveloperDocs/Changelog-Upcoming-Template.md)
above the newest release - never add an entry to a released section.
3. Write the entries into that Upcoming section of `CHANGELOG-big.md`, in the
format described below (subsections, topic groups, `<details>` entries
linking each commit), and update its **In short** paragraph so it still
names the release's main outcomes.
4. Run `node releases/changelog-short.mjs`: `CHANGELOG.md` then shows that
Upcoming **In short** first and the five newest releases below it, with the
longer details only in `CHANGELOG-big.md`.
5. Run the changelog tests - `tests/changelogShort.test.cjs`,
`tests/changelogFormat.test.cjs`,
`tests/changelogEntriesBelongToTheirRelease.test.cjs` (or
`node tests/run-node-suites.cjs changelog`) - and commit
`CHANGELOG-big.md` and `CHANGELOG.md` together.
- During development, add entries under a new `# Upcoming WeKan ® release` section above
the newest release (FerretDB uses `## Upcoming FerretDB release`; the patch repos use
`# Upcoming <repo> release`). Do **not** hand-edit
@@ -438,10 +484,10 @@ directly after the merge.
add and syntax-check/register the test and state clearly what could not be run.
Do not duplicate an existing test merely to satisfy the audit — extend it when
that keeps one behavior in one suite.
- **CHANGELOG.md stays small enough for GitHub to show it.** GitHub renders
- **CHANGELOG-big.md stays small enough for GitHub to show it.** GitHub renders
Markdown up to 500 KiB; above that it shows only plain source text, and above
about 2 MB nothing at all ("we can't show files that are this big right
now"). CHANGELOG.md reached 2.6 MB over 1,100 releases back to 2015 (#6580),
now"). The changelog reached 2.6 MB over 1,100 releases back to 2015 (#6580),
and keeping only the current month still came to 2.1 MB, because releases
here are frequent and large. So the cut is by SIZE: 450,000 bytes
(`BUDGET` in `releases/changelog-archive.mjs`), under GitHub's limit with
@@ -449,7 +495,7 @@ directly after the merge.
| | |
| --- | --- |
| `CHANGELOG.md` | `# Status` (Platforms/TODO Later inside it), `# Upcoming`, and the newest releases that fit the budget |
| `CHANGELOG-big.md` | `# Status` (Platforms/TODO Later inside it), `# Upcoming`, and the newest releases that fit the budget |
| `old-CHANGELOG/<year>/<MM>.md` | a month's releases, newest first; a month over the budget continues in `<MM>-part2.md`, `<MM>-part3.md`, ... |
| `old-CHANGELOG/<year>.md` | 2015–2025, whole |
@@ -464,11 +510,11 @@ directly after the merge.
still in git (`gitk`, `git-gui`, `git log --follow`), and being small enough
to open is worth more.
Run `node releases/changelog-archive.mjs` whenever CHANGELOG.md has grown -
Run `node releases/changelog-archive.mjs` whenever CHANGELOG-big.md has grown -
after a release is the natural moment. It is idempotent — a run with nothing
to move only refreshes the tables and parts — and it decides from the FILE
rather than the clock, so two people running it agree.
`tests/changelogArchive.test.cjs` fails when CHANGELOG.md is over the budget.
`tests/changelogArchive.test.cjs` fails when CHANGELOG-big.md is over the budget.
An archived section is never edited, for the same reason a released one is
not.
- **The file's shape, top to bottom** — keep it exactly as it is now. There is
@@ -564,7 +610,7 @@ directly after the merge.
`<details>` blocks below carry that information. Keep the paragraph current as
topics change, and shorten it when added commits make it grow. A finished release
keeps the paragraph it was written with.
- **Neither CHANGELOG.md nor release notes may contain a binaries provenance
- **Neither changelog nor the release notes may contain a binaries provenance
section or a Platform/Bundle/Binary/From/Version/Checked/SHA256 table.**
Do not add "Binaries in these bundles" at the top or bottom of either.
Release notes summarize each Translations group as only a list of updated
@@ -577,7 +623,7 @@ directly after the merge.
no security fixes - is left out rather than added empty.
Release notes have only In short, Security, Translations (language list),
the standard thanks line and More details at ChangeLog with the release
anchor link. Other details stay in CHANGELOG.md. Keep download
anchor link into CHANGELOG-big.md. Other details stay in CHANGELOG-big.md. Keep download
verification and `provenance.tsv` build artifacts for diagnostics; do not
append their contents or `releases/provenance-table.sh` output to notes.
- **Inside a subsection, entries are GROUPED BY TOPIC/AREA.** A release touches a handful
@@ -636,7 +682,7 @@ directly after the merge.
Because CRITICAL comes first, it keeps the `This release ` prefix; a following non-security
subsection becomes `and …` per the rule above.
- **TODO Later** — a `<details>` nested inside `# Status` near the TOP of
`CHANGELOG.md` (above the version sections), a triage backlog for open
`CHANGELOG-big.md` (above the version sections), a triage backlog for open
issues that were **investigated but not fixed here**, each
recorded with a concrete REASON so whoever picks it up next knows why. Use it when working
through open issues (the "Fix open issues" process): for each issue, either **fix it** (commit
@@ -717,7 +763,7 @@ have cost a released section its accuracy:
- **Work continues immediately after a release**, so an entry written right
after one has to go somewhere. `release-all.sh` renames `# Upcoming WeKan ®
release` to `# v<NEW> …` and stops there — it does **not** create a new,
empty `# Upcoming` section. CHANGELOG.md never carries an
empty `# Upcoming` section. CHANGELOG-big.md never carries an
`**In short:** nothing here yet.` placeholder sitting between releases; it
used to (via a now-deleted `releases/changelog-open-next.mjs`), and that
meant the file always had a section saying nothing, from the moment a
@@ -869,6 +915,21 @@ have cost a released section its accuracy:
sizes them at 24px, and keeps the icon and its red drop on one line at every width
(they are one icon) — the table itself stays percentage-sized with no fixed or
minimum width, so it fills the page at every browser width.
- **A report that arrives WITHOUT a GHSA is upgraded when its GHSA arrives.** The
stars follow `.tools/wekan.fi/hall-of-fame/Audit-Stars.md`: a GHSA with a
suggested fix is 5, a report sent to security@wekan.fi or any other way, with a
suggested fix, is 3. When that researcher is later able to add a GitHub security
advisory for the same report, the maintainer requests the CVE through the GHSA
process, and the row's 3 stars become 5. So whenever an AI updates or audits the
Hall of Fame, it checks each 3-star row whose Process says the report came
without a GHSA against [WeKan Security
Advisories](https://github.com/wekan/wekan/security/advisories): when a GHSA for
that report now exists, it sets the row to 5 stars on the index AND on the row's
own page, links the GHSA (and the CVE in the CVE cell once one is assigned),
rewrites the Process cell to say the report later arrived as a GHSA, and records
the change in Audit-Stars.md. Match by the report's subject and reporter, not by
a nearby advisory - a GHSA about something else is no upgrade. Until the GHSA
exists, the row stays at 3 and its Process cell says the GHSA is awaited.
- **On the Hall of Fame index page, the Process and Vulnerabilities cells are
collapsed.** Those two columns carry the prose — how the report arrived, and the
whole story of the vulnerability — so the page would be one wall of text and the
+8121
View File
File diff suppressed because it is too large. Load diff
+43 -8277
View File
File diff suppressed because it is too large. Load diff
+73 -12
View File
@@ -35,7 +35,7 @@ contributor — that offer is the mistake, not the rule.
The maintainer's half is the release flow, not taste. Releases here are frequent —
several a day when a fault is being chased — and `releases/release-all.sh` cuts one
from whatever is on `main`, taking the version from the `# Upcoming WeKan ® release`
section of `CHANGELOG.md`. Work parked on a branch is work that is not in the next
section of `CHANGELOG-big.md`. Work parked on a branch is work that is not in the next
release, and `tests/changelogEntriesBelongToTheirRelease.test.cjs` fails when a
released section links a commit that release does not contain.
@@ -340,7 +340,7 @@ directly after the merge.
the two patch repositories under `.tools/` — node-patches and mongo-tools-patches.
- Directory structure:
- `wekan` — this repo (https://github.com/wekan/wekan); see
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG.md` at root. It is at
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG-big.md` and `CHANGELOG.md` at root. It is at
`~/repos/wekan` on Linux, `~/Documents/repos/wekan` on macOS and
`Downloads\repos\wekan` on Windows. The maintainer commits on `main`; a
contributor works on a branch and opens a pull request — see "Where this
@@ -414,7 +414,7 @@ directly after the merge.
| Repository | File | Format |
| --- | --- | --- |
| `wekan/wekan` | `CHANGELOG.md` | the WeKan format this section describes: `# Status` (Platforms/Version/TODO Later nested as `<details>`), then `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` sections of `<details>` entries whose `<summary>` links the commit |
| `wekan/wekan` | `CHANGELOG-big.md` (and the generated `CHANGELOG.md`, below) | the WeKan format this section describes: `# Status` (Platforms/Version/TODO Later nested as `<details>`), then `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` sections of `<details>` entries whose `<summary>` links the commit |
| `wekan/node-patches` | `CHANGELOG.md` | the same WeKan format, with `# Upcoming node-patches release` |
| `wekan/mongo-tools-patches` | `CHANGELOG.md` | the same WeKan format, with `# Upcoming mongo-tools-patches release` |
| `wekan/FerretDB` | `CHANGELOG.md` | **upstream FerretDB's** format, not WeKan's: `## [v1.48.0](tag URL) (YYYY-MM-DD)` and `### New Features 🎉` / `### Fixed 🐛` / `### Other Changes 🤖` bullets ending `by @xet7. Thanks to xet7.` |
@@ -426,6 +426,52 @@ directly after the merge.
out and would break the tooling that parses them. When this file and the file being
edited disagree, **the file being edited wins** — and everything below in this
section is about the WeKan format specifically.
- **WeKan has TWO changelogs, and only one is written.** `CHANGELOG-big.md` is
the changelog: `# Status`, `# Upcoming WeKan ® release` and every release in
full, in the `<details>` format below - every rule in the rest of this section
is about it, and every entry, fix and release goes into it. `CHANGELOG.md` is
the SHORT changelog, generated from it by `node releases/changelog-short.mjs`
and never edited by hand:
| | `CHANGELOG.md` |
| --- | --- |
| Status, TODO Later | none |
| First | the unreleased `# Upcoming WeKan ® release`, when there is one: its **In short** paragraph only, then `[All changes so far](CHANGELOG-big.md#upcoming-wekan--release)` |
| Below it | the five newest `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release` |
| Per release | its **In short** paragraph only, then `[All changes in v…](CHANGELOG-big.md#anchor)` |
| Markup | plain Markdown: no `<details>`, `<summary>`, `</summary>` or `</details>`, and every HTML link a Markdown link |
It exists because the big file is for whoever writes and audits a release,
and most readers - GitHub's front page, the Sandstorm App Market
(`sandstorm-pkgdef.capnp` embeds it), the Helm chart's changelog link - want
what changed lately, at a glance. `releases/release-all.sh` regenerates it
right after naming the new release, so it is in the same release commit, and
`releases/changelog.sh` does the same; `tests/changelogShort.test.cjs` fails
when it is not exactly what the generator writes. So: write
`CHANGELOG-big.md`, run the generator, commit both - and never edit
`CHANGELOG.md` to fix something, because the next run overwrites it.
- **"Update wekan changelog" means both files, in this order:**
1. Find what is not in the changelog yet: `git log` since the newest commit
`CHANGELOG-big.md`'s Upcoming section (or, when it has none, the newest
release's `Prepare v… release` commit) already covers, by anybody - a
merged pull request and the maintainer's own commits alike.
2. Check whether a release was cut since the last update (`release-all.sh`
renames Upcoming to `# v<NEW> …` and opens no new one). If there is no
`# Upcoming WeKan ® release` heading, add it from
[docs/DeveloperDocs/Changelog-Upcoming-Template.md](docs/DeveloperDocs/Changelog-Upcoming-Template.md)
above the newest release - never add an entry to a released section.
3. Write the entries into that Upcoming section of `CHANGELOG-big.md`, in the
format described below (subsections, topic groups, `<details>` entries
linking each commit), and update its **In short** paragraph so it still
names the release's main outcomes.
4. Run `node releases/changelog-short.mjs`: `CHANGELOG.md` then shows that
Upcoming **In short** first and the five newest releases below it, with the
longer details only in `CHANGELOG-big.md`.
5. Run the changelog tests - `tests/changelogShort.test.cjs`,
`tests/changelogFormat.test.cjs`,
`tests/changelogEntriesBelongToTheirRelease.test.cjs` (or
`node tests/run-node-suites.cjs changelog`) - and commit
`CHANGELOG-big.md` and `CHANGELOG.md` together.
- During development, add entries under a new `# Upcoming WeKan ® release` section above
the newest release (FerretDB uses `## Upcoming FerretDB release`; the patch repos use
`# Upcoming <repo> release`). Do **not** hand-edit
@@ -438,10 +484,10 @@ directly after the merge.
add and syntax-check/register the test and state clearly what could not be run.
Do not duplicate an existing test merely to satisfy the audit — extend it when
that keeps one behavior in one suite.
- **CHANGELOG.md stays small enough for GitHub to show it.** GitHub renders
- **CHANGELOG-big.md stays small enough for GitHub to show it.** GitHub renders
Markdown up to 500 KiB; above that it shows only plain source text, and above
about 2 MB nothing at all ("we can't show files that are this big right
now"). CHANGELOG.md reached 2.6 MB over 1,100 releases back to 2015 (#6580),
now"). The changelog reached 2.6 MB over 1,100 releases back to 2015 (#6580),
and keeping only the current month still came to 2.1 MB, because releases
here are frequent and large. So the cut is by SIZE: 450,000 bytes
(`BUDGET` in `releases/changelog-archive.mjs`), under GitHub's limit with
@@ -449,7 +495,7 @@ directly after the merge.
| | |
| --- | --- |
| `CHANGELOG.md` | `# Status` (Platforms/TODO Later inside it), `# Upcoming`, and the newest releases that fit the budget |
| `CHANGELOG-big.md` | `# Status` (Platforms/TODO Later inside it), `# Upcoming`, and the newest releases that fit the budget |
| `old-CHANGELOG/<year>/<MM>.md` | a month's releases, newest first; a month over the budget continues in `<MM>-part2.md`, `<MM>-part3.md`, ... |
| `old-CHANGELOG/<year>.md` | 2015–2025, whole |
@@ -464,11 +510,11 @@ directly after the merge.
still in git (`gitk`, `git-gui`, `git log --follow`), and being small enough
to open is worth more.
Run `node releases/changelog-archive.mjs` whenever CHANGELOG.md has grown -
Run `node releases/changelog-archive.mjs` whenever CHANGELOG-big.md has grown -
after a release is the natural moment. It is idempotent — a run with nothing
to move only refreshes the tables and parts — and it decides from the FILE
rather than the clock, so two people running it agree.
`tests/changelogArchive.test.cjs` fails when CHANGELOG.md is over the budget.
`tests/changelogArchive.test.cjs` fails when CHANGELOG-big.md is over the budget.
An archived section is never edited, for the same reason a released one is
not.
- **The file's shape, top to bottom** — keep it exactly as it is now. There is
@@ -564,7 +610,7 @@ directly after the merge.
`<details>` blocks below carry that information. Keep the paragraph current as
topics change, and shorten it when added commits make it grow. A finished release
keeps the paragraph it was written with.
- **Neither CHANGELOG.md nor release notes may contain a binaries provenance
- **Neither changelog nor the release notes may contain a binaries provenance
section or a Platform/Bundle/Binary/From/Version/Checked/SHA256 table.**
Do not add "Binaries in these bundles" at the top or bottom of either.
Release notes summarize each Translations group as only a list of updated
@@ -577,7 +623,7 @@ directly after the merge.
no security fixes - is left out rather than added empty.
Release notes have only In short, Security, Translations (language list),
the standard thanks line and More details at ChangeLog with the release
anchor link. Other details stay in CHANGELOG.md. Keep download
anchor link into CHANGELOG-big.md. Other details stay in CHANGELOG-big.md. Keep download
verification and `provenance.tsv` build artifacts for diagnostics; do not
append their contents or `releases/provenance-table.sh` output to notes.
- **Inside a subsection, entries are GROUPED BY TOPIC/AREA.** A release touches a handful
@@ -636,7 +682,7 @@ directly after the merge.
Because CRITICAL comes first, it keeps the `This release ` prefix; a following non-security
subsection becomes `and …` per the rule above.
- **TODO Later** — a `<details>` nested inside `# Status` near the TOP of
`CHANGELOG.md` (above the version sections), a triage backlog for open
`CHANGELOG-big.md` (above the version sections), a triage backlog for open
issues that were **investigated but not fixed here**, each
recorded with a concrete REASON so whoever picks it up next knows why. Use it when working
through open issues (the "Fix open issues" process): for each issue, either **fix it** (commit
@@ -717,7 +763,7 @@ have cost a released section its accuracy:
- **Work continues immediately after a release**, so an entry written right
after one has to go somewhere. `release-all.sh` renames `# Upcoming WeKan ®
release` to `# v<NEW> …` and stops there — it does **not** create a new,
empty `# Upcoming` section. CHANGELOG.md never carries an
empty `# Upcoming` section. CHANGELOG-big.md never carries an
`**In short:** nothing here yet.` placeholder sitting between releases; it
used to (via a now-deleted `releases/changelog-open-next.mjs`), and that
meant the file always had a section saying nothing, from the moment a
@@ -869,6 +915,21 @@ have cost a released section its accuracy:
sizes them at 24px, and keeps the icon and its red drop on one line at every width
(they are one icon) — the table itself stays percentage-sized with no fixed or
minimum width, so it fills the page at every browser width.
- **A report that arrives WITHOUT a GHSA is upgraded when its GHSA arrives.** The
stars follow `.tools/wekan.fi/hall-of-fame/Audit-Stars.md`: a GHSA with a
suggested fix is 5, a report sent to security@wekan.fi or any other way, with a
suggested fix, is 3. When that researcher is later able to add a GitHub security
advisory for the same report, the maintainer requests the CVE through the GHSA
process, and the row's 3 stars become 5. So whenever an AI updates or audits the
Hall of Fame, it checks each 3-star row whose Process says the report came
without a GHSA against [WeKan Security
Advisories](https://github.com/wekan/wekan/security/advisories): when a GHSA for
that report now exists, it sets the row to 5 stars on the index AND on the row's
own page, links the GHSA (and the CVE in the CVE cell once one is assigned),
rewrites the Process cell to say the report later arrived as a GHSA, and records
the change in Audit-Stars.md. Match by the report's subject and reporter, not by
a nearby advisory - a GHSA about something else is no upgrade. Until the GHSA
exists, the row stays at 3 and its Process cell says the GHSA is awaited.
- **On the Hall of Fame index page, the Process and Vulnerabilities cells are
collapsed.** Those two columns carry the prose — how the report arrived, and the
whole story of the vulnerability — so the page would be one wall of text and the
+1 -1
View File
@@ -13,7 +13,7 @@ For all code at WeKan GitHub Organization https://github.com/wekan
- All known vulnerabilities have been fixed at newest WeKan, please upgrade https://wekan.fi/upgrade/
- There is no known backdoors. If WeKan is installed by someone else, first registered user is Admin and has Admin Panel access.
- Maintainer of WeKan has no access to someone elses installed WeKan.
- For someone elses installed WeKan, see [Forgot Password](https://github.com/wekan/wekan/blob/main/docs/Features/Login/Forgot-Password.md)
- For someone elses installed WeKan, see [Forgot Password](https://github.com/wekan/wekan/blob/main/docs/Features/Login-Page/Forgot-Password.md)
# Reporting about violations or something else
+31 -4
View File
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=12.44
ARG VERSION=12.94
ARG DEBIAN_FRONTEND=noninteractive
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
@@ -148,10 +148,10 @@ ARG DEBIAN_FRONTEND=noninteractive
ENV \
DEBUG=false \
DDP_TRANSPORT=sockjs \
NODE_VERSION=v26.11.1 \
METEOR_RELEASE=METEOR@3.6-rc.0 \
NODE_VERSION=v26.13.0 \
METEOR_RELEASE=METEOR@3.6-rc.1 \
USE_EDGE=false \
NPM_VERSION=12.2.0 \
NPM_VERSION=12.2.1 \
SRC_PATH=./ \
WITH_API=true \
MONGO_OPLOG_URL="" \
@@ -291,6 +291,33 @@ ENV \
LOGOUT_IN="" \
LOGOUT_ON_HOURS="" \
LOGOUT_ON_MINUTES="" \
USERS_DISABLE_INACTIVE_AFTER_DAYS="" \
LOGOUT_IDLE_MINUTES="" \
REQUIRE_2FA="" \
PASSWORD_MIN_LENGTH="" \
PASSWORD_MIN_LENGTH_ENFORCE="" \
PASSWORD_CHECK_PWNED="" \
HSTS_MAX_AGE="" \
HSTS_INCLUDE_SUBDOMAINS="" \
HSTS_PRELOAD="" \
ATTACHMENTS_RETENTION_DAYS="" \
ATTACHMENTS_RETENTION_MODE="" \
ATTACHMENTS_PURGE_DELETED_AFTER_DAYS="" \
ATTACHMENTS_RETENTION_DRY_RUN="" \
ATTACHMENTS_RETENTION_MAX_PER_RUN="" \
BACKUP_KEEP_DAYS="" \
ACCOUNTS_COOKIE_REFRESH_RATE_LIMIT="" \
INBOUND_IMAP_POLL_SECONDS="" \
INBOUND_EMAIL_PER_ADDRESS_PER_HOUR="" \
INBOUND_EMAIL_WEBHOOK_SECRET="" \
INBOUND_EMAIL_MAX_BYTES="" \
INBOUND_EMAIL_MAX_ATTACHMENT_BYTES="" \
EMAIL_VIEW_SENDS_PER_HOUR="" \
INBOUND_IMAP_DISABLED="" \
WEKAN_DISK_MONITOR="" \
WEKAN_DISK_WATCH_PATHS="" \
WEKAN_RAM_MONITOR="" \
WEKAN_IMPORT_RUN_STALE_MS="" \
CORS="" \
CORS_ALLOW_HEADERS="" \
CORS_EXPOSE_HEADERS="" \
+3 -3
View File
@@ -24,7 +24,7 @@ that by providing one-click installation on various platforms.
- WeKan ® is used in [most countries of the world](https://snapcraft.io/wekan).
- WeKan ® largest user has 30k users using WeKan ® in their company.
- WeKan ® has been [translated](https://app.transifex.com/wekan/) into 234 locale catalogs;
171 have non-English text for over 90% of source keys. This measures text coverage,
234 have non-English text for over 90% of source keys. This measures text coverage,
not translation quality; language review and remaining translations are ongoing.
- [Databases](https://github.com/wekan/wekan/blob/main/docs/Databases/Supported.md):
- FerretDB v1 with SQLite, the default on every platform - bundle, Docker,
@@ -41,7 +41,7 @@ that by providing one-click installation on various platforms.
- All Boards page, Drag drop reorder with one or Multi-Selection:
- Board icons at Remaining, (Sub)Workspaces, Archive
- Shared Templates: Boards, Lists, Cards
- Add Board / Import ([all import and export formats](docs/Features/ImportExport/External-Tools.md)):
- Add Board / Import ([all import and export formats](docs/Features/Board-Sidebar/Board-Settings/Import-Export/External-Tools.md)):
- WeKan JSON/.zip, CSV/TSV, Excel, Markdown, Obsidian Kanban, Leo, OPML, Org mode, todo.txt
- Trello, Jira, Asana, Zenkit, Focalboard, Todoist, Microsoft Planner, MeisterTask, Linear, TickTick, ClickUp, Nullboard, Kanri, Pivotal Tracker, Redmine, Tasks.org, monday.com,
Super Productivity, Taiga, Vikunja, Wrike (Excel and workflow), Quire, Teamwork.com, Businessmap, Notion, Plane
@@ -90,7 +90,7 @@ that by providing one-click installation on various platforms.
- Import / Export IFTTT Rules; import n8n, Node-RED and Trello Butler; import / export Wrike workflows
- Export board :
- Select what to include: Card details, Board, Activities, Labels, People (Creator, Owner, Members, Assignees), Board Info (Board, List, Swimlane), Dates (Created, Received, Start, Due, End), Description, Custom Fields, Checklists, Subtasks, Comments, Attachments, Votin, Plannin Poker, Stickers, Location (link to map based on coordinates), Dependencies, Sort, Scrum Settings
- Export to ([all formats](docs/Features/ImportExport/External-Tools.md)): PDF, Excel, HTML, Calendar feed (iCal), Dependencies JSON/SVG,
- Export to ([all formats](docs/Features/Board-Sidebar/Board-Settings/Import-Export/External-Tools.md)): PDF, Excel, HTML, Calendar feed (iCal), Dependencies JSON/SVG,
CSV , ; TSV, JSON with/without attachmeents, .zip (Attachments),
Kanboard, Markdown, Leo, OPML, Org mode, todo.txt, Taskwarrior, Focalboard, Todoist,
Microsoft Planner, MeisterTask, Obsidian Kanban, Linear, TickTick, ClickUp, Nullboard, Kanri, Pivotal Tracker, Redmine, Tasks.org, monday.com,
+13 -5
View File
@@ -29,7 +29,15 @@ because WeKan is NOT Big Tech. WeKan is FLOSS.
- **Remediation**: Please wait for a new WeKan release that addresses the issue.
Fixes are announced at the top of the [ChangeLog](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
- **Recognition**: We will acknowledge your contribution by adding you to our
[Hall of Fame](https://wekan.fi/hall-of-fame/).
[Hall of Fame](https://wekan.fi/hall-of-fame/). The stars follow the
[Stars Audit](https://github.com/wekan/wekan.fi/blob/main/hall-of-fame/Audit-Stars.md):
a GitHub security advisory (GHSA) with details and a suggested fix gets 5 stars,
a report sent to security@wekan.fi or some other way, with a suggested fix, gets 3.
- **Adding the GHSA later**: if you sent your report to security@wekan.fi, or some
other way, and are later able to add it as a "New draft security advisory" at
[WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories),
please do. The maintainer of WeKan then requests the CVE through that GHSA, and
your Hall of Fame row is upgraded from 3 stars to 5.
- **CVE Policy**: WeKan Security requests CVE at GitHub when releasing Security Advisory.
### 3. Post-Release and Public Disclosure:
@@ -264,13 +272,13 @@ Meteor.startup(() => {
On the Sandstorm platform, Standalone WeKan features like the Admin Panel are turned off using environment variables, because Sandstorm platform provides SSO for all apps running on Sandstorm.
[Sandstorm](https://sandstorm.io) is a separate Open Source platform that has been
[security audited](https://sandstorm.io/news/2017-03-02-security-review) and found bugs fixed.
[Sandstorm](https://sandstorm.org) is a separate Open Source platform that has been
[security audited](https://sandstorm.org/news/2017-03-02-security-review) and found bugs fixed.
Sandstorm also has passwordless login, LDAP, SAML, Google, and other auth options already.
On Sandstorm, code is read-only and signed by app maintainers; only grain content can be modified.
WeKan on Sandstorm runs in a sandboxed grain; it does not have access elsewhere without a user-visible PowerBox request or opening a randomly-generated API key URL.
Also read [Sandstorm Security Practices](https://docs.sandstorm.io/en/latest/using/security-practices/) and
[Sandstorm Security non-events](https://docs.sandstorm.io/en/latest/using/security-non-events/).
Also read [Sandstorm Security Practices](https://docs.sandstorm.org/en/latest/using/security-practices/) and
[Sandstorm Security non-events](https://docs.sandstorm.org/en/latest/using/security-non-events/).
For Sandstorm specific security issues you can contact [kentonv](https://github.com/kentonv) by email.
## What WeKan bugs are eligible?
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v12.44.0"
appVersion: "v12.94.0"
files:
userUploads:
- README.md
+46 -53
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
@@ -77,7 +70,7 @@ If *nix: chmod +x api.py => ./api.py users
python3 api.py importboard EXPORT.json # Import a whole board (with rules/workflows) from a WeKan export
python3 api.py migratefromwekan REMOTE_URL REMOTE_USER REMOTE_PASS # Import ALL boards+workflows+rules from another WeKan
python3 api.py exportboardpdf BOARDID OUTPUT.pdf # Export a whole board to PDF
python3 api.py importboardfrom SOURCE FILE [--csv-mapping MAPPING.json] # Import a tool's export file (csv and excel: --csv-mapping says which column is which field); SOURCE is a key of the import page (trello, jira, csv, excel, planner, wrike, notion, ...), see docs/Features/ImportExport
python3 api.py importboardfrom SOURCE FILE [--csv-mapping MAPPING.json] # Import a tool's export file (csv and excel: --csv-mapping says which column is which field); SOURCE is a key of the import page (trello, jira, csv, excel, planner, wrike, notion, ...), see docs/Features/Board-Sidebar/Board-Settings/Import-Export
python3 api.py exportboardformat BOARDID FORMAT OUTPUTFILE # Export to a tool's format; FORMAT is a key of the export menu (kanboard, trello, planner, wrike, wrikeworkflow, ...)
python3 api.py importboardsfrom SOURCE [--split] FILE_OR_DIR ... # Import many boards: each file (or each file of a directory or .zip) becomes a board; --split makes one board per project
python3 api.py exportallboards FORMAT OUTPUTFILE [--boards ID1,ID2] # Export all boards: Excel as one workbook with a sheet per board, other formats as a .zip with a file per board
@@ -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]
@@ -1282,7 +1275,7 @@ if arguments >= 1:
# ------- BOARD MEMBER / CARD FIELD API ENDPOINTS START -----------
# These commands wrap REST endpoints that already exist in Wekan.
# See CHANGELOG.md "Upcoming" section for related issues and design notes.
# See CHANGELOG-big.md "Upcoming" section for related issues and design notes.
# Map a human readable ROLE to the board member permission boolean flags
# used by /api/boards/:boardId/members/:userId/add and .../members/:memberId
@@ -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]
@@ -1472,7 +1465,7 @@ if arguments >= 1:
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
# NOTE: this REPLACES the card's labels. Bulk add/remove (merge) is not yet
# implemented server-side, see Issue #5819 in CHANGELOG.md "Upcoming".
# implemented server-side, see Issue #5819 in CHANGELOG-big.md "Upcoming".
put_data = {'labelIds': labelids if labelids else []}
body = requests.put(edcard, json=put_data, headers=headers)
print("=== SET CARD LABELS ===\n")
@@ -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':
+78 -31
View File
@@ -60,11 +60,10 @@ exit /b 0
REM --- Repo root = folder of this script (strip trailing backslash) ---
set "REPO=%~dp0"
if "%REPO:~-1%"=="\" set "REPO=%REPO:~0,-1%"
REM As build.sh: Meteor installed into the repo's .tools is on PATH, and the
REM Playwright browsers live in .tools\ms-playwright, not in the user's cache,
REM so a clean checkout and EVERYTHING use the same browsers.
if exist "%REPO%\.tools\.meteor" set "PATH=%REPO%\.tools\.meteor;%PATH%"
if not defined PLAYWRIGHT_BROWSERS_PATH set "PLAYWRIGHT_BROWSERS_PATH=%REPO%\.tools\ms-playwright"
REM As build.sh: everything this script writes - temporary files, downloads,
REM Node.js, Meteor, the npm cache, Go and the Playwright browsers - stays in
REM .tools\, and those tools come first on PATH (releases\tools-env.bat).
call "%REPO%\releases\tools-env.bat"
cd /d "%REPO%"
REM Give the Meteor build tool and Node processes a larger heap so long
@@ -199,6 +198,11 @@ echo 14^) All databases ^(sequential^): build newest FerretDB v1, run every que
echo against every database with an image for this CPU, compare the answers
echo 15^) Run all FerretDB tests - SEQUENTIAL ^(unit, vet, integration^)
echo 16^) EVERYTHING source one by one ^(Dev server nobuild, all browsers^)
echo 17^) Live tests with your own accounts: GitLab write-back with the mirror's
echo glab login ^(only scratch items it creates and removes^) and the API
echo imports, read only ^(releases\live-tests\README.md^)
echo 18^) Database speed test: the common WeKan queries on every database with an
echo image for this CPU, compared, written to SpeedTest\Results.md
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" goto test_everything_two
@@ -217,6 +221,8 @@ if "%choice%"=="13" goto count_tests
if "%choice%"=="14" goto test_all_databases
if "%choice%"=="15" goto test_ferretdb
if "%choice%"=="16" goto test_everything_source
if "%choice%"=="17" goto test_live
if "%choice%"=="18" goto test_db_speed
if "%choice%"=="0" goto menu
goto menu_tests
@@ -845,8 +851,8 @@ REM ===========================================================================
echo Installing WeKan dependencies for Windows.
rem Full MIME detection uses the libmagic WASM engine/database installed by npm.
rem No external file.exe installation is needed.
echo This uses Chocolatey to install git, curl, Node.js LTS and MongoDB tools,
echo then installs Meteor with npx meteor.
echo This uses Chocolatey to install git, curl and the MongoDB tools, then puts
echo Node.js and Meteor into .tools\ of this checkout - not into the system.
where choco >nul 2>&1
if errorlevel 1 (
echo Installing Chocolatey package manager ...
@@ -860,33 +866,50 @@ for /f "tokens=2 delims== " %%v in ('findstr /r /c:"NODE_VERSION=v[0-9]" "%REPO%
for /f "tokens=2 delims== " %%v in ('findstr /r /c:"NPM_VERSION=[0-9]" "%REPO%\Dockerfile"') do if not defined WK_NPM_VERSION set "WK_NPM_VERSION=%%v"
if defined WK_NODE_VERSION set "WK_NODE_VERSION=%WK_NODE_VERSION:v=%"
call choco install -y git curl mongodb-shell
if defined WK_NODE_VERSION (
echo Installing Node.js %WK_NODE_VERSION% ^(Dockerfile NODE_VERSION^) ...
call choco install -y nodejs --version=%WK_NODE_VERSION% --allow-downgrade
if errorlevel 1 (
echo Chocolatey has no Node.js %WK_NODE_VERSION% yet; installing its newest Node.js instead.
call choco install -y nodejs
)
) else (
call choco install -y nodejs-lts
REM Node.js from nodejs.org's zip, unpacked into .tools\node-v<version>-win-<arch>
REM and verified against its published SHASUMS256.txt first - not a system-wide
REM Chocolatey install. curl.exe and tar.exe come with Windows 10 and 11.
if not defined WK_NODE_VERSION (
echo ERROR: could not read NODE_VERSION from the Dockerfile.
goto end
)
set "WK_NODE_ARCH=x64"
if /I "%PROCESSOR_ARCHITECTURE%"=="ARM64" set "WK_NODE_ARCH=arm64"
set "WK_NODE_NAME=node-v%WK_NODE_VERSION%-win-%WK_NODE_ARCH%"
if exist "%WEKAN_TOOLS_DIR%\%WK_NODE_NAME%\node.exe" (
echo Node.js %WK_NODE_VERSION% is already in .tools\%WK_NODE_NAME%.
) else (
echo Installing Node.js %WK_NODE_VERSION% ^(Dockerfile NODE_VERSION^) into .tools ...
curl -fsSL -o "%TEMP%\%WK_NODE_NAME%.zip" "https://nodejs.org/dist/v%WK_NODE_VERSION%/%WK_NODE_NAME%.zip" || goto end
curl -fsSL -o "%TEMP%\SHASUMS256.txt" "https://nodejs.org/dist/v%WK_NODE_VERSION%/SHASUMS256.txt" || goto end
powershell -NoProfile -Command "$want = ((Select-String -Path $env:TEMP\SHASUMS256.txt -Pattern ([regex]::Escape($env:WK_NODE_NAME + '.zip') + '$')).Line -split '\s+')[0]; $got = (Get-FileHash -Algorithm SHA256 ($env:TEMP + '\' + $env:WK_NODE_NAME + '.zip')).Hash; if (-not $want -or $got -ne $want.ToUpper()) { Write-Error ('SHA256 mismatch for ' + $env:WK_NODE_NAME + '.zip'); exit 1 }" || goto end
tar -xf "%TEMP%\%WK_NODE_NAME%.zip" -C "%WEKAN_TOOLS_DIR%" || goto end
del "%TEMP%\%WK_NODE_NAME%.zip" "%TEMP%\SHASUMS256.txt"
)
REM npm's global installs (.tools\npm-global) before Node.js's own npm, so the
REM npm installed next is the one found.
set "PATH=%npm_config_prefix%;%WEKAN_TOOLS_DIR%\%WK_NODE_NAME%;%PATH%"
if defined WK_NPM_VERSION (
echo Installing npm %WK_NPM_VERSION% ^(Dockerfile NPM_VERSION^) ...
call npm install -g npm@%WK_NPM_VERSION%
)
REM Meteor as https://docs.meteor.com/about/install.html installs it on Windows,
REM Linux and macOS alike: npx meteor. The meteor npm package is only an
REM installer; npx runs it directly, into %LOCALAPPDATA%\.meteor, and it adds
REM that to the user's PATH. npm -g install meteor ran the same installer as an
REM install script, which npm 12 refuses to run.
where meteor >nul 2>&1
if errorlevel 1 (
echo Installing Meteor ^(npx meteor^) ...
call npx -y meteor
REM installer; npx runs it directly, into %LOCALAPPDATA%\.meteor - so
REM LOCALAPPDATA is .tools for the installer only, and Meteor lands in
REM .tools\.meteor. npm -g install meteor ran the same installer as an install
REM script, which npm 12 refuses to run.
if exist "%WEKAN_TOOLS_DIR%\.meteor\meteor.bat" (
echo Meteor is already installed in .tools\.meteor.
) else (
echo Meteor is already installed.
echo Installing Meteor ^(npx meteor^) into .tools\.meteor ...
setlocal
set "LOCALAPPDATA=%WEKAN_TOOLS_DIR%"
call npx -y meteor
endlocal
)
echo Done. Open a new terminal so PATH changes take effect, then re-run this script.
call "%REPO%\releases\tools-env.bat"
echo Done. build.bat puts .tools\ Node.js and Meteor on PATH itself; re-run this script.
goto end
REM ===========================================================================
@@ -966,15 +989,18 @@ exit /b %errorlevel%
:buildcommon
REM The same diagnostics as build.sh build_wekan: npm verbose output and
REM foreground install scripts, METEOR_PROFILE, Node module resolution and the
REM resolver command/output tracer, so a failed Windows build leaves the same
REM evidence in its log. WEKAN_BUILD_HEAP_SNAPSHOT=1 caps the heap at
REM foreground install scripts, METEOR_PROFILE and the resolver command/output
REM tracer, so a failed Windows build leaves the same evidence in its log. Node
REM module resolution (one line per require, gigabytes a minute) only with
REM WEKAN_BUILD_TRACE_MODULES=1. WEKAN_BUILD_HEAP_SNAPSHOT=1 caps the heap at
REM WEKAN_BUILD_HEAP_SNAPSHOT_MB (4096) and writes a heap snapshot near the limit.
setlocal
set "npm_config_loglevel=verbose"
set "npm_config_foreground_scripts=true"
if not defined METEOR_PROFILE set "METEOR_PROFILE=100"
if defined NODE_DEBUG (set "NODE_DEBUG=%NODE_DEBUG%,module") else (set "NODE_DEBUG=module")
if "%WEKAN_BUILD_TRACE_MODULES%"=="1" (
if defined NODE_DEBUG (set "NODE_DEBUG=!NODE_DEBUG!,module") else (set "NODE_DEBUG=module")
)
if "%WEKAN_BUILD_HEAP_SNAPSHOT%"=="1" (
if not defined WEKAN_BUILD_HEAP_SNAPSHOT_MB set "WEKAN_BUILD_HEAP_SNAPSHOT_MB=4096"
set "TOOL_NODE_FLAGS=--max-old-space-size=!WEKAN_BUILD_HEAP_SNAPSHOT_MB! --heapsnapshot-near-heap-limit=1"
@@ -982,7 +1008,7 @@ if "%WEKAN_BUILD_HEAP_SNAPSHOT%"=="1" (
echo Heap snapshot: ON, heap capped at !WEKAN_BUILD_HEAP_SNAPSHOT_MB! MB; the build will fail sooner and write Heap.*.heapsnapshot.
)
set "NODE_OPTIONS=%NODE_OPTIONS% --require="%REPO%\tools\build-command-output.cjs""
echo Build diagnostics: resolver command/output tracing, Node module resolution, npm verbose output, foreground install scripts, METEOR_PROFILE=%METEOR_PROFILE% ^(milliseconds^).
echo Build diagnostics: resolver command/output tracing, npm verbose output, foreground install scripts, METEOR_PROFILE=%METEOR_PROFILE% ^(milliseconds^).
call :buildcommon_steps
set "BUILD_RC=%errorlevel%"
if not "%BUILD_RC%"=="0" if defined WEKAN_BUILD_LOG (
@@ -1601,6 +1627,13 @@ for /f "usebackq delims=" %%P in (`powershell.exe -NoProfile -Command "[Environm
if not "%FORGE_INSTALL_STATUS%"=="0" echo One or more mirror tools could not be installed. See MISSING entries above.
exit /b %FORGE_INSTALL_STATUS%
REM ===========================================================================
:test_live
REM releases/live-tests/run.mjs runs every live test there (the GitLab one is
REM releases\live-tests\gitlab-write-back.mjs); each skips a service with no login.
node "%REPO%\releases\live-tests\run.mjs"
goto end
REM ===========================================================================
:mirror_forge
call "%REPO%\releases\mirror.bat"
@@ -1947,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"
@@ -2054,6 +2087,20 @@ if errorlevel 1 (
bash ./releases/db-conformance.sh
goto end
REM ===========================================================================
:test_db_speed
REM build.sh's "Database speed test": db-conformance.sh's databases, started the
REM same way, each loaded with the same dummy WeKan data and timed on the common
REM WeKan queries. See docs\Databases\FerretDB\1\SpeedTest\README.md.
where bash >nul 2>&1
if errorlevel 1 (
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
echo Install either, or run this from WSL: ./docs/Databases/FerretDB/1/SpeedTest/speedtest.sh
goto end
)
bash ./docs/Databases/FerretDB/1/SpeedTest/speedtest.sh
goto end
REM ===========================================================================
:test_ferretdb
REM All of FerretDB's own tests, one at a time: unit, vet, integration.
+123 -64
View File
@@ -103,6 +103,10 @@ WEKAN_TOOLS_DIR="$WEKAN_DIR/.tools"
export WEKAN_DIR WEKAN_TOOLS_DIR
export PATH="$WEKAN_TOOLS_DIR/bin:${GOBIN:+$GOBIN:}$PATH"
. "$WEKAN_DIR/releases/ensure-tools.sh"
# Everything this script and the releases/ scripts write - temporary files,
# downloads, Meteor, Node.js, the npm cache - stays in .tools/, on Linux, macOS
# and Windows alike (releases/tools-env.sh).
. "$WEKAN_DIR/releases/tools-env.sh"
# Prefer the repository-local toolchain installed under .tools. A fresh shell
# does not normally have .tools/.meteor on PATH, which made unattended test and
@@ -464,10 +468,17 @@ function build_wekan(){
echo "===== wekan build started $(date '+%F %T') ====="
export npm_config_loglevel=verbose npm_config_foreground_scripts=true
export METEOR_PROFILE="${METEOR_PROFILE:-100}"
export NODE_DEBUG="${NODE_DEBUG:+$NODE_DEBUG,}module"
# Node module resolution, one line per require. OFF unless
# WEKAN_BUILD_TRACE_MODULES=1: a Meteor build writes it at gigabytes a
# minute - 1.1 GB in the first three minutes of a menu dev build, over
# 12 GB in the first half hour of EVERYTHING on macOS, written twice
# through tee - and slows the build it is tracing.
if [ "${WEKAN_BUILD_TRACE_MODULES:-0}" = 1 ]; then
export NODE_DEBUG="${NODE_DEBUG:+$NODE_DEBUG,}module"
fi
# NODE_OPTIONS parses quoted paths, including checkouts with spaces.
export NODE_OPTIONS="${NODE_OPTIONS:-} --require=\"$(pwd)/tools/build-command-output.cjs\""
echo "Build diagnostics: resolver command/output tracing, Node module resolution, npm verbose output, foreground install scripts, METEOR_PROFILE=$METEOR_PROFILE (milliseconds)."
echo "Build diagnostics: resolver command/output tracing, npm verbose output, foreground install scripts, METEOR_PROFILE=$METEOR_PROFILE (milliseconds), Node module resolution $([ "${WEKAN_BUILD_TRACE_MODULES:-0}" = 1 ] && echo on || echo 'off (WEKAN_BUILD_TRACE_MODULES=1 turns it on)')."
build_stage "1/4 Remove dependencies and build caches" rm -rf node_modules node_modules/.cache .meteor/local .build _build || return $?
# Updating npm metadata has historically been best effort; installation is required.
build_stage "2/4 Compile app to resolve Meteor plugin npm dependencies" meteor update --npm || echo "WARNING: Meteor plugin npm dependency compilation failed; trying dependency installation."
@@ -641,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
@@ -666,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" \
@@ -706,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
@@ -728,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"
}
@@ -1216,7 +1256,7 @@ function run_all_tests(){
PW_FAILURES=""
TEST_SERVER_PID=""
MONGOD_PID=""
STATDIR="$(mktemp -d)"
STATDIR="$(mktemp -d "$TMPDIR/wekan-stat.XXXXXX")"
BPIDS=""
# STOP WHAT THIS RUN STARTED, on EVERY way out - not only the happy path.
@@ -1502,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 &
@@ -1530,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.
@@ -2389,8 +2433,8 @@ function git_fix_changelog_links(){
local script="$WEKAN_DIR/releases/fix-changelog-hashes.sh"
[ -f "$script" ] || return 1
bash "$script" || return 1
if [ -n "$(git status --porcelain -- CHANGELOG.md 2>/dev/null)" ]; then
git add CHANGELOG.md
if [ -n "$(git status --porcelain -- CHANGELOG-big.md CHANGELOG.md 2>/dev/null)" ]; then
git add CHANGELOG-big.md CHANGELOG.md
git commit -q -m "CHANGELOG: repoint commit links after history moved.
A rebase rewrote the commits these entries link to, so the links named commits
@@ -2601,7 +2645,7 @@ choose() {
# snap/bundle, not on a maintainer's machine) and the superseded old-*.sh and
# translations/fill_translations.py.
# Release notes summarize translations as affected languages; other changelog
# details stay in CHANGELOG.md. Notes include only In short, Security,
# details stay in CHANGELOG-big.md. Notes include only In short, Security,
# Translations language list, thanks and the changelog link. Binary provenance remains
# a build artifact and must not be appended by any release entry point.
RELEASE_SCRIPTS=( "Release|Release All: audit, commit, push and build with Actions|releases/release-all.sh|||"
@@ -2916,7 +2960,9 @@ while [ -z "$opt" ]; do
"Floating-promises guard|Check floating promises guard (@typescript-eslint/no-floating-promises + auth await scan)" \
"Count tests by category|Count amount of tests by category" \
"Run all FerretDB tests - SEQUENTIAL|Run all FerretDB tests - SEQUENTIAL: unit, vet and the integration suite of the FerretDB subdirectory of this repo, one at a time, logs in log/<datetime>/" \
"All databases (sequential)|Test all databases that have a Docker image for this CPU, SEQUENTIALLY: build newest FerretDB v1 from source, then run every FerretDB v1 query type against each database and compare that they all answer the same (results in log/<datetime>/)" ;;
"Live tests|Run live tests against real services with your own accounts: GitLab write-back with the mirror's glab login (only scratch items it creates and removes) and the API imports, read only (releases/live-tests/README.md); a service with no login here is skipped, log in .tools/log/<datetime>/" \
"All databases (sequential)|Test all databases that have a Docker image for this CPU, SEQUENTIALLY: build newest FerretDB v1 from source, then run every FerretDB v1 query type against each database and compare that they all answer the same (results in log/<datetime>/)" \
"Database speed test|Time the common WeKan queries on every database that has a Docker image for this CPU, SEQUENTIALLY, with twice a production WeKan of dummy data; check they all answer the same and write docs/Databases/FerretDB/1/SpeedTest/Results.md" ;;
"Tools")
choose "Tools" \
"Save Meteor deps list|Save Meteor dependency chain to ../meteor-deps.txt" \
@@ -2970,15 +3016,20 @@ for _once in 1; do
;;
*) echo "Unsupported Linux distribution; install a C/C++ toolchain, git, curl, wget, 7zip, zip, unzip, file and npm." >&2; exit 1 ;;
esac
sudo npm -g install n
sudo n "$_wekan_node_version"
sudo npm install -g "npm@${_wekan_npm_version:-latest}"
# Node.js into .tools/n with n's N_PREFIX, as this user: not into
# /usr/local with sudo. releases/tools-env.sh puts .tools/n/bin on
# PATH, and npm's global installs go to that same prefix.
N_PREFIX="$WEKAN_TOOLS_DIR/n" npx -y n "$_wekan_node_version"
export PATH="$WEKAN_TOOLS_DIR/n/bin:$PATH"
hash -r
npm install -g "npm@${_wekan_npm_version:-latest}"
hash -r
# Meteor the way https://docs.meteor.com/about/install.html installs
# it on Linux, macOS and Windows alike: `npx meteor`, as this user and
# never with sudo. The meteor npm package is only an installer; npx
# runs its cli.js, which installs into ~/.meteor and adds that to
# PATH in the shell rc files. `sudo npm -g install meteor` ran the
# runs its cli.js, which installs into $HOME/.meteor and adds that to
# PATH in the shell rc files - so HOME is .tools for the installer
# only, and Meteor and those rc lines land in .tools/. `sudo npm -g install meteor` ran the
# same installer as an install script instead, which npm 12 refuses
# to run (and which also refused --unsafe-perm and the documented
# --foreground-script).
@@ -2986,16 +3037,14 @@ for _once in 1; do
# The global meteor package such a setup left behind is only that
# installer, never Meteor itself (~/.meteor is not touched).
sudo npm -g uninstall meteor >/dev/null 2>&1 || true
# npx caches in ~/.npm, which an old `sudo npm` could leave root-owned.
if [ -d "$HOME/.npm" ] && [ ! -w "$HOME/.npm" ]; then
sudo chown -R "$(id -u):$(id -g)" "$HOME/.npm"
fi
if [ -x "$HOME/.meteor/meteor" ]; then
echo "Meteor is already installed in $HOME/.meteor"
# npx caches in npm_config_cache (.tools/npm-cache).
mkdir -p "$npm_config_cache"
if [ -x "$WEKAN_TOOLS_DIR/.meteor/meteor" ]; then
echo "Meteor is already installed in $WEKAN_TOOLS_DIR/.meteor"
else
npx -y meteor
HOME="$WEKAN_TOOLS_DIR" npx -y meteor
fi
export PATH="$HOME/.meteor:$PATH"
. "$WEKAN_DIR/releases/tools-env.sh"
elif [[ "$OSTYPE" == "darwin"* ]]; then
echo "macOS"
# Node comes from nvm, not from Homebrew's node@26 keg.
@@ -3007,13 +3056,14 @@ for _once in 1; do
# puts that on PATH itself, so this does not go stale the way a
# pinned version does. npm comes with the Node it installs, so
# there is no `brew install npm` either.
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
# NVM_DIR is .tools/nvm (releases/tools-env.sh).
mkdir -p "$NVM_DIR"
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
echo "Installing nvm into $NVM_DIR ..."
# A PINNED tag, not master: this pipes a downloaded script into
# a shell, so it must be a revision somebody has looked at.
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.6/install.sh | bash
# PROFILE=/dev/null: the installer edits no shell rc file.
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.6/install.sh | PROFILE=/dev/null bash
fi
# nvm is a shell FUNCTION, not a binary, so it has to be sourced -
# `command -v nvm` finds nothing until this line has run.
@@ -3035,23 +3085,18 @@ for _once in 1; do
nvm use 26
npm install -g "npm@${_wekan_npm_version:-latest}"
echo "Node $(node --version), npm $(npm --version)"
# Let new shells find nvm too. Its installer appends these itself,
# but only to the rc file it detects and only when IT did the
# install - so a machine that already had nvm keeps working.
touch "$HOME/.zshrc"
for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do
[ -e "$rc" ] || continue
grep -qxF 'export NVM_DIR="$HOME/.nvm"' "$rc" || echo 'export NVM_DIR="$HOME/.nvm"' >> "$rc"
grep -qxF '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' "$rc" || echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> "$rc"
done
# No shell rc file is edited: build.sh and the releases/ scripts put
# .tools/nvm's Node.js and .tools/.meteor on PATH themselves
# (releases/tools-env.sh).
# Meteor as https://docs.meteor.com/about/install.html installs it:
# `npx meteor`, as this user, never sudo (see the Linux branch).
if [ -x "$HOME/.meteor/meteor" ]; then
echo "Meteor is already installed in $HOME/.meteor"
# `npx meteor`, as this user, never sudo, with HOME=.tools for the
# installer only (see the Linux branch).
if [ -x "$WEKAN_TOOLS_DIR/.meteor/meteor" ]; then
echo "Meteor is already installed in $WEKAN_TOOLS_DIR/.meteor"
else
npx -y meteor
HOME="$WEKAN_TOOLS_DIR" npx -y meteor
fi
export PATH="$HOME/.meteor:$PATH"
. "$WEKAN_DIR/releases/tools-env.sh"
exit;
elif [[ "$OSTYPE" == "cygwin" ]]; then
# POSIX compatibility layer and Linux environment emulation for Windows
@@ -3332,6 +3377,14 @@ for _once in 1; do
break
;;
"Run live tests against real services with your own accounts: GitLab write-back with the mirror's glab login (only scratch items it creates and removes) and the API imports, read only (releases/live-tests/README.md); a service with no login here is skipped, log in .tools/log/<datetime>/")
# releases/live-tests/run.mjs: every live test there; each skips a
# service it has no login for. Human-run only - they write to the
# services (scratch items they remove), so no automatic run calls them.
node "$WEKAN_DIR/releases/live-tests/run.mjs"
break
;;
"Mirror GitHub to active mirrors: code, issues, PR conversations, releases and assets")
mirror_forge
break
@@ -3361,6 +3414,12 @@ for _once in 1; do
./releases/db-conformance.sh
;;
"Time the common WeKan queries on every database that has a Docker image for this CPU, SEQUENTIALLY, with twice a production WeKan of dummy data; check they all answer the same and write docs/Databases/FerretDB/1/SpeedTest/Results.md")
# docs/Databases/FerretDB/1/SpeedTest/README.md: db-conformance.sh's databases,
# the speed test's queries.
./docs/Databases/FerretDB/1/SpeedTest/speedtest.sh
;;
"Count amount of tests by category")
LOG="$(one_log test-counts)"
echo "Log: $LOG"
+26 -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);
});
});
}
@@ -111,6 +114,28 @@ Meteor.startup(() => {
}
});
// LOGOUT_IDLE_MINUTES: report the user's activity while signed in, if the
// limit is set (models/lib/idleLogout.js).
const { createIdleActivityReporter } = require('/client/lib/idleActivity');
const idleReporter = createIdleActivityReporter({
target: document,
touch: () => Meteor.call('touchActivity', () => {}),
});
let idleCheckedFor = null;
Tracker.autorun(() => {
const userId = Meteor.userId();
if (!userId) {
idleCheckedFor = null;
idleReporter.stop();
return;
}
if (idleCheckedFor === userId) return;
idleCheckedFor = userId;
Meteor.call('getIdleLogout', (error, result) => {
if (!error && result && result.enabled && Meteor.userId() === userId) idleReporter.start();
});
});
// Initialize mobile mode on startup for iOS devices
// This ensures mobile mode is applied correctly on page load
Tracker.afterFlush(() => {
+13 -5
View File
@@ -1,6 +1,11 @@
.new-comment {
position: relative;
margin: 0 0 20px 38px;
/* #6805: the room for the avatar is on the side the avatar is drawn - the
reading start (inset-inline-start below). A physical left margin kept it on
the left while a right-to-left page drew the avatar on the right, outside
the card. */
margin-block: 0 20px;
margin-inline: 38px 0;
}
.new-comment .member {
opacity: 0.7;
@@ -29,7 +34,8 @@
border: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
height: 36px;
margin: 4px 4px 6px 0;
margin-block: 4px 6px;
margin-inline: 0 4px;
padding: 9px 11px;
width: 100%;
}
@@ -47,9 +53,10 @@
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;
margin-block: 4px 6px;
margin-inline: 0 4px;
width: 92%;
}
.comment-item:hover {
@@ -161,7 +168,8 @@
background: #f4f4f4;
border-inline-start: 2px solid #ccc;
padding: 4px 6px;
margin: 0 4px 6px 0;
margin-block: 0 6px;
margin-inline: 0 4px;
font-size: 0.85em;
color: #666;
display: flex;
+6 -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'}}")
@@ -66,6 +66,10 @@ template(name="comment")
if currentUser.isBoardMember
= ' - '
a.js-reply-comment {{_ "comment-reply"}}
//- Action items, Show on Card (docs/Features/Cards/Action-Items.md).
if actionItemsOf this
= ' - '
a.js-comment-action-items {{_ "action-items-create"}}
if canEditThisComment
+editOrDeleteComment
+2
View File
@@ -211,6 +211,8 @@ Template.comment.events({
'click .js-comment-permalink'() {
if (this._id) Session.set('revealCommentId', this._id);
},
// docs/Features/Cards/Action-Items.md
'click .js-comment-action-items': Popup.open('actionItems', { titleKey: 'action-items' }),
'click .js-copy-comment-link'(evt, tpl) {
evt.preventDefault();
const url = commentPermalinkFor(this);
@@ -7,7 +7,7 @@
//- the same `.sidebar-content` with a title and a back arrow above the view. So
//- it looks and behaves like the one on a board without pretending to be it.
//-
//- docs/Features/Page/Search.md, docs/Features/Page/Multi-Selection.md
//- docs/Features/Board-Header/Search.md, docs/Features/General/Multi-Selection.md
template(name="allBoardsSidebar")
//- The theme class goes on an ANCESTOR of `.sidebar`, never on it: every
@@ -42,7 +42,7 @@ template(name="allBoardsHomeSidebar")
what you are looking at, which belongs beside the page's name rather than
behind a panel you have to open. Search and Multi-Selection were in that bar
as well and are NOT repeated here: this view has offered both as rows since
it was written. docs/Features/Page/All-Boards.md
it was written. docs/Features/All-Boards/All-Boards.md
a.sidebar-btn.js-open-boards-sort(class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
i.fa.fa-sort
span {{_ 'sort-boards'}}
+6 -5
View File
@@ -25,10 +25,11 @@ import {
sidebarViewTemplate,
} from '/models/lib/allBoardsSidebar';
import { selectedStarTitleKey, selectedStarAction } from '/models/lib/selectedStars';
import { errorMessage } from '/client/lib/errorMessage';
// The All Boards right sidebar. Its shell mirrors the board sidebar's; what it
// shows is this page's own. docs/Features/Page/Search.md,
// docs/Features/Page/Multi-Selection.md
// shows is this page's own. docs/Features/Board-Header/Search.md,
// docs/Features/General/Multi-Selection.md
Template.allBoardsSidebar.helpers({
isSidebarOpen() {
@@ -99,7 +100,7 @@ Template.allBoardsHomeSidebar.events({
evt.preventDefault();
closeAllBoardsSidebar();
// The SECTION of this page, not the full-width page it replaced.
// docs/Features/Page/Archive.md
// docs/Features/All-Boards/Archive.md
FlowRouter.go(allBoardsPath(SECTION_ARCHIVE, []));
},
});
@@ -211,7 +212,7 @@ Template.allBoardsMultiSelectionSidebar.events({
if (!ids || !confirm(TAPi18n.__('delete-board-confirm-popup'))) return;
Meteor.call('permanentlyDeleteArchivedBoards', ids, (err) => {
if (err) {
alert(err.reason || err.message || 'Failed to permanently delete boards');
alert(errorMessage(err));
return;
}
BoardMultiSelection.reset();
@@ -243,7 +244,7 @@ Template.allBoardsMultiSelectionSidebar.events({
) {
selectedBoards.forEach((boardId) => {
Meteor.call('archiveBoard', boardId, (err) => {
if (err) alert(err?.reason || err?.message || 'Failed to archive board');
if (err) alert(errorMessage(err));
});
});
BoardMultiSelection.reset();
+1 -1
View File
@@ -3,7 +3,7 @@ import Boards from '/models/boards';
import { Utils } from '/client/lib/utils';
import { TABLE_PAGE_ROWS_PER_PAGE } from '/models/lib/tablePage';
// The app's one rows-per-page (docs/Features/Page/Table.md): the archive pages
// The app's one rows-per-page (docs/Features/General/Table.md): the archive pages
// like every other paginated page, whatever pager it draws.
const ARCHIVED_BOARDS_PER_PAGE = TABLE_PAGE_ROWS_PER_PAGE;
+2
View File
@@ -12,6 +12,8 @@ template(name="board")
//-- XXX We need a better error message in case the board has been archived
+message(label="board-not-found")
//-- | {{goHome}}
else if boardLoadError
+message(label="board-load-failed")
else
+spinner
+37 -61
View File
@@ -1,3 +1,4 @@
import { markBoardVisited } from '/client/lib/boardVisits';
import { ReactiveCache } from '/imports/reactiveCache';
import { setSortableOptions } from '/client/lib/sortableOptions';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
@@ -16,6 +17,7 @@ import { formatDateByUserPreference } from '/imports/lib/dateUtils';
import { calendarDateDisplayOptions } from '/client/lib/dateDisplay';
import { toFullCalendarFirstDay } from '/client/lib/calendarFirstDay';
import { weekNumberByFirstDay } from '/models/lib/weekStart';
import { calendarBaseOptions } from '/models/lib/calendarBaseOptions';
import Swimlanes from '/models/swimlanes';
import Lists from '/models/lists';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
@@ -38,6 +40,10 @@ let BoardBody = null;
Template.board.onCreated(function () {
this.isBoardReady = new ReactiveVar(false);
// The board publication stopped with an error: say so instead of spinning
// forever (a board restored from an old WeKan whose data the publication
// could not read showed only the loading animation).
this.boardLoadError = new ReactiveVar(false);
// Kept for the template/jade which still references it; conversion is a no-op.
this.isConverting = new ReactiveVar(false);
this._swimlaneCreated = new Set(); // boards where a default swimlane was ensured
@@ -231,6 +237,7 @@ Template.board.onCreated(function () {
if (this.subscriptionBoardId !== currentBoardId) {
this.subscriptionBoardId = currentBoardId;
this.isBoardReady.set(false);
this.boardLoadError.set(false);
}
if (!currentBoardId) return;
@@ -239,6 +246,14 @@ Template.board.onCreated(function () {
currentBoardId,
false,
subscriptionGeneration,
{
onStop: (error) => {
if (error && this.subscriptionBoardId === currentBoardId) {
console.error('[board] the board publication stopped:', error.reason || error.message || error.error);
this.boardLoadError.set(true);
}
},
},
);
// Learn this board's card-loading mode (lazy vs eager) so 'auto' can decide
// per board by size; the flag drives isLazyCards(boardId). #6480.
@@ -301,6 +316,9 @@ Template.board.helpers({
return Template.instance().isConverting.get();
},
boardLoadError() {
return Template.instance().boardLoadError.get();
},
isBoardReady() {
return Template.instance().isBoardReady.get();
},
@@ -323,6 +341,12 @@ Template.boardBody.onCreated(function () {
// Store global reference for external access (swimlanes, cardDetails, etc.)
BoardBody = this;
// Remember this visit, once per board per page load, for Board menu ->
// Since your last visit (client/lib/boardVisits.js).
this.autorun(() => {
markBoardVisited(Session.get('currentBoard'));
});
// Methods on the template instance for programmatic access
this.setIsDragging = (bool) => {
this.draggingActive.set(bool);
@@ -550,7 +574,7 @@ Template.boardBody.onRendered(function () {
outline: none;
}
/* Sidebar hamburger menu button in header. Renamed with the button when
it moved to the first top header bar: docs/Features/Page/Header.md */
it moved to the first top header bar: docs/Features/Header-Bar/Header.md */
.js-toggle-page-sidebar .fa-bars {
color: #fff !important;
}
@@ -1215,82 +1239,34 @@ Template.calendarView.onRendered(function () {
Template.calendarView.helpers({
calendarOptions() {
const t = (key, fallback) => {
const translated = TAPi18n.__(key);
return translated && translated !== key ? translated : fallback;
};
// #5521: respect the user's "start day of week" setting instead of the
// FullCalendar locale default. getStartDayOfWeek() returns a zero-based day
// index (0=Sunday..6=Saturday), the same convention FullCalendar's firstDay
// uses; toFullCalendarFirstDay() validates it and falls back to Monday (1).
// #5521: getStartDayOfWeek() returns a zero-based day index
// (0=Sunday..6=Saturday), the convention FullCalendar's firstDay uses;
// toFullCalendarFirstDay() validates it and falls back to Monday (1).
const currentUser = ReactiveCache.getCurrentUser();
const firstDay = toFullCalendarFirstDay(
currentUser ? currentUser.getStartDayOfWeek() : 1,
);
const displayOptions = calendarDateDisplayOptions('calendar-view');
return {
id: 'calendar-view',
...displayOptions,
initialView: displayOptions.initialView || 'dayGridMonth',
firstDay,
...calendarBaseOptions({
id: 'calendar-view',
displayOptions: calendarDateDisplayOptions('calendar-view'),
firstDay,
weekNumberOf: weekNumberByFirstDay,
translate: key => TAPi18n.__(key),
locale: TAPi18n.getLanguage(),
rtl: TAPi18n.isRTL(),
}),
editable: true,
selectable: true,
weekNumbers: true,
// #4946: FullCalendar's default weekNumberCalculation is locale-based
// (Sunday-anchored for many locales), so the week-number column ignored
// the user's start-day-of-week setting even though `firstDay` moved the
// grid. Compute the number from the SAME firstDay so the label matches the
// way the week is laid out.
weekNumberCalculation: date => weekNumberByFirstDay(date, firstDay),
// Use non-localized AM/PM time format to avoid confusing notations like 上/下/中
// Use full 'am'/'pm' instead of single-letter 'a'/'p' for clarity
eventTimeFormat: {
hour: 'numeric',
minute: '2-digit',
meridiem: 'short',
},
slotLabelFormat: {
hour: 'numeric',
minute: '2-digit',
meridiem: 'short',
},
// #6686: Today/Previous/Next used to sit in their own group under the
// title, with the Day/Week/Month view toggles in a third, CENTER group
// that pushed everything onto a second row. All the buttons now sit
// together in ONE group on the right side of the title, which stays
// alone on the left - see calendarView.css for the title's vertical
// centering against that single row.
headerToolbar: {
left: 'title',
center: '',
right:
'today prev,next timeGridDay,listDay timeGridWeek,listWeek dayGridMonth,listMonth',
},
buttonIcons: false,
buttonText: {
prev: t('previous', 'Previous'),
next: t('next', 'Next'),
today: t('today', 'Today'),
day: t('day', 'Day'),
week: t('week', 'Week'),
month: t('month', 'Month'),
list: t('list', 'List'),
},
// height: 'parent', nope, doesn't work as the parent might be small
height: 'auto',
/* TODO: lists as resources: https://fullcalendar.io/docs/vertical-resource-view */
navLinks: true,
nowIndicator: true,
businessHours: {
// days of week. an array of zero-based day of week integers (0=Sunday)
daysOfWeek: [1, 2, 3, 4, 5], // Monday - Friday
start: '8:00',
end: '18:00',
},
locale: TAPi18n.getLanguage(),
direction: TAPi18n.isRTL() ? 'rtl' : 'ltr',
events(fetchInfo, callback) {
const currentBoard = Utils.getCurrentBoard();
// #5656: apply the active board Filter (member / assignee / due-date /
File diff suppressed because it is too large. Load diff
-65
View File
@@ -642,71 +642,6 @@
transform: none !important;
}
/* Force mobile mode to override any media query styles */
@media screen and (min-width: 801px) {
.mobile-mode .board-canvas {
display: block !important;
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
.mobile-mode .swimlane {
display: block !important;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
float: none !important;
clear: both !important;
}
.mobile-mode .swimlane .lists {
display: block !important;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
}
.mobile-mode .list {
display: block !important;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
float: none !important;
clear: both !important;
border-inline-start: none !important;
border-inline-end: none !important;
border-top: none !important;
border-bottom: 2px solid #ccc !important;
flex: none !important;
flex-basis: auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
position: static !important;
inset-inline-start: auto !important;
inset-inline-end: auto !important;
top: auto !important;
bottom: auto !important;
transform: none !important;
}
}
/* Hide desktop-only elements in mobile mode (like mobile media queries do) */
.mobile-mode .board-header-btn i.fa + span {
display: none !important;
+6 -11
View File
@@ -11,7 +11,7 @@
//-
//- There is no desktop/mobile split any more either. The second bar wrote the
//- whole set out TWICE, once for each, because a labelled row does not fit on a
//- phone; icons do, so one set serves both. docs/Features/Page/Header.md
//- phone; icons do, so one set serves both. docs/Features/Header-Bar/Header.md
template(name="boardHeaderButtons")
if currentBoard
if currentUser
@@ -50,7 +50,7 @@ template(name="boardHeaderButtons")
//- The name beside the icon where the bar has room for it, dropped
when it does not - one rule in header.css hides every label of this
bar below the width they stop fitting at.
docs/Features/Page/Header.md
docs/Features/Header-Bar/Header.md
a.board-header-btn(title="{{#if isSortActive}}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}" class="js-sort-cards {{#if isSortActive }}emphasis{{/if}}")
i.fa.fa-sort
span.board-header-btn-label {{#if isSortActive}}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
@@ -171,7 +171,7 @@ template(name="boardChangeWatchPopup")
(models/lib/boardViewSettings.js boardViewMenuEntries), so the popup and
the menu can not drift apart. Each anchor keeps its per-view class
(js-open-<view>-view), which is what the click handlers in boardHeader.js
listen for. docs/Features/Board/Board-View-Settings.md
listen for. docs/Features/Board-Sidebar/Board-Settings/Board-View.md
template(name="boardChangeViewPopup")
ul.pop-over-list
each boardViewMenuEntries
@@ -280,11 +280,6 @@ template(name="boardChangeTitlePopup")
p.error {{_ boardKeyError}}
input.primary.wide(type="submit" value="{{_ 'rename'}}")
template(name="boardCreateRulePopup")
p {{_ 'close-board-pop'}}
button.js-confirm.negate.full(type="submit") {{_ 'archive'}}
template(name="cardsSortPopup")
ul.pop-over-list
li
@@ -326,7 +321,7 @@ template(name="cardsSortPopup")
//- always on screen - the same place the All Boards Lists/Table menu is.
//-
//- `unless currentBoard.isTemplatesBoard`: there is nothing to look at six
//- ways on a templates board. docs/Features/Page/Header.md
//- ways on a templates board. docs/Features/Header-Bar/Header.md
//- Whether THIS board is starred, and the button that changes it. Its own
//- template because the first header bar places it beside the starred-boards
//- dropdown rather than among the board's other controls: the two are one pair -
@@ -334,7 +329,7 @@ template(name="cardsSortPopup")
//-
//- The outline star means not starred and the solid one means starred, so the
//- button shows its state rather than the action - the tooltip says the action.
//- docs/Features/Page/Header.md
//- docs/Features/Header-Bar/Header.md
template(name="boardStarButton")
if currentBoard
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
@@ -349,7 +344,7 @@ template(name="boardViewMenu")
icons are six glyphs to learn, and a tooltip is the one place a name
cannot be read without hovering - so the button says which view is on.
The bar wraps to a second row when it runs out of width, which is what
makes a word beside the icon affordable. docs/Features/Page/Header.md
makes a word beside the icon affordable. docs/Features/Header-Bar/Header.md
a.board-header-btn.js-toggle-board-view(title="{{boardViewName}}")
i.fa.fa-caret-down
if $eq boardView 'board-view-swimlanes'
+10 -7
View File
@@ -68,7 +68,7 @@ Template.boardChangeTitlePopup.events({
if (newTitle) {
// The board this popup was opened FOR, when it was opened for one: the
// All Boards Table view opens this same popup from a row whose data
// context is that row's board (docs/Features/Page/All-Boards.md), and on
// context is that row's board (docs/Features/All-Boards/All-Boards.md), and on
// that page there is no "current board" at all. The board header opens it
// with the current board as its context, so that side is unchanged.
const context = Template.currentData();
@@ -223,7 +223,7 @@ Template.boardHeaderButtons.events({
'click .js-watch-board': Popup.open('boardChangeWatch'),
// Boards in Archive is a SECTION of All Boards, not a page of its own: the
// row in its left menu. `/archive` is the full-width page that section
// replaced. docs/Features/Page/Archive.md
// replaced. docs/Features/All-Boards/Archive.md
'click .js-open-archived-board'() {
FlowRouter.go(allBoardsPath(SECTION_ARCHIVE, []));
},
@@ -288,7 +288,7 @@ Template.boardHeaderButtons.events({
},
});
// Board Settings / Board View (docs/Features/Board/Board-View-Settings.md):
// Board Settings / Board View (docs/Features/Board-Sidebar/Board-Settings/Board-View.md):
// a view the board admin un-ticked for this board's visibility (public or
// private) is not offered. Reads the board reactively, so switching the
// board Private <-> Public swaps the menu on the spot.
@@ -692,13 +692,16 @@ Template.boardVisibilityList.helpers({
// Settings / Visibility (Settings.customPrivateBoardDesc /
// customPublicBoardDesc); empty/unset falls back to the i18n default,
// byte-identical to before this setting existed.
// TAPi18n.__ reads `this` (its reactive language dependency), so it is
// passed bound: handed over bare it threw inside the helper and both
// descriptions rendered as an empty span in every language.
privateDesc() {
const setting = ReactiveCache.getCurrentSetting();
return visibilityDesc(setting?.customPrivateBoardDesc, TAPi18n.__, 'private-desc');
return visibilityDesc(setting?.customPrivateBoardDesc, key => TAPi18n.__(key), 'private-desc');
},
publicDesc() {
const setting = ReactiveCache.getCurrentSetting();
return visibilityDesc(setting?.customPublicBoardDesc, TAPi18n.__, 'public-desc');
return visibilityDesc(setting?.customPublicBoardDesc, key => TAPi18n.__(key), 'public-desc');
},
});
@@ -920,7 +923,7 @@ Template.cardsSortPopup.events({
// The board's view menu is its own template in the FIRST header bar now, so its
// handler is here rather than on the second bar that used to draw it.
// docs/Features/Page/Header.md
// docs/Features/Header-Bar/Header.md
Template.boardViewMenu.events({
'click .js-toggle-board-view': Popup.open('boardChangeView'),
});
@@ -930,7 +933,7 @@ Template.boardViewMenu.helpers({
return Utils.boardView();
},
// The tooltip: the name of the view that is ON. The button is icon only, so
// this is the only place the name appears. docs/Features/Page/Header.md
// this is the only place the name appears. docs/Features/Header-Bar/Header.md
boardViewName() {
const names = {
'board-view-swimlanes': 'swimlanes',
+23 -23
View File
@@ -45,7 +45,7 @@
/* `auto`, not a fixed 260px: the menu carries its own width (below), so the
track is whatever the menu is - and folded, when the grid drops to a single
column, there is no second track left holding a gap open.
docs/Features/Page/Left-Menu.md */
docs/Features/All-Boards/Left-Menu.md */
grid-template-columns: auto 1fr;
/* Between the columns only. A `gap` is between tracks, not around them, so
this never insets the menu from the window edge - the padding that used to
@@ -64,12 +64,12 @@
min-height: calc(100vh - var(--wekan-header-height, 0px));
/* The width grip is positioned against the GRID: absolutely, so it is no grid
item and adds no track, and outside the menu, which scrolls its own
entries. docs/Features/Page/Left-Menu.md */
entries. docs/Features/All-Boards/Left-Menu.md */
position: relative;
}
/* The left menu is styled like the Admin Panel's (settingBody.css,
docs/Features/Page/Left-Menu.md): a PANEL with its own background, border and
docs/Features/All-Boards/Left-Menu.md): a PANEL with its own background, border and
rounded corners, not a column separated from the page by a hairline. WeKan
has one kind of left menu and it should look like one kind of left menu.
@@ -101,7 +101,7 @@
/* Flush on three sides, like the Admin Panel's: it starts at the window's
left edge, directly under the header, and runs to the bottom - so it has a
border and a corner only on the side that faces the page. A rounded corner
on a flush edge shows the grey behind it. docs/Features/Page/Left-Menu.md */
on a flush edge shows the grey behind it. docs/Features/All-Boards/Left-Menu.md */
border: 1px solid #f0f0f0;
border-inline-start: 0;
border-top: 0;
@@ -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
@@ -212,10 +212,10 @@
rather than being a grey box in a coloured page. `--theme-accent` is set by
the applied theme (boardColors.css); the fallback is the default header blue,
for the themes that predate the variable.
docs/Features/Board/Starred.md */
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;
@@ -277,7 +277,7 @@
the board area so it cannot be missed, and turns red under the icon. Red at
REST would be a standing warning about a board nobody is touching; red on
hover is the answer to "what happens if I let go here".
docs/Features/Board/Home.md */
docs/Features/All-Boards/Home.md */
.home-remove-target {
display: flex;
align-items: center;
@@ -312,7 +312,7 @@
/* An empty Home section: the row exists before there is a board at it, so the
pane says what to drag rather than showing nothing at all. Sized and spaced
like a board tile so it sits where the first tile would.
docs/Features/Board/Home.md */
docs/Features/All-Boards/Home.md */
/* Home's "Drag only one board here to open it after login" placeholder.
It stands where a board tile will be, so it is the SIZE of a board tile:
114px like `.board-list .board-list-item` and `.js-add-board .label`, with
@@ -377,7 +377,7 @@
background: #888;
/* The rows' own inline margin, so the rule lines up with them instead of
running past their ends - the same `4px` the Admin Panel's separator uses.
docs/Features/Page/Left-Menu.md */
docs/Features/All-Boards/Left-Menu.md */
margin: 8px 4px;
}
@@ -417,7 +417,7 @@
whatever has been dragged into whatever.
`padding-inline-start`, so a right-to-left language indents from the right by
itself rather than needing a second rule kept in step.
docs/Features/Page/Workspaces.md */
docs/Features/All-Boards/Workspaces.md */
.workspace-tree {
list-style: none;
padding-inline-start: 10px;
@@ -466,7 +466,7 @@
drop-inside - the row lights up and the slot opens INDENTED under it, which
is where the workspace will appear: as its child.
docs/Features/Page/Workspaces.md */
docs/Features/All-Boards/Workspaces.md */
.workspace-node.drop-before > .workspace-node-content,
.workspace-node.drop-after > .workspace-node-content,
.workspace-node.drop-inside > .workspace-node-content {
@@ -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;
@@ -567,7 +567,7 @@
The cursor stays `pointer`, not `grab`: clicking this row is what opens the
workspace, and that is what a reader does with it nearly every time. Dragging
is the second gesture on the same element, exactly as it is on a board tile
with handles off. docs/Features/Page/All-Boards.md */
with handles off. docs/Features/All-Boards/All-Boards.md */
.workspace-node .js-select-space[draggable="true"] {
user-select: none;
-webkit-user-select: none;
@@ -603,7 +603,7 @@
`flex: 1`, and without this a long workspace name shrinks its two neighbours
instead of ellipsing itself, which pushes the count off the row that is
supposed to be showing it. The phone rules said this already; the desktop
ones had not. docs/Features/Page/All-Boards.md */
ones had not. docs/Features/All-Boards/All-Boards.md */
.workspace-node .workspace-count {
background: #ddd;
padding: 2px 6px;
@@ -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 {
@@ -714,7 +714,7 @@
which boards are in it.
So it keeps the same grey pill every other row has, and the rule that made it
white is gone rather than re-tinted: there is no accent behind it to contrast
with. docs/Features/Page/All-Boards.md */
with. docs/Features/All-Boards/All-Boards.md */
.boards-right-grid {
min-height: 200px;
@@ -735,7 +735,7 @@
out of the grid, but the TRACK it was placed in stays, so the boards would
have been left in the `auto` column with the 1fr one empty beside them. One
column, and the menu hidden by the same rule.
docs/Features/Page/Left-Menu.md */
docs/Features/All-Boards/Left-Menu.md */
.boards-layout.collapsed {
/* `!important` because the phone rules below set the two tracks with an
`!important` of their own (#6488/#6523, so a narrow screen keeps the menu
@@ -758,7 +758,7 @@
this the title would sit directly on top of the first row of board tiles.
The board tiles carry a 7px margin of their own, so 12px of margin here plus
that 7px is the same gap the Admin Panel's 12px gives above a flush pane.
docs/Features/Page/All-Boards.md */
docs/Features/All-Boards/All-Boards.md */
.boards-right-grid > .admin-pane-title {
margin-bottom: 12px;
/* The pager and the tiles below start at the tiles' own 7px inset, so the
@@ -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;
@@ -2232,7 +2232,7 @@ body.has-theme-board-tiles .board-list > li.js-board .board-list-card-count {
}
/* All Boards: the Table view.
* Design: docs/Features/Page/All-Boards.md, which is docs/Features/Page/Table.md.
* Design: docs/Features/All-Boards/All-Boards.md, which is docs/Features/General/Table-Page.md.
* Only what is particular to these rows; the table itself is tablePage.css. */
.all-boards-row {
cursor: default;
+14 -14
View File
@@ -10,7 +10,7 @@ template(name="boardList")
a 260px-wide nothing beside them. The grid drops to one column and hides
the menu in the same rule. The Admin Panel marks its menu instead, because
a flex row with a hidden item simply closes up.
docs/Features/Page/Left-Menu.md
docs/Features/All-Boards/Left-Menu.md
.boards-layout(class="{{#if isLeftMenuCollapsed}}collapsed{{/if}}")
// Left menu
.boards-left-menu
@@ -18,7 +18,7 @@ template(name="boardList")
menus cannot end up with two different carets, and the same control a
list has on a board. Folded, the menu is not drawn at all and the
caret that brings it back rides on the pane title below.
docs/Features/Page/Left-Menu.md
docs/Features/All-Boards/Left-Menu.md
+leftMenuCollapse
ul.menu
//- ONE row, rendered four times, rather than four copies of the same
@@ -110,21 +110,21 @@ template(name="boardList")
child of the GRID and not of the menu: it is positioned absolutely, so it
is no grid item and adds no track, and it does not scroll away with a
long list of workspaces the way a handle inside the menu would.
docs/Features/Page/Left-Menu.md
docs/Features/All-Boards/Left-Menu.md
+leftMenuResize
// Right boards grid
//- There is NO bar above the boards, and no second header bar either.
Every control of this page - Sort, Search, Multi-Selection, the view
menu and the actions on a selection - is in the right sidebar, and the
page is named by the FIRST header bar. docs/Features/Page/All-Boards.md
page is named by the FIRST header bar. docs/Features/All-Boards/All-Boards.md
.boards-right-grid
//- The heading of the pane: which list of boards this is. It sits above
the view branch, so it is drawn ONCE whichever view is on - the board
icons and the Table are two ways of showing the same section, not two
sections.
The Admin Panel's own `paneTitle`, so the two pages have one heading
at one size and colour. docs/Features/Page/All-Boards.md
at one size and colour. docs/Features/All-Boards/All-Boards.md
+paneTitle(allBoardsPaneTitle)
if showsBoardSelectionControls
.board-multiselection-controls
@@ -135,7 +135,7 @@ template(name="boardList")
board ICONS every other section uses. It had a table of its own, which
meant the one place you go to bring a board back was the one place
multi-selection, dragging and the tile layout did not work.
docs/Features/Page/Archive.md
docs/Features/All-Boards/Archive.md
if isAllBoardsView 'table'
//- The create action is supplied to tablePage with its other controls,
rather than occupying an otherwise empty row of its own. Archive and
@@ -219,7 +219,7 @@ template(name="boardList")
screen when an icon is picked up and is not there the rest of the
time. Dropping a board here takes it off Home; the board itself
stays where it lives, the way an app stays in the drawer.
docs/Features/Board/Home.md
docs/Features/All-Boards/Home.md
if showsHomeRemoveTarget
li.home-remove-target.js-home-remove
i.fa.fa-trash
@@ -258,7 +258,7 @@ template(name="boardList")
and the URL says what it is, which is what a bookmark bar shows.
Dragging one past another reorders them, and that order is the
order of the header dropdown - the two are views of one array.
docs/Features/Board/Starred.md
docs/Features/Pages/Starred.md
if isSelectedMenu 'starred'
each starredPages
li.js-bookmark.nodragscroll.board-list-item-bookmark(data-url="{{url}}", draggable="true")
@@ -507,7 +507,7 @@ template(name="workspaceTree")
NOT the whole row either: the row also holds the ⋯ menu and the board
count, and a drag started on those is a drag of something else.
`dragstart` bubbles, so the handler stays on the row in both cases.
docs/Features/Page/All-Boards.md
docs/Features/All-Boards/All-Boards.md
li.workspace-node(class="{{#if $eq id ../selectedWorkspaceId}}active{{/if}}" data-workspace-id="{{id}}")
.workspace-node-content
//- The caret that folds this workspace's own workspaces away, at
@@ -518,7 +518,7 @@ template(name="workspaceTree")
A workspace with no workspaces under it has nothing to fold, so it
gets a spacer of the same width instead: a row that shifted
sideways when it gained a child would be a moving target.
docs/Features/Page/Workspaces.md
docs/Features/All-Boards/Workspaces.md
if workspaceHasChildren
a.workspace-collapse-indicator.js-collapse-workspace(
title="{{workspaceCollapseLabel}}"
@@ -561,7 +561,7 @@ template(name="workspaceTree")
`..` counts block levels, and a second block between the `each` and
this inclusion is another level for `../selectedWorkspaceId` to
climb - which is how the highlight was lost once already.
docs/Features/Page/Workspaces.md
docs/Features/All-Boards/Workspaces.md
if workspaceShowsChildren
+workspaceTree(nodes=children selectedWorkspaceId=../selectedWorkspaceId)
@@ -602,7 +602,7 @@ template(name="workspaceActionsPopup")
//-
//- It names the CURRENT view, not itself: the board header says "Swimlanes" or
//- "Lists" rather than "Board View", and this matches it.
//- docs/Features/Page/Header.md
//- docs/Features/Header-Bar/Header.md
//- The page's controls, as icons, in the first top header bar - left of the
//- bell, exactly where a board's own controls are. They were rows inside the
//- right sidebar's home view, which meant two clicks and a panel covering the
@@ -612,7 +612,7 @@ template(name="workspaceActionsPopup")
//-
//- Each names itself beside its icon where the bar has room, and the one rule
//- in header.css drops every label below 1100px, leaving the tooltips.
//- docs/Features/Page/All-Boards.md
//- docs/Features/All-Boards/All-Boards.md
template(name="allBoardsHeaderButtons")
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
i.fa.fa-sort
@@ -631,7 +631,7 @@ template(name="allBoardsHeaderButtons")
board's own Multi-Selection has. It looked identical whether or not a
selection was running, so the one control that changes what a click on a
board does gave no sign it had changed it, and the only way out was to
find the row inside the sidebar. docs/Features/Page/Multi-Selection.md
find the row inside the sidebar. docs/Features/General/Multi-Selection.md
a.board-header-btn.js-all-boards-sidebar-multiselection(
title="{{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
class="{{#if BoardMultiSelection.isActive}}emphasis{{/if}}")
+35 -34
View File
@@ -10,7 +10,7 @@ import getSlug from 'limax';
// The archived-at line on a tile in the Archive, in the reader's own format.
import { boardCreationAllowed } from '/client/lib/boardCreationAllowed';
// The All Boards URLs, and the slug path of a workspace in the tree.
// docs/Features/Page/All-Boards-URLs.md
// docs/Features/All-Boards/URLs.md
import {
ALL_BOARDS_SECTIONS,
defaultSection,
@@ -43,7 +43,7 @@ import { Utils } from '/client/lib/utils';
import '/client/lib/dragDropTouch'; // touch -> HTML5 DnD so board icons drag by finger
// What a drag does to the workspaces tree - move before, after, or INTO another
// workspace - as pure functions, so the rules are testable without a browser.
// docs/Features/Page/Workspaces.md
// docs/Features/All-Boards/Workspaces.md
const {
BEFORE: DROP_BEFORE,
AFTER: DROP_AFTER,
@@ -59,7 +59,7 @@ import {
} from '/models/lib/boardSortReorder';
// The sidebar the Search and Multi-Selection controls open. Its state is module
// scope, not a template instance: this bar and the sidebar are separate Blaze
// instances. docs/Features/Page/Search.md, docs/Features/Page/Multi-Selection.md
// instances. docs/Features/Board-Header/Search.md, docs/Features/General/Multi-Selection.md
import {
openAllBoardsSidebar,
closeAllBoardsSidebar,
@@ -70,6 +70,7 @@ import {
SIDEBAR_SEARCH,
SIDEBAR_MULTISELECTION,
} from '/models/lib/allBoardsSidebar';
import { errorMessage } from '/client/lib/errorMessage';
// SubsManager removed for Meteor 3 migration
@@ -228,7 +229,7 @@ function hasStarredBoards() {
// board has always been stored in (#2220), which is what "opened after login"
// reads on its way past. Home is a VIEW of that one field, not a second copy of
// it, so setting a Home board from the menu and setting it from Multi-Selection
// cannot disagree. docs/Features/Board/Home.md
// cannot disagree. docs/Features/All-Boards/Home.md
function homeBoardId() {
const user = ReactiveCache.getCurrentUser();
return (user && user.profile && user.profile.defaultBoardId) || null;
@@ -245,7 +246,7 @@ function homeBoardId() {
// dragover is where it matters. Dragging a board out of Home may only end at
// the Trash - the one gesture that takes it off Home - so every other target
// has to REFUSE the drop while it is still in the air, which means answering
// before the drop happens. docs/Features/Board/Home.md
// before the drop happens. docs/Features/All-Boards/Home.md
const DRAG_FROM_HOME = 'application/x-board-from-home';
const DRAG_FROM_REMAINING = 'application/x-board-from-remaining';
const DRAG_FROM_WORKSPACE = 'application/x-board-from-workspace';
@@ -254,7 +255,7 @@ const ARCHIVED_MULTI_BOARD_DRAG = 'application/x-archived-board-multi';
// Reordering a bookmark carries its own type, so a bookmark and a board cannot
// be dropped on each other. Readable in `dragover` for the same reason
// DRAG_FROM_HOME is - a target has to decide whether to accept the drop before
// the drop happens. docs/Features/Board/Starred.md
// the drop happens. docs/Features/Pages/Starred.md
const BOOKMARK_DRAG = 'application/x-wekan-bookmark';
function isBookmarkDrag(evt) {
@@ -380,7 +381,7 @@ Template.boardList.helpers({
},
// Whether to move the boards left, out from under the right sidebar. The
// sidebar is a separate Blaze instance, so the state is module scope - the
// page cannot read a ReactiveVar on it. docs/Features/Page/Search.md
// page cannot read a ReactiveVar on it. docs/Features/Board-Header/Search.md
isSidebarOpen() {
return isAllBoardsSidebarOpen();
},
@@ -401,11 +402,11 @@ Template.boardList.helpers({
// map catches events from the templates rendered inside it, so these fire for
// this bar's copy and the board header's map fires for its own. That is what
// lets one piece of markup mean "search cards" on a board and "search boards"
// here. docs/Features/Page/Search.md, docs/Features/Page/Multi-Selection.md
// here. docs/Features/Board-Header/Search.md, docs/Features/General/Multi-Selection.md
// Sort and the view menu, which were buttons in this page's second header bar
// and are rows of the sidebar's home view now. A Blaze event map only sees
// events inside its OWN template, so they had to move with their markup.
// docs/Features/Page/All-Boards.md
// docs/Features/All-Boards/All-Boards.md
Template.allBoardsHomeSidebar.events({
// Titled "Sort Boards", from the key the app already has for that phrase -
// the same reasoning as the starred-boards popup: a `boardsSortPopup-title`
@@ -433,7 +434,7 @@ Template.allBoardsHomeSidebar.helpers({
// A Blaze event map only sees events inside its OWN template, so these are
// their own map rather than shared with the sidebar's: the same
// `js-all-boards-sidebar-search` markup exists in both places and each map
// fires for its own copy. docs/Features/Page/All-Boards.md
// fires for its own copy. docs/Features/All-Boards/All-Boards.md
Template.allBoardsHeaderButtons.helpers({
// Every template registers the helpers IT uses. `BoardMultiSelection` is also
// a helper of boardList, but a Blaze template cannot see a sibling's helpers -
@@ -495,7 +496,7 @@ Template.boardList.events({});
// Put the selected menu entry in the address bar. A section is its own name; a
// workspace is the slugs of its names down the tree, so the URL says where you
// are rather than carrying a random id. `go`, not `replace`, so Back returns to
// the previous entry. docs/Features/Page/All-Boards-URLs.md
// the previous entry. docs/Features/All-Boards/URLs.md
function goToAllBoards(tpl, menuValue) {
const path = allBoardsPathForMenu(menuValue, tpl.workspacesTreeVar.get(), getSlug);
if (path && FlowRouter.current().path !== path) FlowRouter.go(path);
@@ -504,7 +505,7 @@ function goToAllBoards(tpl, menuValue) {
// The boards the page shows: the selected section, filtered by the search
// field, sorted and paged. Extracted from the `boards` helper so the Table
// view draws the SAME set - two copies of this would be two answers to
// "which boards am I looking at". docs/Features/Page/All-Boards.md
// "which boards am I looking at". docs/Features/All-Boards/All-Boards.md
// How many archived boards the section shows at once. The publication is
// paginated and this is the page size it is asked for; the Archive is a place
// you go to find one board, not a list to scroll for ever.
@@ -512,7 +513,7 @@ const ARCHIVED_BOARDS_LIMIT = 60;
function boardsForView(tpl) {
// The Archive shows ARCHIVED boards - the one section whose query is the
// opposite of every other one's. docs/Features/Page/Archive.md
// opposite of every other one's. docs/Features/All-Boards/Archive.md
const showsArchive = tpl.selectedMenu.get() === 'archive';
let query = {
$and: [
@@ -725,7 +726,7 @@ Template.boardList.onCreated(function () {
// Shared with the right sidebar, which is a SEPARATE Blaze instance (it is
// rendered beside the page, not inside it) and carries this page's controls. Assigned onto the instance so every `tpl.selectedMenu` /
// `tpl.boardSearchVar` already written here keeps working unchanged.
// docs/Features/Page/All-Boards.md
// docs/Features/All-Boards/All-Boards.md
this.selectedMenu = allBoardsMenuVar;
// Whatever the address named, else the section this user should land on:
// Starred when anything is starred, Remaining when nothing is - opening on an
@@ -753,7 +754,7 @@ Template.boardList.onCreated(function () {
// It runs whenever either changes, so a link followed while the page is
// already open switches workspace, and a slug path that names nothing leaves
// the Workspaces section selected rather than an empty board list.
// docs/Features/Page/All-Boards-URLs.md
// docs/Features/All-Boards/URLs.md
this.autorun(() => {
const slugPath = Session.get('boardListWorkspacePath') || [];
const tree = this.workspacesTreeVar.get();
@@ -842,7 +843,7 @@ Template.boardList.onCreated(function () {
// A workspace was dropped: before, after, or INTO another one. The tree it
// becomes is worked out by the pure module - the guards that keep a subtree
// attached to the root live there, with their own tests - and this only saves
// the answer. docs/Features/Page/Workspaces.md
// the answer. docs/Features/All-Boards/Workspaces.md
this.moveWorkspaceInTree = (draggedId, targetId, position) => {
const tree = this.workspacesTreeVar.get();
// A drop that puts a workspace back where it already is writes the same tree
@@ -1049,7 +1050,7 @@ Template.boardList.helpers({
// The Table view of the same boards the Lists view draws. Ten per page, the
// shared TABLE_PAGE_ROWS_PER_PAGE, and a rowTemplate because the Edit cell is a
// control and the row carries the board's colours.
// docs/Features/Page/All-Boards.md
// docs/Features/All-Boards/All-Boards.md
tablePageData() {
const tpl = Template.instance();
const all = boardsForView(tpl);
@@ -1214,7 +1215,7 @@ Template.boardList.helpers({
extraClass: 'js-open-archived-board' },
// The Home row is also a drop target of its own - dropping a board on it
// makes that board the one that opens after login - so it carries a
// second class like the Archive does. docs/Features/Board/Home.md
// second class like the Archive does. docs/Features/All-Boards/Home.md
home: { icon: 'fa-home', labelKey: 'home', extraClass: 'js-home-menu' },
};
return menuSectionOrder(hasStarredBoards()).map(type => ({
@@ -1232,7 +1233,7 @@ Template.boardList.helpers({
// colour rather than two that drift apart. Its words are the section's own
// title key - the same key the first header bar names the page with, and the
// same one the highlighted menu row carries - so all three say the same
// thing. docs/Features/Page/All-Boards.md
// thing. docs/Features/All-Boards/All-Boards.md
//
// `{ titleKey }` for a section, `{ label }` for a workspace: a workspace's
// name is what somebody typed, and a workspace called "starred" is not the
@@ -1253,14 +1254,14 @@ Template.boardList.helpers({
// The bookmarks, drawn as tiles beside the starred boards. Only in Starred:
// that is the list of places you keep, and a bookmark is one of them.
// docs/Features/Board/Starred.md
// docs/Features/Pages/Starred.md
starredPages() {
const user = ReactiveCache.getCurrentUser();
return user && user.starredPages ? user.starredPages() : [];
},
// Is the Android-launcher Remove bar showing? Only in Home, and only while a
// board from Home is actually in the air. docs/Features/Board/Home.md
// board from Home is actually in the air. docs/Features/All-Boards/Home.md
showsHomeRemoveTarget() {
return Template.instance().selectedMenu.get() === 'home'
&& draggingFromHome.get();
@@ -1382,7 +1383,7 @@ Template.boardList.helpers({
// it, or light the row up to say "into this one". The class is also what the
// drop reads back: the pointer's position is worked out once, while the row is
// showing it, rather than a second time from a drop event that may land a pixel
// away from where the slot was drawn. docs/Features/Page/Workspaces.md
// away from where the slot was drawn. docs/Features/All-Boards/Workspaces.md
const WORKSPACE_DROP_CLASSES = {
[DROP_BEFORE]: 'drop-before',
[DROP_INSIDE]: 'drop-inside',
@@ -1440,7 +1441,7 @@ Template.workspaceTree.helpers({
// The caret, and what it says. `Template.currentData()` is the NODE inside the
// `each`, which is what makes these read the row they are drawn on rather than
// needing the id passed to each of them.
// docs/Features/Page/Workspaces.md
// docs/Features/All-Boards/Workspaces.md
workspaceHasChildren() {
return hasChildren(Template.currentData());
},
@@ -1596,7 +1597,7 @@ Template.boardList.events({
tpl.$('.js-group-member-username').val('');
tpl.loadGroupMembers();
} catch (error) {
alert(error?.reason || error?.message || 'Could not add the member.');
alert(errorMessage(error));
}
},
async 'click .js-remove-group-member'(evt, tpl) {
@@ -1629,7 +1630,7 @@ Template.boardList.events({
tpl.$('.js-board-label-text').val('');
tpl.loadBoardFacets();
} catch (error) {
alert(error?.reason || error?.message || 'Could not label the boards.');
alert(errorMessage(error));
}
},
async 'click .js-remove-board-label'(evt, tpl) {
@@ -2008,7 +2009,7 @@ Template.boardList.events({
if (confirm(TAPi18n.__('archive-board-confirm'))) {
const boardId = this._id;
Meteor.call('archiveBoard', boardId, (err) => {
if (err) alert(err?.reason || err?.message || 'Failed to archive board');
if (err) alert(errorMessage(err));
});
evt.preventDefault();
}
@@ -2144,7 +2145,7 @@ Template.boardList.events({
// the drop handler below reads as "a board was dropped here", so the drop
// did nothing and the row snapped back. Handles ON started the drag from a
// span, which carries no text, which is why the same drop worked there.
// docs/Features/Page/Workspaces.md
// docs/Features/All-Boards/Workspaces.md
if (typeof dt.clearData === 'function') dt.clearData();
dt.setData('application/x-workspace-id', workspaceId);
@@ -2260,7 +2261,7 @@ Template.boardList.events({
// A board picked up in Home may only be dropped on the remove target. NOT
// calling preventDefault() is what REFUSES a drop in HTML5 drag and drop,
// so the cursor says no while the board is still in the air rather than the
// drop landing and quietly doing nothing. docs/Features/Board/Home.md
// drop landing and quietly doing nothing. docs/Features/All-Boards/Home.md
if (isDragFromHome(evt)) return;
const menuType = evt.currentTarget.getAttribute('data-type');
// Remaining accepts board drags generally. Starred additionally accepts a
@@ -2329,7 +2330,7 @@ Template.boardList.events({
boardIds.forEach((boardId) => {
Meteor.call('archiveBoard', boardId, (err) => {
if (err) alert(err?.reason || err?.message || 'Failed to archive board');
if (err) alert(errorMessage(err));
});
});
// The dragged boards are gone from this page, so a selection of them is
@@ -2450,7 +2451,7 @@ Template.boardList.events({
boardIds.forEach((boardId) => {
Meteor.call('clearDefaultBoard', boardId, (err) => {
if (err) alert(err?.reason || err?.message || 'Failed to remove from Home');
if (err) alert(errorMessage(err));
});
});
if (isMultiBoard) BoardMultiSelection.reset();
@@ -2465,7 +2466,7 @@ Template.boardList.events({
// predictable: you drop a board on Home and that board is Home, whatever was
// there before. Taking a board off Home is the opposite gesture - dragging it
// out of the Home section - and clicking the row's Multi-Selection toggle
// still toggles. docs/Features/Board/Home.md
// still toggles. docs/Features/All-Boards/Home.md
'dragover .js-home-menu'(evt) {
if (isArchivedMultiBoardDrag(evt)) return;
evt.preventDefault();
@@ -2505,7 +2506,7 @@ Template.boardList.events({
// silently choose its first id; it has to be narrowed to one before this
// drop can change anything.
Meteor.call('setDefaultBoard', boardIds[0], (err) => {
if (err) alert(err?.reason || err?.message || 'Failed to set Home board');
if (err) alert(errorMessage(err));
});
if (isMultiBoard) BoardMultiSelection.reset();
},
@@ -2562,7 +2563,7 @@ Template.boardList.events({
const board = ReactiveCache.getBoard(boardId);
if (board && board.archived) {
Meteor.call('restoreBoard', boardId, (err) => {
if (err) alert(err?.reason || err?.message || 'Failed to restore board');
if (err) alert(errorMessage(err));
});
}
// ...and out of whatever workspace it was in, which is the other half of
@@ -2719,7 +2720,7 @@ Template.workspaceActionsPopup.onRendered(function() {
// The All Boards view menu, in the FIRST header bar. Its handler and the helper
// that draws it follow it out of the sidebar. docs/Features/Page/Header.md
// that draws it follow it out of the sidebar. docs/Features/Header-Bar/Header.md
Template.allBoardsViewMenu.events({
// Titled, so it has a header with the close ✕ in it - the same popup the
// BOARD's view menu opens, and it is the same question: which view of this
@@ -1,4 +1,4 @@
//- The 10 board report charts (docs/Features/Reports/charts.tsv), each a thin
//- The 10 board report charts (docs/Features/Board-Header/Board-Views/Charts/charts.tsv), each a thin
// wrapper around the shared boardChartView (charts/boardCharts.jade/.js):
// export buttons, the chart's title, a small CSS-bar/table visualization and
// the underlying data table - all driven from the `boardChartData` server
@@ -24,7 +24,7 @@ function loadFlowRenderer() {
// ReactiveVar holds the answer, helpers read it. Drawn with Chart.js (MIT,
// canvas-based), loaded with a dynamic import() so its code only reaches the
// browser when a chart view actually mounts, following
// docs/Features/Reports/charts.tsv for what each chartKey means.
// docs/Features/Board-Header/Board-Views/Charts/charts.tsv for what each chartKey means.
Template.boardChartView.onCreated(function() {
this.chartData = new ReactiveVar(null);
this.options = new ReactiveVar({});
+101 -1
View File
@@ -62,6 +62,19 @@
white-space: nowrap;
}
/* A folded pane keeps its double caret, above the pane's icon. */
.chat-view-pane-folded button {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
/* Right to left the tree is on the right and the chat on the left, so every
double caret points the other way. */
.chat-view[data-dir="rtl"] .chat-view-fold-caret {
transform: scaleX(-1);
}
.chat-view-pane-header button,
.chat-view-pane-folded button {
margin: 0 !important;
@@ -97,10 +110,96 @@
padding: 6px 0;
}
.chat-view-node > ul {
.chat-view-node > ul,
.chat-view-collapse > ul {
padding-inline-start: 14px;
}
/* The caret before the icon: it points to where the text runs (right, or
left in a right-to-left language) when closed, and down when open. */
.chat-view-caret {
flex: 0 0 auto;
width: 0.8em;
text-align: center;
transition: transform 0.2s ease;
}
.chat-view-tree[dir="rtl"] .chat-view-caret {
transform: scaleX(-1);
}
.chat-view-node.is-open > .chat-view-row > .chat-view-caret {
transform: rotate(90deg);
}
.chat-view-tree[dir="rtl"] .chat-view-node.is-open > .chat-view-row > .chat-view-caret {
transform: rotate(90deg) scaleX(-1);
}
/* A card has no caret: its # holds the same place. */
.chat-view-channel > .chat-view-row > .chat-view-hash {
width: 0.8em;
text-align: center;
}
/* Opening and closing slide: the rows under a node grow from nothing to
their height (a 0fr to 1fr grid row) - and simply show or hide for a
member who asked the system for reduced motion. */
.chat-view-collapse {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
}
.chat-view-collapse > ul {
overflow: hidden;
min-height: 0;
visibility: hidden;
transition: visibility 0.2s;
}
.chat-view-node.is-open > .chat-view-collapse {
grid-template-rows: 1fr;
}
.chat-view-node.is-open > .chat-view-collapse > ul {
visibility: visible;
}
@media (prefers-reduced-motion: reduce) {
.chat-view-collapse,
.chat-view-collapse > ul,
.chat-view-caret {
transition: none;
}
}
/* Messages this member has not read, under that row. */
.chat-view-unread {
flex: 0 0 auto;
margin-inline-start: auto;
min-width: 1.6em;
padding: 0 6px;
border-radius: 9px;
background: var(--theme-accent, #01628c);
color: #fff;
font-size: 0.75em;
font-weight: bold;
line-height: 1.6;
text-align: center;
}
.chat-view-all-cards {
flex: 0 0 auto;
margin: 0;
padding: 0 4px;
border: 0;
background: none;
color: inherit;
opacity: 0.5;
cursor: pointer;
}
.chat-view-row:hover .chat-view-all-cards,
.chat-view-all-cards:focus-visible,
.chat-view-all-cards[aria-pressed="true"] {
opacity: 1;
}
.chat-view-row:focus-visible {
outline: 2px solid currentColor;
outline-offset: -2px;
}
.chat-view-row {
display: flex;
align-items: center;
@@ -117,6 +216,7 @@
}
.chat-view-row-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+94 -50
View File
@@ -1,5 +1,5 @@
//- Board View / Chat: the board as a Slack / Rocket.Chat workspace, in the
board's own theme. Design: docs/Features/Board/Chat-View.md.
board's own theme. Design: docs/Features/Board-Header/Board-Views/Chat-View.md.
//-
//- tree Workspaces > boards > swimlanes > lists > cards. A card is a
//- channel; everything can be dragged to reorder it.
@@ -25,57 +25,98 @@ template(name="chatView")
template(name="chatViewTreePane")
if collapsed 'tree'
.chat-view-pane.chat-view-pane-folded
button.js-chat-unfold(data-pane="tree" title="{{_ 'chat-view-show-channels'}}")
i.fa.fa-sitemap
//- The double caret stays where it was, pointing the way the pane opens.
button.js-chat-unfold(data-pane="tree" title="{{_ 'chat-view-show-channels'}}" aria-label="{{_ 'chat-view-show-channels'}}")
i.fa.fa-angle-double-right.chat-view-fold-caret(aria-hidden="true")
i.fa.fa-sitemap(aria-hidden="true")
else
nav.chat-view-pane.chat-view-tree(style="flex-basis: {{width 'tree'}}px" aria-label="{{_ 'chat-view-channels'}}")
nav.chat-view-pane.chat-view-tree(style="flex-basis: {{width 'tree'}}px" aria-label="{{_ 'chat-view-channels'}}" dir="{{treeDir}}")
.chat-view-pane-header
span.chat-view-pane-title {{_ 'chat-view-channels'}}
button.js-chat-fold(data-pane="tree" title="{{_ 'chat-view-hide'}}")
i.fa.fa-angle-double-left
ul.chat-view-workspaces
each workspaceTree.workspaces
+chatViewWorkspace
if workspaceTree.unassigned.length
li.chat-view-node.chat-view-node-heading {{_ 'chat-view-other-boards'}}
each workspaceTree.unassigned
+chatViewBoard
i.fa.fa-angle-double-left.chat-view-fold-caret
//- Workspaces > boards > swimlanes > lists > cards that have a
conversation (server/chatView.js), each level folding open under its
caret; every board without one is under Other boards, until its first
message moves it up to its place.
with channelTree
ul.chat-view-workspaces(role="tree")
each workspaces
+chatViewWorkspaceNode
each unassigned
+chatViewBoardNode
if other.length
li.chat-view-node.chat-view-node-heading {{_ 'chat-view-other-boards'}}
each other
+chatViewBoardNode
template(name="chatViewWorkspace")
li.chat-view-node.chat-view-workspace.js-chat-draggable.js-chat-drop(draggable="true" data-kind="workspace" data-id="{{id}}")
.chat-view-row
i.fa.fa-folder-open-o
span.chat-view-row-title {{name}}
ul
each children
+chatViewWorkspace
each boards
+chatViewBoard
//- A row: the caret before the icon (after it, mirrored, right to left),
the name as Markdown, and the messages this member has not read.
template(name="chatViewWorkspaceNode")
li.chat-view-node.chat-view-workspace.js-chat-draggable.js-chat-drop(draggable="true" data-kind="workspace" data-id="{{id}}" role="treeitem" aria-expanded="{{#if isOpen 'workspace' id}}true{{else}}false{{/if}}" class="{{#if isOpen 'workspace' id}}is-open{{/if}}")
.chat-view-row.js-chat-toggle(data-key="workspace:{{id}}" tabindex="0")
i.chat-view-caret.fa.fa-caret-right(aria-hidden="true")
i.fa(class="{{#if isOpen 'workspace' id}}fa-folder-open-o{{else}}fa-folder-o{{/if}}" aria-hidden="true")
span.chat-view-row-title
+inlineViewer
= name
+chatViewUnread(count=unread)
.chat-view-collapse
ul(role="group")
each children
+chatViewWorkspaceNode
each boards
+chatViewBoardNode
template(name="chatViewBoard")
li.chat-view-node.chat-view-board.js-chat-draggable(draggable="true" data-kind="board" data-id="{{_id}}" class="{{#if isCurrentBoard}}is-current{{/if}}")
a.chat-view-row.js-chat-open-board(href="#" data-board-id="{{_id}}")
i.fa.fa-columns
span.chat-view-row-title {{title}}
if isCurrentBoard
ul
each boardTree
li.chat-view-node.js-chat-draggable.js-chat-drop(draggable="true" data-kind="swimlane" data-id="{{_id}}")
.chat-view-row
i.fa.fa-th-large
span.chat-view-row-title {{title}}
ul
each lists
li.chat-view-node.js-chat-draggable.js-chat-drop(draggable="true" data-kind="list" data-id="{{_id}}" data-swimlane-id="{{swimlaneId}}")
.chat-view-row
i.fa.fa-list
span.chat-view-row-title {{title}}
ul
each cards
li.chat-view-node.chat-view-channel.js-chat-draggable.js-chat-drop(draggable="true" data-kind="card" data-id="{{_id}}" class="{{#if isSelected _id}}is-selected{{/if}}")
a.chat-view-row.js-chat-select-card(href="#" data-card-id="{{_id}}")
span.chat-view-hash #
span.chat-view-row-title {{title}}
template(name="chatViewBoardNode")
li.chat-view-node.chat-view-board.js-chat-draggable(draggable="true" data-kind="board" data-id="{{_id}}" role="treeitem" aria-expanded="{{#if isOpen 'board' _id}}true{{else}}false{{/if}}" class="{{#if isOpen 'board' _id}}is-open{{/if}} {{#if isCurrentNode}}is-current{{/if}}")
//- Clicking a board folds or unfolds it here; it does not leave the Chat view.
.chat-view-row.js-chat-toggle(data-key="board:{{_id}}" data-board-id="{{_id}}" tabindex="0")
i.chat-view-caret.fa.fa-caret-right(aria-hidden="true")
i.fa.fa-columns(aria-hidden="true")
span.chat-view-row-title
+inlineViewer
= title
+chatViewUnread(count=unread)
if hasMessages
button.chat-view-all-cards.js-chat-all-cards(type="button" data-board-id="{{_id}}" title="{{#if full}}{{_ 'chat-view-message-cards'}}{{else}}{{_ 'chat-view-all-cards'}}{{/if}}" aria-pressed="{{#if full}}true{{else}}false{{/if}}")
i.fa(class="{{#if full}}fa-comments{{else}}fa-list-ul{{/if}}")
.chat-view-collapse
ul(role="group")
each swimlanes
li.chat-view-node.js-chat-drop(class="{{#if isCurrentNode}}js-chat-draggable{{/if}} {{#if isOpen 'swimlane' _id}}is-open{{/if}}" draggable="{{#if isCurrentNode}}true{{else}}false{{/if}}" data-kind="swimlane" data-id="{{_id}}" role="treeitem" aria-expanded="{{#if isOpen 'swimlane' _id}}true{{else}}false{{/if}}")
.chat-view-row.js-chat-toggle(data-key="swimlane:{{_id}}" tabindex="0")
i.chat-view-caret.fa.fa-caret-right(aria-hidden="true")
i.fa.fa-th-large(aria-hidden="true")
span.chat-view-row-title
+inlineViewer
= title
+chatViewUnread(count=unread)
.chat-view-collapse
ul(role="group")
each lists
li.chat-view-node.js-chat-drop(class="{{#if isCurrentNode}}js-chat-draggable{{/if}} {{#if isOpen 'list' _id}}is-open{{/if}}" draggable="{{#if isCurrentNode}}true{{else}}false{{/if}}" data-kind="list" data-id="{{_id}}" data-swimlane-id="{{swimlaneId}}" role="treeitem" aria-expanded="{{#if isOpen 'list' _id}}true{{else}}false{{/if}}")
.chat-view-row.js-chat-toggle(data-key="list:{{_id}}" tabindex="0")
i.chat-view-caret.fa.fa-caret-right(aria-hidden="true")
i.fa.fa-list(aria-hidden="true")
span.chat-view-row-title
+inlineViewer
= title
+chatViewUnread(count=unread)
.chat-view-collapse
ul(role="group")
each cards
li.chat-view-node.chat-view-channel.js-chat-drop(class="{{#if isCurrentNode}}js-chat-draggable{{/if}} {{#if isSelected _id}}is-selected{{/if}}" draggable="{{#if isCurrentNode}}true{{else}}false{{/if}}" data-kind="card" data-id="{{_id}}" role="treeitem")
.chat-view-row.js-chat-select-card(data-card-id="{{_id}}" data-board-id="{{boardId}}" tabindex="0")
span.chat-view-hash #
span.chat-view-row-title
+inlineViewer
= title
+chatViewUnread(count=unread)
template(name="chatViewUnread")
if count
span.chat-view-unread(title="{{_ 'chat-view-unread' count=count}}" aria-label="{{_ 'chat-view-unread' count=count}}") {{countText}}
template(name="chatViewCardPane")
if collapsed 'card'
@@ -97,18 +138,21 @@ template(name="chatViewCardPane")
template(name="chatViewChatPane")
if collapsed 'chat'
.chat-view-pane.chat-view-pane-folded
button.js-chat-unfold(data-pane="chat" title="{{_ 'chat-view-show-chat'}}")
i.fa.fa-comments
button.js-chat-unfold(data-pane="chat" title="{{_ 'chat-view-show-chat'}}" aria-label="{{_ 'chat-view-show-chat'}}")
i.fa.fa-angle-double-left.chat-view-fold-caret(aria-hidden="true")
i.fa.fa-comments(aria-hidden="true")
else
section.chat-view-pane.chat-view-chat(style="flex-basis: {{width 'chat'}}px")
.chat-view-pane-header
span.chat-view-pane-title
if selectedCard
| # {{selectedCard.title}}
| #
+inlineViewer
= selectedCard.title
else
| {{_ 'comments'}}
button.js-chat-fold(data-pane="chat" title="{{_ 'chat-view-hide'}}")
i.fa.fa-angle-double-right
i.fa.fa-angle-double-right.chat-view-fold-caret
.chat-view-messages.js-chat-messages
each messages
.chat-view-message(class="{{#if mine}}is-mine{{/if}}")
+176 -29
View File
@@ -1,7 +1,5 @@
import { ReactiveVar } from 'meteor/reactive-var';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { ReactiveCache } from '/imports/reactiveCache';
import Boards from '/models/boards';
import CardComments from '/models/cardComments';
import Cards from '/models/cards';
import Swimlanes from '/models/swimlanes';
@@ -9,7 +7,7 @@ import { Utils } from '/client/lib/utils';
import { TAPi18n } from '/imports/i18n';
const {
DEFAULT_LAYOUT, cleanChatLayout, paneOrder, resizePane, buildBoardTree, buildWorkspaceTree,
DEFAULT_LAYOUT, cleanChatLayout, paneOrder, resizePane, buildChannelTree,
dropNeighbours, dropAllowed,
} = require('/models/lib/chatView');
const { dropPosition, moveWorkspace } = require('/models/lib/workspacesTree');
@@ -21,6 +19,86 @@ const { dropPosition, moveWorkspace } = require('/models/lib/workspacesTree');
const selectedCardId = new ReactiveVar(null);
const dragLayout = new ReactiveVar(null);
// The channel tree (server/chatView.js): the summary of the member's
// conversations, the boards opened with all their cards, and which rows are
// open - kept in this browser, a per-viewer convenience.
const channelSummary = new ReactiveVar(null);
const fullBoards = new ReactiveVar({});
// The board the member is on shows all its cards unless they chose only
// the ones with messages; its cards come from the board's own subscription,
// so a card dragged to another list moves in the tree at once.
const messagesOnly = new ReactiveVar(new Set());
function currentBoardStructure() {
const boardId = Utils.getCurrentBoardId();
if (!boardId || messagesOnly.get().has(boardId)) return null;
const live = item => item && !item.archived;
return {
swimlanes: (ReactiveCache.getSwimlanes({ boardId, archived: false }) || []).filter(live)
.map(s => ({ _id: s._id, boardId, title: s.title || '', sort: s.sort })),
lists: (ReactiveCache.getLists({ boardId, archived: false }) || []).filter(live)
.map(l => ({ _id: l._id, boardId, title: l.title || '', sort: l.sort, swimlaneId: l.swimlaneId || '' })),
cards: (ReactiveCache.getCards({ boardId, archived: false }) || []).filter(live)
.map(c => ({ cardId: c._id, boardId, swimlaneId: c.swimlaneId, listId: c.listId, title: c.title || '', sort: c.sort })),
};
}
// { 'board:<id>': true, 'list:<id>': false, ... }: what the member opened
// or closed; every other row is as defaultOpen says.
const OPEN_KEY = 'wekan-chat-view-open';
const readOpen = () => {
try {
const stored = JSON.parse(window.localStorage.getItem(OPEN_KEY) || '{}');
return stored && typeof stored === 'object' && !Array.isArray(stored) ? stored : {};
} catch (e) { return {}; }
};
const openRows = new ReactiveVar(readOpen());
function setOpen(key, open) {
const next = { ...openRows.get(), [key]: !!open };
const keys = Object.keys(next);
for (const old of keys.slice(0, Math.max(0, keys.length - 500))) delete next[old];
openRows.set(next);
try { window.localStorage.setItem(OPEN_KEY, JSON.stringify(next)); } catch (e) { /* not kept */ }
}
let reloadTimer = null;
function loadChannels(delay = 0) {
clearTimeout(reloadTimer);
reloadTimer = setTimeout(() => Meteor.call('chatViewChannels', (error, summary) => {
if (!error) channelSummary.set(summary);
}), delay);
}
function loadFullBoard(boardId) {
Meteor.call('chatViewBoardChannels', boardId, (error, full) => {
if (!error) fullBoards.set({ ...fullBoards.get(), [boardId]: full });
});
}
// The rows open before the member opened or closed any: the board they are
// on and the workspaces above it.
function defaultOpen(key) {
const boardId = Utils.getCurrentBoardId();
if (key === `board:${boardId}`) return true;
// Under a board that is open, its swimlanes and lists are too.
if (key.startsWith('swimlane:') || key.startsWith('list:')) return true;
if (!key.startsWith('workspace:')) return false;
const user = ReactiveCache.getCurrentUser();
const profile = (user && user.profile) || {};
const assigned = profile.boardWorkspaceAssignments && profile.boardWorkspaceAssignments[boardId];
if (!assigned) return false;
const find = (nodes, id) => {
for (const node of nodes || []) {
if (node.id === id) return node;
const inner = find(node.children, id);
if (inner) return inner;
}
return null;
};
const holds = node => !!node && (node.id === assigned || (node.children || []).some(holds));
return holds(find(profile.boardWorkspacesTree, key.slice('workspace:'.length)));
}
const isOpen = (kind, id) => {
const key = `${kind}:${id}`;
const chosen = openRows.get();
return Object.prototype.hasOwnProperty.call(chosen, key) ? chosen[key] === true : defaultOpen(key);
};
// The language's direction, reactively: the panes re-order the moment the
// member switches to or from a right-to-left language.
function isRtl() {
@@ -42,10 +120,11 @@ function saveLayout(next) {
Meteor.call('setChatViewLayout', clean, () => dragLayout.set(null));
}
// The card opened in the middle, from any board in the tree: its own data
// (popupCardData) and conversation (chatViewCardComments) are subscribed.
function selectedCard() {
const id = selectedCardId.get();
const card = id && ReactiveCache.getCard(id);
return card && card.boardId === Utils.getCurrentBoardId() ? card : null;
return (id && ReactiveCache.getCard(id)) || null;
}
Template.chatView.onCreated(function () {
@@ -54,7 +133,10 @@ Template.chatView.onCreated(function () {
this.subscribe('boards');
this.autorun(() => {
const id = selectedCardId.get();
if (id) this.subscribe('popupCardData', id);
if (id) {
this.subscribe('popupCardData', id);
this.subscribe('chatViewCardComments', id);
}
});
});
@@ -84,16 +166,45 @@ const paneHelpers = {
width(pane) { return layout()[`${pane}Width`] || DEFAULT_LAYOUT[`${pane}Width`]; },
selectedCard,
};
Template.chatViewTreePane.onCreated(function () {
loadChannels();
// A message written on this board - here or anywhere - is a reason to look
// again: a board's first message moves it out of Other boards.
this.autorun(() => {
const boardId = Utils.getCurrentBoardId();
if (boardId) CardComments.find({ boardId }).count();
loadChannels(500);
});
this.refresh = Meteor.setInterval(() => loadChannels(), 60000);
});
Template.chatViewTreePane.onDestroyed(function () {
Meteor.clearInterval(this.refresh);
});
Template.chatViewTreePane.helpers({
...paneHelpers,
workspaceTree() {
treeDir() { return isRtl() ? 'rtl' : 'ltr'; },
channelTree() {
const user = ReactiveCache.getCurrentUser();
const profile = (user && user.profile) || {};
const boards = (ReactiveCache.getBoards({ archived: false, type: 'board', 'members.userId': user && user._id },
{ sort: { title: 1 } }) || []).map(b => ({ _id: b._id, title: b.title, slug: b.slug }));
return buildWorkspaceTree(profile.boardWorkspacesTree, profile.boardWorkspaceAssignments, boards);
const full = { ...fullBoards.get() };
const current = currentBoardStructure();
if (current) full[Utils.getCurrentBoardId()] = current;
return buildChannelTree(channelSummary.get(), profile.boardWorkspacesTree, profile.boardWorkspaceAssignments, full);
},
});
const treeHelpers = {
isOpen,
// On the board the member is on: only there can rows be dragged, since a
// drop moves things on the open board.
isCurrentNode() { return this.boardId === Utils.getCurrentBoardId(); },
isSelected(id) { return selectedCardId.get() === id; },
};
Template.chatViewWorkspaceNode.helpers(treeHelpers);
Template.chatViewBoardNode.helpers(treeHelpers);
Template.chatViewUnread.helpers({
countText() { return this.count > 99 ? '99+' : String(this.count); },
});
Template.chatViewCardPane.helpers(paneHelpers);
Template.chatViewChatPane.helpers({
...paneHelpers,
@@ -114,21 +225,10 @@ Template.chatViewChatPane.helpers({
});
},
canComment() {
// The comment capability, as the server decides (read-only roles too).
return !!(selectedCard() && Utils.currentUserCan('comment'));
},
});
Template.chatViewBoard.helpers({
isCurrentBoard() { return this._id === Utils.getCurrentBoardId(); },
isSelected(id) { return selectedCardId.get() === id; },
boardTree() {
const boardId = Utils.getCurrentBoardId();
return buildBoardTree({
swimlanes: ReactiveCache.getSwimlanes({ boardId, archived: false }) || [],
lists: ReactiveCache.getLists({ boardId, archived: false }) || [],
cards: ReactiveCache.getCards({ boardId, archived: false }) || [],
});
// The comment capability on the CARD's board, as the server decides
// (read-only roles too).
const card = selectedCard();
return !!(card && Utils.currentUserCan('comment', ReactiveCache.getBoard(card.boardId)));
},
});
@@ -194,16 +294,48 @@ Template.chatView.events({
event.preventDefault();
saveLayout({ ...layout(), [`${event.currentTarget.dataset.pane}Collapsed`]: false });
},
// A card is a channel: it opens in the middle, with its conversation
// beside it - from any board, without leaving the board the member is on.
'click .js-chat-select-card'(event) {
const link = event.target.closest && event.target.closest('a[href]');
if (link && event.currentTarget.contains(link)) return;
event.preventDefault();
selectedCardId.set(event.currentTarget.dataset.cardId);
},
'click .js-chat-open-board'(event) {
// A workspace, board, swimlane or list folds or unfolds; a board without
// a conversation shows all its cards when opened, to write the first one.
'click .js-chat-toggle'(event) {
const link = event.target.closest && event.target.closest('a[href], button');
if (link && event.currentTarget.contains(link)) return;
event.preventDefault();
const key = event.currentTarget.dataset.key;
const [kind, id] = [key.slice(0, key.indexOf(':')), key.slice(key.indexOf(':') + 1)];
const open = !isOpen(kind, id);
setOpen(key, open);
if (open && kind === 'board' && id !== Utils.getCurrentBoardId()) {
const summary = channelSummary.get();
const talking = summary && summary.channels.some(c => c.boardId === id);
if (!talking && !fullBoards.get()[id]) loadFullBoard(id);
}
},
'keydown .js-chat-toggle, keydown .js-chat-select-card'(event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.currentTarget.click();
},
'click .js-chat-all-cards'(event) {
event.preventDefault();
event.stopPropagation();
const boardId = event.currentTarget.dataset.boardId;
if (boardId === Utils.getCurrentBoardId()) return;
const board = Boards.findOne(boardId);
if (board) FlowRouter.go('board', { id: boardId, slug: board.slug });
if (boardId === Utils.getCurrentBoardId()) {
const only = new Set(messagesOnly.get());
if (only.has(boardId)) only.delete(boardId); else only.add(boardId);
messagesOnly.set(only);
} else {
const full = { ...fullBoards.get() };
if (full[boardId]) { delete full[boardId]; fullBoards.set(full); } else loadFullBoard(boardId);
}
setOpen(`board:${boardId}`, true);
},
// Resize: the pointer is captured on the handle, the width follows it
@@ -271,6 +403,7 @@ Template.chatView.events({
if (!text || !card) return;
CardComments.insert({ text, boardId: card.boardId, cardId: card._id, parentId: '' });
input.val('');
loadChannels(500);
},
// Enter sends, Shift+Enter is a new line - as in Slack and Rocket.Chat.
'keydown .js-chat-input'(event) {
@@ -281,6 +414,20 @@ Template.chatView.events({
},
});
// The open channel is read: a message arriving while it is open is seen, so
// its unread count does not grow.
Template.chatViewChatPane.onCreated(function () {
this.autorun(() => {
const id = selectedCardId.get();
if (!id) return;
CardComments.find({ cardId: id }).count();
Tracker.nonreactive(() => {
const user = ReactiveCache.getCurrentUser();
if (user && typeof user.setCardLastViewed === 'function') Promise.resolve(user.setCardLastViewed(id)).then(() => loadChannels(1000), () => {});
});
});
});
// Keep the newest message in view, as a chat does.
Template.chatViewChatPane.onRendered(function () {
this.autorun(() => {
+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,
+25
View File
@@ -105,6 +105,31 @@
.email-view-snippet {
opacity: 0.7;
/* Two lines of the rendered message, then an ellipsis. */
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
/* Rendered Markdown in a row reads as the row's text: a heading or a
paragraph keeps the row's size and flows on one line with the rest. */
.email-view-subject-title .viewer :is(h1, h2, h3, h4, h5, h6, p),
.email-view-snippet .viewer :is(h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote) {
display: inline;
margin: 0;
padding: 0;
font-size: inherit;
line-height: inherit;
}
.email-view-snippet .viewer :is(h1, h2, h3, h4, h5, h6, p, li)::after {
content: ' ';
}
.email-view-subject-title .viewer img,
.email-view-snippet .viewer img {
max-height: 1.4em;
vertical-align: middle;
}
.email-view-row-label {
+19 -7
View File
@@ -1,9 +1,9 @@
//- Board View / Email (#794): the board as a Gmail-like mailbox, in the
board's own theme. Design: docs/Features/Board/Email-View.md.
board's own theme. Design: docs/Features/Board-Header/Board-Views/Email-View.md.
//-
//- Left: Compose, the folders (Inbox = the list email to card puts new cards
in, Sent, All mail), the board's lists as folders and its labels as Gmail
labels. Right: the shared table page (docs/Features/Page/Table.md) - one
labels. Right: the shared table page (docs/Features/General/Table.md) - one
page of messages, newest first. A row opens the card popup, as a message
opens in Gmail; its comments are the conversation.
template(name="emailView")
@@ -24,14 +24,18 @@ template(name="emailView")
li.email-view-folder(class="{{#if active}}is-active{{/if}}")
a.js-email-folder(href="#" data-folder="{{id}}")
i.fa.fa-folder-o
span.email-view-folder-name {{title}}
span.email-view-folder-name
+inlineViewer
= title
if labelFolders.length
li.email-view-folder-heading {{_ 'labels'}}
each labelFolders
li.email-view-folder(class="{{#if active}}is-active{{/if}}")
a.js-email-folder(href="#" data-folder="{{id}}")
span.email-view-label-dot(class="{{labelColorClass color}}" style="{{labelColorStyle color}}")
span.email-view-folder-name {{name}}
span.email-view-folder-name
+inlineViewer
= name
.email-view-main
unless receiving
p.quiet.email-view-hint {{_ 'email-view-not-receiving'}}
@@ -44,12 +48,20 @@ template(name="emailViewRow")
if outgoing
span.quiet {{_ 'email-view-to'}}&nbsp;
| {{sender}}
//- Markdown, like every card title and description in WeKan: the viewer
honours the Admin Panel's security settings for links and code.
td.email-view-subject
span.email-view-subject-title {{title}}
.email-view-subject-title
+viewer
= title
if snippet
span.email-view-snippet - {{snippet}}
.email-view-snippet
+viewer
= snippet
each labels
span.card-label.email-view-row-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}" title=name) {{name}}
span.card-label.email-view-row-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}" title=name)
+inlineViewer
= name
a.email-view-reply.js-email-reply(href="#" title="{{_ 'email-view-reply'}}" data-card-id="{{_id}}")
i.fa.fa-reply
td.email-view-date {{date}}
+8 -3
View File
@@ -4,9 +4,10 @@ import { TAPi18n } from '/imports/i18n';
import Cards from '/models/cards';
import { Utils } from '/client/lib/utils';
import { reportPageDocs } from '/client/lib/reportPages';
import { reportPageKey } from '/models/lib/reportPageIndex';
import { buildHeader, docsByIds, pageInfo } from '/models/lib/tablePage';
const { EMAIL_VIEW_PAGE_ID, parseFolder, snippet } = require('/models/lib/emailView');
const { EMAIL_VIEW_PAGE_ID, emailViewPageArgs, parseFolder, previewMarkdown } = require('/models/lib/emailView');
// Board View / Email (emailView.jade, server/emailView.js). The folder, the
// page and the search are this view's state; the rows are the page the server
@@ -88,7 +89,11 @@ Template.emailView.helpers({
const info = pageInfo(tpl.total.get(), tpl.page.get());
const board = Utils.getCurrentBoard();
const labels = (board && board.labels) || [];
const cards = reportPageDocs(Cards, EMAIL_VIEW_PAGE_ID, docsByIds);
// The index of THIS request only: a previous search's index can still be
// in minimongo while its subscription stops.
const key = reportPageKey(EMAIL_VIEW_PAGE_ID, emailViewPageArgs(Utils.getCurrentBoardId(),
{ folder: tpl.folder.get(), page: tpl.page.get(), search: tpl.searchTerm.get() }));
const cards = reportPageDocs(Cards, key, docsByIds);
const docs = cards.map(card => {
const email = card.email || {};
const outgoing = email.direction === 'out';
@@ -100,7 +105,7 @@ Template.emailView.helpers({
return {
_id: card._id,
title: card.title,
snippet: snippet(card.description),
snippet: previewMarkdown(card.description),
outgoing,
sender,
date: formatDate(card.receivedAt || card.createdAt),
+1 -1
View File
@@ -42,4 +42,4 @@ template(name="mapImageUpload")
input.js-map-upload-input.hide(type="file" accept="image/png,image/jpeg,image/gif,image/webp")
button.primary.js-map-upload-button(type="button" disabled=uploading) {{_ 'map-view-upload'}}
if error
p.warning {{error}}
p.warning {{_ error}}
+7 -2
View File
@@ -130,12 +130,17 @@ Template.mapImageUpload.events({
});
uploader.on('end', err => {
tpl.uploading.set(false);
if (err) tpl.error.set(err.reason || err.message || 'upload-failed');
// The template translates the key; the reason is for the console.
if (err) {
if (process.env.DEBUG === 'true') console.warn('map image upload failed:', err);
tpl.error.set('upload-failed');
}
});
uploader.start();
} catch (error) {
tpl.uploading.set(false);
tpl.error.set((error && (error.reason || error.message)) || 'upload-failed');
if (process.env.DEBUG === 'true') console.warn('map image upload failed:', error);
tpl.error.set('upload-failed');
} finally {
input.value = '';
}
@@ -6,6 +6,7 @@ import { TAPi18n } from '/imports/i18n';
import { ReactiveVar } from 'meteor/reactive-var';
import { toFullCalendarFirstDay } from '/client/lib/calendarFirstDay';
import { weekNumberByFirstDay } from '/models/lib/weekStart';
import { calendarBaseOptions } from '/models/lib/calendarBaseOptions';
const { notHelperBoardTitle } = require('/models/lib/helperBoards');
// #2469: "Multi Board Calendar" - reuses #4223 Bigboard's exact
@@ -51,22 +52,21 @@ Template.multiboardCalendarView.helpers({
return !Template.instance().subsReady.get();
},
multiboardCalendarOptions() {
const t = (key, fallback) => {
const translated = TAPi18n.__(key);
return translated && translated !== key ? translated : fallback;
};
const currentUser = ReactiveCache.getCurrentUser();
const firstDay = toFullCalendarFirstDay(
currentUser ? currentUser.getStartDayOfWeek() : 1,
);
const displayOptions = calendarDateDisplayOptions('multiboard-calendar-view');
return {
id: 'multiboard-calendar-view',
...displayOptions,
initialView: displayOptions.initialView || 'dayGridMonth',
firstDay,
...calendarBaseOptions({
id: 'multiboard-calendar-view',
displayOptions: calendarDateDisplayOptions('multiboard-calendar-view'),
firstDay,
weekNumberOf: weekNumberByFirstDay,
translate: key => TAPi18n.__(key),
locale: TAPi18n.getLanguage(),
rtl: TAPi18n.isRTL(),
}),
// #2469 scope: cross-board drag-to-reschedule is out of scope for
// this pass (a card's board isn't necessarily the one currently
// open, and moving it needs more care than a single setDue call) -
@@ -74,39 +74,7 @@ Template.multiboardCalendarView.helpers({
// behaviour unchanged, and is unaffected by this view.
editable: false,
selectable: false,
weekNumbers: true,
weekNumberCalculation: date => weekNumberByFirstDay(date, firstDay),
eventTimeFormat: {
hour: 'numeric',
minute: '2-digit',
meridiem: 'short',
},
slotLabelFormat: {
hour: 'numeric',
minute: '2-digit',
meridiem: 'short',
},
headerToolbar: {
left: 'title',
center: '',
right:
'today prev,next timeGridDay,listDay timeGridWeek,listWeek dayGridMonth,listMonth',
},
buttonIcons: false,
buttonText: {
prev: t('previous', 'Previous'),
next: t('next', 'Next'),
today: t('today', 'Today'),
day: t('day', 'Day'),
week: t('week', 'Week'),
month: t('month', 'Month'),
list: t('list', 'List'),
},
height: 'auto',
navLinks: false,
nowIndicator: true,
locale: TAPi18n.getLanguage(),
direction: TAPi18n.isRTL() ? 'rtl' : 'ltr',
events(fetchInfo, callback) {
const filterSelector = Filter.isActive()
? Filter._getMongoSelector()
@@ -1,60 +0,0 @@
template(name="originalPositionsView")
.original-positions-view
.original-positions-header
button.btn.btn-sm.btn-outline-secondary.js-toggle-original-positions
i.fa.fa-history
if isShowingOriginalPositions
| Hide Original Positions
else
| Show Original Positions
if isShowingOriginalPositions
button.btn.btn-sm.btn-outline-primary.js-refresh-history
i.fa.fa-refresh
| Refresh
if isShowingOriginalPositions
.original-positions-content
if isLoading
.original-positions-loading
i.fa.fa-spinner.fa-spin
| Loading original positions...
else
.original-positions-filters
.btn-group.btn-group-sm(role="group")
button.btn.js-filter-type(type="button" class="{{#if isFilterType 'all'}}btn-primary{{else}}btn-outline-secondary{{/if}}" data-filter-type="all")
| All
button.btn.js-filter-type(type="button" class="{{#if isFilterType 'swimlane'}}btn-primary{{else}}btn-outline-secondary{{/if}}" data-filter-type="swimlane")
i.fa.fa-bars
| Swimlanes
button.btn.js-filter-type(type="button" class="{{#if isFilterType 'list'}}btn-primary{{else}}btn-outline-secondary{{/if}}" data-filter-type="list")
i.fa.fa-columns
| Lists
button.btn.js-filter-type(type="button" class="{{#if isFilterType 'card'}}btn-primary{{else}}btn-outline-secondary{{/if}}" data-filter-type="card")
i.fa.fa-sticky-note
| Cards
.original-positions-list
each getFilteredHistory
.original-position-item
.original-position-item-header
i.fa(class="{{getEntityTypeIcon entityType}}")
span.entity-type {{getEntityTypeLabel entityType}}
span.entity-name
+viewer
| {{getEntityDisplayName this}}
span.entity-id ({{entityId}})
.original-position-item-details
.original-position-description
| {{getEntityOriginalPositionDescription this}}
if originalTitle
.original-title
strong Original title:
+viewer
= originalTitle
.original-position-date
small.text-muted Created: {{formatDate createdAt}}
else
.no-original-positions
i.fa.fa-info-circle
| No original position data available for this board.
@@ -1,135 +0,0 @@
import { formatDateForDisplay } from '/client/lib/dateDisplay';
import { ReactiveVar } from 'meteor/reactive-var';
import { Meteor } from 'meteor/meteor';
/**
* Component to display original positions for all entities on a board
*/
Template.originalPositionsView.onCreated(function () {
this.showOriginalPositions = new ReactiveVar(false);
this.boardHistory = new ReactiveVar([]);
this.isLoading = new ReactiveVar(false);
this.filterType = new ReactiveVar('all'); // 'all', 'swimlane', 'list', 'card'
const tpl = this;
this.loadBoardHistory = function () {
const boardId = Session.get('currentBoard');
if (!boardId) return;
tpl.isLoading.set(true);
Meteor.call('positionHistory.getBoardHistory', boardId, (error, result) => {
tpl.isLoading.set(false);
if (error) {
console.error('Error loading board history:', error);
tpl.boardHistory.set([]);
} else {
tpl.boardHistory.set(result);
}
});
};
});
Template.originalPositionsView.onRendered(function () {
this.loadBoardHistory();
});
Template.originalPositionsView.helpers({
isShowingOriginalPositions() {
return Template.instance().showOriginalPositions.get();
},
isLoading() {
return Template.instance().isLoading.get();
},
getBoardHistory() {
return Template.instance().boardHistory.get();
},
getFilteredHistory() {
const tpl = Template.instance();
const history = tpl.boardHistory.get();
const filterType = tpl.filterType.get();
if (filterType === 'all') {
return history;
}
return history.filter(item => item.entityType === filterType);
},
isFilterType(type) {
return Template.instance().filterType.get() === type;
},
getEntityDisplayName(entity) {
const position = entity.originalPosition || {};
return position.title || `Entity ${entity.entityId}`;
},
getEntityOriginalPositionDescription(entity) {
const position = entity.originalPosition || {};
let description = `Position: ${position.sort || 0}`;
if (entity.entityType === 'list' && entity.originalSwimlaneId) {
description += ` in swimlane ${entity.originalSwimlaneId}`;
} else if (entity.entityType === 'card') {
if (entity.originalSwimlaneId) {
description += ` in swimlane ${entity.originalSwimlaneId}`;
}
if (entity.originalListId) {
description += ` in list ${entity.originalListId}`;
}
}
return description;
},
getEntityTypeIcon(entityType) {
switch (entityType) {
case 'swimlane':
return 'fa-bars';
case 'list':
return 'fa-columns';
case 'card':
return 'fa-sticky-note';
default:
return 'fa-question';
}
},
getEntityTypeLabel(entityType) {
switch (entityType) {
case 'swimlane':
return 'Swimlane';
case 'list':
return 'List';
case 'card':
return 'Card';
default:
return 'Unknown';
}
},
formatDate(date) {
return formatDateForDisplay(date, true, value => value.toLocaleString());
},
});
Template.originalPositionsView.events({
'click .js-toggle-original-positions'(evt, tpl) {
tpl.showOriginalPositions.set(!tpl.showOriginalPositions.get());
},
'click .js-refresh-history'(evt, tpl) {
tpl.loadBoardHistory();
},
'click .js-filter-type'(evt, tpl) {
const type = evt.currentTarget.dataset.filterType;
tpl.filterType.set(type);
},
});
+3 -3
View File
@@ -1,5 +1,5 @@
/* /public — the Public Boards table.
* Design: docs/Features/Page/Public.md, which is docs/Features/Page/Table.md.
* Design: docs/Features/All-Boards/Public.md, which is docs/Features/General/Table-Page.md.
*
* ONLY the row's board colouring lives here. The table itself — its width, its
* columns, its cells, its controls row and its stacking below 800px — is
@@ -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;
+2 -2
View File
@@ -1,6 +1,6 @@
//- /public — the boards anybody may open.
//- Design: docs/Features/Page/Public.md, which is the Table page design
//- (docs/Features/Page/Table.md).
//- Design: docs/Features/All-Boards/Public.md, which is the Table page design
//- (docs/Features/General/Table.md).
//-
//- The whole page is the shared table. It used to render `boardList`, the All
//- Boards page, with its query swapped for `{ permission: 'public' }` — which
+7 -3
View File
@@ -2,6 +2,7 @@ import { ReactiveVar } from 'meteor/reactive-var';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Boards from '/models/boards';
import { ReportPages } from '/client/lib/reportPages';
import { reportPageKey } from '/models/lib/reportPageIndex';
import {
buildHeader,
docsByIds,
@@ -10,7 +11,7 @@ import {
} from '/models/lib/tablePage';
// /public — the boards anybody may open, as a table page.
// Design: docs/Features/Page/Public.md (which is docs/Features/Page/Table.md).
// Design: docs/Features/All-Boards/Public.md (which is docs/Features/General/Table.md).
//
// The page is ONLY the table. It used to render `boardList`, the All Boards page,
// with its query swapped for `{ permission: 'public' }` — which brought the
@@ -62,7 +63,10 @@ Template.publicBoards.helpers({
// a plain find(): every board this user has opened is also in minimongo, and
// reading the collection would draw those too — the pager would say "1 / 3"
// beside rows from all three pages.
const index = ReportPages.findOne(PAGE_ID);
// The index of THIS request (models/lib/reportPageIndex.js reportPageKey),
// built from the same arguments the autorun subscribed with.
const index = ReportPages.findOne(reportPageKey(PAGE_ID,
[tpl.searchTerm.get() || '', TABLE_PAGE_ROWS_PER_PAGE, (tpl.page.get() - 1) * TABLE_PAGE_ROWS_PER_PAGE]));
const ids = (index && index.ids) || [];
const boards = ids.length
? docsByIds(ids, Boards.find({ _id: { $in: ids } }).fetch())
@@ -88,7 +92,7 @@ Template.publicBoards.helpers({
// `boardListHeaderBar`, whose `h1` says "Public". The shared table page
// prints a title only when one is supplied, precisely so a page that
// already has a heading does not show two - the same reason the Admin
// Panel panes supply none (docs/Features/Page/Table.md).
// Panel panes supply none (docs/Features/General/Table.md).
header: buildHeader(COLUMNS),
// A row template, not `rows`: see COLUMNS above.
rowTemplate: 'publicBoardRow',
@@ -52,8 +52,14 @@ const boardFor = context => ReactiveCache.getBoard(context?.record?.boardId || S
// `field` (a definition suffix, 'Sprint') draws one field alone: on a card and
// a minicard each Scrum field sits where the board's Board Settings / Card
// order puts it (models/lib/cardFieldOrder.js SCRUM_FIELD_KEYS).
// Read through the board as that side sees it (models/lib/cardShownFields.js
// boardForSide): a Scrum section hidden whole on Show on Card or Show on
// Minicard in Card Settings hides these fields there too. The settings above
// (scrumFieldSettings) read the board itself.
function visibleFields(context) {
const board = boardFor(context);
const { boardForSide } = require('/models/lib/cardShownFields');
const side = context?.kind === 'card' || context?.kind === 'minicard' ? context.kind : null;
const board = side ? boardForSide(boardFor(context), side) : boardFor(context);
return (definitions[context?.kind] || []).filter(([suffix]) => (!context.field || context.field === suffix) &&
board?.scrum?.visibility?.[`${context.kind}${suffix}`] === true);
}
@@ -59,7 +59,7 @@ template(name="scrumView")
select(name="estimateSource")
each estimateSources
option(value=value selected=selected) {{label}}
label {{_ 'custom-field'}}
label {{_ 'custom-fields'}}
select(name="estimateCustomFieldId")
option(value="") —
each estimateFields
@@ -141,7 +141,7 @@ template(name="scrumView")
textarea(name="goal") {{selectedSprint.goal}}
label {{_ 'start'}}
input(type="date" name="plannedStart" value=sprintStart)
label {{_ 'end'}}
label {{_ 'card-end'}}
input(type="date" name="plannedEnd" value=sprintEnd)
label {{_ 'scrum-capacity'}}
input(type="number" name="capacity" min="0" step="any" value=selectedSprint.capacity)
@@ -174,7 +174,7 @@ template(name="scrumView")
textarea(name="goal") {{selectedRelease.goal}}
label {{_ 'start'}}
input(type="date" name="plannedStart" value=releaseStart)
label {{_ 'end'}}
label {{_ 'card-end'}}
input(type="date" name="plannedEnd" value=releaseEnd)
label {{_ 'status'}}
select(name="state")
@@ -241,7 +241,7 @@ template(name="scrumView")
button.primary(type="submit" disabled=busy) {{_ 'save'}}
else
tr
td(colspan="6") {{_ 'no-cards'}}
td(colspan="6") {{_ 'no-cards-found'}}
if hiddenCards
button.js-scrum-show-more(type="button") {{showMoreLabel}}
if isSprints
+17
View File
@@ -83,11 +83,28 @@
color: #333;
}
/* The table, not the board, scrolls sideways when its columns are wider than
the window. At 800px and below the table scrolls itself (display: block);
just above that - an iPad or an unfolded phone held upright, 834-928px - it
did not, the board canvas around it is overflow-x: hidden, and Members, Labels
and the date columns were cut off with no way to reach them.
tests/playwright/specs/viewport-matrix.e2e.js checks every column is reachable. */
.table-view-wrapper {
max-width: 100%;
overflow-x: auto;
}
.table-view-controls .js-table-view-toggle-card-title-wrap,
.table-view-controls .js-table-view-toggle-swimlane-groups {
box-sizing: border-box;
width: 34px;
height: 34px;
/* The global `button` rule (forms.css) pads every button 35px on each side
and paints its text white. On these white toggles that made the icon
white on white - two empty squares - and the padding stretched the 34px
box to 72px. tests/playwright/specs/viewport-matrix.e2e.js checks both. */
padding: 0;
color: #333;
line-height: 1;
border: 1px solid #bbb;
background: #fff;
+2 -2
View File
@@ -1,6 +1,6 @@
template(name="tableView")
if currentBoard
.wrapper
.wrapper.table-view-wrapper
.table-view-controls
.table-view-search-box
input.js-table-view-search(type="text" placeholder="{{_ 'search'}}" aria-label="{{_ 'search'}}")
@@ -57,7 +57,7 @@ template(name="tableView")
each row in rows
if row.isGroupHeader
tr.table-view-swimlane-group
th(colspan="11" scope="rowgroup")
th(colspan=columnCount scope="rowgroup")
+viewer
= row.swimlaneTitle
else
+17 -1
View File
@@ -3,6 +3,7 @@ import { Random } from 'meteor/random';
import { isLazyCards } from '/client/lib/lazyCards';
import { ReactiveCache } from '/imports/reactiveCache';
import { Utils } from '/client/lib/utils';
import { TAPi18n } from '/imports/i18n';
import { Filter } from '/client/lib/filter';
import { tableViewCardsSelector, tableCardWithDates } from '/models/lib/tableViewFilter';
import {
@@ -239,6 +240,18 @@ Template.tableView.helpers({
const board = Utils.getCurrentBoard();
return !!board && (board.allowsEndDate || board.allowsEndDateOnMinicard);
},
// A swimlane header row spans the whole table: Edit, Card, List and Swimlane,
// then - except on a small screen - Assignees, Members, Labels and each date
// column the board shows. A fixed 11 left it one short with all four dates
// and too wide on a phone.
columnCount() {
if (Utils.isMiniScreen()) return 4;
const board = Utils.getCurrentBoard();
const dates = !board ? 0 : ['Received', 'Start', 'Due', 'End']
.filter(kind => board[`allows${kind}Date`] || board[`allows${kind}DateOnMinicard`]).length;
return 7 + dates;
},
});
Template.tableView.events({
@@ -285,7 +298,10 @@ Template.tableView.events({
swimlaneId: String(tpl.$('.js-table-view-add-card-swimlane').val() || '') });
input.val('');
} catch (error) {
alert(error?.reason || error?.message || 'Could not add the card.');
// tableViewAddCard refuses with an error code; show it in the reader's
// language rather than the code or an English sentence.
const key = error?.error === 'not-found' ? 'error-list-doesNotExist' : 'error-notAllowed';
alert(TAPi18n.__(key));
}
},
+3 -2
View File
@@ -46,8 +46,9 @@ template(name="timelineView")
p.timeline-card-due
| {{_ 'due-date'}}: {{this.formattedDueAt}}
if selectedTimestamp
button.timeline-card-restore.js-restore-card-timeline.primary(type="button")
| {{_ 'board-view-timeline-restore'}}
if canRestoreCard this.cardId
button.timeline-card-restore.js-restore-card-timeline.primary(type="button")
| {{_ 'board-view-timeline-restore'}}
template(name="cardRestoreToTimelinePopup")
p {{_ 'board-view-timeline-restore-confirm'}}
+13 -1
View File
@@ -3,6 +3,7 @@ import { ReactiveCache } from '/imports/reactiveCache';
import { ReactiveVar } from 'meteor/reactive-var';
import { Template } from 'meteor/templating';
import { Utils } from '/client/lib/utils';
import { TAPi18n } from '/imports/i18n';
import { reconstructBoardStateAt } from '/models/lib/boardTimeline';
// Board View / Timeline (Step 2 of the maintainer's spec, Step 1 wiring):
@@ -91,6 +92,13 @@ function boardActivities() {
}
Template.timelineView.helpers({
// Restoring writes the card through its own setters, so it is offered only
// to those who may change that card now - the same rule as everywhere else
// (role, list exceptions, assigned-only).
canRestoreCard(cardId) {
const card = ReactiveCache.getCard(cardId);
return !!card && Utils.canModifyCard(card);
},
selectedTimestamp() {
return Template.instance().selectedTimestamp.get();
},
@@ -259,7 +267,11 @@ Template.timelineView.events({
'click .js-restore-card-timeline': Popup.afterConfirm('cardRestoreToTimeline', function () {
const cardContext = this;
if (cardContext && cardContext.cardId) {
restoreCardFromTimeline(cardContext.cardId, cardContext.fields);
// A refused write used to vanish: nothing awaited the restore.
restoreCardFromTimeline(cardContext.cardId, cardContext.fields).catch(error => {
if (process.env.DEBUG === 'true') console.warn('timeline restore failed:', error);
alert(TAPi18n.__('error-notAllowed'));
});
}
}),
});
@@ -467,3 +467,49 @@
.vcs-wiki-sidebar {
padding: 8px 16px;
}
/* Titles, labels and milestones that open or filter are spans with
role=link (a rendered title can hold a link of its own). */
.vcs-view [role="link"] {
cursor: pointer;
}
.vcs-view [role="link"]:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Attachments: the controls of an opened card's attachments. */
.vcs-file-view {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: start;
cursor: pointer;
}
.vcs-file-view:hover,
.vcs-file-view:focus-visible {
text-decoration: underline;
}
.vcs-file-thumbnail {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 3px;
}
.vcs-file-actions {
white-space: nowrap;
}
.vcs-file-actions a {
margin-inline-end: 8px;
color: inherit;
cursor: pointer;
}
@@ -1,6 +1,6 @@
//- Board View / Version Control: the board as a GitHub / Gitea repository's
issue tracker, in the board's own theme. Design:
docs/Features/Board/Version-Control-View.md. A card is an issue, the board's
docs/Features/Board-Header/Board-Views/Version-Control-View.md. A card is an issue, the board's
labels are its labels, a label with a due date is a milestone, the comments
are the conversation, the attachments are the files and the board's change
history is the history, with its undo.
@@ -9,7 +9,9 @@ template(name="versionControlView")
header.vcs-header
h2.vcs-repo
i.fa.fa-code-fork
span.vcs-repo-name {{boardTitle}}
span.vcs-repo-name
+inlineViewer
= boardTitle
if isPublic
span.vcs-badge {{_ 'public'}}
.vcs-header-actions
@@ -22,7 +24,9 @@ template(name="versionControlView")
each syncedLists
button.vcs-button.js-vcs-sync(type="button" data-list-id="{{_id}}" disabled="{{#if busy}}disabled{{/if}}" title="{{source}} {{project}}{{#if error}} - {{error}}{{/if}}")
i.fa.fa-refresh(class="{{#if busy}}fa-spin{{/if}}")
| &nbsp;{{_ 'vcs-sync-now' source}}: {{title}}
| &nbsp;{{_ 'vcs-sync-now' source}}:
+inlineViewer
= title
a.vcs-button.js-vcs-export(href="#")
i.fa.fa-download
| &nbsp;{{_ 'export-board'}}
@@ -66,17 +70,25 @@ template(name="versionControlView")
li.vcs-issue
i.fa.vcs-issue-icon(class="{{#if closed}}fa-check-circle is-closed{{else}}fa-dot-circle-o is-open{{/if}}" aria-hidden="true")
.vcs-issue-main
a.vcs-issue-title.js-vcs-open-issue(href="#" data-card-id="{{_id}}") {{title}}
//- Rendered titles and labels can hold links, and a link
inside a link is not one: the clickable ones are spans.
span.vcs-issue-title.js-vcs-open-issue(role="link" tabindex="0" data-card-id="{{_id}}")
+inlineViewer
= title
each labels
a.card-label.vcs-label.js-vcs-label(href="#" data-name="{{name}}" class="{{labelColorClass color}}" style="{{labelColorStyle color}}") {{name}}
span.card-label.vcs-label.js-vcs-label(role="link" tabindex="0" data-name="{{name}}" class="{{labelColorClass color}}" style="{{labelColorStyle color}}")
+inlineViewer
= name
.vcs-issue-meta.quiet
if number
| {{numberText}}&nbsp;
| {{_ 'vcs-opened-by' date author}}
each milestones
a.vcs-issue-milestone.js-vcs-milestone(href="#" data-title="{{title}}")
span.vcs-issue-milestone.js-vcs-milestone(role="link" tabindex="0" data-title="{{title}}")
i.fa.fa-map-signs
| &nbsp;{{title}}
| &nbsp;
+inlineViewer
= title
.vcs-issue-side
each assignees
+userAvatar(userId=this noRemove=true)
@@ -93,7 +105,9 @@ template(name="versionControlView")
each milestones
li.vcs-milestone
.vcs-milestone-main
a.vcs-milestone-title.js-vcs-milestone(href="#" data-title="{{title}}") {{title}}
span.vcs-milestone-title.js-vcs-milestone(role="link" tabindex="0" data-title="{{title}}")
+inlineViewer
= title
.quiet(class="{{#if overdue}}vcs-overdue{{/if}}")
if dueAt
i.fa.fa-calendar
@@ -112,7 +126,12 @@ template(name="versionControlView")
ul.vcs-labels
each labelRows
li.vcs-label-row
a.card-label.vcs-label.js-vcs-label(href="#" data-name="{{name}}" class="{{labelColorClass color}}" style="{{labelColorStyle color}}") {{#if name}}{{name}}{{else}}{{color}}{{/if}}
span.card-label.vcs-label.js-vcs-label(role="link" tabindex="0" data-name="{{name}}" class="{{labelColorClass color}}" style="{{labelColorStyle color}}")
if name
+inlineViewer
= name
else
| {{color}}
span.quiet {{_ 'vcs-open-issues' open}}
else
li.vcs-empty.quiet {{_ 'vcs-no-labels'}}
@@ -123,7 +142,9 @@ template(name="versionControlView")
li.vcs-box.vcs-release
.vcs-comment-header
i.fa.fa-tag
strong {{title}}
strong
+inlineViewer
= title
span.quiet {{date}}
.vcs-comment-body
+viewer
@@ -143,7 +164,9 @@ template(name="versionControlView")
.vcs-wiki
.vcs-wiki-main
with wikiPage
h2.vcs-issue-heading {{title}}
h2.vcs-issue-heading
+inlineViewer
= title
p.vcs-wiki-tools
a.js-vcs-wiki-history(href="#")
i.fa.fa-history
@@ -173,7 +196,9 @@ template(name="versionControlView")
ul.vcs-wiki-pages
each wikiPages
li(class="{{#if active}}is-active{{/if}}")
a.js-vcs-wiki-page(href="#" data-card-id="{{_id}}") {{title}}
span.js-vcs-wiki-page(role="link" tabindex="0" data-card-id="{{_id}}")
+inlineViewer
= title
if wikiSidebar
.vcs-box.vcs-wiki-sidebar
.vcs-wiki-body
@@ -187,17 +212,46 @@ template(name="versionControlView")
table.vcs-files
tbody
each files
tr
td
i.fa.fa-file-o
| &nbsp;
a(href="{{link}}" target="_blank" rel="noopener noreferrer") {{name}}
//- The controls of an opened card's attachments
(cards/attachments.jade attachmentGallery): a click on the
thumbnail or the name views it in the same viewer, walking the
tab's files; then download, rename, delete and the cover /
background menu, for the same members.
tr.vcs-file(data-attachment-id="{{_id}}")
td.vcs-file-name
if canPreview
button.vcs-file-view.js-vcs-file-view(type="button" data-attachment-id="{{_id}}" title="{{_ 'preview'}}" aria-label="{{_ 'preview'}}: {{cleanFilename name}}")
if isImage
img.vcs-file-thumbnail(src="{{attachmentPreviewUrl attachment}}" alt="" loading="lazy")
else
i.fa.fa-file-o
| &nbsp;{{cleanFilename name}}
else
i.fa.fa-file-o
| &nbsp;
a(href="{{link}}" target="_blank" rel="noopener noreferrer") {{name}}
td.quiet {{size}}
td.vcs-file-actions
if canPreview
a.js-vcs-file-view(data-attachment-id="{{_id}}" title="{{_ 'preview'}}")
i.fa.fa-search-plus
a.js-download(href="{{link}}?download=true" download="{{downloadFilename name}}" title="{{_ 'download'}}")
i.fa.fa-arrow-down
if currentUser.isBoardMember
unless currentUser.isCommentOnly
unless currentUser.isWorker
a.js-vcs-file-rename(data-attachment-id="{{_id}}" title="{{_ 'rename'}}")
i.fa.fa-pencil-square-o
a.js-vcs-file-delete(data-attachment-id="{{_id}}" title="{{_ 'delete'}}")
i.fa.fa-trash
a.js-vcs-file-menu(data-attachment-id="{{_id}}" title="{{_ 'attachmentActionsPopup-title'}}")
i.fa.fa-bars
td
a.js-vcs-open-issue(href="#" data-card-id="{{cardId}}")
span.js-vcs-open-issue(role="link" tabindex="0" data-card-id="{{cardId}}")
if number
| {{numberText}}&nbsp;
| {{issueTitle}}
+inlineViewer
= issueTitle
td.quiet {{date}}
else
tr
@@ -216,7 +270,9 @@ template(name="versionControlIssue")
i.fa.fa-arrow-left
| &nbsp;{{_ 'vcs-issues'}}
h2.vcs-issue-heading
| {{title}}&nbsp;
+inlineViewer
= title
| &nbsp;
if number
span.quiet {{numberText}}
.vcs-issue-status
@@ -273,7 +329,9 @@ template(name="versionControlIssue")
.vcs-side-block
h3 {{_ 'labels'}}
each labels
span.card-label.vcs-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}") {{name}}
span.card-label.vcs-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}")
+inlineViewer
= name
else
p.quiet {{_ 'vcs-none'}}
.vcs-side-block
@@ -281,7 +339,9 @@ template(name="versionControlIssue")
each milestones
p
i.fa.fa-map-signs
| &nbsp;{{title}}
| &nbsp;
+inlineViewer
= title
else
p.quiet {{_ 'vcs-none'}}
.vcs-side-block
@@ -308,7 +368,12 @@ template(name="versionControlNewIssue")
each boardLabels
label.vcs-new-label
input.js-vcs-new-label(type="checkbox" value="{{_id}}")
span.card-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}") {{#if name}}{{name}}{{else}}{{color}}{{/if}}
span.card-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}")
if name
+inlineViewer
= name
else
| {{color}}
if error
p.warning {{error}}
.vcs-comment-actions
@@ -5,6 +5,8 @@ import CardComments from '/models/cardComments';
import { Utils } from '/client/lib/utils';
import { liveAttachments } from '/models/lib/attachmentSoftDelete';
import { IMPORT_SOURCES } from '/models/lib/importSources';
import { attachmentKind } from '/models/lib/attachmentKind';
import { openAttachmentSlideshow } from '/client/components/cards/attachments';
const vcs = require('/models/lib/versionControlView');
@@ -16,6 +18,13 @@ const vcs = require('/models/lib/versionControlView');
const TAB_ICONS = { issues: 'fa-dot-circle-o', milestones: 'fa-map-signs', labels: 'fa-tag', releases: 'fa-tags', wiki: 'fa-book', files: 'fa-file-o', history: 'fa-history' };
const TAB_LABELS = { issues: 'vcs-issues', milestones: 'vcs-milestones', labels: 'labels', releases: 'vcs-releases', wiki: 'vcs-wiki', files: 'attachments', history: 'history' };
const SOURCES = ['github', 'gitlab', 'gitea', 'forgejo'];
// A click on a link inside a rendered title or label (inlineViewer), not on
// the title itself.
const inRenderedLink = event => {
const link = event.target && event.target.closest && event.target.closest('a[href]');
return !!(link && event.currentTarget.contains(link) && link !== event.currentTarget);
};
const SOURCE_NAMES = { github: 'GitHub', gitlab: 'GitLab', gitea: 'Gitea', forgejo: 'Forgejo', jira: 'Jira' };
// The view's state. One board view is drawn at a time, so it lives here and is
@@ -243,7 +252,14 @@ Template.versionControlView.helpers({
const issues = new Map(boardIssues().map(i => [i._id, i]));
return filesOf([...issues.keys()]).map((file) => {
const issue = issues.get(file.meta && file.meta.cardId) || {};
const kind = attachmentKind(file);
return {
_id: file._id,
attachment: file,
isImage: !!kind.isImage,
// What the attachment viewer opens (cards/attachments.js attachmentCanBeOpened).
canPreview: !!(kind.isImage || kind.isPDF || kind.isText || kind.isJSON
|| kind.isOffice || kind.isZIP || kind.isVideo || kind.isAudio),
name: file.name,
link: typeof file.link === 'function' ? file.link() : '',
size: humanSize(file.size),
@@ -263,7 +279,35 @@ function setQuery(tpl, value) {
tab.set('issues');
}
// The popups of an opened card's attachment work on the attachment itself.
const fileOf = event => ReactiveCache.getAttachment(event.currentTarget.dataset.attachmentId);
const deleteFile = Popup.afterConfirm('attachmentDelete', function () {
// A soft delete, as the card's gallery does (History.md §12.1).
if (this._id) Meteor.call('attachments.softDelete', this._id);
Popup.back();
});
Template.versionControlView.events({
// Viewed in the card's viewer; next and previous walk this tab's files.
'click .js-vcs-file-view'(event, tpl) {
event.preventDefault();
const ids = tpl.findAll('.vcs-file[data-attachment-id]').map(row => row.dataset.attachmentId);
openAttachmentSlideshow(event.currentTarget.dataset.attachmentId, ids);
},
// Not bubbled to FlowRouter, which would empty the page (#101).
'click .vcs-file-actions .js-download'(event) { event.stopPropagation(); },
'click .js-vcs-file-rename'(event, tpl) {
const file = fileOf(event);
if (file) Popup.open('attachmentRename').call(file, event, tpl);
},
'click .js-vcs-file-delete'(event, tpl) {
const file = fileOf(event);
if (file) deleteFile.call(file, event, tpl);
},
'click .js-vcs-file-menu'(event, tpl) {
const file = fileOf(event);
if (file) Popup.open('attachmentActions').call(file, event, tpl);
},
'click .js-vcs-tab'(event) {
event.preventDefault();
tab.set(event.currentTarget.dataset.tab);
@@ -283,15 +327,28 @@ Template.versionControlView.events({
'change .js-vcs-sort'(event, tpl) {
setQuery(tpl, vcs.withQualifier(query.get(), 'sort', event.currentTarget.value));
},
// The clickable titles and labels are spans with role=link: Enter and
// Space click them, as on a link or a button.
'keydown [role="link"]'(event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.currentTarget.click();
},
'click .js-vcs-label'(event, tpl) {
// A link inside a rendered title opens itself.
if (inRenderedLink(event)) return;
event.preventDefault();
setQuery(tpl, vcs.withQualifier(tab.get() === 'issues' ? query.get() : 'is:open', 'label', event.currentTarget.dataset.name));
},
'click .js-vcs-milestone'(event, tpl) {
// A link inside a rendered title opens itself.
if (inRenderedLink(event)) return;
event.preventDefault();
setQuery(tpl, vcs.withQualifier(tab.get() === 'issues' ? query.get() : 'is:open', 'milestone', event.currentTarget.dataset.title));
},
'click .js-vcs-open-issue'(event) {
// A link inside a rendered title opens itself.
if (inRenderedLink(event)) return;
event.preventDefault();
tab.set('issues');
creating.set(false);
@@ -309,6 +366,8 @@ Template.versionControlView.events({
Meteor.call('syncListNow', listId, () => syncing.set(null));
},
'click .js-vcs-wiki-page'(event) {
// A link inside a rendered title opens itself.
if (inRenderedLink(event)) return;
event.preventDefault();
wikiPageId.set(event.currentTarget.dataset.cardId);
wikiHistory.set(false);
+42 -7
View File
@@ -124,15 +124,44 @@ div.attachment-thumbnail-container .attachment-thumbnail-text {
.attachment-actions a:first-child {
margin-inline-start: 0;
}
.add-attachment {
display: flex;
/* #6796: the add button is a small "+" like every other section's
(.card-label.add-label), not a full-width dashed box. */
.attachment-gallery .add-attachment {
align-self: flex-start;
margin-top: 4px;
cursor: pointer;
}
/* A file with nothing to preview, or every file when Attachments: Preview is
off: a small button with the file icon and its extension, which still opens
the preview, instead of a 144x96 box (models/lib/attachmentTile.cjs). */
.attachment-item.attachment-item-compact {
margin-top: 8px;
gap: 8px;
}
button.attachment-thumbnail-container.attachment-thumbnail-compact,
button.attachment-thumbnail-container.attachment-thumbnail-compact:hover,
button.attachment-thumbnail-container.attachment-thumbnail-compact:focus,
button.attachment-thumbnail-container.attachment-thumbnail-compact:active {
flex: 0 0 auto;
width: auto;
min-width: 2.6em;
height: 2.6em;
padding: 0 0.4em;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px dashed #555;
border-radius: 5px;
padding: 10px;
cursor: pointer;
margin-top: 16px;
gap: 0.3em;
line-height: 1;
}
.attachment-thumbnail-compact .attachment-thumbnail-ext {
font-size: 0.75em;
text-transform: lowercase;
}
/* The name first and on a line of its own, so a long one is read, not hidden
behind the buttons; who, when and the buttons follow on the next line. */
.attachment-details > div:first-child {
flex: 1 1 100%;
min-width: 0;
}
.icon {
font-size: 1.5em;
@@ -512,3 +541,9 @@ div.attachment-thumbnail-container .attachment-thumbnail-text {
flex: 0 0 auto;
cursor: pointer;
}
/* #6796: the upload rules, in the upload popup rather than above the list. */
.attachment-upload-help {
margin: 8px 12px 4px;
font-size: 0.85em;
}
+32 -10
View File
@@ -23,6 +23,19 @@ template(name="cardAttachmentsPopup")
//- #3257: a card attached to this card, as Trello's card attachments.
li
a.js-attach-card {{_ 'attach-card'}}
//- #6802: a new drawing, the whole window, saved as an SVG attachment.
li
a.js-attach-drawing
i.fa.fa-pencil
| {{_ 'cardDrawingPopup-title'}}
//- #6796: the upload rules, where the upload is chosen - they used to be
body text above the opened card's attachment list.
.attachment-upload-help.quiet
if Meteor.settings.public.attachmentsUploadMaxSize
div {{_ 'max-upload-filesize'}} {{Meteor.settings.public.attachmentsUploadMaxSize}}
if Meteor.settings.public.attachmentsUploadMimeTypes
div {{_ 'allowed-upload-filetypes'}} {{Meteor.settings.public.attachmentsUploadMimeTypes}}
div {{_ 'invalid-file'}}
//- #3257: attach a card - a pasted card link (Copy card link gives one), or one
of this board's cards found by its title.
@@ -59,7 +72,7 @@ template(name="attachmentViewer")
#viewer-container
button.fa.fa-caret-left#prev-attachment(type="button" aria-label="{{_ 'previous'}}")
#viewer-content
img#image-viewer.hidden
img#image-viewer.hidden(alt="{{_ 'preview'}}")
video#video-viewer.hidden(controls="true")
audio#audio-viewer.hidden(controls="true")
object#pdf-viewer.hidden(type="application/pdf")
@@ -73,15 +86,22 @@ template(name="attachmentGallery")
.attachment-gallery
if canModifyCard
a.attachment-item.add-attachment.js-add-attachment(title="{{_ 'add-attachment'}}")
//- #6796: a small "+" like every other section's, not a full-width box.
if canAddAttachment
a.card-label.add-label.add-attachment.js-add-attachment(title="{{_ 'add-attachment'}}" aria-label="{{_ 'add-attachment'}}")
i.fa.fa-plus
each attachments
.attachment-item(class="{{#if isAttachmentMigrating _id}}migrating{{/if}}")
button.attachment-thumbnail-container.open-preview(type="button" aria-label="{{_ 'preview'}}: {{cleanFilename name}}" data-attachment-id="{{_id}}" data-card-id="{{ meta.cardId }}")
if link
//- #6796: Attachments: Preview, Show on Card (Board Settings / Card).
Off, the tile is a small file button that still opens the preview;
a file with nothing to preview (a .txt) always gets the small one.
.attachment-item(class="{{#if isAttachmentMigrating _id}}migrating{{/if}} {{#if attachmentTileCompact this}}attachment-item-compact{{/if}}")
button.attachment-thumbnail-container.open-preview(type="button" aria-label="{{_ 'preview'}}: {{cleanFilename name}}" data-attachment-id="{{_id}}" data-card-id="{{ meta.cardId }}" class="{{#if attachmentTileCompact this}}attachment-thumbnail-compact{{/if}}")
if attachmentTileCompact this
i.fa.fa-file-o(aria-hidden="true")
span.attachment-thumbnail-ext= extension
else if link
if(isImage)
img.attachment-thumbnail(src="{{attachmentPreviewUrl this}}" title="{{cleanFilename name}}" alt="{{cleanFilename name}}" loading="lazy")
else if($eq extension 'svg')
@@ -106,10 +126,12 @@ template(name="attachmentGallery")
div
b {{ cleanFilename name }}
span.file-size ({{fileSize size}})
if uploaderName
div.attachment-uploader {{uploaderName}}
if uploadedAt
div.attachment-uploaded-at {{uploadedAt}}
//- #6796: Attachments: Details, Show on Card - who and when.
unless $eq currentBoard.allowsAttachmentDetailsOnCard false
if uploaderName
div.attachment-uploader {{uploaderName}}
if uploadedAt
div.attachment-uploaded-at {{uploadedAt}}
.attachment-actions
a.js-download(href="{{link}}?download=true", download="{{downloadFilename name}}", title="{{_ 'download'}}")
i.fa.fa-arrow-down
+38 -3
View File
@@ -16,9 +16,12 @@ 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';
import { EscapeActions } from '/client/lib/escapeActions';
import { openNewDrawing } from '/client/lib/newDrawing';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { ReactiveVar } from 'meteor/reactive-var';
@@ -159,6 +162,8 @@ function openAttachmentViewer(attachmentId) {
switch(true){
case (kind.isImage):
$("#image-viewer").attr("src", getAttachmentUrl(attachment));
// #337: the picture is named by its file for a screen reader.
if (attachment.name) $("#image-viewer").attr("alt", attachment.name);
$("#image-viewer").removeClass("hidden");
break;
case (kind.isPDF):
@@ -334,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) {
@@ -463,6 +468,20 @@ 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) {
if (!file) return true;
const board = ReactiveCache.getBoard(file.meta?.boardId) || Utils.getCurrentBoard();
const link = typeof file.link === 'function' ? file.link() : file.link;
return attachmentTileCompact({
isImage: file.isImage, extension: file.extension, hasLink: !!link,
}, board?.allowsAttachmentPreviewsOnCard);
},
attachedCards() {
return Template.instance().attachedCards.get();
},
@@ -586,6 +605,17 @@ Template.cardAttachmentsPopup.events({
},
'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'),
'click .js-attach-card': Popup.open('attachCard'),
// #6802: draw a new file - the whole window - saved as an SVG attachment.
'click .js-attach-drawing'(event) {
event.preventDefault();
// This popup goes, and the drawing opens once the click is over: the
// document's click-outside handler would otherwise see a click on a popup
// that no longer exists and close the new one at once.
event.stopPropagation();
const cardId = this && this._id;
Popup.close();
setTimeout(() => openNewDrawing(cardId), 0);
},
});
// #3257: attach a card by a pasted card link, or one of this board's cards
@@ -647,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) {
@@ -865,9 +898,11 @@ Template.attachmentRenamePopup.events({
'click button.js-submit-edit-attachment-name'(event, tpl) {
// save button pressed
event.preventDefault();
// A migrated attachment can lack extensionWithDot; its extension is enough.
const suffix = this.extensionWithDot || (this.extension ? `.${this.extension}` : '');
const name = tpl.$('.js-edit-attachment-name')[0]
.value
.trim() + this.extensionWithDot;
.trim() + suffix;
if (name === sanitizeText(name)) {
Meteor.call('renameAttachment', this._id, name);
}
+285
View File
@@ -0,0 +1,285 @@
/* Card Settings (Board Settings / Card, docs/Features/Cards/Card-Settings.md):
"Show" and its two choices, then the sections drawn like checklists. */
.card-settings-inline { padding: 4px 0 8px; }
/* Two panes, Minicard first (the start: left in a left-to-right page, right in
a right-to-left one) and Card after it (sidebar.jade boardCardSettingsPopup).
STACKED by default - Minicard above Card - and the container scrolls as one;
SIDE BY SIDE once the popup is wide enough for two panes of about 20em,
and then each pane's body scrolls on its own while the container does not.
A collapsed pane is its header only, and the other pane takes the width.
The popup's content wrapper does not scroll here (popup.css), so the
container is the one scroller in the stacked layout. */
.card-settings-panes-container {
container-type: inline-size;
container-name: card-settings-panes;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
.card-settings-panes {
display: flex;
flex-direction: column;
flex: 0 0 auto;
gap: 8px;
}
.card-settings-pane { min-width: 0; }
.card-settings-pane-head {
display: flex;
align-items: center;
gap: 6px;
padding-block: 4px 6px;
border-block-end: 1px solid #ddd;
margin-block-end: 8px;
}
.card-settings-pane-collapse {
padding: 2px 4px;
color: var(--wekan-quiet-text, #595959);
text-decoration: none;
cursor: pointer;
}
.card-settings-pane-collapse:hover,
.card-settings-pane-collapse:focus-visible { color: #2573a7; }
.card-settings-pane-title {
margin: 0;
font-size: calc(17px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
}
.card-settings-pane-body[hidden] { display: none !important; }
/* The splitter: only side by side with neither pane collapsed (the container
query below shows it). A thin line with a wider grab area, the Admin Panel
left menu's grip (settingBody.css .left-menu-resize-handle) in look. */
.card-settings-splitter {
display: none;
flex: 0 0 10px;
align-self: stretch;
cursor: col-resize;
touch-action: none;
position: relative;
border-radius: 3px;
transition: background-color 0.2s ease;
}
.card-settings-splitter::before {
content: '';
position: absolute;
inset-block: 0;
inset-inline-start: 4px;
width: 2px;
background: #ddd;
}
.card-settings-splitter:hover,
.card-settings-splitter:focus-visible {
background: rgba(0, 123, 255, 0.4);
outline: none;
}
.card-settings-splitter:active { background: rgba(0, 123, 255, 0.6); }
body.card-settings-splitting-active {
user-select: none;
cursor: col-resize;
}
.card-settings-pane.is-pane-collapsed .card-settings-pane-head { margin-block-end: 0; }
@container card-settings-panes (min-width: 40em) {
.card-settings-panes {
flex-direction: row;
align-items: stretch;
flex: 1 1 auto;
min-height: 0;
gap: 0;
}
.card-settings-pane {
flex: 1 1 0;
display: flex;
flex-direction: column;
min-height: 0;
}
/* The Minicard pane takes its share of the width (the splitter between
them, models/lib/cardSettingsSplit.js); the Card pane takes the rest. */
.card-settings-panes:not(.has-collapsed-pane) > .card-settings-pane-minicard {
flex: 0 0 calc((100% - 10px) * var(--card-settings-split, 0.5));
}
.card-settings-pane.is-pane-collapsed { flex: 0 0 auto; }
.card-settings-panes:not(.has-collapsed-pane) > .card-settings-splitter { display: block; }
.card-settings-pane-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-inline-end: 4px;
}
}
/* A section: the checklist's grey block, its title line and its items. */
.card-settings-inline-section {
/* 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;
}
.card-settings-inline-section.ui-sortable-helper {
box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.3), 0 0 1px rgba(0, 0, 0, 0.5);
cursor: grabbing;
}
.card-settings-inline-section-head {
display: flex;
align-items: center;
gap: 6px;
padding-block: 4px;
}
.card-settings-inline-section-title {
flex: 1 1 auto;
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
min-width: 0;
overflow-wrap: anywhere;
}
.card-settings-inline-section-title.js-card-settings-section-handle { cursor: grab; }
.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: 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: #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; }
.card-settings-section-side > a.flex { margin: 0; }
.card-settings-inline-section.is-section-hidden .card-settings-inline-items { opacity: 0.55; }
/* One row on this side: one line, no caret - its checkbox where the other
sections' checkboxes are. */
.card-settings-inline-section.is-single .card-settings-inline-section-head {
padding-inline-start: calc(0.5em + 8px);
}
/* "+" under Custom Fields' items: a new custom field. */
.card-settings-add-custom-field { padding: 2px 0 0 22px; }
.card-settings-add-custom-field-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
min-height: 28px;
border-radius: 3px;
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: #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; }
/* An item: the checkbox, the name, and its hamburger at the end
(sidebar.css .card-settings-line is the line itself). */
.card-settings-inline .card-settings-line {
grid-template-columns: auto minmax(0, 1fr) auto;
/* A space's width between the checkbox and the name, as in text. */
column-gap: 0.3em;
border-bottom: 0;
padding-inline-start: 22px;
}
.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. */
.card-settings-section-menu .materialCheckBox,
.card-settings-item-menu .materialCheckBox { transition: none; }
.card-settings-section-menu .is-animating .materialCheckBox,
.card-settings-item-menu .is-animating .materialCheckBox { transition: 0.2s; }
/* The hamburgers' popups. */
.card-settings-item-menu,
.card-settings-section-menu { min-width: 16em; }
/* The hamburgers' checkboxes: a space's width before their label, as on
the rows. */
.card-settings-item-menu .card-settings-board-option > a.flex,
.card-settings-section-menu .card-settings-board-option > a.flex {
align-items: center;
column-gap: 0.3em;
}
/* A menu's options start at its edge, like its table: not .check-div's
indent for a form's checkboxes. */
.card-settings-item-menu .card-settings-board-option,
.card-settings-section-menu .card-settings-board-option {
margin-inline-start: 0;
}
/* A tick is drawn rotated (forms.css #6786) and reaches about 3px past its
box toward the label, which ate the space: drawn 3px further left, it keeps
it - the box, and so the label, do not move when it is ticked. Right to
left the label is on the other side of the box and the tick leans away
from it, so it stays as it is. */
:root:not([dir="rtl"]) .card-settings-inline .materialCheckBox.is-checked,
:root:not([dir="rtl"]) .card-settings-item-menu .materialCheckBox.is-checked,
:root:not([dir="rtl"]) .card-settings-section-menu .materialCheckBox.is-checked {
left: -6px;
}
/* A hamburger's panel (sidebar.jade cardSettingsInlinePanel): what the
hamburger holds, opened UNDER its line inside the pane, pushing the rest
down - not a popup. The open hamburger is tinted, and the panel hangs from
it in the same tint, with a notch pointing up at the line it belongs to. */
.card-settings-inline-menu.is-open,
.card-settings-add-custom-field-button.is-open {
background: rgba(41, 128, 185, 0.15);
color: #2573a7;
border-radius: 3px;
}
.card-settings-inline-panel {
position: relative;
margin-block: 6px 8px;
padding: 10px 12px 8px;
border: 1px solid rgba(41, 128, 185, 0.45);
border-radius: 4px;
background: rgba(41, 128, 185, 0.06);
/* Its own block, the width of what it hangs from. */
flex: 0 0 100%;
box-sizing: border-box;
}
.card-settings-inline-panel::before {
content: '';
position: absolute;
inset-block-start: -7px;
inset-inline-end: 10px;
width: 12px;
height: 12px;
background: inherit;
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: 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 {
inset-inline-end: auto;
inset-inline-start: 6px;
}
.card-settings-inline-panel-close {
/* Above the notch, which is drawn at the same end of the panel's top. */
z-index: 1;
position: absolute;
inset-block-start: 4px;
inset-inline-end: 6px;
color: var(--wekan-quiet-text, #595959);
padding: 2px 4px;
}
.card-settings-inline-panel-close:hover,
.card-settings-inline-panel-close:focus-visible { color: #2573a7; }
.card-settings-section-action-title {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 6px;
}
+35
View File
@@ -0,0 +1,35 @@
import './cardDetails.jade';
import { Utils } from '/client/lib/utils';
// docs/Features/Cards/Card-Fields.md: an opened card shows every field its
// board shows on cards, empty or not, less the Card Settings sections the
// board hides on Show on Card. The rules are models/lib/cardShownFields.js.
const f = require('/models/lib/cardShownFields');
// The board as its opened cards see it (cardShownFields.js boardForCard): the
// same for every card of the board, empty or not, so `data` (the card) does
// not change it - it is kept so the callers read as before.
export function cardBoardView(data) { // eslint-disable-line no-unused-vars
return f.boardForCard(Utils.getCurrentBoard());
}
// The opened card and each of its section templates read `currentBoard`; this
// template-level helper answers before the global one, so every
// `currentBoard.allowsX` in them asks the card's view - and a template not
// listed here (the board, Card Settings itself) is unchanged. The minicard has
// its own (client/components/cards/minicard.js MINICARD_FIELD_TEMPLATES).
export const CARD_FIELD_TEMPLATES = Object.freeze([
// One per Card Settings section the opened card draws (cardDetails.jade).
'cardDetails', 'cardFieldSectionLabels', 'cardFieldSectionDate', 'cardFieldSectionMembers',
'cardFieldSectionCard', 'cardFieldSectionTime', 'cardFieldSectionCustomFields',
'cardFieldSectionVote', 'cardFieldSectionPoker', 'cardFieldSectionDescription', 'cardFieldSectionScrum',
'cardFieldSectionChecklists', 'cardFieldSectionSubtasks', 'cardFieldSectionAttachments', 'cardCommentsSection',
]);
CARD_FIELD_TEMPLATES.forEach(name => {
Template[name].helpers({
currentBoard() {
const tpl = Template.instance();
return cardBoardView(tpl && tpl.data);
},
});
});
+14 -11
View File
@@ -19,17 +19,20 @@ template(name="cardCustomFieldsPopup")
= name
a.js-edit-custom-field(title="{{_ 'edit'}}" aria-label="{{_ 'edit'}}")
i.fa.fa-pencil-square-o(aria-hidden="true")
//- The rule separates the list above from "Add custom field" below - with
no custom field yet, there is nothing above it to separate from.
if customFields.length
hr
a.quiet-button.full.js-open-create-custom-field
i.fa.fa-plus
span {{_ 'createCustomField'}}
//- #5681: this card's fields linked, by name, to another card's.
a.quiet-button.full.js-open-custom-field-links
i.fa.fa-link
span {{_ 'custom-field-links'}}
//- In Card Settings' Custom Fields pane these two are forms of their own
under the list, so they are not repeated here (sidebar.css).
.card-custom-fields-more
//- The rule separates the list above from "Add custom field" below -
with no custom field yet, there is nothing above it to separate from.
if customFields.length
hr
a.quiet-button.full.js-open-create-custom-field
i.fa.fa-plus
span {{_ 'createCustomField'}}
//- #5681: this card's fields linked, by name, to another card's.
a.quiet-button.full.js-open-custom-field-links
i.fa.fa-link
span {{_ 'custom-field-links'}}
//- #5681: the cards this card's custom fields are linked to, and the form that
links one more - a pasted card link (Copy card link gives one) or one of this
-9
View File
@@ -33,9 +33,6 @@ template(name="dateBadgeBody")
b
| {{showWeek}}
template(name="dateBadge")
+dateBadgeBody(canModifyCard=canModifyCard baseClass="card-date" showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="cardReceivedDate")
+dateBadgeBody(canModifyCard=canModifyCard baseClass="card-date" showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
@@ -48,12 +45,6 @@ template(name="cardDueDate")
template(name="cardEndDate")
+dateBadgeBody(canModifyCard=canModifyCard baseClass="card-date" showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="dateCustomField")
+dateBadgeBody(showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="cardCustomFieldDate")
+dateBadgeBody(showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="voteEndDate")
+dateBadgeBody(canModifyCard=canModifyCard baseClass="card-date" showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
+137 -347
View File
@@ -187,360 +187,150 @@ function cardDateHelpers(extraHelpers) {
return Object.assign(base, extraHelpers);
}
// cardReceivedDate
Template.cardReceivedDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(cardFromDateContext().getReceived()));
});
});
// The four card dates - Received, Start, Due, End - each drawn twice: in the
// opened card (card*Date) and on the minicard (minicard*Date). Both badges of a
// date read the same field, colour themselves the same way and open the same
// editor, so each date is described once here and both templates are built
// from it.
const CARD_DATE_KINDS = {
Received: {
read: card => card.getReceived(),
editor: 'editCardReceivedDate',
helpers: {
classes() {
const tpl = Template.instance();
let classes = 'received-date ';
const data = cardFromDateContext();
const dueAt = data.getDue();
const endAt = data.getEnd();
const startAt = data.getStart();
const theDate = tpl.date.get();
Template.cardReceivedDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'received-date ';
const data = cardFromDateContext();
const dueAt = data.getDue();
const endAt = data.getEnd();
const startAt = data.getStart();
const theDate = tpl.date.get();
if (
(startAt && isAfter(theDate, startAt)) ||
(endAt && isAfter(theDate, endAt)) ||
(dueAt && isAfter(theDate, dueAt))
) {
classes += 'overdue';
} else {
classes += 'not-due';
}
return classes;
if (
(startAt && isAfter(theDate, startAt)) ||
(endAt && isAfter(theDate, endAt)) ||
(dueAt && isAfter(theDate, dueAt))
) {
classes += 'overdue';
} else {
classes += 'not-due';
}
return classes;
},
showTitle() {
const tpl = Template.instance();
const formattedDate = formatCardDateForDisplay(tpl.date.get(), true);
return `${TAPi18n.__('card-received-on')} ${formattedDate}`;
},
},
},
showTitle() {
const tpl = Template.instance();
const formattedDate = formatCardDateForDisplay(tpl.date.get(), true);
return `${TAPi18n.__('card-received-on')} ${formattedDate}`;
Start: {
read: card => card.getStart(),
editor: 'editCardStartDate',
helpers: {
classes() {
const tpl = Template.instance();
let classes = 'start-date ';
const data = cardFromDateContext();
const dueAt = data.getDue();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
if ((endAt && isAfter(theDate, endAt)) || (dueAt && isAfter(theDate, dueAt))) {
classes += 'overdue';
} else if (isAfter(theDate, nowVal)) {
classes += 'not-due';
} else {
classes += 'current';
}
return classes;
},
showTitle() {
const tpl = Template.instance();
const formattedDate = formatCardDateForDisplay(tpl.date.get(), true);
return `${TAPi18n.__('card-start-on')} ${formattedDate}`;
},
},
},
}));
// Only the due date counts down (dueCountdownText), and only until the card
// has an end date.
Due: {
read: card => card.getDue(),
editor: 'editCardDueDate',
helpers: {
classes() {
const tpl = Template.instance();
const data = cardFromDateContext();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
Template.cardReceivedDate.events({
'click .js-edit-date': openDateEditor('editCardReceivedDate'),
});
// cardStartDate
Template.cardStartDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(cardFromDateContext().getStart()));
});
});
Template.cardStartDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'start-date ';
const data = cardFromDateContext();
const dueAt = data.getDue();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
if ((endAt && isAfter(theDate, endAt)) || (dueAt && isAfter(theDate, dueAt))) {
classes += 'overdue';
} else if (isAfter(theDate, nowVal)) {
classes += 'not-due';
} else {
classes += 'current';
}
return classes;
return `due-date ${dueDateClass(theDate, nowVal, endAt)}`;
},
showTitle() {
const tpl = Template.instance();
const formattedDate = formatCardDateForDisplay(tpl.date.get(), true);
const data = cardFromDateContext();
const endAt = data.getEnd();
const title = `${TAPi18n.__('card-due-on')} ${formattedDate}`;
if (endAt) return title;
return `${title} (${dueCountdownText(tpl.date.get(), tpl.now.get())})`;
},
showDate() {
const tpl = Template.instance();
const formattedDate = formatCardDateForDisplay(tpl.date.get(), true);
const data = cardFromDateContext();
const endAt = data.getEnd();
if (endAt) return formattedDate;
return `${formattedDate} (${dueCountdownText(tpl.date.get(), tpl.now.get())})`;
},
},
},
showTitle() {
const tpl = Template.instance();
const formattedDate = formatCardDateForDisplay(tpl.date.get(), true);
return `${TAPi18n.__('card-start-on')} ${formattedDate}`;
End: {
read: card => card.getEnd(),
editor: 'editCardEndDate',
helpers: {
classes() {
const tpl = Template.instance();
let classes = 'end-date ';
const data = cardFromDateContext();
const dueAt = data.getDue();
const theDate = tpl.date.get();
if (!dueAt) {
classes += 'completed';
} else if (isBefore(theDate, dueAt)) {
classes += 'completed-early';
} else if (isAfter(theDate, dueAt)) {
classes += 'completed-late';
} else {
classes += 'completed-on-time';
}
return classes;
},
showTitle() {
const tpl = Template.instance();
return `${TAPi18n.__('card-end-on')} ${formatCardDateForDisplay(tpl.date.get(), true, date => format(date, 'LLLL'))}`;
},
},
},
}));
};
Template.cardStartDate.events({
'click .js-edit-date': openDateEditor('editCardStartDate'),
});
// cardDueDate
Template.cardDueDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(cardFromDateContext().getDue()));
});
});
Template.cardDueDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
const data = cardFromDateContext();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
return `due-date ${dueDateClass(theDate, nowVal, endAt)}`;
},
showTitle() {
const tpl = Template.instance();
const formattedDate = formatCardDateForDisplay(tpl.date.get(), true);
const data = cardFromDateContext();
const endAt = data.getEnd();
const title = `${TAPi18n.__('card-due-on')} ${formattedDate}`;
if (endAt) return title;
return `${title} (${dueCountdownText(tpl.date.get(), tpl.now.get())})`;
},
showDate() {
const tpl = Template.instance();
const formattedDate = formatCardDateForDisplay(tpl.date.get(), true);
const data = cardFromDateContext();
const endAt = data.getEnd();
if (endAt) return formattedDate;
return `${formattedDate} (${dueCountdownText(tpl.date.get(), tpl.now.get())})`;
},
}));
Template.cardDueDate.events({
'click .js-edit-date': openDateEditor('editCardDueDate'),
});
// cardEndDate
Template.cardEndDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(cardFromDateContext().getEnd()));
});
});
Template.cardEndDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'end-date ';
const data = cardFromDateContext();
const dueAt = data.getDue();
const theDate = tpl.date.get();
if (!dueAt) {
classes += 'completed';
} else if (isBefore(theDate, dueAt)) {
classes += 'completed-early';
} else if (isAfter(theDate, dueAt)) {
classes += 'completed-late';
} else {
classes += 'completed-on-time';
}
return classes;
},
showTitle() {
const tpl = Template.instance();
return `${TAPi18n.__('card-end-on')} ${formatCardDateForDisplay(tpl.date.get(), true, date => format(date, 'LLLL'))}`;
},
}));
Template.cardEndDate.events({
'click .js-edit-date': openDateEditor('editCardEndDate'),
});
// cardCustomFieldDate
Template.cardCustomFieldDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().value));
});
});
Template.cardCustomFieldDate.helpers(cardDateHelpers({
showDate() {
const tpl = Template.instance();
return formatCardDateForDisplay(tpl.date.get(), true);
},
showTitle() {
const tpl = Template.instance();
const formattedDate = formatCardDateForDisplay(tpl.date.get(), true);
return `${formattedDate}`;
},
classes() {
return 'customfield-date';
},
}));
// --- Minicard date templates ---
// minicardReceivedDate
Template.minicardReceivedDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(cardFromDateContext().getReceived()));
});
});
Template.minicardReceivedDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'received-date ';
const data = cardFromDateContext();
const dueAt = data.getDue();
const endAt = data.getEnd();
const startAt = data.getStart();
const theDate = tpl.date.get();
if (
(startAt && isAfter(theDate, startAt)) ||
(endAt && isAfter(theDate, endAt)) ||
(dueAt && isAfter(theDate, dueAt))
) {
classes += 'overdue';
} else {
classes += 'not-due';
}
return classes;
},
showTitle() {
const tpl = Template.instance();
const formattedDate = formatCardDateForDisplay(tpl.date.get(), true);
return `${TAPi18n.__('card-received-on')} ${formattedDate}`;
},
showDate() {
return formatCardDateForDisplay(Template.instance().date.get(), true);
},
}));
Template.minicardReceivedDate.events({
'click .js-edit-date': openDateEditor('editCardReceivedDate'),
});
// minicardStartDate
Template.minicardStartDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(cardFromDateContext().getStart()));
});
});
Template.minicardStartDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'start-date ';
const data = cardFromDateContext();
const dueAt = data.getDue();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
if ((endAt && isAfter(theDate, endAt)) || (dueAt && isAfter(theDate, dueAt))) {
classes += 'overdue';
} else if (isAfter(theDate, nowVal)) {
classes += 'not-due';
} else {
classes += 'current';
}
return classes;
},
showTitle() {
const tpl = Template.instance();
const formattedDate = formatCardDateForDisplay(tpl.date.get(), true);
return `${TAPi18n.__('card-start-on')} ${formattedDate}`;
},
showDate() {
return formatCardDateForDisplay(Template.instance().date.get(), true);
},
}));
Template.minicardStartDate.events({
'click .js-edit-date': openDateEditor('editCardStartDate'),
});
// minicardDueDate
Template.minicardDueDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(cardFromDateContext().getDue()));
});
});
Template.minicardDueDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
const data = cardFromDateContext();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
return `due-date ${dueDateClass(theDate, nowVal, endAt)}`;
},
showTitle() {
const tpl = Template.instance();
const formattedDate = formatCardDateForDisplay(tpl.date.get(), true);
const data = cardFromDateContext();
const endAt = data.getEnd();
const title = `${TAPi18n.__('card-due-on')} ${formattedDate}`;
if (endAt) return title;
return `${title} (${dueCountdownText(tpl.date.get(), tpl.now.get())})`;
},
showDate() {
const tpl = Template.instance();
const formattedDate = formatCardDateForDisplay(tpl.date.get(), true);
const data = cardFromDateContext();
const endAt = data.getEnd();
if (endAt) return formattedDate;
return `${formattedDate} (${dueCountdownText(tpl.date.get(), tpl.now.get())})`;
},
}));
Template.minicardDueDate.events({
'click .js-edit-date': openDateEditor('editCardDueDate'),
});
// minicardEndDate
Template.minicardEndDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(cardFromDateContext().getEnd()));
});
});
Template.minicardEndDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'end-date ';
const data = cardFromDateContext();
const dueAt = data.getDue();
const theDate = tpl.date.get();
if (!dueAt) {
classes += 'completed';
} else if (isBefore(theDate, dueAt)) {
classes += 'completed-early';
} else if (isAfter(theDate, dueAt)) {
classes += 'completed-late';
} else {
classes += 'completed-on-time';
}
return classes;
},
showTitle() {
const tpl = Template.instance();
return `${TAPi18n.__('card-end-on')} ${formatCardDateForDisplay(tpl.date.get(), true, date => format(date, 'LLLL'))}`;
},
showDate() {
return formatCardDateForDisplay(Template.instance().date.get(), true);
},
}));
Template.minicardEndDate.events({
'click .js-edit-date': openDateEditor('editCardEndDate'),
});
for (const [kind, spec] of Object.entries(CARD_DATE_KINDS)) {
for (const name of [`card${kind}Date`, `minicard${kind}Date`]) {
Template[name].onCreated(function () {
cardDateOnCreated(this);
this.autorun(() => {
this.date.set(new Date(spec.read(cardFromDateContext())));
});
});
Template[name].helpers(cardDateHelpers(spec.helpers));
Template[name].events({
'click .js-edit-date': openDateEditor(spec.editor),
});
}
}
// minicardCustomFieldDate
Template.minicardCustomFieldDate.onCreated(function () {
+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}}
+187 -48
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 */
@@ -366,43 +401,43 @@ body.desktop-mode .card-details.card-details-collapsed {
position: relative;
}
/* The title is two things at once, split like a minicard's: the LEADING half
opens the editor, the trailing half drags the window. With drag handles OFF
there is no handle, so the title bar is what the window is moved by - and a
heading that is entirely an edit target leaves nothing to take hold of.
/* #6839: the whole title edits - every word of it and the space beside a
short last line, which is what anybody clicks to change the title. The window
is moved from the empty space ABOVE the title, in the row of the collapse
caret and the menu (cardDetails.js), or by the drag handle when drag handles
are on. It was split like a minicard's - the trailing half dragged - and a
click on the right of the title did nothing anybody expected.
An overlay rather than a split of the text, because the text is a `.viewer`
and cutting it in two would cut the sentence in two. `inset-inline-start`, so
the edit half is the left in English and the right in Arabic from one rule.
The drag handler steps aside for this zone (cardDetails.js). */
An overlay rather than a link around the text, because the text is a
`.viewer` with its own links. */
.card-details .card-details-header .card-details-title .card-details-title-edit-zone {
position: absolute;
top: 0;
bottom: 0;
inset-inline-start: 0;
/* Half of the heading - and the heading is stretched to the row below, so
this is half of the BAR however long the title happens to be. */
width: 50%;
width: 100%;
cursor: text;
z-index: 1;
}
/* The heading fills the row rather than shrinking to its text, so "half the
title" and "half the bar" are the same half however long the title is. A
heading only as wide as its words leaves the empty space beside it belonging
to nothing - and that space is where anybody would grab to move the window.
It stays inline-block so the complete-toggle beside it keeps its place; the
buttons floating at the trailing edge take their room out of this width. */
/* The heading fills the row rather than shrinking to its text, so the edit
zone covers the space beside a short title too. It stays inline-block so
the complete-toggle beside it keeps its place. */
.card-details .card-details-header .card-details-title {
min-width: 60%;
}
/* With drag handles ON the handle is the only drag source, so the whole title
is the edit target - the same rule the minicard follows, from the same
question: `card-details-with-handle` is set by the template from
isTouchScreenOrShowDesktopDragHandles, which is Utils.showDragHandles(). */
.card-details.card-details-with-handle .card-details-header .card-details-title .card-details-title-edit-zone {
width: 100%;
/* The empty part of the header row is what moves the window when there is no
drag handle; with one, the handle alone does. */
.card-details:not(.card-details-with-handle) .card-details-header {
cursor: move;
}
.card-details .card-details-header .card-details-title,
.card-details .card-details-header a {
cursor: auto;
}
.card-details .card-details-header a {
cursor: pointer;
}
.card-details .card-details-header .linked-card-location {
font-style: italic;
@@ -490,10 +525,38 @@ body.desktop-mode .card-details.card-details-collapsed {
cursor: pointer;
}
.card-details .card-details-person-controls {
align-items: flex-start;
align-items: center;
display: flex;
flex-direction: column;
gap: 0.25em;
flex-direction: row;
gap: 0.4em;
}
/* #6796: a field's add button sits on the line of its title, not on a line of
its own under it - which was most of the height of an opened card with
nothing filled in yet. What a field holds (labels, avatars, dates) follows
on the same line and wraps onto the next when there is more of it. */
.card-details .card-details-group-body > .card-details-item:is(
.card-details-item-labels, .card-details-item-stickers, .card-details-item-location,
.card-details-item-received, .card-details-item-start, .card-details-item-due,
.card-details-item-end, .card-details-item-members, .card-details-item-assignees,
.card-details-item-creator, .card-details-item-name, .card-details-item-dependencies) {
display: flex;
flex-wrap: wrap;
align-items: center;
column-gap: 0.4em;
row-gap: 0.25em;
}
/* The title as wide as its words, not the whole item (checklists.css and the
section header rule give every .card-details-item-title width: 100%), so
the add button has room beside it. */
.card-details .card-details-group-body > .card-details-item:is(
.card-details-item-labels, .card-details-item-stickers, .card-details-item-location,
.card-details-item-received, .card-details-item-start, .card-details-item-due,
.card-details-item-end, .card-details-item-members, .card-details-item-assignees,
.card-details-item-creator, .card-details-item-name, .card-details-item-dependencies)
> .card-details-item-title {
width: auto;
flex: 0 1 auto;
margin-block: 0;
}
.card-details .activities {
padding-top: 10px;
@@ -508,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;
@@ -559,6 +635,9 @@ body.desktop-mode .card-details.card-details-collapsed {
float: inline-end;
inset-inline-start: 50%;
margin: 15px 0;
/* The comments are here when maximized (cardCommentsSection): the column
is as wide as the left one, beside it. */
width: calc(50% - 40px);
}
.card-details-maximized .card-details-header {
width: 100%;
@@ -815,15 +894,6 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
.voted {
opacity: 0.7;
}
.vote-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.vote-title .js-edit-date {
align-self: flex-start;
margin-inline-start: 6px;
}
.vote-result {
display: flex;
gap: 6px;
@@ -834,15 +904,6 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
.poker-voted {
opacity: 0.7;
}
.poker-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.poker-title .js-edit-date {
align-self: flex-start;
margin-inline-start: 6px;
}
.poker-result {
display: flex;
flex-wrap: wrap;
@@ -1083,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 {
@@ -1199,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
@@ -1235,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;
}
@@ -1366,6 +1448,28 @@ body.desktop-mode .card-details.card-details-position-pending:not(.card-details-
opacity: 0.8;
}
/* At risk, Show on Card (docs/Features/Cards/At-Risk.md). */
.card-details-at-risk {
margin: 4px 0 8px;
padding: 4px 8px;
border-radius: 4px;
background: #fdecea;
color: #8a1c12;
overflow-wrap: anywhere;
}
/* Suggested members, above the full list (docs/Features/Cards/Suggested-Members.md). */
.card-members-suggested {
margin: 6px 0 4px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.card-members-suggested-title {
margin: 0 0 2px;
font-size: 0.85em;
color: #666;
}
/* #2015: the card's link as a QR code - always black on white with its quiet
zone, whatever the theme, so a phone camera can read it. */
.card-qr-code {
@@ -1385,3 +1489,38 @@ body.desktop-mode .card-details.card-details-position-pending:not(.card-details-
font-size: 0.8em;
word-break: break-all;
}
/* A maximized card has its comments at the top of the right column
(cardCommentsSection): nothing is above them there to be separated from, so
their heading has no rule. */
.card-details-right .card-details-comments .card-details-section-rule {
display: none;
}
/* Card kind: the selected one is marked, at the end of its line, and bold. */
.card-kind-list .js-select-card-kind.is-active { font-weight: bold; }
.card-kind-list .card-kind-selected { margin-inline-start: 0.5em; color: #3cb500; }
/* ONE SECTION MODEL (2026-10-28): the opened card draws Card Settings'
sections (cardDetails.jade `.card-details-section`), each a full-width row
of the items, headed by cardSectionHeader with the rule above it. The FIRST
section has nothing above it to be separated from, so its rule is not drawn
- whichever section the board put first (the template cannot know). */
.card-details .card-details-items > .card-details-section {
flex: 0 0 100%;
width: 100%;
min-width: 0;
}
.card-details .card-details-items > .card-details-section:first-child > hr.card-details-section-rule:first-child,
.card-details .card-details-items > .card-details-section:first-child > * > hr.card-details-section-rule:first-child,
.card-details .card-details-items > .card-details-section:first-child > * > .comment-title > hr.card-details-section-rule:first-child,
.card-details .card-details-items > .card-details-section:first-child > .card-details-group-vote-poker > hr:first-child {
display: none;
}
/* Lists that were sections of their own and are a field of one now: a row of
their own inside the section. */
.card-details .card-details-group-body > .card-details-item-similar-cards,
.card-details .card-details-group-body > .card-details-item-buttons,
.card-details .card-details-section .card-details-item-text-notes {
flex: 0 0 100%;
}
File diff suppressed because it is too large. Load diff
+404 -228
View File
@@ -1,4 +1,5 @@
import { getSidebarInstance } from '/client/features/sidebar/service';
import { filterSubtasksOf } from '/client/lib/filterSubtasks';
import { backIfOwnPopup } from '/client/lib/popupBack';
import { canEditCardDependenciesHere } from '/client/lib/dependencyLayers';
import { relativeCardSort } from '/client/lib/relativeCardPosition';
import { Random } from 'meteor/random';
@@ -10,6 +11,7 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { ReactiveVar } from 'meteor/reactive-var';
import { resolveShowCardLabelText } from '/client/lib/minicardLabelText';
const { isCardFieldHiddenOnBoard } = require('/models/lib/cardFieldVisibility');
import { cardBoardView } from '/client/components/cards/cardAddFields';
import { hasUnreadComments } from '/models/lib/unreadComments';
import {
DEPENDENCY_TYPES,
@@ -20,6 +22,11 @@ import {
import { canArchiveCard } from '/client/lib/archivePermission';
import { isTextSelectionInsideCard } from '/client/lib/cardCloseGuard';
import { placeCardDetailsX, MARGIN } from '/client/lib/cardDetailsPlacement';
import { dueFromTitle } from '/client/components/forms/titleDueHint';
import { riskOf } from '/client/lib/cardRiskOf';
import Rules from '/models/rules';
import { suggestedMembersFor } from '/client/lib/suggestedMembers';
import '/client/components/cards/similarCards';
// Which dependency the icon picker should apply its choice to. The icon popup's
// own data context is the dependency row (not the source card), so we capture
@@ -31,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';
@@ -64,7 +52,6 @@ import { isHexColor, isAllowedCardColor, toHex, contrastText, customColorsInUse
import { uniqBy } from '/imports/lib/collectionHelpers';
import { memberTargetBoardId } from '/models/lib/linkedCardMembers';
import { isSubmitKey } from '/models/lib/editorSubmitKey';
import { UserAvatar } from '../users/userAvatar';
import { Filter } from '/client/lib/filter';
import { BoardSwimlaneListCardDialog } from '/client/lib/dialogWithBoardSwimlaneListCard';
import { handleFileUpload } from './attachments';
@@ -79,10 +66,12 @@ import { CSSEvents } from '/client/lib/cssEvents';
import { UnsavedEdits } from '/client/lib/unsavedEdits';
import { EscapeActions } from '/client/lib/escapeActions';
import { MultiSelection } from '/client/lib/multiSelection';
import { errorMessage } from '/client/lib/errorMessage';
import { Utils } from '/client/lib/utils';
import autosize from 'autosize';
import { caretClassFor } from '/client/lib/sectionCaret';
const { openCardIsUnavailable } = require('/models/lib/openCardPresence');
import { restoreArchivedCard } from '/client/components/sidebar/sidebarArchives';
const { openCardIsUnavailable, openCardCloseDecision } = require('/models/lib/openCardPresence');
// Id of the location currently being edited in the cardLocationsPopup; null
// when adding a new location.
@@ -92,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');
@@ -349,9 +339,68 @@ function bindCardDetailsResize() {
});
}
// #3114 / #6808: close every way the card `cardId` is open - the window,
// the popup, the card's address - once it no longer belongs on `boardId`.
function closeGoneCard(cardId, boardId) {
Session.set(
'openCards',
(Session.get('openCards') || []).filter(id => id !== cardId),
);
if (Session.get('currentCard') === cardId) {
Session.set('currentCard', null);
}
if (Session.get('popupCardId') === cardId) {
Session.set('popupCardId', null);
Session.set('popupCardBoardId', null);
Popup.close();
}
const routeCardId = FlowRouter.current()?.params?.cardId;
if (routeCardId === cardId) {
Utils.goBoardId(boardId);
}
}
// #6808: a card missing from Minimongo is closed only when the server
// confirms it is gone, asked once the connection and the login have settled,
// and only if Minimongo still agrees when it answers. Kept OUTSIDE the
// window's lifecycle on purpose: when a card really is deleted, its window is
// destroyed with its data, often before the answer comes back - the close (and
// the step back from the card's address) must still happen then. A dropped
// connection that comes back answers "available", and nothing is closed.
const confirmingGoneCards = new Set();
function confirmGoneCard(cardId, boardId) {
if (!cardId || !boardId || confirmingGoneCards.has(cardId)) return;
confirmingGoneCards.add(cardId);
// Not a child of whatever computation calls this, so it outlives it.
Tracker.nonreactive(() => Tracker.autorun(computation => {
if (!Meteor.status().connected || Meteor.loggingIn()) return;
computation.stop();
Meteor.callAsync('cards.openCardAvailable', cardId, boardId)
.then(available => {
confirmingGoneCards.delete(cardId);
if (available) return;
// Still gone now that the server has answered.
if (!openCardIsUnavailable(ReactiveCache.getCard(cardId), boardId)) return;
closeGoneCard(cardId, boardId);
})
.catch(() => {
// No answer (the connection dropped again): keep the card; it is
// asked again the next time it looks gone.
confirmingGoneCards.delete(cardId);
});
}));
}
Template.cardDetails.onCreated(function () {
this.currentBoard = Utils.getCurrentBoard();
this.isLoaded = new ReactiveVar(false);
// Whether the desktop window has been placed beside its minicard: until
// then it carries card-details-position-pending (unpainted). A reactive
// class rather than a static one that onRendered removes: Blaze writes a
// tag's static classes again whenever its class attribute changes, so the
// removed class came back on Maximize / Minimize and Collapse, and the
// opened card turned invisible (latin-card-archive.e2e.js).
this.positionSettled = new ReactiveVar(false);
this.infiniteScrolling = new InfiniteScrolling();
const openedCardId = this.data?._id;
const openedBoardId = this.data?.boardId;
@@ -361,32 +410,24 @@ Template.cardDetails.onCreated(function () {
// board. Minimongo then removed the data while the mobile details view kept
// rendering an empty shell. Observe the card that created this instance and
// close every way it can be open as soon as it no longer belongs here.
// #6808: but a card that only LOOKS gone - Minimongo emptied for a moment
// while a dropped connection comes back and its subscriptions re-run, as
// behind a reverse proxy while the page loads - is not closed: the server
// is asked first (models/lib/openCardPresence.js openCardCloseDecision).
this.autorun(() => {
if (!openedCardId || !openedBoardId) return;
// A same-tab card link can render the details before the destination card
// publication arrives. Only a ready subscription can prove it is gone.
if (!cardSubscription?.ready()) return;
const card = ReactiveCache.getCard(openedCardId);
if (!openCardIsUnavailable(card, openedBoardId)) return;
Session.set(
'openCards',
(Session.get('openCards') || []).filter(id => id !== openedCardId),
);
if (Session.get('currentCard') === openedCardId) {
Session.set('currentCard', null);
}
if (Session.get('popupCardId') === openedCardId) {
Session.set('popupCardId', null);
Session.set('popupCardBoardId', null);
Popup.close();
}
const routeCardId = FlowRouter.current()?.params?.cardId;
if (routeCardId === openedCardId) {
Utils.goBoardId(openedBoardId);
}
const decision = openCardCloseDecision({
// A same-tab card link can render the details before the destination
// card publication arrives. Only a ready subscription can prove it is gone.
subReady: Boolean(cardSubscription?.ready()),
connected: Meteor.status().connected,
loggingIn: Meteor.loggingIn(),
card: ReactiveCache.getCard(openedCardId),
openedBoardId,
});
if (decision === 'confirm') confirmGoneCard(openedCardId, openedBoardId);
});
this.openedBoardId = openedBoardId;
// #6686: a sub-popup opened from this card (Labels, Members, ...) is a
// global singleton (client/lib/popup.js) that is not tied to this card
@@ -483,6 +524,7 @@ Template.cardDetails.onRendered(function () {
// #6465: place the window beside its minicard (X only) once it has been laid
// out, so the width measured here is the one the stylesheet gave it.
const $cardDetails = this.$('.card-details').first();
const tpl = this;
if ($cardDetails.length) {
Tracker.afterFlush(() => {
const element = $cardDetails.get(0);
@@ -492,6 +534,9 @@ Template.cardDetails.onRendered(function () {
// The desktop window must never paint at the stylesheet's fallback
// dock before its minicard-relative geometry has been measured.
element.classList.remove('card-details-position-pending');
// And it stays removed: the class is drawn from this, so a later
// change of the window's classes does not bring it back.
tpl.positionSettled.set(true);
}
});
}
@@ -624,6 +669,13 @@ Template.cardDetails.onRendered(function () {
});
Template.cardDetails.onDestroyed(function () {
// #6808: the window is destroyed with its data when the card is deleted,
// often before the autorun above could ask the server - ask now. A card that
// merely left for a moment (a reconnect) comes back and answers "available".
if (this.openedCardId && this.openedBoardId
&& openCardIsUnavailable(ReactiveCache.getCard(this.openedCardId), this.openedBoardId)) {
confirmGoneCard(this.openedCardId, this.openedBoardId);
}
const boardBody = getBoardBodyInstance();
if (boardBody !== null) {
boardBody.showOverlay.set(false);
@@ -631,9 +683,12 @@ Template.cardDetails.onDestroyed(function () {
// #6686: see the comment in onCreated above. Only close a popup that is
// still showing THIS card's data at the base of its stack - a popup opened
// for an unrelated card/context must not be touched.
// for an unrelated card/context must not be touched. #6840: and only when
// this card is no longer the open one - closed, or another card opened. A
// card whose details are merely rendered again is still open, and its popup
// (the Due date one `d` just opened) must not flash shut.
const openedCardId = this.openedCardId;
if (openedCardId && Popup.isOpen()) {
if (openedCardId && Popup.isOpen() && Utils.getCurrentCardId() !== openedCardId) {
const baseStackEntry = Popup._stack?.[0];
if (baseStackEntry?.dataContext?._id === openedCardId) {
Popup.close();
@@ -685,8 +740,14 @@ Template.registerHelper('cardSwimlaneTitle', function cardSwimlaneTitle() {
return swimlane && swimlane.title;
});
// The Custom Fields section: on every card the reader may change whose board
// shows Custom Fields on cards, empty or not (docs/Features/Cards/Card-Fields.md)
// - its hamburger is where the fields are added. The board as its cards see it,
// so a board that hides the whole Custom Fields section on Show on Card
// (models/lib/cardShownFields.js boardForCard) hides it here too.
Template.registerHelper('canShowCustomFieldsOnCard', function canShowCustomFieldsOnCard() {
const board = this?.board?.();
const { boardForCard } = require('/models/lib/cardShownFields');
const board = boardForCard(this?.board?.());
return Utils.canModifyCard(this) && board?.allowsCustomFields !== false;
});
@@ -817,32 +878,122 @@ Template.registerHelper('isCurrentListId', function isCurrentListId(listId) {
return data.listId == listId;
});
// #4448: the fields INSIDE a reorderable section (the four dates, the people
// of Members, ...) render in the board's order too. One argument-less helper
// per section, registered globally like isDateFormat above because each
// section is its own template. The card is the data context (`each field in`
// keeps it), so `this.board()` is the board. models/lib/cardFieldOrder.js
// The opened card draws Card Settings' sections (models/lib/cardSettingsSections.js)
// in the board's Show on Card section order, and the fields INSIDE each in the
// order Card Settings lists that section's rows (board.cardFieldOrder through
// models/lib/cardFieldOrder.js and cardSettingsSections.sectionFieldsOn) - one
// order, so the card and Card Settings cannot disagree. One argument-less
// helper per section, registered globally because each section is its own
// template. The card is the data context (`each field in` keeps it), so
// `this.board()` is the board.
const cardSettingsSections = require('/models/lib/cardSettingsSections');
const { applyCardOrder } = require('/models/lib/cardFieldOrder');
function cardSectionFields(card, sectionId) {
const board = card?.board?.();
return cardSettingsSections.sectionFieldsOn('card', applyCardOrder(board?.cardFieldOrder), sectionId);
}
{
const { orderedCardFieldsOf } = require('/models/lib/cardFieldOrder');
const { boardAllows, boardForCard } = require('/models/lib/cardShownFields');
const sectionHelper = section => function orderedFields() {
const board = this?.board?.();
return orderedCardFieldsOf(board?.cardFieldOrder, section);
return cardSectionFields(this, section);
};
Template.registerHelper('orderedLabelsFields', sectionHelper('labels'));
Template.registerHelper('orderedDatesFields', sectionHelper('dates'));
Template.registerHelper('orderedDateFields', sectionHelper('date'));
Template.registerHelper('orderedMembersFields', sectionHelper('members'));
Template.registerHelper('orderedSortFields', sectionHelper('sort'));
Template.registerHelper('orderedVoteAndPokerFields', sectionHelper('voteAndPoker'));
Template.registerHelper('orderedCardFields', sectionHelper('card'));
Template.registerHelper('orderedTimeFields', sectionHelper('time'));
Template.registerHelper('orderedChecklistsFields', sectionHelper('checklists'));
Template.registerHelper('orderedAttachmentsFields', sectionHelper('attachments'));
// Only the Scrum fields the board shows on cards (Board Settings / Scrum,
// #6830): a Scrum field has no board flag in the template to read.
Template.registerHelper('orderedScrumFields', function orderedScrumFields() {
const board = boardForCard(this?.board?.());
return cardSectionFields(this, 'scrum').filter(key => boardAllows(board, key));
});
}
Template.cardFieldSectionSort.helpers({
hasVisibleSortFields() {
const board = this.board();
return !!(board && (
board.allowsCardSortingByNumber || board.allowsShowLists ||
board.allowsFlowtime || board.allowsPomodoro ||
(board.allowsSpentTime && this.getSpentTime())
));
// Whether the card has anything to show in section `id`, read through the
// board as its cards see it (cardShownFields.js boardForCard, which already
// takes away the rows of a section hidden whole on Show on Card): a section
// with nothing in it draws no heading. The same gates its template had before
// the card used Card Settings' sections (docs/Features/Cards/Card-Fields.md).
function cardSectionHasContent(card, board, id) {
const { boardAllows } = require('/models/lib/cardShownFields');
if (!board) return false;
const may = Utils.canModifyCard(card);
switch (id) {
case 'labels': return Boolean(board.allowsLabels || board.allowsStickers || board.allowsLocation);
case 'date':
return Boolean(board.allowsReceivedDate || board.allowsStartDate || board.allowsDueDate || board.allowsEndDate
|| (board.allowsAtRiskOnCard !== false && riskOf(card, { withMoves: true })));
case 'members':
return Boolean(board.allowsMembers || board.allowsAssignee || board.allowsCreator
|| board.allowsRequestedBy || board.allowsAssignedBy);
case 'card': {
const swimlane = typeof card?.swimlane === 'function' ? card.swimlane() : null;
return Boolean(board.allowsDependencies || board.allowsCardSortingByNumber || board.allowsShowLists
|| (board.allowsSwimlaneNameOnCard && swimlane && swimlane.title)
|| (board.allowsCardButtonsOnCard !== false && cardHasButtons(card)));
}
case 'time': return Boolean(board.allowsSpentTime || board.allowsFlowtime || board.allowsPomodoro);
case 'customFields': return may && board.allowsCustomFields !== false;
case 'vote': return Boolean(board.allowsVote);
case 'poker': return Boolean(board.allowsPoker);
case 'description': {
const shows = board.allowsDescriptionTitle || board.allowsDescriptionText;
const writer = may && !ReactiveCache.getCurrentUser()?.isWorker?.();
return Boolean(shows && (writer || (card && card.getDescription && card.getDescription())));
}
case 'scrum': return cardSectionFields(card, 'scrum').some(key => boardAllows(board, key));
case 'checklists': return Boolean(board.allowsChecklists);
case 'subtasks': return Boolean(board.allowsSubtasks);
case 'attachments': return Boolean(board.allowsAttachments || board.allowsTextNotes);
case 'comments': return Boolean(board.allowsComments);
default: return false;
}
}
// Whether the board has card buttons (rules) - what the cardButtons template
// draws (client/components/rules/cardButtons.js buttonRulesForBoard); without
// any, Card is not headed for them alone.
function cardHasButtons(card) {
return Boolean(card && card.boardId && Rules.find({ boardId: card.boardId, buttonType: 'card' }).count());
}
// The card's sections, in the board's Show on Card section order
// (cardSettingsSections.sectionOrderOf), less those hidden whole on Show on
// Card and those with nothing to show for this card - and less Comments on a
// maximized card, which has them at the top of the right column.
function orderedSectionsOf(card, { maximized = false } = {}) {
const { boardForCard } = require('/models/lib/cardShownFields');
const raw = card?.board?.();
const board = boardForCard(raw);
const hidden = cardSettingsSections.hiddenSectionsOf(raw, 'card');
return cardSettingsSections.sectionOrderOf(raw, 'card')
.filter(id => !hidden.includes(id))
.filter(id => !(maximized && id === 'comments'))
.filter(id => cardSectionHasContent(card, board, id));
}
function isCardMaximized() {
const currentUser = ReactiveCache.getCurrentUser();
const maximized = currentUser
? currentUser.hasCardMaximized()
: window.localStorage.getItem('cardMaximized') === 'true';
return !Utils.getPopupCardId() && maximized;
}
// What the Date and Checklists sections read that used to be the opened
// card's own helpers.
Template.cardFieldSectionDate.helpers({
cardAtRisk() {
return riskOf(Template.currentData(), { withMoves: true });
},
});
Template.cardFieldSectionChecklists.helpers({
checklistSectionOpen() {
return ReactiveCache.getBoard(this.boardId)?.allowsChecklistTitle === false || isCardSectionOpen('checklists');
},
});
@@ -854,54 +1005,54 @@ Template.cardDetails.helpers({
? Meteor.connection._lastSessionId
: null;
},
// #4448: the order the reorderable card-detail sections (Labels, Dates,
// Members, Dependencies, Sort, Custom Fields, Vote/Poker, Description)
// render in, resolved from the board's stored setting - the section order
// of the flat field order Board Settings / Card writes.
// models/lib/cardFieldOrder.js
// The sections the card draws, in Card Settings' Show on Card section order
// (orderedSectionsOf above): the ids of models/lib/cardSettingsSections.js.
orderedCardFieldSections() {
const board = this?.board?.();
if (board && typeof board.getCardFieldOrder === 'function') {
return board.getCardFieldOrder();
}
const { applyCardFieldOrder } = require('/models/lib/cardFieldOrder');
return applyCardFieldOrder(board?.cardFieldOrder);
return orderedSectionsOf(this, { maximized: isCardMaximized() });
},
// Placed beside its minicard: card-details-position-pending is off for good.
positionSettled() {
const tpl = Template.instance();
return Boolean(tpl && tpl.positionSettled && tpl.positionSettled.get());
},
cardMaximized() {
const currentUser = ReactiveCache.getCurrentUser();
const maximized = currentUser
? currentUser.hasCardMaximized()
: window.localStorage.getItem('cardMaximized') === 'true';
return !Utils.getPopupCardId() && maximized;
return isCardMaximized();
},
// List title, Show on Card: the card's own choice, unless the
// administrator unticked List title in Admin Panel / Settings / Visibility /
// Features (models/lib/cardFieldVisibility.js). The card keeps its choice.
// Its section (Card) hidden whole on Show on Card hides it as well.
showListOnCard() {
const board = this?.board?.();
return Boolean(this?.showListOnCard) && !isCardFieldHiddenOnBoard(board, 'listTitle');
const { hidesRow } = require('/models/lib/cardShownFields');
return Boolean(this?.showListOnCard) && !isCardFieldHiddenOnBoard(board, 'listTitle')
&& !hidesRow(board, 'card', 'listTitle');
},
// The header's own flags, read through the board as its cards see it
// (cardBoardView), so a Card Settings section hidden whole on Show on Card
// takes them away too.
// Card color, Show on Card: the header's color, on unless turned off.
showCardColorOnCard() {
return Utils.getCurrentBoard()?.allowsCardColorOnCard !== false;
return cardBoardView(this)?.allowsCardColorOnCard !== false;
},
// Collapse, Show on Card: the header's collapse caret, on unless turned off.
allowsCardCollapse() {
return Utils.getCurrentBoard()?.allowsCardCollapse !== false;
return cardBoardView(this)?.allowsCardCollapse !== false;
},
// Labels title, Show on Card: the labels above the title, as on a minicard.
showLabelsAboveTitleOnCard() {
return Utils.getCurrentBoard()?.labelsAboveTitleOnCard === true;
return cardBoardView(this)?.labelsAboveTitleOnCard === true;
},
// ...as long as the board shows labels at all.
labelsShownAboveTitle() {
return Utils.getCurrentBoard()?.allowsLabels !== false;
return cardBoardView(this)?.allowsLabels !== false;
},
cardCollapsed() {
// A board that turned the caret off has no collapsed cards.
if (Utils.getCurrentBoard()?.allowsCardCollapse === false) return false;
if (cardBoardView(this)?.allowsCardCollapse === false) return false;
const user = ReactiveCache.getCurrentUser();
if (user && user.profile) {
return !!user.profile.cardCollapsed;
@@ -943,10 +1094,6 @@ Template.cardDetails.helpers({
return user && user.isVerticalScrollbars();
},
checklistSectionOpen() {
return ReactiveCache.getBoard(this.boardId)?.allowsChecklistTitle === false || isCardSectionOpen('checklists');
},
isLoaded() {
return Template.instance().isLoaded;
},
@@ -954,9 +1101,9 @@ Template.cardDetails.helpers({
// WHICH SECTIONS OF A CARD ARE OPEN.
//
// Every section of an opened card - Labels, Date Format, Members, Dependencies,
// Sort, Custom Fields, Description, Checklists, Subtasks, Attachments, Comments
// and Activities - carries a caret that collapses it, and one rule above it.
// Every section of an opened card - Card Settings' sections, Labels, Date,
// Members, Card, Time, Custom Fields, ..., Comments, and Activities - carries a
// caret that collapses it, and one rule above it.
// Before this there was one caret for the whole card and an EYE on Activities
// that showed and hid exactly what a caret would: two controls for one idea,
// able to disagree with each other.
@@ -967,9 +1114,11 @@ Template.cardDetails.helpers({
//
// ACTIVITIES starts CLOSED and everything else open. A card is opened to read
// the card; its history is the thing you go looking for.
// The sections are Card Settings' (models/lib/cardSettingsSections.js ids,
// 2026-10-28), plus the right column's Activities and the Similar cards list
// inside Card.
const CARD_SECTIONS = [
'labels', 'date-format', 'members', 'dependencies', 'sort', 'custom-fields',
'description', 'checklists', 'subtasks', 'attachments', 'comments', 'activities',
...require('/models/lib/cardSettingsSections').SECTION_IDS, 'activities', 'similar-cards',
];
const COLLAPSED_BY_DEFAULT = ['activities'];
@@ -1006,10 +1155,18 @@ Template.cardSectionHeader.events({
// A hamburger at the end of a heading opens that section's settings. Neither
// may reach the heading itself: a click there folds the section, so opening
// the menu would close what you opened it from.
'click .js-open-subtasks-settings'(event) {
// Subtasks: Filter: Subtasks, and the subtask settings
// (docs/Features/Cards/Card-Fields.md).
'click .js-open-subtasks-menu'(event) {
event.preventDefault();
event.stopPropagation();
Popup.open('boardSubtaskSettings')(event);
Popup.open('cardSubtasksMenu').call(Utils.getCurrentCard(), event);
},
// Scrum settings: Card kind - a Story or a Task (#2012).
'click .js-section-card-kind'(event) {
event.preventDefault();
event.stopPropagation();
Popup.open('cardKind').call(Utils.getCurrentCard(), event);
},
// Every custom field the board has, ticked when it is on this card, with a
// pencil each and "Add custom field" under a rule. The card is its data
@@ -1034,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
@@ -1075,85 +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;
}
// ...and don't drag from the half that EDITS. The title is split like a
// minicard's: the leading half opens the editor, the trailing half is what
// you take hold of. With drag handles ON the zone covers the whole title
// (cardDetails.css), so the window is dragged by its handle alone - which is
// what turning handles on means.
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.
if ($(event.target).closest('.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();
@@ -1246,19 +1399,26 @@ Template.cardDetails.events({
// description" warning behind.
UnsavedEdits.reset({ fieldName: 'cardDescription', docId: card._id });
} catch (error) {
alert(error?.reason || error?.message || 'Failed to save description');
alert(errorMessage(error));
}
},
async 'submit .js-card-details-title'(event, tpl) {
event.preventDefault();
const titleInput = tpl.find('.js-edit-card-title');
const title = titleInput ? titleInput.value.trim() : '';
const typed = titleInput ? titleInput.value.trim() : '';
const card = Template.currentData();
// A due date typed at the end of a CHANGED title; saving the title a card
// already had never dates it. docs/Features/Cards/Dates/Due-Date-From-Title.md
const fromTitle = typed && typed !== card.title
? dueFromTitle(titleInput.closest('form'), typed, 'allowsDueFromTitleOnCard')
: null;
const title = fromTitle ? fromTitle.title : typed;
// #5809: surface a visible error instead of failing silently (see above).
try {
await card.setTitle(title || '');
if (fromTitle) await card.setDue(fromTitle.dueAt);
} catch (error) {
alert(error?.reason || error?.message || 'Failed to save title');
alert(errorMessage(error));
}
},
'submit .js-card-details-assigner'(event, tpl) {
@@ -1623,6 +1783,13 @@ Template.cardDetails.events({
});
// docs/Features/Cards/At-Risk.md: the opened card also counts moves back.
Template.cardDetails.helpers({
cardAtRisk() {
return riskOf(Template.currentData(), { withMoves: true });
},
});
Template.cardDetails.helpers({
isPopup() {
let ret = !!Utils.getPopupCardId();
@@ -1748,6 +1915,13 @@ Template.inlinedCardDescription.events({
});
Template.cardDetailsActionsPopup.helpers({
// The moving and copying items: for who may change the card, and - as they
// always were - for a member who is not read-only.
cardMenuWrites() {
if (Utils.canModifyCard(this)) return true;
const user = ReactiveCache.getCurrentUser();
return Boolean(user && !(user.isReadOnly && user.isReadOnly()) && !(user.isReadAssignedOnly && user.isReadAssignedOnly()));
},
isWatching() {
if (!this || typeof this.findWatcher !== 'function') return false;
return this.findWatcher(Meteor.userId());
@@ -1769,22 +1943,31 @@ Template.cardDetailsActionsPopup.helpers({
},
});
Template.cardDetailsActionsPopup.events({
// The section hamburgers of the opened card (docs/Features/Cards/Card-Fields.md):
// what the card's actions menu had, where the section is.
Template.cardSubtasksMenuPopup.events({
'click .js-filter-subtasks'(event) {
event.preventDefault();
const card = this.card || this;
if (!card?._id || card.boardId !== Session.get('currentBoard')) return;
Filter.parentId.reset();
Filter.parentId.add(card._id);
Filter.resetExceptions();
Session.set('openCards', (Session.get('openCards') || []).filter(id => id !== card._id));
if (Session.get('currentCard') === card._id) Session.set('currentCard', null);
Session.set('popupCardId', null);
Session.set('popupCardBoardId', null);
Popup.close();
Utils.goBoardId(card.boardId);
getSidebarInstance().setView('filter');
filterSubtasksOf(this.card || this);
},
'click .js-open-subtask-settings'(event) {
event.preventDefault();
Popup.open('boardSubtaskSettings')(event);
},
});
Template.cardFieldSectionVote.events({
'click .js-section-edit-voting': Popup.open('cardStartVoting'),
});
Template.cardFieldSectionPoker.events({
'click .js-section-edit-poker': Popup.open('cardStartPlanningPoker'),
});
Template.cardSpentTimeMenu.events({
'click .js-section-spent-time': Popup.open('editCardSpentTime'),
});
Template.cardDetailsActionsPopup.events({
// docs/Features/Cards/Action-Items.md: the description's action items.
'click .js-description-action-items': Popup.open('actionItems', { titleKey: 'action-items' }),
// History.md §7a: adding History to a menu is a menu item and this handler,
// opening the SAME historyTable every other scope opens.
'click .js-card-history'(event) {
@@ -1803,7 +1986,7 @@ Template.cardDetailsActionsPopup.events({
// tooltip to name it - the one place a name cannot be read without hovering -
// and it is here now beside the swimlane's and the list's, so all three are
// copied the same way from the same kind of menu.
// docs/Features/Page/Board-Item-Links.md
// docs/Features/General/Board-Item-Links.md
'click .js-card-qr-code': Popup.open('cardQrCode'),
'click .js-copy-card-link'(event, tpl) {
event.preventDefault();
@@ -1835,13 +2018,10 @@ Template.cardDetailsActionsPopup.events({
'click .js-members': Popup.open('cardMembers'),
'click .js-assignees': Popup.open('cardAssignees'),
'click .js-attachments': Popup.open('cardAttachments'),
'click .js-start-voting': Popup.open('cardStartVoting'),
'click .js-start-planning-poker': Popup.open('cardStartPlanningPoker'),
'click .js-received-date': Popup.open('editCardReceivedDate'),
'click .js-start-date': Popup.open('editCardStartDate'),
'click .js-due-date': Popup.open('editCardDueDate'),
'click .js-end-date': Popup.open('editCardEndDate'),
'click .js-spent-time': Popup.open('editCardSpentTime'),
'click .js-move-card': Popup.open('moveCard'),
'click .js-copy-card': Popup.open('copyCard'),
// #2209: "Create template from element" - save THIS card as a card template
@@ -1859,14 +2039,13 @@ Template.cardDetailsActionsPopup.events({
await Meteor.callAsync('saveCardAsTemplate', cardId);
Popup.back();
} catch (err) {
alert(err?.reason || err?.message || 'Failed to save card as template');
alert(errorMessage(err));
}
},
'click .js-set-card-recurrence-interval': Popup.open('cardRecurrenceInterval'),
'click .js-convert-checklist-item-to-card': Popup.open('convertChecklistItemToCard'),
'click .js-copy-checklist-cards': Popup.open('copyManyCards'),
'click .js-set-card-color': Popup.open('setCardColor'),
'click .js-set-card-kind': Popup.open('cardKind'),
'click .js-card-drawing': Popup.open('cardDrawing'),
async 'click .js-move-card-to-top'(event) {
event.preventDefault();
@@ -1911,18 +2090,9 @@ Template.cardDetailsActionsPopup.events({
const card = Cards.findOne(getCardId());
if (!card) return;
const currentList = ReactiveCache.getList(card.listId);
if (!currentList) {
Popup.open('restoreArchivedCardToList')(event, {
dataContextIfCurrentDataIsUndefined: { _id: card._id },
});
return;
}
if (typeof card.canBeRestored === 'function' && card.canBeRestored()) {
await card.restore();
}
Popup.back();
// The same as the Archive sidebar's Restore: a missing list, or one a hard
// WIP limit has filled (#6772), asks which list to restore it to.
if (await restoreArchivedCard(event, card)) Popup.back();
},
'click .js-more': Popup.open('cardMore'),
'click .js-create-board-from-card': Popup.open('createBoardFromCard'),
@@ -1995,6 +2165,9 @@ Template.cardMembersPopup.events({
});
Template.cardMembersPopup.helpers({
suggestedMembers() {
return suggestedMembersFor(getCurrentCardFromContext(), 'members');
},
isCardMember() {
const card = getCurrentCardFromContext();
if (!card) return false;
@@ -2200,7 +2373,7 @@ const filterMembers = (filterTerm) => {
.map(({ member }) => member);
}
return members;
}
};
Template.editCardRequesterForm.onRendered(function () {
autosize(this.$('.js-edit-card-requester'));
@@ -2853,7 +3026,7 @@ Template.cardMorePopup.events({
try {
card.addParent(parentId);
} catch (error) {
alert(error?.reason || error?.message || 'Could not add the parent card.');
alert(errorMessage(error));
}
},
'click .js-remove-other-parent'(event) {
@@ -3005,7 +3178,7 @@ Template.cardStartVotingPopup.events({
if (endString) {
Meteor.call('cards.setVoteEnd', card.getRealId(), new Date(endString));
}
Popup.back();
backIfOwnPopup(evt, 'cardStartVotingPopup');
},
'click .js-remove-vote': Popup.afterConfirm('deleteVote', function () {
const card = Cards.findOne(getCardId());
@@ -3109,7 +3282,7 @@ Template.cardStartPlanningPokerPopup.events({
if (endString) {
Meteor.call('cards.setPokerEnd', card.getRealId(), new Date(endString));
}
Popup.back();
backIfOwnPopup(evt, 'cardStartPlanningPokerPopup');
},
'click .js-remove-poker': Popup.afterConfirm('deletePoker', function () {
const card = Cards.findOne(getCardId());
@@ -3218,6 +3391,9 @@ Template.cardAssigneesPopup.events({
});
Template.cardAssigneesPopup.helpers({
suggestedMembers() {
return suggestedMembersFor(getCurrentCardFromContext(), 'assignees');
},
isCardAssignee() {
const card = getCurrentCardFromContext();
if (!card) return false;
@@ -3421,6 +3597,6 @@ Template.cardKindPopup.events({
if (card && typeof card.setCardKind === 'function') {
try { await card.setCardKind(event.currentTarget.dataset.kind || null); } catch (error) { console.error(error); }
}
Popup.back();
backIfOwnPopup(event, 'cardKindPopup');
},
});
+42 -3
View File
@@ -1,8 +1,47 @@
/* #1178: drawing on a card. */
.card-drawing { min-width: min(820px, 90vw); }
.card-drawing-surface { border: 1px solid #ccc; touch-action: none; cursor: crosshair; background: #fff; display: block; }
.card-drawing-tools, .card-drawing-actions, .card-drawing-list { display: flex; flex-wrap: wrap; gap: 0.4em; align-items: center; margin: 0.4em 0; }
/* #6802: the popup is a column - tools, then the drawing area taking the room
that is left: the whole window when maximized. */
.card-drawing { min-width: min(820px, 90vw); display: flex; flex-direction: column; height: min(75vh, 720px); }
.pop-over--maximized .card-drawing { height: calc(100vh - 6.5em); }
/* An outline, not a border: the surface's box is exactly the drawing, so a
pointer position maps to it without an offset. */
.card-drawing-surface { outline: 1px solid #ccc; touch-action: none; cursor: crosshair; background: #fff; display: block; }
.card-drawing-tools, .card-drawing-list { display: flex; flex-wrap: wrap; gap: 0.4em; align-items: center; margin: 0.4em 0; }
/* Save at the end of the tools, above the drawing. */
.card-drawing-actions { display: flex; flex-wrap: wrap; gap: 0.6em; align-items: center; margin-inline-start: auto; }
.card-drawing-actions .js-drawing-save { margin: 0; }
.card-drawing-color { width: 1.4em; height: 1.4em; border-radius: 50%; border: 2px solid #999; display: inline-block; }
.card-drawing-color--custom { border-style: dashed; }
.card-drawing-color.is-active, .card-drawing-width.is-active, .card-drawing-thumb.is-active { outline: 2px solid #2b6cb0; }
.card-drawing-width { padding: 0 0.4em; border: 1px solid #ccc; border-radius: 3px; }
.card-drawing-thumb svg { width: 6em; height: auto; border: 1px solid #ddd; display: block; }
/* #6802: the eraser, and the surface while it is on. */
.card-drawing-eraser { padding: 0 0.3em; }
.card-drawing-eraser.is-active { outline: 2px solid #2b6cb0; border-radius: 3px; }
.card-drawing-surface.is-erasing { cursor: cell; }
/* #6802: the lasso, its loop, the selection and its resize handle. */
.card-drawing-lasso { padding: 0 0.3em; }
.card-drawing-lasso.is-active { outline: 2px solid #2b6cb0; border-radius: 3px; }
.card-drawing-surface.is-lasso { cursor: default; }
.card-drawing-selection-tools { display: flex; flex-wrap: wrap; gap: 0.6em; align-items: center; margin: 0.2em 0 0.4em; }
.card-drawing-lasso-loop { fill: rgba(43, 108, 176, 0.08); stroke: #2b6cb0; stroke-width: 1; stroke-dasharray: 4 3; pointer-events: none; }
.card-drawing-selection { fill: none; stroke: #2b6cb0; stroke-width: 1; stroke-dasharray: 5 3; cursor: move; pointer-events: none; }
.card-drawing-resize-handle { fill: #ffffff; stroke: #2b6cb0; stroke-width: 2; cursor: nwse-resize; }
/* #6802: colors - the custom-color button, the eyedropper, the wheel. */
.card-drawing-add-custom, .card-drawing-eyedropper { padding: 0 0.3em; }
.card-drawing-eyedropper.is-active { outline: 2px solid #2b6cb0; border-radius: 3px; }
.card-drawing-surface.is-picking { cursor: copy; }
.card-drawing-color-editor { display: flex; flex-wrap: wrap; gap: 1em; align-items: center; margin: 0.2em 0 0.4em; }
.card-drawing-wheel { width: 160px; height: 160px; border-radius: 50%; touch-action: none; cursor: crosshair; }
.card-drawing-color-fields { display: flex; flex-direction: column; gap: 0.4em; align-items: flex-start; }
.card-drawing-color-fields label { display: flex; flex-direction: column; gap: 0.2em; }
.card-drawing-color-fields .js-drawing-hex { width: 7em; font-family: monospace; }
.card-drawing-color-preview { width: 3em; height: 1.4em; border: 1px solid #999; border-radius: 3px; display: inline-block; }
/* #6802: the drawing area - it scrolls when zoomed past the room it has, and
grows from the handle at its bottom-right corner. */
.card-drawing-zoom { min-width: 3em; text-align: center; }
.card-drawing-stage { flex: 1 1 auto; min-height: 0; overflow: auto; position: relative; padding-inline-end: 10px; padding-block-end: 10px; box-sizing: border-box; }
.card-drawing-canvas { position: relative; }
/* At the inline end: bottom-right, or bottom-left in a right-to-left language. */
.card-drawing-canvas-resize { position: absolute; inset-inline-end: -6px; bottom: -6px; width: 14px; height: 14px; background: #fff; border: 2px solid #2b6cb0; border-radius: 2px; cursor: nwse-resize; touch-action: none; }
.card-drawing-canvas-resize:focus-visible { outline: 2px solid #2b6cb0; outline-offset: 2px; }
+495 -32
View File
@@ -1,3 +1,4 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
import { Random } from 'meteor/random';
import { TAPi18n } from '/imports/i18n';
@@ -6,6 +7,7 @@ import { Utils } from '/client/lib/utils';
import { buildAttachmentUploadConfig } from '/client/lib/attachmentUploadConfig';
import Cards from '/models/cards';
import Attachments from '/models/attachments';
import { openWhiteboard } from '/client/components/cards/whiteboard';
const d = require('/models/lib/cardDrawing');
const W = 800;
@@ -16,6 +18,101 @@ function pathD(points) {
return points.map((p, i) => `${i ? 'L' : 'M'}${p[0]} ${p[1]}`).join('');
}
// The drawing as a PNG file, the size it is drawn at - for the popup's Save
// and the whiteboard's Save picture (docs/Features/Cards/Whiteboard.md).
export async function drawingPngFile(target, strokes, { width = W, height = H } = {}) {
const svg = d.strokesToSvg(strokes, { width, height });
const img = new Image();
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; });
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').drawImage(img, 0, 0);
URL.revokeObjectURL(url);
const png = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
return new File([png], `${(target.title || 'drawing').slice(0, 60)}.png`, { type: 'image/png' });
}
// #6802: the drawing as an SVG file, for a new drawing from Add Card or Add
// attachment. The server sanitizes every SVG it is given (models/lib/sanitizeSvg.js);
// this one is lines only.
export function drawingSvgFile(target, strokes, { width = W, height = H } = {}) {
const svg = d.strokesToSvg(strokes, { width, height });
return new File([svg], `${(target.title || 'drawing').slice(0, 60)}.svg`, { type: 'image/svg+xml' });
}
// #6802: the custom colors beside the palette. A signed-in user keeps them
// with the account (profile.drawingColors, the setDrawingColors method), so
// they follow the user to every browser; someone drawing on a public board
// without signing in keeps them in this browser.
const CUSTOM_COLORS_KEY = 'wekan-drawing-custom-colors';
function loadBrowserColors() {
try { return d.addCustomColor(JSON.parse(window.localStorage.getItem(CUSTOM_COLORS_KEY) || '[]'), null); } catch (e) { return []; }
}
// Reactive: the account's colors, or this browser's.
function customColorsOf(tpl) {
const user = ReactiveCache.getCurrentUser();
if (user) return d.addCustomColor((user.profile && user.profile.drawingColors) || [], null);
return tpl.customColors.get();
}
function saveCustomColors(tpl, list) {
if (Meteor.userId()) {
Meteor.callAsync('setDrawingColors', list).catch(error => {
tpl.message.set((error && (error.reason || error.message)) || 'error');
});
return;
}
tpl.customColors.set(list);
try { window.localStorage.setItem(CUSTOM_COLORS_KEY, JSON.stringify(list)); } catch (e) { /* a preference */ }
}
const WHEEL = 160;
// The color wheel at brightness `v`, with a ring where `hex` sits on it.
function paintWheel(canvas, v, hex) {
if (!canvas) return;
const ctx = canvas.getContext('2d');
const r = WHEEL / 2;
const image = ctx.createImageData(WHEEL, WHEEL);
for (let y = 0; y < WHEEL; y += 1) {
for (let x = 0; x < WHEEL; x += 1) {
const i = (y * WHEEL + x) * 4;
if (Math.hypot(x - r, y - r) > r) { image.data[i + 3] = 0; continue; }
const c = d.wheelColor(x, y, r, v);
image.data[i] = parseInt(c.slice(1, 3), 16);
image.data[i + 1] = parseInt(c.slice(3, 5), 16);
image.data[i + 2] = parseInt(c.slice(5, 7), 16);
image.data[i + 3] = 255;
}
}
ctx.putImageData(image, 0, 0);
const at = d.wheelPoint(hex, r);
if (at) {
ctx.beginPath();
ctx.arc(at.x, at.y, 6, 0, Math.PI * 2);
ctx.lineWidth = 2;
ctx.strokeStyle = v > 0.5 ? '#000000' : '#ffffff';
ctx.stroke();
}
}
// A new drawing is the card's cover image (docs/Features/Cards/Drawing.md).
function coverOf(attachmentId) {
return attachmentId ? { coverId: attachmentId } : {};
}
// Upload a drawing's PNG as a card attachment; resolves with its id.
export async function uploadDrawingPng(target, file) {
const uploader = await Attachments.insertAsync(buildAttachmentUploadConfig({ file, meta: Utils.getCommonAttachmentMetaFrom(target) }), false);
return new Promise((resolve, reject) => {
uploader.on('end', (error, fileRef) => (error ? reject(error) : resolve(fileRef && fileRef._id)));
uploader.start();
});
}
// #1178: Template.currentData() is the card.
Template.cardDrawingPopup.onCreated(function () {
this.strokes = new ReactiveVar([]);
@@ -25,24 +122,166 @@ Template.cardDrawingPopup.onCreated(function () {
this.editingId = new ReactiveVar(null);
this.saving = new ReactiveVar(false);
this.message = new ReactiveVar('');
this.erasing = new ReactiveVar(false);
// #6802, the lasso: a loop selects lines; the selection moves, resizes,
// groups and changes layer (models/lib/cardDrawing.js).
this.lasso = new ReactiveVar(false);
this.loop = new ReactiveVar(null);
this.selection = new ReactiveVar([]);
this.drag = null;
// Undo steps back through every change - a line, an erase, a move - and,
// with nothing left to step back to, takes away the last line as before.
this.history = [];
// Palm rejection (models/lib/cardDrawing.js pointerDraws).
this.pointer = { penSeen: false, activePointerId: null };
// #6802: the drawing's own size, in drawing units, and how large it is shown.
this.size = new ReactiveVar({ width: W, height: H });
this.zoom = new ReactiveVar(1);
// Colors: the custom ones, the wheel and code editor, the eyedropper.
this.customColors = new ReactiveVar(loadBrowserColors());
this.colorEditor = new ReactiveVar(false);
this.brightness = new ReactiveVar(1);
this.picking = new ReactiveVar(false);
});
// The room the drawing area has on the screen.
function stageRoom(tpl) {
const stage = tpl.find('.js-drawing-stage');
if (!stage) return null;
// Less its padding (room for the corner handle) and the surface's border.
return { width: Math.max(0, stage.clientWidth - 14), height: Math.max(0, stage.clientHeight - 14) };
}
// A NEW drawing takes the room there is; an existing one keeps its size and
// is zoomed to fit.
function fitToStage(tpl, size) {
const room = stageRoom(tpl);
if (!room || room.width < 50 || room.height < 50) return;
if (!size) {
tpl.size.set(d.drawingSize(room.width, room.height));
tpl.zoom.set(1);
return;
}
tpl.size.set(d.drawingSize(size.width, size.height));
const z = Math.min(room.width / size.width, room.height / size.height);
tpl.zoom.set(Math.round(Math.min(d.ZOOM_MAX, Math.max(d.ZOOM_MIN, z)) * 1000) / 1000);
}
Template.cardDrawingPopup.onRendered(function () {
// After the popup has maximized (popup.js does that after a flush), and once
// the layout has settled.
const tpl = this;
Tracker.afterFlush(() => requestAnimationFrame(() => requestAnimationFrame(() => {
if (!tpl.view.isDestroyed && !tpl.strokes.get().length && !tpl.editingId.get()) fitToStage(tpl, null);
})));
// ...and again whenever that room changes while the drawing is still new,
// empty and not sized or zoomed by hand. Opened from another popup (the
// card's + Add, Add attachment) the popup is the SAME rendered one, and its
// maximize follows reactively - after the fit above had already taken the
// un-maximized 820px, so a new drawing kept about 784px of a 1280px window.
// The same refit follows the window being resized or the popup maximized.
const stage = this.find('.js-drawing-stage');
if (stage && typeof ResizeObserver !== 'undefined') {
let lastRoom = '';
this.stageObserver = new ResizeObserver(() => {
const room = stageRoom(tpl);
const key = room ? `${room.width}x${room.height}` : '';
if (key === lastRoom) return;
lastRoom = key;
if (tpl.view.isDestroyed || tpl.sizedByHand || tpl.strokes.get().length || tpl.editingId.get()) return;
fitToStage(tpl, null);
});
this.stageObserver.observe(stage);
}
// The color wheel follows the brightness and the color.
this.autorun(() => {
if (!tpl.colorEditor.get()) return;
const v = tpl.brightness.get();
const hex = tpl.color.get();
Tracker.afterFlush(() => paintWheel(tpl.find('.js-drawing-wheel'), v, hex));
});
});
// Picks `hex` as the pen color, and shows it in the editor.
function useColor(tpl, hex) {
const c = d.cleanColor(hex);
if (!c) return;
tpl.color.set(c);
const hsv = d.hexToHsv(c);
if (hsv) tpl.brightness.set(hsv.v);
}
const HISTORY = 100;
const HANDLE = 12;
// Before a change: remember the strokes, for Undo.
function remember(tpl, strokes = tpl.strokes.get()) {
tpl.history.push(strokes);
if (tpl.history.length > HISTORY) tpl.history.shift();
}
function setTool(tpl, tool) {
tpl.erasing.set(tool === 'eraser');
tpl.lasso.set(tool === 'lasso');
tpl.picking.set(false);
tpl.selection.set([]);
tpl.loop.set(null);
}
// The selection's box, with the resize handle at its bottom-right corner.
function selectionBox(tpl) {
const b = d.selectionBounds(tpl.strokes.get(), tpl.selection.get());
return b && { ...b, hx: b.x + b.width - HANDLE / 2, hy: b.y + b.height - HANDLE / 2, size: HANDLE };
}
// What a press at `p` does with the lasso: resize by the handle, move inside
// the selection, or start a new loop.
function lassoAction(box, p) {
if (box && Math.abs(p[0] - (box.x + box.width)) <= HANDLE && Math.abs(p[1] - (box.y + box.height)) <= HANDLE) return 'resize';
if (box && p[0] >= box.x - 4 && p[0] <= box.x + box.width + 4 && p[1] >= box.y - 4 && p[1] <= box.y + box.height + 4) return 'move';
return 'loop';
}
function card(tpl) {
return ReactiveCache.getCard(tpl.data._id) || tpl.data;
}
// The drawing's size, for every operation that keeps lines inside it.
function bounds(tpl) {
return tpl.size.get();
}
// A pointer position in drawing units: the surface is shown at the drawing's
// size times the zoom, so a fraction of its box is the same fraction of the
// drawing.
function pointFrom(tpl, event) {
const svg = tpl.find('.js-drawing-surface');
const rect = svg.getBoundingClientRect();
const e = event.originalEvent || event;
return [Math.round(((e.clientX - rect.left) / rect.width) * W * 10) / 10, Math.round(((e.clientY - rect.top) / rect.height) * H * 10) / 10];
const { width, height } = bounds(tpl);
return [Math.round(((e.clientX - rect.left) / rect.width) * width * 10) / 10, Math.round(((e.clientY - rect.top) / rect.height) * height * 10) / 10];
}
Template.cardDrawingPopup.onDestroyed(function () {
if (this.stageObserver) this.stageObserver.disconnect();
});
Template.cardDrawingPopup.helpers({
drawings() { return (card(Template.instance()).drawings || []); },
isEditing() { return Template.instance().editingId.get() === this._id; },
thumbSvg() { return d.strokesToSvg(this.strokes, { width: this.width || W, height: this.height || H }); },
colors() { return d.COLORS; },
customColors() { return customColorsOf(Template.instance()); },
isCustom(c) { return customColorsOf(Template.instance()).includes(c); },
currentColor() { return Template.instance().color.get(); },
colorEditorOpen() { return Template.instance().colorEditor.get(); },
brightnessPercent() { return Math.round(Template.instance().brightness.get() * 100); },
picking() { return Template.instance().picking.get(); },
drawingWidth() { return Template.instance().size.get().width; },
drawingHeight() { return Template.instance().size.get().height; },
canvasWidth() { const tpl = Template.instance(); return Math.round(tpl.size.get().width * tpl.zoom.get()); },
canvasHeight() { const tpl = Template.instance(); return Math.round(tpl.size.get().height * tpl.zoom.get()); },
zoomPercent() { return Math.round(Template.instance().zoom.get() * 100); },
widths() { return d.WIDTHS; },
isColor(c) { return Template.instance().color.get() === c; },
isWidth(w) { return Template.instance().width.get() === w; },
@@ -50,40 +289,258 @@ Template.cardDrawingPopup.helpers({
return Template.instance().strokes.get().map(s => ({ ...s, pathD: pathD(s.points) }));
},
saving() { return Template.instance().saving.get(); },
newFile() { return Boolean(Template.instance().data && Template.instance().data.drawingNewFile); },
showDrawingList() {
const tpl = Template.instance();
return !(tpl.data && tpl.data.drawingNewFile) && (card(tpl).drawings || []).length > 0;
},
erasing() { return Template.instance().erasing.get(); },
lassoOn() { return Template.instance().lasso.get(); },
lassoPath() {
const loop = Template.instance().loop.get();
return loop && loop.length > 1 ? `${pathD(loop)}Z` : null;
},
selectionBox() { return selectionBox(Template.instance()); },
hasSelection() { return Template.instance().selection.get().length > 0; },
selectionGrouped() {
const tpl = Template.instance();
const strokes = tpl.strokes.get();
return tpl.selection.get().some(i => strokes[i] && strokes[i].group);
},
message() { return Template.instance().message.get(); },
});
Template.cardDrawingPopup.events({
'click .js-drawing-color'(event, tpl) { event.preventDefault(); tpl.color.set(event.currentTarget.dataset.color); },
// A color is picked, and the wheel and the HTML code open to change it.
'click .js-drawing-color'(event, tpl) {
event.preventDefault();
useColor(tpl, event.currentTarget.dataset.color);
tpl.colorEditor.set(true);
},
'click .js-drawing-custom-color'(event, tpl) { event.preventDefault(); tpl.colorEditor.set(!tpl.colorEditor.get()); },
'click .js-drawing-close-colors'(event, tpl) { event.preventDefault(); tpl.colorEditor.set(false); },
'click .js-drawing-add-color'(event, tpl) {
event.preventDefault();
saveCustomColors(tpl, d.addCustomColor(customColorsOf(tpl), tpl.color.get()));
},
'click .js-drawing-remove-color'(event, tpl) {
event.preventDefault();
saveCustomColors(tpl, d.removeCustomColor(customColorsOf(tpl), tpl.color.get()));
},
'input .js-drawing-hex, change .js-drawing-hex'(event, tpl) {
let v = String(event.currentTarget.value || '').trim();
if (/^[0-9a-f]{6}$/i.test(v)) v = `#${v}`;
if (d.cleanColor(v)) useColor(tpl, v);
},
'input .js-drawing-brightness'(event, tpl) {
const v = Number(event.currentTarget.value) / 100;
const hsv = d.hexToHsv(tpl.color.get()) || { h: 0, s: 0 };
tpl.brightness.set(v);
tpl.color.set(d.hsvToHex(hsv.h, hsv.s, v));
},
'pointerdown .js-drawing-wheel, pointermove .js-drawing-wheel'(event, tpl) {
const e = event.originalEvent || event;
if (event.type === 'pointermove' && !e.buttons) return;
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * WHEEL;
const y = ((e.clientY - rect.top) / rect.height) * WHEEL;
tpl.color.set(d.wheelColor(x, y, WHEEL / 2, tpl.brightness.get()));
},
// The eyedropper: from anywhere on the screen where the browser allows it,
// from the drawing otherwise.
async 'click .js-drawing-eyedropper'(event, tpl) {
event.preventDefault();
if (typeof window.EyeDropper === 'function') {
try {
const result = await new window.EyeDropper().open();
useColor(tpl, result && result.sRGBHex);
} catch (error) { /* cancelled */ }
return;
}
tpl.picking.set(!tpl.picking.get());
},
// A zoom or a corner resize is the user's choice of size: from then on a
// change of the room no longer refits the drawing (onRendered's observer).
'click .js-drawing-zoom-in'(event, tpl) { event.preventDefault(); tpl.sizedByHand = true; tpl.zoom.set(d.nextZoom(tpl.zoom.get(), 1)); },
'click .js-drawing-zoom-out'(event, tpl) { event.preventDefault(); tpl.sizedByHand = true; tpl.zoom.set(d.nextZoom(tpl.zoom.get(), -1)); },
// The drawing area grows (or shrinks, never cutting a line) from its corner.
'pointerdown .js-drawing-canvas-resize'(event, tpl) {
const e = event.originalEvent || event;
if (e.button !== 0) return;
event.preventDefault();
event.stopPropagation();
try { event.currentTarget.setPointerCapture?.(e.pointerId); } catch (error) { /* resized without capture */ }
// Right to left, the handle is at the bottom-left and grows the drawing leftwards.
const direction = window.getComputedStyle(event.currentTarget).direction === 'rtl' ? -1 : 1;
tpl.sizedByHand = true;
tpl.canvasResize = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, size: tpl.size.get(), zoom: tpl.zoom.get(), direction };
},
'pointermove .js-drawing-canvas-resize'(event, tpl) {
const e = event.originalEvent || event;
const r = tpl.canvasResize;
if (!r || r.pointerId !== e.pointerId) return;
event.preventDefault();
const min = d.minimumDrawingSize(tpl.strokes.get());
const next = d.drawingSize(r.size.width + (r.direction * (e.clientX - r.x)) / r.zoom, r.size.height + (e.clientY - r.y) / r.zoom);
tpl.size.set({ width: Math.max(min.width, next.width), height: Math.max(min.height, next.height) });
},
'pointerup .js-drawing-canvas-resize, pointercancel .js-drawing-canvas-resize, lostpointercapture .js-drawing-canvas-resize'(event, tpl) {
tpl.canvasResize = null;
},
'keydown .js-drawing-canvas-resize'(event, tpl) {
const steps = { ArrowRight: [50, 0], ArrowLeft: [-50, 0], ArrowDown: [0, 50], ArrowUp: [0, -50] };
const step = steps[event.key];
if (!step) return;
event.preventDefault();
tpl.sizedByHand = true;
const min = d.minimumDrawingSize(tpl.strokes.get());
const size = tpl.size.get();
const next = d.drawingSize(size.width + step[0], size.height + step[1]);
tpl.size.set({ width: Math.max(min.width, next.width), height: Math.max(min.height, next.height) });
},
'click .js-drawing-width'(event, tpl) { event.preventDefault(); tpl.width.set(Number(event.currentTarget.dataset.width)); },
'click .js-drawing-undo'(event, tpl) { event.preventDefault(); tpl.strokes.set(tpl.strokes.get().slice(0, -1)); },
'click .js-drawing-clear'(event, tpl) { event.preventDefault(); tpl.strokes.set([]); },
'click .js-new-drawing'(event, tpl) { event.preventDefault(); tpl.editingId.set(null); tpl.strokes.set([]); },
'click .js-drawing-undo'(event, tpl) {
event.preventDefault();
tpl.selection.set([]);
tpl.strokes.set(tpl.history.length ? tpl.history.pop() : tpl.strokes.get().slice(0, -1));
},
'click .js-drawing-clear'(event, tpl) { event.preventDefault(); remember(tpl); tpl.selection.set([]); tpl.strokes.set([]); },
'click .js-drawing-eraser'(event, tpl) { event.preventDefault(); setTool(tpl, tpl.erasing.get() ? 'pen' : 'eraser'); },
'click .js-drawing-lasso'(event, tpl) { event.preventDefault(); setTool(tpl, tpl.lasso.get() ? 'pen' : 'lasso'); },
'click .js-drawing-group'(event, tpl) {
event.preventDefault();
const sel = tpl.selection.get();
if (sel.length < 2) return;
remember(tpl);
tpl.strokes.set(d.groupStrokes(tpl.strokes.get(), sel, Random.id()));
},
'click .js-drawing-ungroup'(event, tpl) {
event.preventDefault();
remember(tpl);
tpl.strokes.set(d.ungroupStrokes(tpl.strokes.get(), tpl.selection.get()));
},
'click .js-drawing-layer'(event, tpl) {
event.preventDefault();
const sel = tpl.selection.get();
if (!sel.length) return;
remember(tpl);
const { strokes, selection } = d.reorderStrokes(tpl.strokes.get(), sel, event.currentTarget.dataset.layer);
tpl.strokes.set(strokes);
tpl.selection.set(selection);
},
// docs/Features/Cards/Whiteboard.md
'click .js-open-whiteboard'(event, tpl) {
event.preventDefault();
openWhiteboard(card(tpl)._id, event.currentTarget.dataset.id);
Popup.close();
},
'click .js-new-drawing'(event, tpl) {
event.preventDefault();
tpl.editingId.set(null);
tpl.history = [];
tpl.selection.set([]);
tpl.strokes.set([]);
tpl.sizedByHand = false;
fitToStage(tpl, null);
},
'click .js-edit-drawing'(event, tpl) {
event.preventDefault();
const drawing = (card(tpl).drawings || []).find(x => x._id === event.currentTarget.dataset.id);
if (!drawing) return;
// A drawing keeps the size it was made at (older ones are 800 x 500).
const size = d.drawingSize(drawing.width || W, drawing.height || H);
tpl.editingId.set(drawing._id);
tpl.strokes.set(d.cleanStrokes(drawing.strokes, { width: W, height: H }));
tpl.history = [];
tpl.selection.set([]);
tpl.size.set(size);
tpl.strokes.set(d.cleanStrokes(drawing.strokes, size));
fitToStage(tpl, size);
},
'pointerdown .js-drawing-surface'(event, tpl) {
const e = event.originalEvent || event;
if (e.pointerType === 'pen') tpl.pointer.penSeen = true;
if (!d.pointerDraws(e, tpl.pointer)) return;
event.preventDefault();
event.currentTarget.setPointerCapture?.((event.originalEvent || event).pointerId);
tpl.pointer.activePointerId = e.pointerId;
// Capture keeps a line going past the edge; failing to capture must not
// stop the line from starting.
try { event.currentTarget.setPointerCapture?.(e.pointerId); } catch (error) { /* drawn without capture */ }
const point = pointFrom(tpl, event);
// The eyedropper on the drawing: the color under the pointer.
if (tpl.picking.get()) {
useColor(tpl, d.colorAt(tpl.strokes.get(), point));
tpl.picking.set(false);
tpl.pointer.activePointerId = null;
return;
}
if (tpl.lasso.get()) {
const box = selectionBox(tpl);
const kind = lassoAction(box, point);
if (kind === 'loop') {
tpl.selection.set([]);
tpl.loop.set([point]);
}
tpl.drag = { kind, start: point, base: tpl.strokes.get(), box };
return;
}
remember(tpl);
tpl.selection.set([]);
if (tpl.erasing.get()) {
tpl.erasingNow = true;
tpl.strokes.set(d.eraseAt(tpl.strokes.get(), pointFrom(tpl, event)));
return;
}
tpl.current = { color: tpl.color.get(), width: tpl.width.get(), points: [pointFrom(tpl, event)] };
tpl.strokes.set([...tpl.strokes.get(), tpl.current]);
},
'pointermove .js-drawing-surface'(event, tpl) {
const e = event.originalEvent || event;
if (e.pointerId !== tpl.pointer.activePointerId) return;
if (tpl.drag) {
const p = pointFrom(tpl, event);
const { kind, start, base, box } = tpl.drag;
if (kind === 'loop') {
tpl.loop.set([...(tpl.loop.get() || []), p]);
return;
}
// A move or a resize is one Undo step, from where it started.
if (!tpl.drag.changed) { remember(tpl, base); tpl.drag.changed = true; }
if (kind === 'move') {
tpl.strokes.set(d.moveStrokes(base, tpl.selection.get(), p[0] - start[0], p[1] - start[1], bounds(tpl)));
} else if (kind === 'resize' && box) {
// From the box's top-left corner, as far as the pointer went.
const fx = box.width > 0 ? (p[0] - box.x) / box.width : 0;
const fy = box.height > 0 ? (p[1] - box.y) / box.height : 0;
const factor = Math.max(fx, fy);
if (factor > 0) tpl.strokes.set(d.scaleStrokes(base, tpl.selection.get(), factor, bounds(tpl)));
}
return;
}
if (tpl.erasingNow) {
tpl.strokes.set(d.eraseAt(tpl.strokes.get(), pointFrom(tpl, event)));
return;
}
if (!tpl.current) return;
tpl.current.points.push(pointFrom(tpl, event));
const list = tpl.strokes.get().slice(0, -1);
tpl.strokes.set([...list, { ...tpl.current }]);
},
'pointerup .js-drawing-surface, pointerleave .js-drawing-surface'(event, tpl) {
'pointerup .js-drawing-surface, pointerleave .js-drawing-surface, pointercancel .js-drawing-surface'(event, tpl) {
const e = event.originalEvent || event;
if (tpl.pointer.activePointerId !== null && e.pointerId !== tpl.pointer.activePointerId) return;
if (tpl.drag && tpl.drag.kind === 'loop') {
tpl.selection.set(d.lassoSelect(tpl.strokes.get(), tpl.loop.get() || []));
tpl.loop.set(null);
}
tpl.drag = null;
tpl.current = null;
tpl.erasingNow = false;
tpl.pointer.activePointerId = null;
},
'click .js-drawing-download-svg'(event, tpl) {
event.preventDefault();
const blob = new Blob([d.strokesToSvg(tpl.strokes.get(), { width: W, height: H })], { type: 'image/svg+xml' });
const blob = new Blob([d.strokesToSvg(tpl.strokes.get(), bounds(tpl))], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -95,32 +552,38 @@ Template.cardDrawingPopup.events({
async 'click .js-drawing-save'(event, tpl) {
event.preventDefault();
const target = card(tpl);
const strokes = d.cleanStrokes(tpl.strokes.get(), { width: W, height: H });
const size = bounds(tpl);
const strokes = d.cleanStrokes(tpl.strokes.get(), size);
if (!target || !strokes.length) return;
tpl.saving.set(true);
try {
const svg = d.strokesToSvg(strokes, { width: W, height: H });
const img = new Image();
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; img.src = url; });
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
canvas.getContext('2d').drawImage(img, 0, 0);
URL.revokeObjectURL(url);
const png = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
const file = new File([png], `${(target.title || 'drawing').slice(0, 60)}.png`, { type: 'image/png' });
const id = tpl.editingId.get() || Random.id();
const uploader = await Attachments.insertAsync(buildAttachmentUploadConfig({ file, meta: Utils.getCommonAttachmentMetaFrom(target) }), false);
uploader.on('end', async (error, fileRef) => {
// #6802: a new drawing becomes an SVG attachment of the card, and is kept
// on the card too, so the Drawing popup can edit it later.
if (tpl.data && tpl.data.drawingNewFile) {
try {
const id = Random.id();
const svgAttachmentId = await uploadDrawingPng(target, drawingSvgFile(target, strokes, size));
const drawing = { _id: id, strokes, width: size.width, height: size.height, svgAttachmentId, updatedAt: new Date() };
// A new drawing is the card's cover image.
await Cards.updateAsync(target._id, { $set: { drawings: d.drawingsAfterSave(target.drawings, drawing), ...coverOf(svgAttachmentId) } });
tpl.saving.set(false);
if (error) { tpl.message.set(error.reason || error.message); return; }
const drawing = { _id: id, strokes, width: W, height: H, pngAttachmentId: fileRef && fileRef._id, updatedAt: new Date() };
await Cards.updateAsync(target._id, { $set: { drawings: d.drawingsAfterSave(target.drawings, drawing) } });
tpl.editingId.set(id);
tpl.message.set(TAPi18n.__('card-drawing-saved'));
});
uploader.start();
Popup.close();
} catch (error) {
tpl.saving.set(false);
tpl.message.set((error && (error.reason || error.message)) || 'error');
}
return;
}
try {
const file = await drawingPngFile(target, strokes, size);
const isNew = !tpl.editingId.get();
const id = tpl.editingId.get() || Random.id();
const pngAttachmentId = await uploadDrawingPng(target, file);
tpl.saving.set(false);
const drawing = { _id: id, strokes, width: size.width, height: size.height, pngAttachmentId, updatedAt: new Date() };
// A NEW drawing is the card's cover image; saving an edit leaves the cover.
await Cards.updateAsync(target._id, { $set: { drawings: d.drawingsAfterSave(target.drawings, drawing), ...(isNew ? coverOf(pngAttachmentId) : {}) } });
tpl.editingId.set(id);
tpl.message.set(TAPi18n.__('card-drawing-saved'));
} catch (error) {
tpl.saving.set(false);
tpl.message.set((error && (error.reason || error.message)) || 'error');
+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'}}
+3 -2
View File
@@ -1,6 +1,7 @@
import { TAPi18n } from '/imports/i18n';
import Cards from '/models/cards';
import { getCurrentCardIdFromContext } from '/client/lib/currentCard';
import { backIfOwnPopup } from '/client/lib/popupBack';
function getCardId() {
return getCurrentCardIdFromContext();
@@ -36,7 +37,7 @@ Template.editCardSpentTimePopup.events({
if (spentTime >= 0) {
card.setSpentTime(spentTime);
card.setIsOvertime(isOvertime);
Popup.back();
backIfOwnPopup(evt, 'editCardSpentTimePopup');
} else {
tpl.error.set('invalid-time');
evt.target.time.focus();
@@ -48,7 +49,7 @@ Template.editCardSpentTimePopup.events({
if (!card) return;
card.setSpentTime(null);
card.setIsOvertime(false);
Popup.back();
backIfOwnPopup(evt, 'editCardSpentTimePopup');
},
'click a.js-toggle-overtime'(evt, tpl) {
evt.preventDefault();
Loaded 100 of 2714 files, more files were not shown because too many files have changed in this diff. Show more