Compare commits

...
457 Commits
Author SHA1 Message Date
github-actions[bot] e6ec503ae1 Bump versions for v10.97 2026-08-16 02:27:53 +00:00
Lauri Ojansivu d7efcbb2f8 Prepare v10.97 release 2026-08-16 05:26:47 +03:00
Lauri Ojansivu eacbb670a3 CHANGELOG: the post-release fixes move out of v10.96 and into Upcoming.
v10.96 was released in the middle of this work, which renamed Upcoming to
`# v10.96` - and the four entries written after it went on landing in that
section, because the anchor they were added above (the closing "Thanks to above
GitHub users" line) had moved into it.

A released section is a record, not a draft. So the three post-release entries -
the source-map boot fix and the snap's two - move into a new
`# Upcoming WeKan ® release` above it, with the two commits that had no entry at
all beside them: the npm prune and the i18n guard.

Two repairs to what stays behind, both of which now describe work that did not
ship in v10.96: its `**In short:**` claimed the snap had stopped taking itself
offline, and its source-map entry pointed at "the entry below" for the boot
crash. The first sentence is removed and the second is rewritten to stand on its
own and say the fix is in the release above - a reader of v10.96's notes should
still learn that its bundle could not start, which is the one thing about that
release worth knowing.

Thanks to xet7 !
2026-08-16 05:26:09 +03:00
Lauri Ojansivu faf9bd8027 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-08-16 05:21:05 +03:00
Lauri Ojansivu a113464b61 CHANGELOG: the snap's two-database startup gets its own group in Upcoming.
Two entries under it: the read that stamped the files it then read as evidence,
and the comparison that held the web port shut while it ran. They are halves of
one outage - the first made it recur at every restart, the second made each
recurrence an outage - so they sit together.

Thanks to xet7 !
2026-08-16 05:21:03 +03:00
Lauri Ojansivu 062d4fede5 The database comparison at startup cannot take the site down any more.
An instance on 10.96 went offline at every restart. wekan-control ran
bin/database-autopick synchronously and unbounded before anything opened the web
port - and that comparison starts each database on a temporary port to read it,
which on a real instance is minutes. Until it returned a browser got a
connection timeout, and the reason was in `snap logs`, the last place somebody
whose site is down thinks to look.

That is #6592 one step earlier than #6592 was fixed. The database WAIT already
serves a "waiting for its database" page; the helpers for it were defined 200
lines BELOW the comparison that needed them. They move up, and the comparison
uses them - after the same grace period, so a comparison that finishes in
seconds does not flash a page up and teach proxies to cache a 503 for a healthy
site.

And a bound: WEKAN_AUTOPICK_TIMEOUT, 900s by default. When it runs out WeKan
says so, changes nothing, and starts on the database already selected. Stopping
the comparison is safe - it never deletes anything on either side, and the merge
it may be in the middle of only INSERTS what is missing, so a partial one is
fewer documents copied and the next start finishes the job. Serving the site
beats finishing the comparison.

WEKAN_AUTOPICK was an env var and nothing else, so an admin whose site was down
had no supported way to skip the comparison. It is a snap config key now, with
the bound beside it:

  snap set wekan autopick=false
  snap set wekan autopick-timeout=1800

The timeout message names all three of those, because somebody reading it is
somebody whose site just came up late.

This is the other half of the mtime fix. Before that, the comparison ran at
EVERY start - autopick's own mongod stamped the files the staleness check reads
- so every restart took the site down for a comparison that could never
conclude. One made it recur; this one makes it survivable.

Thanks to xet7 !
2026-08-16 05:21:03 +03:00
Lauri Ojansivu cf283be5e1 CHANGELOG: the source-map entry gets its correction, and the boot fix its own.
The maps entry claimed nothing on any loading path reads a .map. That was true
of the client and false of the server, and a released image crash-looped on it.
The claim is corrected where it was made, and the fix has an entry of its own.

Thanks to xet7 !
2026-08-16 05:21:03 +03:00
Lauri Ojansivu dc19f06612 Dropping a source map must un-name it too, or the server will not boot.
A released image crash-looped:

  Error: ENOENT: no such file or directory,
    open '/build/programs/server/packages/ecmascript.js.map'
    at /build/programs/server/boot.js:101:29

My claim that "nothing on any loading path reads a .map" was true of the client
and false of the server. boot.js reads every map NAMED in
programs/server/program.json, at boot, unconditionally:

  serverJson.load.forEach(function (fileInfo) {
    if (fileInfo.sourceMap) {
      var rawSourceMap = fs.readFileSync(path.resolve(serverDir, fileInfo.sourceMap), 'utf8');

63 of the 102 load entries name one, 60 MiB of them, so removing the files left
63 dangling names and the server died before opening its port.

The names now go with the files: the same pass deletes `sourceMap` and
`sourceMapRoot` from every load entry. The client is unaffected and always was -
its program.json names no maps at all (678 manifest entries, zero sourceMap
fields) and webapp reads only program.json itself at startup, so a client map is
found through the //# sourceMappingURL comment and 404s harmlessly when absent.

Verified by BOOTING a trimmed bundle rather than by reading the code again: with
uWebSockets.js, the legacy client and all 4766 maps removed, `node main.js`
loads the whole server and reaches AccountsServer.init, failing only on the
deliberately unreachable MONGO_URL it was given. That is the check that was
missing the first time.

tests/bundleTrim.test.cjs pins the invariant boot.js actually requires - every
map program.json names exists on disk - for BOTH settings of --keep-maps, so
neither branch can drift back into the crash.

Thanks to xet7 !
2026-08-16 05:21:03 +03:00
Lauri Ojansivu da221c5496 Reading MongoDB to compare the copies must not look like writing to it.
An instance on 10.96 serving FerretDB prints this at every restart:

  WeKan: BOTH databases have been written to since the migration.
    MongoDB  last written 2026-08-16 01:41 (WiredTiger.wt)
    FerretDB last written 2026-08-16 01:38

Nothing had opened that MongoDB in a month. The giveaway is in the report
itself: the MongoDB timestamp is the minute the snap started, three minutes
AFTER the FerretDB it is compared against.

bin/ferretdb-migration-stale decides "MongoDB has been written to" from the
mtimes of the WiredTiger data files, and its own header admits an mtime cannot
tell "somebody used this database" from "this database was started".
bin/database-autopick is the answer to that - it reads both copies and compares
their contents. But to read MongoDB it STARTS mongod, and starting mongod does
recovery and a checkpoint, which stamps exactly the files the staleness check
reads. So the diagnostic manufactures its own evidence: after autopick has run
once, MongoDB looks freshly written forever, and the ambiguity can never resolve
however long nobody touches it.

The read now notes the newest data-file mtime before mongod starts and puts
anything newer back afterwards - files mongod CREATED during the read included,
since a new journal file is the newest thing in the directory and reports as a
write on its own. mongod does not use mtimes (the catalog and the journal are
what it reads) and what it checkpointed is not user data, so the metadata is
made to say what is true: nobody wrote to this database. Every way out of the
read restores, including the failures, because a failing read is exactly when
the ambiguity gets reported.

A write that happened BEFORE the read still survives it - the point is to hide
WeKan's own read, never somebody's work - and the log and pid files that
ferretdb-migration-stale already ignores are left alone.

tests/autopickReadLeavesNoTrace.test.cjs runs the real shell functions against a
WiredTiger-shaped directory, and pins the two filename lists together: a file
the staleness check reads as evidence and the read does not put back is a stamp
left behind.

Thanks to xet7 !
2026-08-16 05:21:03 +03:00
Lauri Ojansivu 1619a6c37a Guard that every language stays lazily loaded.
WeKan ships 246 languages, 37 MB of JSON. What makes that affordable is one
character in each of 246 near-identical blocks in imports/i18n/languages.js:

  load: () => import('./data/ace.i18n.json'),

A dynamic import() is a SPLIT POINT. rspack emits each language as its own
chunk - measured on a built bundle: 238 chunks, 34 MiB of JS - and a browser
fetches exactly one, about 145 KB for the language in use. Written instead as
`import data from './data/ace.i18n.json'` or with require(), the same line stops
splitting and 34 MiB joins the main bundle for every user, in every language.

That edit is made by hand each time a language is added, which is why it wants a
guard rather than a convention. Seven checks: every entry has a load:, every
load: is the dynamic form, none uses require(), no static import of a data file,
nothing outside languages.js imports one, every file is claimed and every claim
has a file, and a Transifex pull writes the file the app actually loads.

The last two are symlink-aware, and that is the point of them. `.tx/config`'s
lang_map renames most of Transifex's underscored locales to WeKan's hyphenated
files; for the two it does not - km_KH and ru_RU - the hyphenated name is a
SYMLINK to the file Transifex writes. Two names for one file, not two copies.
Reading it the other way costs a language its real translations, so both checks
compare through realpath and say so in their comments.

Thanks to xet7 !
2026-08-16 05:21:03 +03:00
Lauri Ojansivu d6e21202f2 Prune what no require() in the server bundle can reach.
programs/server/npm/node_modules is 347 MB of an ~850 MB bundle, and measuring
its require graph says 206 MB of that is unreachable from any server entry
point. Two things cause it, neither a bug in Meteor:

  * the tree is OUTSIDE the bundler's graph. Meteor 3.5 compiles through rspack
    - the server output is minified and programs/server/app/app.js requires only
    76 bare specifiers, because rspack inlined the app's real dependencies into
    it - but this tree is what Atmosphere packages declare with Npm.depends() and
    load through Npm.require(), which rspack cannot follow. The tree is largely
    the INPUT to a build whose OUTPUT ships beside it;
  * it is a full `npm install`, devDependencies included.

Duplication is the smaller half: 586 distinct packages exist as 815 copies, but
the redundant copies are only ~28 MB, because Meteor keeps per-package
node_modules on purpose so packages can pin conflicting versions.

releases/prune-unreachable-npm.mjs removes 61.3 MiB of it, in four categories
whose reason is PROVABLE rather than merely plausible - the standard the
uWebSockets.js removal met:

  * typescript, 23.2 MiB - a devDependency of 196 packages here (the @aws-sdk
    family) and a runtime dependency of none;
  * openpgp + nodemailer-openpgp, 21.3 MiB - reachable only through an optional
    nodemailer plugin nothing in meteor/email requires;
  * @types/*, 9.6 MiB across 24 copies - verified to contain no .js at all, so
    no require() can resolve into one;
  * sinon, 7.2 MiB - a test framework inside meteor/ostrio_files.

The remaining 145 MB of unreachable packages STAYS. jquery, hotkeys-js and the
@azure storage adapters are almost certainly dead too, but "almost certainly" is
not the standard, and the tail of 590 packages is where a static scan is most
likely to be wrong.

Two independent safeties, because this is riskier than the uws removal was. The
reachable set is recomputed from the bundle every run and deliberately
OVER-approximated - once a package is reached, every file in it is scanned and
every require() string literal counts. The policy then only proposes; the graph
has a VETO, so an entry whose package is actually required is refused and said
out loud rather than applied. And after deleting, every path in the reachable
set must still exist or the run fails.

Verified on a real bundle: 473M -> 410M, and the reachable count is 211 before
and after - nothing that was reachable became unreachable. A second run finds
nothing left to do.

Thanks to xet7 !
2026-08-16 05:21:02 +03:00
github-actions[bot] 024a4e3a42 Bump versions for v10.96 2026-08-16 01:01:12 +00:00
Lauri Ojansivu f98f5b75c3 Prepare v10.96 release 2026-08-16 04:00:07 +03:00
Lauri Ojansivu 61a5a4c92e CHANGELOG: Upcoming gets the entry it was missing, and the right subsections.
The source-map removal was folded into the sockjs entry, whose commit had
KEPT source maps everywhere except Sandstorm - so that entry claimed a change
its commit did not make, and the commit that did make it had no entry at all
and no link. It has both now.

The last two entries are also not build failures: they are a decision about
what a bundle carries. They move to their own 'and changes what every platform
ships:' subsection under a group line of their own, and the In short paragraph
stops saying 'two build failures' for what is now five entries across two
subsections.

Thanks to xet7 !
2026-08-16 03:55:55 +03:00
Lauri Ojansivu a1e175e5f5 CHANGELOG: the source maps go from every platform, not only Sandstorm.
Thanks to xet7 !
2026-08-16 03:50:19 +03:00
Lauri Ojansivu c1e0f02979 Source maps go from every platform, not only the Sandstorm .spk.
The .zip bundles and the Docker image were trimmed with --keep-maps, so only
Sandstorm - which had a 1 GiB ceiling to get under - dropped them. Every
platform drops them now.

152 MiB per bundle, measured: 188 MiB of maps across 4766 files, less the 36 MiB
in web.browser.legacy that --drop-legacy-client already takes with it.

Nothing on any loading path reads one. A source map translates a position in
built code back to the source that produced it - this bundle's server side is
one 117 MiB programs/server/app/app.js, and its 58 MiB app.js.map is what turns
`app.js:1284531` into a file and a line. The two things that read it are browser
devtools, which fetch the .map only when they are open, and Node stack traces
through source-map-support. A released bundle has neither attached to it.

The `//# sourceMappingURL` comments stay behind, and are comments: a missing
target means devtools shows compiled positions and a server stack trace prints
bundle offsets. Debugging a production crash goes back to reproducing it against
a development build, which is where the maps still are.

Thanks to xet7 !
2026-08-16 03:49:55 +03:00
Lauri Ojansivu c9c2bcdb29 CHANGELOG: sockjs everywhere joins Upcoming.
Thanks to xet7 !
2026-08-16 03:42:31 +03:00
Lauri Ojansivu 33c867f4d8 Every platform talks sockjs, and no bundle ships uws or the legacy client.
uws is not reliable enough yet to be what a default points at, so nothing WeKan
ships selects it any more, and nothing carries the module.

DEFAULTS. Every docker-compose file, start-wekan.sh, start-wekan.bat, build.sh,
build.bat, the image's own ENV block, the snap config and the Sandstorm grain
now say sockjs. The commented-out `#- DDP_TRANSPORT=uws` alternatives beside
them are replaced by a note saying why there is no alternative, rather than
advertising a value that no longer works.

BUNDLES. Every place a built bundle is post-processed - the amd64 build, each
per-arch rebuild, the repack container, install-node-for-arch.sh and the
Dockerfile - now runs bundle-trim.mjs beside the prune it already ran:

  * --transport sockjs removes uWebSockets.js entirely. 121 MiB of prebuilt
    binaries for OS/CPU/ABI combinations one machine cannot use, and a sockjs
    server never requires the module at all: ddp-server's
    `Npm.require('uWebSockets.js')` is inside the uws transport's setup(), which
    runs only for the transport that was chosen.
  * --drop-legacy-client removes programs/web.browser.legacy, a whole second
    copy of the client for browsers without modern JS. 81 MiB measured. Meteor
    supports running with architectures excluded and says so in webapp's
    categorizeRequest(): "If our preferred arch is not available, it's better to
    use another client arch that is available than to guarantee the site won't
    work". An old browser is served web.browser; the 404 branch below that is
    reached only when NO arch matches, which cannot happen while web.browser is
    there, and autoupdate iterates the programs that actually loaded.

    The files are only half of it. The arch is NAMED in programs/server/config.json
    and star.json, and boot.js builds a dynamic-import root for every name in the
    first - so the name is removed with the files. Those manifests are mode 444
    as Meteor writes them, so they are made writable, rewritten, and set back:
    failing there would leave the one state that actually breaks a server, files
    gone and manifests still naming the arch.

UPGRADES. An existing docker-compose.yml that says DDP_TRANSPORT=uws would ask
the new image for a module it does not have and crash-loop on the require. So
wekan-entrypoint.sh and the bundle's start-wekan.sh coerce uws back to sockjs
before starting anything, and print why - a setting silently ignored is worse
than one that fails.

tests/sockjsEverywhere.test.cjs pins all three halves together, because each is
useless or harmful without the others: a default without the coercion is an
upgrade trap, a coercion without the trim is dead weight, and a trim without
both is a crash. tests/bundleTrim.test.cjs covers the legacy removal, the
manifest deregistration and the read-only rewrite.

Thanks to xet7 !
2026-08-16 03:40:57 +03:00
Lauri Ojansivu 15b94e7e58 CHANGELOG: the sockjs grain gets its own entry beside the trim.
Two commits, so two entries: the trim that thins uWebSockets.js to one
platform's prebuilds, and the removal of the module altogether now that the
grain's transport is pinned to sockjs. Each links its own commit.

Thanks to xet7 !
2026-08-16 03:22:41 +03:00
Lauri Ojansivu cdbd33b590 The Sandstorm .spk ships no uWebSockets.js at all: a grain talks sockjs.
Trimming uWebSockets.js down to the prebuilds a grain's own platform can open
freed 93M of its 121M. It can all go, because a grain loads NONE of it.

The uws transport is optional in Meteor 3. `ddp-server`'s transports/index.js
resolves the transport from `Meteor.settings.packages['ddp-server'].transport`,
then `DDP_TRANSPORT`, then `DISABLE_SOCKJS`, and DEFAULTS to sockjs - and
`Npm.require('uWebSockets.js')` sits inside the uws transport's `setup()`, which
runs only for the transport that was actually chosen. `sandstorm-pkgdef.capnp`
sets none of those, and its `environ` is by its own comment the app's ENTIRE
environment, so a grain has been running sockjs all along while carrying 121M of
a module it never required.

So the pkgdef now PINS `DDP_TRANSPORT=sockjs` - the removal should rest on a
stated fact, not on an upstream default staying put - and `bundle-trim.mjs`
takes `--transport sockjs`, which removes the module whole rather than thinning
its prebuilds. 120 MiB measured, 27 MiB more than the platform trim, and the
.spk is now ~860M against the 1 GiB limit.

It is a FLAG and not the default because WeKan asks for uws nearly everywhere
else - docker-compose, start-wekan.sh, build.sh - and those bundles keep the
module, trimmed to the prebuilds their own platform can open.

tests/bundleTrim.test.cjs ties the grain's pinned transport to the flag the .spk
is trimmed with. If they ever disagreed, the grain would require a module that
was left out and fail to boot, which is the one way this can go wrong.

Thanks to xet7 !
2026-08-16 03:20:46 +03:00
Lauri Ojansivu 453d4bdf4d CHANGELOG: the cancellation fix and the Sandstorm trim join Upcoming.
The Sandstorm size limit leaves TODO Later with them: v10.95's size report
named what filled the gigabyte, so it is no longer a question a run has to
answer.

Thanks to xet7 !
2026-08-16 03:10:15 +03:00
Lauri Ojansivu 14e8ca4a1e The Sandstorm .spk gets under 1 GiB, by dropping what a grain cannot run.
v10.93, v10.94 and v10.95 all failed to pack with *App exceeds uncompressed size
limit of 1 GiB*. v10.95 was the first run whose size report worked, and it named
the gigabyte: `.meteor-spk/bundle` 852M plus `.meteor-spk/deps` 336M, so 1188M
against a 1024M ceiling. It also answered the question that report was written
to ask — a second `meteor-spk pack` REUSES the bundle rather than rebuilding it,
because the pruned tree stayed pruned across the retry (852M to 833M). So
trimming between the two attempts works; 19M was simply never going to close a
165M gap.

Measuring the bundle rather than guessing at it turned up two passengers that
are large, unreachable at runtime, and safe to drop:

  * uWebSockets.js ships TWENTY prebuilt binaries — linux/macOS/Windows times
    x64/arm64 times four Node ABIs, 121M — and its loader is one line:
    `require('./uws_' + process.platform + '_' + process.arch + '_' +
    process.versions.modules + '.node')`. A grain is one platform running one
    Node; the other sixteen files can never be opened by it. Keeping every ABI
    of the target platform+arch, so a Node major bump still finds its binary,
    still frees ~93M.
  * Source maps — 4766 files, 188M, over a fifth of the bundle. They exist for
    a debugger attached to the process. A packed app has none, and a missing
    .map degrades a stack trace at worst.

releases/bundle-trim.mjs does both, measured at 281 MiB on a real bundle, and
the Sandstorm leg runs it beside the existing prune before the retry pack:
1188M becomes ~888M, with 136M of headroom.

An architecture with NO uWebSockets.js prebuild at all — ppc64le, s390x,
riscv64, where ddp-server falls back to sockjs — is left completely alone, since
deleting the other platforms' files there would free nothing that matters and
could only break the fallback. tests/bundleTrim.test.cjs pins that, the four
kept ABIs, that a directory merely ending in `.map` is not a source map, and
that the trim runs before the retry pack rather than after it.

Thanks to xet7 !
2026-08-16 03:09:02 +03:00
Lauri Ojansivu 0f374a0d41 Cancelling a release run stops it, and a stuck job no longer needs a human.
Pressing Cancel on a release-all run did not stop the `docker` job: it kept
going for another twenty minutes and would have pushed a multi-arch image for a
release that was being abandoned.

The cause is `always()`. It is true while a run is CANCELLING, which is exactly
why it was chosen in v10.80 — a `build-mac-x64` queued on a runner label GitHub
had retired sat for two hours, was cancelled by hand, and that cancellation
SKIPPED `charts`, `ucs` and `nextcloud`, so the Helm chart for a published WeKan
was never pushed. `always()` fixed that by ignoring cancellation entirely, and
ignoring cancellation is also what disabled the Cancel button on a job that
takes hours.

`!cancelled()` keeps the half that matters — do not skip me because a SIBLING
failed — and drops the half that overrides a person. Nine job-level conditions
across release-all.yml, release-all-missing.yml, Flatpak.yml and AppImage.yml
change; the 22 step-level `if: always()` reporting steps do not, because those
correctly print CANCELLED.

That alone would reopen v10.80, so the other half of the fix is that no job
needs a hand cancellation any more: the eight jobs that had no `timeout-minutes`
now have one, and every job in release-all.yml is bounded. A stuck job FAILS on
its own, and a failure satisfies `!cancelled()` the same way `always()` let it
through. Cancel is left meaning only what a person meant by it.

tests/releaseCancelSafeJobs.test.cjs records both incidents and now pins the
opposite of what it used to: no job-level `always()` anywhere, and a timeout on
every job.

Thanks to xet7 !
2026-08-16 03:08:47 +03:00
Lauri Ojansivu 262d2befae CHANGELOG: the two dependabot merges join Upcoming.
Both landed after 'Bump versions for v10.95', so they are not in v10.95:
#6600 raised @aws-sdk/lib-storage 3.1104.0 to 3.1109.0 and #6599 raised
@aws-sdk/client-s3 3.1108.0 to 3.1109.0. They are one dependency batch of
plain bullets closing with 'Thanks to dependabot.', as CLAUDE.md keeps
them, and they say what each package is for rather than only its numbers -
the S3 client the attachments go through, and the multipart helper that
streams the large ones.

The In short paragraph accounts for them in a clause, and the SignupBleed
entry's commit link is checked against the current history, which has moved
twice since it was written.

Thanks to xet7 !
2026-08-16 02:40:57 +03:00
Lauri Ojansivu 4bbb023f5d 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-08-16 02:37:32 +03:00
Lauri Ojansivu 1991aed713 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-08-16 02:37:31 +03:00
Lauri Ojansivu a3f1b62629 SignupBleed: registration disabled, and the REST API created accounts anyway.
POST /users/register guarded itself with
Accounts._options.forbidClientAccountCreation, which NOTHING IN WEKAN EVER
SETS:

  * the only Accounts.config() call, in server/accounts-common.js, sets
    loginExpirationInDays and nothing else;
  * forbidClientAccountCreation: disableRegistration in config/accounts.js
    is passed to AccountsTemplates.configure() - the useraccounts package's
    own options object, not Meteor's Accounts;
  * and that disableRegistration is only assigned inside an async
    Meteor.call('isDisableRegistration') callback that fires AFTER
    configure() has run, which the file's own comment already records.

So the condition was always falsy and the endpoint never refused anybody.
Turning registration OFF in the Admin Panel closed the sign-up form and
left POST /users/register creating accounts for whoever asked - on an
instance whose administrator had decided nobody else may join. The setting
is read from where it actually lives now, the same way the
isDisableRegistration Meteor method reads it, and a missing Settings
document still means registration is allowed rather than locking a fresh
instance out.

Every call that reaches the refusal is an attempt, because there is no
legitimate caller once the admin has turned registration off, so it is
recorded and shows in Admin Panel / Problems under authz.register /
SignupBleed.

Found while reviewing PR #6598 by AhmedLukman, filed against #4774 - a 403
nobody could reproduce, and which this line cannot produce. Looking for it
turned up the opposite fault.

Nine tests, four of them negative, on a route that had none at all: that
the setting is read where the Meteor method reads it, that the dead Meteor
option is not consulted again AND is still never set anywhere (so a future
change cannot quietly reintroduce two sources of truth that disagree), that
an enabled instance still creates the user and gets its token, and that no
Settings document does not refuse everybody.

Thanks to AhmedLukman and xet7 !
2026-08-16 02:37:31 +03:00
Lauri Ojansivu 6f46d836d8 Merge pull request #6599 from wekan/dependabot/npm_and_yarn/aws-sdk/client-s3-3.1109.0
Bump @aws-sdk/client-s3 from 3.1108.0 to 3.1109.0
2026-08-16 02:37:15 +03:00
dependabot[bot] 45a22792bc Bump @aws-sdk/client-s3 from 3.1108.0 to 3.1109.0
Bumps [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) from 3.1108.0 to 3.1109.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.1109.0/clients/client-s3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-15 23:36:25 +00:00
Lauri Ojansivu 7120a9fb1e Merge pull request #6600 from wekan/dependabot/npm_and_yarn/aws-sdk/lib-storage-3.1109.0
Bump @aws-sdk/lib-storage from 3.1104.0 to 3.1109.0
2026-08-16 02:33:57 +03:00
github-actions[bot] 38bf09d3ee Bump versions for v10.95 2026-08-15 23:14:01 +00:00
Lauri Ojansivu 26d31f42ff Prepare v10.95 release 2026-08-16 02:12:54 +03:00
Lauri Ojansivu a7928388f0 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-08-16 02:12:17 +03:00
Lauri Ojansivu e2b305d5fe CHANGELOG: the Sandstorm diagnostic fix goes in a new Upcoming.
v10.94 was tagged and its section is a record, and the fix came after that
prepare, so it belongs above it rather than inside it - the same rule the
v10.93 fixes followed.

The entry says what the v10.94 log actually showed: the size report added
in that release printed nothing, because it ran before meteor-spk pack and
pack is what builds the trees it was measuring. It now reports after pack,
dereferences the symlink that hid most of the size, and prunes the
build-only toolchain for one more attempt before giving up.

Thanks to xet7 !
2026-08-16 02:12:16 +03:00
Lauri Ojansivu 671da7c166 Sandstorm: the size report measured directories that did not exist yet.
v10.94's log shows the report printing NOTHING:

  --- packing (Sandstorm refuses over 1 GiB uncompressed)
  Building Meteor app...

meteor-spk pack is what BUILDS the Meteor app - "Building Meteor app..."
comes after it starts - so .meteor-spk/deps and .meteor-spk/bundle do not
exist until it has run. The report was measuring two directories that were
not there, found neither, and skipped both silently. A diagnostic that runs
before the thing it diagnoses tells you nothing, which is what v10.94 got.

The sizes are taken AFTER pack now: on failure, which is when they are the
whole point, and on success, where the headroom against 1 GiB is worth
knowing before the next thing is added. du -shL DEREFERENCES, because deps
is a symlink to the tree build-deps.sh assembles and plain du -sh on a
symlink reports the link, not the gigabyte behind it. The bundle's server
packages are listed separately, since they are the part every other bundle
prunes.

And ONE bounded retry, which is a real attempt at the fix rather than more
looking. meteor-spk pack runs npm install inside the bundle's
programs/server - that is where tar@6.2.1 and node-gyp's tree come from in
the log - and the Sandstorm leg is the only one that never removes them
afterwards; prune-build-only-modules.mjs drops 83 of 120 packages
everywhere else, and nothing in a packed app runs any of them. Whether it
helps turns on something no log has answered yet: does a second pack REUSE
.meteor-spk/bundle or rebuild it and undo the prune? Both answers are
useful and neither is worse than the hard failure we have now - if it
rebuilds, the second failure is identical and the log says the prune was
undone; if it reuses, the .spk packs. It runs once, only after a failure,
and only if the bundle is actually there.

Exercised both paths against a mock pack: the failure path reports sizes,
prunes, retries and still exits 1 with the reason; the success path packs
and reports as before.

Thanks to xet7 !
2026-08-16 02:12:16 +03:00
github-actions[bot] a69b2f0064 Bump versions for v10.94 2026-08-15 22:21:43 +00:00
Lauri Ojansivu d60d178a05 Prepare v10.94 release 2026-08-16 01:20:39 +03:00
Lauri Ojansivu c5d80ef990 CHANGELOG: an Upcoming section for the v10.93 build fixes.
v10.93 published, so its section stays as the record of what shipped, and
the two build fixes made since go in a new Upcoming above it - which is
what CLAUDE.md now says to do when a release ships with something broken.

Two entries: the Windows one, which is a fixed code fault, and the
Sandstorm one, which is a fixed BLINDNESS - the size limit still stands,
but the pack step now names what filled the gigabyte instead of only
refusing.

The binaries table carries the four platforms this run recorded a complete
provenance for - amd64, arm64 and both macs, with the Node.js and FerretDB
each build verified - and says so, rather than carrying the previous
release's table forward. That habit is why v10.93's table still lists
FerretDB v1.49.0 while every job in that run resolved v1.53.0: the table
is hand-carried between releases and drifts, while the release notes are
generated from provenance.tsv and do not. The release job regenerates this
one the same way.

Thanks to xet7 !
2026-08-16 01:00:16 +03:00
Lauri Ojansivu be0965611e TODO Later: the two v10.93 build failures a reading of the tree cannot answer.
Both from the job logs, both left with the finding rather than a guess.

build-sandstorm hit Sandstorm's 1 GiB uncompressed limit, which means no
.spk is written and nothing says what is big. Recorded with what DOES count
towards that limit - sandstorm-pkgdef.capnp packs alwaysInclude = ["."]
from .meteor-spk/deps and .meteor-spk/bundle, so the assembled Node 24,
FerretDB, mongod 3.0, niscud and Mongo CLIs count beside every package in
the bundle - and with the two candidates worth measuring first: the
Sandstorm leg is the only one that never runs prune-build-only-modules.mjs,
which removes 83 of 120 packages everywhere else, and the deps tree carries
two database engines because the migration needs both. The pack step now
prints the sizes before packing, so the next run names the offender.

snap-launchpad riscv64 was cancelled after printing "Pending: riscv64"
until the job timed out. s390x, ppc64el and armhf all built and published
in the same run, so this is Launchpad queue time, not the recipe.

Thanks to xet7 !
2026-08-16 00:55:58 +03:00
Lauri Ojansivu 21363baf6d Windows builds died on spawnSync npm ENOENT, after all the work was done.
v10.93: build-win64 and build-win-arm64 both failed, and both had already
built the bundle and compiled its native modules. What killed them was one
line of bump-bundle-npm-deps.mjs:

  Error: spawnSync npm ENOENT

npm on Windows is npm.cmd, a batch script, and Node applies no PATHEXT when
it spawns - so execFileSync('npm', ...) resolves to nothing. build-win32 was
skipped that run for want of a published Node.js, so it never reached this
and looked fine; the fault was never architecture-specific.

npm's own CLI is run with the Node already running instead:
execFileSync(process.execPath, [npm-cli.js, ...]). No PATH lookup, no
PATHEXT, no shell - and the same npm either way. shell: true would have
found the .cmd and broken differently, because with a shell Node joins the
arguments and quotes NOTHING, so the first Windows temp path with a space
would corrupt the install. A bare npm on PATH remains as the last resort,
for a Node with no npm beside it, and on Windows it now says which case
that is instead of letting ENOENT speak for itself.

Verified end to end: a fake bundle holding qs 6.0.0 is bumped to 6.15.3
through the new path, with its new dependencies copied in beside it.

Also, build-sandstorm failed with "App exceeds uncompressed size limit of
1 GiB" and nothing else - no list, no sizes, and the .spk that would have
shown them was never written. sandstorm-pkgdef.capnp packs alwaysInclude =
["."] from .meteor-spk/deps and .meteor-spk/bundle, so everything in both
trees counts. The step now prints the total and the twenty biggest
directories of each BEFORE packing, so the next run says what to trim
rather than only that trimming is needed. Trimming it needs a run to
verify, so it is recorded rather than guessed at.

Thanks to xet7 !
2026-08-16 00:55:58 +03:00
dependabot[bot] f76e560e91 Bump @aws-sdk/lib-storage from 3.1104.0 to 3.1109.0
Bumps [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) from 3.1104.0 to 3.1109.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1109.0/lib/lib-storage)

---
updated-dependencies:
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1109.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-15 20:33:28 +00:00
github-actions[bot] fe65bb658d Bump versions for v10.93 2026-08-15 20:14:00 +00:00
Lauri Ojansivu 94b1bc60e5 Prepare v10.93 release 2026-08-15 23:12:50 +03:00
Lauri Ojansivu dd588eac63 The Docker fix moves to a new Upcoming, and CLAUDE.md says which case is which.
v10.92 PUBLISHED, and Docker did not work in it. A released section is a
record, not a draft, so v10.92 keeps everything it shipped with - including
the release-notes fix, which is what let it publish at all - and the
waiting-page fix moves to a fresh '# Upcoming WeKan (R) release' above it.
release-all.sh will rename that to v10.93 on its own.

CLAUDE.md described the happy path correctly and said nothing about the two
ways a release goes wrong, both of which happened with v10.92 within an
hour of each other, and which need OPPOSITE handling:

  * nothing published - there is no release for that number, so the heading
    goes back to Upcoming and the same number is used again;
  * published but broken - the section stays, and the fix goes in a NEW
    Upcoming.

What decides it is whether the GitHub Release exists, not whether the tag
does: prepare pushes the tag early, so v10.92 had a tag for hours while
nothing was published under it. That is written down now, because guessing
it wrong either erases a release people downloaded or claims one that never
existed.

Also made the workflow description exact while there: bump bumps the
versions and rebuilds the API docs, prepare pushes the tag and checks the
notes, and release publishes - it said 'the release jobs tag v<new>', which
is the wrong job and the wrong order.

Thanks to xet7 !
2026-08-15 23:11:45 +03:00
Lauri Ojansivu 53d64f2e77 CHANGELOG: the waiting page that never left goes under Upcoming.
Beside the entry that ADDED the page, in the same Starting up group, since
the two are one story: the page is new in this unreleased version, 10.91
had none, and the page is what broke Docker for the reporter.

Says what it was (the probe could not resolve a driver, so it never asked
the database anything), why the snap was fine (it never runs db-ready.mjs),
what else was found while proving it (the forced directConnection, and the
silence), and that WEKAN_DB_WAIT_PAGE=false still turns it all off.

Thanks to Alishara and xet7 !
2026-08-15 23:01:02 +03:00
Lauri Ojansivu df41f61c26 The waiting page hid a healthy WeKan: the probe never found a driver to ask with.
The login page did not load. The container logged "The database is not
answering yet" and served the waiting page, while mongosh on the host
talked to the same MongoDB without complaint - a Meteor 3 production setup
from docs/Platforms/FOSS/Container/Docker/Meteor3, MongoDB 7.0.40, rs0,
network_mode host, MONGO_URL a single host with authSource.

The database was never the problem. db-ready.mjs asked for the driver at
programs/server/node_modules only - and mongodb is a devDependency, not a
dependency, so a production bundle has NOTHING there. Meteor's own driver
lives under programs/server/npm/node_modules/meteor/npm-mongo. require
threw MODULE_NOT_FOUND on every ask, in every container, whatever the
database was doing; the catch turned that into "not ready", the entrypoint
sent the reason to /dev/null, and WeKan sat behind the page for the whole
ten-minute window before starting.

The snap already carries this scar. Its db-eval.mjs says so in a comment -
"made WeKan loop 'MongoDB not ready' forever" - and resolves the driver
from a list of bundle paths. db-ready.mjs now uses the same list. That is
also why the snap was unaffected: it never runs db-ready.mjs at all.

And no driver no longer means a page. It exits 2, distinct from 1, and the
entrypoint starts WeKan without the page: "I could not ask" is not evidence
that anything is wrong, and a page shown on that basis hides a WeKan that
would have served fine. Docker with an external MongoDB needs no waiting
screen, and now it does not get one it has not earned.

Reproduced against a bundle-shaped tree with the driver only under
npm-mongo: the old probe exits 1 and prints nothing, the new one resolves
the driver and reports what the database actually said.

Thanks to Alishara and xet7 !
2026-08-15 22:58:57 +03:00
Lauri Ojansivu 9f5ea0f498 The waiting-for-database page never left, at a database that was answering.
Reported with a screenshot: the container logs "The database is not
answering yet" and serves the waiting page forever, while mongosh on the
host connects to the same MongoDB - replica set rs0, [direct: primary],
7.0.40 - without complaint.

The probe was wrong, not the database. db-ready.mjs forced
directConnection: true. For one host that is harmless; a replica set is
normally a SEED LIST, and the driver refuses that combination outright:

  MongoParseError: directConnection option requires exactly one host

The throw happened while the client was being CONSTRUCTED, which was
OUTSIDE the try, so the probe died with an unhandled error on every ask.
Its exit code said "not ready", the entrypoint sent its stderr to
/dev/null, and the page stayed up with nothing anywhere saying why.

The options now come from the URL and nowhere else. That fixes the crash
and is the more correct question besides: WeKan connects with the URL as
written, so a probe that quietly connects DIFFERENTLY can report ready for
a database WeKan cannot reach - dropping the page and leaving the web port
closed, which is the exact fault this feature exists to prevent. A URL the
driver refuses is now reported rather than thrown: waiting will never fix
a malformed MONGO_URL, and saying so beats a page that spins for ten
minutes.

The reason is printed once, and put ON THE PAGE. Whoever is waiting is
looking at a browser, not at docker logs, and
"MongoServerSelectionError: connect ECONNREFUSED wekan-db:27017" names the
host that could not be reached. The first probe reports; the three-second
poll stays quiet; the last one reports again if the whole window expires.

Only the container is affected, which is why the snap was fine: the snap
serves its own page from wekan-control and never runs db-ready.mjs, and
its MONGO_URL is always a single local endpoint - a unix socket or
127.0.0.1:27019 - so it never had a seed list for the forced option to
collide with. The shape that breaks only occurs when MONGO_URL points at a
real MongoDB replica set, which is the Docker case.

Thanks to Alishara and xet7 !
2026-08-15 22:53:39 +03:00
Lauri Ojansivu 0e4177bb31 Prepare v10.92 release 2026-08-15 21:46:19 +03:00
Lauri Ojansivu 8cda7007e9 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-08-15 21:46:02 +03:00
Lauri Ojansivu 9d020e9333 CHANGELOG: v10.92 never published, so its section is Upcoming again.
The release job failed, and with it every job that needs it - snap, docker,
AppImage, the Windows and macOS bundles - so nothing was published under
that tag at all. A "# v10.92" section describes a release a reader can
download, and there is none, so the heading goes back to Upcoming and the
whole of what was written for v10.92 ships in the next release instead.
That is exactly the rename "Prepare v10.92 release" made, undone.

The workflow fix joins the release-workflow group under the bug fixes,
where the v10.91 snap-attachment fix already is: the notes outgrew the
128 KiB a single environment variable may hold, so execve refused to start
bash before a line of the step ran, and they go from file to file now.

The In short paragraph leads with it, because "this was tagged v10.92 and
never published" is the first thing a reader of this section needs.

Two rows of the binaries table were wrong and are corrected from the build
logs: amd64 and arm64 embed FerretDB v1.53.0, not v1.49.0, with the SHA256
each build verified. Those are the only two platforms that got as far as
building, so they are the only two there is evidence for; the rest keep
what the previous release recorded and refresh from provenance.tsv on the
next successful build.

Thanks to xet7 !
2026-08-15 21:46:00 +03:00
Lauri Ojansivu ac02941663 v10.92 could not start bash: the release notes outgrew an environment variable.
The release job failed before running a line of its script:

  ##[error]An error occurred trying to start process /usr/bin/bash with
  working directory /home/runner/work/wekan/wekan. Argument list too long

Nothing in the step was wrong. The notes are the whole newest CHANGELOG
section - prepare measured them at 172,458 characters - and they reached
the step as env: CHANGELOG. Linux caps a SINGLE argv/envp string at
MAX_ARG_STRLEN, 128 KiB, so execve refused to start the shell at all. Both
bundles had built and verified; the release, and every job that needs it -
snap, docker, AppImage, the Windows and macOS bundles - never happened.

The environment was itself the fix for the previous failure: interpolated
inline as ${{ }} the notes become part of the shell SOURCE, so a backtick
in any `code` span runs as a command, which is how v10.59 published nothing
and printed "Incorrect: command not found". One shape is unsafe, the other
does not scale, and this release was big enough to find the second wall.

So the notes stop being a shell value at all. releases/release-notes.sh
prints the CHANGELOG section for a version, reading the file itself and
taking its arguments from the environment inside a QUOTED heredoc; the jobs
append its stdout to release-notes.md. Only file paths are passed around,
which has no size limit and leaves the text as data no shell ever parses.

prepare keeps the validation - a missing section is still cheap to fix
there - but no longer publishes the text as a job output, because an output
nobody can safely consume is a trap for the next person. The release job
and the notes-rewrite job check out ref: main, the same ref prepare read.

The guard test is rewritten around the new shape: no changelog job output,
no CHANGELOG in an env:, no interpolation into a script, and the extraction
script is what all three consumers run. It kept passing through this
failure, because it only knew about the injection.

Thanks to xet7 !
2026-08-15 21:46:00 +03:00
github-actions[bot] 24973ea98b Bump versions for v10.92 2026-08-15 18:13:13 +00:00
Lauri Ojansivu 9bf592a5f8 Prepare v10.92 release 2026-08-15 21:12:08 +03:00
Lauri Ojansivu 7ecce0502f TODO Later says where the translation work stands, and a command that says it.
The entry had grown by accretion into five overlapping bullets, and every
number in it was a number in a changelog - true on the day it was written
and stale by the next commit. It is rewritten as something a later session
can resume from, and it opens with the command rather than the figures:

  node releases/translations/fill-translations.mjs --status

--status is new. A flat count of "strings still equal to the English
source" is several times the size of the backlog and says nothing about
where the work is, so it splits the count two ways. What the string IS: a
product name, a bare number, a symbol or a bare __placeholder__ has no
translation to give and will never stop counting. And WHICH SCRIPT the
language uses: in a Latin-script language an untranslated Status reads as a
word, and in a Greek, Arabic, Thai or Devanagari interface it is a foreign
alphabet mid-sentence. It then ranks the remaining keys by how many files
share each, which is the work order - one key missing in fifty files is one
table, not fifty visits.

Today that reads 99 words left in the 56 non-Latin near-complete files,
1,371 in the 86 Latin ones, and 193,247 in the 92 second-tier ones.

The entry keeps what a reader needs and drops what repeats: what is DONE
(both wrong-language axes, with the guard command), what is left in the
order worth doing it, the two rules that must not bend (translate directly,
never overwrite a human translation), and three things that bit and will
again - files written in a language other than their name, operator names
that cannot contain a space, and a reworded English source leaving stale
English that stops being offered.

Thanks to xet7 !
2026-08-15 21:05:14 +03:00
Lauri Ojansivu d1bd063a2c CHANGELOG: Upcoming had drifted from four rules CLAUDE.md sets.
Checked the whole section against CLAUDE.md rather than against the guard
suite, which only pins what can be pinned mechanically. Four things were
wrong, and none of them failed a test.

SUBSECTION ORDER. Developer-facing changes sat between the new features
and the bug fixes. CLAUDE.md puts it after the bug fixes, before the
documentation and the translations, so the release reads in the order a
reader cares about it.

GROUPING. Eight translation entries were under the group that happened to
be above them rather than the one they belong to: the four wrong-language
fixes had landed under "Panels added since a file was last touched", and
the four by-key fills under "The search operators". The wrong-language
four move up to "Files written in another language", and the by-key four
get a group of their own, since filling one key across a hundred files is
a different job from translating one file.

THE CLOSING LINE was missing outright. Upcoming now ends with the sentence
CLAUDE.md prescribes word for word.

THE IN SHORT PARAGRAPH was stale - it still said four files in the wrong
language and 3,984 strings, which was true for about a day. It now says
what the release actually amounts to: 8,716 values in the wrong language
found by two checks neither of which existed before, the search operators
and their shorthands, and the panels a file never had. It also loses a
one-word line left over from a bad wrap.

Also trimmed one summary from 111 characters to fit the 110 CLAUDE.md aims
for.

Thanks to xet7 !
2026-08-15 20:59:58 +03:00
Lauri Ojansivu dc1b8c3824 CHANGELOG: the import dialogue everywhere, and what the refusals measured.
Two entries, and a second measurement bullet in TODO Later beside the
by-script one. The 86 Latin-script near-complete files hold 1,371 real
words against 3,981 product names - the same split, one script over.

The way to find out how small the remainder really is turned out to be to
OFFER a key its translation and count the refusals: twenty everyday words
were filled 48 times and ignored 202, because Filter, Container, Version,
Pause, Type and Latitude are already those languages' own words. That
number is the reason the backlog reads several times larger than it is,
and it is worth writing down so the next person does not rediscover it.

Thanks to xet7 !
2026-08-15 20:52:27 +03:00
Lauri Ojansivu 8e62dd157a Twenty everyday words, and the 202 that were already right.
The next twenty keys by how many files share them - Dialog, Container,
Tests, Filter, Version, Pause, Repository, Detail, Type, Color, Error,
Roles, Latitude, Longitude, orange, Admin - were offered to the 56
Latin-script files that still had them in English. 48 were filled and 202
were IGNORED, because the value offered was the English word and the
English word is what that language uses: Filter is Filter in German, Dutch,
Danish, Frisian and Estonian; Container, Tests, Version, Pause, Repository,
Detail, Type and Latitude are themselves across most of western Europe.

What did change is where a language really has its own: Spanish Funciones
for roles, Administrator and Administrateur and Frisian Behearder for
admin, Slovak Podrobnosť for detail and Dialóg for a dialog, Italian
Contenitore and Finestra di dialogo, Vietnamese Vùng chứa, Portuguese
Pausa, and Yoruba Ìwọ̀n ìhà and Ìwọ̀n gígùn for latitude and longitude.

The 202 are the answer worth recording. They are counted as untranslated
only because the tool's test for it is "still equal to English", and no
amount of work will ever reduce them - which is why the backlog number is
four times the size of the backlog.

Thanks to xet7 !
2026-08-15 20:50:21 +03:00
Lauri Ojansivu e53e895f6b The import-mapping dialogue, in the other 85 files.
Finishes the pair the non-Latin files got two commits ago: 170 values
across the Latin-script languages, from Afrikaans to Zulu. These are the
two longest strings in the file - a paragraph explaining that mapping an
imported member to a real user moves that member's cards, comments and
activity, and that the user is added with the imported member's OWN role,
so mapping can never grant more permission than the import did. That last
clause is the one worth getting right in every language, because it is the
answer to "what am I about to give this person".

Seven files are written in a language other than the one their name claims
and get that one: ace is Malay, ast-ES Spanish, ro Italian, ve Zulu,
vl-SS Dutch, and vo, wo, zgh and wa-RR are French.

tlh is deliberately left in English. Klingon has words for a board, a card
and a rank, and this paragraph needs none of those - it needs three
subordinate clauses about permission, and inventing them would produce
something that reads as though somebody meant it. An English placeholder
says plainly that nobody has translated it yet, which is the more useful
thing for the one reader who could.

Thanks to xet7 !
2026-08-15 20:47:38 +03:00
Lauri Ojansivu c4799099d6 CHANGELOG: the operator words, the loanword split and the import dialogue.
Three entries for this round of the prose backlog, and a new first bullet
in TODO Later: read the count BY SCRIPT. An untranslated string in a
Latin-script language is a word; in a Greek, Arabic, Thai or Devanagari
interface it is a different alphabet mid-sentence. By that measure the 52
non-Latin files hold 133 real words against 1,927 product names, and 71 of
the 133 are the two act-with*Title keys, which are pure __board__
placeholders with no translation to give. That half is effectively done,
which is the thing the flat number never said.

Thanks to xet7 !
2026-08-15 20:16:53 +03:00
Lauri Ojansivu 4a950b80bb The import-mapping dialogue, in the 48 files where English is a foreign alphabet.
An untranslated string is not equally visible everywhere. In a Latin-script
language "Status" reads as a word; in a Greek, Arabic, Thai or Devanagari
interface an English paragraph is a different alphabet in the middle of the
page. Measured that way, the non-Latin files are nearly done: 235 real
words still in English across 52 of them, and 102 of those were these two
keys.

They are the strings the import dialogue shows when it asks which real user
an imported member is - the pair whose English source was reworded, so
every file kept the old English and the fill tooling stopped offering them.
96 values here, in 24 languages: Arabic, Belarusian, Bulgarian, Greek,
Persian, Gujarati, Hebrew, Hindi, Armenian, Japanese, Georgian, Khmer,
Korean, Macedonian, Mongolian, Odia, Punjabi, Russian, Serbian, Tamil,
Telugu, Thai, Uyghur, Ukrainian, Yiddish and Chinese in both scripts.

zh-TW and uz-AR already had human translations and kept them.

Serbian gets the case-file vocabulary the rest of that file uses: a board
is "списи" and a member a "сарадник", so the sentence reads as the same
document the rest of the interface describes.

Thanks to xet7 !
2026-08-15 20:14:29 +03:00
Lauri Ojansivu da8d02315a Statistics, package and the region names, where the word is not the English one.
128 values across 67 files, chosen by asking of each key whether the
language actually has a different word - because most of what the count
calls a prose backlog is not one. Status is Status in Danish, German,
Dutch, Swedish and Polish; Server is Server almost everywhere; Normal,
Ticket, Menu and Logo are themselves in most of Europe. Those are correct
as they stand and are left alone.

What is filled is where the English word is visibly foreign or the language
plainly has its own: the non-Latin scripts (Κάδος and Στατιστικά in Greek,
דלי in Hebrew, سطل in Persian, Кофа in Bulgarian, Корпа in Serbian,
كنجب in Khmer, Багц in Mongolian, and America in seven Indic and East Asian
files), the African languages that had been given English (IMelika,
Umlawuli, Iphakethe, Iphakheji, Ngwugwu, Idi, Marekani), and Statistics in
the twelve European languages that had not translated it.

uz-AR already had human translations for the two it was offered and kept
them, which is the fill rule rather than a special case.

Thanks to xet7 !
2026-08-15 20:07:51 +03:00
Lauri Ojansivu d62c15aedc The rest of the search operators, in the 107 files that had done the others.
A file that translated board:, list: and member: and left org:, title: and
customfield: in English is inconsistent with itself: half the search syntax
is in the reader's language and half is not, and nothing on screen says
which half is which. 217 operator words are filled here, only in files that
had already translated at least two of the core three - so this never
invents a search vocabulary for a language that has not chosen one.

An operator NAME is matched before the colon, so it can never contain a
space. Languages that write these as two words run them together, the way
Greek already writes προσαρμοσμένοπεδίο: Slovak vlastnepole, Hungarian
egyenimezo, Thai and Vietnamese likewise.

72 are deliberately left as they are. status, limit, team, selector,
projection, description, week and open are those languages' OWN words in
Dutch, Swedish, Spanish, French, Catalan, Czech, Polish, Turkish and Malay,
and the fill step ignores a value equal to the English source rather than
pretending a translation happened.

Five of the files are written in a different language from their name and
get that one: ace is Malay, ast-ES Spanish, ro Italian, ve Zulu, and vo, wo
and zgh are French.

Thanks to xet7 !
2026-08-15 20:04:25 +03:00
Lauri Ojansivu 0ced10941c CHANGELOG: the Latin-only detector and its four files go under Upcoming.
Four entries: the detector itself and the stale-English reset beside it,
the small files it caught first (ka Turkish, ar-DZ French, mn), Thai's 683
Vietnamese values, and Greek's 924 Italian ones.

TODO Later stops describing the wrong-language problem as four files in
another script. It was two problems: 5,542 values in another SCRIPT and
3,174 in the LATIN alphabet inside a language not written in it, which no
Unicode-block comparison can see. Both are at zero, and the paragraph says
which of the languages were done by looking the words up rather than by a
speaker, since that is what a reviewer needs to know first.

Thanks to xet7 !
2026-08-15 19:44:38 +03:00
Lauri Ojansivu 8df7376600 Greek: the last 459, and the Latin-only backlog is empty.
Finishes el and el-GR - global search with its whole instruction text, the
board and card reports, the wait spinners, the attachment storage, every
board and database migration with its confirmation dialogue, the
brute-force lockout settings, and the login and repository pages.

The search operators are Greek words, as they are now in Hindi, Tamil,
Georgian, Korean and Thai, because they are what a user TYPES: πίνακας for
a board, λίστα for a list, προθεσμία for due - and the instruction text
names those same words through its __operator_*__ placeholders.

That empties the Latin-only backlog the detector found three commits ago:
3,174 values across ka, ar-DZ, mn, th, el, el-GR and a tail of small ones,
all done. wrong-script.mjs --count reports zero for both of its checks now
- no value in another SCRIPT, and none in the Latin alphabet inside a
language that is not written in it.

Greek was written by looking the words up rather than by a native speaker;
a review is welcome and replaces these strings permanently.

Thanks to xet7 !
2026-08-15 19:42:32 +03:00
Lauri Ojansivu 186ca9ebaf Greek: 465 of 924 values that were Italian.
el and el-GR are the same file twice, and 924 of their values were written
in ITALIAN - lista rinominata in, si e unito a, Bacheca for a board, Lista
for a list, Couloir (French, for good measure) for a swimlane. Latin script
in a Greek file, invisible to a Unicode-block comparison, and 926 of them
sat behind a green count.

465 are Greek here, in both files: the activity feed, the workspace and
board menus, the archive dialogues, Planning Poker, the export fields, the
shortcuts, the rules engine and the Admin Panel.

The terms follow what the file already had right - Πίνακας, Κάρτα, Λίστα,
Ετικέτα, Μέλος, Λίστα ελέγχου, Αρχείο, Προσαρμοσμένα Πεδία - and fill the
two it did not: Διάδρομος for a swimlane and Υπεύθυνος for an assignee.

459 left.

Thanks to xet7 !
2026-08-15 19:36:52 +03:00
Lauri Ojansivu 482065d8fb Thai: 683 values that were Vietnamese.
A third of th.i18n.json was written in VIETNAMESE - Bang for a board, The
for a card, Danh sach for a list, Lan ngang for a swimlane - beside 1,594
values of real Thai. Latin script in a Thai file, so the Unicode-block scan
never saw it; the Latin-only detector added in the previous commit did.

All 683 are Thai now: the activity feed, the rules engine, the card
details, the filters, the Admin Panel, and the whole of global search. The
terms are fixed once and used throughout - บอร์ด for a board, การ์ด for a
card, รายการ for a list, สวิมเลน for a swimlane, เช็คลิสต์ for a
checklist, คลังเก็บ for the archive.

One value was not a translation at all: act-withCardTitle read
__kartu__[__Panel__], two Indonesian words in placeholder syntax, so the
notification it formats could never substitute a board or a card. It is
[__board__] __card__ again.

Thai was written by looking the words up rather than by a native speaker,
so a review is welcome - and it replaces these strings permanently when it
arrives on Transifex.

Thanks to xet7 !
2026-08-15 19:32:04 +03:00
Lauri Ojansivu b65518dca9 Georgian was Turkish, Algerian Arabic was French, Mongolian was English.
The Latin-only detector's first harvest, smallest first. ka held 43 values
of TURKISH (Klavye kisayollari etkin), ar-DZ held 508 of FRENCH, and mn
held 44 that were never translated at all but had drifted far enough from
the English source that the fill tooling no longer offered them. Plus the
tail: six in ug, two in fa, one each in or_IN, pa and uk.

ar-DZ is fixed from ar, key by key and only where ar has a translation -
the four search operators ar itself lacked are written for both, so the
variant is never ahead of its base.

The script map also grew the languages it never listed: ary, the nine
Turkic and Mongolic languages written in Cyrillic (ba, bua, cv, kk, ky, mn,
sah, tg, tt), and Chinese under every tag it ships as. Of those only mn had
anything to report, which is the answer worth having.

Left: el and el-GR hold 924 values of Italian each and th holds 683 of
Vietnamese. Those are the next backlog.

Thanks to xet7 !
2026-08-15 19:23:39 +03:00
Lauri Ojansivu ae3686d67c Latin in a non-Latin file, and English that stopped looking untranslated.
Two blind spots, both found by asking the scan a question it could not
answer before.

FIRST: a value written entirely in the LATIN alphabet inside a language
that is not written in Latin. The Unicode-block comparison could never see
it, because Latin is not one of the blocks it compares against - so el and
el-GR hold 926 values of ITALIAN each, th holds 685 of VIETNAMESE, ar-DZ
holds 510 of FRENCH and ka 45 of TURKISH, and the count read zero
throughout. wrong-script.mjs now flags them as latin:N. Product names are
Latin too, so a value is only suspect when it says something: five letters
or more, and not the English source wearing different punctuation.

SECOND: the two keys map-to-existing-user-desc and -none were REWORDED in
en.i18n.json, and every other file kept the old English. Old English is not
equal to the new source, so the fill tooling counted it as a translation and
stopped offering the key - English that has stopped looking untranslated,
in 145 files. The current source is written back, which changes nothing a
reader sees and makes 290 values countable again.

The 3,096 latin:N values are NOT fixed here; they are the next backlog and
are recorded as such.

Thanks to xet7 !
2026-08-15 19:18:57 +03:00
Lauri Ojansivu fc96d57bf2 CHANGELOG: the scan's blind spots and the search shorthands go under Upcoming.
Two more entries under the wrong-language group - the base-tag-only scan
that never looked at hi-IN or ko-KR, and the CJK exclusion that was wide
enough to pass 80 non-Korean values per Korean file - and a new group for
the 277 search shorthands derived from each language's own operator word.

The shorthand entry keeps the three orderings that had to be right, because
each of them silently produced something worse than what it replaced and
the next person to touch that derivation will hit them again.

Thanks to xet7 !
2026-08-15 19:04:51 +03:00
Lauri Ojansivu 774d7a032b Search shorthands are the language's own letter, in 89 files.
Each search operator has a one-letter shorthand beside it, and the
shorthand is the first letter of THAT LANGUAGE'S word - French couloir is
c, Russian доска is д, Georgian დაფა is დ. A file that translated the word
but kept the English letter contradicts itself: board: works, and b: stands
for nothing the reader can see.

277 shorthands, derived rather than guessed: the first grapheme of the
file's own word, extended while it collides with another shorthand in the
same file, and written only when it differs from the English letter -
Portuguese lista and German Liste both start with an l, so l is already
correct and is left alone. Every file ends with five distinct shorthands;
Welsh aelod and aseinai are a and as, Hungarian Tábla and tag are t and ta.

Three orderings had to be right or the result was worse than before it:
case-fold before the collision test (Tábla and tag are both T), seed the
taken set from the shorthands that are NOT being changed (Frisian lijst
kept l while lid was given l), and test the English letter AFTER the
collision loop rather than before it (or Welsh keeps a twice on the grounds
that a is what English uses).

Skipped where the operator WORD is itself in another language, because a
shorthand derived from it would carry that one step further and the word is
what wants fixing: tlh is German, th is Vietnamese, br is French, ve-PP is
Finnish, and one key each in mn, sk, lv, vo and zgh. Latin-script
contamination like that is invisible to wrong-script.mjs, which compares
Unicode blocks.

Thanks to xet7 !
2026-08-15 19:02:47 +03:00
Lauri Ojansivu 4be57c2ad5 Korean: 80 values per file that were Chinese or Japanese, not hanja.
The scan excluded CJK entirely for a Korean file, on the correct grounds
that hanja beside hangul is legitimate Korean. That exclusion was too wide:
it also passed a value with NO hangul in it at all, which is not Korean
under any reading - it is whatever the file was seeded from. 看板 for a
board, 拡大 and 縮小 for zoom, 担当者 for an assignee, 賛成 and 反対 for
the two sides of a vote, and the three date formats written 年-月-日.

The rule is now the narrower one it should always have been: hanja is fine
WITH hangul, and a hangul-script value that contains CJK and no hangul is
flagged. That is 80 values in ko and the same 80 in ko-KR, all translated
here, including the search operators a user types - 담당자, 마감, 조직.

Thanks to xet7 !
2026-08-15 19:01:09 +03:00
Lauri Ojansivu fb969b2745 The scan said zero and meant zero of the tags it happened to name.
wrong-script.mjs listed the script for a base tag only - hi, ko, ja, el,
ru - so hi-IN and ko-KR were never checked at all. Both are full copies of
what hi and ko used to be: hi-IN held 1,204 values in Gujarati and ko-KR
held 354 in Japanese kana, and the count reported zero the whole time the
two of them were the worst files in the tree.

Every file whose tag reduces to a known base is now checked under that
base's script, so a variant can never hide behind its parent again. The two
that surfaced are fixed from their corrected base, key by key, and only
where the base actually has a translation - the three values the base still
carries in English (ldap, excel-font, gridfs-storage) stay in Latin because
that is what they are.

node releases/translations/wrong-script.mjs --count reports zero again, and
this time it has looked at all 246 files.

Thanks to xet7 !
2026-08-15 18:58:32 +03:00
Lauri Ojansivu 6f49798840 CHANGELOG: the Home page, Board status and Board roles fills go under Upcoming.
A new group, "Panels added since a file was last touched", for the two
batches: 197 values for the All Boards Home and starred pages across 33
languages, and 307 for the board-status summary and the roles-status table
across 47 files. Both say which files are written in a different language
from their name and are completed in THAT one, and which are low
confidence and want a speaker.

TODO Later loses 505 from its count and gains what those two batches
taught: much of what looked like a prose backlog is loanwords. magenta and
indigo are magenta and indigo in nearly every language that "misses" them,
and so are Format, Ticket, Normal, Admin and Status in most European ones,
so the real remainder is nearer 2,200 than 3,400 - and the way to work it
is by KEY, since one key is missing in ten to a hundred files at once.

Thanks to xet7 !
2026-08-15 18:36:08 +03:00
Lauri Ojansivu 330b642190 Board status and Board roles, in the 47 files that still showed English.
Two panels added since those files were last touched: the board-status
summary (card loading, time spent, total, cards with time, overtime cards)
and the roles-status table (what each board role may do, and its Invite /
Sees cards / Create and edit / Board settings columns). 307 values.

Five of the files are not written in the language their name claims, and
are completed in the language they are ACTUALLY in rather than left half
English: ro is Italian, ast-ES is Spanish, ve is Zulu, wo is French, and
sr uses case-file vocabulary throughout - a board there is "Списи", so its
board status is "Стање списа" and not a literal translation of the English.

Two are low confidence and want a speaker: Klingon, where the boQwI"
lexicon has "patlh" for a rank but no idiom for a board role, and Volapuek.

Roles-status-role is deliberately left as-is in Czech, Spanish and Walloon:
"Role" and "Rol" are those languages" own words, and the fill step ignores
a value equal to the English source rather than pretending otherwise.

Thanks to xet7 !
2026-08-15 18:33:22 +03:00
Lauri Ojansivu 3eb1631d54 The Home page and starred pages, in the 33 languages that still showed English.
The All Boards Home strings - Remove from Home, its confirmation, Click to
star this page, Click to unstar this page, Starred Pages, and the empty
Home hint - were the newest keys in the file and had reached only the
languages that had been touched since. 197 values are filled here across
33 files.

Four of those files are not written in the language their name claims, and
are completed in the language they are ACTUALLY in, as CLAUDE.md requires
rather than leaving half of each in English: ace is Malay, ast-ES is
Spanish, ve is Zulu, and every uz variant including uz-AR is Latin Uzbek.

km_KH already had human translations for all six and kept them - the fill
step writes only where the value is still the English source, which is why
that file reports "skipped 6" rather than being overwritten, and sr kept
the one it had.

Thanks to xet7 !
2026-08-15 18:27:05 +03:00
Lauri Ojansivu 86f3785c3c CHANGELOG: the four files written in another language go under Upcoming.
Korean, Georgian, Hindi and Tamil held 3,984 values in a script the reader
of that language may not read - kana, Cyrillic, Gujarati and Telugu. Two
entries under a new "Files written in another language" group say what was
wrong, why nobody had noticed (each file was mixed, with save right and
board wrong), what the search-operator decision was, and which three
values stay in Latin because they are a font family, a protocol and a
product name.

TODO Later loses the table that listed them as outstanding: they are done,
and what remains there is the prose backlog and the 92 second-tier
languages. The In short paragraph names them too, since it is the first
thing a reader of the release sees.

Thanks to xet7 !
2026-08-15 18:20:05 +03:00
Lauri Ojansivu 7a3cd208c7 Tamil: the last 517 foreign-script strings are Tamil now, and no file is left.
Finishes ta.i18n.json - the global search instructions, the reports and
wait spinners, the whole attachment storage and migration machinery, the
brute-force lockout settings, and the login and repository pages. Two
values stay in Latin because they are not words: excel-font is the font
family handed to the spreadsheet writer, and gridfs-storage is a product
name; the Telugu transliterations of both were unusable either way.

That closes the whole wrong-script backlog. Every one of the 246 language
files now holds text in the language it claims - ko, ka, hi and ta were
the four seeded from another language, and all four are done. The scan
that found them is still there:

  node releases/translations/wrong-script.mjs --count

now reports zero, and it is the guard that catches the next file seeded
from a neighbour before anyone ships it.

hi and ta were translated by looking the words up rather than by native
speakers. A speaker of either who reviews them is welcome, and the pull
merge guarantees their Transifex translation replaces these strings and is
never reverted.

Thanks to xet7 !
2026-08-15 18:15:48 +03:00
Lauri Ojansivu b6c4fc64f8 Tamil: 1,120 of 1,637 done - the rules, the settings and the search.
Continues the pass through ta.i18n.json's Telugu- and Hindi-script values:
the whole rules engine with its triggers and actions, the Admin Panel's
authentication and custom-HTML settings, the due-date reminders, the
notification centre, My Cards and Due Cards, and all of global search.

The search operators are Tamil words, as they are now in Hindi and
Georgian, because they are what a user types: `பலகை` for board, `பட்டியல்`
for list, `கெடு` for due - and the instruction text names those same words
through its __operator_*__ placeholders, so the two can never drift apart.

Thanks to xet7 !
2026-08-15 18:07:28 +03:00
Lauri Ojansivu f0eff59b3c Tamil: the first 560 of 1,637 foreign-script strings are Tamil now.
ta.i18n.json was seeded from Telugu and Hindi, so 1,637 of its values are
written in a script a Tamil reader does not read. The first 560 are
translated here: the whole activity feed, the workspace and board menus,
the archive, the card details with its voting and planning poker, the
custom fields, the account emails, the export and import dialogues, and
every filter.

The terms are fixed once and used throughout, which is what makes the rest
mechanical: பலகை for board, அட்டை for card, பட்டியல் for list,
நீச்சல்பாதை for swimlane, சரிபார்ப்புப் பட்டியல் for checklist,
காப்பகம் for archive, தனிப்பயன் புலம் for custom field.

Translated by looking the words up, not by a native speaker; a Tamil
speaker reviewing it is welcome, and their review on Transifex replaces
these strings for good.

Thanks to xet7 !
2026-08-15 18:00:48 +03:00
Lauri Ojansivu 9ca57a8166 Hindi: the last 605 Gujarati strings are Hindi now.
hi.i18n.json was seeded from Gujarati, so 1,204 of its values were written
in another language entirely - readable to nobody who came to WeKan for
Hindi. The remaining 605 are translated here: the search operators and
their predicates, the whole globalSearch instruction text, the reports and
wait spinners, the attachment storage and S3 settings, every board and
database migration with its confirmation dialogue, the brute-force lockout
settings, and the login and repository pages.

The search operators are the words a user TYPES, so they are native words
(operator-board is बोर्ड) and the instruction text names those same words
through its __operator_*__ placeholders. Three values stay in Latin on
purpose: ldap, gridfs-storage and excel-font are a protocol, a product and
a font family Excel is handed by name, and the Gujarati transliterations of
them were wrong in a way that no reader could act on.

hi is now at zero wrong-script values. It was translated by reading the
words up rather than by a native speaker, so a Hindi speaker reviewing it
is welcome - and when that review arrives on Transifex it replaces these
strings, which the pull merge guarantees is never reversed.

Thanks to xet7 !
2026-08-15 17:52:30 +03:00
Lauri Ojansivu fcf6a80fc4 Hindi: 604 of 1,204 done, exactly half left.
Two more chunks: the Rules engine's actions and date fields, the custom head
tags and web manifest settings, LDAP and authentication, the due-date reminder
activity lines, the weekdays, assignees, organisations and teams, and the
notification list.

Written from the English source and the file's own Devanagari vocabulary; a
Hindi speaker's review is still wanted.

Thanks to xet7 !
2026-08-15 17:45:14 +03:00
Lauri Ojansivu 50e782fdd6 Hindi: 361 of 1,204 done, 843 left.
Another chunk of the Gujarati values replaced with Devanagari: the login and
account strings, multi-selection, the archive views, the shortcut list, time
tracking, the custom logo and login-page settings, SMTP and invitations,
webhooks and the version panel.

Same caveat as the last chunk: written from the English source and the file's
own correct Devanagari vocabulary, and worth a Hindi speaker's review.

Thanks to xet7 !
2026-08-15 17:41:11 +03:00
Lauri Ojansivu b36995617f Hindi: 236 of the 1,204 Gujarati values are Hindi now.
The third wrong-script file. hi.i18n.json holds 1,204 values in GUJARATI -
`board` was બોર્ડ, `save` was સાચવો - beside the Devanagari ones that were
always right. Two closely related scripts, which is probably how it went
unnoticed: it reads as Hindi at a glance and is not.

236 done: the activity feed, workspaces and All Boards settings, the archive
confirmations, Planning Poker, the colour names, the permission descriptions,
the email templates, card export, the filters and the import instructions.

Written from the English source and the file's own correct Devanagari
vocabulary. Hindi is not a language I can check as closely as the two before it,
so these are worth a native speaker's eye - the merge rules mean a human
translation arriving on Transifex replaces them and is never overwritten back.

968 left in this file.

Thanks to xet7 !
2026-08-15 17:40:02 +03:00
Lauri Ojansivu 3673177dab Georgian is Georgian again: 789 values that were Russian.
The second of the four wrong-script files is finished. ka.i18n.json held 789
values in RUSSIAN - `board` was Доска, the whole activity feed was Russian, the
Gantt view was Диаграмма Ганта - beside the Georgian ones that were always
right. Mixed, so no whole-file revert could have told them apart, and nothing
counted them: they are not equal to the English source, so `--missing` never saw
them and the merge rules would have protected them forever.

All 789 are Georgian now. The activity feed, the archive and template strings,
board backgrounds and All Boards settings, voting and Planning Poker, the colour
names, the filters, the whole Rules engine with its triggers and actions, SMTP
and webhooks, the custom logo and layout settings, card and minicard settings,
the due-date reminders, the weekdays, the search operators and predicates with
their instruction text, the reports, attachments and storage, the ticket and
request strings, and the custom-translation dialogs.

The search `operator-*` and `predicate-*` keys are the words a user TYPES into a
query, so they are translated as words rather than left in English: `დაფა` for
board, `ვადა` for due, and single Georgian letters for the abbreviations. That
is what those keys are for - a Georgian reader searching in Georgian - and the
instruction text below them now names the same words.

The last four were product names carrying Russian: Диаграмма Ганта and Покер
планирования are Georgian now too, as განტის დიაგრამა and დაგეგმვის პოკერი.

`--count ka` is 0. Remaining across the other two files: hi 1,204, ta 1,637.

Thanks to xet7 !
2026-08-15 17:37:01 +03:00
Lauri Ojansivu 88c23203a9 Georgian: 366 of 789 done, 300 left.
Two more chunks of the Russian values in ka.i18n.json replaced with Georgian:
the whole Rules engine (triggers, actions, the date fields, the email action),
the SMTP and webhook settings, the custom logo and layout settings, the card and
minicard settings, the due-date reminders and the activity feed's label and
custom-field lines, the organisation and team dialogs.

Thanks to xet7 !
2026-08-15 17:29:43 +03:00
Lauri Ojansivu 6d69bf8937 Georgian: 244 of the 789 Russian values are Georgian now.
Continuing the wrong-script work. ka.i18n.json holds 789 values in RUSSIAN -
`board` was Доска, every activity line was Russian - beside the Georgian ones
that were always right. Mixed, so no whole-file revert could tell them apart.

244 of them are Georgian now: the whole activity feed, the archive and template
strings, the board background and All Boards settings, voting and Planning
Poker, the colour names, the filters, the import instructions and the keyboard
shortcuts. They use the words the file already used for board, list, card and
swimlane, so the new lines read with the old ones.

545 left in this file, and the tool counts them exactly: `node
releases/translations/wrong-script.mjs --count ka`.

Thanks to xet7 !
2026-08-15 16:35:24 +03:00
Lauri Ojansivu f225b3245d Korean is Korean again: 354 values that were Japanese.
The scan for the TODO Later backlog turned up something worse than any missing
string, and this is the first of the four files it found. `ko.i18n.json` held
354 values in Japanese kana - `board` was ボード, every activity line was
Japanese - beside 2,000 that were correct Korean. Mixed, so no whole-file revert
could have told them apart.

That is worse than English. An untranslated string shows English, the reader
sees it is unfinished, a speaker fixes it, and `--missing` counts it. A value in
the wrong language shows confident text the reader may not be able to read,
nothing counts it, and the merge rules protect it forever because it is not
equal to the English source. It would have survived every pull and every fill.

All 354 are Korean now, written from the English source and the file's own
correct vocabulary so the new lines use the same words for board, list, card and
swimlane that the rest of the file already used. `--count ko` is 0.

releases/translations/wrong-script.mjs is the tool: --count says how many values
each language carries in a script that is not its own, --list dumps those keys
with their English source, and --apply writes translations back. It overwrites,
which fill-translations.mjs deliberately never does - filling only ever touches
English placeholders - and that is exactly why it is a separate tool: replacing
a wrong-language value is the one case where overwriting a non-English value is
right, and --apply refuses any key the scan did not flag.

The shared Indic danda is not counted as evidence of Devanagari, and Korean
hanja is not counted as evidence of Chinese, so neither shows up as a false
positive.

Still to do, recorded in TODO Later: ta 1,637, hi 1,204, ka 789.

The README said WeKan is translated to 154 languages. It has been 234 for a
while - 142 of them essentially complete - and the number is checked against the
data files now rather than remembered, so it cannot drift again.

Thanks to xet7 !
2026-08-15 16:25:51 +03:00
Lauri Ojansivu 394bd12b74 CHANGELOG: TODO Later says what the translation backlog really is.
The near-complete half was recorded as 9,072 strings. Half of that is values
that are the same in every language - product names, bare numbers, symbols,
unit abbreviations - which count as untranslated only because they equal the
English source and will never stop counting. The real prose backlog is about
3,400 values over 251 keys, median 23 per language, and the entry says so.

It also records what the scan for this turned up, which is worse than anything
missing: four files carry text in ANOTHER language. Tamil holds 1,174 Telugu
values and 463 Devanagari ones, Hindi holds 1,201 Gujarati ones, Georgian 789
Russian ones and Korean 354 Japanese ones - each file mixed, with some keys
right and some not. A missing string shows English and invites a correction;
these show confident text the reader may not be able to read, and the merge
rules protect them forever because they are not equal to English.

Thanks to xet7 !
2026-08-15 16:12:42 +03:00
Lauri Ojansivu a1477d0c93 Five strings the Admin Panel shows, in 126 languages.
Continuing the TODO Later translation backlog, starting where one decision buys
the most: the keys missing from the MOST languages. Five of them, 252 values,
across the 126 near-complete languages that lacked them:

  event-ip             IP address
  event-attempts       Attempts
  databaseReportTitle  Database problems
  color-magenta        magenta
  normal               Normal

The first three are the Admin Panel / Problems page - the page the security
events from this release are listed on - so they were English in every language
that had not translated them, on a page an operator reads when something is
wrong. They are complete now: 0 of 142 near-complete languages still lack them.

`color-magenta` and `normal` are filled where the language genuinely differs and
left alone where it does not: magenta is the same word in most Latin-script
languages, so 57 files keep it and are not "missing" it in any meaningful sense.
Same for `normal` in 37.

Written per base language and expanded to that language's variants, so the eight
Spanish files, the four German ones and the five Chinese ones agree with each
other by construction rather than by luck.

The backlog for the near-complete half goes from 9,066 to 8,808 - but the number
that matters is smaller than either. Of those 9,066, about 4,700 are values that
are the same in every language: product names (Meteor, MongoDB, S3/MinIO,
OAuth2), bare numbers (the Planning Poker values), symbols (`/`, `#`, `@`), unit
abbreviations and one empty string. They count as "untranslated" only because
the value equals the English source, which for a proper noun is the correct
translation. The real prose backlog is about 3,400 values over 251 keys, median
23 per language.

Thanks to xet7 !
2026-08-15 16:12:10 +03:00
Lauri Ojansivu 9ee8deae3a Conformance cases for $meta, and a test run that stops the databases it started.
Two things.

$META HAS CONFORMANCE CASES NOW. The catalogue gained $slice and $elemMatch when
they were implemented; $meta went in afterwards and had none, so the one part of
the projection work that a live database could check was the part nothing
checked. Two cases, compared differently on purpose:

  projection/$meta recordId   COMPARE.SHAPE
  projection/$meta textScore  full value comparison

recordId is the STORAGE identity of a document, so its value is each backend's
own and comparing values would report a difference that is not one. What has to
agree is that every backend ANSWERS, and answers with the same shape - a number
on every row - which is what SHAPE checks. Before the fix they all errored, so
that is exactly the regression worth guarding.

textScore is computed by the handler ABOVE the backends, from the document's own
strings, so every backend must produce the same number and the values are
compared in full. It is also the only case that exercises $text: only `alpha`
(_id 1) carries the term, and carries it once. Sorted by _id so the comparison
does not depend on the order a backend returns rows in.

A TEST RUN STOPS ITS DATABASES ON EVERY WAY OUT. build.sh stopped the test server
and the mongod at the END of the run - reached when the run finishes, and not
reached when it is interrupted or when one of the early failures returns. Then
the mongod on :3001 and the bundle server on :3000 outlive the run.

That is not untidy so much as expensive, and the cost lands on the NEXT run. The
harness reuses a database only when it ANSWERS - a port held by something else
moves it aside - so a leftover mongod IS reused, holding a database this run
never seeded, and the failures land somewhere else entirely.

So the stopping is a shell function called from a trap on EXIT INT TERM, and the
normal path calls the same function. It is idempotent - each half clears its PID -
so the trap firing afterwards stops nothing twice, and a database this run did
NOT start is still left alone, because its PID was never recorded. Conformance
containers go with it: that stage names each engine's container
wekan-conformance-db-<timestamp> and removes it when the engine is done, but an
interrupted run leaves the one it was on, and a container still holding 5432 or
3306 fails the next run's engine before it starts.

cmd has no trap, so build.bat does the same clearing on the way IN as well as on
the way out: one :stop_test_databases label, called at the start and the end of
both all-tests flows. Starting from a clean slate is the batch answer to a trap
it cannot have. The two copies of the stopping that were already there are now
that one label, so they cannot drift.

tests/buildScriptParity.test.cjs pins both: the trap and the idempotence in
build.sh, the four calls in build.bat, and that both stop the same two servers
and the conformance containers.

Thanks to xet7 !
2026-08-15 14:07:05 +03:00
Lauri Ojansivu 0201c1fb89 Custom Fields form: the checkboxes are the animated one, beside their labels.
The markup was already the Admin Panel / Announcement checkbox - `a.flex >
.materialCheckBox + span`, a grey square that morphs into a green tick over
0.2s. What it lacked is where it is drawn.

The Custom Fields create and edit form is a POP-OVER, and a pop-over renders in
the global popup container, not inside `.sidebar`. So these rows never got the
`align-items: center` that #6465 added for exactly this - `.flex` on its own is
only `display: flex`, with no alignment and no gap, so the box sits on one line
and its label on another. The morph was there the whole time; it played on a box
that was not next to the words it belongs to, which beside the announcement's
neat row is what "not the animated one" looks like.

The rows are aligned now, and both halves of the box are spelled out for this
popup: the 13px square with its 0.2s transition, and the green tick it becomes.
The checked half already existed for the board Card Settings and Subtask
Settings popups, which are pop-overs for the same reason; the create/edit form
is added to that list, and the unchecked half is written out beside it - without
it the transition would run between a box some other rule happened to size and
the tick, which is the animation looking wrong rather than missing.

tests/customFieldsSectionMenu.test.cjs pins that the form's six checkboxes are
`.materialCheckBox` and not a native one the browser would draw, that both popup
names carry the alignment and both box states, and that the transition is there
- it is the transition that makes it animate rather than jump.

Thanks to xet7 !
2026-08-15 11:23:21 +03:00
Lauri Ojansivu 2b30439ced push-all-translations pushed 146 of 245 languages, and said nothing about it.
`tx push -t` uploads translations for the languages the PROJECT has as targets.
A language that exists only here - every language added to imports/i18n/data/
since the project was last reconciled - is not a target there, so the CLI skips
its file without a word. WeKan ships 245 translated languages; the project had
146, so ninety-nine were pushed nowhere and the run looked like it had worked.

Pushing harder cannot fix that: the language has to exist on the project first.
So the script reconciles before it pushes.

sync-transifex-languages.mjs enumerates the language files, resolves each to the
code Transifex knows it by (reversing .tx/config's lang_map, so `ar-DZ.i18n.json`
is asked for as `ar_DZ`), reads the project's current target languages over the
API - following pagination, since a project with hundreds of languages does not
answer in one page - and adds the missing ones. English is the source, not a
target, so it is left out: 245, not 246.

They are added ONE AT A TIME on purpose. Transifex refuses a language code it
does not know, and in a batch that one refusal takes the whole batch with it. A
code WeKan invented should cost only itself and should be NAMED, so it can be
mapped in lang_map the way `vec: ve-CC` and `vls: vl-SS` already are.

The count is printed at both ends of the run, so "146 of 245" would be visible
rather than something noticed months later when a language is still English on
Transifex. --dry-run says what it would add and changes nothing; --list prints
the codes and needs neither a token nor the network, so the enumeration can be
checked offline. The token comes from TX_TOKEN or ~/.transifexrc, the file the tx
CLI itself writes; without one the reconcile fails loudly and the push stops,
because pushing anyway is exactly the silent partial push this fixes. --skip-sync
is there for a push known to add no language.

Checked here: the enumeration returns 245 with lang_map reversed correctly, and
the API paths were exercised against a stub server - pagination followed across
two pages, --dry-run posting nothing, and the live run adding each missing
language and skipping the two the project already had.

One thing the enumeration turned up: lang_map still carries `es_TX: es-TX`, for
which there is no file. It is left alone here - it is a config question, not a
push bug - but it is why the map has 63 entries and only 62 are used.

Thanks to xet7 !
2026-08-15 11:16:16 +03:00
Lauri Ojansivu 763a3827d1 Every attempt at the five REST API holes is shown in Admin Panel / Problems.
A guard that refuses in silence tells the operator nothing. Four of the five
findings have an ATTEMPT that can be told apart from ordinary use, so each
refusal is now recorded through server/lib/securityLog into the `security`
stream - which is what Admin Panel / Problems counts and lists - with a catalog
entry naming it after its Hall of Fame page:

  authz.card-delete  PurgeBleed   critical  CWE-639
  authz.card-member  GuestBleed   high      CWE-639
  authz.board-list   StaleBleed   medium    CWE-863
  spoofing.author    AuthorBleed  medium    CWE-345

What counts as an attempt is the part worth being careful about, because a log
that fires on ordinary use is a log nobody reads:

- PurgeBleed logs when the card id names a card that exists but is NOT on the
  authorised board. A card id that names nothing is an ordinary 404 and writes
  nothing. Telling those apart needs the bare lookup back, so it is there once
  more - after the constrained one has already failed, named `elsewhere` for
  what it is, and never handed to cardRemover.
- GuestBleed logs the ids dropped from a members/assignees array. An array whose
  ids are all fine writes nothing.
- StaleBleed logs when the caller HAS revoked memberships that the listing
  withheld - counted, not named, because the point is that somebody removed is
  still asking and the titles are the thing being withheld. A caller with no
  revoked membership writes nothing.
- AuthorBleed logs when the body still carries an authorId naming somebody
  other than the session. Naming yourself is what an old client does and is not
  an attempt.

HashBleed deliberately has NO key, and the catalog says why: every call to
GET/PUT /api/users/{userId} is a legitimate admin call. The fault was in what
the answer carried, not in who asked, so a log line there would fire on ordinary
use and say nothing about an attacker.

`req.body.authorId` is still READ - that is how the spoof is noticed - but never
used as an identity, and the guard pins that: not stored as userId, not passed
to cardCreation, cardRemover or customFieldCreation, and read exactly once in
cards.js, inside noteAuthorSpoof.

tests/restApiIdorBatch.test.cjs is at 14 checks, five of them negative: that
ordinary use writes nothing on all four, that HashBleed has no key and neither
endpoint logs, and that every securityLog call sits in a try/catch - a log that
throws would turn a refusal into a 500, which makes the logging its own denial
of service.

Thanks to ybsun0215 and xet7 !
2026-08-15 11:16:16 +03:00
Lauri Ojansivu 28086fe7d7 CHANGELOG: the five CRITICAL REST API fixes go first under Upcoming.
A CRITICAL subsection comes before everything else, so it keeps the "This
release " prefix and the container-scanning section below it becomes "and fixes
the following SECURITY ISSUES found by container scanning:".

One group, **The REST API** - what an endpoint authorises, and what it then acts
on - because that is the one sentence all five share. Each entry links its Hall
of Fame page and says what the endpoint authorised, what it then reached, and
what the fix ties together.

The **In short:** paragraph leads with them now, and names the worst: any user
with write access to one board could destroy the comments, checklists and
history of every card on every board in the instance.

The guard names each of the five in its header, which is what
fixedVulnerabilityClasses.test.cjs compares the Hall of Fame against - a
published vulnerability with no test that names it is a fix the next refactor
can undo quietly.

Thanks to ybsun0215 and xet7 !
2026-08-15 10:57:19 +03:00
Lauri Ojansivu 9cc4139699 PurgeBleed, HashBleed, GuestBleed, StaleBleed, AuthorBleed: five REST API fixes.
Five findings reported by ybsun0215 against v10.91, all the same family - an
endpoint authorises ONE id and then acts on, or answers with, something the
caller was never authorised for. All need WITH_API=true.

PurgeBleed (GHSA-8cqr-x6m5-v4w6) is the severe one. The single-card DELETE
authorises the caller on the board in the URL and then fetched the card by its
id ALONE. cardRemover runs BEFORE the removal, so it erased the checklists,
checklist items, comments, activity history and subcard tree of whatever card
that bare lookup returned - any card in the instance, on boards the caller
cannot read, irreversibly. The removal after it uses a triple key that a foreign
card never matches, so the card shell survived and the endpoint answered 200:
nothing said the contents of somebody's card had just been destroyed. Any user
with write access to one board - their own suffices - could reach every board.
The bulk endpoint had always constrained its lookup with {_id, boardId}; this
one is the sibling that was missed, the same shape as PassBleed. It uses the
constrained lookup now.

HashBleed (GHSA-6qpx-x7vr-p9w6): GET and PUT /api/users/{userId} serialised the
whole Meteor user document, so every answer carried services.password.bcrypt -
an offline-crackable password hash - and services.resume.loginTokens, the hash
of every live session with the time it began. The authorisation was never the
problem; the payload was. Walking the ids from GET /api/users harvested the
credential material of the instance. The sibling endpoints in the same file show
what was meant: the list projects to _id and username, and the self view deletes
services. One helper strips services and sessionData now and both endpoints
answer through it.

GuestBleed (GHSA-whxm-pxgj-7wqv): the members and assignees ARRAYS on card
create and update were stored as given, and GET /api/user/cards answered by card
membership without re-checking board access. Together: a member of a private
board could put an outsider on a card, and that outsider's own feed then
returned the private board's card titles, board/list/swimlane ids, dates and
co-members - continuously, while their direct read of the board stayed
Forbidden. The rule already existed on the merge endpoint since #5998; it covers
the array shapes now, on single create, bulk create and update, and the listing
is filtered by board visibility as well so a card placed before this release
stops answering too.

StaleBleed (GHSA-r8r3-23vr-8jh6): the board listing matched 'members.userId'
with a dotted path, which ignores isActive - and removing a member does not
delete their entry, it sets isActive: false and keeps it. A removed member's own
listing went on showing the board's id and title. Reading the board was already
refused, so this is the id and the title, but the id is what the rest of the API
is addressed by. Same class as GHSA-gwc4-fw7p-gw58, whose fix wrote "a share
entry counts only while it is active, everywhere"; this endpoint predates that
consolidation. It uses $elemMatch now.

AuthorBleed (GHSA-6jr3-42jf-vhm5): six paths - single, bulk and linked card
create, single and bulk card delete, custom-field create - took the actor from
req.body.authorId, checking only that such a user exists. So a board member could
write "victim created this card" and "victim deleted this card" into a board's
history, and the card document recorded the forged userId as its creator. The
same class was fixed for comments in 8.19 and for the card PUT handler, whose
note still says "use req.userId consistently (it previously read
req.body.authorId here)". All six read req.userId now.

tests/restApiIdorBatch.test.cjs pins all five in the shape a regression would
change: the constrained lookups, the stripped subtree, the one assignable rule
across every shape, the $elemMatch, and each of the six paths by what it records.

Thanks to ybsun0215 and xet7 !
2026-08-15 10:53:13 +03:00
Lauri Ojansivu feda5fc72b CHANGELOG: the Export word and the Custom Fields popup go under Upcoming.
The missing `export` key goes under **New strings**, beside the other keys this
release added, because that is what it is: a key the release adds, and then
translates into all 245 other files. The Custom Fields popup - a field made from
a card that was never created, and the pencil that sat under the name instead of
beside it - goes under **Card details**, next to the section that was unreachable
on a card with no fields.

The **In short:** paragraph counts twelve bug fixes now instead of ten, and names
both.

Thanks to xet7 !
2026-08-15 10:21:22 +03:00
Lauri Ojansivu 67b91c8504 Custom Fields popup: a new field appears, and the pencil is beside the name.
Two reports against the popup the Custom Fields heading opens.

A new field made from it never appeared. "Add custom field" sits OUTSIDE the list
of fields, so its data context is the popup's own - which is the CARD - and the
shared create/edit form decided insert-vs-update on whether its context had an
`_id`. A card has one, so creating a field from a card ran the UPDATE branch
against a custom field whose id was a card's: no such document, nothing written,
nothing said. From the board sidebar the same form works, because there the
context has no `_id`, which is why this only ever happened from a card.

Both halves are fixed. The popup hands the create form an empty context, since a
new field is made from nothing. And the form no longer trusts a bare `_id`: it
asks whether that id NAMES a custom field, so a context arriving from anywhere
cannot make it update one that is not there.

The pencil was on a line of its own under the field's name. Every other pop-over
list has one anchor per row, so `li` is a block and the anchor fills it - and two
anchors in a block stack. This list has two by design, the name with its checkbox
and the pencil that edits the field, so the ROW is the flex container and the
name takes the space the pencil does not. Scoped to this popup, so no other list
moves.

tests/customFieldsSectionMenu.test.cjs gains both: that the create form is handed
an empty context and decides by looking the id up, and that a row is a checkbox,
a name and a pencil on one line.

Thanks to xet7 !
2026-08-15 06:24:06 +03:00
Lauri Ojansivu 9417e35d0b Export is a word now, in every language, not the key "export" in lowercase.
Every menu on a board grew an Export row and an Import row, and the Export one
was written `{{_ 'export'}}` - a key that has never existed in en.i18n.json, or
in any of the other 245 files. i18next answers a key it does not know with the
key, so the row read "export": lowercase, untranslated, in every language
including English. The report came from Finnish, where every other word in the
menu is Finnish and this one was an English-looking lowercase word.

`export` is a key now, "Export" in English, and it is translated into all 245
other files. The word is each language's own: for 225 of them it is the shared
part of that file's existing "Export list" and "Export swimlane", which is the
word they already use - "Vie" in Finnish, "Exportieren" in German,
"Экспортировать" in Russian.

The rest are written out, because a shared prefix is the wrong answer for them.
Where the derivation took an article with it - Portuguese "Exportar a", Irish
"Easpórtáil an", Welsh "Allforio'r" - the article is dropped. Where the verb is a
circumfix that only appears with its object between the halves, both halves are
written: Afrikaans "Voer uit", Icelandic "Flytja út", Akan "Yi adi", Tok Pisin
"Salim i go aut", Ewe "Ɖe ɖe go". Hungarian takes the nominative "Exportálás"
rather than the possessive "Exportálása". Klingon keeps its own capitalisation,
yIngeH with the lowercase y, which a capital-first rule got wrong. And the
twenty-one languages that do not put spaces between words - the Chinese variants,
Japanese, Khmer, Thai, Burmese, Tibetan and Dzongkha - had nothing to derive from
and are written from their own phrases: the two Chinese forms by script, and
エクスポート, នាំចេញ, ส่งออก, ထုတ်ယူရန်.

`import` already existed and was translated everywhere. In seven files it was
still the bare English noun while their Export was a verb, which read as a pair
that did not match: Czech gets "Importovat" beside "Exportovat", Turkmen "Import
et" beside "Eksport et", and the four Uzbek files "Import qilish" beside "Eksport
qilish". Malay and the English variants keep "Import", which is the word those
languages use.

239 files changed, one line each. Key order is unchanged - `export` sits where
en.i18n.json puts it, ahead of `export-board`, in every file - and
verify-human-preference.mjs passes.

Thanks to xet7 !
2026-08-15 06:24:06 +03:00
Lauri Ojansivu d49d055d09 CHANGELOG: the removed heading row goes under Upcoming.
Under Card and minicard menus, beside the rest of what moved into a thing's own
menu.

Thanks to xet7 !
2026-08-15 06:15:01 +03:00
Lauri Ojansivu 02025aa6c6 Show on Card and Show on Minicard: no heading row saying the title again.
The heading row named the columns of a ROW - "Show on Card" over the checkbox and
"Description" over the setting's name - which is what they labelled, but not what
they looked like. Since the rows flow into as many columns as the window fits,
the two headings sat above the FIRST of three or four columns and read as if they
named those: "Show on Card" over one column of settings and "Description" over
another, when both columns are the same kind of row.

The first heading also said what the popup's own title says. Show on Card opens a
popup titled "Show on Card" and then repeated it a line below; the same for Show
on Minicard.

Both are gone, and the two-pixel rule under them with them - one more line across
a popup that is a list of lines. A row is a checkbox and the name of a setting,
which needs no heading over it. One template serves both popups, so both lose it
together, and the sticky positioning, background and z-index that existed only to
keep those headings visible while the rows scrolled go too.

tests/showOnCardMenus.test.cjs pinned the heading row's full-width span; it now
pins the opposite, with the reason, and adds that the popup does not print its own
title twice.

Thanks to xet7 !
2026-08-15 06:14:30 +03:00
Lauri Ojansivu c878bdb8c0 CHANGELOG: three commits since v10.91 had no entry at all.
An audit of the Upcoming section against CLAUDE.md: every entry is shaped right,
every summary carries its Thanks, none ends in punctuation before the link, and
all 70 commit links resolve to a commit in this clone and to one made after the
v10.91 bump. One summary is 111 characters against the 110 the rules aim at,
which is close enough to leave.

What it did find is 21 commits since that bump with no link in Upcoming. Seventeen
are a sibling of an entry that is there - the three Export-popup commits under one
entry, the board-background pair, the two Custom Fields moves - which is how a
feature built over several commits is written up, and they stay as they are.

Three had no trace anywhere. **Move/Copy selection and Move/Copy swimlane** became
one dialog each instead of two, which is the same deduplication as the date
markup and belongs beside it - so the group line widens from markup to markup or
component. The two **test harness** fixes get a group of their own: reusing a
database because its port was open handed the server a socket that answered and
then closed, and a port owned by something else (an "Omi Server" answering HTTP
on 3001) now moves the test database rather than failing the run at its first
query.

Thanks to xet7 !
2026-08-15 05:53:08 +03:00
Lauri Ojansivu d74a9e5391 CHANGELOG: the Custom Fields fix and the two browser-test fixes go under Upcoming.
The Custom Fields regression is a bug fix, under **Card details** with the rest
of the opened card. The two Playwright commits are developer-facing, under a new
**Browser tests** group beside the shared templates - three page objects that
still described markup as it was before it moved, and the wait for the client
bundle that four Firefox-only failures needed.

The **In short:** paragraph says ten bug fixes now instead of nine, and names
Custom Fields as the one the browser tests found.

Thanks to xet7 !
2026-08-15 05:27:31 +03:00
Lauri Ojansivu aba09a0879 Custom Fields: the section is there for every card, so the only way in always is.
Playwright failed "custom fields panel opens from card details" in all three
browsers, and it was the app, not the guard. Everything about custom fields
moved to the hamburger at the end of the card's own Custom Fields heading - the
picker of which fields are on this card, a pencil each, and "Add custom field" -
and the card menu's entry, the Board Settings row and the wrapper popup went
with it. But the heading was drawn only `if customFieldsWD`, the card's own
custom-field VALUES.

So the one way in existed only once you were already through it. A card with no
custom field had no heading and therefore no hamburger; a board that had never
used them had no way anywhere, since the sidebar view that still holds the
create/edit forms is opened by nothing. The chicken-and-egg was invisible on any
board that already had a field, which is every board a developer tests on, and
was caught by a seeded board that had none.

The heading is now drawn for every card its reader may write to - `canModifyCard`,
the same condition the rest of the editable card uses. The FIELDS below still
come from `customFieldsWD`, so a card with none shows an empty section rather
than a phantom row per board definition, and a reader who may not write sees no
heading at all.

`a.js-custom-fields` went too. The move took away its label text and left the
tag: an empty anchor that rendered nothing, could not be clicked, and had two
`'click .js-custom-fields'` handlers still bound to it in cardDetails.js. All
three are gone, and `CardPage.openCustomFields` clicks the hamburger that is the
real entry point.

tests/customFieldsSectionMenu.test.cjs gains the two that would have caught
this: the heading is gated on who may write rather than on what the card already
has, and no empty anchor or orphaned handler is left behind. Its five existing
assertions - including that the three old places stay gone - still pass.

Thanks to xet7 !
2026-08-15 05:23:35 +03:00
Lauri Ojansivu 7a08f08124 Playwright: wait for the client bundle before calling a Meteor method.
Four Firefox-only failures in the 03:34 run, all the same cause: `Error:
page.evaluate: Meteor is not defined`, and in the fourth the count came back as
the string "error: can't access property \"callAsync\", window.Meteor is
undefined". Chromium and WebKit passed all four.

`page.goto(..., { waitUntil: 'networkidle' })` says the NETWORK went quiet, not
that the client bundle has finished executing. Under the three-browser parallel
run against one shared server, Firefox reached the evaluate with `Meteor` still
undefined - the bundle is large and had been fetched but not yet run.

`helpers/auth.js` has had `waitForMeteor` for exactly this since WebKit needed
it; these two specs simply never called it. `callMethod` in the All Boards spec
now awaits it before every call, which covers all three failures there, and the
admin Files report awaits it after its goto. It is idempotent and returns at
once when Meteor is already up, so it costs the passing browsers nothing.

Thanks to xet7 !
2026-08-15 05:19:42 +03:00
Lauri Ojansivu acaaa3c03f Playwright: three page objects still described the markup as it was before it moved.
The 03:34 run failed the same four tests in Chromium and in Firefox, and all
four are the guard describing the OLD markup rather than the app being wrong.
Each moved deliberately in this release, so the guard is what changes.

`editTitle` waited for `.js-card-title.js-open-inlined-form` - both classes on
ONE element. The title splits now: `span.card-details-title-edit-zone
.js-open-inlined-form` is the leading half that opens the editor, and the rest
of the `h2` drags the window, which is why `js-card-title-drag-handle` stays on
the whole heading. The class is a DESCENDANT of the title, not a second class
on it, so the old selector matches nothing and the click waits out its timeout.
That failure is `02-cards-open-view.e2e.js:104`.

`openCustomFields` opened the hamburger and clicked `.js-pop-over
.js-custom-fields`. Custom Fields is no longer a row of the actions popup - it
is on the Custom Fields SECTION of the card itself, `a.js-custom-fields
.card-details-item`, because the settings about a thing belong on the thing
that shows it. One click now, and no hamburger. That failure is
`03-cards-operations.e2e.js:246` and `09-my-cards-filter.e2e.js:122`.

The Activities heading in the board sidebar carries TWO icons since the caret
became shared with the card's eleven sections: the caret that says whether the
section is open, and the section's own comment icon. A bare `i.fa` matches both
and Playwright fails it as a strict-mode violation rather than picking one. The
spec now asks for the caret specifically - `fa-caret-down`, `fa-caret-right` or
`fa-caret-left`, the three directions `caretClassFor` can produce - which is
also a stronger assertion than "some icon is visible", since the caret is the
part that indicates state. That failure is `08-notifications-activity.e2e.js:22`.

Each selector was read against the markup that is in the tree now:
`cardDetails.jade` lines 76-79 and 456, and `sidebar.jade` lines 62-64.

Thanks to xet7 !
2026-08-15 04:24:00 +03:00
Lauri Ojansivu dcbb219b6b CHANGELOG: the twelve language entries move up to Upcoming, and what is left goes to TODO Later.
Twelve entries had been added under `and improves the translations:` inside the
ALREADY RELEASED v10.84 section rather than under Upcoming - Klingon, the six,
the eight, the nine, the ten, the fourteen, the fifteen, the eighteen, the
twenty, the sort letters, the 31 new strings and the second tier for the newest
languages. As released, v10.84 had exactly one entry there, the three
Version-pane and checklist strings, and it keeps it. The other twelve are the
work of the release being prepared now, so that is where a reader of the next
release notes has to find them.

With thirteen entries the subsection has to be grouped, per the rule a
subsection with more than one entry follows: **Deeper translation** for the two
that take languages past the words on the board, **New languages** for the ten
that add files WeKan had none of, and **New strings** for the 31 keys this
release added.

`TODO Later` gets the number a release wants to know. The 234 language files
hold 218,148 strings still equal to the English source, and they are two
different problems: 142 long-standing languages are nearly complete (9,072
between them, avg 64, worst Zeelandic at 114), and 92 recently-added ones carry
only the first two tiers (2,273 missing each, 209,076 in total). A release can
go out on either - an untranslated key renders its English source, never the
key - but the first half is what a language advertised as complete is judged
on, and it is about a day of work.

Thanks to xet7 !
2026-08-15 04:22:13 +03:00
Lauri Ojansivu 1fcf3bb5b9 CHANGELOG: the 81 deepened languages and the one-language test go under Upcoming.
Two entries, and the developer-facing subsection becomes grouped now that it
holds more than one: **Language loading** for the guard that pins that a browser
fetches one language file rather than all 246, and **Shared templates** for the
date markup that was already there.

Thanks to xet7 !
2026-08-15 04:13:40 +03:00
Lauri Ojansivu 72fd4aa127 Eighty-one languages go past the board, into the menus and the login page.
A language file started here with the words a board is made of - board, list,
card, swimlane, member, the buttons - about 50 to 77 strings. Eleven of the
largest new ones went further last time, to roughly 112, and the difference is
what somebody meets in the first minute rather than the first second: Log Out,
Forgot password, Email Address, Create Board, Move to Archive, Restore, Copy
Card, Move Card, Delete Card?, List Actions, Board Settings, Member Settings,
Search All Boards, Custom Fields, Add Attachment, Accounts, People,
Organizations, Teams - and, for the ones that had not reached it yet,
Activities, Attachments, Checklists, Assignee, Due Date, Register, Change
Password, Admin Panel, Profile, Watch and Export list.

Every other language that was still at that first tier now carries the second
one too: Maltese, Luxembourgish, Shona, Albanian, Bosnian, Hawaiian, Latin,
Luganda, Assamese, Irish, Icelandic, Javanese, Kurmanji, Kyrgyz, Malagasy,
Maori, Marathi, Chichewa, Oromo, Pashto, Kinyarwanda, Sindhi, Sinhala, Sesotho,
Tajik, Tigrinya, Setswana, Xitsonga, Tatar, Friulian, Ladin, Neapolitan,
Romansh, Aragonese, Corsican, Sardinian, Sicilian, Aromanian, Kashubian, Upper
Sorbian, Silesian, Faroese, Greenlandic, Northern Sami, Bislama, Tok Pisin,
Fijian, Samoan, Tongan, Haitian Creole, Papiamento, Inuktitut, Aymara, Quechua,
Nahuatl, Cherokee, Manx, Cornish, Scottish Gaelic, Guarani, Bashkir, Buryat,
Chuvash, Sakha, Tibetan, Dzongkha, Bhojpuri, Maithili, Konkani, Kashmiri,
Sorani, Akan, Bambara, Ewe, Fula, Northern Ndebele, Northern Sotho, Kirundi,
Swati, Tigre and Wolaytta.

Each string was written from that file's own existing vocabulary, so the new
menu rows use the same word for board, list and card that the board already
uses. The ones written with least confidence are Cherokee, Inuktitut, Nahuatl,
Wolaytta, Tigre and Kashmiri - a speaker who sees an error there should correct
it, and the merge rules guarantee a human translation arriving on Transifex
replaces a filled one and is never overwritten by it.

That is 4,082 strings across 81 files, all of them into placeholders that were
still English: verify-human-preference.mjs passes, and nothing here is pushed
to Transifex as if it were human. Every file is still a small fraction of
2,384 keys, deliberately - the rest falls back to English exactly as before.

Thanks to xet7 !
2026-08-15 04:10:32 +03:00
Lauri Ojansivu 5ab4b74310 Update newest changes from CLAUDE.md to AGENTS.md .
Thanks to xet7 !
2026-08-15 03:58:01 +03:00
Lauri Ojansivu 59c742e6c9 The browser downloads one language, and a test says so.
imports/i18n/data/ is 37 MB across 246 files, all of them reachable from the
client. The only thing keeping them out of the initial bundle is that every
entry in languages.js loads its file through `() => import('./data/<tag>.json')`
- a call Meteor's dynamic-import package code-splits into a module fetched on
demand - and that tap.js imports exactly one of them statically, English, so
the UI stays readable when dynamic import is broken (#6503).

That was true when checked and nothing pinned it. A single
`import data from './data/xx.i18n.json'` added anywhere on the client would
quietly ship that language to every visitor, and nothing about the app would
look wrong; it would just be a heavier download, which no other test measures.

The guard checks the six things that have to hold together: every registered
language has a dynamic loader, languages.js pulls in no data itself, tap.js
statically imports English and nothing else, the loader is called once for the
single resolved tag rather than mapped over the registry, no client file
bundles a language file, and dynamic-import is still in .meteor/packages. A
negative test proves the detector really sees a static import, so the other
checks cannot pass by failing to look.

Thanks to xet7 !
2026-08-15 03:50:20 +03:00
Lauri Ojansivu af2915cb54 CHANGELOG: the second tier for the newest languages goes under Upcoming. 2026-08-15 03:21:38 +03:00
Lauri Ojansivu cd13f56c68 The newest languages go past the words on the board, into the menus.
A new language file started with the words a board is made of - board, list,
card, swimlane, member, the buttons. Enough to recognise the app, not enough to
use it: the menus, the popup titles and the login page were still English.

Eleven of the largest new ones - Bengali, Urdu, Filipino, Hausa, Amharic,
Kannada, Malayalam, Nepali, Somali, Kazakh and Burmese - now carry the second
tier as well: Log Out, Forgot password, Email Address, Create Board, Move to
Archive, Restore, Copy Card, Move Card, Delete Card?, List Actions, Board
Settings, Member Settings, Search All Boards, Custom Fields, Add Attachment,
Accounts, People, Organizations, Teams - the strings somebody meets in the first
minute rather than the first second.

They are at about 112 strings each now, from 77. Still a small fraction of
2,384, and deliberately so: these are the ones where being in English is most
visible, and the rest falls back to English exactly as before.

Thanks to xet7 !
2026-08-15 03:21:38 +03:00
Lauri Ojansivu 52ad6817c4 CHANGELOG: the twenty further languages go under Upcoming. 2026-08-15 03:07:36 +03:00
Lauri Ojansivu a7332f2336 Twenty more languages, including the ones I was least sure of - and which those are.
246 languages, from 154 when this release started.

Confident enough to write without hedging: Tibetan, Dzongkha, Kalaallisut,
Inuktitut, Cherokee, Nahuatl, Buryat, Kashubian, Silesian, Aragonese, Ladin,
Aromanian, Maithili, Bhojpuri, Konkani and Sorani Kurdish (right-to-left).

Written with less to go on, and recorded here rather than quietly: Kashmiri
(right-to-left), Pulaar, Tigre and Wolaytta. For these four the sources are thin
- a wordlist and a grammar sketch rather than a dictionary with a UI vocabulary
in it - so the terms for board, swimlane and checklist are built from the
ordinary words for a writing-board, a path and a list of things to check. They
are the strings most likely to be wrong, and the most likely to be corrected by
the first speaker who opens the language picker and finds their language in it
at all.

That is the whole argument for doing them: the alternative was not a better
translation, it was no language. Cherokee has around 2,000 fluent speakers;
Tigre, Wolaytta and Pulaar have millions between them and almost no software. A
wrong word invites a correction. An English placeholder invites nothing.

The RTL guard grew by two, Sorani Kurdish and Kashmiri, so neither can lay
itself out backwards in silence.

Thanks to xet7 !
2026-08-15 03:07:36 +03:00
Lauri Ojansivu 2859499e9f CHANGELOG: the fourteen further languages go under Upcoming. 2026-08-15 02:34:30 +03:00
Lauri Ojansivu 0cca649b7d Fourteen more: the Pacific, the Caribbean, and the languages of Italy.
226 languages, from 154 at the start of this release.

Kreyol ayisyen, Papiamentu, Tok Pisin, Bislama, Gagana Samoa, Lea faka-Tonga, Na
Vosa Vakaviti, Corsu, Sardu, Sicilianu, Napulitano, Furlan, Rumantsch and
Hornjoserbsce.

Four are creoles - Haitian, Papiamentu, Tok Pisin and Bislama - which between
them are the everyday language of about fifteen million people and are almost
never what software is written in. Five are languages of Italy that Italian does
not cover: Sardinian and Sicilian each have more than a million speakers.
Rumantsch is one of Switzerland's four national languages, and Hornjoserbsce is
one of Germany's.

Each is the same shape as the rest: the words a board is made of, the buttons,
the menus, the dates - and English underneath until somebody corrects it.

Thanks to xet7 !
2026-08-15 02:34:30 +03:00
Lauri Ojansivu 38c4cde1b8 CHANGELOG: the eighteen further languages go under Upcoming. 2026-08-15 02:22:31 +03:00
Lauri Ojansivu a3ee0262d6 Eighteen more languages: the Americas, the Sahel, Siberia and the Celtic ones.
212 languages. WeKan had 154 when this release started.

This block: Bamanankan, Akan, Evegbe, Ikirundi, isiNdebele, Sesotho sa Leboa,
siSwati, Bashkir, Chuvash, Sakha, Runasimi (Quechua), Avane'e (Guarani), Aymar
aru, Foroyskt, Davvisamegiella, Gaidhlig, Kernewek and Gaelg.

Three are indigenous languages of the Americas with millions of speakers between
them and no project-management tool in any of them. Three are Siberian and Volga
languages of the Russian Federation. Four are Celtic - and Cornish and Manx are
revived languages, which is exactly the case where the wording being imperfect
matters least and the language existing at all matters most.

Scottish Gaelic and Cornish take the Scottish and English regional flags rather
than the Union Jack, and Manx takes the Isle of Man's own: a language gets the
flag of where it is spoken, and for these three that is not the same thing as
the flag of the state.

Thanks to xet7 !
2026-08-15 02:22:31 +03:00
Lauri Ojansivu 456dfb93e4 CHANGELOG: the fifteen further languages go under Upcoming. 2026-08-15 02:12:29 +03:00
Lauri Ojansivu 2395dfd084 Fifteen more languages, most of them African.
194 languages. This block is the one WeKan was furthest from having: Tatarcha,
Hawaiian, chiShona, Ikinyarwanda, Chichewa, Sesotho, Setswana, Xitsonga,
Luganda, Afaan Oromoo, Tigrinya, Kurdi, Sindhi, Assamese and Malagasy.

Nine of the fifteen are African languages with tens of millions of speakers each
and no kanban tool in them at all. Sindhi is rtl: true; Kurdish takes the Iraqi
flag, because Kurdistan has no emoji and that is where most of its speakers are
- the same rule as any language spoken across a border.

The three edits are one script now, releases/translations/add-language.mjs: it
writes the strings file as a full copy of the English one with the translations
swapped in, inserts the registry entry in alphabetical order with the name in
the language's own script, and adds the flag only if the map does not already
anticipate it. That is why this block took three commands rather than thirty,
and why nothing was left half-wired.

Thanks to xet7 !
2026-08-15 02:09:44 +03:00
Lauri Ojansivu 584f7d6d86 CHANGELOG: the ten further languages go under Upcoming. 2026-08-15 01:58:15 +03:00
Lauri Ojansivu 0d3bb3ee75 Ten more languages, and Latin among them.
179 languages. This block adds Islenska, Gaeilge, Shqip, Bosanski, Malti,
Letzebuergesch, Kyrgyzcha, Tojiki, Maori and Latina - the same three edits each,
the name written in its own language, and a flag.

Two of them say something about the policy. Maori and Latin have nobody waiting
to translate a kanban board, and Latin has no country at all; it takes the
Vatican's flag because that is the state that still uses it, which is the same
reasoning that gives a language spoken across a border the flag of where it is
spoken. A constructed language keeps the globe instead - Esperanto, Klingon and
Volapuek are nobody's country, and borrowing one would be a claim rather than a
hint.

Latin is a real translation and not a joke: tabula, charta, index, semita,
titelli, sodales, and the imperatives a menu is written in - Adde, Dele, Serva,
Quaere.

Thanks to xet7 !
2026-08-15 01:58:15 +03:00
Lauri Ojansivu 8d36122f90 CHANGELOG: the nine further new languages go under Upcoming. 2026-08-15 01:47:58 +03:00
Lauri Ojansivu 244710e91e Nine more languages: Kannada, Malayalam, Burmese, Sinhala, Nepali, Javanese,
Somali, Pashto, Kazakh.

WeKan is at 169 languages. These nine were missing entirely, and between them
they are spoken by something like 250 million people.

Each arrives the same way as the last six: the strings file, the entry naming
the language in its own script, and the flag. Pashto is rtl: true, and the RTL
guard's list grew with it - that list is the one place the direction of a script
is written down, so a new right-to-left language that is not added to it fails
rather than laying itself out backwards in silence.

Each starts with the words a board is made of - board, list, card, swimlane,
label, member, the buttons, the menus, the dates - about eighty strings, and the
rest falls back to English until somebody who speaks it says otherwise. A
language that is 3% translated is a language somebody can start using and
correcting; 0% is not.

Thanks to xet7 !
2026-08-15 01:47:58 +03:00
Lauri Ojansivu 613ea56d9d Those two language files were symlink targets, not orphans.
The previous commit's guard reported km_KH.i18n.json and ru_RU.i18n.json as
translated files that no entry loads. They are not: km-KH.i18n.json and
ru-RU.i18n.json are SYMLINKS to them. Those two locales are the ones
.tx/config's lang_map does not rename, so Transifex writes the underscored name
and the hyphenated link is what languages.js loads - a working arrangement, and
the reason the pairs were byte-identical.

The guard resolves symlinks now, and checks the properties that actually matter:
every link points at a file that exists, and one end of each pair is loaded by
an entry. The CHANGELOG entry that repeated the wrong finding is corrected with
it.

Nothing was deleted and nothing in .tx/config changed.

Thanks to xet7 !
2026-08-15 01:34:57 +03:00
Lauri Ojansivu d57d2fb045 CHANGELOG: the six new languages go under Upcoming. 2026-08-15 01:25:10 +03:00
Lauri Ojansivu 1fedfb2625 Six languages WeKan did not have: Bengali, Urdu, Marathi, Filipino, Hausa, Amharic.
WeKan had 154 language files and not one of them was Bengali - about 270
million speakers - or Urdu, or Marathi, Filipino, Hausa or Amharic. The list of
languages WeKan supports was never a judgement about which languages matter; it
is the list somebody happened to start.

Each is three edits, and all three are needed or the language is invisible: the
strings file, the entry in languages.js naming the language in that language,
and the flag in the picker. Urdu is rtl: true, so the interface lays itself out
right-to-left for it.

A new file is a full copy of en.i18n.json with the translated values swapped in,
which is what every other language file here is: the key-order guard reads
absolute positions, so a file holding only the strings it has translated would
put every later key at the wrong index.

tests/newLanguageWiring.test.cjs holds the three edits together, and found two
things while being written: fourteen languages were showing a globe instead of a
flag (Acehnese, Moroccan Arabic, Asturian, Breton, Frisian, Norwegian Bokmal,
Occitan, Turkmen, Walloon, Wu, Yiddish, Tamazight and two Chinese variants),
which they no longer do; and two files nobody loads - km_KH.i18n.json and
ru_RU.i18n.json are translated, committed, and imported by no entry, because the
entries for those tags load the hyphenated files instead. They are pinned as a
known state, so a third one is a failure rather than a shrug.

Thanks to xet7 !
2026-08-15 01:25:09 +03:00
Lauri Ojansivu 3c6344154b CHANGELOG: the eight further languages and the sort letters go under Upcoming. 2026-08-15 01:10:53 +03:00
Lauri Ojansivu 240f8f2d4a Uyghur, Belarusian, Catalan, Estonian, Malay, Turkmen, Xhosa, Igbo, sort letters.
Another eight languages from their own vocabulary: the export and import menus,
the roles table, the board-status pane, the starred pages, the search operators,
and for Igbo the last of its report and recovery strings.

The list sort letters are translated too. A list header shows (N), (M) or (L)
for whether it is sorted by NAME, by your MANUAL order or by LAST access -
initials of the English words, so in any other language they were three letters
that stand for nothing. They are that language's own initials now: German
(N)/(R)/(Z) for Name, Reihenfolge, Zugriff; Finnish (N)/(J)/(K) for Nimi,
Jaerjestys, Kaeyttoe; Russian (И)/(П)/(Д) - twenty-five languages, and their
regional variants after them.

What is left is mostly not translatable. Of the strings still flagged in German,
French and Dutch, three quarters are a word of one or two syllables that those
languages spell exactly as English does - Filter, Team, Text, Details, Layout,
Person, Pause, Actions, Date, Description, Notifications, Type, Database, Help,
Repository. They are counted as untranslated because the only test available is
"is this still equal to the English source", and by that test a correct
translation that happens to be identical can never pass.

Thanks to xet7 !
2026-08-15 01:10:52 +03:00
Lauri Ojansivu 8674936753 CHANGELOG: the eight volunteer-less languages go under Upcoming. 2026-08-15 00:53:35 +03:00
Lauri Ojansivu da022efe62 Frisian, Igbo, Yoruba, Wolof, Breton, Walloon, Volapuek and Tamazight.
Eight languages nobody has volunteered for. Five of their files were not
untranslated at all - wo, wa, br, vo and zgh were written in French ("Tableau",
"Carte", "Liste", "Supprimer"), with Esperanto in the Volapuek file and Arabic
in the Tamazight one, seeded from whatever was to hand. As with Klingon, that is
not a translation to protect: the core vocabulary is replaced with each
language's own words - Breton Taolenn, Kartenn, Roll; Walloon Tavlea, Cate,
Djiveye; Wolof Tablo, Karta, Limu; Volapuek Bod, Kad, Lised; Tamazight
Tafelwit, Takarda, Tabdart.

On top of that each got the strings a board actually shows: the export and
import menus, the roles table, the board status pane, the starred pages, and
the sentence that explains what dragging a board onto Home does.

They are imperfect, and that is the point: a wrong string is readable, obviously
improvable and an invitation to the person who speaks the language, which an
English placeholder never is. A correction arriving through Transifex replaces
the filled one, because the merge always prefers a human translation.

What is left in the big files is mostly not translatable at all: German Filter,
Team, Text, Details, French Actions, Date, Description, Notifications and Dutch
Filter, Type are the words those languages use, and they are counted as
untranslated only because the tool's test for it is "still equal to English".

Thanks to xet7 !
2026-08-15 00:53:34 +03:00
Lauri Ojansivu ff514a5bb6 CHANGELOG: Klingon and the variant inheritance go under Upcoming. 2026-08-15 00:39:53 +03:00
Lauri Ojansivu 3b5f2b24cc Klingon from its own lexicon, and every regional variant inherits its language.
tlh.i18n.json was not untranslated - it was written in French and German:
"Tableau", "Karte", "Liste", "Löschen", "Board exportieren". The words are
looked up now, in the boQwI' lexicon (github.com/De7vID/klingon-assistant-data,
the Klingon Language Institute's own data): 'echlet is a board, 'echletHom a
card, tetlh a list, per a label, chuD a member, mI' a number, Dotlh a status,
yer a domain, mIllogh a picture. The actions are imperatives, which is what a
menu entry is in Klingon - yISuq (acquire it) for import, yIngeH (send it) for
export, yIQaw' (destroy it) for delete - so "Export list" is tetlh yIngeH and
"Import card" is 'echletHom yISuq.

A regional variant that had not translated a string carried the English source
while its base language had a translation two directories away: de-AT in
English beside German, es-MX beside Spanish, zh-Hant beside Traditional
Chinese. Each variant now inherits its base for the strings it has not
translated itself, and only for those.

Three files are in another language entirely and are completed in the language
they are actually written in rather than left half-English: vl-SS is Dutch,
ace is Malay, ve is Zulu.

CLAUDE.md now says to do this for every language: look the words up in any
dictionary on the Internet, complete a file in the language it is already
written in, and add the languages WeKan does not have yet - the strings file,
the entry in languages.js with the name written in that language, and the flag
in userHeader.js, because a language missing any of the three is invisible.

Thanks to xet7 !
2026-08-15 00:34:37 +03:00
Lauri Ojansivu f1d886c3b4 CLAUDE.md: translate every language, from any dictionary, imperfect beats absent.
WeKan cannot find a volunteer for every language, and a file left in English
stays in English for years. Klingon, Volapuek, Wolof, Venda, Tamazight and the
rest are no longer exceptions: look the words up in any dictionary, word list or
Wiktionary page on the Internet and write the translation from them, and where a
file is already written in another language, complete it in that language rather
than leaving half of it English.

A bad translation beats no translation: it is readable, it is obviously
improvable, and it invites the person who speaks the language to fix it, which
an English placeholder never does. The merge rules already guarantee that a
human translation arriving from Transifex replaces the filled one and is never
overwritten by it.

Thanks to xet7 !
2026-08-15 00:28:24 +03:00
Lauri Ojansivu 07cea1e4de CHANGELOG: the 31 newly translated strings go under Upcoming. 2026-08-15 00:17:03 +03:00
Lauri Ojansivu f12095efab Translate the 31 new strings into 127 languages.
Everything added to en.i18n.json since 10.91 - the new popup titles, the export
and import wording, the card-number search operator, the filesystem integrity
report - shipped as English placeholders in every other language file.
Translated directly, as CLAUDE.md requires: no external translation service,
API or key, and each language's own existing strings as the reference, so the
new wording matches what that file already says.

The four import titles are the four export titles with that language's own word
for importing, because in a menu that already says what it is about they are
the same sentence pointed the other way. operator-number is not a label but a
word somebody types into the search box beside operator-board and
operator-list, so it is that language's plain word for a number, in the form
those two are already in.

Nothing overwrote a human translation: the fill step writes only into keys that
are still the English source, and a translation already committed for a key was
kept. None of this is pushed to Transifex, so a filled string can never
masquerade there as a human one.

Fifteen languages are left as English placeholders rather than guessed at:
Klingon, Volapük, Acehnese, Breton, Igbo, Uyghur, Venda and its two variants,
Walloon and its variant, Wolof, Yoruba, Tamazight, and vl-SS. A wrong
translation reads as though somebody meant it; an English placeholder says
plainly that nobody has translated it yet.

Thanks to xet7 !
2026-08-15 00:17:03 +03:00
Lauri Ojansivu 8ff33a5d85 CHANGELOG: the Export and Import rows in every board menu go under Upcoming. 2026-08-14 18:51:51 +03:00
Lauri Ojansivu e948acfdd3 Every menu on a board has an Export row and an Import row.
The card, list, swimlane and board menus each had one row, and each named
itself differently: "Export card", "Export list / Import", "Export swimlane /
Import", "Export board". Two of them mentioned an import that was a section
inside the popup rather than a thing to go to, and the card and board menus did
not mention it at all - so importing into a list was discoverable and importing
into a card was not.

Each of the four now has Export and Import, in that order, named for what they
do and not for what they act on: the menu already says whether it is a card, a
list, a swimlane or the board.

Both rows open the same popup - the same panes, the same table of formats, the
same selection - with one difference: the second pane offers the formats to
write out, or the file to read in. The selection means the same thing in both
directions, which is why it is the pane they share.

Import writes, so it is offered only to somebody who may change the board. That
question is asked once, in one function registered as a helper the four menus
use, and asked again inside the popup, because a row that is merely hidden is
not a permission check.

Thanks to xet7 !
2026-08-14 18:51:51 +03:00
Lauri Ojansivu cd5571b080 CHANGELOG: the combined export popup goes under Upcoming. 2026-08-14 18:44:36 +03:00
Lauri Ojansivu df2913744e Every export popup is one popup with one scope, and looks like it.
There were two export menus. The board popup wrote nineteen formats of its own
under four subheadings, in two panes across the width of the window; the
swimlane, list, card and checklist popups wrote five in one narrow column with
no panes. So "the export popup" looked like two different features depending on
which menu opened it, and a format added to one was missing from the other.

They are one template now, drawing one table of formats. Each entry says what
it is - an icon, a label, and either a path to download or a class to click -
and which scopes it belongs to: BOARD_ONLY for the ones that only make sense
for a whole board (the HTML archive, the dependency graph, the CSV columns and
the exports written for Trello, Jira, Kanboard and the rest), and nothing at
all for the ones that work anywhere, because exportUrl already carries the
scope. The board popup is the same one-line include the other four are, with no
markup and no URL builders of its own.

All five get the board's panel: what to include in one pane and what to export
it to in the other, pinned to the viewport so the header and its X are always
reachable. The layout is written against the panes' own class rather than
against one popup's name, so it cannot be true of one menu and not another.

Which scope a popup is, is asked in one place - and every scope is named there,
because a scope left out would be read as a whole board and offered a board's
formats.

Thanks to xet7 !
2026-08-14 18:44:35 +03:00
Lauri Ojansivu 48c1c46ed3 CHANGELOG: the custom colour in all three pickers goes under Upcoming. 2026-08-14 18:28:57 +03:00
Lauri Ojansivu 024036f4ee Custom colour is offered in Member Settings and the Admin Panel too.
All three places render the same picker, so the Custom color wheel was already
written for all three - it was never reachable in two of them. It appeared only
once a named theme had been selected, and a board always has a colour whose
first entry is flat, so Board Settings always showed it; Member Settings /
Change Color and Admin Panel / Settings / Visibility open on "Default (no
override)" with nothing selected, so both looked as though they had no custom
colour at all.

The wheel is offered from that state too, as the flat category's single colour,
and choosing one applies it over the first flat theme. That fallback was
already in the code that saves; it is written into the picker's own selection
as well, because otherwise the wheel would store a theme the page did not show
as chosen and the next click would read the selection back as none.

Picking a clear theme first still gives the two wheels its colour slide needs.
One helper answers which category's custom colours the picker is offering, so
the wheel, the preview and what gets saved cannot disagree.

Thanks to xet7 !
2026-08-14 18:28:57 +03:00
Lauri Ojansivu c1e8586bfd CHANGELOG: the removed text background colour goes under Upcoming. 2026-08-14 18:20:32 +03:00
Lauri Ojansivu b77a1663d8 Remove Member Settings / Font / Text background color.
A colour painted behind the text needs elements to sit on, and neither choice
of them looks good. On the boxes - which is what it did, from <body> - it
painted the whole window: the board canvas and the empty space under the lists,
which is a page background and not a text background. On the elements that
actually carry text it striped every heading, menu row and paragraph with a
full-width band. A setting nobody can make look good is worse than no setting.

The wheel and its Unset button are gone from the popup, nothing reads
profile.uiTextBgColor, and no stylesheet rule paints it. A profile that already
has a colour is cleaned rather than left dormant: setUiColors unsets the field
on every call, whatever it is passed - and it still accepts the argument, so an
older client cannot fail against a newer server. The schema key stays declared
for exactly one reason: a modifier touching a key SimpleSchema does not know is
rejected, which would leave those profiles the only ones that could not be
cleaned.

The text colour beside it stays.

Thanks to xet7 !
2026-08-14 18:20:31 +03:00
Lauri Ojansivu b19586eb72 CHANGELOG: the two Font colour fixes go under Upcoming. 2026-08-14 18:12:42 +03:00
Lauri Ojansivu c56b556538 Text colour reaches all the text, and text background stops painting the page.
The two colour choices under Member Settings / Font had the opposite faults.

Text colour reached too little. Colour is inherited, and it was set on <body>
and the form controls, so it only ever reached text that had no colour of its
own - and WeKan gives most of its text one: the header bar's buttons, the left
menu's rows, a minicard's title, a list header. Choosing green recoloured the
page heading and the menu and left the rest as it was. It is set on every
element now. .fa is left out, because those are Font Awesome glyphs rather than
text - a red alert and a green tick mean something by being that colour - and
the icons meant to follow their label already say color: inherit, so they
follow this anyway.

Text background reached too much. It was on <body>, so choosing orange painted
the whole window orange - the board canvas, the empty space under the lists -
which is a page background and not a text background. It is a marker pen now:
it goes on the elements that carry text (span, a, p, a heading, a list item, a
table cell, a label, a viewer, and the form controls) and never on the boxes
that carry elements.

Thanks to xet7 !
2026-08-14 18:12:42 +03:00
Lauri Ojansivu acdde1e1f0 CHANGELOG: the font-size setting reaching all text goes under Upcoming. 2026-08-14 18:06:10 +03:00
Lauri Ojansivu d5b69137cc Font size setting moves all of the text, not the part written in rem.
Picking Largest grew the page heading and the minicards and left the header
bar, the left menu, the lists, the swimlane header and every popup exactly as
they were.

The preset was a percentage on the root element, and a root percentage only
reaches text whose size is written in a relative unit: rem is measured against
the root, em against its parent. WeKan writes most of its sizes in px, and px
is absolute, so exactly the rem-sized parts scaled. The base rule made it
worse: html, body, input, select, textarea, button re-stated font: 14px, so the
body took the stock size straight back off the scaled root.

The preset is published as a number as well (--wekan-ui-font-scale), and every
px font-size and line-height in the client stylesheets - 433 declarations
across 37 files - is written calc(14px * var(--wekan-ui-font-scale, 1)).
line-height scales with the size on purpose: 21px of type in an 18px line box
is the same setting half-applied. The base font rule is split into font-family
and font-size, because the shorthand's size was the one thing the setting had
to be able to move.

Nothing renders differently until a preset is chosen: the fallback is 1 and the
variable is removed for Default. tests/uiFonts.test.cjs fails on any bare px
font-size or line-height, so a new one cannot quietly opt out of the setting;
the two density guards unwrap the calc as they read the file, so each still
pins the size it means.

Thanks to xet7 !
2026-08-14 18:06:10 +03:00
Lauri Ojansivu ec62581af0 CHANGELOG: the board tile theming and the theme fixes go under Upcoming. 2026-08-14 17:52:48 +03:00
Lauri Ojansivu 05ddc83cb7 Board tiles carry the whole theme, and Clean Light is readable on it.
A board on the clearpink theme was a pink rectangle floating inside a grey
tile, while the flat-coloured board beside it filled its tile edge to edge.

Two lists decided what a tile is coloured with. boardColors.css painted
.board-list .board-color-<name> a - the link inside the tile, which sits inside
the tile's own 24px/18px padding and so can never reach its edges - and
boardsList.css carried a hand-copied list of seventeen flat background colours
on the tile itself, which is what made the flat themes look right. The five
colour slide themes added later were never copied into that list, so their
tiles kept the default grey; clearblue was in it, flattened to one hex, so it
did not show its slide either; and cleandark and cleanlight were in neither.
Each theme now paints .board-list li.board-color-<name> where it paints its
header bar and its Public Boards row, and the copy is gone.

A tile writes its title in #f6f6f6, which is invisible on a light one: Apple
Glass Pastel's tile is a pastel wash from #f6f7fb, so its name and description
were white on near-white. Both light themes write their title, description and
archive line in their own dark ink and darken the card-count pill and the
unstarred star with them, in one block named as the place a third light theme
goes.

In Clean Light's top bar, header.css paints every icon white by id. The buttons
inside a ul li escaped that through a more specific color: inherit; the house
and the notification bell are in no ul li and stayed white on #F1F1F3. Both are
dark now, and the bar's labels move from 0.5 to the same 0.85, so there is one
shade in it instead of three. The current entry, which used to be the dark one
among pale ones, is the full-black heavier one instead. An unread bell keeps
its white glyph on the red circle.

Thanks to xet7 !
2026-08-14 17:52:48 +03:00
Lauri Ojansivu b7c6855201 CHANGELOG: the import page's checkboxes, columns and buttons go under Upcoming. 2026-08-14 16:24:27 +03:00
Lauri Ojansivu 5d68d94bb7 Import page: the app's own checkboxes, two columns, and both buttons alike.
The pop-over that opens the page said "Import board (Trello, Jira, WeKan
export, CSV, Excel, ...)" - five of the fourteen sources, in a menu whose only
job is to reach the page that lists all of them. It says "Import board" now.

"Import from:" and "Select what to include:" drew a grey fa-check per row, the
same shape chosen or not. Both use .materialCheckBox, the animated green
checkbox Admin Panel / Announcement uses, and the rows sit in two columns when
the window has room for two 280px ones. The row declares no alignment of its
own: the tick is a rotated shape with negative offsets written for the plain
flow, and an align-items on top of it puts the tick on the first word of the
label. The gap to the label is spelled out for both states, since the checked
box is narrower and shifted left.

"Import without mapping members (map later)" is a primary button like the
Import beside it, instead of the one unstyled button on the page.

Thanks to xet7 !
2026-08-14 16:24:27 +03:00
Lauri Ojansivu 923f84f1d5 CHANGELOG: point the page-sidebar controls entry at its commit.
Thanks to xet7 !
2026-08-14 16:02:34 +03:00
Lauri Ojansivu 9ef05970d3 Page sidebar: its controls are rows, not three links run together.
Rules, My Cards, Due Cards and Global Search each keep their controls in the
shared page sidebar, and each draws them as `.board-header-btn` - which is what
they were when they lived in that page's own second header bar.

Every rule for that class is scoped to `#header-quick-access` (header.css), so
inside the sidebar they had no styling at all. On the Rules page that is three
bare links jammed onto one line - "← Board / Workflow view / Import export" -
with the icons run into the words and nothing to click but the text.

They are rows now: one per line, the width of the panel, padding to click
against, a hover, and the icon in a fixed 16px column so a row with a caret and
a row without one still start their words at the same x. That is the shape the
board sidebar's own rows have. Both sidebars built on this shell get it - All
Boards' as well as the page one - because both had the same problem for the
same reason.

tests/pageSidebarControls.test.cjs pins it, including that this is the class
the pages actually draw rather than a new one invented for the sidebar.

Thanks to xet7 !
2026-08-14 16:02:34 +03:00
Lauri Ojansivu d6e6e9a087 Show on Card and Show on Minicard: wide, and in columns.
Two dozen settings in one column is a list nobody sees the end of: the popup
opened and the answer to "is Description on?" was below the fold.

With one of the two checkbox columns hidden, each row is half as wide as it was
- so the width is there to be used. Both popups are min(90vw, 900px) now, and
the FORM is the grid: each row is one of its cells, keeping its own
"checkbox | name" grid inside, and the cells flow into as many columns as fit.
One on a narrow window, three or four on a wide one, without a media query per
size. The heading row spans the whole width, because it names the columns of a
ROW - "Show on Card", "Description" - not of the outer grid.

Desktop only: below 800px every popup is a full-screen sheet, where one column
is the right answer. The clamp that places a popup knows the 900 as well, or one
opened near the right edge would land half off screen.

tests/showOnCardMenus.test.cjs pins the width, the columns and the heading span.

Thanks to xet7 !
2026-08-14 15:47:20 +03:00
Lauri Ojansivu 2121be5f72 CHANGELOG: point the minicard label and sticker entries at their commit.
Thanks to xet7 !
2026-08-14 15:35:46 +03:00
Lauri Ojansivu f525b391a6 Opened card: the header row is the drag bar, and a label click stays at the label.
Dragging the opened card from the right half of its title did nothing with drag
handles off, and the reason is what a heading IS: only as wide as its own text.
So "the trailing half of the title" was a few pixels on a short title, and the
empty space beside it - the obvious place to take hold of - belonged to the
header row, which had no handler.

The row is the drag surface now, and the heading fills it, so half the title is
half the bar however long the title happens to be. The handler steps aside for
three things: the buttons in that row (they are links), the drag handle when
handles are on (it has its own handler, and both would move the window twice),
and the half that edits.

Clicking a label on a minicard opened the labels popup AND the card details
behind it - the click reached the minicard too, so one click did two things and
the one nobody asked for was underneath. It stops at the label now, and which
label was clicked is read from the event rather than from `:hover`, which
answers about the pointer and on a touch screen can still be true for whatever
was tapped last.

The stickers popup is as wide as the colour pickers, and its picker is a grid
that fits as many columns as the width allows: a hundred and fifty icons eight
to a row was a column taller than the screen.

Thanks to xet7 !
2026-08-14 15:35:46 +03:00
Lauri Ojansivu f4789e6810 Titles: the leading half edits, the trailing half drags.
With drag handles OFF a card is dragged by its own body, so a title that is
entirely an edit target leaves nowhere on that line to take hold of: a grab that
moves a few pixels is a click, and the editor opens instead of the card moving.

So the edit target is the LEADING half of the title - left in English, right in
Arabic, from one `inset-inline-start` - and the trailing half is there to drag
from. It is 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; dragging still
works over the zone as well, since jQuery UI sortable's handle is the whole
minicard. What the zone decides is where a CLICK edits.

With drag handles ON the handle is the only drag source, so nothing has to be
reserved and the whole title edits again - one rule keyed off the same class the
handle is.

The opened card's title splits the same way, and its own drag handle now appears
only when drag handles are on: with them off the title bar is what moves the
window, and a handle would be a second way to do what the bar already does. The
window-drag handler steps aside for the edit zone, so the two do not fight over
one heading.

Thanks to xet7 !
2026-08-14 15:27:22 +03:00
Lauri Ojansivu 590ccf3e81 build.sh: the test database moves off a port another program owns.
The next run stopped where the previous one had died, and the port says why:
127.0.0.1:3001 on this machine is an "Omi Server" answering HTTP.

  $ printf 'GET / HTTP/1.0\r\n\r\n' >&3
  HTTP/1.1 200 OK
  <title>Omi Server - Repositories</title>

That is what the old check called "the MongoDB already listening on :3001", and
what the test server then died against with "Topology is closed". The ping added
in the previous commit catches it - but stopping is not the answer either, since
the port belongs to a service the machine is entitled to run.

So 3001 is a default, not a requirement: when it is taken by something that does
not answer a MongoDB ping, the run moves the test database to the first free
port of 3011, 3021, 3031, 3041, 3051 and TELLS the tests where it went. They
already read WEKAN_MONGO_URL (tests/playwright/helpers/db.js), which is now
exported, and the test server is started against the same URL rather than a
hard-coded one. WEKAN_TEST_DB_PORT picks the port by hand; with all the
fallbacks taken, the run says which it tried and stops.

The readiness wait for a mongod this script starts asks the same question now -
a port that opens is not a database that answers, and waiting for the first is
how a run gets to "Topology is closed" ten minutes later.

Thanks to xet7 !
2026-08-14 15:12:50 +03:00
Lauri Ojansivu 92650b782e build.sh: reuse the test database only when it answers, not when the port is open.
The 2026-08-14 run reported "WeKan tests FAILED" while the node suites (428),
mocha (518) and the import regression all passed. What failed was the test
server, on its first query:

  MongoTopologyClosedError: Topology is closed
  [An error occurred when creating an index for collection "users: Topology is closed]

There was no wekan-test-mongod.log in the run directory, which says the rest:
the script took its "reuse the MongoDB already listening on :3001" branch and
started no mongod of its own. That branch asked only whether something accepted
a TCP connection - and a mongod that is shutting down, which this same script
kills a few lines earlier on its way in, accepts for a moment longer.

It asks the database now: a `ping` through the driver already in the built
bundle, so nothing is installed for it and a dead port is refused in three
seconds rather than after a minute of server-selection. A port that is open but
does not answer is an error with the command to free it, because we cannot start
our own mongod on a taken port and starting the server against nothing is the
failure this came from.

tests/buildDevServer.test.cjs pins both halves.

Thanks to xet7 !
2026-08-14 14:58:11 +03:00
Lauri Ojansivu c31c4f9adc CHANGELOG: point the release-upload entry at its commit.
Thanks to xet7 !
2026-08-14 14:15:25 +03:00
Lauri Ojansivu 38c6ee29fd Release: one snap listed twice is why three architectures fell off v10.91.
The v10.91 release run failed in four jobs. Three of them - s390x, ppc64el and
riscv64 - had done all the work: each snap built on Launchpad and each was
published to the Snap Store. What failed was attaching it to the GitHub Release:

  HTTP 404: Not Found (https://uploads.github.com/repos/wekan/wekan/releases/
  370103352/assets?label=&name=wekan_10.91_s390x.snap)

The step collected the file with two patterns, `wekan_${VERSION}_<arch>*.snap`
and `*_<arch>.snap`, and a snap called `wekan_10.91_s390x.snap` matches both -
so the same path went to `gh release upload --clobber` twice. Asked to attach
one name twice, it deletes the asset it has just uploaded and then 404s on it.
The two other places in the same job that build this list were already piped
through `sort -u`; this one was missed.

It is deduplicated now, and the attach is confirmed from the other side the way
the native snap job already did: read the release's assets back and fail if this
snap is not among them. An upload that reports success and leaves nothing behind
is the failure nobody notices until a download 404s - and every job that lost an
architecture had already said the snap was built and published.

The fourth failure, armhf, is not this: snapcraft died with SSLEOFError while
downloading the build log from Launchpad, on all three attempts, and produced no
snap.

tests/releaseSnapArches.test.cjs now requires every snap list to be
deduplicated, and the attach step to read the assets back.

Thanks to xet7 !
2026-08-14 14:15:25 +03:00
Lauri Ojansivu d36df4c245 CHANGELOG: wrap the container wait-page paragraph at 80 columns.
Thanks to xet7 !
2026-08-14 14:06:54 +03:00
Lauri Ojansivu 01b12d8852 CHANGELOG: point the container wait-page entry at its commit.
Thanks to xet7 !
2026-08-14 14:05:37 +03:00
Lauri Ojansivu b4499b0ddc Docker: wait for the database behind a page, not behind a closed port.
"We upgraded to 10.91 ... Gateway timeout appears" - from the same reporter as
#6592, whose fix shipped in 10.91 and could not reach them: that fix is the
snap's, and this is a container.

WeKan does not open its web port until the database answers, and in a container
nothing else was listening while it waited, so a reverse proxy in front returned
a gateway timeout. That is the same symptom for two completely different faults:
WeKan is broken, or the database has simply not come up yet.

The entrypoint now asks whether the database answers - one ping, with the driver
already in the bundle (releases/ferretdb/db-ready.mjs) - and while it does not,
it serves the bridge page the recovery case already uses, taught a second reason:
"WeKan is waiting for its database", with where to look, as a 503 that refreshes
itself.

What is bounded is the PAGE, not the wait. A database can take minutes to come
up after an update and giving up on it would be worse than waiting, so when the
window ends the page stops, WeKan starts, and WeKan keeps waiting exactly as it
did before. WEKAN_DB_WAIT_PAGE=false turns it off; WEKAN_DB_WAIT_MAX_SECONDS
sets the window. The page is killed and reaped before WeKan binds the port,
because two servers cannot hold one.

tests/dockerWaitsBehindPage.test.cjs pins that, the probe writing nothing, and
that the recovery reason still says recovery.

Thanks to Alishara and xet7 !

Fixes #6595,
2026-08-14 14:05:37 +03:00
Lauri Ojansivu 0b9bca6b87 CHANGELOG: point the index entry at its commit.
Thanks to xet7 !
2026-08-14 13:58:44 +03:00
Lauri Ojansivu 569dd4af54 Index the collections a card is made of.
Reported by email against 10.91: "Still slow on loading cards. Test server with
me as only user." One user is the part that says what it is not: with nobody
else on the server there is no contention, no queue and no lock, so the time is
going into the queries themselves.

It was. cards, activities, cardComments, checklists and checklistItems - every
collection a board draws and an opened card pulls in - had NO index at all.
"The cards of this list", "the comments of this card", "the newest activities of
this board" each walked the whole collection. That is invisible on a demo board
and expensive on a real one, and worse on FerretDB, whose SQLite backend has to
walk the same documents. The activities publication even explains that it keeps
its selector flat so it can push down to FerretDB's index; there was no index
for it to push down to.

Each index matches a selector the app really makes WITH the sort it really uses
- boardId+archived, listId+sort, swimlaneId+sort, parentId, boardId+cardNumber
for cards; cardId+createdAt and boardId+createdAt for activities and comments;
cardId+sort and checklistId+sort for the checklists - because an index on the
filter alone still leaves an in-memory sort of everything it matched, which on a
board with a year of history is the slow half.

They go through the same idempotent ensureIndex every other index here uses: it
waits for the replica-set primary, does nothing when the index is already there,
and logs a backend that refuses one instead of stopping the server.

tests/cardQueryIndexes.test.cjs pins the set, and that each one carries the sort
as well as the filter.

Thanks to xet7 !
2026-08-14 13:58:44 +03:00
Lauri Ojansivu ca662c737c CHANGELOG: point the attachment and snap database entries at their commits.
Thanks to xet7 !
2026-08-14 13:50:20 +03:00
Lauri Ojansivu 804004e9e8 Snap: comparing the two databases reads the one that is already running.
`snap run wekan.database-compare` answered "MongoDB: <unreadable>" and
"FerretDB: <unreadable>" on a live instance whose wekan.sqlite was 85 MB and
whose site was up at the time.

FerretDB was unreadable BECAUSE it was up: SQLite has one writer, so the second
FerretDB the comparison started against the same directory did not get the data.
The tool then reported the database it had been talking to all along as
unreadable, and refused to choose.

The live one is asked first now - `db-eval evidence` only counts and sorts - and
a second copy is started only for a database that is not running. Both speak the
MongoDB wire protocol on the same port, so which one is answering is asked
rather than assumed: `db-eval kind` reads buildInfo, where FerretDB names itself
and a mongod does not. Reading one as the other would be a wrong answer given
with confidence, which is worse than "unreadable".

database-compare is autopick with --dry-run, so the automatic choice at startup
and the command an admin runs by hand both get it.

tests/snapCompareReadsLiveDatabase.test.cjs pins all of it.

Thanks to waltermhl and xet7 !

#6583 comment 5291730920
2026-08-14 13:50:19 +03:00
Lauri Ojansivu 2ff21ac962 Snap: MongoDB files no reader can open are not compared with FerretDB.
An instance whose MongoDB data was written by a MongoDB older than any mongod
this snap carries printed, at every start and before the site came up:

  Database selection: setting='ferretdb' ferretdb_has_data=true mongodb_has_data=true
  WeKan: BOTH databases have been written to since the migration.
  [autopick] reading both databases to see which one holds the work ...

`mongodb_has_data` answers "are the files there", not "can anything here open
them", and on that instance those are different answers - the same issue shows
mongod 7, 5.0 and 4.2 each refusing the files in turn. So the comparison ran on
a copy that cannot be served, and it is not a cheap question: it starts a mongod
that cannot open the data, and then a second FerretDB against the SQLite
directory the running one already holds.

migration-control has already tried every reader in the snap and left
.mongodb-data-too-old behind when none of them could open the files. With that
marker present the MongoDB copy is not a candidate for holding the newest work,
so both comparison branches skip it and WeKan serves the FerretDB that has the
data. The empty-FerretDB safety still asks about the files being there, because
that one is not a comparison.

Nothing is deleted and the marker stays: it is true, and it is right again on a
snap that can read those files.

tests/snapUnreadableMongoNotCompared.test.cjs pins which question each branch
asks.

Thanks to mueschel and xet7 !

#6585 comment 5290402146
2026-08-14 13:50:19 +03:00
Lauri Ojansivu 146a5184d9 Attachment migration: keep a binary two records share, and name a missing one.
Moving attachments from MongoDB CollectionFS to File-System stopped on some of
them with "FileNotFound: file 66336fc372e64200010f4832 was not found", and the
reporter had it exactly: it is the identical files.

A CollectionFS filerecord points at its binary by copies.<coll>.key, the id of a
file in the cfs_gridfs.<coll> bucket, and two filerecords can carry the SAME key
- the same file attached twice, or a board copied with its attachments. The
migration deleted the binary as soon as it had moved the first of them, so the
second read a file that was no longer there.

The binary now goes only when no other filerecord still names it, asked after
the record itself is deleted so it cannot count itself. When that question
cannot be asked at all, the binary stays: a file left behind can be removed
later, an attachment deleted out from under another record cannot be brought
back.

A binary that really is missing - metadata restored without the chunks - is no
longer a MongoDB stack trace naming a GridFS id. It names the attachment, says
where it was looked for, and is counted as SKIPPED rather than failed, because
there was nothing to move; the Admin Panel's summary line shows both counts, so
a run that leaves attachments behind cannot look like one that moved
everything.

tests/collectionFsSharedBinary.test.cjs pins the shared-binary check, the order
it is asked in, and that a skipped record is not a failure.

Thanks to sbruckmueller and xet7 !

Fixes #6596,
2026-08-14 13:49:59 +03:00
Lauri Ojansivu e612fdfee9 Custom fields: everything about them on the section that shows them.
They were spread over three places. Board Settings had the board's list of
fields behind a right-sidebar view; the card menu had an entry for that list and
another for the picker of which fields are on this card; and the picker had a
cog that jumped back to the sidebar, closing the menu and the card pane on the
way.

One place now: the hamburger at the end of the card's own Custom Fields
heading. It opens every field the board has, ticked when it is on this card,
with a pencil each and, under a rule, "Add custom field". Edit and Add open in
the same pop-over on top of that list, so the back arrow returns to it and the
card stays open behind, and they are the board's own forms rather than second
copies of them.

Gone with it: the card menu's Custom Fields entry, the Board Settings row, the
wrapper popup that nothing opened any more, and the styles that existed only to
make a sidebar list look right inside a pop-over. The sidebar view template
stays where it is, with a note that nothing opens it.

tests/customFieldsSectionMenu.test.cjs pins the popup's three parts, that the
forms are the shared ones, and that none of the three old places came back.

Thanks to xet7 !
2026-08-14 08:20:30 +03:00
Lauri Ojansivu 18076c138d Subtasks Settings: a hamburger on the Subtasks heading, and gone from Board Settings.
What subtasks do on a board - whether they are shown at all, which board and
list a new one is deposited in, and how a parent is named on a minicard - is
behind a hamburger at the end of the Subtasks heading on an opened card, where
the card and the minicard already keep theirs. It opens the same popup Board
Settings did, and Board Settings no longer has the entry.

The heading is the control that folds the section, so the hamburger's click
stops there: without that, opening the menu would close the thing it was opened
from.

The hamburger is a `menuClass` on the shared section header, so any other
section can be given one, and it is pushed to the end of the row by
`margin-inline-start: auto` - a logical margin, so a right-to-left card needs no
second rule. A subtask's own menu is about the subtask again: the settings
belong to the section, not to one subtask of it.

tests/subtaskSettingsMenu.test.cjs pins all of it, including that the click
does not reach the heading.

Thanks to xet7 !
2026-08-14 08:12:03 +03:00
Lauri Ojansivu 82db0800ef Move/Copy selection and Move/Copy swimlane: one dialog each, not two.
Both pairs duplicated their whole component, not only their markup. The
selection dialog was 152 lines twice over, and 145 of them were the same: the
four reactive selections, the four selects' helpers, the change handlers and
the sort-index maths. The seven that differed are what each does to a card once
the destination is known - move it, or copy it and move the copy. That is now
`applyToCard`, passed to one registration.

The swimlane pair is the same story with one difference instead of seven: the
method called on Done.

The markup of both is one template too - selectionDestinationPicker and
swimlaneDestinationPicker - handed the popup's own instance as `dialog`,
because a helper is looked up on the template it is written in. What the two
copies of each really did differ in was the ids their labels point at, so those
are passed in: `idSuffix` for the radios, `titleId` for the title field, which
takes it as an `id=` attribute since a literal id cannot hold a mustache.

sidebarFilters.js lost 106 lines, sidebarFilters.jade 17 and swimlanes.jade 10.
A scan for near-duplicate templates is at 7 pairs, from 74 before this work
started. tests/sharedFormTemplates.test.cjs covers both, including that move
and copy still do different things and that a label still clicks its own
control.

Thanks to xet7 !
2026-08-14 07:34:13 +03:00
Lauri Ojansivu d6df742f56 Card menu: Custom Fields is one entry, and Board Settings has none.
The menu had two: the board's LIST of custom fields and the picker for which of
them are on this card, one above the other. That puts the general question
above the particular one and makes the menu ask which you want before you have
seen either.

One entry now - the picker, with the icon and the place the picker's entry had,
named "Custom Fields". The board's list is behind that popup's own Settings
cog, which is where you look once you have seen the fields and want to add one,
and it opens as a pop-over on top of the picker rather than sending the reader
to the right sidebar: the back arrow returns to the fields, and the card stays
open behind them.

Board Settings loses its Custom Fields row with it. The fields are where the
cards are, and a third way in is a third place to look.

Thanks to xet7 !
2026-08-14 07:09:11 +03:00
Lauri Ojansivu f4b4068c8c CHANGELOG: point the shared-form entry at its commit.
Thanks to xet7 !
2026-08-14 07:04:28 +03:00
Lauri Ojansivu 53c3123de5 Three more forms written once instead of ten times over.
Three shapes were copied, and in each the JavaScript was already one piece
while the markup was not - so a behaviour change was one edit and a markup
change was four:

- where a card goes (board, swimlane, list, above or below which card, Done),
  drawn four times: Move card, Copy card, Copy checklist to many cards, Convert
  checklist item to card;
- where a list goes, drawn twice: Copy list and Move list;
- the Create Board form, drawn four times: the one on All Boards and three
  popups, one of which creates a template board and says so with a Session flag
  rather than with different markup.

Each is one template now - cardDestinationPicker, listDestinationPicker,
createBoardForm - and what it shows is passed in, because a helper is looked up
on the template it is written in and not on the one including it. The two
pickers are handed the dialog and keep it on their own INSTANCE: inside `each
boards` the data context is a board, so a helper reaching into the context for
it would find nothing there.

The events stay with the popups. An event inside an included template bubbles
to the one that includes it, which is the one holding the state - that is what
lets four popups do four different things with one form.

cardDetails.jade lost 71 lines, boardHeader.jade 58 and listHeader.jade 10, and
a scan for near-duplicate templates went from 74 pairs to 9.
tests/sharedFormTemplates.test.cjs pins all three, including that the
template-board popup still creates template boards.

Thanks to xet7 !
2026-08-14 07:04:28 +03:00
Lauri Ojansivu 1c3f72acf8 CHANGELOG: point the Subtasks Settings entry at its commit.
Thanks to xet7 !
2026-08-14 06:52:56 +03:00
Lauri Ojansivu ec8ba58753 Subtasks Settings is in the menu of the subtask it is about.
What subtasks do on a board - whether they are shown at all, which board and
list a new one is deposited in, and how a parent is named on a minicard - was
in Board Settings only: open the sidebar, open the board menu, find Subtasks
Settings. Somebody who wants to change it is usually looking at a subtask on an
opened card at that moment.

It is at the bottom of that subtask's own menu now, under a rule, below the
things the menu does to the subtask itself. A board admin's, as it is in Board
Settings, whose entry is unchanged.

The settings are their own template with their own state, helpers and handlers,
included by both places, so neither needs code of its own and the two cannot
drift. That is what the move needed: a template included in two places cannot
reach its parent's helpers, because a helper is looked up on the template it is
written in.

tests/subtaskSettingsMenu.test.cjs pins the placement, the rule above it, the
permission, and that nothing was left behind on the popup.

Thanks to xet7 !
2026-08-14 06:52:56 +03:00
Lauri Ojansivu 9faf63fced CHANGELOG: point the date-template entry at its commit.
Thanks to xet7 !
2026-08-14 06:48:48 +03:00
Lauri Ojansivu 9c288c3d4d Date templates: one edit form and one badge instead of twenty-two copies.
Two shapes were copied across three files. The edit-a-date form - date, time,
Save, Delete - existed eight times, letter for letter: the card's Received,
Start, Due and End, a vote's end date, a planning poker's end date, a date
custom field, and a ninth `datepicker` template that nothing included and no
popup could open (a popup's template name ends in `Popup`). The date badge -
the coloured date on a card and on a minicard - existed fourteen times.

The JavaScript was already shared: client/lib/datepicker.js holds the state and
the handlers, and each popup differs only in the field it stores. It was only
the markup, so a change to the form meant eight edits and a change to the badge
meant fourteen, with nothing to say so.

Each is one template now - editDateForm and dateBadgeBody. They take what they
draw as ARGUMENTS, because a helper is looked up on the template it is written
in and not on the one including it: that is what lets one piece of markup serve
them all while every popup keeps its own state, its own click and its own name.
cardDate.jade went from 289 lines to 91, and the dead datepicker.js went with
the template it wired up.

The badge's `baseClass` is the trap this had to avoid: three of the fourteen -
the custom-field dates - were never `.card-date` and must not become one, so
the class each caller carried is passed in rather than baked into the shared
markup. tests/dateTemplates.test.cjs pins that, the minicard kind classes, and
that no state moved.

Thanks to xet7 !
2026-08-14 06:48:47 +03:00
Lauri Ojansivu 1f9848920b CHANGELOG: point the popup-title entry at its commit.
Thanks to xet7 !
2026-08-14 06:37:24 +03:00
Lauri Ojansivu 47099b8baa Popups: the ten that had no header now have a title, like every other one.
A pop-over draws its header from its title, and with no title it renders as
`no-title`: no header, and so no close button and no back arrow - Escape or a
click away are the only ways out. Noticed on "Are you sure you want to delete
this background image?", which is the worst place for it.

Ten popups were in that state and are titled the way the other 151 already
were, by a `<name>Popup-title` key: Delete Background Image, Delete Duplicate
Lists, Delete Account, Add Domain, Remove Domain, Map to existing user, Export
swimlane, Export list, Export checklist, and the member popup.

The keys are in EVERY language file at the same position, as the English
placeholder a Transifex pull would produce for a key nobody has translated yet:
the files are one key order, and a key inserted in some and appended in others
makes every later diff unreadable (tests/boardItemLinks.test.cjs pins that).

The other mechanism, Popup.open(name, { titleKey }), is not a second way but
the same one pointed at a phrase the app already has - "Custom Fields", "Sort
Boards", "Show on Card" - so a word translated 147 times is not copied into a
new key that would start as English in all of them.

tests/popupTitles.test.cjs walks every popup template in the client and fails
if one resolves no title through either, so the next popup cannot ship without
a header.

Thanks to xet7 !
2026-08-14 06:37:23 +03:00
Lauri Ojansivu a3774245f5 CHANGELOG: wrap the background-upload paragraph at 80 columns.
Thanks to xet7 !
2026-08-14 06:24:21 +03:00
Lauri Ojansivu 2a50e7104c Board backgrounds: one entry, and the picture is the button.
"Board backgrounds" is gone from Board Settings. Its content - the pictures
uploaded to this board - is drawn inside Change Background Image, under the
upload that adds to them, so a second menu entry was a second place to look for
one list. The popup it opened had become a single include and went with it.

Clicking a picture, or its name, now puts that one behind the board. It was a
14px check icon in the row under an 80px picture of the thing it applies, so
the obvious click - on the picture - did nothing. That icon is gone; the row
keeps download and delete, which are the two things a picture cannot say.

Thanks to xet7 !
2026-08-14 06:23:00 +03:00
Lauri Ojansivu 4b3a37b5a2 Board background: the uploaded picture is listed, named, and applied.
An upload ran, the spinner stopped, and nothing else happened: the picture was
not in the list, and the board looked the same. Three reasons, all of them the
same shape - the upload had no way to say what it had done.

The list of what has been uploaded is its own template now and is drawn in BOTH
popups: under the upload in Change Background Image, and as the whole of Board
backgrounds. One subscription, in the template that draws it. Uploading into a
popup that then shows nothing is indistinguishable from an upload that failed.

Each picture is shown with its NAME. Two photos of the same holiday are the
same picture at 80 pixels, and after an upload nothing said which one arrived.

A finished upload puts itself behind the board. "Add background image" is asked
for by somebody who wants that picture there; an upload that only lands in a
list, with the board unchanged, reads as one that did not work. The check in
the list still switches between the pictures already uploaded, and a failed
upload sets nothing.

Thanks to xet7 !
2026-08-14 06:07:06 +03:00
Lauri Ojansivu 9035d6e829 Board background upload: a failure says so instead of vanishing.
The upload can fail before there is an uploader to listen to - a config the
collection refuses, a name its namingFunction cannot build. `insertAsync`
rejected into nothing there: the spinner stopped, no message appeared, and the
picture simply never turned up in the list. That is how the missing `fileId`
hid for as long as it did.

The insert is inside a try now: the reason lands in the popup's own warning
line and in the console, and the file input is cleared either way so the same
file can be picked again after a failure.

Thanks to xet7 !
2026-08-14 05:58:51 +03:00
Lauri Ojansivu 1386bd9ee1 CHANGELOG: point the upload and avatar entries at their commit.
Thanks to xet7 !
2026-08-14 05:47:38 +03:00
Lauri Ojansivu ac201d4b59 Upload background image works, one upload config, and an avatar with no name.
Two fixes that both came out of the same session.

"Upload background image" picked a file and nothing arrived. Two fields of an
upload's config are not optional in the way they look, and the board
background's config - written by hand - had neither:

- `fileId`, and the same id copied into `meta.fileId`. Attachments'
  namingFunction decides the name a file is STORED under, and on the client it
  reads that id out of `meta` and deletes it. Without it the name is
  `undefined`.
- `transport`. HTTP everywhere, because Meteor-Files' default DDP floods the
  WebSocket and makes Safari reconnect at ~95%; DDP on Sandstorm, whose
  http-bridge strips the x-* upload headers so every chunk comes back "Can't
  continue upload, session expired" [408].

Both come from client/lib/attachmentUploadConfig.js now, used by the card's
file picker, the card's pasted image and the board background alike - so the
next uploader gets them by asking for a config rather than by knowing two
things nothing would have told it. The two card call sites lost their
hand-written copies with it.

getInitials() ended with `this.username[0]`, so a user document that arrived
without a username threw a TypeError from a Blaze helper, mid-render. Two
helpers call it, so each avatar threw twice, and the second throw left the
SVG's viewBox half-written ("0 0  15"), which Firefox refuses outright: the
avatar drew nothing and took the rest of that render pass with it. It is total
now - initials, fullname, the first letter of a username, then an empty string -
and a fullname of only spaces no longer spells the literal word "undefined".

tests/attachmentUploadConfig.test.cjs and tests/userInitials.test.cjs pin both,
including the empty-document case that threw.

Thanks to xet7 !
2026-08-14 05:46:41 +03:00
Lauri Ojansivu a833e49174 Export board: a full-width panel, with its X always on screen.
It was anchored to the button that opens it and clamped into the viewport using
an assumed width - 1100 in popupOffset.js against `min(96vw, 1100px)` in
popup.css. The two disagreed by a few percent, so on a narrow window the
panel's trailing edge, and the pop-over's own X with it, ended up past the edge
of the screen: Escape or clicking away were the only ways to shut it.

It is a panel now, not a menu hanging off a button: pinned to the top left of
the viewport at the 10px gutter every popup is kept inside, and
`calc(100vw - 20px)` wide - the whole window, less that gutter on each side.
The header, the X and both panes are always on screen, and there is no assumed
width left to disagree with the stylesheet.

Thanks to xet7 !
2026-08-14 05:35:51 +03:00
Lauri Ojansivu 54ceb74ad7 CHANGELOG: point the board-background entry at its commit.
Thanks to xet7 !
2026-08-14 05:31:01 +03:00
Lauri Ojansivu 6a6fc25292 Board background: the upload is with the URL, and Unset sits beside Save.
Two popups of the same menu answered one question. "Change Background Image"
asked for a URL; "Board backgrounds" listed the images uploaded to this board
and carried the upload button - so the popup that reads as the place to set a
background had half of the ways to set one, and the other half was behind a
different entry.

The upload is under the URL field now: a picture from the web and a picture
from this machine, read together. "Board backgrounds" keeps what it is named
for - the images already there, to set active, download or delete. The uploader
moved as its own template with its own state and handlers, once, so there is
still one uploader; the subscription stayed with the popup that lists the
images, which is the only one that needs it.

Unset was five blank lines and a rule below the Save it belongs beside. They
are one row now, Save first, sharing the width. Unset is explicitly
type="button": inside that form a button with no type is a submit button, so
beside Save it would have saved the URL it is meant to clear.

tests/boardBackgroundPopups.test.cjs pins the layout, the type, and that
nothing about uploading is left behind or duplicated.

Thanks to xet7 !
2026-08-14 05:31:00 +03:00
Lauri Ojansivu 0461e0bed2 CHANGELOG: the header fold is its own entry, linking its own commit.
Thanks to xet7 !
2026-08-14 05:23:55 +03:00
Lauri Ojansivu 7d0396ebec Board header: the board's controls fold into one caret.
A caret leads the group, at its leading edge - left of the lock in a
left-to-right page, right of it in Arabic, because its direction comes from the
same rule every other caret here uses. Folded, the seven controls after it are
gone - Private, Muted, Sort Cards, Filter, Search, Show dependencies,
Multi-Selection - and the bar is the logo, the board's name and the caret.

The buttons a board's own Rules put in this bar are NOT folded with them:
somebody added those to this board on purpose, so they sit outside the fold and
the standard controls fold around them. It is named by the words the app
already has - Collapse and Uncollapse - so no key was added to 147 language
files to say them again, and it carries role="button" and a tabindex, so Enter
and Space work.

The sidebar's Members and Labels folds moved into the same store on the way:
`client/lib/sidebarSectionState.js` is now `client/lib/foldState.js`, with
`isFoldOpen` / `foldCaret` / `js-toggle-fold` for all three. One store, one
rule, one class - a header caret and a sidebar caret pointing different ways in
one language would be the bug this avoids.

tests/sidebarSectionCollapse.test.cjs is tests/foldSections.test.cjs and covers
both ends, including that the Rules buttons stay outside the fold.

Thanks to xet7 !
2026-08-14 05:22:31 +03:00
Lauri Ojansivu 23b15078a4 CHANGELOG: point the sidebar fold entry at its commit.
Thanks to xet7 !
2026-08-14 05:15:04 +03:00
Lauri Ojansivu 48cf269870 Board sidebar: Members and Labels fold by their headings.
The two sections at the top of the right sidebar could not be folded at all -
the People / Organizations / Teams / Domains tabs with their avatars, and every
label of the board, were always open and pushed everything under them down the
panel.

Both have a caret in front of their heading now, and the heading is the button:
the same control an opened card's sections use, and the one Activities got
below them, with its direction from the same function. Enter and Space do what
a click does.

The choice is the reader's and lasts the session: neither section decides
anything on the server, so folding one for yourself must not fold it for
everybody. Activities is the exception and stays as it is - its caret writes
board.showActivities, which also decides what the publication sends.

tests/sidebarSectionCollapse.test.cjs pins both, including that the labels are
still inside their content box rather than siblings of it, which is the shape
an indentation change like this breaks.

Thanks to xet7 !
2026-08-14 05:15:04 +03:00
Lauri Ojansivu e80009bb80 Board Settings: remove Card Settings, which the card and minicard menus now hold.
Every setting in that table is in the menu of the thing it is about: the card's
menu has "Show on Card" and a minicard's has "Show on Minicard", each with that
column of it. A board-wide entry as well is a third way to one list, and the
way furthest from what it changes.

The table itself is untouched - the two menus include it - so this removes the
row from the board menu and the handler that opened it, nothing else. The
Sandstorm and Scaling docs named the old route and now name the new one.

Thanks to xet7 !
2026-08-14 05:09:44 +03:00
Lauri Ojansivu f822d426bf Minicard: the list title is a line of its own, not another badge.
The badge row is `float: inline-start`, so the block written after it shared
its line: "Show on Minicard / List title" rendered wedged in beside the
attachment and checklist counts and read as one more badge. `clear:
inline-start` puts it where the markup already meant to put it - a small grey
line under the badges - and clears on whichever side the page starts.

Thanks to xet7 !
2026-08-14 05:05:52 +03:00
Lauri Ojansivu 6bd2816484 CHANGELOG: point the Date Format fix at its commit.
Thanks to xet7 !
2026-08-14 05:02:57 +03:00
Lauri Ojansivu 443e65b6d0 Card details: the Date Format menu is back - two collapse mechanisms folded one field.
The dropdown that chooses a card's date format disappeared and nothing brought
it back. The dates under the same heading were there; the selector above them
was not.

Two things were folding it. #1591 gave every field of an opened card a caret on
its own title and hid everything after that title when clicked, storing the
fold per card in the user's profile. The section carets that replaced it put
one handle on each section's heading - and that heading is drawn on the FIRST
field of the section, so a click on it was also a click on a field title. The
old handler ran too, folded that field, and stored it. Reopening the section
brought the other fields back but not the one the heading sits on, and the
per-field caret had been suppressed inside a group, so nothing was left to open
it.

The #1591 half is gone: the delegated handler, the caret it drew on every
title, the rule that hid the siblings, and the per-card state re-applied on
render. Every field lives in a section now and the section's heading is the
only handle. The same per-user store is still the checklists' - they key their
entries by checklist id, which is what makes a checklist folded on the card
folded on its minicard - so the store and its two model methods stay.

tests/cardSectionCollapse.test.cjs pins the select inside its section and that
no field folds on its own; tests/checklistCollapse.test.cjs keeps the half that
is still in use and says why the other half went.

Thanks to xet7 !
2026-08-14 05:02:57 +03:00
Lauri Ojansivu 1769a6bdd6 Card menu / Custom Fields opens in the menu, not in the right sidebar.
It opened the sidebar's Custom Fields view: a different part of the screen,
with the menu gone and the card pane closed behind it, and the answer to "what
fields does this board have" on the other side of the window.

It is a pop-over now, in the place the menu was, so the pop-over's own back
arrow returns to the card menu - and Create and Edit, which were popups
already, stack on top of it and come back to the list. It is the same template
the sidebar view uses, included rather than copied, so the two lists cannot
drift. What it needed was styles of its own: every rule that list has is
written for `.sidebar .sidebar-content` and none of them reach inside a
pop-over.

The board menu's Custom Fields still opens the sidebar view, unchanged.

Thanks to xet7 !
2026-08-14 04:55:59 +03:00
Lauri Ojansivu 8bdcf2a26f CHANGELOG: point the List title entry at its commit.
Thanks to xet7 !
2026-08-14 04:49:34 +03:00
Lauri Ojansivu 9d9b8ae93b Show list on minicard became the List title row of Show on Minicard.
It was a line in the card's menu reading "Show list on minicard", or "Hide list
on minicard" once it was on - a menu entry that changes its own name is the
shape a checkbox has, drawn as a sentence. It is a checkbox now, the first row
of Show on Minicard, above Received, named "List title" and unchecked by
default, which is what the field itself has always been.

It shows the name of the list the card is in on its minicard, which is worth
having where a card is read away from the column it lives in: a swimlane,
search results, My Cards. The board-wide "Show lists" row further down the same
table turns that on for every card; this one is for a single card, and either
is enough for the name to show.

It belongs to the card rather than to the board, so it is drawn only in the
minicard's own view - Board Settings has no card to act on - and only for
somebody who may change that card, which is the permission the menu entry had.
The card is passed into the popup and re-read from the collection when it is
toggled: a minicard's menu is opened from the board, where there is no current
card to fall back on.

Thanks to xet7 !
2026-08-14 04:49:33 +03:00
Lauri Ojansivu ac35762eec CHANGELOG: point the custom-fields entry at its commit.
Thanks to xet7 !
2026-08-14 04:38:55 +03:00
Lauri Ojansivu 8d0a457e68 Custom Fields and Edit custom fields are one group in the card's menu.
The board's LIST of custom fields - where a field is created, renamed or
deleted - was in Board Settings only, and the picker for which of those fields
are on this card sat down among Voting, Planning Poker and Spent time. Two
halves of one subject, three groups apart.

They are one group now, under Watch and its rule: Custom Fields first, because
a field has to exist before a card can be given it, then Edit custom fields,
then a rule like every other group in that menu. The first opens the same
sidebar view the board menu opens, asked for through the sidebar service rather
than assumed to be there, and closes the menu behind it. A board admin's, the
same as in Board Settings.

Thanks to xet7 !
2026-08-14 04:38:55 +03:00
Lauri Ojansivu 09c5764a27 CHANGELOG: point the menu-restructuring entries at their commit.
Thanks to xet7 !
2026-08-14 04:33:12 +03:00
Lauri Ojansivu f0204ae014 Show on Card and Show on Minicard in the menus of the card and the minicard.
Board Settings / Card Settings is a table of twenty-four settings in two
columns - what a card shows, and what a minicard shows - and it lived in one
place only, four clicks from the card whose fields it decides. It is now also
the first entry of the two menus it is about: the opened card's hamburger
offers Show on Card, a minicard's offers Show on Minicard, each followed by a
rule like every other group in those menus.

Both open the SAME popup with the other column hidden by a class, so there is
one list of settings and one set of handlers; a setting added to the table
appears in all three places by itself. The permission is the one Board Settings
already used - a board admin - asked in the menu and again where it acts. The
menu is opened from two places and its data context is the card either way, so
the opener records which hamburger it was in a module-level reactive value
rather than a field on the card document, which a re-render throws away.

"Hide minicard label text" left the right sidebar, where nothing else about a
minicard is, and became the "Labels text" row of the same table, directly under
Labels. It is ticked when the text is SHOWN, which is the default: the stored
flag is a "hidden" one, and reading it straight through would leave the box
unticked for the behaviour a board has out of the box. It is the one personal
row of a board-wide table, so it shows in the minicard's view only, is offered
to everybody, and a reader who is not a board admin gets that row alone.

The move found a bug: the setting was written out three times and the
minicard's own copy only ever wrote localStorage, so a logged-in user toggling
it set something nothing reads. One module reads and writes it now.

The board sidebar's Activities heading had an eye and the words "Show
activities" beside it - one setting with two controls and its own name twice.
It is a caret in front of the heading now, and the heading is the button, which
is what every collapsible section of an opened card already is. Both take the
caret's direction from one function, so it cannot point one way on a card and
another in the sidebar of the same language.

tests/showOnCardMenus.test.cjs pins all of it. The Sandstorm and Scaling docs
name the new route beside the old one, and the developer directory structure
lists the three new client/lib modules.

Thanks to xet7 !
2026-08-14 04:32:04 +03:00
Lauri Ojansivu 9c86b42eb1 CHANGELOG: point the two title-editing entries at their commit.
Thanks to xet7 !
2026-08-14 04:00:49 +03:00
Lauri Ojansivu c6ad795629 Edit a title where it is written: a card's on the board, a board's in the header bar.
A list's title has always been edited in place. A card's could only be changed
by opening the card, so fixing a typo on a board of forty cards was open, edit,
close, forty times. Clicking a minicard's title text now opens the same
inlinedForm the list heading uses - textarea, Save, X - for anybody who may
write; the complete checkbox, the linked-card icons and the card number keep
doing what they did, and the rest of the card still opens the card.

The minicard sits inside the link to the card, so a click in the open editor
would have navigated away mid-rename. That default is cancelled, except on the
Save button whose own default IS the submit. An empty save is a no-op rather
than a way to end up with a card that has nothing to click.

The board's name had a pencil beside it: two targets for one job, and the
smaller of them was the one that did it. The name itself now opens the same
boardChangeTitlePopup the pencil opened, with the board as its data context
because the header bar's context is the page. A board whose title is empty
renders no text, and a zero-width element cannot be clicked, so the clickable
title carries a minimum width and height - an empty title can still be given
one.

tests/titleClickToEdit.test.cjs pins both ends, including the two negatives:
the save button keeps its default while everything else in the editor does not,
and the empty title stays clickable.

Thanks to bonnebulle and xet7 !

Fixes #4990,
2026-08-14 03:59:38 +03:00
Lauri Ojansivu 0edbf117d3 CHANGELOG: point the restoration entry at its commit.
Thanks to xet7 !
2026-08-14 03:43:11 +03:00
Lauri Ojansivu 7a75779b2c Card details: put back what the restructuring dropped, and theme the Copy
link button.

Moving eleven sections with a script is how markup disappears without
anything failing: the file compiles, the tests pass, and a card is quietly
missing a row. Comparing the card against its state before the section work -
by counting every line of markup, not by reading - turned up four things.

Three were LOST:

- +cardButtons, the card's whole button row: a slice used it as a boundary
  and swallowed it;
- a custom field's NAME. +cardCustomField renders the value and nothing else,
  so a card with three custom fields showed three values with nothing to say
  what they were;
- the attachment count beside the Attachments heading. The section header
  takes a count now, and the board setting that governed it still does.

One was GAINED: a second, bare `+` beside the End date's add button - an icon,
not a button, from a slice that duplicated a line. That is the one the report
asked about.

Each of the four has a test now, because each was invisible to the compiler,
to the suites and to a reading of the diff.

Also: the Copy card link to clipboard button carried only `.btn`, so it fell
back to the plain grey button whose dark label is nearly unreadable on a dark
theme. It is `.primary` now - the board accent with white text - and it is
NAMED in the forms.css rules that already carry that look rather than given a
copy of them, which is what that file asks for.

Thanks to xet7 !
2026-08-14 03:43:11 +03:00
Lauri Ojansivu 25d9662fd1 CHANGELOG: point the one-row titles entry at its commit.
Thanks to xet7 !
2026-08-14 03:37:04 +03:00
Lauri Ojansivu c747d51baf Card details: the group caret is on its first field's title, one row of
titles.

Grouping the fields gave each group a header LINE of its own, and for a group
named after its first field that line repeated the word: "Labels" above
"Labels Stickers Location", "Members" above "Members Assignee Creator".

The caret belongs to the first field's TITLE now, so there is one row of
titles - Labels Stickers Location - with the caret at the start of it and the
+ buttons on the row below. Everything the caret folds is behind one switch:
that field's own content, and every field beside it. A collapsed group is
exactly one line, which is what "only caret and icon and text Members" asked
for.

The rule moved with it, from inside a field to the group, where it spans the
card - a rule inside a flex item is as wide as the item, which is the third
time that has bitten in this card and is written down where each fix lives.

The Checklists icon is a plain check again rather than a check in a box: it
was fa-check before the section header gave it fa-check-square-o.

Rebuilt one group at a time, compiling after each, because the first attempt
at this restructure produced anchors as siblings of their <li> and an orphan
`if` - both of which a running build rejected with "Can't use the built-in
'if' here" while a passing test looked fine. The file is verified through the
loader's own parse + SpacebarsCompiler.codeGen pipeline, which is what the
build runs.

Thanks to xet7 !
2026-08-14 03:37:03 +03:00
Lauri Ojansivu db4e0297b7 CHANGELOG: point the Requested By entries at their commit.
Thanks to xet7 !
2026-08-14 03:28:22 +03:00
Lauri Ojansivu 050df89f06 Requested By and Assigned By: a + to set them, and they survive a round
trip.

Both fields were set by clicking the word "Add". They have the round + that
Members and Assignee use now, opening the same editor the text opens - the
same class, so it is the same button and not a second one that looks like
it.

Checking where else they live turned up a ROUND TRIP THAT LOST THEM, which
is the worst shape this bug takes: the export looks complete and the import
reports success. They were exported by the card PDF, the card Excel and the
board CSV, and imported by nothing - a card exported and imported back had
forgotten who asked for it and who assigned it. models/wekanCreator.js and
the per-menu scoped importer both set them now.

The board's streaming Excel TABLE exported neither, so it gained two
columns: header and value in the same commit, because a header without its
value shifts every column after it.

From other trackers, where the idea has another name: Jira's REPORTER is who
asked for the work, and an issue's AUTHOR on GitHub, Gitea, Forgejo and
GitLab is the same thing. Both arrive as Requested By, as free text, so they
survive an import from a tracker nobody on this board has an account on. A
source with no such field - Trello, CSV - gains nothing, and a negative test
says so.

Also two things the grouping left crooked, both visible on an opened card:

- Members, Assignee and Creator were each a full-width row, so the last two
  fell below the first. The class that made a FIELD full width predates the
  groups; the GROUP is the full-width row now, and its fields share it.
- A field whose title the group header had taken over put its + on the first
  line, level with its neighbours' titles. Every field in a group keeps its
  own title again: the group header names the family and folds it, the
  field's title names the field, and the + starts on the line below it.

tests/requestedAssignedByRoundTrip.test.cjs walks the whole way round - the
card, Card Settings, four exports, three importers - because this was found
by looking rather than by anything failing.

Thanks to xet7 !
2026-08-14 03:28:21 +03:00
Lauri Ojansivu 8c718aa42d CHANGELOG: point the card field grouping entry at its commit.
Thanks to xet7 !
2026-08-14 03:14:52 +03:00
Lauri Ojansivu 83147dcc3e Card details: fields fold in groups, one caret each, Members before Creator.
The caret beside Labels folded the Labels field and left Stickers and
Location sitting under it. And every field already had a caret of its own on
the right - #1591 draws one on each .card-details-item - so a row could
carry two handles saying the same thing in two sizes.

The fields belong to families, and the families fold:

  Labels        Labels, Stickers, Location
  Date Format   the format, Received, Start, Due, End
  Members       Members, Assignee, Creator, Requested By, Assigned By
  Dependencies  Dependencies
  Sort          Sort, List, Spent time

One caret per group, on its title, at the reading direction's start. The
fields inside a group have none: #1591's per-item caret is turned off inside
a group body and its title stops being a handle. OUTSIDE a group it is
untouched, so nothing that folded before stopped folding - there is a
negative test for that, because turning the rule off globally would have
been the easy way to do this and would have removed a feature.

A collapsed group is one line - caret, icon, name - because everything else,
including the first field's own controls, is inside the fold.

Members reads Members, Assignee, Creator. The loose `hr` the layout drew
above the users block is gone with the grouping: the group's rule sits above
the whole group, so there is one line there rather than two.

Thanks to xet7 !
2026-08-14 03:14:52 +03:00
Lauri Ojansivu 12e91eb720 CHANGELOG: point the section rule styling entry at its commit.
Thanks to xet7 !
2026-08-14 03:05:13 +03:00
Lauri Ojansivu 365cfdc4df Card details: the section rules are the page's own line, and only where one
belongs.

Four things about those separators, all visible at a glance and none of them
visible in the source.

THE STYLE. The section rule drew a border of its own - 1px of rgba(0,0,0,
.12) - which is darker than the line the card already had above Creator.
That lighter line is the page's `hr` (layouts.css); the section rule sets
only its margin now, so a card has one line style instead of two.

ABOVE LABELS. Labels is the FIRST section: there is nothing above it to be
separated from, so it asks for no rule.

ABOVE MEMBERS. It sat immediately under the rule the layout already draws
above Creator, so a reader got two lines with a heading between them. No
rule there either. Every other section still has one, and a test checks both
halves of that.

ABOVE ACTIVITIES. The rule was to the LEFT of the heading, not above it:
`.activity-title` is `display: flex`, so a rule inside it is a flex ITEM and
lays out beside its sibling. The heading is outside that row now, where a
block-level rule spans the pane. Same class of mistake as the short rules in
the flex row of card items - a separator only separates when it is not a
flex child.

Thanks to xet7 !
2026-08-14 03:05:12 +03:00
Lauri Ojansivu acc53b8b4f CHANGELOG: point the section rule and duplicate heading entry at its commit.
Thanks to xet7 !
2026-08-14 02:46:52 +03:00
Lauri Ojansivu c4d1801022 Card details: one full-width rule per section, and each section named once.
Two things the collapsible sections got wrong, both visible the moment a
card was opened.

SHORT RULES EVERYWHERE. `.card-details-items` is a wrapping flex row and the
rule was drawn INSIDE a section, so it was as wide as that box - a stub of a
line beside a heading instead of a separator across the card. The six
sections that live in that row (Labels, Date Format, Members, Dependencies,
Sort, Custom Fields) are rows of their own now, so the rule in them spans
the card. The items laid out side by side on purpose - Stickers, Location,
the four dates, Creator, Assignees, Spent time - still share rows, with a
negative test saying they were not swept up in this.

TWO HEADINGS FOR ONE SECTION. checklists.jade and subtasks.jade draw their
own <h3>, and the section header drew it again, so a card showed
"Checklists" and "Subtasks" twice each, one above the other. Those templates
keep what belongs to the LIST - add a checklist, add a subtask - and the
section header is the only thing that names the section.

tests/templateRegistration.test.cjs failed on the second change and was
right to: it pins the LINE of a known dangling include in subtasks.jade so
the exemption cannot quietly cover a new one, and removing the <h3> moved
that line from 8 to 7. The record is updated rather than loosened, with a
note saying why it moves.

Thanks to xet7 !
2026-08-14 02:46:52 +03:00
Lauri Ojansivu d111e8633e Export board popup: HTML sits beside PDF and Excel.
HTML was its own single-entry list under the Dependencies heading, which put
it in a group it has nothing to do with - a dependency graph is a picture of
what links to what, and HTML is the board itself as a page.

It is on the first row now, beside PDF and Excel. Those three are the ones
that render a board as a document to LOOK at; everything under the headings
below is data to reimport somewhere.

The order test walks the whole menu, so it moved with it.

Thanks to xet7 !
2026-08-14 02:38:35 +03:00
Lauri Ojansivu a5a757c686 Export board popup: a rule above each subheading.
The three groups - Dependencies, CSV, JSON - were named but not separated,
so a heading floated between the list above it and the list below it with
nothing to say which one it belonged to.

An <hr> above each, which is the same thing the card sections do: the
heading names the family, the rule says where the family before it ended.
The rule is tighter than the default margin so the heading stays visually
attached to its own list rather than sitting in the middle of two gaps.

The test counts them - one rule per subheading, no stray separators - since
a menu that grows a fourth group is exactly where an extra or a missing one
appears.

Thanks to xet7 !
2026-08-14 02:36:38 +03:00
Lauri Ojansivu 0174851e64 CHANGELOG: the export menu is two panes, not one column of them.
Thanks to xet7 !
2026-08-14 02:30:57 +03:00
Lauri Ojansivu c1fe65d3ec Export board popup: two panes - what to include, and what to export to.
The menu was one column: the parts checkboxes, then twenty-odd formats under
three subheadings, and a scrollbar past most of it even on a wide screen.

With room it is now two panes side by side - the selection on the left, the
formats on the right - each filling its own width with as many columns as
fit, so the whole menu is visible at once. Width goes from 760 to 1100.

The panes are grid COLUMNS, and that is what makes the right-to-left case
free: a mirrored page puts the first column on the right, so the selection
lands on the right and the formats on the left with no second rule to write
or forget. The divider between them is a border-inline-start and a
padding-inline-start for the same reason, and a negative test checks that
nothing physical - border-left, float: right, a [dir=rtl] branch - crept in
to be mirrored by hand.

Below 800px the panes stack: popup.css already lays every popup out as a
full-screen sheet there, which is what a phone should get.

The 1100 is mirrored in client/lib/popupOffset.js, which places a popup
using its width - left at the default 380, a 1100px menu opened near the
right edge lands most of the way off the screen. Both copies name the other.

The template is rewritten rather than patched: an indentation edit had put
the anchors as siblings of their <li> instead of children, which the jade
compiler accepts and a browser renders as a flat list of links.

Thanks to xet7 !
2026-08-14 02:30:34 +03:00
Lauri Ojansivu 6adf607a0a CHANGELOG: point the export menu width and selection entry at its commit.
Thanks to xet7 !
2026-08-14 02:22:48 +03:00
Lauri Ojansivu bb6f18c469 Export board: the selection reaches every format, and the menu opens wide.
Two halves of the same menu.

THE SELECTION REACHES ALL OF IT. PDF, Excel, JSON and .zip took the parts
checkboxes; CSV, TSV, Kanboard and the eleven JSON dialects built their own
addresses and could not carry them, so ticking "no comments" changed four
downloads out of nineteen. Every board export link goes through the one url
helper now, so ?fields= rides along with all of them.

What a format can DROP depends on what it has, and that is the honest part:

- a CSV has no comments to leave out; what it has is COLUMNS, so the
  selection lands there. Unticking People removes five, Dates removes eight,
  Custom Fields removes the trailing block. One mask filters the header and
  every row, built from the untranslated column keys, so the two cannot
  drift apart;
- a Trello, Jira or GitHub export carries a title, a description, a due date
  and labels. Those three parts are gated and nothing pretends to gate what
  is not there - a negative test checks that gateItem does not mention
  comments, checklists or attachments.

AND THE MENU IS A MENU AGAIN. Twenty-odd entries under three subheadings
were one column to scroll. With room it is laid out in columns like the
language list - width: min(90vw, 760px) and repeat(auto-fill, minmax(210px,
1fr)) - so the whole thing is visible at once. Below 800px nothing changes:
popup.css already makes every popup a full-screen sheet there. The 760 is
mirrored in client/lib/popupOffset.js, which places a popup using its width;
left at the default 380, a wide menu opened near the right edge lands half
off the screen. Both copies say where the other one is.

Thanks to xet7 !
2026-08-14 02:22:48 +03:00
Lauri Ojansivu a4175a79bd CHANGELOG: rewrap the Upcoming section at 80 columns.
Thanks to xet7 !
2026-08-14 02:11:53 +03:00
Lauri Ojansivu c3a9072785 CHANGELOG: point the bundle startup fix at its commit.
Thanks to xet7 !
2026-08-14 02:11:10 +03:00
Lauri Ojansivu b77299c806 Fix the client bundle dying at startup: a component must load its own
template.

Reported from testi.wekan.fi, on the SIGN-IN page:

  Uncaught TypeError: can't access property "helpers",
    Template.exportScopeBody is undefined
  [UserAccounts] Warning no template passwordInput found!

`Template.exportScopeBody.helpers(...)` runs at module scope and throws when
the template is not defined yet. That does not break one popup: it stops the
module evaluating, so every template registered after it never registers -
which is why a sign-in form nobody had touched came up blank.

The central lists in client/features/*.js import a component's .jade before
its .js, and that is enough for a component nobody else imports. The export
popup body is imported by sidebar.js, by cardDetails.js and by the import
page, so whichever of those the graph reaches first evaluates it, long
before the feature list gets to the template. It imports its own .jade now.
The module cache makes the second import free, and the order stops being a
hope.

tests/clientBundleImports.test.cjs gains the rule - a component .js that
other components import, that registers on a template of the same basename,
must import that template - and it found a second file already in that
state: migrationProgress.js, imported by boardBody.js. Fixed the same way
rather than excepted, since it is one line and the failure it prevents takes
the whole client with it.

Thanks to xet7 !
2026-08-14 02:11:10 +03:00
Lauri Ojansivu d53aa8a832 CHANGELOG: point the export menu grouping entry at its commit.
Thanks to xet7 !
2026-08-14 02:03:02 +03:00
Lauri Ojansivu 8d716de7cc Export board popup: grouped under subheadings instead of one flat list.
Nineteen entries in one list, each spelling out its whole family: "Export /
CSV (,)", "Export / CSV (;)", "Export / TSV", then eleven lines beginning
"Export / JSON /". The part that differed - the only part worth reading -
started two thirds of the way along every line.

A subheading names the family once and the entries under it say only what
they are:

  PDF
  Excel
  Dependencies:  JSON, SVG
  HTML
  CSV:           (,), (;), TSV
  JSON:          JSON, JSON (without attachments), .zip, Kanboard, Trello,
                 Jira, NextCloud Deck, OpenProject, GitHub, GitLab, Gitea,
                 Forgejo, Asana, Zenkit

The .zip is in the JSON group because it is that document in another
container - the same bytes with the attachments beside them as files.

The parts checkboxes stay at the top, from the shared popup body: the board
passes hideFormats, because it lays the formats out itself, and borrows the
selection rather than growing a second copy of it. Its links come from the
same exportUrlFor helper every other menu uses, so a menu cannot send a
different selection than the one on the screen - which is the failure this
kind of regrouping invites.

tests/exportBoardPopupOrder.test.cjs walks the whole list in order, checks
the three subheadings, and has a negative test that every format offered
before is still offered - a reordering that loses an entry looks like a
tidy-up.

Thanks to xet7 !
2026-08-14 02:03:02 +03:00
Lauri Ojansivu 59da349fa2 CHANGELOG: point the card section collapse entry at its commit.
Thanks to xet7 !
2026-08-14 01:58:58 +03:00
Lauri Ojansivu 384f03b359 Card details: each section collapses from its own caret, one rule between
sections.

A card had ONE caret, in its header, collapsing the whole card - and
Activities had an eye beside its heading. Every other section was a heading
with no control, and the rules between sections were written by hand
wherever somebody remembered one.

Labels, Date Format, Members, Dependencies, Sort, Custom Fields,
Description, Checklists, Subtasks, Attachments, Comments and Activities each
carry a caret that collapses that section and one rule above it, both drawn
by one cardSectionHeader template - so eleven sections cannot end up with
ten separators and nine carets. The heading announces itself as a button and
answers Enter and Space, rather than being a click target only a mouse can
find. The caret points down when open and toward the reading direction's end
when closed: right in English, left in Arabic, Hebrew and Persian, read from
the language rather than guessed.

Activities starts collapsed, everything else open. An unknown section
defaults to OPEN, so a section added later shows up rather than hiding.

THE EYE WAS NOT THE SAME CONTROL AS THE CARET, and removing it needed care.
`showActivities: false` - its default - subscribes to activityType
'addComment' ONLY (server/publications/activities.js), so it chose between
"comments" and "the whole history" as much as it showed and hid. Deleting it
and nothing else would have quietly turned an opened Activities section into
a comments-only list: a feature lost by accident rather than by decision. So
an open card section asks for the whole history, and a closed one subscribes
to nothing at all - the cheaper half of what the flag was for, without a
second control to keep in step. A card with the flag explicitly false keeps
it.

Custom Fields is one section with one caret however many fields a card has;
its header was briefly inside the loop that draws them, which would have
been six sections sharing a caret on a card with six fields. Stickers,
Location, the four dates, Creator, Assignees and Spent time were not in the
list and keep the headings they had.

tests/cardSectionCollapse.test.cjs pins the twelve sections, the shared
header, the RTL caret, the keyboard, and that the history did not silently
become comments-only. tests/jadeCompiles.test.cjs - the suite that runs the
real compiler - is what made a restructure of this size checkable at all.

Thanks to xet7 !
2026-08-14 01:58:58 +03:00
Lauri Ojansivu f7eae43087 CHANGELOG: point the dependency wrap entry at its commit.
Thanks to xet7 !
2026-08-14 01:47:45 +03:00
Lauri Ojansivu 0c7b3e8df3 Card details: a dependency's title wraps instead of being cut off.
In an opened card's Dependencies section each row is an icon, the linked
card's title and its controls, and the title was `white-space: nowrap` with
an ellipsis. The card details pane is narrow and a card title is a sentence,
so most of a real title - everything right of the coloured icon - could not
be read at all.

It wraps now. Two properties, and the second is the one that is easy to
miss: `white-space: normal` alone would have changed nothing, because a flex
item's default `min-width: auto` refuses to shrink below its content, so the
text overflowed the row rather than wrapping inside it. `min-width: 0` is
what lets it wrap, and there is a negative test saying so, because removing
it would look harmless.

A title that is one long token - a URL, an id - breaks inside itself with
`overflow-wrap: anywhere` rather than pushing the type, colour and remove
controls off the row. The row aligns to flex-start now, so the icon and the
controls sit beside the FIRST line instead of floating halfway down a
three-line block.

Thanks to xet7 !
2026-08-14 01:47:45 +03:00
Lauri Ojansivu 93e1fcf821 CHANGELOG: point the streaming zip import entry at its commit.
Thanks to xet7 !
2026-08-14 01:45:59 +03:00
Lauri Ojansivu fe77b7461e Stream a .zip import through an upload endpoint instead of the browser.
Reading an archive in the browser and sending its attachments as base64 over
DDP is fine for a card and wrong for a board: 2 GB of attachments become 2.7
GB in one message, in the browser's memory and then in the server's.

A .zip now goes to POST /api/import/zip as the file itself, and nothing is
ever whole in memory on the way in:

- the request body is streamed to a temp file as it arrives;
- unzipper.Open.file reads the archive's central directory, so entries are
  opened on demand rather than inflated together - what the backup restore
  already does;
- each attachment is piped from the archive into the collection by
  addAttachmentFromStream, which writes it to a temp file and hands
  Meteor-Files the PATH rather than a Buffer.

That helper is lifted out of copyFile in models/lib/fileStoreStrategy.js,
which already streamed an attachment to a temp path and called addFile - so
there is one way to add an attachment from a stream rather than two.

Where the files end up is not decided here: addFile fires the collection's
onAfterUpload, which validates the file and moves it to the default storage
configured in the Admin Panel, exactly as an ordinary upload does.

The cap is checked as the body ARRIVES rather than after
(WEKAN_IMPORT_ZIP_MAX_BYTES, 5 GB by default), so an oversized archive never
lands, and the temp file is removed in a finally whatever happens. Importing
is a write, so the route asks for board membership, and the Admin Panel's
import switch still applies.

An entry name is data, never a path: only the attachment id before the first
dash is read from it, the temp file is named by WeKan, and the path goes
through the same safeEntryPath containment check the backup restore uses.
tests/fixedVulnerabilityClasses.test.cjs caught this file the moment it read
an archive and built a path - the guard was right to ask, and asking it for
proof is cheaper than arguing that the name was ours.

A .json still travels as a document over DDP, which is what it is.

Thanks to xet7 !
2026-08-14 01:45:59 +03:00
Lauri Ojansivu 209df66804 CHANGELOG: point the card title X and attachment import entries at their commit.
Thanks to xet7 !
2026-08-14 01:35:07 +03:00
Lauri Ojansivu bada25de4d Editing a card title keeps its X, and imported cards bring their
attachments.

Two things, one commit each would have been two lines apart in the same
files.

THE X. Clicking a card's title opens the title editor in place of the card
header - and the header is where the card's own close X lives, so the X
disappeared the moment you started editing and Save became the only visible
way out. Escape still worked; nothing on the screen said so.

The editor draws the X itself now, reusing .close-card-details, the header
button's own class, so it is the same size in the same place and does not
jump as the editor opens and closes. That class floats to inline-end - the
right in English, the left in Arabic - so the RTL mirroring needs no
direction branch, and a negative test says it must not grow one. It is an
<a> and not a <button>, because a button inside the form would submit it,
and the click reaches the shared inlinedForm handler by bubbling.

The same one-line omission was in the Requested by and Assigned by editors
below it: the close anchor was there and its icon was not, so the click
target existed and was invisible. Ten more of those exist in other files and
are left for their own change.

THE ATTACHMENTS. The scoped import was half a round trip: a card imported
from a swimlane, list or card menu arrived with its checklists and comments
and without its files.

A .json export carries each attachment as base64, and ScopedImporter writes
them the way the board import always has - the server-side Meteor-Files
writeAsync, one at a time. A .zip carries them as files named
attachments/<id>-<name>; the archive is unpacked in the browser and each
file is put back on the metadata row its id names, so the server sees the
same document either way and there is ONE import path rather than one per
container. The whole-board import on the import page now takes a .json or a
.zip through that same reader, which is the case the .zip exists for.

An attachment lands where its CARD landed - the list and swimlane it is in
now, not the ones it was exported from. One unreadable file is warned about
and skipped rather than losing the rest of the import, an empty one is not
written as a zero-byte attachment, and a `url` attachment from an older
export still goes through the downloader that validates and pins every hop,
so FollowBleed stays fixed on this path too.

The tradeoff worth knowing: the archive is unpacked in the browser and sent
as base64, so a .zip full of large attachments costs memory on the way in.
It is the shape the board import has always used, and it is written down
where the unpacking happens.

Thanks to xet7 !
2026-08-14 01:35:06 +03:00
Lauri Ojansivu c6cad5bb4a CHANGELOG: point the import page entry at its commit.
Thanks to xet7 !
2026-08-14 01:25:26 +03:00
Lauri Ojansivu f0745dec4a One import page, for every source, with the same checkboxes the exports use.
The last piece of #1173: "combine all import options to same template". They
were fourteen - a link per source in a pop-over, each going to its own
address - and the page each landed on never said which other sources
existed, so "where do I import a Jira export" was answered by a menu
somewhere else, if you knew it was there.

All Boards / + Add Board / Import is now one full-width page listing every
source it can read: Trello, Jira, CSV/TSV, Excel, Kanboard, NextCloud Deck,
OpenProject, GitHub, GitLab, Gitea, Forgejo, Asana, Zenkit, and a previous
export of this WeKan - named with the Product name this instance is branded
with rather than with a product the reader has never seen.

Under the source picker are the same "what to include" checkboxes every
export offers, from the same list (models/lib/exportFields.js) and sharing
the very same selection state, so a part unticked for an export is unticked
here too. On this side they say what comes IN.

That works for every source without teaching five creators a selection each:
models/lib/importParts.js takes the unticked parts OUT of the parsed
document before any creator sees it, at the one point every source passes
through. A creator that never sees a comment cannot import one. Three things
that pruning gets right and are covered by tests:

- an unselected section is EMPTIED, not deleted - the creators read
  board.comments directly, and undefined is a crash where an empty array is
  "there were none";
- a source's own name for a part goes with it, since Trello calls its
  comments "actions";
- a key the list does not know about is left alone, rather than silently
  dropping data nobody asked about.

/import/:source still works, under its own route name, so every existing
link, bookmark and back button lands where it did. The pop-over keeps the one
import that is not a board: card dependencies.

Thanks to xet7 !
2026-08-14 01:25:26 +03:00
Lauri Ojansivu 6ce8047fa4 Search: #12 and 12 find the label 12 and the card numbered 12.
Follows the card-number operator. #5006's title says "number/#", and the
form people actually type is the one on the card: #12, or just 12 in the
search box. Both now search for BOTH - a label called 12, and the card whose
number is 12.

Which of the two somebody means cannot be read off the text: a board calls a
card "#12", and a label can be called anything, including "12". Answering
with both is the only reading that never hides what they were looking for.

The two halves are joined with OR, and that is the property that makes this
safe rather than destructive. A search that used to find a label called
"2024" still finds it; the card numbered 2024 is added to the answer instead
of replacing it. An AND would have found nothing at all in almost every case
and would have looked like the feature working.

Only a term that is entirely digits gets the second half, so #red is the
label red exactly as before, and a label-only search takes the code path it
always took. A bare 12 adds the card number as one more alternative in the
$or that already holds title, description, custom fields, checklists,
attachments and comments - so nothing that matched before stops matching.

#12 on a board with no label called 12 no longer reports "label not found":
the card-number half is a real answer, and a not-found message beside the
card it just found contradicts the screen.

The guard that pinned the opposite decision moved rather than being deleted.
It now pins the union, the OR, that only numbers get it, that #red is
untouched, and that the not-found error still fires for a word.

Thanks to mfilser and xet7 !
2026-08-14 01:20:44 +03:00
Lauri Ojansivu 3020d5e6b9 CHANGELOG: point the All Boards tile height entry at its commit.
Thanks to xet7 !
2026-08-14 01:10:04 +03:00
Lauri Ojansivu 6c59196802 All Boards: a starred page is the same height as a board and as "+ Add
Board".

In All Boards / Starred a bookmark tile stood 8px taller than the board tile
beside it and than the grey "+ Add Board" box, and pulled its whole row up
with it.

Two of the four tiles in that grid carry `border: 4px solid #fff` - a
bookmark and the Template Container - to say they are not an ordinary board.
The tile's height was a 72px floor with 42px of padding added outside it,
which comes to the same 114px for a plain tile and 122px for a bordered one:
on a content-box element the border is added to the height rather than taken
out of it. Every height in the stylesheet said 72px, so nothing there looked
wrong.

The tile now states the height it actually renders at - 114px, padding and
border folded in with box-sizing: border-box - which is what the mobile
rules further down the same file already do, for the same reason (#6523).
Nothing moves except the two bordered tiles, which lose the 8px they were
never meant to have.

The guard compared the declared min-height and padding of two tiles that
were both content-box, so it could not see a border at all - which is how
this went unnoticed while a test watched it. It computes RENDERED heights
now, borders included, checks all four tile kinds, and has a negative test
against a tile rule opting back into content-box.

Thanks to xet7 !
2026-08-14 01:10:04 +03:00
Lauri Ojansivu 7b1cefe4e9 CHANGELOG: point the card number search entry at its commit.
Thanks to xet7 !
2026-08-14 01:06:35 +03:00
Lauri Ojansivu 530ef32c16 Search cards by their card number.
#5006, open since 2023: "A customer has just hinted us that we are not able
to search cards by its number (#)". A card carries a number, the board shows
it, people quote it to each other - and the one thing that could not be done
with it was find the card again.

`number:12` now does, in the global search and everywhere else that parses
the same query. It is an EQUALITY match on a number rather than a regular
expression on text, which is the difference between finding card 12 and
finding 12, 120 and 312. A predicate that does not parse is dropped before
it reaches the selector, and an empty list adds no clause at all rather than
an impossible one.

`number:abc` is refused with the same "expected a number" error `limit:`
already gives. A string never equals a numeric field, so the alternative is
a search that silently finds nothing, which teaches nobody anything.

The issue's title says "number/#", and `#12` is deliberately not this. `#`
is already the abbreviation for a LABEL search, and reinterpreting it when
the text after it happens to be digits would break searching for a label
called "2024" - a real label name - and would make what a query means depend
on the data rather than on what was typed. The operator is the part that can
be added without taking something else away; tests/searchCardNumber.test.cjs
pins that # still means a label.

Thanks to mfilser and xet7 !

Fixes #5006,
2026-08-14 01:06:35 +03:00
Lauri Ojansivu 066f92fe2b CHANGELOG: point the scoped import entry at its commit.
Thanks to xet7 !
2026-08-14 01:03:08 +03:00
Lauri Ojansivu d11f5ffdb7 Import into a swimlane, a list or a card, beside the one whose menu was
used.

Continues #1173, the other direction of the same popup. Importing used to
mean one thing: a whole new board, from the full-width page at All Boards. A
swimlane's menu now imports a swimlane below that swimlane, a list's menu
imports a list after it, and a card's menu imports a card below it, into the
board that is already open.

"After" is to the RIGHT in English and to the LEFT in Arabic, and that is one
rule rather than two. The page carries dir (client/components/main/
layouts.jade), so the board's row of lists mirrors itself and "after in sort
order" is already "the other side"; a direction branch in the placement
would mirror it twice and put the list back where it started. The reason is
written in models/lib/insertPosition.js, next to a negative test, because it
looks like a missing branch.

Placement is a fraction between the target's sort and the next one's, not a
renumbering of every sibling - renumbering is what a board with ten thousand
cards cannot afford and what two clients doing it at once get wrong. Several
imported items spread evenly through that gap, so a swimlane of ten lists
does not arrive with nine of them in the same position. The arithmetic is a
pure module and is tested as arithmetic, junk input included.

The file is what the export writes, at any scope, and the SAME checkboxes
decide what comes in: a document full of comments imported with comments
unticked brings the cards and leaves the comments. A .zip is unpacked in the
browser and its wekan.json handed to the same method, so there is one import
path and not two.

Nothing is merged. Everything created is new, with new ids, because an
import that half-updated a board would be an edit nobody could undo -
importing the same file twice gives two copies, which is at least a thing
you can delete. A custom field is matched by NAME, since an id from another
board matches nothing here, and a comment comes back under the importing
user, because the original author may not exist on this server.

Importing WRITES, so unlike the exports beside it in the same popup it asks
isBoardMember() rather than isVisibleBy(), the Admin Panel's import switch
still applies, the format is checked, and the deadline that bounds a board
import bounds this one. A read-only member is not offered it at all.

tests/scopedImport.test.cjs covers the placement arithmetic and pins the
selection, the write check and the single import path.

Thanks to xet7 !
2026-08-14 01:03:08 +03:00
Lauri Ojansivu e38750c54c CHANGELOG: point the JSON and .zip export entry at its commit.
Thanks to xet7 !
2026-08-14 00:52:48 +03:00
Lauri Ojansivu b0ca226099 JSON and .zip export at every menu, streamed, from the one export popup.
Continues #1173. The board's Export menu had JSON; nothing else did, and
there was no .zip anywhere. Both are now offered wherever an export is - the
board, a swimlane, a list, a card and a checklist - from the same popup,
with the same checkboxes deciding what goes in.

They are the same export in two shapes rather than two exports. The document
is written by models/exporter.js' streaming writer either way, so a .zip's
wekan.json and a .json download of the same scope are the same bytes. What
differs is where the attachments are: base64 inside the document for JSON,
or beside it under attachments/ as the files they are for the .zip. The
"without attachments" option is now offered at every menu instead of only on
the board's.

Both halves stream. The JSON writer already wrote a document at a time from
raw cursors with backpressure; ExporterZip gives it a PassThrough that
archiver compresses as it fills, and pipes every attachment from the file
store rather than reading it into a Buffer. A board with a gigabyte of
attachments costs a gigabyte of disk reads and not a gigabyte of RAM, which
is what makes the .zip the shape to use when the JSON is too large to hold
as one string.

A scoped export is the same wekan-board-1.0.0 document with fewer rows in
it, plus the lists and swimlanes its cards refer to, so what comes out can
be imported back into somewhere. A section the popup did not tick is an
empty array rather than a missing key, for the same reason. A checklist
scope exports the card that holds it, because a checklist alone has nowhere
to land, and a swimlane export carries no board-wide rules.

Less duplication, which is the other half of this:

- every export route - JSON, .zip, PDF, Excel - reads the scope through one
  parseExportScope() and the selection through one parseExportFields(),
  instead of each picking ids out of the query itself;
- the CARD export popup IS the shared body now, with the card as its scope.
  It was its own template with its own field list, its own ReactiveDict and
  its own url builder, which is how its checkboxes came to drive the Excel
  download and not the PDF;
- one url builder serves every format and every scope, and knows that a card
  has export routes of its own so there are not two ways to ask for one
  card's PDF.

tests/boardExportScope.test.cjs gains the JSON/.zip shape, the scoped
document and the single scope parser. Two guards moved rather than being
deleted: they pinned the routes parsing their own ids and the card popup
holding its own copy of the field list.

Thanks to xet7 !
2026-08-14 00:52:48 +03:00
Lauri Ojansivu 8a278937ab CHANGELOG: point the board export entries at the commit that made them.
Thanks to xet7 !
2026-08-14 00:38:55 +03:00
Lauri Ojansivu bd009cf601 Export a board, a swimlane or a list, in the card export's own layout,
with a popup for what to include.

#1173 "Add Feature: Print Board with Params" has been open since 2017, and
two things were missing.

There was no CHOICE of what to print. The board's Excel and PDF exports took
everything they knew how to render and nothing else, while the card export
already had a popup with a checkbox per section. And they did not LOOK like
the card export: the board's Excel export was a spreadsheet table, one row
per card and eighteen columns, which is a data dump rather than a printed
board.

Both now render a board as its own header followed by every card as the CARD
export's block, drawn by the card export's own code - cardBlockLines() for
the PDF, ExporterExcelCard.renderCardBlock() for the Excel one. A card looks
the same whether it was exported alone or inside its board, and the two
cannot drift into two layouts again. Each card starts on its own page in the
Excel export, because a printed board is read a card at a time.

The swimlane and list menus offer the same export, which is the board export
with one query parameter saying which cards are in scope. They sit beside
"Copy link" rather than under the permission checks, because exporting is
reading, and the file is named for what was exported - a PDF titled with the
board that holds one list is a file nobody can place afterwards.

The selection is one popup body used by all three menus, and one list:
models/lib/exportFields.js, imported by the exporters and by every popup.
The card export's list used to live twice, in the server and in
cardDetails.js under a comment reading "Must match ALL_FIELDS ..." - which
is a comment, not a mechanism, and a section added on one side is either a
checkbox that does nothing or a section nobody can turn off. The same
?fields= now gates the same sections in both formats; the card popup's
checkboxes used to say "fields to include in Excel export" and did nothing
to the PDF.

A card block cannot be streamed - it merges cells, styles them and returns
to earlier rows - and models/server/ExporterExcel.js streams on purpose,
after the in-memory version ate gigabytes on boards with thousands of cards.
That exporter is still there and still reachable: unticking "Card details"
asks for it. A checkbox, not a silent fallback.

A board's per-card data is read once per collection and grouped by card. The
card export's own per-card queries would be fifteen hundred round trips on a
board of three hundred cards.

tests/boardExportScope.test.cjs pins the shared block, the shared list, the
gating, the three menus and the streaming exporter still being reachable.
Two guards moved rather than being deleted: they pinned the board PDF
building its own labels and the server's own copy of the field list, which
is the behaviour that deliberately changed.

Thanks to xet7 !

Fixes #1173,
2026-08-14 00:38:55 +03:00
Lauri Ojansivu 2c1027e060 CHANGELOG: point the card-export entries at the commit that made them.
Thanks to xet7 !
2026-08-14 00:09:37 +03:00
Lauri Ojansivu 698e980e27 Card PDF and Excel exports say the same thing, in the reader's language,
format and time zone.

#6586 was reopened for what the first fix left, and every point of it was a
symptom of one thing: the PDF export of a card had grown separately from the
Excel export of the same card.

"Assignee, Labels, due,... these titels should be in the user set language" -
the labels were hard-coded English, and not even consistent with each other:
the card export wrote "Due: " while the board export wrote "due" with no
colon. Both exports now build every label from the same i18n keys, in the
language the request carries, through one field() helper that writes every
"Label: value" - so the two cannot drift apart again. Every key carries its
English text as the fallback, so a missing translation shows the word and
never the key.

"I think all those other things we set in a card should be also present" -
both exports now carry title, labels, creator, assignees, members, board,
swimlane, list, card number, requested by, assigned by, the six dates, spent
time, description, custom fields, checklists, subtasks, comments,
attachments, voting and Planning Poker, in that order. Custom fields, voting
and poker are new on BOTH sides; they are appended to ALL_FIELDS rather than
inserted, so a saved ?fields= link still asks for what it always asked for,
and they are checkboxes in the export popup like the rest.

"the time is not in the user set timezone (-2h wrong for Europe/Berlin)" -
it was worse than -2h: the PDF printed UTC and the Excel export printed
whatever zone the server was started in. Dates are stored in UTC and a
profile carries no zone, so the export links now send the browser's IANA
zone and the date format the OPENED CARD is showing - which for a logged-out
reader lives in localStorage, where no server-side lookup reaches it. Both
exports format through formatDateByUserPreference, which gained an optional
zone; without one it still renders in the process zone, which is what every
client-side caller wants. A server-built export given no zone renders UTC
and says UTC. Verified: 12:00Z prints 14:00 for Europe/Berlin, 00:00 on the
15th for Pacific/Auckland, and an unknown zone falls back instead of
failing.

"would it make sense to support markdown formated text in description" - a
description is now cut into runs and drawn in the four base-14 Courier
faces, so **bold**, *italic* and ***both*** are bold, italic and both.
Consecutive Tj operators continue at the current text position, so a font
switch needs no glyph measuring. Strikethrough and inline code keep their
words and lose their markers - a Type1 base-14 font has no strike and there
is no fifth face for code - and an underscore inside a word stays an
underscore.

tests/cardExportParity.test.cjs pins the shared field list, keys, formatter
and request params; tests/pdfExport.test.cjs gains the runs, the wrapping
and the four declared faces. Two of its guards moved rather than being
deleted: they pinned the hard-coded English "Labels: " and "due", which is
the behaviour that deliberately changed, and they now pin that both come
from the same keys.

Thanks to Heart1010 and xet7 !

Fixes #6586,
2026-08-14 00:09:31 +03:00
Lauri Ojansivu 490686a3f1 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-08-13 23:51:23 +03:00
Lauri Ojansivu 935f59a2e9 CHANGELOG: point the new entries at the commit that made them.
Thanks to xet7 !
2026-08-13 23:51:23 +03:00
Lauri Ojansivu 6157094ab8 Stop shipping npm and node-gyp, and raise the bundle's own npm packages.
A container scan of ghcr.io/wekan/wekan:v10.91 reported 80 findings against
"Node.js", three of them CRITICAL, and not one of them was in code WeKan
runs.

Three CRITICAL and most of the HIGH findings were three copies of one
package - tar 6.2.1 twice, tar 7.5.11 once - carried by two BUILD tools
left in the finished image:

- node-gyp and @mapbox/node-pre-gyp are meteor-dev-bundle dependencies that
  compile native modules during the npm install in programs/server. WeKan
  compiles nothing at run time; uWebSockets.js, bcrypt and argon2 all ship
  prebuilt .node files that node-gyp-build picks at require time. Their tree
  is 83 of the 120 packages that install leaves behind, and it brought tar
  6.2.1 (node-gyp, cacache), brace-expansion, minimatch and npm's networking
  stack with it.
- npm itself runs exactly once, for that install. The container starts bash
  /build/wekan-entrypoint.sh and never calls it, so its bundled tar 7.5.11,
  sigstore 4.1.0, @sigstore/*, ip-address 10.1.0 and brace-expansion 5.0.4
  were image content no code path could reach.

releases/prune-build-only-modules.mjs removes the first. It is a
REACHABILITY walk, not a list of 83 names - start from every dependency of
programs/server/package.json except those two, follow each package's own
dependencies, keep the closure - so it does not go stale when Meteor
changes its dev-bundle. The Dockerfile removes npm and npx in cleanup;
node stays. Verified by booting the pruned bundle: identical failure at the
database, no missing module.

The rest of the report was the npm packages Meteor's own packages bundle -
nodemailer 8.0.3, openpgp 5.11.1, svgo 2.8.2, postcss 8.5.1, nanoid 3.3.15,
qs 6.13.0, cookie 0.4.1, on-headers 1.0.2, tmp 0.2.3, lodash 4.17.21, diff
3.5.0, body-parser 1.20.3, @babel/runtime 7.20.7, underscore 1.13.7.
Npm.depends names an exact version and meteor build copies it into
programs/server/npm/node_modules/meteor/<package>/node_modules, where no
package.json or overrides entry here is consulted - so the bundle is the
only place they can be raised.
releases/bump-bundle-npm-deps.mjs does that from the minimums in
releases/bundle-npm-security-bumps.json, with --ignore-scripts so no
prebuilt native is rebuilt, replacing only copies below the minimum.

A minimum stays inside the major the package was built against, and that
was learned rather than assumed: uuid's lowest fixed release, 11.1.1, moved
its entry point to dist/cjs/index.js and the bundle - which recorded
dist/index.js at build time - died on boot with "Cannot find module
.../uuid/dist/index.js". It is in notFixable with that error, beside
lodash.template, which has no fixed release at all.

Both run in every place a bundle is made: the amd64 build, the arm64
container, three Windows legs, two macOS legs, install-node-for-arch.sh for
the emulated arches, and the Dockerfile - each reinstalls programs/server,
which is also why each bumps again (meteor-dev-bundle pins underscore
1.13.7, CVE-2026-27601, over the bumped copy).

tests/imageBuildOnlyModules.test.cjs and
tests/bundleNpmSecurityBumps.test.cjs pin both, and buildScriptParity's
skip list records why these two scripts are not menu entries. The debian
13.6 target's 176 findings are all "Fixed in: -" and no upgrade closes
them; the build/<tool> and build/ferretdb targets are fixed in
wekan/mongo-tools-patches and wekan/FerretDB.

Thanks to xet7 !
2026-08-13 23:51:23 +03:00
github-actions[bot] cb9a2c5a6f Bump versions for v10.91 2026-08-13 17:46:11 +00:00
Lauri Ojansivu a19629e858 Prepare v10.91 release 2026-08-13 20:44:56 +03:00
Lauri Ojansivu 7d73845192 CHANGELOG: the Member Settings theming goes under Upcoming.
Thanks to xet7 !
2026-08-13 20:10:26 +03:00
Lauri Ojansivu e86e7abe05 Member Settings: "All Boards" beside Default, and popup rows highlighted like the menu.
Two things in Member Settings / Change color.

ALL BOARDS. Beside "Default (no override)", on the same row, a second switch:
All Boards. Turned on, every tile in the All Boards overview takes the THEME's
lighter colour - a white veil over --theme-accent, which is one shade up from
whatever the theme is at any theme, with no second variable to keep in step and
no color-mix() - with white text on it, instead of the colour its own board was
given. Turned off, which is the default, the overview is exactly what it was.

That is #6593's observation, as a per-user choice rather than a stylesheet. The
pull request makes every tile white for everybody, and the observation behind it
is right: a wall of boards in eleven colours reads as a palette rather than a
list. The conclusion is not, because "the tile colours are noise" and "the tile
colours are how I find my board" are both true, of different people - so it goes
where the rest of that user's theme is chosen, and it is off unless asked for.

A board with a background IMAGE keeps its image: that is a picture somebody
chose, not a colour, and painting over it would be losing something rather than
calming it down.

It is profile.allBoardsThemeTiles (optional boolean, absent = off), flipped by
toggleAllBoardsThemeTiles, which writes the CALLER and refuses an anonymous one,
and it reaches the page as has-theme-board-tiles on <body>, so it follows the
user to every page that shows board tiles. Only the user's own scope of the
shared picker has the switch: a board has no overview of its own, and a site
admin does not choose this for everybody. The label is the existing `all-boards`
string, so no new key needs 135 translations to say what one already says.

THE HIGHLIGHT. The row under the pointer in a popup was filled with a fixed dark
navy (#005377) that appears nowhere else in WeKan, and stayed navy however the
user had themed it. The All Boards left menu fills its selected row with
var(--theme-accent, #2980b9) and turns the label and the icon white, and so does
the Admin Panel; the popup does now too, so there is one selected-thing look
rather than three. The colour needs !important because the base rule pins every
row's text to #000 !important, and the icon is named separately for the same
reason.

tests/allBoardsThemeTiles.test.cjs pins both, with the negatives that matter:
only the user scope has the switch, an image tile is left alone, every new rule
is under the body class so the overview is untouched with it off, and the navy
is gone.

Thanks to khuongsatou and xet7 !
2026-08-13 20:09:06 +03:00
Lauri Ojansivu fa0bfb69e9 CHANGELOG: the two Admin Panel report fixes go under Upcoming.
Thanks to xet7 !
2026-08-13 19:44:59 +03:00
Lauri Ojansivu 034a23edef A restart is not a crash, and a reverse proxy is not a spoofer.
Two Admin Panel reports from a server running WeKan 10.90 Snap, both full of
things that never happened.

FILESYSTEM INTEGRITY, over and over:

  availability  DowntimeBleed  high  failed  uptime
    the previous run STOPPED WITHOUT SHUTTING DOWN CLEANLY, and this server was
    down for about 4 minute(s)

on a snap that had been refreshed, not crashed. Two faults, and the second made
the downtime real rather than merely reported:

  1. `IntegrityKeys.update()` is not synchronous in Meteor 3. It starts a write
     and hands back a promise nobody waited for, so the clean-shutdown mark was
     never on disk when the process went, and every stop looked unclean.
  2. Registering ANY listener for SIGTERM replaces Node's default behaviour,
     which is to terminate. Nothing in that listener exited, so WeKan ignored
     SIGTERM outright: systemd/snapd waited out its stop timeout and used
     SIGKILL. That is both the minutes of "downtime" in those rows and a
     genuinely unclean kill of the app on every ordinary restart - and on a snap
     refresh it is minutes of the site being down for no reason.

So the mark is written with updateAsync and the handler exits: with 0, because a
stop that was asked for is a success and a non-zero code makes systemd mark the
service failed every time; after at most two seconds, because a shutdown must
never be held open by the bookkeeping about shutdowns; and exactly once, however
many signals arrive. The classification itself is untouched - the rows were
wrong because the input was wrong, and a "fix" that stopped calling a crash a
crash would hide the next real one.

SECURITY REPORT, over and over:

  spoofing  MetricsBleed  medium  detected  canary:spoof.forwarded-header
    127.0.0.1  denied /metrics with X-Forwarded-For present

"Nothing legitimate sends a forwarded-for header to an endpoint that does not
trust one" is what that canary assumed, and it is wrong: every reverse proxy
adds X-Forwarded-For to everything it forwards. A Prometheus scrape through a
local proxy, on a server whose METRICS_ACCEPTED_IP_ADDRESS does not cover it, is
an ordinary refusal - and it was being written up as a medium-severity spoofing
attempt from 127.0.0.1, again and again, in the report where a real one would
have to be noticed.

The spoof has a signature, so it is asked for: the header NAMES an allowlisted
address while the connection itself is not from one. That is somebody claiming
to be a whitelisted host, and only that trips the canary now. The 401 is
unchanged, and gained the sentence the admin actually needs - the address in it
is the proxy, not the scraper, unless METRICS_TRUST_PROXY says how many hops to
trust.

tests/restartIsNotACrash.test.cjs pins both, including that SIGTERM still
terminates and that the crash classification still calls a crash a crash.

Thanks to xet7 !
2026-08-13 19:44:31 +03:00
Lauri Ojansivu 55552a7203 Snap: the pages shown without a database use the product name and the dashboard's colours.
Two things about the standalone pages the snap serves when WeKan itself cannot
be served - under maintenance, recovering data, database too old, and the new
"waiting for its database" (#6592).

THE NAME. They are a node:http server with no database connection, which is the
whole point of them, so the product name comes from a file: wekan-control caches
it once per start, right after the database comes up. That leaves the case that
matters - an admin sets the product name in the Admin Panel and the snap is not
restarted before the next outage - and a rebranded WeKan then tells its users
"WeKan is waiting for its database", a word they have never seen. WeKan is the
only thing that knows the name the moment it changes, so WeKan writes it:
server/productNameCache.js writes $SNAP_COMMON/.productname.txt at startup and
whenever the setting changes, the same way customHeadRender keeps the <title> in
step. An empty name is not written (the pages fall back to WeKan on their own),
a read-only $SNAP_COMMON is a warning and nothing more, and off snap there is no
SNAP_COMMON and nothing happens at all.

THE COLOURS. The schema-upgrade dashboard (/schema-upgrade-status) and these
pages are the same thing to a reader: the product saying what it is doing while
it cannot show them the app. They now look like it - the dashboard's #111
ground, #ddd text, #7bf blue and monospace face, dark only as the dashboard is,
rather than a light card that reads as a different product one page over.

Thanks to xet7 !
2026-08-13 19:44:15 +03:00
Lauri Ojansivu ffc0b14581 CHANGELOG: the waiting-for-its-database page goes under Upcoming.
Thanks to xet7 !
2026-08-13 19:32:11 +03:00
Lauri Ojansivu 4254dfa396 Snap: a wait for the database is a page, not a timeout.
#6592: "We upgraded from 10.85 to 10.89 and later to 10.90 - a reload of wekan
got a timeout (loading forever). ... Reload with F5 or Ctrl+F5 - opening in a
new private browser tab also doesn't work."

WeKan does not open its web port until the database answers, and the two waits
in wekan-control are endless on purpose: a database can take minutes to come up
after an update, and a snap that gave up on it would be worse than one that
waits. They are not silent either - after two minutes each prints what to check.

But NOTHING was listening on the web port while they waited. A browser gets a
timeout, which is what "loading forever" is, and everything the script printed
went to `snap logs` - the last place somebody whose site is down thinks to look.
It also makes the two possible faults look identical: "WeKan does not load" is
the report either way, whether WeKan is broken or FerretDB simply did not start.

So the wait says so, on the web port. wekan-control serves the existing
maintenance page with a new reason, `waiting-for-database`, which names the
database being waited for and, in the browser, the four commands: `snap logs
wekan.<service>` for the real reason (an `exec format error` is the bundled
binary not running on this CPU), `snap run wekan.problems` for which copy of the
data is being served, `snap start --enable` for a service left stopped by a
failed migration, and `snap revert` back to the revision that worked - with the
answer to the question actually being asked, which is whether the data is gone.
It refreshes itself away when WeKan starts.

Three things make it safe rather than merely nice, and the tests pin each:

  * it appears only after a grace period (WEKAN_DB_WAIT_PAGE_SECONDS, 30s), so
    an ordinary restart - which passes through these loops in seconds - never
    shows it, and proxies are not taught to cache a 503 for a healthy site;
  * it is stopped, and waited for, before anything else binds that port. The
    page is a child process and `exec` keeps children alive, so the two re-execs
    inside the MongoDB loop had to stop it first or the next start would die on
    EADDRINUSE - a worse fault than the one being fixed;
  * it is behind its own reason, so maintenance mode, the recovery bridge and
    the data-too-old stop are untouched.

This does not say WHY the reporter's database did not come up - the issue has no
logs, and that is the next thing to ask for. It does mean the next person sees
the reason instead of a timeout.

tests/snapWaitingForDatabasePage.test.cjs drives the page and reads what a
browser would get. tests/cpuExecWiring.test.cjs counted the maintenance-page
launches and expected two; a count says nothing about the launch that was added,
so it now asks each launch line whether it goes through cpu-exec.

Thanks to Alishara and xet7 !

Refs #6592 — the reporter's own logs are still needed to say what stopped their
database, so this does not close it.
2026-08-13 19:31:47 +03:00
Lauri Ojansivu c9f301ceb0 CHANGELOG: point the status-report entry at its commit.
Thanks to xet7 !
2026-08-13 19:20:11 +03:00
Lauri Ojansivu 4ca131e3b6 CHANGELOG: an Upcoming section for the work since v10.90.
Three entries. Two are new - the minicard that would not close the card it had
opened, and the snap status report that never said which copy of the data it was
reading - and the third was written into the v10.90 section after v10.90 had
already been tagged, so it moves up here where the release it will ship in is.

Thanks to xet7 !
2026-08-13 19:20:05 +03:00
Lauri Ojansivu 504e450a11 Snap: the status report says which copy of the data is being served.
#6583, comment 5282677837:

  "sudo snap run wekan.database-compare  brings me to:
   error: cannot find app "database-compare" in "wekan"
   It seems, that database compare is not included in wekan 10.82, we have
   installed. Should I upgrade to the most recent version and then try again?"

Yes - the two recovery commands are snap APPS, so they exist only in the
revision that ships them (v10.90). That is the smaller half of this. The larger
half is what happened before it: the same instance, serving data from a
migration done in July, was told "No problems detected" by
`snap run wekan.problems`, and the admin had to find the command names in a
GitHub thread.

That report was not wrong. It reads ONE database - the one WeKan is connected
to - and every check inside it passed. The question that mattered, WHICH of the
two copies that is, was asked by nothing, while the docs already claimed this
command answered it.

So the report ends with a "Databases on this machine" section: which copy is
being served and why (`bin/database-role --why`, the one helper every part of
the snap asks, so the report cannot disagree with what the snap does), whether
MongoDB files and a FerretDB database both exist, and - when they do - the two
commands to run. It reads the files rather than a database, so it answers with
WeKan down, and it declares no fault: two copies is the normal state of a
migrated snap, and calling that a problem would make every migrated instance
look broken.

A revision without the commands says so and says to refresh, instead of leaving
snapd's "cannot find app" as the last word, and says why refreshing is safe for
exactly this case: it does not import an old MongoDB over a FerretDB already in
use (#6585), and neither copy is ever deleted.

The script ran the Node report with `exec`, which replaces the process, so this
had to stop doing that; the report's exit status is still the command's.
`wekan.problems migrations|login|cpu|broken-cards` are unchanged - they are used
in pipes - and `wekan.help` and Migration-to-FerretDB.md now list the two
commands, with the release they arrived in.

tests/snapProblemsDatabases.test.cjs pins all of it, including the two negatives:
no fault is declared, and the sub-commands print only what they were asked for.

Thanks to waltermhl and xet7 !

Refs #6583 — the reporter still has to run the two commands, so this does not
close it.
2026-08-13 19:17:41 +03:00
Lauri Ojansivu 664cc6b95b Fix #6465: clicking a minicard again really closes the card.
"you now can click outside your mini card and the popout will close. Clicking
the mini card again to close the popout is still not possible I think."
(comment 5281667054), on v10.90 - which already had the toggle, and a test
suite pinning it.

The toggle was closing the wrong thing. On a desktop-sized screen the card
details are NOT the route: clicking a minicard writes the card id into the
`openCards` session list and boardBody renders one draggable window per id in
it, leaving the URL on the board. The toggle asked whether the card was
`currentCard` and closed it with `Utils.goBoardId(...)` - which navigates to the
board route, clears `currentCard`, and leaves the window on screen, because
nothing had taken the card OUT of `openCards`. Only the window's own X button
did that. That is why closing worked from the card and not from the minicard,
and why the guard for the earlier fix passed while the reported behaviour did
not change.

So the two questions become two helpers, and both minicard click paths - the
title, which is most of a minicard, and the rest of it - use them:

  cardWindowIsOpen  asks the list that RENDERS the window on a desktop, and
                    `currentCard` for a card opened by its own URL. With "Open
                    many cards at once" on, `currentCard` is only the last card
                    clicked, so every earlier window was impossible to close by
                    clicking its minicard.
  closeCardWindow   removes the id from `openCards`, clears `currentCard` and
                    the phone popup ids, and navigates back to the board only
                    when the card IS the route - a card opened by a click is
                    not, and navigating would reset the board view for nothing.

That is the card details' own `click .js-close-card-details` logic, so both ways
of closing a card now do the same thing; tests/cardClickToggle.test.cjs pins
that they agree, and its two assertions that pinned the routing close are
replaced with the reason above rather than deleted. The phone path, where the
card is a popup and `Popup.back()` closes it, is unchanged.

Thanks to Heart1010, csonkaoszimt and xet7 !

Fixes #6465.
2026-08-13 19:14:02 +03:00
Lauri Ojansivu 3e9e02ba08 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-08-13 19:06:00 +03:00
Lauri Ojansivu 0fae50d97c CHANGELOG: point the verified-issues entry at its commit.
Thanks to xet7 !
2026-08-13 19:05:59 +03:00
Lauri Ojansivu b9e00dbb85 Close two open issues the current code already answers.
Both were in the CHANGELOG's TODO Later list as "needs the running app", and
both are decided by files that can be read.

#5052, "Attachments cannot be opened (.eml)": a blank page in the browser and
nothing usable in Thunderbird, after a board was copied. Three mechanisms could
produce that and each is handled now.

  - The NAME. An unknown MIME used to append .bin (#6589), and Thunderbird will
    not open a .bin. Every type a mail file arrives as - message/rfc822,
    application/octet-stream, none at all - now keeps the extension, and the
    board copy names copies with the same function the upload used.
  - The SERVING. message/rfc822 is in neither the dangerous-types nor the
    safe-inline list, so it takes the "unknown types" branch: Content-Type
    application/octet-stream, Content-Disposition attachment, under the file's
    own name. Inline is what shows a browser a blank page for a type it cannot
    render.
  - The FILE. An attachment whose recorded versions[].path and on-disk name had
    diverged is found by the same search reading uses (#6589), so a copied
    board's attachments open even when the database's idea of the path is stale.

#5081, "Correct display of owner, member and assignee on mini cards": owner on
the very left, members next, assignees on the very right, wrapping to a second
right-aligned row when they do not fit. That is what the minicard renders
today, and the reason is the float - three groups that float to the inline end
are laid out RIGHT to left in DOM order, so the markup's assignees, members,
creator renders as creator | members | assignees. Each avatar floats as well,
so a row that does not fit wraps and stays right-aligned, and an empty group is
display:none rather than a gap.

tests/openIssuesVerifiedFromCode.test.cjs pins both, and #5052 is removed from
TODO Later.

Thanks to xet7 !

Fixes #5052,
Fixes #5081.
2026-08-13 19:05:59 +03:00
github-actions[bot] 1c3068c08f Bump versions for v10.90 2026-08-13 13:16:11 +00:00
Lauri Ojansivu 75367e0e15 Prepare v10.90 release 2026-08-13 16:14:58 +03:00
Lauri Ojansivu e5506c5767 Translations: error-watch-disabled in 135 languages.
The string the watch popup shows when the server refuses - added with the fix
for the silent Change watch popup, and pulled into every language file as the
English source, which is the placeholder state.

Filled directly, as CLAUDE.md's translation policy says: each language's own
existing strings and ordinary kanban vocabulary are the reference, no external
service is involved, and only PLACEHOLDERS were written - a value that already
differed from English would have been left alone.

Fourteen of these files are written in a language other than the one their code
names: ary holds Persian, tlh German, vo and zgh French, vl-SS Dutch, ve and its
variants Zulu, ace Indonesian, cmn and wuu-Hans simplified Chinese, yue_CN
traditional. Each follows what is IN the file, because that is what a reader of
that file sees - cmn and wuu-Hans say 版块 for board, which is the word those
files already use.

English and the en-* variants keep the source string, by design. 135 files
changed; verify-human-preference.mjs still passes 10/10.

Thanks to xet7 !
2026-08-13 16:08:04 +03:00
Lauri Ojansivu 2e1f4c8824 CLAUDE.md/AGENTS.md: a commit that fixes an issue ends with Fixes #NNNN.
The format was in the file, in the 'Fix open issues' process and in the commit
template, but not as a rule with its edges: when it applies, when it must NOT,
and what a commit with no issue behind it looks like. Those edges are where it
goes wrong - #6586 was closed by the umlaut fix and REOPENED for the five
follow-ups in its next comment, so it is fixed again only when those are done,
and #6585 has a reporter who is still stuck, so a commit that improves its
diagnostics references it and does not close it.

Thanks to xet7 !
2026-08-13 16:03:12 +03:00
Lauri Ojansivu 03a99e67b1 CHANGELOG: point the recovery-commands entry at its commit.
Thanks to xet7 !
2026-08-13 15:53:30 +03:00
Lauri Ojansivu 073a327d62 Snap: two commands for a snap serving the older of its two copies.
#6583, comment 5280504937: "The migration and the update to 10.83 startet at
11.08.2026 at 6:35 pm and migration failed. Now we just see the old data from a
migration we did in july 2026. ... Which steps exactly could we do, to restore
the database with our most recent data?"

Everything needed to answer that already existed - db-eval evidence,
database-choose.mjs, database-merge-missing.mjs, database-autopick - and none of
it was a command anybody could run. `snap run wekan.problems` said "No problems
detected", which is true of the things it checks and no help here.

  sudo snap run wekan.database-compare    # what does each copy hold?
  sudo snap run wekan.database-merge      # bring the missing documents across

compare starts each database on a temporary port, counts its documents and finds
the newest moment its data carries, and prints both sides. The running WeKan is
not disturbed and nothing is written - it is database-autopick --dry-run, so
there is one implementation of "read both and compare" rather than a second one
that can drift. It also prints where both copies live and how to take a copy.

merge inserts the documents that exist in the MongoDB copy and not in the
FerretDB one: database-autopick --to-ferretdb. It overwrites nothing, deletes
nothing and reads the MongoDB files only, so it is safe to run without first
knowing which copy is "right" - WeKan's history is append-only, so merging can
only ADD to what a card shows, and the stranded work becomes readable in that
card's History. It asks for a copy of $SNAP_COMMON first, with the command, and
takes --dry-run.

Both are registered in snapcraft.yaml and snapcraft-core26.yaml. The removed
`snap run wekan.database` switch is gone from core26 too, where it had been left
behind when the database setting was removed.

Thanks to waltermhl, lukechao and xet7 !
2026-08-13 15:53:30 +03:00
Lauri Ojansivu 6655bf7565 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-08-13 15:51:26 +03:00
Lauri Ojansivu 69e81632c1 CHANGELOG: an Upcoming section for the five fixes since v10.89.
v10.89 was cut with the Upcoming section it had, and nothing has recorded what
came after it: the archived cards that reappeared in the top swimlane and the
watch popup that answered nothing (both reported by email), #6589's .bin
attachment, #6591's Excel export, #6590 closed as #6588's other end, #6585's
reader diagnostics, and the two release-tooling fixes from the v10.89 run.

Grouped by area as CLAUDE.md asks - Boards, Cards and attachments, Exporting a
board, The snap, then the release tooling - with the summary paragraph, the
binaries table carried over from v10.89, and each entry linking its own commit.

Thanks to xet7 !
2026-08-13 15:51:25 +03:00
Lauri Ojansivu a70e69767f Snap: a Launchpad job that runs out of time says the build is still going.
v10.89's riscv64 leg ran 5h50m - its cap - with Launchpad still printing
"Building: riscv64", and the run showed CANCELLED and nothing else. What is
true at that moment is worth saying: the Launchpad build is NOT cancelled with
the job, it keeps its name, and re-running this job reconnects to the same
build and downloads the snap rather than starting another one.

The cap goes to 360 minutes, which is the maximum a hosted runner allows, and a
cancelled job now prints that explanation plus any Launchpad URL its logs
carry.

Thanks to xet7 !
2026-08-13 15:51:25 +03:00
Lauri Ojansivu 224e2cc9c4 Tests: accept the --repo argument in the gh release guards.
The two guards matched the exact string `gh release upload "v${VERSION}"`,
which no longer exists: every call names the repository now, because a job that
flattened its history for the Launchpad push has no remote for gh to infer one
from. Both still require --clobber and the verification that follows, and they
additionally require --repo.

Thanks to xet7 !
2026-08-13 15:51:25 +03:00
Lauri Ojansivu d38ca46803 Release: gh needs to be told which repository.
v10.89's snap-launchpad armhf, ppc64el and s390x jobs published the snap and
then failed on the line that attaches it to the GitHub Release:

  Revision 3661 created for 'wekan' and released to 'beta', 'candidate',
    'edge', and 'stable'
  no git remotes found
  Error: Process completed with exit code 1

Those jobs flatten history so the Launchpad push stays small, and the git
remote goes with it - so gh had nothing to infer the repository from. The snap
was in the store and not on the release, which reads like a failed build.

Every `gh release upload|view|edit` in every workflow now passes
--repo "${GITHUB_REPOSITORY:-wekan/wekan}", so the call does not depend on what
the checkout looks like, and tests/workflowRepoScripts.test.cjs fails a bare
one.

Thanks to xet7 !
2026-08-13 15:51:25 +03:00
Lauri Ojansivu 8ec78bb14c Archived cards stay archived, and a silent watch button says why.
Three email reports, 2026-08-13.

"PREVIOUSLY ARCHIVED CARDS (SOME SEVERAL YEARS OLD) HAVE REAPPEARED. THESE
CARDS HAVE INCORRECTLY BEEN PLACED IN THE TOP SWIMLANE."

The schema upgrade's swimlane rescue treated "this card's swimlane is archived"
as breakage and moved the card to the board's first VISIBLE swimlane. But
archiving a swimlane is how a whole swimlane is put away: its cards stay where
they are, archived: false, hidden because the swimlane is hidden. So every card
anybody had ever archived that way was hoisted back into view, years later, in
the top swimlane.

A card is orphaned when its swimlane does not EXIST, or belongs to another
board - #1959, which is still rescued. loadVisibleSwimlanes now also returns
existsById, and both the check and the write use it.

#1971, the issue the sweep cited, is about cards ADDED in List view landing in
an archived swimlane, and that is fixed where cards are created:
getDefaultSwimline() picks a non-archived swimlane. It never needed a sweep
over data somebody archived on purpose. The two guards that pinned the sweep
now pin the opposite, with the reason in them.

"SILENT DOES NOT RESPOND. IF WE TRY TO CHANGE IT DOES NOT CHANGE. NOTHING
HAPPENS." (the board's Change watch popup)

Driven against a running WeKan, the popup works for a board MEMBER - level
written, check mark moved, popup closed - and does nothing at all for anybody
who reaches the board another way:

  login as non-member admin: ok
  watch -> ERROR error-board-notAMember

A board is shared four ways: membership, an organisation, a team, and (#5850)
an email domain. Only the first puts anybody in `members`, and the watch method
asked hasMember(). Everyone sharing through an org, a team or a domain could
open the board, see the button, and be refused the moment they used it. It now
asks whether the user may SEE the board, through the same
boardVisibilitySelectors the publications use, so a watch can never be granted
where the board is not visible - and a revoked share (isActive: false) still is
not.

The other half is why nobody could tell: the popup closed on success and did
NOTHING otherwise, so a refusal was indistinguishable from a dead button. It
reports the reason now - the watch feature being off in the Admin Panel, or the
board not being visible - and en.i18n.json gains error-watch-disabled, which
was thrown but never translated. The click also reads its level with String(),
since Blaze can hand back a boxed String that `typeof x === 'string'` refuses.

Thanks to xet7 !
2026-08-13 15:51:25 +03:00
Lauri Ojansivu a712db9473 Fix the board Excel export, and the attachment stuck as .bin.
#6591 - "Board Settings -> Export board -> export/Excel didn't work".
Reproduced against a running WeKan: CSV and JSON of the same board answered
200, Excel never answered at all, and the log said nothing.

  csv:   HTTP 200 4758b
  json:  HTTP 200 4491b
  excel: Operation timed out after 30002 ms with 0 bytes received

Two faults, either of which hangs the browser on its own.

The zip: exceljs 4.7.3's streaming WorkbookWriter calls archiver the way
archiver 7 was called - Archiver('zip', opts) - and WeKan moved to archiver 8
for the low-memory backup zips. archiver 8 is ESM and exports classes, so that
is "TypeError: Archiver is not a function"; the export has been broken since
the bump. Supplying the missing factory does not rescue it: archiver 8's
readable-stream then refuses the objects exceljs appends ("input source must be
valid Stream or Buffer instance"), as an error EVENT. So createWorkbookWriter
asks what archiver exports and, when it cannot stream, returns a buffered
writer with the same API that writes the whole workbook on commit() - the path
the export used before it was made streaming. Bounded memory is what is lost,
not the export.

The silence: the route called exporterExcel.build(res) without awaiting it, so
the rejection went nowhere - no 500, no log, and a response that was never
written or ended. Every sibling route awaits; this one did not. It does now,
and a failure answers 500 with the reason.

#6589 - a .drawio attachment stored and shown as ".bin", unopenable, and
impossible to rename. Also two faults:

The name: a browser sends application/octet-stream for a type it does not
know, and the upload-time "correct the extension to the type" step took that
literally - mime.extension('application/octet-stream') is 'bin', so
"sso-proconnect-keycloak.drawio" became "...drawio.bin". Every unrecognised
format went the same way. An uninformative type now yields no extension at all,
while a type that does say something still corrects the name.

The rename: versions[].path and the file on disk had diverged, and rename used
the recorded path alone -

  Error: ENOENT: no such file or directory, rename
    '/data/files/attachments/6a7d66369c6aee799e857d36.drawio' -> ...

- while the READER already searched every layout WeKan has used and found the
file. That search is a method now (candidatePaths/resolveExistingPath) and
reading, renaming and deleting all use it, so an attachment that can be read
can also be repaired. When there is genuinely no file, the error names the
attachment instead of a path nobody recognises.

Thanks to rmb82, titver968 and xet7 !

Fixes #6589,
Fixes #6591.
2026-08-13 15:51:25 +03:00
Lauri Ojansivu 1f83020b4d Snap: say WHY each reader refused the data, and where to get an old MongoDB.
#6585 comment 5276581923 is a log that says everything except the useful part:

  [migration] mongod 7 could not open the data; trying the bundled mongod 5.0 ...
  [migration] mongod 7 could not open the data; trying the bundled mongod 4.2 ...
  [migration] The database files were made by an older MongoDB
              (MongoDB 4.2 or earlier can still read them).

Every reader was tried, each of them said something, none of it was shown - and
the conclusion recommends the version that had just failed. mongod 7 names the
version that can open the FORMAT; it cannot know the files are also damaged, or
left locked by an unclean shutdown.

So each reader now prints its own last words when it does not open the data
(reader_failed, the tail of migration-mongod.log), the "trying the bundled X"
lines name the reader being TRIED instead of blaming mongod 7 for all of them,
and when mongod 7 asks for a version this snap CARRIES, the report says it was
tried too and that --repair on a COPY is the usual next step.

And the other half of the report - "If you need us to run some external tools,
like an old mongodb, it would be good to provide a source for them": the log and
the explanatory page now link mongodb.com/try/download/community and
fastdl.mongodb.org, name the docker one-liner, and spell out mongod --dbpath /
mongodump on a COPY, never the original.

Also closes #6590, which is #6588 from the other end: the same
`this.__schemas__[...].validate is not a function` out of linkify-it 6, reported
as a card whose description hung a whole BOARD for everyone rather than one card
that would not open. Fixed by 75a23b76a on 2026-08-12; the regression test now
renders the exact onenote:///path/to/file.one#section-id={GUID} from the report,
description and checklist item alike.

Thanks to mueschel, titver968 and xet7 !

Fixes #6590,
2026-08-13 15:51:25 +03:00
github-actions[bot] 11bdb0abfa Bump versions for v10.89 2026-08-13 04:38:11 +00:00
Lauri Ojansivu dd250991b4 CHANGELOG: point the re-migration entry at its commit.
Thanks to xet7 !
2026-08-13 07:30:33 +03:00
Lauri Ojansivu e63ac0e77a Snap: a migration never runs over the database it already produced.
From #6583: "the migration re-ran yesterday (even though it had already run
successfully a few weeks ago). The .migration-to-ferretdb-done file is time
stamped yesterday ... That explains why I'm seeing old data."

The instance had been migrated and had served from FerretDB for weeks. The
marker went missing, the old staleness guard put it back on database=mongodb,
and the migration ran again - importing the MongoDB copy it had been made from,
over the database holding every change since. discard_partial_ferretdb could
then delete that database, because "partial" was assumed rather than checked.

Two locks now, opened by the same fact: the importer writes
migration-progress.json as it goes and resumes from it, so a FerretDB with data
and NO checkpoint beside it is a finished database in use, never a migration to
continue.

  - migration-control checks that before it probes, reads or deletes anything.
    Finding one, it marks the migration done, starts FerretDB, stops MongoDB and
    exits without importing.
  - discard_partial_ferretdb checks it again before removing a SQLite, and says
    so when it declines.

The MongoDB data is untouched either way. migration-pending already answers the
same question through database-role, so neither branch should be reachable,
which is why they exist: the cost of being wrong here is somebody's data.

Also from the v10.89 run:

  - The push preflight I added blocked the Docker publish on a false positive.
    GHCR hands back an OPAQUE token ("v1:wekan/wekan:1786595038984745940", no
    dots, no claims), and reading no "push" in it was treated as a refusal.
    Only an explicit refusal fails now - an access claim naming the repository
    whose actions omit push, which is what Quay and Docker Hub return - and an
    unreadable token says so and lets the push be the check. Verified against
    all three registries.
  - snap-launchpad armhf, ppc64el and s390x uploaded a file that existed and
    was not a snap ("could not unsquash snap file"). A .snap is squashfs, so it
    starts with "hsqs": the build step now checks that and a 50 MB floor,
    deletes a stale file before each attempt so a truncated one cannot be picked
    up, deduplicates the two globs that matched the same file twice, and the
    upload step checks the magic again with its own message.

Thanks to lukechao and xet7 !
2026-08-13 07:30:33 +03:00
Lauri Ojansivu b32d8977e7 CHANGELOG: point the Docker-variant entry at its commit.
Thanks to xet7 !
2026-08-12 23:45:25 +03:00
Lauri Ojansivu e533829f5b Docker: publish only wekan, on all three registries. Variants commented out.
wekan-ondra and wekan-gantt-gpl are SNAP names - a snap name cannot be changed
once people have it installed - and as Docker images they were only ever a
second name for the same image. The release tagged them on all three registries
for two versions; it does not any more.

Six extra repositories across three registries, each with its own visibility and
its own push permission, is six new ways for a release to fail in order to
publish a copy of something already published, and v10.88 failed exactly that
way an hour into an emulated build:

  ERROR: failed to push quay.io/wekan/wekan-ondra:v10.88:
    unauthorized: access to the requested resource is not authorized

The wekan image still goes to all three: wekanteam/wekan, quay.io/wekan/wekan
and ghcr.io/wekan/wekan, version tag and latest.

The variant -t lines are COMMENTED OUT, not deleted, with what it would cost to
uncomment them written beside them, and the same for their names in the two
verification loops and the push preflight. docker-variant.yml and
releases/docker-publish-variant.sh stay for publishing one out of band; both are
started by hand and no release calls them.

The SNAPS are untouched: snap-variants still builds and publishes wekan-ondra
and wekan-gantt-gpl on amd64 and arm64, and SNAP_AUTH still carries all three
snap names. Nothing is deleted from any registry either - the tags that exist
keep working for whoever pinned them, they only stop gaining versions.

tests/dockerNoVariantImages.test.cjs replaces the two suites that pinned the
opposite decision: no active variant tag or check, the comments and their
reason present, the manual workflow still dispatch-only, and the snaps still
built.

Thanks to xet7 !
2026-08-12 23:45:25 +03:00
Lauri Ojansivu 905b1bb73f 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-08-12 23:01:57 +03:00
Lauri Ojansivu 65c5803f0a CHANGELOG: point the workflow-path entry at its commit.
Thanks to xet7 !
2026-08-12 23:01:56 +03:00
Lauri Ojansivu 18b8812620 Release: fix the path a pushd invalidates, and the last bare downloads.
The v10.89 run failed four more jobs, the same two mistakes one step further
along.

THE WINDOWS JOBS check this repository out to path: src, so the scripts were
addressed as src/releases/... - correct until the bcrypt step does
pushd "$TMP", after which a relative path resolves against a temp directory:

  bash: src/releases/npm-retry.sh: No such file or directory

The location is now fixed before anything moves - SRC="$PWD/src" at the top of
the step, then "$SRC/releases/..." - in the eighteen blocks that need it, and
the same for the ucs job's univention/.

THE DOWNLOADS THAT ARE NOT IN A WORKFLOW. snapcraft.yaml builds the snap in its
own container and sandstorm-src/build-deps.sh runs on the runner; both still
used a bare curl, and github.com's 503s took them out:

  :: curl: (56) Connection died, tried 5 times before giving up
  :: caddy: no linux/arm64 archive in Caddy 2.11.4 - nothing left to try.
  ==> [4/7] FerretDB v1 (amd64) at deps root
  curl: (56) Connection died, tried 5 times before giving up

Both go through releases/fetch.sh now - the snap parts reach it through
CRAFT_PROJECT_DIR, since snapcraft mounts the project into the build - so the
caddy, MongoDB, mongod 4.2/5.0 and OpenSSL downloads, the meteor-spk and
Node.js tarballs and the FerretDB binary all wait an outage out. The Caddy
version LOOKUP stays a plain curl: when it fails the pinned version is used,
which is what it is for. curl https://install.sandstorm.io | sudo bash became a
download and a run, because a pipe cannot be retried.

tests/workflowRepoScripts.test.cjs grew the two checks that would have caught
these, and releaseBuildJobsCheckout accepts $SRC/releases as the same statement
as src/releases, made absolute.

Thanks to xet7 !
2026-08-12 23:01:55 +03:00
github-actions[bot] cc72a84f4c Bump versions for v10.89 2026-08-12 19:37:43 +00:00
Lauri Ojansivu e508f27aec Prepare v10.89 release 2026-08-12 22:36:34 +03:00
Lauri Ojansivu dde460eec0 CHANGELOG: the entries written after v10.88 was tagged go under Upcoming.
release-all.sh renamed the Upcoming section to v10.88 at 21:22 and the release
notes were taken from it then. The snap work (21:55, 22:12) and the workflow
paths fix (22:24) came after that, so they were never part of what v10.88
shipped. They move to a new Upcoming section, grouped by area, and v10.88 keeps
what it was released with. All commit links resolve.

Thanks to xet7 !
2026-08-12 22:28:43 +03:00
Lauri Ojansivu 2626796ee2 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-08-12 22:25:20 +03:00
Lauri Ojansivu 09de31496c CHANGELOG: wrap the two error lines at 80.
Thanks to xet7 !
2026-08-12 22:25:18 +03:00
Lauri Ojansivu 52b5f5a6b4 CHANGELOG: point the workflow-paths entry at its commit.
Thanks to xet7 !
2026-08-12 22:25:18 +03:00
Lauri Ojansivu bdacff80e2 Release: a repo script only runs where the repo is, and check push first.
The v10.88 run lost seven jobs to two mistakes of the same kind.

THE SCRIPTS WERE NOT ON DISK YET. Putting the downloads and package installs
behind releases/fetch.sh and releases/apt-install.sh turned steps that needed
nothing into steps that need this repository:

  bash: /home/runner/work/wekan/wekan/releases/apt-install.sh: No such file or directory
  bash: D:\a\wekan\wekan/releases/npm-retry.sh: No such file or directory

build-extra-arches had "Install dependencies" as its first step, before
actions/checkout - which was fine while it was a plain apt-get. The Windows
jobs check this repo out to path: src, so $GITHUB_WORKSPACE/releases does not
exist there; they already called the other scripts as src/releases/... Same two
shapes in the Flatpak job (no checkout at all), release-all-missing's
extra-arches and charts (path: wekan), and ucs (path: univention).

tests/workflowRepoScripts.test.cjs reads every workflow for a step that runs a
repo script before its job checks out, or through a prefix that does not match
that job's checkout path, and for a script a workflow names but the repo does
not have.

AND THE PUSH THAT WAS NEVER GOING TO WORK. The docker job built every
architecture under emulation for most of an hour and threw it away on

  ERROR: failed to push quay.io/wekan/wekan-ondra:v10.88: unauthorized

The credentials were fine; the login check passed. Quay grants push per
REPOSITORY and wekan-ondra had just been created, so the account had no rights
on it. The job now asks each registry whether it would grant a push token for
each of the nine images, before building anything, and fails in seconds naming
the per-repository permission. No answer is a warning - that is the network.
Verified against the real registries: an unauthenticated request is granted
"pull" by Quay and nothing by GHCR, which is the shape this detects.

Thanks to xet7 !
2026-08-12 22:25:18 +03:00
Lauri Ojansivu 5d51f49802 CHANGELOG: point the database-setting removal entry at its commit.
Thanks to xet7 !
2026-08-12 22:25:18 +03:00
Lauri Ojansivu bb76e7bf10 Snap: remove the database setting. There is one answer, and it is FerretDB.
`snap set wekan database=mongodb|ferretdb` is gone, and so is
`snap run wekan.database`. WeKan runs on FerretDB on every platform; MongoDB is
in the amd64/arm64 snaps to be READ while a migration is owed.

A setting could say something the data did not support, and every way it could
was a report: set to mongodb it kept a site on the database the snap migrates
away from, for good, because nothing ever set it back - including instances a
failed migration or a wrong staleness guess had put there; set to ferretdb with
no FerretDB present it would have served an empty site, so the guard against
that had to exist anyway; and each script carried its own copy of "which
database is this, then".

snap-src/bin/database-role replaces it. One helper, asked by wekan-control,
mongodb-control, ferretdb-control, migration-pending, attachment-repair and the
configure hook, answering from the data: a FerretDB with something in it and a
migration that has finished means ferretdb, MongoDB data with the migration
still owed means mongodb. An interrupted migration is told from a finished one
by the importer's checkpoint, so a partial FerretDB resumes and a finished one
whose marker went missing is not migrated over again (#6585). An empty FerretDB
beside a MongoDB full of boards is never served.

The explanatory page is no longer a dead end: when this snap cannot read the
MongoDB files but a FerretDB copy is there, the copy is served - older beats
unreadable - and the page says the snap keeps trying rather than giving a
command to type. A snap that still has the old setting is told once that it is
ignored, and it is unset.

docs/Platforms/FOSS/Container/Snap/Migration-to-FerretDB.md is the design in one
page: all text data to SQLite, CollectionFS and Meteor-Files attachments to the
filesystem, the card History with it, which MongoDB versions can be read, when
it runs, what happens when it fails, and how two copies are reconciled. The
Admin Panel, Snap and CPU-platform docs point there instead of documenting a
setting that no longer exists.

Nine guards updated; each says in it which decision changed and why.

Thanks to xet7 !
2026-08-12 22:25:18 +03:00
Lauri Ojansivu 539dda7d5d CHANGELOG: point the snap-migration entry at its commit.
Thanks to xet7 !
2026-08-12 22:25:18 +03:00
Lauri Ojansivu 60b4ceda3b Snap: end up on FerretDB, whatever the snap was running before.
Three ways a snap could stay on MongoDB for good, all of them reported.

A DATABASE NOTHING COULD OPEN. A mongod starts only on data whose
featureCompatibilityVersion is at most one major behind it, so the readers
covered FCV 6.0/7.0 (mongod 7), 4.0/4.2 (mongod 4.2) and 3.x (the 3.2 tools),
and nothing covered 4.4 or 5.0 - although this snap shipped MongoDB 5 in
February 2023 and 6.0.6 only in May. Those instances got .mongodb-data-too-old
and an explanatory page. mongod 5.0 is bundled now as a fourth read-only
reader, tried between 7 and 4.2, through cpu-exec because MongoDB 5.0 requires
AVX on x86_64.

"WEKAN CHANGED TO OLD MONGODB DATA." When the migrated FerretDB copy had fallen
behind the MongoDB beside it, wekan-control switched the snap to
database=mongodb - back onto the database it is migrating away from, and when
the detector guessed wrong (#6583) onto a copy that was weeks behind. It merges
now: database-autopick --to-ferretdb copies the documents MongoDB has and
FerretDB does not into FerretDB, inserting what is missing and overwriting
nothing, and WeKan carries on there. The history is append-only, so that work
lands in the card History. Switching to MongoDB is only the fallback for when
the merge cannot run, because serving a copy that is behind is the complaint
itself.

A FAILED MIGRATION THAT NEVER TRIED AGAIN. A failure set migrate=off and
nothing ever set it back on. It is recorded now - attempts, when, and which
snap revision - and retried by itself: immediately after the next refresh,
otherwise after a wait doubling from an hour to a day. The same record replaces
migrate=off on the unreadable-database path, which is what lets this release's
5.0 reader reach the instances already given up on. snap set wekan migrate=off
still stops it: an admin saying "not now" is a decision, not a failure.

Nothing is deleted; the MongoDB data stays in $SNAP_COMMON.

tests/snapAlwaysFerretdb.test.cjs covers all three. Two existing guards pinned
the old decisions and now pin these, with the reason written in them.

Thanks to xet7 !
2026-08-12 22:25:17 +03:00
github-actions[bot] 7d645d51d0 Bump versions for v10.88 2026-08-12 18:23:48 +00:00
Lauri Ojansivu 6daaa86e30 Prepare v10.88 release 2026-08-12 21:22:44 +03:00
Lauri Ojansivu 6c067b8c2b CHANGELOG: point the browser-test session entry at its commit.
Thanks to xet7 !
2026-08-12 21:16:41 +03:00
Lauri Ojansivu 4c68a8a49a Browser tests: a login must not log the test's other tabs out.
The last test run failed one test in all three browsers, having passed for a
month:

  02-cards-open-view.e2e.js:66 copy-link ... opens the card in full-screen view
  Error: Token login failed: You've been logged out by the server.

Driven over DDP against the running server, with a token seeded the way the
fixtures seed one:

  session A: ok           tokens: [CfgBWImyytla]
  session B: ok           tokens: [CfgBWImyytla]  <- two sessions, one token
  after B logged out      tokens: []              <- logout removed it
  session C (same token): ERROR You've been logged out by the server.

A seeded user has ONE resume token and Meteor.logout() deletes it on the
server, for every session sharing it. loginWithToken called it when the page
was logged in as somebody else, so switching users in one page stranded the
test's other pages. The copy-link test is the only one that logs a second page
in, which is why it was the one that failed.

loginWithToken now ends the previous session in the client - drop the three
Accounts keys, reload - which it already does for its own first load. The
token is untouched and the page still arrives with no user. logout() stays as
its own helper: 05-admin-users logs out and back in with a password, which is
what a user really does.

Two pages are two browsers, so they get two tokens: db.addResumeToken() adds
one to an existing user and the second tab uses it. That tab also stopped
waiting for networkidle before looking for the card - a card renders when its
subscriptions land, not when the network goes quiet.

tests/e2eSessionTokens.test.cjs pins both rules and scans every spec for two
logins sharing one token. Verified against the running server: the copy-link
test passed four times in a row, and 05-admin-users, 32-org-team-feature-
toggles, 33-board-domains and 38-impersonation - the specs that switch users -
all pass.

Thanks to xet7 !
2026-08-12 21:16:40 +03:00
Lauri Ojansivu 2d90c269f1 CHANGELOG: the two fixes written after v10.87 was tagged go under Upcoming.
release-all.sh renamed the Upcoming section to v10.87 at 20:10 and the release
notes were extracted from it there and then. The download-retry and apt-install
fixes were committed after that, so they were never part of what v10.87
shipped: they move to a new Upcoming section, and v10.87 keeps the summary it
was released with - the npm install retry alone.

Thanks to xet7 !
2026-08-12 21:04:30 +03:00
Lauri Ojansivu e78709c074 CHANGELOG: point the apt-install entry at its commit.
Thanks to xet7 !
2026-08-12 20:58:09 +03:00
Lauri Ojansivu a6edf86bc3 Install packages through a script that survives a mirror mid-republish.
The bump job of the same afternoon died on a repository the release does not
use:

  E: Failed to fetch https://dl.google.com/linux/chrome-stable/deb/dists/stable/main/binary-amd64/Packages.gz
     Hash Sum mismatch
  E: Some index files failed to download.
  Error: Process completed with exit code 100.

It was installing python3 and curl. A GitHub runner ships with google-chrome,
microsoft-prod, azure-cli and docker repositories configured, and apt-get
update fails as a whole when any one of them serves an index that does not
match its own hashes.

releases/apt-install.sh retries the update, clearing the cached lists first,
because a Hash Sum mismatch is a cached index disagreeing with the server and
re-reading it reports the same thing. If it still fails, it moves the
third-party lists aside and updates from the distribution archive alone - where
every package a release job installs comes from. Both steps say so in the log,
and a mirror that never comes back still fails the job, naming the mirror.

Every apt-get update + install pair in release-all.yml, release-all-missing.yml,
sandstorm.yml, sandstorm-store.yml, meteor-spk.yml, Flatpak.yml and the emulated
build container goes through it.

tests/releaseAptInstall.test.cjs drives it with a fake apt-get that mismatches
on demand and a fake sudo that records rather than runs. cpuExecWiring's
qemu-user-static guard now looks for the package wherever it is asked for, not
only after a literal apt-get install.

Thanks to xet7 !
2026-08-12 20:58:08 +03:00
Lauri Ojansivu 9f4aa4f8d1 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-08-12 20:45:16 +03:00
Lauri Ojansivu bb413493cc CHANGELOG: point the download-retry entry at its commit.
Thanks to xet7 !
2026-08-12 20:45:15 +03:00
Lauri Ojansivu e771f8c661 Wait out a 503, and keep telling it apart from a 404.
github.com spent the afternoon returning 503, and it took a second release run
an hour after the first - this time in build-amd64, downloading the FerretDB
binary:

  curl: (22) The requested URL returned error: 503
  Warning: Problem : HTTP error. Will retry in 10 seconds. 5 retries left.
  ...
  curl: (56) Connection died, tried 5 times before giving up

nodejs.org was fine in the same step, seconds earlier: the bundled Node.js
downloaded and verified. --retry 5 --retry-delay 10 is fifty seconds.

releases/fetch.sh downloads a file in a release now. 5xx, 429, 408 and the
connection errors are retried on a backoff of about fifteen minutes, and every
download in release-all.yml, release-all-missing.yml, embed-verified-node.sh,
install-node-for-arch.sh and resolve-node-source.sh goes through it. The
Dockerfile copies it in beside resolve-node-source.sh, which asks it which
Node.js builds exist, and its two bare wgets get the same --retry-on-http-error
as the others.

A 404 is not an outage, and that is the half a longer --retry cannot do. The
preflight that skips an architecture with no Node.js build yet, the MongoDB
tools not built for every platform, the .sha256sum a source may not publish:
those fail at once, and quietly with --optional.

An existence check has three answers - present, absent, and the server would
not say. The third used to be the second, which is how a 503 could drop a
platform from the Docker image or skip an architecture whose binary was
published all along. check-arch-binaries.sh, require-binaries.sh and the docker
job's platform list now stop and say to re-run instead.

tests/releaseDownloads.test.cjs runs it against a local server that 503s, 404s
and 429s on demand, and reads the workflows for a download that still goes
straight to curl.

Thanks to xet7 !
2026-08-12 20:45:15 +03:00
github-actions[bot] a02959dde6 Bump versions for v10.87 2026-08-12 17:11:28 +00:00
Lauri Ojansivu 024ae3c1cd Prepare v10.87 release 2026-08-12 20:10:36 +03:00
Lauri Ojansivu e8843f51a1 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-08-12 20:08:32 +03:00
Lauri Ojansivu c1bed83464 CHANGELOG: point the npm-retry entry at its commit.
Thanks to xet7 !
2026-08-12 20:08:31 +03:00
Lauri Ojansivu 644e61f6a3 Retry the release's npm installs when the far end 5xxes.
The v10.86 run failed in build-amd64 with

  npm error code E503
  npm error 503 Service Unavailable - GET
  https://github.com/meteor/node-source-map-support/tarball/81bce1f9...

github.com was 503ing for a few minutes. npm's own fetch-retries is two quick
attempts and does not cover a git tarball fetch at all, so the install gave up;
every other Linux bundle is repacked from the amd64 one, so the whole release -
eleven architectures, the Docker images, the snap - was skipped and had to be
started again by hand. Every curl in that workflow has carried --retry 5 for
years; the npm installs carried nothing.

releases/npm-retry.sh runs an npm command with backoff (15s, 30s, 60s, 120s,
five attempts) and every npm install, npm pack and meteor npm install in
release-all.yml now goes through it, including the two inside docker run, which
get releases/ mounted the way the arch builds already do. Release All Missing
repacks through the same container script and inherits it.

A real npm error is NOT retried: ERESOLVE, a 404 for a package that does not
exist, a gyp compile error fail on the first attempt saying they are not a
network reason, because five attempts at an emulated arm64 install that was
never going to work is half an hour spent to print the same message. Only 5xx,
429 and the socket errors count as transient.

The Meteor installer is downloaded and then run instead of curl | sh, which
cannot be retried once sh is half way through the script.

tests/npmRetry.test.cjs pins both halves against a fake npm and reads the
workflow for a bare npm install.

Thanks to xet7 !
2026-08-12 20:08:30 +03:00
github-actions[bot] f1a569dae1 Bump versions for v10.86 2026-08-12 16:40:39 +00:00
Lauri Ojansivu 5ca9f20e03 Prepare v10.86 release 2026-08-12 19:39:48 +03:00
Lauri Ojansivu 52fe1822bf CHANGELOG: point the variant-images entry at the commit that survived the amend.
Thanks to xet7 !
2026-08-12 18:58:44 +03:00
Lauri Ojansivu 1d76e0d434 Publish the variant images on Docker Hub and Quay too, in the release build.
wekan-ondra and wekan-gantt-gpl are byte-identical to wekan/wekan apart from
the snap name:, so their images were retags of the released manifest, pushed
whenever somebody remembered to start docker-variant.yml. GHCR already got
them from the release build; Docker Hub and Quay now do as well - eighteen
tags out of the one docker buildx build --push, so a variant tag carries the
release's own digests for every architecture and cannot name a version it does
not contain.

docker-variant.yml stays for republishing one out of band, and is still
workflow_dispatch only.

A pushed tag is not a pullable tag: after the credentialed verification of all
nine images, a new step asks each one anonymously, the way a user does, and a
401/404 fails the job with the fix for that registry. Quay creates a new
repository private, so quay.io/wekan/wekan-ondra will pull for the pusher and
for nobody else until its visibility is changed - the same trap
ghcr.io/wekan/ferretdb fell into. An unreachable registry is a warning, not a
failure.

Thanks to xet7 !
2026-08-12 18:58:28 +03:00
Lauri Ojansivu b091a8ec57 CHANGELOG: the variant images on GHCR, and the chart's image going back there.
Two corrections and an addition to the Helm chart subsection: the default image
is ghcr.io/wekan/ferretdb again now that the package is public, the paragraph
about it tells the whole story (private package, org policy, what Artifact Hub
said and what a cluster would have said), and the release publishing
wekan-ondra and wekan-gantt-gpl on GHCR from its own build gets an entry of its
own - including that Release All Missing has no Docker part to change.
2026-08-12 18:45:13 +03:00
Lauri Ojansivu 4a519b6562 Release: publish the two variant images on GHCR from the release build itself.
wekan-ondra and wekan-gantt-gpl are the same WeKan as wekan - those repositories
are byte-identical to wekan/wekan apart from the snap `name:` in snapcraft.yaml,
which is why their Docker images have never been rebuilt but retagged from the
released manifest (docker-variant.yml, run by hand).

On GHCR they are now tagged in the release's own `docker buildx build --push`,
beside ghcr.io/wekan/wekan. Being in the SAME build is what makes it safe: the
variant tags carry the release's own digests for every architecture, there is no
second emulated build to go wrong, and there is no window in which a variant
image can differ from the release it names. The push verification loop asks the
registry about them like any other tag, so a variant tag that did not arrive
fails the job rather than being found months later by somebody on that CPU.

Docker Hub and Quay variants stay manual: docker-variant.yml exists so the
maintainer publishes those deliberately, and this does not change that.

Release All Missing has no Docker part to change - its jobs are plan,
extra-arches, appimage, flatpak, charts and done. It fills in artifacts for
versions whose image already exists; the charts job even refuses to publish a
chart for a version that has none.

The condition every bit of this rests on is written where the tags are, and in
docs/Design/Autoupdate/Snap-Ondra-Gantt.md: if a variant repository ever stops
being identical to wekan/wekan, these tags become a lie and that variant needs
its own build from its own Dockerfile.

The chart's default image also goes back to ghcr.io/wekan/ferretdb in the same
round, now that the package is public - the guard that briefly required it NOT to
be the GHCR one is replaced by the rule that outlives either state: the default
must be one of the three registries wekan/FerretDB pushes to, and the chart must
say which of them can be pulled.

Verified: the workflow parses, tests/chartsFerretdb.test.cjs passes (18 tests),
whole node suite 385 suites, 0 failed.

Thanks to xet7 !
2026-08-12 18:43:36 +03:00
Lauri Ojansivu d2a220a785 Pin @meteorjs/rspack to the version the Meteor rspack plugin asks for.
The build stopped before any test ran:

  => 🔧 Installing 1 dev dependency...
  => ❌ Failed to install Rspack
  For dev dependencies, run: meteor npm install -D @meteorjs/rspack@2.1.0

Nothing was missing. node_modules already had @meteorjs/rspack 2.1.0 and the
lockfile already resolved 2.1.0. What did not match was the DECLARATION:
package.json said `^2.1.0-beta.0`, and the plugin compares against the declared
string rather than the installed version, so it decided the dependency was not
there and tried to install it.

The trigger was mine: the dependency batch moved the Meteor package rspack from
1.2.0 to 1.2.1, and 1.2.1 asks for `@meteorjs/rspack@2.1.0` exactly. The
`^2.1.0-beta.0` range is older than that - it came in with "Updated dependencies."
on 2026-07-31 - and it was never really a prerelease in practice: npm resolves
that range up to the stable 2.1.0, which is what was installed all along. Only
the declaration still named a beta.

So it declares 2.1.0, which is both what the plugin wants and what is already on
disk. Nothing is downloaded and the resolved version does not change.

The package-lock.json diff also carries an unrelated npm reshuffle from the same
install - gcp-metadata re-nested under google-auth-library - which is left as npm
wrote it.

Verified by xet7: building and running WeKan works again.

Thanks to xet7 !
2026-08-12 18:40:33 +03:00
Lauri Ojansivu 67a3705cf5 Charts release: declare the images and the changes, and default to one that pulls.
From an Artifact Hub scan of the published chart:

  error scanning image ghcr.io/wekan/ferretdb:latest: image not found
  (package wekan:10.86.0)

Two separate things behind one line, and the second is the serious one.

The package 10.86.0 was a chart published out of band and already taken back off
gh-pages; the index there is clean again (230 entries, checked against the
registry and against the archives: every digest, inner chart version and
appVersion matches its entry, which is what Artifact Hub reads).

The image is the real fault, and it would have shipped in the next release.
ghcr.io/wekan/ferretdb cannot be pulled by anyone who is not logged in: a GHCR
package is private until it is made public, and that one has not been - an
anonymous manifest request answers 403 DENIED, while the same request for
ghcr.io/wekan/wekan answers 200. In a cluster that is ImagePullBackOff. The chart
now defaults to quay.io/wekan/ferretdb, which was verified to serve the full
multi-arch set (amd64, arm64, arm v5/v6/v7, 386, ppc64le, riscv64, s390x,
loong64), as does wekanteam/ferretdb on Docker Hub.

AND THE RELEASE NOW WRITES VERSION INFO INTO THE CHART, which is what a release
knows and a chart does not. releases/release-charts.sh adds two Artifact Hub
annotations to Chart.yaml alongside the version bump:

  artifacthub.io/changes   "WeKan updated to v<version>" with links to that
                           release's notes and the CHANGELOG. A chart version
                           with no changelog is one nobody can tell apart from
                           the version before it.
  artifacthub.io/images    the images this chart pulls, with exact tags, so the
                           scanner reads a list instead of inferring one - an
                           inferred list is what named an image the chart does
                           not use. The database image is READ out of values.yaml
                           as it is written, so the declaration cannot drift from
                           what is pulled.

Both are rewritten every release and were checked against the registries for the
current pair: ghcr.io/wekan/wekan:v10.85 and quay.io/wekan/ferretdb:latest exist.

The whole charts job was then run end to end against a throwaway clone of the
repository, and it does what the git history of both branches shows: main gets a
"<version>.0" commit with the bumped Chart.yaml, the archive is packaged and
carried to gh-pages, index.yaml is rebuilt from the packages, and gh-pages gets
its own "<version>.0" commit before returning to main. The archive has the same
structure and the same naming as every previous release - wekan/ with Chart.yaml,
values.yaml and templates/ - minus the Chart.lock and vendored MongoDB subchart,
which no longer exist, and plus the two FerretDB templates.

tests/chartsFerretdb.test.cjs gains three checks: the default image must be one
of the two registries that serve it publicly (never the GHCR one) with the reason
written beside it, the chart's declared images and changelog link must match its
own appVersion and its values.yaml, and the release script must build both from
the version being released rather than repeating them.

Verified by running that suite (18 tests) and the whole node suite (385 suites,
0 failed).

Thanks to xet7 !
2026-08-12 18:14:24 +03:00
Lauri Ojansivu ee7baf0b23 CHANGELOG: the Helm chart gets its own subsection, with the release path and the index.
The chart switch was sitting under "test coverage work", which is not what it is.
It now has a subsection of its own with the three entries that belong together:
the switch to FerretDB (published by this release's charts job, not before it),
the two release-path guards - a version bump cannot rewrite the database image,
and old releases are not backfilled onto a database they never ran - and the
Artifact Hub report about packages whose container image was deleted, which the
index already excluded and the backfill would have put back.

The first entry also carries what came after it: every FerretDB setting saying
what it would be on MongoDB, both compose files linked, the production notes
pointed at from three places, and the Docker Hub mongo:7 the FerretDB image
replaced named beside the three registries that carry it.
2026-08-12 18:02:17 +03:00
Lauri Ojansivu de8a293850 Backfill: rebuild the chart index with the tool that checks images.
From an Artifact Hub scan report of wekan/charts, 2026-08-11:

  error scanning image docker.io/bitnami/mongodb:7.0.14-debian-12-r3 (package wekan:7.94.0)
  error scanning image ghcr.io/wekan/wekan:v8.30: image not found (package wekan:8.30.0)
  ... v9.12, v9.14, v9.38, v9.39, v9.62

Checked against the live registry: every one of the index's 230 entries points at
an image that exists, so the index itself is already clean - releases/reindex-
charts.py asks the registry about every image a package pins and leaves out the
packages whose image is gone, which is how those seven left the index.

The hole was here. backfill-charts.sh rebuilt the index with `helm repo index .
--merge`, and helm indexes what it FINDS: 135 packages on that branch have an
image that no longer exists - six WeKan images that were never published, and 129
charts vendoring a Bitnami MongoDB image Bitnami has deleted - so one --apply run
would have put all 135 back and mailed the owner again. It now calls
reindex-charts.py, the same tool release-charts.sh uses, so "a package whose image
is gone stays out of the index" is one rule in one place rather than two tools
that disagree.

The duplicate-entry repair that followed the merge is gone with it: it existed
because `--merge` preserved the duplicates an older release-charts.sh prepended,
and reindex-charts.py writes ONE entry per package keyed on the version inside
it, so a duplicate cannot be written. The rule it implemented - keep the entry
whose digest matches the package on disk - is exercised where it now lives,
tests/reindexCharts.test.cjs, which already covers it.

tests/backfillCharts.test.cjs loses the extract-and-run of that block and gains
the two checks that matter now: no `helm repo index` in the script, and helm
still required for the one thing only helm does (packaging).
tests/chartsFerretdb.test.cjs pins the same rule from the chart's side, including
that a registry which cannot be reached is never read as "image gone" - that
would drop good entries on a network hiccup.

Six packages whose WeKan image no longer exists were deleted from the charts
gh-pages branch in the same round; the 129 Bitnami ones are left, since their
WeKan image is fine and only the old subchart's database image is missing.

Verified by running both suites and the whole node suite (385 suites, 0 failed).

Thanks to xet7 !
2026-08-12 17:44:32 +03:00
Lauri Ojansivu 2ad02cccc5 Release path: the next release publishes the FerretDB chart, and only for releases that had FerretDB.
Checking the Release All / Release All Missing charts jobs against the chart
change turned up three things worth fixing and one worth stating.

WHAT ALREADY WORKED, verified rather than assumed: a release changes exactly
three things in the chart - appVersion, the chart version, and the WeKan image
tag - and packages whatever else is on the charts repo's main branch. That branch
now holds the FerretDB chart, so the next release publishes it. Simulated with
the real substitutions for a hypothetical 10.86: appVersion 10.86, chart 10.86.0,
image ghcr.io/wekan/wekan:v10.86, no dependencies, ferretdb enabled with
ghcr.io/wekan/ferretdb:latest, both ferretdb templates in the package, and the
FerretDB MONGO_URL helper.

The tag substitution is `tag: v<digits>` and every other image in the chart is
`tag: latest` - FerretDB and the two busybox images - so a WeKan version bump
cannot rewrite the database image. The test asserts that against the pattern
itself rather than against a copy of it.

BACKFILL NEEDED A FLOOR. releases/backfill-charts.sh fills holes in the published
index by packaging TODAY'S chart with an old release's numbers on it. Today's
chart installs FerretDB, and WeKan did not default to FerretDB until v10.00
(commit 4dbf99010, released 2026-07-18) - so a backfilled chart for v6.09 would
pair that image with a database nobody ever ran it against, published under a
version number that says it is that release's chart. Versions below the floor are
now reported as "older" and left without a chart; CHART_FERRETDB_FLOOR overrides
it. Old ENTRIES are untouched either way: a version that already has a package is
"keep", and keep is never rebuilt.

The comments in release-charts.sh no longer describe a MongoDB dependency that is
gone; they say what the anchored substitutions protect now.

tests/chartsFerretdb.test.cjs gains the release-path half, which runs whether or
not the charts clone is present: three substitutions and no more, `tag: latest`
unmatched by the tag pattern, the image-existence check before publishing, and
the backfill floor.

Verified by running the suite (12 tests) and the whole node suite (385 suites,
0 failed).

Thanks to salcinad, ouvry-ems and xet7 !
2026-08-12 17:29:25 +03:00
Lauri Ojansivu 7a38213ab6 CHANGELOG: the Helm chart's FerretDB switch, guarded from here. 2026-08-12 17:15:06 +03:00
Lauri Ojansivu 5d1c9d0a3c Tests: pin the Helm chart's FerretDB switch from this side of the fence.
wekan/charts is a separate repository with no test runner, and the two things
that went wrong in it (charts#54, charts#55) are WeKan's own settings: the URL
parameter WeKan needs (#6582) and the environment WeKan's docker-compose.yml runs
FerretDB with. So the guard lives here, reads .tools/charts, and SKIPS with a
message when that clone is absent rather than passing quietly - the same shape as
the FerretDB checks in tests/fixedVulnerabilityClasses.test.cjs.

What it holds: no per-pod StatefulSet DNS may be assembled in _helpers.tpl again
(that was charts#54 - Bitnami's naming against groundhog2k's services, so WeKan
dialled a host that does not exist); the Service the URL names is the Service the
chart creates; directConnection=true is there and an external URL that already
sets it is left alone; the WeKan environment is docker-compose.yml's FerretDB one
(polling, sockjs, WRITABLE_PATH, WITH_API) with no MONGO_OPLOG_URL and no second
MONGO_URL; the image is ghcr.io/wekan/ferretdb with quay.io and Docker Hub named
as alternatives; there is one replica and the reason is written down; the data
survives a restart; and an existing MongoDB is still reachable after the upgrade.

One check is a sweep rather than an example: every setting docker-compose.yml
documents has to appear in values.yaml. It found NODE_OPTIONS missing, which the
chart now carries too - a Helm user gets the same reference a Docker user does.

Verified by running the suite against the current .tools/charts checkout (9 tests)
and the whole node suite (385 suites, 0 failed).

Thanks to salcinad, ouvry-ems and xet7 !
2026-08-12 17:13:36 +03:00
Lauri Ojansivu b07b599af8 CHANGELOG: repoint the Worker-role link after the commit was amended. 2026-08-12 16:06:41 +03:00
Lauri Ojansivu 2328b70aa9 CHANGELOG: the Worker role doing what it is for, and the card that lost its name. 2026-08-12 16:05:15 +03:00
Lauri Ojansivu f2e58945f0 Fix #3144: an archived card is still named in its own history.
"Activities for archived card displayed as undefined on board settings." Move a
card around to make some activities, archive it, open the board sidebar - and the
sentences that named that card name nothing. The reporter said what they wanted
instead: "{{ card title }} [archived]".

The feed asked `activity.card()` and rendered its title. An archived card is not
published to the client, so that lookup returns nothing and each sentence was
built around a hole: a card that still exists, still has a title, and whose
activities are right there on the page went nameless in its own history.

The activity already knew the title in most cases - createCard, moveCard and
moveCardBoard have stored `cardTitle` for years. So the answer is not to publish
archived cards to every sidebar, which would send data to clients that do not need
it; it is to read what the activity recorded. models/lib/activityCardLink.js
decides, in order: the card's CURRENT title when the card is here (a card renamed
since should read as it reads now), marked when that card says it is archived; the
title the activity recorded when the card is not here; and null when neither
exists, which the feed renders as "this card" rather than as a gap in the middle
of a sentence.

The link survives all of it. A card URL is /b/<boardId>/<slug>/<cardId>, and an
activity carries both ids, so the anchor is built without the card document
(models/lib/cardUrl.js). No card document also means no slug, and the fallback
'board' is a segment the route ignores - it does not invent "/b/undefined/...",
which is the bug that helper was written for (#6427).

A missing card is NOT called archived, and that is deliberate. It nearly always is
- a deleted card takes its activities with it (Cards.cardRemover) - but with lazy
card loading a card can simply be outside the window this client was sent, and
"archived" would then be a statement the feed cannot support. The mark is used
only where the card is here and says so itself.

The two activities that were NOT storing a title are the two about archiving:
archivedCard and restoredCard now record `cardTitle` like the others. They are
exactly the ones guaranteed to be about a card the client can no longer look up,
which is why this bug was reported about archiving and not about anything else.
Activities written before this have no title, and those are the ones that read
"this card".

Both strings are translated (`archived`, `this-card`, which already exist in every
language file), and a title out of the database is still sanitised before it
reaches the page.

tests/activityCardLink.test.cjs covers the reported case and the ones around it:
the live title winning over the recorded one, the board slug when the board
document is at hand, an activity with no cardId naming the card without inventing
a link, junk titles ('', '   ', null, numbers, objects) never becoming titles, and
a missing card never being called archived. Plus both ends of the wiring: the
archive activity recording the title, and no call site passing only the card.

Verified by running the suite and the whole node suite (384 suites, 0 failed). Not
verified in a browser: this sandbox has none.

Thanks to rptl and xet7 !

Fixes #3144.
2026-08-12 16:01:51 +03:00
Lauri Ojansivu 7b35cb5f03 Fix #3189: a Worker can move a card and assign themselves to it.
"User with Worker permission can't assignee card if it has been assigned to
someone else before." The card showed the reporter's name for a moment and then
showed the previous assignee again - an optimistic write the server threw away.
The previous assignee was a red herring: a Worker could not assign themselves at
all.

The board schema defines the role as "only allowed to move card, assign himself to
card and comment". Both of those are card UPDATES, and the capability table gives
Worker `write: false` - so the role defined by two specific writes was allowed
neither. The client had already built the UI for it: the assignee popup shows a
Worker exactly one name, their own. The server discarded what they clicked.

Widening `write` to true was never the fix - that hands a Worker every field of
every card, which is the opposite of what the role is. What was missing is the
field-level policy the "Known gaps" section of docs/Features/Members/Roles.md said
this needed, so that is what this is.

models/lib/workerCardWrite.js decides an update by WHAT IT WRITES:

  MOVE          $set of listId / swimlaneId / sort, plus the dateLastActivity and
                modifiedAt written with them
  SELF-ASSIGN   $addToSet or $pull of `assignees` with EXACTLY the caller's own id

and refuses everything else BY DEFAULT. Not "deny a list of dangerous fields" -
allow only when every operator and every field in the modifier is one of the
above. A modifier it does not recognise ($unset, $rename, $inc, a raw replacement
document, an operator MongoDB adds next year) is not a move and not a
self-assignment, so it is refused. A qualifier on the assignee write ($each, $in)
is refused rather than unpicked: a policy that starts unpicking list shapes is a
policy with a hole in it.

Deliberately not included: boardId, because "move card" here means within the
board and a cross-board move is a different act with its own guard
(GHSA-gm7v-pc38-53jr); `members`, because the role says assignee; and assigning or
unassigning anybody else, because the role says himself.

server/permissions/cards.js reads the modifier now (Meteor passes it to the allow
rule and it was being ignored) and consults the policy only for a member who has
no write access AND is a Worker on that board. Write access is still the first
question, and the vote/poker canaries still fire before either.

`moveCard` becomes its own capability in models/lib/boardRoleCapabilities.js -
true for every role that can write, plus Worker - so the client's canMoveCard()
asks for the same thing the server enforces. That is one answer added rather than
eight changed.

docs/Features/Members/Roles.md gains the column, the paragraph that used to
explain why moving a card could NOT be its own column now explains why it is, and
the Worker entry moves from "Known gaps" to "Fixed". The gaps section is empty and
says so.

tests/workerCardWrite.test.cjs covers both halves of the role and, more
importantly, the refusals: somebody else's name, a move with a passenger field, a
title, a label, members, an $unset, an $inc, a $rename, a whole-document
replacement, $each/$in qualifiers, and junk of every shape - none of which may
become permission. tests/boardRoles.test.cjs gains the column and flips its
gap test: the ⚠ that marked this role had to go with the fix.

Verified by running both suites and the whole node suite (383 suites, 0 failed).
Not verified in a browser: this sandbox has none, so the client half is verified
from the code and the popup template that has always shown a Worker their own name.

Thanks to rptl and xet7 !

Fixes #3189.
2026-08-12 15:58:17 +03:00
Lauri Ojansivu 49463d3dcb CHANGELOG: the snap reconciling its two copies, and the link that closed a card. 2026-08-12 15:40:15 +03:00
Lauri Ojansivu 75a23b76ae Fix #6588: a file:// link in a card no longer makes the card impossible to open.
"A card whose description or a comment contains a `file://` URL cannot be opened.
Clicking the minicard plays the open animation but the card details panel never
mounts. There is no visible error by default because Blaze swallows the render
exception." Captured, it was:

  TypeError: this.__schemas__[t.toLowerCase()].validate is not a function
      testSchemaAt / matchAtStart / markdown-it (tokenize -> parse -> render)

WeKan registers eight custom URL schemes so links like `thunderlink:` and
`file://` are recognised, and it registered them the linkify-it 4/5 way:

  Markdown.linkify.add(scheme + ':', 'http:')      // "behave like http:"

linkify-it 6 removed string aliases and builds the definition by SPREADING it:

  const def = { normalize: ..., ...definition };

Spreading the string 'http:' produces {0:'h',1:'t',2:'t',3:'p',4:':'} - an entry
with no `validate` - and testSchemaAt then calls `.validate(...)` on it. So every
one of those eight schemes was a landmine in any card's text, and the card holding
one could not be opened again. Reproduced here against the installed linkify-it,
not from the report: the test builds that same registration and watches it throw.

Each scheme now carries a validate of its own, which is all the alias was standing
in for: the URL after the scheme - an optional `//`, then everything up to
whitespace or a bracket - with trailing sentence punctuation handed back, so "see
file://server/share/doc.xlsm." does not swallow the full stop.

WHAT THAT DOES NOT DO, because the list promises more than it delivers.
Registering a scheme makes markdown-it RECOGNISE it; two filters then decide
whether a reader gets a clickable link, and both refuse these. markdown-it's own
validateLink blocks file: (with javascript:, vbscript: and data:), and the
viewer's DOMPurify allows only http/https/ftp/ftps/mailto/tel/callto/cid/xmpp
hrefs. So none of the eight has ever produced a clickable link - its only
observable effect was the crash. Making them clickable means relaxing both
filters for schemes that launch local applications, which is the security decision
#3218 asks for and not something to slip in while fixing a crash. It goes to TODO
Later; the tests pin today's answer so the day it changes is a decision.

And the guarantee behind the fix, which is worth more than the fix: nothing a card
contains may make that card impossible to open. The markdown render is wrapped, so
a throw from a plugin, a pathological formula or the next markdown-it upgrade
shows the text as written - escaped, sanitised, unformatted, and readable - instead
of a panel that never mounts.

tests/markdownCustomUrlSchemes.test.cjs runs the real linkify-it and markdown-it
from node_modules: the old registration throwing, every scheme having a working
validate, the reported strings rendering, punctuation staying in the sentence, a
bare `file:` and junk input returning zero rather than throwing, and the viewer's
fallback returning sanitised text rather than nothing.

Verified by running the suite and the whole node suite (382 suites, 0 failed). Not
verified in a browser: this sandbox has none, so the crash and the fix are
exercised against the same dependency the browser would load.

Thanks to rmb82 and xet7 !

Fixes #6588.
2026-08-12 15:36:53 +03:00
Lauri Ojansivu 40ff8151ea Snap: pick the database that holds the work, and merge the other one into its history.
An email report, on an instance being served the older of its two copies: "some
users are unable to log in (error: 'user not found'), and boards created after
mid-July appear to be missing." That is what serving a copy looks like from the
outside - the accounts and boards made after the copy was taken simply are not
there.

Until now the snap handled that by telling the admin to sort it out. #6583 taught
it not to switch on a guess, so when both MongoDB and the migrated FerretDB have
been written to since the migration it printed the two `snap set wekan
database=...` commands and stayed where it was. That message is in `snap logs`;
most people never see it, and their site meanwhile shows the wrong copy.

A file timestamp cannot answer "which copy holds the work" - an mtime says when a
file was touched, and starting a database touches its files. But both copies can
be READ, so ask them. bin/db-eval.mjs gains an `evidence` command (per-collection
counts, plus the newest timestamp any document carries) and bin/database-choose.mjs
decides from the two answers:

  one holds everything the other holds, and is not older -> serve it, nothing to
  merge;
  both hold work the other does not, and one is clearly newer -> serve the newer
  one and copy across what only exists in the other;
  too close to call, or nothing readable -> change NOTHING and keep the existing
  message, because that case is a decision about somebody's work.

"Clearly newer" is a margin, not a tie-break: six hours by default, so the writes
the migration itself makes on both sides cannot decide anything.

WHY THE MERGE IS SAFE, and it is WeKan's own design that makes it safe: the
history is APPEND-ONLY (docs/Features/Reports/History/History.md, which gains a
section 9a on exactly this). Activities, comments and the coming changeHistory
rows are never rewritten in place, so they can only be ADDED to. bin/database-
merge-missing.mjs therefore inserts every document whose _id is ABSENT from the
chosen database and does nothing else:

  * a document that IS there is never overwritten - a card edited on both sides
    keeps the version in the copy being served, which is the newer one;
  * nothing is deleted, on either side;
  * the copy that was not chosen stays on disk, so `snap set wekan database=<the
    other one>` is still the way back - now a choice rather than a repair;
  * the work done on the copy that is not being served becomes readable in the
    served copy's card History instead of sitting in a database nobody opens.

For the reported symptoms that means the missing users and boards are inserted
rather than left behind: a user who could not log in exists again, with their own
_id, and their boards with them.

A three-way merge - reconciling two edits of one field - is not attempted and will
not be from a script. Where the evidence cannot separate the copies, the snap
still says so and hands the decision to a person.

WEKAN_AUTOPICK=false turns the whole thing off, `database-autopick --dry-run`
shows what it would do, and AUTOPICK_MERGE_LIMIT bounds how much it copies.

tests/snapDatabaseAutopick.test.cjs covers the decision (both superset
directions, the both-diverged case, one side empty or unreadable, too-close, no
timestamps, and a BIGGER but older copy that must not win on size) and runs the
merge against an in-memory pair of databases: what is missing is inserted, what
exists is not overwritten, nothing is deleted on either side, --dry-run writes
nothing, a limit is not an error, and the merge script contains no update or
delete operation at all.

tests/ferretdbMigrationStale.test.cjs keeps its rule - no switch on a guess - with
the assertion updated to match what changed: this branch may not choose from the
timestamps it has, it delegates to the one thing that can choose, and when even
that cannot tell the copies apart the admin still gets both commands.

Verified by running both suites and the whole node suite (381 suites, 0 failed).
The snap itself cannot be built or refreshed in this sandbox, so the orchestration
that starts each database on a temporary port is verified from the code and from
the shapes migration-control already uses, not from a running snap.

Thanks to xet7 !
2026-08-12 15:30:25 +03:00
Lauri Ojansivu b7affb7a94 Update dependencies: the S3/GCS clients, bson, ws and the build toolchain.
Two Meteor packages and 63 npm ones, from a lockfile refresh - package.json's own
ranges are unchanged, so these are the versions those ranges already allowed.

The two that reach running WeKan rather than a build: bson 7.3.1 -> 7.3.2, the
codec the driver speaks to MongoDB and to FerretDB with, and ws 8.21.2 -> 8.21.3,
the WebSocket under Meteor's DDP connection. aldeed:collection2 4.2.1 -> 4.2.2 is
the schema validator - the one whose error formatter turned a list with no title
into "Cannot read properties of undefined (reading 'title')" in v10.80.

The AWS SDK (client-s3 and eighteen credential/signing/presigner packages, plus
the @smithy transport under them) and @google-cloud/storage are the optional
attachment backends: unused unless S3 or GCS is configured. The rest - rspack,
terser, browserslist and its tables, eslint and @typescript-eslint, puppeteer,
memfs and the @jsonjoy.com filesystem packages - are build- and test-time only
and ship in no WeKan bundle.

The CHANGELOG entry is the dependency batch of the upcoming release, in the
established form: what each one IS, so a reader can tell which of them could
affect their instance and which cannot.

Verified by running the whole node suite (380 suites, 0 failed) after the update.

Thanks to dependabot.
2026-08-12 15:16:52 +03:00
Lauri Ojansivu c495cb5d29 CHANGELOG: what the PDF fix could not reach, and one report with nothing to point at yet. 2026-08-12 15:13:36 +03:00
Lauri Ojansivu 35184e89bd CHANGELOG: the snap's four ways of serving a copy, and the reported UI bugs. 2026-08-12 15:11:49 +03:00
Lauri Ojansivu eb47e04657 Tests: ask whether a fixed mistake is anywhere ELSE, and close two gaps it found.
Every entry in https://wekan.fi/hall-of-fame/ has a suite pinning the place it was
found. None of them could answer the other half of the question: is the same
MISTAKE somewhere else - in WeKan, or in the FerretDB fork WeKan ships as its
default database? A per-site regression test knows one file, and the next
occurrence is written months later by somebody who never read the advisory.

tests/fixedVulnerabilityClasses.test.cjs asks it as a CLASS, over the whole tree
and over .tools/FerretDB when the clone is there (and saying it skipped those
checks when it is not, rather than passing quietly):

  ZipBleed      an archive entry naming its own destination path, without a
                containment check - and only for real archive readers, since a
                readdir entry name is one component and can escape nothing
  WhereBleed    a client-supplied selector carrying operators that execute
                JavaScript
  TransitBleed  rejectUnauthorized: false, NODE_TLS_REJECT_UNAUTHORIZED, and Go's
                InsecureSkipVerify in the FerretDB fork
  SourceBleed   eval() / new Function() on the server
  MimeBleed     an SVG served inline by a route that neither sanitizes it nor
                forces a download
  RandomBleed   a secret from Math.random(), and random bytes folded onto an
                alphabet with a bare % - CWE-1204 modulo bias

Comments and string literals are stripped before matching, or the advisory notes
explaining a fix would trip the check enforcing it. Allowed occurrences are listed
with the reason they are allowed. Each check was confirmed to FAIL on the mistake
by writing one into the tree and watching it fire; both probes were removed.

It found one straight away. models/lib/mongoSelectorSafety.js rejected `$where`
and nothing else - but `$where` stopped being the only way a FIND filter runs
JavaScript in MongoDB 4.4, which takes an aggregation expression inside a find
filter, and `$expr: { $function: { body, args, lang } }` there runs a JavaScript
body through the same client-supplied selector. `$accumulator` is the same again.
Both are rejected now, the helper is named for what it tests (hasServerSideJs,
with hasWhere as the alias every caller and the advisory use), and a cyclic
selector cannot spin it - an infinite recursion inside a security check is a
denial of service of its own. tests/mongoSelectorSafety.test.cjs gains those,
plus the negatives that keep it usable: a field literally called `function`, and
`$expr` doing ordinary comparison, are both fine.

randombleed moves from RECORDED to GUARDED in tests/securityRegressionCoverage.
test.cjs, and its gap count from 25 to 24: the rejection sampling CWE-1204 was
about is now held everywhere rather than in one file.

And one release entry had shipped with no test at all - the only one since v10.80
that is not a translation update. tests/databaseProblemsFixes.test.cjs covers it:
the default-swimlane self-heal calling the synchronous upsert Meteor 3 removed
(and the getter that starts the async one without awaiting it, which must handle
its own rejection or a self-heal failure takes the process with it), List.move()
inserting a list with no title and getting an error from the error formatter
rather than from the validator, and the two classifier rules that stop either
reading as "unknown / unclassified" in Admin Panel / Problems - on every database
WeKan runs on, and without swallowing an ordinary network error.

Coverage checked, not assumed: all 60 published vulnerabilities are named by a
suite, and every one of those suites carries negative assertions as well as
positive ones. New suites need no registration - build.sh's test run calls
tests/run-node-suites.cjs, which DISCOVERS every tests/*.test.cjs - so all three
files here are in the "Run WeKan's own tests" options already.

Verified by running the new suites and the whole node suite (380 suites, 0 failed).

Thanks to xet7 !
2026-08-12 15:06:45 +03:00
Lauri Ojansivu eb14168893 Fix #6586: the PDF export writes umlauts, not question marks.
"When using Board settings » Export board » export / PDF we get a pdf file but the
umlauts (german, ä, ü, ö,...) are corrupt", "all the text in this PDF file is
markdown formatted - this doesn't make sense in a pdf file, does it?", and "I can't
see in which swimlane a card is in that export, no tags".

The umlauts were removed on purpose, one line before anything was written:

  .replace(/[^\x20-\x7E\n]/g, '?')

so "Grüße" left the server as "Gr??e". That line was a workaround for the real
problem rather than a choice: text in a PDF is BYTES PLUS AN ENCODING, and the
exporter declared neither. A `(...)` string in a content stream is read through the
font's encoding; no /Encoding was given, so a viewer used the font's built-in one,
where the bytes above 127 are not the Latin-1 letters - and the file was assembled
as a UTF-8 string, so one "ü" would have arrived as two bytes and drawn as two
wrong glyphs. With no encoding to write into, erasing the letters was the only
thing left that did not corrupt the file.

So the font declares /WinAnsiEncoding - Windows-1252, which every viewer has for
the base-14 fonts - the text is encoded to those single bytes, and the file is
assembled as binary. The xref offsets are measured in bytes for the same reason:
measured as UTF-8, every offset after the first accented character is wrong, and
some viewers refuse the file outright.

What Windows-1252 does not have is transliterated rather than erased: a Latin
letter loses its mark (ā -> a, ź -> z, Ł -> L) and keeps its word. Only a script
the base-14 fonts cannot draw at all - Cyrillic, Greek, Hebrew, Arabic, CJK, emoji
- still falls back to '?', because the fix for those is an embedded Unicode font
and that is a font binary this repository does not carry. It is in TODO Later.

Markdown is flattened instead of printed. `**bold**` in a PDF is four asterisks,
and the board export was itself emitting markdown - "## List (3)", "- Card" - into
a file that has no markdown in it. The structure is drawn now: headings and titles
in Courier-Bold, cards as bullets, and a card's own markdown reduced to its words
(links keep their text and their URL). Emphasis underscores only count at a word
boundary, so `snake_case` and `file_name_here` are left alone - markdown treats
them the same way.

And the board export says what it was missing: swimlanes (walked swimlane by
swimlane when a board has more than the one every board is created with, and not
mentioned at all when it does not), labels, members, assignees and the dates.

The PDF itself - encoding, flattening, wrapping, pagination - moves to
models/lib/pdfDocument.js, which is pure, so tests/pdfExport.test.cjs reads the
BYTES that come out: that ü is 0xFC and ß is 0xDF, that the euro sign is 0x80,
which Latin-1 does not have at all, that every xref row points at the object it
claims, and that the bold font is selected only where a heading asks for it. Its
negative half is the part worth keeping: junk never throws, an empty board still
produces a file that opens, `2 * 3 * 4` and `a_b_c` are not emphasis, and a
Japanese title degrades to '?' without ever emitting a byte above 0xFF into the
stream.

Verified by running the suite and the whole node suite (378 suites, 0 failed). Not
verified against a PDF viewer: this sandbox has none, so the bytes are checked
against the PDF specification's own rules instead.

Thanks to Heart1010 and xet7 !

Fixes #6586.
2026-08-12 14:53:06 +03:00
Lauri Ojansivu 32b61f186f Checklists: "Show on minicard" is an override, so unchecking it hides the checklist.
Reported by email with a screenshot (Swedish): "Även om jag avmarkerar 'Visa på
minikort' (och vill dölja checklistan på minikortet)" - the switch is off and the
checklist is on the minicard anyway.

It was off for everybody. The minicard asked:

  board.allowsChecklistsOnMinicard || checklist.showChecklistAtMinicard

and the board flag defaults to TRUE. An OR cannot be argued with: while the board
setting is on, no value of the checklist's own field changes the answer. The popup
made it look like a working switch - it drew the state from the raw field, which
starts false, so it read OFF beside a checklist that was plainly showing, and
clicking it wrote true and changed nothing visible. Clicking again wrote false and
changed nothing either. Dead in both directions, which is exactly what the
screenshot shows.

The two settings are a DEFAULT and an OVERRIDE, not two ways of saying yes:

  showChecklistAtMinicard === true   -> shown, whatever the board says
  showChecklistAtMinicard === false  -> hidden, whatever the board says
  showChecklistAtMinicard undefined  -> follow the board's default

An override needs three states, so the field loses `defaultValue: false`: with
every checklist born false, "hidden" and "not chosen" are the same value and the
override cannot exist. models/lib/minicardChecklistVisibility.js is the one rule,
used by the minicard, the popup's switch and the toggle - and the toggle now flips
what is ON SCREEN rather than the raw field, because flipping the field is what
made the first click do nothing.

Checklists written under the old default carry a false that meant "follow the
board". Left alone under the new reading it would mean "hide this one" and every
checklist would vanish from every minicard - the mirror image of the bug. The
schema-upgrade step checklist-minicard-unset clears exactly those, and it is the
one step there that must run ONCE EVER rather than once per version: afterwards a
stored false is somebody's choice, and re-running would put their checklists back.
It keeps its own marker beside the schema-upgrade one for that.

tests/minicardChecklistVisibility.test.cjs covers the reported case, the opposite
one, that two clicks return to the board default, and that only true and false are
opinions - a string, a number or a null from an old REST client or a bad import
must not quietly mean "hide". tests/schemaUpgradeSteps.test.cjs gains the step,
including the negative: after it has run, a false set by a person survives the next
release.

Verified by running both suites and the whole node suite (377 suites, 0 failed).
Not verified in a browser: this sandbox has none.

Thanks to xet7 !
2026-08-12 14:45:07 +03:00
Lauri Ojansivu f7b0a4d1d2 Fix #6587: a board's type now follows Member Settings / Font size.
"The font size setting in the user menu (Settings -> Font Size) has no effect on
mini cards, while it correctly applies to other UI elements."

Two halves, and both had to be wrong for this to happen.

The preset was applied to <body>. A font-size on body is inherited by children
that ask for a relative size, and it leaves `rem` alone entirely - rem is measured
against the ROOT element. So anything sized in rem never heard about the setting.

And a board's type is sized in px: the list heading, the minicard title and the
"Add card" link were pinned to 16/14/13px in #6465, and the minicard's menu, drag
handle and mobile icons alongside them. px follows nothing at all - not the root
size, not body's - so the board was the part of the UI the setting could not reach
no matter which element carried it.

So the preset moves to <html> and the board's sizes become rem: 1rem, 0.875rem,
0.8125rem, which are the same 16/14/13px at the default root size. The type scale
#6465 set is unchanged and its guard still holds the order; what changes is that a
130% preset now scales it, and 80% shrinks it.

The size goes on ONE element only. With it on html AND body, a 130% preset
compounds to 169% for everything sized relative to body. Everything under body
still scales, because body inherits the root size - so nothing is lost by moving
it up, and uiFont.js sends only the font-size marker class to the root while the
font-family and colour rules keep matching on body, where their form-control
selectors live.

rem, not px and not vw: px cannot follow the setting, and vw is what made elements
resize with the browser window (fixed in earlier commits, deliberately). rem
follows the root font size and nothing else, which is exactly the preference the
user set.

The report also says minicards are "approximately 7-8pt at 100% browser zoom". At
the default root size a card title is 14px, and this commit does not change that
number - what it changes is that the setting can now raise it. If cards still come
out much smaller than the list heading above them, the board's own zoom control is
the other thing that scales them, and it is not affected here.

tests/listTypeScale.test.cjs reads rem as well as px (1rem = 16px at the default,
so the pinned ORDER reads the same either way) and gains two checks of its own:
that each of the three sizes is in a unit the preset can scale, and that the preset
lands on the root element and only there.

Verified by running that suite, tests/uiFonts.test.cjs and the whole node suite
(376 suites, 0 failed). Not verified in a browser: this sandbox has none.

Thanks to CCmesch and xet7 !

Fixes #6587.
2026-08-12 14:38:27 +03:00
Lauri Ojansivu 4400629066 Snap upgrade: copying the old common directory back is what destroys the restore.
From an upgrade report by email. The admin upgraded 6.09 -> 10.85 exactly by the
documented route - dump, move /var/snap/wekan/common aside, refresh, restore - and
it worked: the boards were back. Then, to get their attachments, they undid step 3
the way it reads:

  cp -pR /root/common/* /var/snap/wekan/common/

Their log, seconds later:

  mongodb-control: line 489: 351018 Aborted   ... $SNAP/bin/mongod --dbpath=...
  snap.wekan.mongodb.service: Main process exited, code=exited, status=134/n/a

"After that, Wekan was still running, but all the boards were missing."

Everything beside files/ in that directory is the raw database - *.wt, WiredTiger*,
_mdb_catalog.wt, sizeStorer.wt, storage.bson, mongod.lock, journal/ - and copying
it over a RUNNING mongod replaces the files it has open underneath it. mongod
aborts, and what comes back up is the 6.09 database it cannot read.

The documentation is where this is decided, because the mistake is made before any
code of ours runs. Step 6 already said to copy back only `files`; what it did not
say is what the obvious inversion of step 3 costs, so it says it now, in the step
itself: the exact wrong command, which files those are, what the failure looks like
in `snap logs wekan.mongodb` so somebody who has already done it recognises their
own log, and that the whole directory IS put back in one place - the "going back to
6.09" procedure - onto a STOPPED snap with the current contents removed first,
which is a different operation.

And mongodb-control now recognises the abort it produces. Exit 134 gets a case of
its own beside exit 132 (SIGILL/AVX): SIGABRT, the data directory changed underneath
a running mongod, which files may and may not be copied back, and the one way out,
`snap run wekan.database-restore <archive>`. Before this the whole story in the log
was the word "Aborted".

The same report also had a board with attachments that loads forever after a
restore. That one is not diagnosed here - the attached logs are mongod startup
only, with no errors in them - so it goes to TODO Later rather than being guessed
at.

tests/snapCopyBackDestroysDatabase.test.cjs pins both halves, including that the
warning sits in the step where the mistake is made and above the procedure it must
not be confused with.

Verified by running the new suite and the whole node suite (376 suites, 0 failed).

Thanks to xet7 !
2026-08-12 14:31:58 +03:00
Lauri Ojansivu c3811c5283 Snap: a MongoDB this snap has no reader for stops and says so, on every CPU.
"It turns out the mongodb version on my installation was even older. It was
running on mongodb 3.2, this is why your 4.2 check was not doing anything." Their
site never showed the explanatory page either: it waited for MongoDB forever,
which is the loop #6471 was opened about.

The migratemongo 3.2 tools are staged for amd64 ONLY - MongoDB published no 3.2
build for any other architecture (the migratemongo part in snapcraft.yaml). So on
arm64 and everything else, a MongoDB 3.x database has NO reader in this snap at
all, and that branch handed the case to fall_back_to_mongodb, which starts mongod
7 again. mongod 7 is the binary that has already refused these files, so it fails,
mongodb-control re-runs the migration, and the site sits on "MongoDB not ready
yet, retrying in 5 seconds..." with the reason only in `snap logs`.

A missing reader and an unreadable database are not the same thing, and only one
of them can usefully be retried. When mongod has already said "too recent to start
up on the existing data files", the tool that could have read them not being in
this snap is a final answer: stop, keep every byte, and serve the page. Data that
no mongod ever called too old still falls back to MongoDB as before, because that
case may yet come up serving. The log names the architecture, since that is the
part an admin cannot see from the outside.

The page also stops leaving the way out as an exercise. "Move the data across with
a MongoDB that can read it" was true and not enough - the reporter worked out the
steps themselves, and another admin who got this far lost their restored database
to the step that is easy to get wrong: they copied the old data files back into a
RUNNING data directory and mongod aborted with SIGABRT. So the page gives the four
steps in this snap's own paths: copy $SNAP_COMMON somewhere safe, dump it with a
MongoDB of that vintage, move the old database files OUT (naming them, and saying
that files/ - the attachments and avatars - stays), and restore with the snap's own
`snap run wekan.database-restore <archive>` rather than a mongorestore invocation
they have to assemble.

tests/snapOldMongoData.test.cjs gains the missing-reader branch and reads the
page's own HTML for the four steps. Its "newest first" test no longer checks the
order by "the first mongod_says_data_too_old comes after the 3.x probe": there are
now two ways the third reader produces no database - it is ABSENT, decided just
before the probe, or it RUNS and refuses, decided just after - and neither is
reachable until mongod 7 and mongod 4.2 have both failed, which is what that test
is really about.

Verified by running the suite and the whole node suite (375 suites, 0 failed). The
snap itself cannot be built or refreshed in this sandbox, so the arm64 path is
verified from the code and the packaging, not from a running snap.

Thanks to Philippe-Bentegeac, JDeepix and xet7 !

Fixes #6471.
2026-08-12 14:28:49 +03:00
Lauri Ojansivu 4b99fa701d Snap: the old MongoDB files are not WeKan's problem once it runs on FerretDB.
"It somehow tries to access Mongodb again instead of Ferretdb. I don't even have
an old version, but just this" - and the screenshot is the #6471 page, "Wekan
cannot open the existing database", on a snap whose data was in FerretDB and
perfectly readable.

The migration never deletes what it copied from, so a migrated snap keeps its old
MongoDB files in $SNAP_COMMON forever. Two places treated their presence as
something WeKan had to act on:

migration-pending called a migration "pending" for any $SNAP_COMMON holding
WiredTiger files without the .migration-to-ferretdb-done marker - the database
setting was never consulted. So an instance whose marker is gone (a forced
re-migration cleared it, or it was migrated by a snap old enough never to have
written one) was dragged back to those files on every start: mongodb-control
handed off to migration-control, which probed them with mongod 7, mongod 4.2 and
the 3.2 tools, and when none could open them wrote .mongodb-data-too-old.

wekan-control then served the explanatory page for that marker unconditionally.
That page is right when MongoDB is the database WeKan needs and cannot be read.
It is wrong when the snap runs on FerretDB: it replaced a working site with an
explanation of a database that site does not use.

Both now ask what the snap is actually running, and "already on FerretDB" means
the SETTING plus real data in files/db - never the setting alone, because
database=ferretdb with an empty files/db is precisely the case the migration
exists for and must still run.

The marker is left in place rather than deleted. It is true - those files really
are unreadable by this snap - and it belongs to MongoDB, so switching back with
`snap set wekan database=mongodb` brings the page back, correctly.

Nothing is switched automatically. #6583 is what choosing between two copies on
the snap's own initiative costs, and this page's own promise is that nothing
changes until an admin acts. What it does now is SAY that a FerretDB copy is
there when one is: a third way forward, the one command that serves it, the one
that undoes it, and the warning that a migrated copy is only as new as the
migration that made it. With no copy to serve it is word for word the page #6471
wrote.

tests/snapFerretdbNotBlockedByOldMongo.test.cjs RUNS migration-pending against
synthesised $SNAP/$SNAP_COMMON trees with a stub snapctl - already on FerretDB,
database=ferretdb with an empty files/db, database=mongodb with a copy beside it,
and the completed marker - and reads the page's own HTML for both shapes. It also
pins that the marker re-exec in the wait loop stays in the MongoDB branch: reached
while serving FerretDB, the new "start WeKan normally" branch would send it
straight back into the loop, exec after exec with no sleep between.

Verified by running the new suite, tests/snapOldMongoData.test.cjs (the #6471 page
it must not change) and the whole node suite (375 suites, 0 failed). The snap
itself cannot be built or refreshed in this sandbox.

Thanks to mueschel and xet7 !

Fixes #6585.
2026-08-12 14:22:24 +03:00
Lauri Ojansivu f5fee7dc01 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-08-12 14:05:35 +03:00
Lauri Ojansivu 894d7e87c0 CHANGELOG: the resume that copied a database three weeks out of date. 2026-08-12 14:05:34 +03:00
Lauri Ojansivu c783829823 Snap: a migration may not resume onto a database the source has outgrown.
"Our snap updated automatically from 10.81 yesterday to 10.85 this morning. Data
in the DB is from about 2 - 3 weeks ago. Many cards and work is lost."

Nothing was lost: the MongoDB data is in $SNAP_COMMON and untouched. What the
update completed was a migration that had been interrupted weeks earlier.

The MongoDB -> FerretDB migration is resumable, and has to be - it can run for
hours, and a snap refresh, a stop or a reboot part-way through is normal rather
than exceptional (migration-control's interrupted_keep_progress). So the importer
records every finished collection in $SNAP_COMMON/migration-progress.json and
skips those next time.

That checkpoint was only ever checked against the TARGET. migration-control
deletes it whenever it discards a partial SQLite (discard_partial_ferretdb), so it
can never name collections that are not there. Nothing checked it against the
SOURCE - and between an interrupted migration and its retry the snap hands WeKan
back to MongoDB and people go on using it (fail_3x_keep_progress,
resume_next_start). A migration interrupted in July and resumed in August
therefore skipped every collection it had finished in July, copied only the rest,
and reported success. finish_success then switched the snap onto that copy:
boards and cards as they stood in July, with three weeks of work still in MongoDB
and simply not being served.

A new snap revision is usually what sets the resume going again, because the
per-revision failure counter starts at zero (bin/stale-marker) - which is why
this reads as "the update lost my data". The update is when the weeks-old copy
finally got served. Same family as #6583, and the same answer: a copy made at one
time may not be used as if it were current.

Two checks, because they fail differently.

bin/migration-checkpoint-stale.mjs asks whether the checkpoint is too old to
resume from at all. mongod rewrites its WiredTiger files whenever it commits, so
the newest mtime among them is when MongoDB was last written to - the evidence
bin/ferretdb-migration-stale already uses for the finished-migration case. Later
than the checkpoint (by more than a margin, since the migration's own temporary
mongod writes on the way out) means the collections it lists are copies of an
older database, so the collection half is dropped and they are copied again. The
checkpoint's age is the OLDER of its updatedAt and the file's mtime: the two
disagreeing is a reason to doubt it, and a needless re-copy costs minutes while a
wrong skip costs weeks. The file half is kept - attachments are written once and
re-verified on disk at the recorded size, and re-extracting gigabytes is the
slowest part of a resume.

migration-control runs it as its first step, before it starts any mongod of its
own: starting one rewrites those files, and after that the question can no longer
be asked. It is the only way into a text migration - snap run wekan.migrate,
auto-migration and mongodb-control's fallback all arrive through it.

verifyCollectionCounts, in both importers, then asks the two databases themselves
once the copying is done, when both are quiet: a collection whose copy holds fewer
documents than the source is copied again from the source as it is now. It does
not care WHY a copy came up short, which is the point of a check standing behind
another one. Only a shortfall is acted on - a copy holding MORE is a resume
carrying documents deleted from MongoDB since, and deleting on that evidence is
the guess this whole family of bugs is made of - and a shortfall that survives the
second copy is reported rather than fatal, so one document FerretDB will not
accept cannot leave the snap in a migration it can never finish. It sits inside
the same "not repair mode" block as the copying it verifies, since a FILES_ONLY
repair (#6473) must never write text over a FerretDB people have been using.

tests/migrationCheckpointStale.test.cjs runs the detector against synthesised
$SNAP_COMMON directories: the reported case, an ordinary resume minutes later, the
margin that covers the migration stopping its own source mongod, a mongodb.log
that must not count as somebody using the database, and that migration-control
asks before it starts a mongod. tests/migrationCopyShortfall.test.cjs extracts
verifyCollectionCounts from BOTH importers and exercises it behaviourally.

Verified by running both suites and the whole node suite (374 suites, 0 failed).
The snap itself cannot be built or refreshed in this sandbox.

Thanks to Alishara and xet7 !

Fixes #6585.
2026-08-12 14:05:34 +03:00
github-actions[bot] c87da21587 Bump versions for v10.85 2026-08-12 04:19:57 +00:00
Lauri Ojansivu 6c8e4e88b3 Prepare v10.85 release 2026-08-12 07:18:38 +03:00
Lauri Ojansivu 2d3d6d6ca7 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-08-12 06:55:35 +03:00
Lauri Ojansivu 5b23954be8 CHANGELOG: the Font Awesome bounding boxes Firefox kept correcting. 2026-08-12 06:55:34 +03:00
Lauri Ojansivu f368c1a2ac Font Awesome: state the bounding box each glyph actually has.
Firefox logged one warning per glyph, on every page load, for every Font Awesome
file WeKan serves:

  downloadable font: glyf: Glyph bbox was incorrect; adjusting (glyph 19)
  (font-family: "Font Awesome 6 Free" style:normal weight:400 stretch:100
  src index:0) source: .../webfonts/fa-regular-400.woff2

819 of the 2163 glyphs in the four files were affected - 540 in fa-solid-900,
204 in fa-brands-400, 59 in fa-regular-400, 16 in fa-v4compatibility - so the
console filled with hundreds of lines and stopped being useful for spotting
anything else.

Every TrueType glyph stores its own bounding box in the `glyf` table. Font
Awesome ships boxes that are TIGHTER than the outline: they bound the on-curve
points only, while the box has to bound the control points too, because a
quadratic curve can bulge past its endpoints. Firefox's OpenType sanitiser
notices, corrects each box in memory and says so. Nothing rendered wrongly - the
warning was the whole of the damage - but the numbers in the file were wrong,
and they are wrong upstream, in Font Awesome's own build.

releases/fix-font-bboxes.py recomputes them: every glyph's box from its points,
the font-wide box in `head` from those, and the .ttf and .woff2 written from one
corrected font so the pair cannot drift. That last part also settles a smaller
oddity - fa-regular-400.woff2 does not round-trip through fontTools' woff2
reader at all, so it is rebuilt from the .ttf rather than read.

Only `head` and `hhea` differ from the files Font Awesome shipped, and both
follow from the boxes: `head` carries the font-wide box, and `hhea`'s
minRightSideBearing and xMaxExtent are derived from the glyph boxes. Every other
table - glyf outlines, cmap, hmtx, name, OS/2, post, GSUB, GPOS, loca, maxp -
compiles identically. The script verifies that itself, reading back what it
wrote and refusing to leave a font behind whose glyph order, character map,
advance widths, contour counts or point coordinates moved. The files also got
smaller (fa-solid-900.woff2 150024 -> 118388 bytes), which is fontTools'
compression rather than anything being dropped.

recalcTimestamp=False keeps Font Awesome's own head.modified, so two runs over
the same input agree byte for byte - verified - rather than churning a committed
binary with "now" on every run.

tests/fontGlyphBounds.test.cjs parses the .ttf files with its own reader, not
fontTools, so the check does not depend on the tool that wrote them. Verified it
FAILS on the original fa-regular-400.ttf with "59 of 212 glyphs state a wrong
bounding box" and passes on the corrected one.

The guard matters more than the fix here: these fonts are VENDORED, so the next
Font Awesome upgrade drops fresh upstream files straight back into the tree and
the warnings would return silently, months from anything that would explain
them. The failure message names the script to re-run.

Thanks to xet7 !
2026-08-12 06:55:33 +03:00
Lauri Ojansivu 4c02cf5585 CHANGELOG: v10.84 does rebuild its bundles, so it cannot say it does not. 2026-08-12 06:55:33 +03:00
github-actions[bot] 9a9e81f15a Bump versions for v10.84 2026-08-12 01:55:33 +00:00
Lauri Ojansivu aa76ae81b1 Prepare v10.84 release 2026-08-12 04:53:36 +03:00
Lauri Ojansivu 8d0de427ef CHANGELOG: v10.83's binaries table is v10.83's build, not v10.82's.
From the v10.83 build logs. The release itself is fine - every bundle built, the
GitHub release notes carry a correct generated table, and the only job failures
were three snap-launchpad uploads rejected by the Snap Store's own
upload-uniqueness check. What was wrong is the table in the CHANGELOG.

An Upcoming section that rebuilds nothing may say so and reuse the previous
release's table rather than invent one, and this one did: "The binaries below are
v10.82's: nothing here rebuilds them." Releasing invalidates both halves of that.
release-all.sh renamed the heading, every bundle WAS rebuilt, and the sentence
and the table came along unchanged - so v10.83 shipped claiming:

  FerretDB v1.48.0, when v1.49.0 was built and shipped;
  a loong64 row, when loong64 was SKIPPED this release - debian:trixie publishes
    no linux/loong64 image, so there is no loong64 userland to build in, and the
    job says exactly that and uploads no provenance;
  no armhf, armv6, armv7, i386 or win-arm64 rows, all of which were built.

The generated table at the top of the GitHub release notes was right all along -
releases/provenance-table.sh builds it from the provenance.tsv each build job
records, and it has 13 bundles and no loong64 - so the release notes carried two
tables that disagreed. The table here is now that same data: FerretDB v1.49.0
throughout, the five missing platforms added, loong64 gone, and s390x's FerretDB
marked *no checksum published*, which is what the build found rather than a
failed check. Full SHA256s recovered from the per-job build logs, which print
them in full where the release-notes table abbreviates.

This matters because the table is the answer to "which Node.js is in the arm64
bundle of 10.69, and was it checked" after the build log has expired. A table
carried forward unchanged answers that wrongly, which is worse than not
answering.

The Upcoming section keeps the same shape - nothing there rebuilds anything - but
its table is now v10.83's, so whatever is cut next starts from the current build
rather than one two releases back.

tests/changelogFormat.test.cjs gains a guard on the NEWEST release: it may not
contain "nothing here rebuilds them", because a release rebuilds every bundle.
Scoped to the newest deliberately. v10.73 through v10.81 carry the same sentence,
and whether each of those really rebuilt its bundles cannot be established now
that their logs have expired - rewriting eight historical tables on a guess would
put invented provenance where merely doubtful provenance is, so they are left as
they shipped.

Thanks to xet7 !
2026-08-12 04:36:00 +03:00
Lauri Ojansivu f3f6032160 CHANGELOG: the three new strings, in 133 languages. 2026-08-12 04:26:03 +03:00
Lauri Ojansivu 639214574c Translations: the three new Version-pane and checklist strings, in 133 languages.
"invalid-year":       "Invalid year. Please type all four digits, for example 2026."
  "collapse-checklist": "Collapse checklist"
  "expand-checklist":   "Expand checklist"

The keys shipped in en.i18n.json with the card-date fix and the collapsible
checklists; every other language file carried them as English placeholders.
Translated directly, as CLAUDE.md requires - no external translation service,
API or key - from each language's OWN existing strings, so the wording matches
what that file already says rather than being invented beside it.

Three anchors did most of the work. "checklist" and "collapse"/"uncollapse" give
each language its established terms, and "invalid-domain" is the same shape of
sentence as the new one - a rejection, then an instruction with an example - so
its phrasing, punctuation and politeness register carried over directly.

Where an anchor was itself wrong the correct term was used instead of copying the
mistake forward. Several files have terms that drifted in from another language:
Italian "Non collassare" in the Greek and Romanian files, Russian in the Georgian
and Mongolian ones, Vietnamese in the Thai one, Serbian in the Slovenian and
Bulgarian ones, German in the Klingon one. Others use a literal sense of
"collapse" that is not the UI one - Azerbaijani "Yıxılma", Estonian
"Kokkupõrge", Khmer "ដួលរលំ" and Chinese "崩溃" are structural collapse, a
building falling down. The new strings use the folding sense each language
actually uses for this control.

9 languages are deliberately left as English placeholders rather than guessed at:
tlh (Klingon), vo (Volapük), zgh (Tamazight), wa (Walloon), wo (Wolof), uz-AR
(Uzbek in Arabic script), and ve / ve-CC / ve-PP, whose files disagree with their
own locale tags - ve-CC reads as Venetian and ve-PP as Veps, so which language to
write is a question about the file, not about the string. A placeholder says
"nobody has translated this yet", which is true; a fabrication would say
something false in a shipped product. They are the documented
"untranslated everywhere" case and remain open to a translator.

Applied with fill-translations.mjs --apply, which writes ONLY into placeholders:
every language reported "filled 3, skipped 0 existing human translation(s)", so
no human translation was touched. Key order and the 2-space indent are preserved,
all 154 files still parse, and verify-human-preference.mjs passes 10/10.

Also carries two Traditional Chinese improvements that were already staged in the
tree from a Transifex pull - 系統運行時間 to 系統運作時間 in OS_Uptime, and the
same wording in database-migration-description. They are human translations and
the policy is that those always win.

Thanks to xet7 !
2026-08-12 04:24:23 +03:00
Lauri Ojansivu 7b84c74359 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-08-12 04:07:45 +03:00
Lauri Ojansivu 807fc5ea89 CHANGELOG: the staleness guard that called the live database stale. 2026-08-12 04:07:45 +03:00
Lauri Ojansivu 1993d7d3c8 Snap: a started mongod is not a used mongod, so stop calling the live copy stale.
The staleness guard added for #6583 had the opposite failure of the bug it fixed,
and this reporter ran into it:

  "A couple of weeks ago, I did a snap revert ... but then completed the
   migration successfully. Today, my database suddenly reverted to an old version
   from what looks like weeks ago. Upgrading to 10.83 did not fix the problem
   automatically."

Their FerretDB was the live database and had been for two weeks. MongoDB was the
frozen one. The guard decided otherwise because it compared exactly two things:
the newest mtime under the MongoDB data directory, and the migration marker.
STARTING mongod rewrites those files - recovery, and the checkpoint it writes on
startup - so one service start during a refresh put MongoDB's newest mtime at
today against a marker from two weeks ago. The guard called the live copy stale
and wekan-control set database=mongodb, which served data frozen at the migration
date: "reverted to an old version from weeks ago", exactly.

Upgrading could not fix it because the upgrade was the cause. wekan-control runs
this on every start, so every start re-applied it.

An mtime cannot tell "somebody used this database" from "this database was
started". Asking it of ONE copy therefore cannot answer the question. Asking it
of BOTH can, because the case the guard exists for has a shape the mistaken one
does not:

  migrated copy untouched since the migration, MongoDB moved on
    -> STALE (exit 0). Nothing has used FerretDB; MongoDB holds everything since.
  migrated copy moved on, MongoDB did not
    -> CURRENT (exit 1). The normal state after a successful switch.
  both moved on
    -> AMBIGUOUS (exit 2). Two databases have been written to since they were
       copies of each other. A timestamp says when a file was touched, not how
       much is in it, so there is no answer here - only a choice, and it is the
       admin's.

Only exit 0 may be acted on automatically. Both callers test with `if`, so that
falls out of the existing code, and the test pins it - most of all for the branch
in mongodb-control that DELETES files/db to migrate again, where ambiguity
reaching the rm -rf would destroy the very copy in doubt.

The ambiguous case switches nothing and says so, in mongodb-control and in
wekan-control, printing both databases' last-written times and the two commands
to look at each. Nothing is deleted by choosing either, and both live in
$SNAP_COMMON.

The message in wekan-control deliberately has NO database=ferretdb condition on
it. An instance the old guard already moved to database=mongodb is sitting on the
wrong copy right now and its setting persists, so a fix that only speaks up when
ferretdb is selected would leave it there silently. Whichever side is selected,
the admin hears that the other one holds writes of its own.

tests/ferretdbMigrationStale.test.cjs gains the reported regression - a
two-week-old migration, a FerretDB written to a minute ago, a mongod started an
hour ago - and pins that ambiguity can never reach the deletion. Its fixture now
stamps the SQLite at the migration time, which is what the migration itself
leaves behind; a later stamp is what "something has been using FerretDB" means.

Verified by running the detector against synthesised $SNAP_COMMON directories for
all four shapes. The snap itself cannot be built or refreshed in this sandbox.

Thanks to lukechao, markusst1982 and xet7 !

Fixes #6583,
2026-08-12 04:07:45 +03:00
github-actions[bot] ae46562308 Bump versions for v10.83 2026-08-11 16:23:54 +00:00
Lauri Ojansivu f48b2c03e2 Prepare v10.83 release 2026-08-11 19:22:45 +03:00
Lauri Ojansivu 3f9b81bcbc CHANGELOG: the public-board half of the avatar fix. 2026-08-11 19:21:54 +03:00
Lauri Ojansivu 484eae7362 Avatars: honour the ?boardId= the client has been sending all along.
Follow-up to the previous commit, found while checking why the Admin Panel
showed a picture that a board did not. The two URLs differ in one thing: the
avatarUrl helper in client/components/users/userAvatar.js appends
`?boardId=<id>`, and says why in its own comment - "so public viewers can access
avatars on public boards". The Admin Panel uses profile.avatarUrl raw.

/cdn/storage/avatars/:fileName never read that parameter. It required a signed-in
user and nothing else, so on a public board every visitor who was not logged in
got 401 and the missing-picture icon, which is the case the parameter was added
for. Fixing Meteor.userId() alone would have left that half broken.

avatarIsOnAPublicBoard() answers it: the named board must exist, be public, AND
have the avatar's owner as a member. The last part is not ceremony - without it,
naming any public board would unlock any avatar on the instance, and a public
board publishes its own members, not everybody.

The legacy redirect now keeps the query string. /cfs/files/avatars/<id> 301s to
/cdn/storage/avatars/<id>, and that is the path EVERY migrated 6.x avatar URL
takes, so dropping ?boardId= there would 401 exactly the installs the previous
commit set out to fix.

tests/requestUserAuth.test.cjs gains both: that the public path checks all three
conditions and is awaited (an unawaited Promise is truthy, which would make every
avatar public), and that the redirect carries the query and only when there is
one.

Thanks to markusst1982 and xet7 !

Fixes #6583,
2026-08-11 19:21:19 +03:00
Lauri Ojansivu 3f80161607 CHANGELOG: the avatar route that could never serve anybody. 2026-08-11 19:21:19 +03:00
Lauri Ojansivu bf047d53c8 Avatars: ask the request who it is, because Meteor.userId() cannot.
Following the same upgrade as #6583, profile pictures came back as broken
images:

  "I can see Profile Pics from the user in the Admin/User Pannel if i open a
   User. But on the boards i see only the 'missing Picture' Icon"
  "initials are OK, but there is broken avatar image for those that have image"

Nothing was lost, and the migration is not at fault. The avatar files migrate,
and the Meteor-Files record made from a CollectionFS filerecord even reuses its
_id (cfsRecordToMeteorFile), so a 6.x URL still names the right object. What
broke is the request for it.

A 6.x install stores profile.avatarUrl as /cfs/files/avatars/<id>. That route
serves the legacy CollectionFS bytes if they are still there, and otherwise
redirects to /cdn/storage/avatars/<id> - which asked who was asking like this:

  const userId = Meteor.userId();
  if (!userId) { res.writeHead(401); ... }

Meteor.userId() reads the current DDP invocation's environment. That exists
inside a method or a publication and NOT in a WebApp handler, where it does not
return "nobody" - it THROWS "Meteor.userId can only be invoked in method calls
or publications". The handler wraps its body in a try/catch that answers 500, so
the throw was swallowed into a broken image and no avatar served through that
route ever reached anybody, on any install. The upgrade did not cause it; it
moved every avatar URL onto the route where it already applied.

server/routes/legacyAttachments.js had the identical call, so legacy attachment
URLs failed the same way and for the same reason.

Initials were unaffected because they are rendered client-side from the username
and fetch nothing, which is why the report describes them as fine.

The other half of the report - the Admin Panel showing a picture that a board did
not - is NOT explained by this. Both templates end at the same route:
peopleBody.jade puts profile.avatarUrl into an img src, and a board goes through
+userAvatar, which appends ?boardId=. That parameter is the difference, and it
has a cause of its own; the commit that follows this one deals with it.

An HTTP request carries its identity in the request: a bearer token, an
X-Auth-Token header, an ?authToken= parameter, or the login cookie - and on
Sandstorm, a platform-injected user id and no Meteor token at all.
server/routes/universalFileServer.js has always resolved it that way and serves
attachments correctly today. server/lib/requestUser.js lifts that resolution out
so the two routes that were guessing share it rather than grow a third copy, and
so the next file route has something to call. It never throws: a caller deciding
whether to serve a file wants an answer, not an exception its own catch will turn
back into a 500.

universalFileServer keeps its own inline copy for now. Rewiring a working file
server is a separate change from fixing a broken one, and this commit should be
readable as the fix.

tests/requestUserAuth.test.cjs pins that neither route calls Meteor.userId(),
that both await the resolver (an unawaited Promise is truthy and would authorise
everybody), that all four token carriers and the Sandstorm path are handled, and
that the migration still reuses the id the old URL names.

Verified by reading and by the source guard; confirming the served image needs an
upgraded instance, which this sandbox has no Docker to run.

Thanks to markusst1982 and xet7 !

Fixes #6583,
2026-08-11 19:21:19 +03:00
Lauri Ojansivu c623718db1 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-08-11 19:02:58 +03:00
Lauri Ojansivu 4b89394087 CHANGELOG: PassBleed is the Upcoming release, not part of v10.82.
v10.82 was prepared and released while this was being written, so the PassBleed
entry had landed in a section for a release that had already gone out without the
fix - the same way the chart-index entry did after v10.81.

It moves to a new Upcoming section with its own summary and binaries table, and
v10.82 goes back to naming WhereBleed as its single CRITICAL issue, which is what
it actually shipped.

Thanks to TWPaMWang and xet7 !
2026-08-11 19:02:58 +03:00
Lauri Ojansivu 5269d0ba5c PassBleed: the Excel card export authorised one board and read a card from another.
GHSA-6p5m-f9p2-wqm5, CWE-639, CVSS 6.5.

    GET /api/boards/:boardId/lists/:listId/cards/:cardId/exportExcel

checked whether the caller could see the board in :boardId, then resolved the card
by :cardId alone. Nothing confirmed the card was on that board, so the two
identifiers came apart: one decided the authorisation, the other decided the data.
canExport() is given this._boardId and never sees this._cardId, so it can answer
"may this user see that board?" and cannot answer "may this user see that card?".

The pass is self-service. POST /api/boards takes `permission` straight from the
request body, so any authenticated user could mint their own PUBLIC board, name it
as :boardId, and pass the id of a card in somebody else's private board as
:cardId. :listId was never used in a query at all and could be any string.

What came back was the card: title, full description, members and assignees, every
comment with its author, checklists and checklist items, subtask titles,
attachment metadata - and, because image attachments are read through
getReadStream() and embedded with workbook.addImage, the attachment BYTES, which
is functionally an arbitrary private attachment download. One board could be
reused while :cardId was substituted, making it a scriptable bulk-collection
primitive rather than a single disclosure, and the REST API is on by default in
the shipped Docker configuration.

THE FIX WAS ALREADY IN THE CODEBASE, ONE FILE AWAY. The structurally identical
PDF route has always resolved getCard({ _id, boardId, listId }) and 404s a
cross-board id (ExporterCardPDF._getCardData). That control is what shows the
Excel exporter's omission was a defect rather than a decision, and it is exactly
what the Excel exporter does now.

Constraining the QUERY matters more than adding a check after it. The exporter
fans out on the same card id - checklists, checklist items, subtasks, comments,
attachments - and none of those carry a board constraint of their own. A card that
cannot resolve outside the authorised board makes every one of them safe by
construction rather than by a second guard that could be missed the same way.

The route binds the two identifiers as well, before either branch builds. That is
deliberate duplication: it is cheapest to state where both identifiers arrive
together, and it covers the public-board branch, which skips authentication
entirely and would otherwise rely on the exporter alone. A card that is not on the
named board is a 404 rather than a 403, so the difference does not tell an
unauthorised caller whether a given card id exists.

Audited the reporter's systemic point: the only two single-card export routes are
this one and the PDF one, and the attachment handlers already check
card.boardId !== boardId. tests/exportExcelCardContainment.test.cjs pins the
constrained lookup in both places, that the route checks before EITHER branch
builds, the 404, and that the PDF control it copies is still there.

Thanks to TWPaMWang and xet7 !
2026-08-11 19:02:58 +03:00
github-actions[bot] 3b6b601085 Bump versions for v10.82 2026-08-11 14:44:14 +00:00
Lauri Ojansivu fcc3951f40 Prepare v10.82 release 2026-08-11 17:43:11 +03:00
Lauri Ojansivu 418ea838bc CHANGELOG: the two developer-facing fixes, and the summary they were missing from.
Every commit since v10.81 now has an entry. The two that did not were both about
a build or a test run rather than about WeKan, so they go in a developer-facing
subsection at the end, which is where the format puts them:

  the database-conformance stage opening a debug port nothing in it uses, so an
  unrelated FerretDB on 8088 made every backend report a database problem that
  was not one;

  two more mongo42 give-up paths deleting the directory their own stage filter
  names, which on amd64 and arm64 would end the snap build the same way it ended
  all four Launchpad ones.

The subsections now read as one sentence in the order the format asks for -
CRITICAL security, then the new feature, then the bugs, then these - and the
summary paragraph accounts for them instead of stopping at the bug fixes.

Thanks to xet7 !
2026-08-11 17:42:43 +03:00
Lauri Ojansivu 09a525ae83 Conformance: do not open the debug port nothing in the run uses.
From the 2026-08-11_16-55-04 run, every backend failed before a single query was
compared:

    Failed to create debug handler ... listen tcp 127.0.0.1:8088:
      bind: address already in use
    ERROR sqlite      FerretDB did not start on this backend
    ERROR postgresql  FerretDB did not start on this backend

FerretDB opens a debug handler for metrics and profiling at 127.0.0.1:8088 by
default and EXITS when that address is taken. An unrelated FerretDB was running
on the machine, so the conformance stage reported a database problem that was
nothing of the sort - and would have reported it for any developer with anything
on 8088.

The script already takes exactly this seriously for the two ports it knows about:
it picks a free wire port, makes both overridable, and says in its own comment
that they are "chosen so this can run WHILE something else is running". The debug
port was simply never passed, so it always took the default. Nothing in the run
queries it, so the fix is not to find it a free port but not to open it:
`--debug-addr=-`, which is how FerretDB's main.go spells that
(`if addr := cli.DebugAddr; addr != "" && addr != "-"`). All four handler
launches pass it.

Verified against the built binary with 8088 deliberately held: without the flag
FerretDB does not come up and logs that error, with it it starts and logs
nothing.

tests/conformanceDebugPort.test.cjs pins it for all four backends, that the wire
port is still chosen to be free (the care that was already there), and that the
reason stays written down beside the other ports - the next person choosing one
needs to know there is a third.

Thanks to xet7 !
2026-08-11 17:35:48 +03:00
Lauri Ojansivu 68dbbd8b47 CHANGELOG: collapsible checklists and card sections.
A new-feature subsection between the CRITICAL security one and the bug fixes,
which is where the format puts features, and a line in the summary paragraph.

Thanks to czinkos, MikeRatcliffe, JannetGen and xet7 !
2026-08-11 16:43:19 +03:00
Lauri Ojansivu 1cef5ede9a Collapsible checklists and card sections, on the opened card and the minicard.
#1591, open since 2018: "It would be great to have collapsable checklists on
cards" - "Long checklists can make a card pretty cluttered, so being able to
collapse them and expand only when needed would keep the board much cleaner."

WeKan had something adjacent and it is NOT this. A checklist carries
`hideAllChecklistItems`, reachable through a toggle switch inside the checklist
actions popup - but that is a field ON THE CHECKLIST, so flipping it changes what
everyone on the board sees, and it is an edit to the card rather than a view
preference. Wrong thing, and buried where nobody folding a list would look. It is
untouched here; it has its own uses (showChecklist, isFinished).

Collapsing is per-user, which WeKan already says twice in its own models:
lists.js "collapsed state is per-user only, stored in user profile.collapsedLists"
and swimlanes.js the same for swimlanes. So this follows them:
profile.collapsedCardSections[cardId][sectionKey], beside those two.

ONE map for every foldable thing on a card, because they are the same question. A
feature group uses its own section name ('labels', 'members', 'due', ...); an
individual checklist uses 'checklist-<id>', which is why folding a checklist on
the opened card folds it on the minicard too - the same key, both places.

The control is a caret on the title, not a third entry in a menu: the whole point
is to fold at a glance while reading the card. It points down when open and right
when folded, carries aria-expanded, and answers Enter and Space, because it is an
<a> with no href and would otherwise be unreachable without a mouse. The click
stops propagation - on the opened card the title opens the inline rename form,
and a minicard is a link to the card.

For the sixteen feature groups on the opened card this is done ONCE, with a
delegated handler and CSS, rather than by editing sixteen templates - and it had
to be, because they are not uniform inside: all of them open with an
`h3.card-details-item-title` but only three wrap what follows in a
`.card-details-item-content`. So the title is the handle and folding hides every
sibling after it, which works whatever a section puts there. The caret is drawn
by CSS on the title for the same reason.

Two things this is careful about. A checklist's progress bar stays visible when
folded - it is the summary of what was folded away, and hiding it too would leave
a title that says nothing. And the fold survives reopening the card: the click
toggles the class and stores it, but a fresh render only has the stored state, so
cardDetails re-applies it in an autorun.

`float: inline-start` and logical margins on the caret, so it sits on the reading
-start side under dir=rtl - tests/rtl.test.js enforces that across component CSS
and caught a physical `float: left` here.

tests/checklistCollapse.test.cjs pins the per-user placement against its two
siblings, that the setter writes the USER and never the checklist, that "never
set" stays distinguishable from "expanded", the caret's state and keyboard
reachability, that the minicard uses the same key, and that a folded section hides
the siblings rather than a content element that most sections do not have.

Thanks to czinkos, MikeRatcliffe, JannetGen and xet7 !
2026-08-11 16:41:48 +03:00
Lauri Ojansivu 71992ef546 CHANGELOG: the queue - the snap markers, notifications, card click, dates and template containers.
Five entries and a rewritten summary paragraph, which now leads with WhereBleed
and names the snap upgrade that never retried.

Thanks to Philippe-Bentegeac, JDeepix, imlit, Nissulya, csonkaoszimt, Heart1010
and xet7 !
2026-08-11 16:31:33 +03:00
Lauri Ojansivu 80f5518724 Remove the Templates containers made for accounts that never used them.
From the cpu-monster email feedback: FerretDB at 190-350% CPU on an instance with
14490 boards, of which 13404 are template containers, for 9264 accounts of which
478 have ever logged in.

Before v10.00 every new account got a "Templates" container board at signup
whether or not the person ever saved a template. That was made lazy in v10.00
(#2339, #5850) - `ensureTemplatesBoard` creates one only when a template is
actually saved - so no NEW account creates one. But nothing ever removed the ones
already made, and they are not visible enough for anybody to delete by hand. On
that instance they are 13x the boards collection, and every query that touches
boards carries it; under polling reactivity that is every poll.

THIS DELETES BOARDS, so the rule for what may go is the whole of the risk and is
deliberately narrow: only a container nobody ever used. Anything at all having
been done to one keeps it - a template saved into it, a list, swimlane or card
added, a second member, a rename, a star, a manual archive. The rule is in
models/lib/unusedTemplateContainers.js on its own, so it can be read and tested
without the method around it, and every kept board comes back with the reason it
was kept.

A rename is judged only against the titles the app itself used, supplied by the
caller: that string is translated and has changed over time, so with no list to
compare against the title is not judged at all rather than a board being deleted
because its default name is in another language.

And it does nothing unless asked twice. The default is a dry run reporting what
WOULD go, with a sample rather than thirteen thousand rows; `apply: true` is what
deletes. For a collection this size that is the difference between a cleanup and
an accident. Admin only, checked before it reads anything. The
`profile.templatesBoardId` pointer is cleared with the board, or the next
ensureTemplatesBoard trips over a board that is not there, and one failed
deletion does not abandon the rest - the ones that failed are reported.

tests/unusedTemplateContainers.test.cjs is mostly about what must NOT be removed:
each of the four things that can be inside one, each of the four ways a person can
have claimed one, every non-container board type, junk input, and that the dry run
returns before anything is deleted.

Thanks to xet7 !
2026-08-11 16:28:38 +03:00
Lauri Ojansivu 097ad9160a Snap: a new revision is a new chance, so the 4.2 reader actually gets to run.
#6471, reopened against 10.81: "on 10.81 I still have the exact same issue,
MongoDB cannot start. The web interface is still unreachable, and I do not see
the messages you added in the last commits."

The messages were not printed because the code that prints them never ran. The
4.2 reader added for this issue was in their snap and was never reached, because
two markers in $SNAP_COMMON are PERMANENT:

  .mongodb-data-too-old   written when no reader in the snap could open the data.
                          mongodb-control then refuses to start mongod at all,
                          and wekan-control serves an explanatory page instead of
                          WeKan.
  .mongod-start-failures  a counter; past three, mongodb-control stops re-running
                          the MongoDB -> FerretDB migration, so a
                          migration<->mongod restart loop cannot form.

Both are right, and both record a conclusion about what THAT SNAP could do. An
instance that had already failed on 10.79 or 10.80 - before mongod 4.2 was
bundled - carried a marker saying "no reader can open this" and a counter far
past three. So on 10.81 mongod was never started, the migration was never
attempted, and nothing new was printed. Upgrading to the version with the fix
changed nothing, which is exactly what their log shows: the database-selection
line, then "Waiting for MongoDB replica set primary..." repeating forever.

A conclusion about an older revision is not an answer about this one. Each marker
now records the revision that wrote it (snap-src/bin/stale-marker), and a marker
from a different revision is ignored and cleared, so the snap tries again with
whatever it can do now. A marker with no revision recorded at all is stale by
definition - that is precisely what the affected instances are carrying.

Within one revision nothing changes: same revision, same refusal, same three-try
loop protection. Not knowing the revision (running outside a snap) is never
treated as evidence that it changed, so nothing is cleared on a guess.

Three places consult it. mongodb-control clears both the marker AND the counter
before deciding - an instance that gave up on an older revision has both, and
leaving one latched fixes nothing. wekan-control makes the same judgement because
it can start first and would otherwise keep serving "your data is too old for this
snap" on a snap that has not tried. migration-control stamps the marker when it
writes it. A mongod that does start still clears the counter and its stamp
together.

tests/snapStaleMarker.test.cjs runs the helper for the reported case, the same
revision, the no-stamp case, and the two ways of not knowing, then pins all three
call sites.

Thanks to Philippe-Bentegeac, JDeepix, imlit and xet7 !
2026-08-11 16:24:52 +03:00
Lauri Ojansivu 6420819cfa Clicking an open card closes it, and a focused checkbox is not a diamond.
The two newest comments on #6465.

CLOSING BY CLICKING AGAIN was not a missing feature - it was an unreachable one.
clickOnMiniCard already ended with

    } else if (Session.equals('currentCard', card._id)) {
      Utils.goBoardId(Session.get('currentBoard'));

but the TITLE branch above it returns before that is ever reached, and a
minicard's title covers most of the minicard. So the second click almost always
landed in the title branch, which unconditionally re-opened the card that was
already open. The toggle worked only if you managed to click a part of the card
that was not its title, which is why two people asked for a feature that was
already written.

The title branch now asks the same question first, and closes the same way. A
click on a card that is NOT open still opens it, and still clears the phone popup
ids as it did. On a mini screen the card is a popup rather than a pane, so
"already open" is a different question - `popupCardId` and `Popup.isOpen()` - and
that path closes with `Popup.back()`. Multi-selection still short-circuits both,
so ticking cards is unaffected.

THE PROBLEMS PAGE GLITCH in the screenshot is the focus ring. The Admin Panel
draws its checkboxes as a material square that morphs into a tick, and the tick
IS a 40-degree rotation of the element. A browser draws its focus ring around the
element as it is - rotated - so a checkbox that is both checked and focused, which
is exactly what a checkbox you just clicked is, comes out as a blue DIAMOND where
the tick should be.

The ring is not removed: keyboard users need to see where they are. It moves to
the row that contains the checkbox, which is not rotated - every one of these sits
inside a label (problemsSummary.jade) - so it is a normal rectangle around the
whole row, which is easier to see than a 13px box anyway. `:focus-visible`, so a
mouse click does not leave a ring behind.

tests/cardClickToggle.test.cjs pins that the close decision comes before
re-opening (or it never happens), that opening a different card and the existing
non-title toggle both still work, that multi-selection still wins, that the tick
is still the rotation, and that the label nesting the ring relies on is really
there.

Thanks to csonkaoszimt, Heart1010 and xet7 !
2026-08-11 16:19:43 +03:00
Lauri Ojansivu 184e1713d3 Notifications: cap the tray array, which SQLite was being rewritten by.
#6533. A snap instance reports FerretDB at 737% CPU, logins taking over a minute,
boards not appearing, and logs full of:

    [db-retry] the database was busy: 0 write(s) retried, 0 succeeded on a retry,
      3 given up on.
    ValidationError: ... [collection.go:191 sqlite.(*collection).UpdateAll]
      database is locked (5) (SQLITE_BUSY)
      at _helpersConstructor.addNotification (models/users.js:2248)

The stack names the same write every time. addNotification is an `$addToSet` on
`profile.notifications`, which is an array INSIDE the user document: adding one
entry means reading the whole document, scanning the array for a duplicate, and
writing the whole document back. The cost is proportional to the array. FerretDB
on SQLite has a SINGLE writer, so those rewrites queue behind each other and
start failing outright - and login, which also writes to the user document,
queues behind them. That is the slow login, the boards that never arrive and the
pinned CPU, all from one array.

WHY IT GROWS WITHOUT LIMIT: the cleanup added in #5685 removes a notification only
when every entry for its activity has been READ and is past its removal age. A
user who does not clear their tray accumulates entries forever, and nothing else
in the product shrinks that array. The instance in the report has been running
since at least 2026-07-29 with contention warnings, which is what an array that
only ever grows looks like from the outside.

So the same cleanup pass now also caps it: the newest NOTIFICATION_TRAY_MAX_PER_USER
entries are kept (default 1000, a backstop rather than a retention policy) and the
rest dropped. Newest rather than oldest because `$addToSet` appends, so the tail
is the recent activity a tray is for; an unread notification from months ago is
not something anybody is going to act on.

Three things it is careful about. The cap is applied to what is LEFT AFTER the
expiry pull, not before, or it would trim entries that were about to be removed
anyway and keep fewer than the cap. A user needing neither change is not written
to at all - the whole problem here is writes to user documents, so the cleanup
must not add one per user per day for users with nothing to do. And it stays ONE
write per user, which is what #5685 collapsed K writes into; the capped case uses
`$set` because a `$pull` would have to list the entries it is removing, and there
being too many of them is the point.

This bounds the array. It does not make SQLite a multi-writer engine: an instance
this busy still wants the PostgreSQL backend, which is what the Problems pane
already tells the admin.

tests/notificationCap.test.cjs pins the decisions - keeping the newest, leaving an
array under the cap completely alone (so no write), refusing to act on junk or a
disabled cap, that unread entries are what this catches because the existing rule
provably cannot, and that it is still one write per user.

Thanks to Nissulya and xet7 !
2026-08-11 16:15:06 +03:00
Lauri Ojansivu 0c5cb99b7c Card dates: a typed two-digit year is refused, not stored as the year 26.
Email feedback: "If i write the expiration date with the keyboard it turns red,
if i choose it with the date picker it is yellow. Can you please tell me the
difference?"

It is a bug, and the colour was never it. `<input type="date">` reports its value
as YYYY-MM-DD, but a browser lets the YEAR sub-field be typed as two digits and
reports exactly that: entering 31-12-26 gives "0026-12-31", the year 26 AD. That
is a perfectly valid Date, so nothing refused it, and the card was saved with a
due date two thousand years in the past. Red is what an overdue date is supposed
to look like. The same day chosen from the calendar always fills four digits, so
it is 2026 and yellow, because it is only due soon.

The attached screenshot shows it rather than the colours: the yellow badges read
"31-12-2026 17:00" and the red ones "31-12-26 17:00" - two different years.

Saving now refuses a year outside 1000-9999 with "Invalid year. Please type all
four digits, for example 2026." and puts the cursor back in the date field. The
window is deliberately wide: WeKan holds real historical received dates and
long-dated deadlines, and this is only meant to catch a year that cannot have
been intended.

REFUSED, NOT CORRECTED. 0026 was almost certainly meant as 2026, but silently
rewriting a date somebody typed - one that other people's due-date reminders hang
off - is a guess in the wrong place. The message says which digits are missing,
which is the thing the reporter could not see.

tests/datePickerYear.test.cjs pins the reported value the browser really hands
over, the date that was meant, the width of the window either side, that the
refusal happens before storeDate, and that nothing rewrites the year.

Thanks to xet7 !
2026-08-11 16:07:47 +03:00
Lauri Ojansivu b6d0be0f45 Snap: the other two mongo42 give-up paths deleted their own staged directory.
The earlier fix for the stage filter covered one of three ways this part gives
up - the unsupported-architecture `exit 0`. The other two did `rm -rf "$dest"`
and exited 0, which leaves `stage: mongo42` naming a path that is not there, and
snapcraft ends the whole build on that:

    Failed to copy '/build/.../parts/mongo42/install/mongo42': no such file or directory

Those two are reached on amd64 and arm64, where the binary IS downloaded: OpenSSL
1.1 could not be obtained for the architecture, or the staged mongod 4.2 failed
the smoke test that checks it actually runs. Either would have ended the snap
build for the two architectures that matter most, in the same way it ended all
four Launchpad ones.

Both now remove the CONTENTS and keep the directory. An empty mongo42/ stages to
nothing, and migration-control guards every use on `-x "$M42/bin/mongod"` - the
binary, not the directory - so "no 4.2 reader here" is still exactly what an empty
one means. Checked: that is the only guard, so an empty directory cannot be
mistaken for a working reader.

tests/snapPartFilters.test.cjs now requires every give-up path to clear contents
only, rather than pinning the single shape the first failure happened to take.

Thanks to xet7 !
2026-08-11 15:59:29 +03:00
Lauri Ojansivu 3ebc38cb40 CHANGELOG: WhereBleed leads the release.
A CRITICAL SECURITY section comes first, so the bug subsections that follow it
become "and fixes ...". The summary paragraph leads with it too, in plain text -
the In short paragraph links nothing; the entry does.

Thanks to TungNGo02 and xet7 !
2026-08-11 15:57:11 +03:00
Lauri Ojansivu b4ebe48d7b WhereBleed: eight Admin Panel handlers ran the caller's selector unchecked.
GHSA-phm4-4v26-j2vq, CWE-943, CVSS 5.8. The Admin Panel's people, org, team and
translation publications and their companion count/page methods take a query
selector from the client and validate only its TYPE:

    check(query, Match.OneOf(Object, null));

which is not validation, because a MongoDB selector is executable data. `$where`
makes the database run the caller's JavaScript once per document scanned, so

    Meteor.subscribe('team', { $where: 'while(true){}' }, 25, 0)

pins a database worker for as long as the caller likes and is repeatable: denial
of service for every tenant on the instance, from one narrowly-scoped account.
The reporter demonstrated both halves on v10.81 against mongo:7 - `$where:
'sleep(2000) || true'` made the `people` subscription take 2.03s and return the
document, `$where: 'false'` returned nothing in 0.00s - which is the caller
deciding in JavaScript which documents come back.

It needs an authenticated admin session, so it is not reachable by a board member
or a visitor. It is still worth the fix at this severity because the people and
org surfaces are open to a PER-TENANT Global Admin, a role meant to be confined
to one Organization, and those two wrap the caller's selector as
`{ $and: [query, restriction] }` rather than stripping execution operators out of
it - so merging the tenant restriction never removed the `$where`, and a role
scoped to one tenant reached instance-wide impact.

WHAT MAKES THIS ONE PARTICULAR is that the defence was already here. classifySelector
(models/lib/injectionDetect.js) and hasWhere (models/lib/mongoSelectorSafety.js)
were written for exactly this class and were wired into the card-window
publication, with unit tests. Eight sibling handlers taking the identical shape of
client-supplied selector simply never called them. So this adds no new detection
logic: cardsWindow.js's own helper moves, unchanged, to
/server/lib/selectorGuard.js, and all nine call sites share the one copy. A second
copy of the check would be the same bug set up to happen again, which is why
cardsWindow.js now imports rather than keeps its own.

Each handler refuses with the "match nothing" selector that publication already
uses in production, `{ _id: { $in: [] } }` - frozen, so a caller cannot mutate the
shared object into one that matches - so a refused request returns an empty result
instead of throwing at an admin mid-page, and the canary still records who sent
what from where. Ordinary Admin Panel behaviour is untouched: none of the
searches, filters, regexes, $or/$and/$in/$elemMatch or date ranges those panes
send contains an execution operator, and the test pins fourteen real ones going
through unchanged.

FerretDB, WeKan's default database, rejects $where itself, so this degrades to a
rejected query there; the supported real-MongoDB path
(docker-compose-mongodb-v7.yml) is where it was reproduced. MongoDB 7 also happens
to reject $where inside the aggregation pipeline the four count methods use, but
that is an engine-level accident for one operator on one call path - $function and
$accumulator are legitimate aggregation operators - so the count methods are
guarded like the rest rather than left to it.

tests/selectorGuard.test.cjs pins the reported payloads (including nested inside
$and/$or, which is how the tenant merge would carry them), the other execution
operators, the fourteen ordinary queries, that all nine call sites route through
the guard, that none still passes the raw selector afterwards, and that exactly
one copy of the check exists. Three existing suites asserted the old shape -
canaryCoverage on where the canary trips, injectionDetect on the helper living in
cardsWindow.js, tenantWiring on the scope function receiving the raw query - and
each is updated to the new one, saying why.

Thanks to TungNGo02 and xet7 !
2026-08-11 15:50:31 +03:00
Lauri Ojansivu 09ce64ecf6 CHANGELOG: the snap database copy and the card drag scroll.
The two issues reported after upgrading from v6: a FerretDB migration that had
fallen weeks behind the MongoDB beside it and was being served in its place, and
a card drag that scrolled the board instead of the list because the vertical
auto-scroll never picked the list under the pointer.

Thanks to markusst1982 and xet7 !
2026-08-11 15:34:16 +03:00
Lauri Ojansivu 8456170d45 Dragging a card down scrolls the list, not the whole board.
"With v6 i was able to move a Card downwards by drag & Drop. Upwards is no
   problem, the Line scrolls automaticly up, but this does not work downwards.
   The whole Page/Site scrolls down and not ne Line"

The card sortable's `sort` handler auto-scrolls at the edges. HORIZONTALLY it
picks the lane under the pointer (#443). VERTICALLY it picked nothing: it always
scrolled `.board-canvas`, which is `overflow-y: auto` and holds the swimlanes,
rather than the `.list-body` under the pointer, which is `overflow-y: scroll` and
holds the cards. Scrolling the canvas moves the whole board - which is the report,
in the reporter's own words.

The asymmetry is what makes it reproducible rather than vague. Dragging UP, the
canvas is usually already at scrollTop 0, so computeEdgeScroll returned null, the
handler did nothing, and jQuery UI's own `scroll` option - which acts on the
placeholder's scrollParent, i.e. the list body - scrolled the list, which is why
"upwards is no problem". Dragging DOWN, the canvas nearly always has room left,
so the handler fired first and scrolled the board instead of the list.

The list body under the pointer is now scrolled first, found with the same
rectangle hit-test the lanes use, and the canvas only when the list cannot go
further that way - so a drag down a long list scrolls the list, and a drag past
the end of it moves on to the board, which is what dragging a card to another
swimlane needs. The horizontal lane scroll is unchanged, and the list branch sets
`scrolled` so the #6477 refreshPositions still runs after it: a manual scroll
invalidates jQuery UI's cached geometry whichever element was scrolled.

tests/cardDragVerticalScroll.test.cjs pins the order (list before canvas, canvas
only when the list did not move) and runs computeEdgeScroll with real numbers for
the three cases that decide it: a pointer in the bottom edge zone of a scrollable
list, the same at the end of its scroll range, and a list that does not overflow.

Thanks to markusst1982 and xet7 !

Fixes #6584,
2026-08-11 15:32:47 +03:00
Lauri Ojansivu b1e7b1e538 Snap: never serve a FerretDB copy that is older than the MongoDB beside it.
After upgrading from v6 the reporter saw "the state of the data from days ago"
and suspected a `snap revert` done four weeks earlier. They were right about the
cause, and nothing was lost.

The MongoDB -> FerretDB migration copies MongoDB into SQLite ONCE and writes
.migration-to-ferretdb-done. That copy is a snapshot; nothing keeps it in step.
Revert the snap to a revision that runs mongod and WeKan carries on writing to
MongoDB - for four weeks here - while the finished SQLite sits frozen at the date
it was made. Refresh forward again and mongodb-control's
switch_to_completed_ferretdb saw a marker plus a non-empty SQLite, called that a
completed migration, and switched the snap onto it. Every board and card written
during the revert was still on disk and simply not being served.

The data was never in danger: it lives in $SNAP_COMMON, which is shared across
revisions and is NOT rolled back by a revert (unlike $SNAP_DATA, which is
per-revision). What was wrong was WHICH of the two copies got served, and nothing
compared their ages.

snap-src/bin/ferretdb-migration-stale answers that one question: is the completed
migration older than the MongoDB data it was made from? mongod rewrites its
WiredTiger files whenever it commits, so the newest mtime among them is when
MongoDB was last written to; later than the marker means the copy is behind. It
counts DATA files only - a newer mongodb.log or .pid means the snap was started,
not that the database changed - and allows a margin, because the migration stops
its own temporary source mongod moments after writing the marker and that
shutdown touches the data directory. Anything it cannot tell (no marker, no
SQLite, no MongoDB data, unreadable mtimes) is reported as NOT stale: the callers
act on a yes, so being unsure has to leave the existing behaviour alone.

Three places act on it:

  mongodb-control's switch_to_completed_ferretdb refuses a stale migration and
  leaves MongoDB running, which has the newest data.

  mongodb-control's handle_mongod_start_failure - where mongod will not start at
  all, so keeping MongoDB is not on offer and the choice is between a fresh copy
  and a stale one - migrates AGAIN. The migration reads with its own temporary
  mongod and the 4.2/3.2 readers, none of which is the mongod that just failed,
  so it can still reach everything written since. It has to be restarted rather
  than resumed: migration-control exits immediately while the marker is there,
  and the checkpoint and old SQLite describe a finished copy of the old data, so
  the marker, the checkpoint and files/db are cleared first. The MongoDB data is
  never touched.

  wekan-control gains the second half of a guard it already had. It refused to
  start an EMPTY FerretDB while MongoDB had data; a FULL but out-of-date one looks
  worse, because WeKan comes up with boards and cards all present and only the
  last weeks missing, which reads as data loss rather than as a misconfiguration.
  It now stays on MongoDB and says why, including that nothing is lost.

tests/ferretdbMigrationStale.test.cjs runs the detector against synthesised
$SNAP_COMMON directories - the reported four-week case, a fresh migration, the
margin either side of it, a newer log file, and each "cannot tell" - and pins the
wiring at all three call sites.

Thanks to markusst1982 and xet7 !

Fixes #6583,
2026-08-11 15:32:32 +03:00
Lauri Ojansivu dac8d1da2f CHANGELOG: the two wekan-ldap links point into wekan/wekan.
github.com/wekan/wekan-ldap no longer exists - the API redirects and then answers
422 - so the two links in the v2.52 (2019) entry for "Added and then reverted
option to redirect OIDC OAuth2 login" were dead.

The HASHES do not carry over. The wekan-ldap code was copied into
packages/wekan-ldap on 2019-04-20 ("Include to Wekan packages directory
contents"), not history-merged, so 82a894ac and 36900cc exist in no repository
now: GitHub answers 422 for both under wekan/wekan, and neither object is in this
clone. Repointing the URL alone would have produced a link that still 404s.

What DOES exist is the pair of wekan/wekan commits that recorded those two steps
on the same day, and the first of them is certain rather than inferred - its
commit message IS the old link:

  part1  82a894ac  ->  188d42dcd  "[Add option to redirect OIDC OAuth2 login]
                                    (.../wekan-ldap/commit/82a894ac...)"
  part 2 36900cc   ->  d173844f9  "OAUTH2_LOGIN_STYLE popup or redirect,
                                    part 2 and 3."

So a reader following part1 still sees the original wekan-ldap hash, in the
message of the commit they land on. part3 is unchanged: 7919ae362 is in
wekan/wekan already and is the code change of that day (Dockerfile,
docker-compose.yml, server/authentication.js, the snap config).

Both targets checked against the GitHub API before use: HTTP 200.
releases/fix-changelog-hashes.sh --all-sections now reports all 3235 commit links
resolving.

Thanks to xet7 !
2026-08-11 15:18:26 +03:00
Lauri Ojansivu c452a2defd CHANGELOG: the canary-tokens entry links FerretDB, where that commit lives.
releases/fix-changelog-hashes.sh --all-sections checked all 3234 wekan/wekan
commit links and found exactly one that resolves to nothing:

  https://github.com/wekan/wekan/commit/a260a53e

It is not a rewritten hash, so there was nothing for the script to repoint - it
reported it and left it, which is what it should do. The hash is fine; the
REPOSITORY in the URL was wrong. The entry describes `internal/util/canary`,
which is a FerretDB path, and a260a53e is "Canary tokens: mark the operations the
client never issues." in wekan/FerretDB - confirmed both in .tools/FerretDB and
against the GitHub API, which serves it there and answers 422 for it under
wekan/wekan.

Thanks to xet7 !
2026-08-11 15:13:20 +03:00
Lauri Ojansivu c8393f3f53 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-08-11 11:36:19 +03:00
Lauri Ojansivu be558257cd CHANGELOG: the image-check work belongs to the next release, not to v10.81.
v10.81 was prepared, tagged and published (at "Bump versions for v10.81") while
this was being written, so the entry describing the chart-index image check was
added into a section for a release that had already gone out without it. A
release section has to describe what is IN that release; it is moved to a new
Upcoming section, where the change will actually ship.

What stays in v10.81 is what v10.81 really carries: the FerretDB
directConnection fix, All Boards on a phone, the retired Mac runner and the jobs
a cancellation skipped, the armv6 userland, the Launchpad snap part, and the two
chart commits that were pushed before the release was prepared.

Also: the new "is there an image for this version" check imports
releases/reindex-charts.py, and importing a module writes releases/__pycache__
beside it - untracked junk appearing in the repository on every release. It runs
with PYTHONDONTWRITEBYTECODE=1, verified to leave nothing behind.

Thanks to xet7 !
2026-08-11 11:36:18 +03:00
Lauri Ojansivu 7fc1f35bed CHANGELOG: link the image-check entry to its commit.
Thanks to xet7 !
2026-08-11 11:36:18 +03:00
Lauri Ojansivu a442959dda Charts: list only the ones whose container images still exist.
Backfilling the index taught this within the hour. Artifact Hub scans every entry
in a Helm index and mailed a list of errors:

    error scanning image ghcr.io/wekan/wekan:v9.62: image not found
    error scanning image docker.io/bitnami/mongodb:7.0.14-debian-12-r3: image not found

That was this side's doing, not Artifact Hub's. The rebuild listed every package
sitting on gh-pages, and a chart is a POINTER TO CONTAINER IMAGES: one whose
images have been deleted from their registries installs and then fails at the
pull, so listing it announces that the repository is broken when the repository
is fine and the images are gone.

135 of the 360 packages are in that state, from two unrelated causes. SIX have no
WeKan image - v8.30, v9.12, v9.14, v9.38, v9.39 and v9.62, releases whose own
docker job failed, and exactly the six ghcr.io errors that were reported. The
other 129 vendor the BITNAMI mongodb subchart and pin a tag Bitnami has since
deleted; charts from 8.41 on vendor groundhog2k's mongodb, which uses the
official `mongo` image and is unaffected.

The index now holds 225 entries: every one of the 216 that were listed before -
not one dropped - plus the 9 backfilled packages whose images all resolve. Twelve
listed charts were spot-checked against the registries afterwards and every image
pulled.

THE EXCLUSIONS ARE A FILE, unindexed.txt, beside the packages - not a check made
on every run. A rebuild during a release must not depend on reaching two
registries, where a blip would silently drop half the index; and a recorded
decision is reversible and reviewable, so an image that comes back is one deleted
line away from being listed again. The .tgz files all stay, so direct URLs keep
working. `--check-images` is what regenerates the file.

Two things this shook out, both about being careful with a negative result.

The registry handshake is now the REGISTRY'S OWN: ask for the manifest with no
credentials and let the 401 say which realm and scope to use. The first version
hard-coded a token URL per host and reported every quay.io image as missing -
including quay.io/wekan/wekan:latest, which plainly exists - because the wrong
endpoint returned a token quay would not accept and the manifest came back 404.
Four charts were nearly dropped on that. Related: a repository that already
begins with a host ("ghcr.io/wekan/wekan") must not be read as a docker.io path,
which is how a first pass came back "unknown" for every WeKan image.

And an image that cannot be CHECKED is never treated as missing - only a definite
404 excludes a chart. A registry that refuses anonymous auth or a request that
times out must not be able to silently unpublish anything.

release-charts.sh now refuses to publish a chart when
ghcr.io/wekan/wekan:v<version> does not exist, which is what created those six in
the first place. It checks before it touches the charts repository, and a
registry it cannot reach does not block a release.

Thanks to xet7 !
2026-08-11 11:36:18 +03:00
github-actions[bot] b6d003089c Bump versions for v10.81 2026-08-11 00:02:26 +00:00
Lauri Ojansivu fc1e089d38 Prepare v10.81 release 2026-08-11 03:01:27 +03:00
Lauri Ojansivu 35b44a1f8b CHANGELOG: link the chart-publishing entry to its commit.
Thanks to xet7 !
2026-08-11 02:54:55 +03:00
Lauri Ojansivu a434bb4bd5 Charts: both release workflows publish one, and the index is derived not edited.
The charts repository keeps the chart SOURCE on main and the published packages
plus index.yaml on gh-pages, and its own two scripts move between them: main's
release.sh commits the source, tars `wekan/` into wekan-<version>.0.tgz, checks
out gh-pages and drops the package there; gh-pages' release2.sh commits and
pushes it. release-charts.sh drives both and owns the index in between, and that
middle part is what changes here.

THE INDEX IS REBUILT FROM THE PACKAGES, not edited in place. It used to copy the
newest entry, substitute a few fields and prepend the result, and every one of
the index's four defects came from that: a re-run prepended a second entry for a
version instead of replacing it (9.36.0 ended with four, each a different digest,
so a helm client could pick one matching no file); appVersion was set to the
CHART version rather than the WeKan version on every entry; fields nobody
substituted never changed, so every entry claimed the mongodb subchart was 0.7.2
while the packages had moved to 0.7.6; and a release whose charts job did not run
left a hole nothing ever went back for. Deriving the index makes all four
impossible instead of repaired - one entry per package, its digest computed from
the file, its fields read out of the archive, and any package that was missed
picked up on the next run.

THE PACKAGE IS CHECKED AGAINST ITS OWN FILENAME FIRST. release.sh names the
tarball from its argument (`tar -cvzf wekan-$1.0.tgz wekan`) while the version
inside comes from the Chart.yaml this script sed-ed a moment earlier. When those
drift the repository gains a file called one version that declares another, and a
Helm index keys on the version INSIDE - so it can never be installed under the
name it was given. That is how wekan-1.2.7.tgz (containing 1.2.6) and
wekan-6.96.tgz (containing 6.9.6) came to exist; both are removed from the charts
repository, and a package like them now stops the release with a message naming
both versions rather than being published.

RELEASE ALL MISSING PUBLISHES A CHART TOO. It could rebuild any missing bundle
but not a missing chart - its header said the charts were out of scope - so a
release that never got a chart never would. It has a charts job now, and it is
the one part of that workflow which pushes to another repository, so it is
guarded twice: nothing happens without a WEKAN_REPO_TOKEN that can push to
wekan/charts (a named warning and a skip, not a failure inside git), and nothing
happens when the published index already lists that version, because
re-publishing would re-tar the package and change the digest of a chart people
have already pulled. Both workflows publish through the same
releases/release-charts.sh, so neither can drift from the other.

release-charts.sh also finds the checkout at .tools/charts, where CLAUDE.md keeps
companion repositories, before the older ../w/charts.

Verified against the real checkout: a package dropped in is picked up, sorted
first, with its appVersion and digest read from the archive - then removed and
the index restored. tests/releaseChartsIndex.test.cjs pins that the index is
derived rather than edited, that the filename check runs BEFORE indexing, that
both workflows publish through the same script, and that an already-published
version is not republished. It runs the script's own version-reading code against
a package named 1.2.7 that declares 1.2.6.

Thanks to xet7 !
2026-08-11 02:54:47 +03:00
Lauri Ojansivu 292ffb8906 CHANGELOG: the index.yaml rebuild entry.
Wrapped at 80 and repointed by releases/fix-changelog-hashes.sh after the amend
that rewrote its commit.

Thanks to xet7 !
2026-08-11 02:44:40 +03:00
Lauri Ojansivu 1a2ec06332 Charts: rebuild index.yaml from the packages the repository actually serves.
With wekan/charts cloned at .tools/charts, the index could be compared against
its own packages for the first time, and it had drifted from them in four ways at
once. All four come from the same habit: release-charts.sh writes an entry by
COPYING the previous entry and substituting a few fields, so the index was kept
as the record rather than as a description of the .tgz files beside it.

  146 PACKAGES HAD NO ENTRY. 362 packages, 220 entries. Those charts are
  downloadable by URL and invisible to `helm search`, to
  `helm install wekan --version 9.63.0` and to Artifact Hub. An entry is written
  once, during the release it belongs to, so a release whose charts job did not
  run left a hole and nothing ever went back for it.

  DUPLICATE ENTRIES WITH STALE DIGESTS. 9.36.0 had four entries and 10.30.0 two,
  each copy with a different digest and the same url, because a re-run of a
  release prepended a second entry instead of replacing the first. Only one
  digest per version can be the digest of the file served - the other four match
  no package in the repository - so a helm client that picked one of them failed
  the integrity check on a perfectly good file.

  A WRONG appVersion ON EVERY ENTRY. The index gave the CHART version there
  ("10.79.0") where the packaged Chart.yaml says the WeKan version ("10.79"),
  which is the one a reader is actually looking for.

  FROZEN DEPENDENCY VERSIONS. A field nobody substituted never changed: every
  entry claimed the mongodb subchart was 0.7.2 while the packages have been 0.7.6
  for some time.

releases/reindex-charts.py derives the index from the packages, reading each
one's Chart.yaml out of the archive - so an entry cannot describe a package
wrongly - and needs no helm, only python3.

Nothing is invented and nothing is lost. `created` is preserved exactly on every
entry whose digest matches its package; a package being added takes the date it
was committed to gh-pages, which is when it was really published (wekan-10.79.0
was committed at 2026-08-10T02:46:52Z and its entry already said created
2026-08-10T02:46:52.308983+00:00). Two 2023 packaging slips - wekan-1.2.7.tgz
containing chart 1.2.6, and wekan-6.96.tgz containing 6.9.6, each colliding with
the file whose NAME matches - are reported and left out rather than letting one
version mean two files; both files stay on the server, so a direct URL keeps
working.

Run against the real repository the index went from 220 entries to 360, and was
verified after writing: no repeated version, every digest equal to the sha256 of
its file, every file present, every url under wekan.github.io/charts, and no
version that was listed before missing.

backfill-charts.sh now looks in .tools/charts before ../w/charts, since that is
where CLAUDE.md keeps companion repositories, and both tools are Release menu
entries in build.sh and build.bat.

tests/reindexCharts.test.cjs runs the tool against a synthesised charts directory
- real gzipped chart packages, written without helm - and pins the rules that
have a decision in them: created is preserved rather than restamped, the
surviving duplicate is the one whose digest matches the file, a version claimed
by two packages is resolved by filename and REPORTED, no previously listed
version is dropped, and a report run does not touch the file.

Thanks to xet7 !
2026-08-11 02:43:19 +03:00
Lauri Ojansivu 2c7a016327 CHANGELOG: the Upcoming section for the six fixes.
Two bugs that stopped WeKan starting - the FerretDB replica-set handshake sending
the driver to 0.0.0.0, and All Boards on a phone - and four release-pipeline
failures: the retired macos-13 label that has never once built the Mac x64
bundle, the cancellation that silently skipped charts/ucs/nextcloud, the armv6
bundle assembled in a soft-float ARMv5 userland, and the optional MongoDB 4.2
part that ended every Launchpad snap. Plus the chart-index report.

The binaries table is v10.80's: nothing here rebuilds them.

Thanks to xet7 !
2026-08-11 02:19:44 +03:00
Lauri Ojansivu 18da0d2618 Snap: an optional part was ending every Launchpad build.
All four Launchpad architectures failed in v10.80 - armhf, s390x, ppc64el and
riscv64, three attempts each - and the job reported them as a Launchpad problem:
"often an OOM in the Meteor npm install, or a transient build-farm reset; re-run".
It was neither, and re-running was the one thing that could not help. The build
log, which the job prints, said the same deterministic thing every time:

    Staging mongo42
    Failed to copy '/build/.../parts/mongo42/install/mongo42':
      no such file or directory.
    Build failed

The mongo42 part downloads MongoDB 4.2 so a database from an old MongoDB snap can
still be migrated (#6471), and MongoDB publishes 4.2 for amd64 and arm64 only. On
every other architecture the part prints "nothing to migrate from there;
skipping" and exits 0 having created nothing - and its own comment calls that
"OPTIONAL BY DESIGN". It was not optional: the part also carries
`stage: [mongo42]` and `prime: [mongo42]`, and snapcraft does not skip a filter
whose path is missing, it ends the build. So a migration helper that no exotic
architecture has ever needed took the whole snap down on exactly the four
architectures that have no native runner and can only be built on Launchpad.

The directory is created before anything can decide to skip, so the filters
always have something to copy. The binary is still not downloaded anywhere but
amd64 and arm64, and migration-control still finds nothing there - "no MongoDB
4.2 to migrate from" is a missing binary again instead of a failed build.

riscv64 additionally lost its last attempt to the build farm's network:

    npm ERR! code ECONNRESET
    npm ERR! network request to .../string-width-4.2.3.tgz failed, reason: socket hang up
    'override-build' in part 'wekan' failed with code 1.

after half an hour of building. Every request there goes through Launchpad's
proxy and one tarball out of a couple of thousand can be cut mid-stream; npm's
own --fetch-retries covers the request, not a dropped socket. That npm install is
retried three times now, and still fails loudly if it is not the network.

The ::error:: no longer guesses. It sends the reader to the build log that is
already printed above it and lists what to look for in the order worth checking,
starting with a "Failed to copy .../parts/<part>/install/<path>", which is a
stage:/prime: filter naming something that architecture does not build and will
never pass on a re-run.

tests/snapPartFilters.test.cjs pins the ordering inside mongo42, that it is still
amd64/arm64 only, the npm retry, and the general rule the failure was one
instance of: a literal path in stage:/prime: must be something its part actually
produces.

Thanks to xet7 !
2026-08-11 02:16:30 +03:00
Lauri Ojansivu c0a5c2b7c9 All Boards on a phone: the scroll, the table view, the 4x tiles and the spacing.
Four things, reported together, in a issue that had been closed three times and
reopened against 10.10, 10.37, 10.38, 10.72 and 10.77.

THE SCROLL. Every fix so far removed viewport arithmetic from boardsList.css and
said so in its comments - "no viewport arithmetic here at all now". The last
piece of it was in layouts.css, where nobody looked:

    body.mobile-mode #content { height: calc(100dvh - 48px) }

48px is a guess at the height of the header. The header is not 48px and is not
any one number: Utils publishes --wekan-header-height from a ResizeObserver for
exactly this reason, and its own comment says the quick-access bar wraps to a
second and third row depending on language and window width and that "every fixed
number for it has been wrong for some of those". So on a phone whose bar wraps,
#content was TALLER than the room under the header, and its bottom sat below the
screen. body.mobile-mode is `position: fixed` and the phone rules make it
`overflow: hidden`, so that strip is unreachable, and the `height: 100%` chain
below inherited all of it: the board list scrolled, it just ended off screen, and
its last boards were where no gesture could reach them.

Nothing has to be computed. body is a flex column of exactly one viewport and
#content is its `flex: 1` item, so the space under the header IS that box, at
whatever height the header really is this paint. The calc was overriding a
correct answer with a wrong one. It is gone, and `min-height: 0` lets the chain
below actually shrink.

THE TABLE VIEW. The right column has two branches - the board icons and
`+tablePage` - and only the icons were ever given a scroller. In Table view the
table grew past the bottom of the column and `#content .wrapper` is
`overflow: hidden`, so everything past the fold was clipped with nothing to
scroll. Every section of All Boards can be shown as a table, so that was half the
page for all of them. `.table-page` now takes what the column has left and
scrolls in both directions, and the pane heading is `flex: 0 0 auto` so it keeps
its height instead of being squeezed while the list stays too tall.

THE 4x-TALL TILES. A grid defaults to `align-content: stretch` and
`align-items: stretch`, so a list SHORTER than its column has the leftover height
divided among its rows and each tile grown into it - and a board tile paints its
board colour over the whole cell, so one row of boards became one band several
times a normal icon's height. This was already fixed once, but only on
`.board-list.mobile-view`, the class the template sets from Utils.isMiniScreen().
The phone MEDIA QUERY builds a grid too, and a narrow window that is not a mini
screen - a desktop browser under 800px, a tablet in desktop mode - took that path
and never got it. It belongs on the rule that creates the grid, and is on both.

THE SPACING. The tiles had an 8px grid gap PLUS a `margin-bottom: 0.5rem` each,
so they were 16px apart vertically and 8px horizontally - which is what reads as
"too close to each other" sideways. One value now, 10px, in both directions, with
the margin removed on both the media-query and the mini-screen paths.

The mobile full-screen popup had the same class of bug as the first: `height:
100vh` on a `position: fixed` box (the viewport with the toolbars HIDDEN, so it
reaches under them) and a `.content-wrapper` of `calc(100vh - 48px)`, guessing at
its own title bar. It states dvh now, and the wrapper is left to the flex column
it is already part of.

Desktop is unaffected: above 800px none of these rules apply and the page scrolls
in #content as it always has.

tests/allBoardsPhoneScrollAndTiles.test.cjs pins all four, end to end - including
that no step between the body and the board list computes a height from the
viewport. tests/mobileViewportHeight.test.cjs REQUIRED the calc that was the bug,
so that assertion is replaced with its opposite and says why.

Thanks to mimZD and xet7 !

Fixes #6488,
2026-08-11 02:11:06 +03:00
Lauri Ojansivu 01f691ba3d Build menus: the chart-index report is a command a person runs.
build.sh and build.bat list every releases/*.sh that is not a workflow-only
helper, and backfill-charts.sh is not one of those - it is read by hand, on a
machine with a charts checkout. It is a Release menu entry in both, and
`./build.sh backfill-charts` / `build.bat backfill-charts` on the command line.

Thanks to xet7 !
2026-08-11 02:01:55 +03:00
Lauri Ojansivu 8248892e33 Charts: report the releases the index is missing, and repair it.
The charts job is fixed to RUN (previous commit); this is about what it left
behind. A chart entry is written once, by release-charts.sh, during the release
it belongs to - so a release whose charts job did not run leaves a hole that
nothing ever fills, because the next release adds ITS entry and never looks back.
v10.80 is one of those. Counted against the live index: 216 of the 690 WeKan
releases have a chart entry, and 474 do not.

releases/backfill-charts.sh answers "which WeKan releases should the index
list?" with: the ones that exist AND can be installed.

  keep   216  a chart is already published; never rebuilt, because repackaging
              changes a digest helm clients have already seen
  build  369  the release exists, its container image exists, and no chart is
              published - the hole to fill
  omit   162  the release exists but has NO container image on ghcr. A chart is
              a pointer to an image (tag: v<version>), so an entry for one of
              these is an install that fails at the pull. They stay OUT of the
              index rather than being listed and broken - most are releases whose
              own docker job failed, which is why they have no chart either.

The index is rebuilt with `helm repo index --merge`, from the packages actually
in the directory, so "omitted" needs no bookkeeping: a version with no .tgz gets
no entry, and no entry can point at a package that is not there. A ghcr request
that fails is "unknown", not "no image" - reading a network hiccup as absence
would omit a release that has one.

The run also found something already wrong. The published index has FOUR entries
for 9.36.0 and two for 10.30.0, every copy with a DIFFERENT digest and the SAME
url, because release-charts.sh PREPENDS an entry each time it runs and a re-run
duplicated the version instead of replacing it. At most one of those digests can
be the digest of the wekan-9.36.0.tgz that is really served; a helm client that
picks another fails the integrity check on a perfectly good file. So the rebuild
de-duplicates: for a version with more than one entry, keep the one whose digest
MATCHES the package on disk, and only when there is no package to compare
against fall back to the newest.

Nothing is written without --apply and nothing is pushed without --push, and the
charts job runs it in plan mode only, into the run summary - so the gap is
visible in the release it appears in, while rewriting a published Helm index
stays a command the maintainer runs deliberately.

tests/backfillCharts.test.cjs runs the dedup rule for real against a synthesised
index, including the case that separates the rule from its fallback: the matching
digest on the OLDEST copy, which must still win.

Thanks to xet7 !
2026-08-11 02:00:37 +03:00
Lauri Ojansivu 7b8c377099 Release: the retired Mac runner, the jobs a cancel skipped, and the armv6 userland.
Three failures from the v10.80 run, which reported four snap arches red, one
bundle red, and three jobs that never ran at all.

build-mac-x64 asked for macos-13, and GitHub has RETIRED that image:
actions/runner-images publishes macos-14, macos-15 and macos-26 only, and
macos-13 is gone from the hosted-runner table. A retired label does not fail -
that is the whole problem. The job QUEUES, waiting for a runner that is never
coming. It sat there for nearly two hours and had to be cancelled by hand, and
this has been happening every release: NO WeKan release has ever carried a
wekan-<version>-mac-x64.zip. Intel macOS did not go away, it was renamed, and
.tools/TSC already builds its Intel Mac on the new label: macos-15-intel.

Cancelling it is what skipped charts, ucs and nextcloud. A cancelled job cancels
the RUN, and a cancelling run skips every job that has not started yet - so all
three were skipped in the same second the docker job they wait on succeeded,
after the tag, the release, every bundle and the multi-arch image had shipped.
The Helm chart for a published WeKan was never pushed and nothing said why. A job
whose if is always() still runs while a run is cancelling, so the three now say
always() && <each need>.result == 'success': explicit about the one condition
that ever mattered, instead of relying on a default a cancellation overrides.

The armv6 bundle died on

    qemu-arm: Could not open '/lib/ld-linux-armhf.so.3': No such file or directory

400 lines in, after the whole apt install. DEBIAN HAS NO ARMv6 PORT.
debian:trixie publishes arm/v5 and arm/v7 and nothing between them, and because
containerd treats a lower ARM variant as compatible, --platform linux/arm/v6
quietly resolved to arm/v5: Debian armel, ARMv5 SOFT-float, which contains no
hard-float loader at all. node-armv6 is hard-float, so it could not start.

Two things were wrong. The preflight matched on "architecture": "arm" and ignored
the VARIANT, so "debian:trixie has no linux/arm/v6" was never the answer it gave;
it compares architecture and variant now, and an unrequested variant still
matches anything so linux/arm64 (recorded as arm64/v8) keeps working. And armv6
is built in Debian's arm/v7 container instead, which IS armhf - the same
hard-float ABI and the same loader, so node-armv6 runs in it.

Nothing ARMv7 reaches the bundle. The container's npm install compiles nothing:
it is npm-rebuild over pure-JavaScript packages, with no node-gyp and no CXX in
the log. node-armv6, ferretdb-armv6 and the armv6 MongoDB tools are all
downloaded already built for ARMv6. The single exception is the bundled qemu-user
(#6458), which is copied OUT of the container, and an ARMv7 qemu-armv7l in an
ARMv6 bundle is a binary a Raspberry Pi 1 cannot execute - under a name its
cpu-exec, which looks for qemu-armv6l, would never ask for. So the matrix marks
armv6 same_arch_container: false and install-node-for-arch.sh ships without one,
letting cpu-exec fall back, which is what it is written to do.

Three guards, because each of these was invisible until a release ran:
tests/workflowRunnerLabels.test.cjs checks every runs-on in every workflow
against the labels GitHub currently offers and names the replacement for a
retired one; tests/releaseCancelSafeJobs.test.cjs pins the always() guards and
that they still require every need to have SUCCEEDED;
tests/releaseArmv6Container.test.cjs pins the armv6 container and runs the
preflight's own platform match against the manifest shapes it meets, including
that debian:trixie answers NO to linux/arm/v6.

Thanks to xet7 !
2026-08-11 01:52:41 +03:00
Lauri Ojansivu e1c6221f22 FerretDB: connect with directConnection=true, so a fresh docker compose up starts.
A new install with nothing changed but the port and ROOT_URL could not reach the
database at all:

    MongoServerSelectionError: connect ECONNREFUSED 0.0.0.0:27017
    reason: TopologyDescription { type: 'ReplicaSetNoPrimary',
            servers: Map(1) { '0.0.0.0:27017' => [ServerDescription] },
            setName: 'rs0', ... }

0.0.0.0 is in no compose file. It is FerretDB's own listen address, and the
driver was handed it by the server.

The ferretdb service runs with --repl-set-name=rs0, added in #6480/#6481 so
Meteor can tail an OpLog instead of poll-and-diff. FerretDB therefore answers the
hello handshake as a one-member replica set, and fills hosts, me and primary with
its --listen-addr, which is the wildcard 0.0.0.0:27017. A driver that is not in
direct-connection mode reads that reply as an invitation to do replica-set
DISCOVERY: it adopts the advertised member list and drops the seed it was given,
because the server reports a name other than the one that was dialled. So
mongodb://ferretdb:27017 became 0.0.0.0:27017, which inside the wekan container is
the wekan container itself, where nothing listens.

Measured against FerretDB v1.49.0 - the version reported - with the mongodb
driver the bundle ships:

    mongodb://localhost:27017/wekan
      -> ReplicaSetWithPrimary, servers: 0.0.0.0:27017   (the seed discarded)
    mongodb://localhost:27017/wekan?directConnection=true
      -> Single, servers: localhost:27017

directConnection=true keeps the driver on exactly the host it was given. It costs
nothing else: the handshake still reports setName rs0, which is the only thing
Meteor checks before it will tail an OpLog (accounts for the check in the mongo
package's oplog_replica_set_error.js), so #6480/#6481 are unaffected and the
commented MONGO_OPLOG_URL examples keep replicaSet=rs0 beside it.

All five FerretDB v1 compose files are fixed, being the ones that start FerretDB
with --repl-set-name. The MongoDB compose files are deliberately left alone: those
are real replica sets whose members are reachable under the names they advertise,
and directConnection would pin Meteor to a single node of one.

tests/ferretdbDirectConnection.test.cjs pins both halves - that a compose file
running FerretDB as a replica set carries directConnection=true on every URL
pointing at it, and that the MongoDB ones do not.

Thanks to Dandrass and xet7 !

Fixes #6582,
2026-08-11 01:51:51 +03:00
github-actions[bot] 7f5408947b Bump versions for v10.80 2026-08-10 20:33:21 +00:00
Lauri Ojansivu 061593c5ee Prepare v10.80 release 2026-08-10 23:32:14 +03:00
Lauri Ojansivu 5fe2192962 CHANGELOG: the translations entry, and the Upcoming groups it exposed.
The entry: which three of the six strings needed translating and why the other
three did not, that each language's own OS_Type and OS_Platform decided the form
its translation takes, that Greek and Korean were NOT copied from because those
two files hold Italian and Japanese there, and that forty files keep the English
placeholder on purpose.

Adding it took the Upcoming section to five entries, and the grouping guard then
found what was already wrong: one group line - the snap one - stood above the
Filesystem integrity entry, which is not a snap change, so an unrelated entry sat
inside that group. Every entry now has a group naming its own area, and the snap
group holds only the two snap entries.

Thanks to xet7 !
2026-08-10 20:45:02 +03:00
Lauri Ojansivu 132a644848 Translations: the new Version-pane strings, in 113 languages.
The Version pane's five category labels and the packaging row were added in
English only, so every other language showed them in English. Three of the six
needed translating at all: Platform, package ("Package") and OS. Database was
already translated in 131 languages - the key existed before and this revived it
- and Meteor and Node are product names that stay as they are in every language,
which is also why the filler ignores a value equal to the English source.

Translated directly, per CLAUDE.md: no external service, and each language's own
existing strings as the reference. Its OS_Type and OS_Platform show which form
that language's translators use - "Typ des Betriebssystems", "Tipo SO", "Тип ОС",
"Käyttöjärjestelmän tyyppi" - so OS is "Betriebssystem" in German, "SO" in
Italian and Portuguese, "ОС" in Russian and "Käyttöjärjestelmä" in Finnish
rather than one spelling imposed on all of them.

Two files were NOT copied from: el's OS_Type and OS_Platform hold Italian, and
ko's hold Japanese. Propagating that would have spread somebody else's mistake
into three more strings, so those two got proper Greek and Korean instead.

Applied through releases/translations/fill-translations.mjs --apply, which
writes ONLY into a placeholder - so no human translation could be overwritten
even by accident. The diff shows it: 292 changed lines across 106 files, and
every one of them is Platform, package or OS. Key order and indentation are
unchanged, every file still parses, verify-human-preference.mjs passes 10/10,
and the 350 node suites pass.

40 language files are left with the English placeholder on purpose - ace, ary,
br, gu-IN, ig, km, mn, oc, or_IN, pa, tk_TM, tlh, ug, ve, vl-SS, vo, wa, wo, xh,
yi, yo, zgh, zu and the en-* variants, which are English by design. A placeholder
that says so is better than a translation I cannot stand behind, and Transifex
can still replace any of these with a human one: nothing here is pushed there.

The backlog is 10285 strings, from 10592.

Thanks to xet7 !
2026-08-10 20:40:53 +03:00
Lauri Ojansivu 5852351d30 Updated translations 2026-08-10 20:35:16 +03:00
Lauri Ojansivu a475afd3d3 Merge pull request #6581 from khuongsatou/codex/apple-glass-pastel-theme-clean
Add Apple Glass Pastel board theme
2026-08-10 18:13:23 +03:00
khuongsatou's projects 4763c94604 Add Apple Glass Pastel board theme
Register appleglasspastel as a fixed special theme, wire its accent and exporter colour, add glass/pastel board surfaces, and guard the picker/docs/runtime preview coverage.
2026-08-10 21:51:21 +07:00
Lauri Ojansivu 539c678199 CHANGELOG: the four v10.79 changes that shipped without an entry.
Every commit of a release belongs in that release's section, and four were not
there: the snap-store table correction, and the three that rewrote CLAUDE.md and
AGENTS.md. They are in v10.79 rather than Upcoming because that is the release
they are in - moving them would be tidier and untrue.

The documentation subsection gains a second entry, so it is grouped by area the
way the rules ask, and a new "and documents how to work on these repositories"
subsection carries the instruction-file entries: who maintains these
repositories and who commits, what is in .tools/ and which changelog format each
repository under it uses, and the correction that TSC is xet7's too.

Thanks to xet7 !
2026-08-10 15:26:36 +03:00
Lauri Ojansivu d0dc7dff98 CHANGELOG: the third MongoDB reader, under Upcoming.
Beside the entry it completes: which server can read which data and why that is
a property of the servers the snap carries, that mongod 4.2 reads only during a
migration, why it brings its own OpenSSL 1.1, that a failure to stage it never
fails a release, what was verified on this machine, and which vintages are still
answered by the page instead.

Thanks to xet7 !
2026-08-10 14:58:07 +03:00
Lauri Ojansivu 71ba0c2dcd Snap: a third MongoDB reader, so a 4.x database migrates instead of stopping.
#6471 again, the other half. The previous commit stopped the crash loop and
explained it; this one removes the reason for it in the case that was actually
reported.

A MongoDB server only starts on data whose featureCompatibilityVersion is at most
one major behind it, so what the snap can READ is decided by which servers it
carries: mongod 7 (FCV 6.0, 7.0) and the MongoDB 3.2 tools (3.2). Everything
between was unreadable, and the WeKan snap has shipped 3.6, 4.0, 4.2, 4.4 and 5.0
over the years. The reported error, twice, is mongod 7 naming the gap: "Try
MongoDB 4.2 or earlier" - FCV 4.0.

So the snap now bundles mongod 4.2 as a third reader, used ONLY to read the old
data during a migration and never as the running database. mongod 4.2 opens FCV
4.0 and 4.2, and the modern importer reads it with the bundled driver, which
supports servers from 4.2 up - the same importer that reads a 6/7 source, not a
second copy of it. The probes run newest-first: 7, then 4.2, then the 3.2 tools,
then the explanatory page.

amd64 and arm64 only. MongoDB publishes no 4.2 for the other architectures, and
they have been FerretDB from their first boot, so there is nothing there to
migrate from.

IT CARRIES ITS OWN OpenSSL 1.1. The 4.2 build links libssl.so.1.1 and
libcrypto.so.1.1, and core24 is Ubuntu 24.04, which ships OpenSSL 3 - without
them the binary does not even load. Both come from one Debian libssl1.1 package,
staged beside the binary and put on LD_LIBRARY_PATH exactly as the 3.2 tools
already are. The filename is resolved by listing the pool rather than pinned,
because point releases roll and a pinned name 404s the day they do; three
archives are tried in turn.

OPTIONAL BY DESIGN. Every failure in the part - download, checksum, OpenSSL,
or the binary not running - ends it with a message and no binary, and the
migration simply does not find one. A release is never failed over a migration
aid, and the snap then behaves as it did before: the unreadable-database page.

VERIFIED, as far as this machine allows: mongod 4.2.25 aarch64 was downloaded,
staged with the Debian bullseye libssl1.1, and RUN here - "db version v4.2.25,
OpenSSL version: OpenSSL 1.1.1w" - then started on a dbpath, forked, and
listened on a port. That is the whole mechanism the snap will use, on a 2026
system, outside a snap. The build itself repeats the --version check and
unstages the binary if it fails. What is not verified is a real snap build and a
real 4.x database; the first upgrade is that test.

Still unreadable, and still handled by the page rather than a migration: 3.4,
3.6, 4.4 and 5.0. Adding mongod 5.0 beside this one would close 4.4 and 5.0 the
same way, at the same cost in size.

Thanks to Philippe-Bentegeac, JDeepix, imlit and xet7 !
2026-08-10 14:57:07 +03:00
Lauri Ojansivu 909496fb8b CHANGELOG: the snap's unreadable-database stop, under Upcoming.
The bug subsection has two entries now, so it is grouped by area and its header
is plural. The entry: what the loop was, why nothing in it could succeed, that
the snap stops and exits 0 rather than letting snapd restart it forever, what
the page tells an admin, and that nothing on that path is deleted or modified.

Thanks to xet7 !
2026-08-10 13:37:17 +03:00
Lauri Ojansivu 562fa02718 Snap: a database this snap cannot read stops and says so, instead of 502.
#6471. A snap upgraded onto a database left by a MongoDB 4.x or 5.x snap served
502 Bad Gateway forever, with the reason only in `snap logs`:

    This version of MongoDB is too recent to start up on the existing data
    files. Try MongoDB 4.2 or earlier.

The snap carries two readers - mongod 7, the server it runs, and the MongoDB 3.2
tools for a 6.09-era database - and nothing in between, so 4.x data opens in
neither. What the code then did is the one thing that cannot work:
migration-control found neither reader could open it and handed back to
mongodb-control, mongodb-control started mongod, mongod failed the same way,
mongodb-control re-ran the migration, three times by its own counter, and then
exited 1 for snapd to restart. Nothing in that loop can succeed - reading those
files needs a binary the snap does not have - and the site was down throughout.

It stops now, and says so where an admin will see it. migration-control tells
"no reader for this vintage" from "unreadable/corrupt" by mongod's own words,
writes $SNAP_COMMON/.mongodb-data-too-old carrying the version mongod named as
still able to read the data, pauses auto-migration and exits 0 - 0, because
snapd restarts a failing service forever and no restart can help. mongodb-control
refuses to start a mongod it knows cannot start. wekan-control serves the
maintenance page with a third reason, data-too-old, at the top of the script and
from inside the database wait loop, so a WeKan already waiting switches over
without a restart.

The page names the version that can still read the files and gives the two ways
forward - revert to the revision that worked, or dump with a MongoDB that can
read it and restore into this version - says that nothing was changed and that
attachments and avatars are files on disk, and drops the auto-refresh and the
spinner the other two maintenance pages have: this is a stop, not a wait, and
the page should not promise that something is happening.

NOTHING IS DELETED OR MODIFIED on this path. The source data is exactly as it
was; the marker is the only thing written, and removing it lets the snap try
again.

tests/snapOldMongoData.test.cjs covers the wiring in all three scripts and then
RUNS the page module - it is standalone Node with no dependencies - to check
what an admin actually sees: 503, the version, "untouched", both remedies, no
refresh, no spinner. The snap docs gain the section an admin searching for that
mongod line will find, with the commands.

Thanks to Philippe-Bentegeac, JDeepix, imlit and xet7 !

Fixes #6471,
2026-08-10 13:35:44 +03:00
Lauri Ojansivu fd67a510f8 CHANGELOG: a new Upcoming section for what came after the v10.79 release.
The v10.79 release renamed the Upcoming heading, and two changes landed after
it: the Filesystem integrity fix, whose entry was written "under Upcoming" when
Upcoming had already become v10.79, and the Version table restructure, which had
no entry at all. So the released section claimed a fix that is not in the
release, and the newest work was invisible.

Both are in a new Upcoming section now - the Filesystem integrity entry moved
out of v10.79 rather than copied, so it appears once - with the summary and the
binaries table the shape requires, and the table stated for what it is: v10.79's
binaries, which nothing here rebuilds.

Thanks to xet7 !
2026-08-10 12:57:57 +03:00
Lauri Ojansivu aed26c6771 Version: one table with combined category rows, over two 50% columns.
The pane was five tables, one per category, and five tables size their columns
independently: "WeKan ® Version" made the first one's label column wide and "OS
Type" made the next one's narrow, so the values started at a different x in
every group and the pane read as five unrelated things.

One table now. Each category is a ROW in it - a th with colspan=2, bold and
start-aligned - so it says what the rows under it are about instead of being a
label with an empty cell beside it. A colgroup of two 50% columns plus
table-layout:fixed puts every label and every value in the same place down the
whole pane, and settingBody.css's 240px header cap is undone for this table
only, since the width is now stated outright and the cap would fight it.

The category title takes the table's own font size deliberately: at the pane
title's size five of them would read as five pane titles and "Version" would be
lost among them.

Two of the repo's own guards caught mistakes on the way, which is what they are
for. tests/jadeCompiles.test.cjs refused `th(colspan=2)`: the Meteor jade
dialect wants the value quoted, and an unquoted one is a build failure rather
than a rendering difference. tests/rtl.test.js refused `text-align: left`: the
label column is on the RIGHT in Arabic and Hebrew, so it is `start`.

tests/versionPaneCategories.test.cjs follows the new shape - exactly one table,
five tr.info-category rows each spanning both columns, the colgroup and
table-layout:fixed that make the 50/50 real, and the category title bold with no
font-size of its own. The row-by-category and no-row-was-lost checks are
unchanged and still pass, so the regrouping moved nothing.

Thanks to xet7 !
2026-08-10 12:54:55 +03:00
Lauri Ojansivu ed0d7a2bad 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-08-10 12:51:42 +03:00
Lauri Ojansivu 5a947b6959 CHANGELOG: the blank Filesystem integrity pane, under Upcoming.
First group of the bug fixes, beside the Admin Panel work above it: what the
pane looked like, the one missing helper, and that Blaze reads an undefined
helper as false rather than complaining - which is why three-quarters of the
wiring being right showed as an empty page.

Thanks to xet7 !
2026-08-10 12:51:41 +03:00
Lauri Ojansivu 01c36852da Filesystem integrity showed a blank page: the helper was never added.
Admin Panel / Problems / Filesystem integrity drew nothing at all - the pane
title and then empty space - while Summary went on reporting "7 new problems"
for it.

Everything about the pane looked right, which is why it survived: the menu has a
report-integrity entry, clicking it is handled, the handler sets
tmpl.showIntegrity, the template has `else if showIntegrity.get` and an
+eventStreamReport(stream="integrity") under it. The one missing piece was the
HELPER. showIntegrity() was never added beside showDatabase() and the eight
others, and in Blaze an undefined helper is not an error - it is falsy. So the
branch never ran, the page was blank, and nothing anywhere said why.

The helper is there now, and tests/adminPaneHelpers.test.cjs guards the class
rather than this one pane: every `show*.get` branch in a settings template must
have a helper of that name in that template's own .js, and a ReactiveVar behind
it. The templates are FOUND, not listed, so a pane added later is covered
without editing the test. It also names Filesystem integrity's four wiring
points one by one, because that is the pane that was three-quarters wired.
Checked in the failing direction: removing the helper again fails the test with
the reason.

Thanks to xet7 !
2026-08-10 12:51:41 +03:00
github-actions[bot] afcb9353ee Bump versions for v10.79 2026-08-10 02:07:28 +00:00
Lauri Ojansivu 1d9222eb81 Prepare v10.79 release 2026-08-10 05:06:24 +03:00
Lauri Ojansivu 4588fb8b3d Updated translations. 2026-08-10 05:05:06 +03:00
Lauri Ojansivu fd3a381cd6 CHANGELOG: the packaging row's label is translated, its value is not.
Folded into the Version-pane entry rather than added as a second one: the
release ships one feature, and "we renamed the key afterwards" is not a thing a
reader of the release notes needs to follow.

Thanks to xet7 !
2026-08-10 03:52:34 +03:00
Lauri Ojansivu 5091c3883a Version pane: the packaging row is package, and its value is never translated.
The label was `Platform_packaging` / "Installed as". It is `package` / "Package"
now: a short key for a short column, in the vocabulary the rest of the pane uses.

The half that matters more is the VALUE. bundle.zip, Snap, Docker and Sandstorm
are the names of the things themselves - a package format, a store, a product -
not words describing them, so they are not translated anywhere and none of them
is an i18n key. What an admin reads in the pane is then the same string they can
put in an issue, search the docs for and grep a log with, in every language; a
translated "Ohjelmapaketti" in a Finnish screenshot would match nothing anybody
can look up.

The test carries both halves rather than the comment alone: the label must be
the translated key, the value must be printed straight from
statistics.platform.packaging with no {{_ }} around it, and none of the four
names may exist as an i18n key - which is how "translate it somewhere else
later" is caught. Checked in the failing direction too: wrapping the value in
the translation helper fails with the reason.

Thanks to xet7 !
2026-08-10 03:52:18 +03:00
Lauri Ojansivu de7770f211 CHANGELOG: the Version pane's five tables, under Upcoming.
Its own subsection between the new features and the bug fixes, where an Admin
Panel reorganisation goes: what the pane was, what the five categories are and
why reactivity and DDP moved to Meteor, the new "installed as" row and the order
its detection asks its questions in, and that the heading size is a test rather
than a promise.

Thanks to xet7 !
2026-08-10 03:42:10 +03:00
Lauri Ojansivu dcc6c8fe5d Version pane: five tables with a heading each, and what WeKan is installed as.
Admin Panel / Settings / Version was ONE table of 38 rows. The WeKan version,
the OS load average, the DDP transport and a V8 heap counter were the same kind
of thing in it: one flat list, read top to bottom, with no way to jump to the
part you came for.

Five tables now, each under a small heading, in the order somebody debugging
reads them - Platform (what this IS), OS (what it runs on), Meteor (how it talks
to the client: reactivity, reactivity order, DDP transport), Database (type,
versions, commits, storage engine, OpLog, sessions) and Node (version, heap,
memory). Reactivity and DDP moved to Meteor, where they belong: they are how the
client is fed, and they were the two rows most often read as database settings.
Whether an OpLog EXISTS stays with the database, because that is a property of
it. No row was dropped; a test lists all 38 by name and requires each to still
be shown.

NEW ROW: what WeKan is installed as - bundle.zip, Snap, Docker or Sandstorm. It
is the first thing a support answer turns on, and the pane never said it: the
same version keeps its data somewhere else, carries a different database and
gives the admin different reach in each of the four. models/lib/platformPackaging.js
detects it, most-specific-first: an explicit WEKAN_PACKAGING wins (a packaging
that knows what it is should say so, and it is how a future .deb or chart names
itself), then Sandstorm, then snapd's own SNAP/SNAP_NAME, then a container
runtime's marker file, then the bundle. Sandstorm and Snap are asked BEFORE the
container markers on purpose: a grain is a container, and a confined snap can
look like one, so the other order answers "Docker" for both. It claims nothing
it cannot know - there is no "source checkout" answer, because a `meteor run`
and an unpacked bundle are identical from inside the process.

The detection is a pure function taking the environment, the Sandstorm flag and
a file-exists callback, so all nine of its tests run here without a snap, a
container or a grain - including that an empty SNAP is not a snap, that an empty
WEKAN_PACKAGING falls through rather than blanking the field, and that an
unreadable root answers bundle.zip instead of throwing an admin's Version page
away.

The headings are SMALLER than the pane title above them, and a test compares the
two font sizes rather than trusting the CSS to stay that way: five headings at
1.25rem would read as five pane titles and "Version" would be lost among them,
and an unstyled h3 - the browser default here - is larger still. Six new English
strings; the other languages fall back to English until translated, as usual.

tests/clientBundleImports.test.cjs caught the new stylesheet before it could
matter: package.json sets meteor.mainModule, so a .css nobody imports is simply
not in the bundle. It is imported from client/features/settings.js now.

Thanks to xet7 !
2026-08-10 03:40:12 +03:00
Lauri Ojansivu 3170b70f51 CHANGELOG: the next base's six architectures, under Upcoming.
Its own group under the bug fixes, beside the snap builds group it belongs
with: what was missing from snapcraft-core26.yaml, why nothing could have
noticed, and why adding the platform without the t64 package names would have
been nominal.

Thanks to xet7 !
2026-08-10 03:04:32 +03:00
Lauri Ojansivu 664b8fde6f The next base builds all six architectures too, armhf included.
snapcraft.yaml declares six platforms - amd64, arm64, armhf, ppc64el, riscv64,
s390x - and every one of them is built: amd64 and arm64 natively, the other four
on Launchpad. snapcraft-core26.yaml declared FIVE. armhf was missing, and nothing
could notice: that file is not built by any release, it is the next base kept so
the move can be tested before it is made. The day core26 becomes the base, armhf
would simply stop being published and the only symptom would be a store column
going stale, exactly as wekan-ondra's armhf did at 0.22.

Adding the platform alone would have been nominal. The same file still carried
the PRE-t64 stage-package names - libssl3, libcurl4, libgoogle-perftools4 - and
those are precisely what failed armhf on core24 twice: Ubuntu's 64-bit time_t
transition renamed them, the 64-bit architectures kept a compatibility Provides
so the old spelling resolves there, and armhf did not. So armhf on core26 would
have failed on the first thing it tried, in the way this repository has already
debugged. It has the t64 names now. Its Caddy branch already mapped armhf to
Go's armv7 name; the comment saying that branch is unreachable is no longer true
and says so.

Two guards, both checked in the failing direction as well:
tests/releaseSnapArches.test.cjs now requires the two snapcraft files to declare
the SAME set of architectures - removing armhf from core26 fails it with the
reason - and the stage-package check runs over BOTH files rather than only the
one the release builds, so the next base cannot drift back to a name that breaks
on one architecture only.

Nothing here changes what the release builds today: core24 stays the built base,
and wekan's armhf snap is still waiting on the Caddy armhf fix in the upcoming
release.

Thanks to xet7 !
2026-08-10 03:04:11 +03:00
Lauri Ojansivu cf0c347b2e Snap CPU platforms: what the store actually holds at 10.78, and why.
The table said "as of 10.76" and marked every empty square the same way: "built
by snapcraft.yaml but that snap has no revision for it yet - uploading one is all
that is needed". For eight of the twelve squares that is not true, and it points
whoever reads it at the wrong job.

Three marks now, because there are three situations. wekan's armhf is BUILT by
the release and missing from the store: snapcraft.yaml declares it,
snap-launchpad builds it, and it has been failing - most recently on Caddy, which
publishes no linux_armhf archive because its asset names are Go's, fixed for the
next release. The exotic squares of wekan-ondra and wekan-gantt-gpl are not
pending uploads at all: the snap-variants job has four matrix entries, each
variant on amd64 and arm64 on GitHub runners, and the ppc64el/s390x/riscv64/armhf
builds come from snap-launchpad, which builds only the `wekan` name. Nothing will
ever upload those unless the variant job learns Launchpad remote builds under the
variant snap names. And wekan-ondra's armhf 0.22 stays a fossil, now said
explicitly: there is no newer revision to promote AND nothing currently builds
one.

riscv64 moved from missing to published since 10.76, so wekan is five of six.
Every published architecture is on all four channels, which is the earlier
release-to-all-channels work holding.

Thanks to xet7 !
2026-08-10 02:53:18 +03:00
Lauri Ojansivu bf1f7aea47 CLAUDE.md and AGENTS.md: TSC is xet7's too, under another organisation.
The previous commit read Secretchronicles/TSC's remote, saw it was not a wekan/
repository, and concluded it was somebody else's project xet7 contributes to. It
is his: the organisation is Secretchronicles rather than wekan, and that is the
only difference. His GitHub profile says it in three words - "WeKan and TSC
maintainer" - and the files now cite it, so the next reader checks a source
instead of inferring from a remote URL as this one did.

So maintainer mode covers it - commit directly to devel, no pull request, same
author, no AI attribution - and the files say which .tools/ repository actually
is somebody else's: sandstorm-io/sandstorm, cloned for reference.

What does NOT follow from maintaining it is WeKan's house style. TSC keeps its
own GNU ChangeLog file and its own release process, exactly as the CHANGELOG
section below already said, because a project is read on its own terms.

Thanks to xet7 !
2026-08-10 02:04:31 +03:00
Lauri Ojansivu d077948115 CLAUDE.md and AGENTS.md: what is in .tools/, and each repository's changelog format.
THE LAYOUT OF .tools/. It was one sentence naming three clones. It is now the two
kinds of thing that directory actually holds, because they are treated
differently. Companion git repositories - wekan/FerretDB on main-v1,
wekan/node-patches and wekan/mongo-tools-patches on main, Secretchronicles/TSC on
devel, sandstorm-io/sandstorm - each with their own history, branches, changelog
and release flow, cloned on demand by build.sh's ensure_tool_repo; a commit in
WeKan is never a commit in one of them. And unpacked toolchains that are
downloads rather than repositories: node-v<version>-linux-<arch>, go with its
gopath/gomodcache/gocache, .meteor when HOME points at .tools, the TSC AppImage.
Delete and re-fetch those freely.

TSC and sandstorm are NOT xet7's repositories, and the ownership paragraph at the
top no longer implies they are. The commit author is the same there; the branch,
the contribution process and the changelog format are that project's call.

NO CLAUDE.md OR AGENTS.md UNDER .tools/. node-patches and mongo-tools-patches
each had a pair, and they were removed: the rules are identical for all of these
repositories and a second copy drifts from the first. These two files are where
they live. A fact true of one repository only belongs in that repository's own
README.md or docs/ - and an instruction file found there is to be removed rather
than updated.

CHANGELOG FORMAT, PER REPOSITORY. A table of the five that come up, because they
are not all the WeKan format: wekan/wekan, node-patches and mongo-tools-patches
use it; wekan/FerretDB keeps UPSTREAM FerretDB's (## [v1.48.0](tag) (date) with
### New Features/Fixed/Other Changes bullets ending "by @xet7"); and TSC uses GNU
ChangeLog - a "YYYY-MM-DD  Name  <email>" header over tab-indented "* Fix: ..."
entries ending "(by Name)", in a file called CHANGELOG with no extension. The
reason is the reader: a FerretDB release is read beside upstream's, and a TSC
entry beside a decade of GNU ChangeLog entries. When this file and the file being
edited disagree, the file being edited wins.

AGENTS.md had drifted from CLAUDE.md - it still described a FerretDB subdirectory
at the repo root and told the release step to run from there. The two files are
now identical apart from their first and third lines, which name the tool that
reads them.

Thanks to xet7 !
2026-08-10 01:55:46 +03:00
Lauri Ojansivu 35dd10b89f CLAUDE.md and AGENTS.md: who maintains these repositories, and who commits.
The instruction files opened with an identity CHECK - "maintainer mode only when
the identity is exactly Lauri Ojansivu <x@xet7.org>" - which says what to do with
an identity but never says whose repositories these are. They are xet7's: WeKan,
the FerretDB v1 fork, node-patches and mongo-tools-patches. That is now the first
thing both files say, and the commit author follows from it rather than from a
conditional.

Two rules are spelled out under it. No commit, pull-request body or CHANGELOG
line ever attributes work to an AI - no Co-Authored-By, no "Generated with", no
assistant or model name - and CODE_OF_CONDUCT.md is cited as where that comes
from ("mention only those participants that are human"); a "Thanks to ... and
xet7 !" line credits people. And a missing or wrong git identity in one of these
checkouts is to be SET, not worked around: the .tools/ clones build.sh makes can
come up with no user.name/user.email at all - mongo-tools-patches did - which
would author a commit as whatever the machine's default is.

Contributor mode is unchanged in substance: a different person's identity, in
their own fork or clone, still means a branch and a pull request, and that pull
request carries no AI attribution either.

Thanks to xet7 !
2026-08-10 01:45:21 +03:00
Lauri Ojansivu cf000d000a CHANGELOG: the armv6 Docker platform, and the CPU-platform pages.
Two entries under Upcoming. The armv6 one goes in the "Raspberry Pi 1 and
Zero" group beside the bundle that already ships: what TARGETVARIANT changes,
why the base image is asked what it publishes at all, and that debian:trixie
has arm/v5 and arm/v7 and nothing between them.

The documentation subsection covers the new Docker CPU-platforms page, the
snap page's armv6 section and bundle table, and the FerretDB v1 page's
architecture list. The In short paragraph no longer claims armv6 ships a
Docker image today - it says the image is wired and gated.

Thanks to xet7 !
2026-08-10 01:36:08 +03:00
Lauri Ojansivu 5ec9f3ae5b Document the CPU platforms of the Docker image, beside the snap's.
"Which CPUs is this published for, and why not that one" was answerable for
the snap and nowhere else, so the same page now exists for the image:
docs/Platforms/FOSS/Container/Docker/CPU-platforms.md. It carries the platform
matrix with each platform's TARGETARCH, TARGETVARIANT and bundle, how the set
is decided on every release from what the base image publishes and which
bundles landed, the three names that differ between Docker, Debian and the
WeKan bundles, and one section per platform that is deliberately NOT an image:
loong64 (no base image exists at any tag), armv6 (gated on a base with
arm/v6), armv7 (the one 32-bit ARM slot must carry the armhf baseline) and the
Windows and macOS bundles. It ends with why the FerretDB image covers more
platforms than the WeKan one - FROM scratch around a static Go binary needs no
userland at all.

The snap's page gains the matching armv6 section - the Snap Store has no armv6
architecture, and its only 32-bit ARM is armhf, which an ARMv6 board cannot
run - and a table of all fifteen bundles against which six become snaps, so
"it is missing" and "it cannot be there" stop looking alike. The two pages
link each other, because the answer differs between them.

docs/Databases/FerretDB/1/README.md said the per-architecture FerretDB binary
was embedded in the bundles "for ppc64le, s390x, riscv64". Every bundle
carries one; those three are part of a longer list of platforms where it is
the DEFAULT because MongoDB publishes no server. It now lists all seventeen
built binaries and separates the three 32-bit ARM builds that are not variants
of each other: armhf is GOARM=7, armv6 is GOARM=6, armel is GOARM=5 software
floating point.

Thanks to xet7 !
2026-08-10 01:35:51 +03:00
Lauri Ojansivu f342d54d18 Docker: read TARGETVARIANT, and make linux/arm/v6 a candidate platform.
TARGETARCH does not identify a 32-bit ARM platform: linux/arm/v6 and
linux/arm/v7 both arrive as TARGETARCH=arm, and the generation is in
TARGETVARIANT. The Dockerfile's case mapped arm straight to armhf, so the
moment armv6 existed as a bundle an ARMv6 build would have been handed the
armhf zip - ARMv7-A instructions for a CPU that cannot run them. It now
branches on the variant: v6 takes armv6, v7 and an unset variant take armhf,
and anything else exits. v5 is armel, which Go builds and Node.js does not
exist for, so there is no bundle and refusing is the only honest answer.

linux/arm/v6 joins the docker job's optional platform list, and the release
now asks a question it never asked before: does the BASE IMAGE publish this
platform? For every other architecture that is uninteresting, because a base
that lacks one fails the build. For 32-bit ARM it does not fail - containerd
treats a LOWER arm variant as compatible, so a linux/arm/v6 request against a
base with arm/v5 and arm/v7 resolves to arm/v5, Debian armel, SOFT float, and
the image would be built on a userland whose loader cannot start the
hard-float node-armv6 in the bundle. A silent downgrade is worse than a
dropped platform.

So the job reads the base name out of the Dockerfile - no second copy of it -
asks docker buildx imagetools inspect what that base publishes, and drops a
candidate it lacks with a warning saying why. debian:trixie is 386, amd64,
arm64/v8, arm/v5, arm/v7, ppc64le, riscv64, s390x: no arm/v6, because Debian
has no ARMv6 port at all. The armv6 bundle zip is unaffected, and the image
platform turns itself on the day a base publishes the variant.

VERIFIED: debian:trixie's manifest list was read from the registry rather than
assumed, and the decide step was extracted and run against both that list
(armv6 drops, the other seven build) and a base that does publish arm/v6
(armv6 included, paired with the armv6 bundle). tests/releaseDockerPlatforms
pins the variant split, the refusal of v5, the platform-to-bundle pairing and
that both loops ask the base - the last so that deleting the check cannot
quietly become an image whose Node.js will not start.

Thanks to xet7 !
2026-08-10 01:35:38 +03:00
Lauri Ojansivu d9e4cd57cb Add the armv6 bundle: Raspberry Pi 1 and Zero.
Every piece of the chain now exists, and the missing one was Node.js.
nodejs.org dropped its ARMv6 binaries after Node 11 and unofficial-builds
has none, so wekan/node-patches gained an armv6 target (V8 still accepts
--arm-arch=armv6 and configure.py still carries is_arch_armv6() with
`vfp` among valid_arm_fpu); wekan/FerretDB and wekan/mongo-tools-patches
gained GOARM=6 targets beside their armhf ones.

Their armel is GOARM=5 and WOULD run on these boards, which is why it
looks like a substitute and is not one: GOARM=5 does floating point in
software and ARMv6 boards have VFPv2.

The bundle builds in a linux/arm/v6 container, takes node-armv6, and
releases/resolve-node-source.sh resolves it - naming linux-armv6l as the
upstream spelling even though nothing upstream will ever answer to it, so
the search it prints is honest about where it looked.

NO SNAP, and that is not an oversight: the Snap Store has no armv6
architecture at all. Its only 32-bit ARM is armhf, which is ARMv7-A
hard-float and will not run on an ARMv6 board. So armv6 ships as a
bundle zip and a linux/arm/v6 Docker image, and
models/lib/snapArchitectures.js records that reason in
NOT_SNAP_ARCHITECTURES beside i386's and armv7's.

Four tests found the four places a new platform has to be registered,
which is what they are for: releases/expected-assets.sh (or "Release all
missing" never notices it is absent), the resolver mapping table, the
non-native bundle list, and the snap-platform exemption. Each list was
updated rather than the guard loosened.

WHAT IS NOT VERIFIED: none of these binaries has been built. The Node.js
one is a multi-hour ARM cross compile and the first CI run is its test.
Everything checkable from source was checked - V8's accepted --arm-arch
values, configure.py's ARM handling, Go's GOARM semantics, and that
zlib's ARM SIMD is gated on arm_fpu=="neon" so an armv6 build selects
scalar code and needs none of the NEON patching armv7 does.

Thanks to xet7 !
2026-08-09 23:38:43 +03:00
Lauri Ojansivu 3d9ee1f9fc CHANGELOG: the win-arm64 bundle, and why armv5/armv6 cannot be added.
Thanks to xet7 !
2026-08-09 23:06:38 +03:00
Lauri Ojansivu 8bb7b9ba62 Add the win-arm64 bundle: every binary it needs was already published.
Windows on ARM is a bundle WeKan could always have shipped and simply
did not. All three suppliers publish it:

  Node.js       nodejs.org, node-v24.19.0-win-arm64.zip, officially
  FerretDB      wekan/FerretDB, ferretdb-win-arm64.exe
  mongo-tools   wekan/mongo-tools-patches, every tool as -win-arm64.exe

releases/resolve-node-source.sh gains the platform and resolves it live
to nodejs.org with a published SHA256, so it needs no node-patches build
at all - the asset name is recorded anyway, for the day it does.

The job is build-win64 with the architecture changed. It builds on the
x64 Windows runner, like win32 does: the Meteor bundle is JavaScript,
the native modules are installed with --ignore-scripts and are not
compiled for the runner's architecture either way, and the only
architecture-specific things in the zip are the three binaries above,
which are downloaded rather than built.

Registered where a new bundle has to be registered or it is invisible:
release-notes needs it, or the provenance rows are collected before the
job that writes them has finished; and releases/expected-assets.sh lists
it, or "Release all missing" never notices it is absent.

WHAT CANNOT BE ADDED, checked at the same time: armv5, armv6 and armel.
FerretDB and the MongoDB tools publish armel - they are Go, and Go still
targets it - but Node.js does not exist for any of the three. nodejs.org
publishes no 32-bit ARM at all for v24.19.0, unofficial-builds has no
armv6l, and wekan/node-patches builds node-armhf and node-armv7 only. No
Node.js means no bundle, so no Docker image and no snap either. The Snap
Store has no armv5 or armv6 architecture in any case; its only 32-bit
ARM is armhf, which WeKan already publishes.

tests/cpuExecWiring.test.cjs counted four non-Linux bundle jobs and
found five. Rather than change the 4 to a 5, it now derives the count
from the jobs and checks that EACH of them strips the Linux-only
cpu-exec and qemu-x86_64 - which is what the number was standing in for,
and what a bare count cannot tell you when it changes. Verified it fails
when build-win-arm64 does not strip its own copy.

Thanks to xet7 !
2026-08-09 23:05:00 +03:00
Lauri Ojansivu c892540f43 Caddy: the same armhf mapping in every snapcraft file that fetches it.
snapcraft.yaml was fixed; snapcraft-core26.yaml had the identical
passthrough default and was missed. core26 does not build armhf today,
so nothing failed there - which is exactly why it would have been
rediscovered by a release rather than by a test the day an armhf build
was added.

The guard now checks EVERY snapcraft file that fetches Caddy rather than
the first one it finds, which is the mistake that let this through: the
original test read `yaml.indexOf('case "${CRAFT_ARCH_BUILD_FOR}" in')`
and got the WEKAN_ARCH case above it, so it tested the wrong block while
looking like it worked. It is scoped to the block containing CADDY_ARCH
now. It deliberately does not require two such files - WeKan ships
core24 because that is what may publish to the stable channel, and
either file could be removed one day - so the invariant is about the
files that DO fetch Caddy, not how many there are.

Also pins what makes downloading the official binaries sufficient: the
bundled Caddyfile is `:8080 { reverse_proxy localhost:3000 }` and
nothing else, so every directive is in the standard distribution and no
xcaddy build is needed. A third-party module's directive would change
that and would not fail until Caddy refused the config at runtime, so
the test names the ones that would.

Thanks to xet7 !
2026-08-09 22:51:38 +03:00
Lauri Ojansivu 08f41037f3 CHANGELOG: the Caddy armhf mapping and the Snap Store retry.
Thanks to xet7 !
2026-08-09 22:44:56 +03:00
Lauri Ojansivu 4e9be22f95 Snap: Caddy has no armhf archive, and a store hiccup is not a bad snap.
Two failures in the v10.78 snap jobs, neither of them a problem with
WeKan.

ARMHF ASKED FOR A CADDY ARCHITECTURE THAT DOES NOT EXIST. With the
libcurl4t64 fix in, the armhf build got past the stage-packages and died
further along:

    :: Downloading Caddy 2.11.4 (linux/armhf) from GitHub releases...
    :: + curl -fsSL -o /tmp/caddy.tar.gz https://github.com/caddyserver/
       caddy/releases/download/v2.11.4/caddy_2.11.4_linux_armhf.tar.gz
    :: curl: (22) The requested URL returned error: 404
    'override-build' in part 'caddy' failed with code 1.

Caddy is built by Go and its release assets carry GO's architecture
names, not Debian's. There is no linux_armhf archive and there never has
been - the 32-bit ARM ones are armv5, armv6 and armv7 - and the case
statement had no armhf branch, so it fell through to a default that
passed the Debian name straight into the URL. The "fall back to the
pinned version" path then retried the SAME wrong name, so the failure
read as "Caddy stopped publishing this architecture" when it was this
file's mapping all along.

armhf maps to armv7, not armv6: Debian armhf's baseline is ARMv7-A with
VFPv3-D16 hard-float, and Go's armv7 build is GOARM=7, which is exactly
that. This is NOT the armhf/armv7 distinction that matters for Node.js
in wekan/node-patches - that one is about NEON, and GOARM=7 does not use
NEON. Checked against the actual release: all six mapped URLs answer,
and linux_armhf 404s.

The default branch now names the problem and stops, instead of guessing
a name and letting a 404 blame the wrong project.

THREE GOOD SNAPS WERE LOST TO A STORE HICCUP. riscv64, ppc64el and s390x
each built on Launchpad, downloaded, and were then refused:

    Status: error while processing
    Issues while processing snap:
    - binary_sha3_384: Error checking upload uniqueness.

That is the store failing its OWN duplicate check on a digest it had
just computed - a server-side error, not a bad snap - and the message
the job printed was about 'is not a valid file', credentials and ACLs,
none of which applied. The upload is retried three times with a backoff
now, and the give-up message says the snap is fine and nothing here
needs changing.

The retry stays narrow on purpose: a rejected file, unparseable
credentials or a missing ACL will be rejected identically three times,
and retrying those only buries the one message that says what to fix.
The classifier is tested against all four, not just the one that
happened.

New checks in tests/snapStagePackages.test.cjs (every snap architecture
maps to a Caddy asset that exists, every build-for has a mapping, and -
negatively - an unmapped one fails loudly instead of 404ing) and in
tests/provenanceTable.test.cjs (the processing failure is retried, the
four unretryable messages are not, and the give-up message does not send
somebody debugging a build that succeeded). Verified the Caddy guard
fails when the armhf branch is removed.

Thanks to xet7 !
2026-08-09 22:44:22 +03:00
Lauri Ojansivu 74dfacd76e CHANGELOG: a new Upcoming section for the amd64 provenance fix.
Its commit landed after "Bump versions for v10.78", so it did not ship
in v10.78. The entry had been written into that section anyway, because
the release was already cut by the time it was added; it moves out to
Upcoming, and the sentence in v10.78 that pointed at it says what the
other half was instead of pointing at an entry that is no longer there.

Also records #6580 (CHANGELOG.md is too big for GitHub to render or
blame) under TODO Later rather than leaving it looking unexamined.
Splitting the file by year was tried and abandoned: git records no move,
so a plain `git blame` on a per-year file credits every line to the
split and only `git blame -C` reaches the real history, which editors do
not pass by default. Trading working local blame on eleven years of
entries for a smaller file is the wrong way round, and reverting a split
does not undo it - the restored lines then blame to the revert - so the
note says what a real fix would have to do instead.

Thanks to mimZD and xet7 !
2026-08-09 22:28:22 +03:00
Lauri Ojansivu 058c74e9c8 CHANGELOG: the amd64 provenance script paths.
Thanks to xet7 !
2026-08-09 18:40:28 +03:00
Lauri Ojansivu e596805194 amd64: the provenance scripts were never found, only never complained.
The v10.78 amd64 build failed with exit 127 after the bundle was zipped
and checksummed:

    a95d331b…  wekan-10.78-amd64.zip
    bash: releases/record-provenance.sh: No such file or directory
    bash: releases/ferretdb-latest-tag.sh: No such file or directory

The step runs `cd .build` first, so nothing relative in it means what it
looks like it means. That is TWO bugs, and only the second one had ever
been visible:

The SCRIPT path. `bash releases/record-provenance.sh` resolves against
.build/, which has no releases/. It had printed "No such file or
directory" on every run since it was written, and the `|| true` on the
end swallowed it - so amd64 had never recorded any provenance at all,
and the only symptom was its absence from the release-notes table. The
missing-directory fix in the previous commit therefore fixed the half
that was visible and left the half that was not.

The OUTPUT path, fixed previously: `mkdir -p provenance` made
.build/provenance while upload-artifact looked at provenance/ from the
workspace root.

What turned it loud is that the FerretDB tag lookup added in that same
commit is an ASSIGNMENT. Under `set -e` - and every GitHub `run:` is
`bash -e` - `VAR="$(cmd)"` ends the step when cmd fails, so a line that
had been silently doing nothing for months became a hard 127 that failed
the whole amd64 build. Every path in that step is absolute now, and
every FERRET_TAG assignment across all seven sites ends `|| true`: the
bundle is the deliverable, and which version string reaches a markdown
cell is not worth failing a good build for.

Only amd64's step cds; the other six run from the workspace root, which
matches what the v10.77 run produced - they all uploaded provenance and
amd64 did not.

Two more checks in tests/provenanceTable.test.cjs, now 22. One walks
back from every provenance call to its step header and requires an
absolute path when a `cd` runs inside that step - which is the general
form of this bug and would have caught it. The other requires every
FERRET_TAG assignment to tolerate failure. Both verified to fail when
the v10.78 shapes are put back.

Thanks to xet7 !
2026-08-09 18:39:38 +03:00
690 changed files with 285732 additions and 16513 deletions

No files matched your search

+5 -5
View File
@@ -178,7 +178,7 @@ jobs:
run: |
set -euo pipefail
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
if gh release view '${{ needs.release.outputs.tag }}' --repo "${{ github.repository }}" \
if gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" '${{ needs.release.outputs.tag }}' --repo "${{ github.repository }}" \
--json assets --jq '.assets[].name' 2>/dev/null | grep -qx "$zip"; then
echo "OK: $zip is on ${{ needs.release.outputs.tag }}; building its AppImage."
else
@@ -355,7 +355,7 @@ jobs:
publish:
name: attach to the release
needs: [release, build]
if: ${{ always() && inputs.publish != false }}
if: ${{ !cancelled() && inputs.publish != false }}
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v8
@@ -383,7 +383,7 @@ jobs:
# No AppImage was produced. That is only a FAILURE if there was a base
# bundle to wrap: an AppImage cannot be built without one, and the
# build jobs skip (not fail) when the base bundle is not published yet.
published="$(gh release view '${{ needs.release.outputs.tag }}' --repo "${{ github.repository }}" --json assets --jq '.assets[].name' 2>/dev/null || true)"
published="$(gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" '${{ needs.release.outputs.tag }}' --repo "${{ github.repository }}" --json assets --jq '.assets[].name' 2>/dev/null || true)"
base_present=false
for arch in amd64 arm64 i386 armhf; do
printf '%s\n' "$published" | grep -qx "wekan-${{ needs.release.outputs.version }}-${arch}.zip" && base_present=true
@@ -411,7 +411,7 @@ jobs:
# call in this workflow already passes it; these two did not, so the
# AppImages were built, smoke-tested and uploaded as artifacts, and
# then nothing was attached to the release.
gh release upload '${{ needs.release.outputs.tag }}' assets/* \
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" '${{ needs.release.outputs.tag }}' assets/* \
--repo "${{ github.repository }}" --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' \
echo "Uploaded to $(gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --json url --jq .url)"
+11 -7
View File
@@ -153,8 +153,7 @@ jobs:
if: ${{ env.BUILD_THIS == 'true' }}
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree zip
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" --no-install-recommends flatpak ostree zip
flatpak remote-add --if-not-exists --user flathub \
https://flathub.org/repo/flathub.flatpakrepo
flatpak install --user -y --noninteractive flathub \
@@ -304,9 +303,15 @@ jobs:
publish:
name: attach to the release, and build the repository
needs: [release, build]
if: ${{ always() }}
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
steps:
# FIRST: this job runs releases/apt-install.sh, and actions/checkout cleans
# 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/download-artifact@v8
with:
pattern: flatpak-[0-9a-z]*
@@ -342,10 +347,10 @@ jobs:
# objects/, refs/, summaries/, summary, summary.idx - which is build
# scaffolding, not something to attach to a release. TSC hit this
# exact thing ("upload only the bundle files, not the ostree repo").
gh release upload '${{ needs.release.outputs.tag }}' \
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" '${{ needs.release.outputs.tag }}' \
assets/*.flatpak assets/*.flatpak.md5sum assets/*.flatpak.sha256sum \
--repo "${{ github.repository }}" --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' \
echo "Uploaded to $(gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --json url --jq .url)"
# ---- the repository -------------------------------------------------
@@ -359,8 +364,7 @@ jobs:
if: ${{ inputs.publish_repo != false }}
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" --no-install-recommends flatpak ostree
shopt -s nullglob
parts=(repos/flatpak-repo-*)
if [ ${#parts[@]} -eq 0 ]; then
+9 -4
View File
@@ -10,10 +10,15 @@
# half-hour emulated build, and no way for the variant image to drift from the
# release it claims to be.
#
# `workflow_dispatch` ONLY, on purpose. release-all.yml publishes wekanteam/wekan
# to Docker Hub, Quay and GHCR on every release; the variant images are published
# when the maintainer decides to, which is what this file exists for. Nothing
# here runs on a push, a tag or a schedule.
# A RELEASE NO LONGER NEEDS THIS. release-all.yml's docker job tags both variants
# on all three registries in its own `docker buildx build --push`, so every
# release publishes them with the release's own digests. What this file is for now
# is everything outside a release: publishing a variant for a version whose
# release predates that change, repairing a tag, or pointing a variant at a
# different version deliberately.
#
# `workflow_dispatch` ONLY, on purpose: nothing here runs on a push, a tag or a
# schedule.
#
# If a variant repository ever stops being identical to wekan/wekan, this
# retag becomes wrong: build that repository's own Dockerfile instead.
+2 -3
View File
@@ -50,8 +50,7 @@ jobs:
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git zip
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git zip
- name: Install Sandstorm (dev, unattended)
# build-deps.sh's [bridge] step copies the modern
@@ -104,4 +103,4 @@ jobs:
exit 0
fi
echo "Uploading ${{ steps.zip.outputs.zip }} to the newest release: $TAG"
gh release upload "$TAG" "${{ steps.zip.outputs.zip }}" --clobber
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "$TAG" "${{ steps.zip.outputs.zip }}" --clobber
+118 -14
View File
@@ -10,7 +10,13 @@ name: Release all missing
# those six without running the whole thing again, version bump and all.
#
# This finishes a release that already exists. It never bumps a version, never
# tags, never touches Docker, snap, the website or the charts.
# tags, and never touches Docker, snap or the website.
#
# It DOES publish the Helm chart, because that is a thing a release can be
# missing like any other: the chart entry is written once, during the release it
# belongs to, so a release whose charts job did not run leaves a hole that the
# next release never goes back for - v10.80 is one of those, and it was not
# alone. See the charts job at the end.
#
# WHAT IT CAN BUILD, and why that is not everything. Only build-amd64 runs
# Meteor; every other bundle WeKan ships is that same bundle repacked. This job
@@ -79,14 +85,14 @@ jobs:
V='${{ inputs.version }}'
V="${V#v}"
if [ -z "$V" ]; then
V="$(gh release view --json tagName --jq .tagName)"
V="$(gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" --json tagName --jq .tagName)"
V="${V#v}"
echo "No version given; the newest release is v$V."
fi
echo "version=$V" >> "$GITHUB_OUTPUT"
if gh release view "v$V" >/dev/null 2>&1; then
gh release view "v$V" --json assets --jq '.assets[].name' | sort > existing.txt
if gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "v$V" >/dev/null 2>&1; then
gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "v$V" --json assets --jq '.assets[].name' | sort > existing.txt
else
echo "::error::There is no release v$V to complete. This workflow FINISHES a release; release-all.yml makes one."
exit 1
@@ -188,19 +194,18 @@ jobs:
echo "v${VERSION} already has ${{ matrix.arch }}; nothing to do."
fi
- uses: actions/checkout@v7
- name: Install dependencies
if: ${{ steps.want.outputs.build == 'yes' }}
run: |
sudo apt-get update
sudo apt-get install -y curl unzip zip
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" curl unzip zip
- name: Set up QEMU for ${{ matrix.arch }} emulation
if: ${{ steps.want.outputs.build == 'yes' }}
uses: docker/setup-qemu-action@v4
# Before the download, because actions/checkout cleans the workspace.
- uses: actions/checkout@v7
if: ${{ steps.want.outputs.build == 'yes' }}
# Before the download, because actions/checkout cleans the workspace. if: ${{ steps.want.outputs.build == 'yes' }}
# THE DIFFERENCE from release-all.yml, and the whole point of this
# workflow: the amd64 bundle comes from the RELEASE, not from an artifact
@@ -244,11 +249,11 @@ jobs:
asset="wekan-${VERSION}-${{ matrix.arch }}.zip"
# No --clobber: this architecture was absent, so an upload that would
# overwrite something means the release changed under this run.
gh release upload "v${VERSION}" "$asset" "${asset}.sha256sum"
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "v${VERSION}" "$asset" "${asset}.sha256sum"
# Confirm from the other side. An upload that reports success and
# leaves no asset behind is the failure nobody notices until a
# download 404s.
gh release view "v${VERSION}" --json assets --jq '.assets[].name' > release-assets.txt
gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "v${VERSION}" --json assets --jq '.assets[].name' > release-assets.txt
grep -qxF "$asset" release-assets.txt || {
echo "::error::$asset is not on release v${VERSION} although the upload reported success."
exit 1
@@ -279,10 +284,109 @@ jobs:
publish: true
only: ${{ needs.plan.outputs.flatpak }}
# ─── The Helm chart, for a release that never got one ───────────────────────
#
# Everything else here rebuilds a missing BUNDLE; this publishes a missing
# CHART, which goes missing the same way and for the same reason. A chart is
# written once, by release-charts.sh, during the release it belongs to, so a
# release whose charts job did not run has no chart and nothing ever goes back
# for it. That is not rare - 146 of the packages already on gh-pages had no
# index entry, and v10.80 has no package at all, because a cancelled
# build-mac-x64 cancelled its run before the charts job started.
#
# It is the one part of this workflow that PUSHES to another repository, so it
# is guarded twice: it does nothing unless a token that can push to
# wekan/charts is configured, and nothing if the chart for this version is
# already published. Re-publishing an existing version would re-tar the package
# and change the digest of a chart people have already pulled.
charts:
name: Helm chart
needs: plan
if: ${{ !cancelled() && needs.plan.outputs.version != '' }}
runs-on: ubuntu-latest
steps:
- name: Checkout wekan (for releases/release-charts.sh)
if: steps.check.outputs.skip != 'true'
uses: actions/checkout@v7
with:
path: wekan
- name: Is a chart already published for this version?
id: check
env:
V: ${{ needs.plan.outputs.version }}
REPO_TOKEN: ${{ secrets.WEKAN_REPO_TOKEN }}
run: |
set -uo pipefail
if [ -z "${REPO_TOKEN:-}" ]; then
echo "::warning::No WEKAN_REPO_TOKEN, so the Helm chart cannot be published. Add a token that can push to wekan/charts to have this finish the chart too."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# The published index is the authority on what exists, not this
# checkout. An entry's own version is at EXACTLY four spaces; the
# mongodb subchart's version sits deeper and would match a loose grep.
bash wekan/releases/fetch.sh -o index.yaml https://wekan.github.io/charts/index.yaml || {
echo "::warning::Could not read the published chart index; not touching the charts repo."
echo "skip=true" >> "$GITHUB_OUTPUT"; exit 0; }
# -F, a FIXED string: the dots in a version are regex wildcards
# otherwise, so "10.80" would also match a line reading 10x80. -x so it
# is the whole line, and the four-space indent is what makes it an
# entry's own version rather than the mongodb subchart's, which sits
# deeper.
if grep -qxF " version: ${V}.0" index.yaml; then
echo "::notice::v$V already has a published Helm chart (${V}.0); nothing to do."
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "v$V has no published chart; publishing ${V}.0."
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout wekan/charts (main + gh-pages)
if: steps.check.outputs.skip != 'true'
uses: actions/checkout@v7
with:
repository: wekan/charts
token: ${{ secrets.WEKAN_REPO_TOKEN }}
fetch-depth: 0
path: charts
- name: Install Helm
if: steps.check.outputs.skip != 'true'
uses: azure/setup-helm@v5
- name: Publish the chart for this release
if: steps.check.outputs.skip != 'true'
env:
CHARTS_DIR: ${{ github.workspace }}/charts
run: |
set -euo pipefail
cd charts
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin '+refs/heads/*:refs/remotes/origin/*'
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 }}"
- name: Job result
if: always()
run: |
case "${{ job.status }}" in
success) mark='OK ' ;;
failure) mark='FAILED ' ;;
cancelled) mark='CANCELLED' ;;
*) mark='${{ job.status }}' ;;
esac
echo "$mark charts" >> "$GITHUB_STEP_SUMMARY"
done:
name: what is still missing
needs: [plan, extra-arches, appimage, flatpak]
if: ${{ always() }}
needs: [plan, extra-arches, appimage, flatpak, charts]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -296,7 +400,7 @@ jobs:
echo "::notice::v$V was already complete - nothing was built."
exit 0
fi
gh release view "v$V" --json assets --jq '.assets[].name' | sort > now.txt
gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "v$V" --json assets --jq '.assets[].name' | sort > now.txt
bash releases/expected-assets.sh "$V" > expected.txt
still=""
while read -r kind selector asset sums; do
File diff suppressed because it is too large. Load diff
+2 -3
View File
@@ -51,8 +51,7 @@ jobs:
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git
- name: Install Sandstorm (dev, unattended)
# Provides the `spk` command used to publish, and the sandstorm-http-bridge that
@@ -110,7 +109,7 @@ jobs:
# 3) Download the newest .spk asset on that release. sandstorm.yml attaches it
# as wekan-sandstorm-YYYY_MM_DD-HH_MM_SS.spk; if several exist, take the
# lexicographically-newest (the timestamp sorts chronologically).
ASSET="$(gh release view "$TAG" --json assets \
ASSET="$(gh release view --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "$TAG" --json assets \
--jq '[.assets[].name | select(endswith(".spk"))] | sort | last' 2>/dev/null || true)"
if [ -z "${ASSET:-}" ] || [ "$ASSET" = "null" ]; then
echo "::error::Release $TAG has no .spk asset. Run sandstorm.yml first to build and attach one."
+2 -3
View File
@@ -54,8 +54,7 @@ jobs:
- name: Install system dependencies and Meteor
run: |
sudo apt-get update
sudo apt-get install -y p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git qemu-user-static
bash "$GITHUB_WORKSPACE/releases/apt-install.sh" p7zip-full wget curl build-essential g++ make python3 unzip xz-utils git qemu-user-static
if [ ! -f "$HOME/.meteor/meteor" ]; then curl https://install.meteor.com/ | sh; fi
echo "$HOME/.meteor" >> "$GITHUB_PATH"
@@ -141,4 +140,4 @@ jobs:
exit 1
fi
echo "Uploading ${{ steps.build.outputs.spk }} to the newest release: $TAG"
gh release upload "$TAG" "${{ steps.build.outputs.spk }}" --clobber
gh release upload --repo "${GITHUB_REPOSITORY:-wekan/wekan}" "$TAG" "${{ steps.build.outputs.spk }}" --clobber
+2 -2
View File
@@ -2,7 +2,7 @@ accounts-base@3.3.0
accounts-express@1.0.0
accounts-oauth@1.4.7
accounts-password@3.3.1
aldeed:collection2@4.2.1
aldeed:collection2@4.2.2
aldeed:schema-index@4.0.0
aldeed:simple-schema@1.13.1
allow-deny@2.1.0
@@ -87,7 +87,7 @@ reload@1.3.2
retry@1.1.1
reywood:publish-composite@1.9.0
routepolicy@1.1.2
rspack@1.2.0
rspack@1.2.1
service-configuration@1.3.5
session@1.2.3
sha@1.0.10
+160 -19
View File
@@ -2,7 +2,35 @@
Codex reads this file at the repo root before doing work here. Follow it.
## First: are you the maintainer or a contributor?
## First: who maintains this, and who is committing?
**WeKan, the `wekan/` repositories cloned under `.tools/`, and
[Secretchronicles/TSC](https://github.com/Secretchronicles/TSC) are all maintained by
Lauri Ojansivu (xet7) `<x@xet7.org>`** — [wekan/wekan](https://github.com/wekan/wekan),
[wekan/FerretDB](https://github.com/wekan/FerretDB),
[wekan/node-patches](https://github.com/wekan/node-patches),
[wekan/mongo-tools-patches](https://github.com/wekan/mongo-tools-patches) and TSC,
which is under the **Secretchronicles** organisation rather than **wekan** and is his
all the same — his GitHub profile, [xet7](https://github.com/xet7), says exactly that:
*"WeKan and TSC maintainer"*. Work done on the maintainer's behalf is committed as
**`Lauri Ojansivu <x@xet7.org>`** — that author, in every one of those repositories,
every time. Two rules follow from it and neither has an exception:
- **Never attribute a commit to an AI.** No `Co-Authored-By:` trailer, no "Generated
with", no assistant or model name — not in the commit message, not in a pull-request
body, not in the CHANGELOG. [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) is where this
comes from: *"For pull requests, mention only those participants that are
**human**."* A `Thanks to ... and xet7 !` line credits people — the issue reporter
and xet7 — never a tool.
- **If the git identity is missing or wrong in one of these checkouts, set it; do not
commit under something else.** The `.tools/` clones are made by `build.sh` inside
this checkout and can come up with no `user.name`/`user.email` of their own, which
would silently author a commit as whatever the machine's default is:
```
git -C .tools/<repo> config user.name 'Lauri Ojansivu'
git -C .tools/<repo> config user.email 'x@xet7.org'
```
Check the current git identity before committing or releasing:
@@ -10,16 +38,25 @@ Check the current git identity before committing or releasing:
git config user.name && git config user.email
```
- **Maintainer mode** — ONLY when the identity is exactly
`Lauri Ojansivu <x@xet7.org>` (name `Lauri Ojansivu`, email `x@xet7.org`). Then, and
only then: commit **directly to the current branch** as `Lauri Ojansivu <x@xet7.org>`
with no AI trailer and no pull request, and the **publishing / release steps** below
are available.
- **Contributor mode** — any other git identity. Then: do **not** commit directly to
the branch and do **not** run any release/publishing step. Make changes on a branch
and open a **pull request** for the maintainer to review. The "commit as Lauri
Ojansivu", "commit directly", and all release instructions below are **maintainer-only
and do not apply to you**.
- **Maintainer mode** — the identity is `Lauri Ojansivu <x@xet7.org>` (name
`Lauri Ojansivu`, email `x@xet7.org`), or it is unset in a checkout of one of the
repositories above, which means it is to be SET to that as above rather than worked
around. Then: commit **directly to the current branch** as
`Lauri Ojansivu <x@xet7.org>` with no AI trailer and no pull request, and the
**publishing / release steps** below are available.
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
own. Then: do **not** commit directly to the branch and do **not** run any
release/publishing step. Make changes on a branch and open a **pull request** for
the maintainer to review, and keep that pull request free of AI attribution too.
The "commit as Lauri Ojansivu", "commit directly", and all release instructions
below are **maintainer-only and do not apply to you**.
Maintainer mode covers TSC as well: commit directly to its `devel` branch, no pull
request, same author and no AI attribution. What it does NOT bring along is WeKan's
house style — TSC keeps its own **GNU ChangeLog** `CHANGELOG` and its own release
process, because a project is read on its own terms (see the CHANGELOG section below).
The one repository under `.tools/` that is somebody else's is `sandstorm-io/sandstorm`,
cloned for reference only.
Everything below marked as maintainer-specific (committing directly, the exact commit
author, and the entire "Making a release" / publishing flow) applies only in maintainer
@@ -96,6 +133,38 @@ translations** and general **kanban terminology** for the language as the refere
earlier `machine-translate.mjs` that called LibreTranslate/DeepL is removed on purpose
(it did not work and needed a password). There is **no `WEKAN_MT*` env var** anymore.
- **EVERY language gets translated — including the ones nobody has volunteered for.**
Klingon, Volapük, Wolof, Venda, Tamazight, Acehnese and the rest are not exceptions:
WeKan cannot find a speaker for every language, and a file left in English stays in
English for years. **Look the words up.** Any dictionary, word list, grammar or
Wiktionary page on the Internet is a legitimate source — read it, take the terms it
gives, and write the translation from them. Where a file is already written in ANOTHER
language (several are seeded from French, German, Malay or Zulu), complete it in the
language that file is actually in rather than leaving half of it English.
- **Add the languages WeKan does not have yet.** The 154 files under
`imports/i18n/data/` are not the list of languages worth supporting - they are the
list somebody happened to start. When a language has a dictionary, a word list or a
Wiktionary anywhere on the Internet, it can have a WeKan. Adding one is three edits,
and all three are needed or the language is invisible:
1. `imports/i18n/data/<tag>.i18n.json` — the strings, in `en.i18n.json`'s key ORDER
(`tests/boardItemLinks.test.cjs` checks that), 2-space indent.
2. `imports/i18n/languages.js` — `{ code, tag, name, load, rtl }`. The **name is
written in that language itself** ("Suomi", "tlhIngan Hol"), the way every entry
there already is, because it is read by somebody who does not read English. `rtl:
true` for Arabic, Hebrew, Persian, Uyghur and the rest that are written
right-to-left.
3. `client/components/users/userHeader.js` — the `flagMap` in `languageFlag()`, so the
picker shows a **flag** beside the name. A language without a country of its own
takes the flag of where it is spoken, and a constructed one (Esperanto, Klingon,
Volapük) keeps the `🌐` fallback rather than being given somebody's country.
- **A bad translation beats no translation.** An imperfect string is readable, it is
obviously improvable, and it is an *invitation*: somebody who speaks the language sees
it is wrong and fixes it, which an English placeholder never provokes. So: do not skip
a language because the result would be imperfect. Translate it, say in the commit which
ones were done with low confidence, and let a human correct it — the merge rules below
guarantee that when that human translation arrives on Transifex, it REPLACES the filled
one and is never overwritten by it.
- `node releases/translations/fill-translations.mjs --missing` — per-language count of
strings still needing translation (English + `en-*` variants are skipped: English by
design). Also printed at the end of `pull-translations.sh`.
@@ -121,21 +190,75 @@ pushed to Transifex as if it were human.
"Co-Authored-By" or any other AI trailer, directly to the `main` branch of WeKan and
the `main-v1` branch of the FerretDB fork. **Do not make pull requests.** (Contributors
do the opposite: work on a branch and open a pull request — see the top section.)
- Lauri Ojansivu (xet7) maintains WeKan (https://wekan.fi) and the FerretDB v1 fork.
This is the same rule as the top section, restated where the release work is: one
author, `Lauri Ojansivu <x@xet7.org>`, and no AI attribution anywhere.
- Lauri Ojansivu (xet7) maintains WeKan (https://wekan.fi), the FerretDB v1 fork, and
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.
- `../w/wekan.fi` — the WeKan website.
- `FerretDB` subdirectory (when present) — https://github.com/wekan/FerretDB, its own
`CHANGELOG.md`. FerretDB `.go` files must contain **no** application-specific names
(say "the client" / "a Meteor 3 driver" / a bare `#NNNN`); its `CHANGELOG.md` may
use `wekan/wekan#NNNN`.
- `sandstorm` subdirectory (when present) — https://github.com/sandstorm-io/sandstorm.
- `.tools/` — everything that is NOT part of this repository but is needed to
build, test and release it, in ONE directory that `.gitignore` and
`.meteorignore` already exclude (each used to need its own ignore entry at
the repo root), so nothing in it can reach a commit or a Meteor rebuild. Two
different kinds of thing live there, and the difference matters:
**Companion git repositories.** Separate repositories with their own history,
branches, changelog and release flow — a commit here is never a commit there.
`build.sh`'s `ensure_tool_repo` clones one on demand (SSH first, HTTPS
second), so a fresh checkout needs no manual setup:
| Path | Repository | Branch | What it is |
| --- | --- | --- | --- |
| `.tools/FerretDB` | wekan/FerretDB | `main-v1` | the FerretDB v1 fork WeKan ships as its default database |
| `.tools/node-patches` | wekan/node-patches | `main` | patches to upstream Node.js; builds the `node-<platform>` binaries the bundles, the Docker image and the snap embed |
| `.tools/mongo-tools-patches` | wekan/mongo-tools-patches | `main` | patches to the MongoDB Database Tools; builds `<tool>-<arch>` |
| `.tools/TSC` | Secretchronicles/TSC | `devel` | the game xet7 also maintains — same author and maintainer mode, its own GNU ChangeLog and release process |
| `.tools/sandstorm` | sandstorm-io/sandstorm | — | upstream Sandstorm, when present — somebody else's project, cloned for reference |
**Unpacked toolchains and caches.** Downloads, not repositories — put there by
the sandbox instructions in `docs/Security/Sandboxes/vscode/README.md`, deleted
and re-fetched freely, never committed anywhere:
`node-v<version>-linux-<arch>/` (the Node.js the test suites are run with),
`go/` with `gopath/`, `gomodcache/` and `gocache/` (FerretDB's Go builds),
`.meteor/` when `HOME` is pointed at `.tools`, and the `TSC*` AppImage.
- **Do NOT add a `CLAUDE.md` or an `AGENTS.md` to any repository under
`.tools/`.** node-patches and mongo-tools-patches each had a pair and they were
REMOVED on purpose: the rules are the same for every one of these repositories,
and a second copy of a rule drifts from the first. THIS file, and its `AGENTS.md`
twin beside it, are where they live for all of them. Something true of only one
of those repositories goes in that repository's own `README.md` or `docs/`,
never in a new instruction file — and if you find one there, remove it rather
than updating it.
- `.tools/FerretDB` specifics — FerretDB `.go` files must contain **no**
application-specific names (say "the client" / "a Meteor 3 driver" / a bare
`#NNNN`); its `CHANGELOG.md` may use `wekan/wekan#NNNN`.
### CHANGELOG
- **Every repository writes its changelog in the format that repository's own file
already uses.** Open its changelog, read the entries above the place you are
adding one, and match them — never import another project's shape into it. The
five that come up here:
| Repository | File | Format |
| --- | --- | --- |
| `wekan/wekan` | `CHANGELOG.md` | the WeKan format this section describes: `# Platforms`, `# TODO Later`, 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.` |
| `Secretchronicles/TSC` | `CHANGELOG` (no extension) | **GNU ChangeLog** format: a `YYYY-MM-DD Name <email>` header line, then TAB-indented `* Version …` / `* Fix: …` / `* Misc: …` entries, wrapped and continued with further tabs, each ending `(by Name)` |
The reason is the reader, not consistency for its own sake: a FerretDB release is
read beside upstream FerretDB's releases, and a TSC entry beside a decade of GNU
ChangeLog entries. A WeKan-shaped `<details>` block in either would be the odd one
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.
- During development, add entries under a new `# Upcoming WeKan ® release` section above
the newest release (FerretDB uses `## Upcoming FerretDB release`). Do **not** hand-edit
the newest release (FerretDB uses `## Upcoming FerretDB release`; the patch repos use
`# Upcoming <repo> release`). Do **not** hand-edit
`package.json` or any other version reference — the release workflow bumps those.
- **The file's shape, top to bottom** — keep it exactly as it is now:
1. `# Platforms` — the line `Newest WeKan at these platforms:` and the Install /
@@ -331,6 +454,24 @@ Fixes #1234,
Fixes #1235.
```
- **A commit that fixes a GitHub issue ENDS with `Fixes #NNNN`** — last lines of
the message, one per issue, comma after each and a full stop on the last. That
trailer is what closes the issue when the commit is pushed; a fix that only
*mentions* the number in prose leaves the issue open, and somebody has to close
it by hand later or it stays open forever. Several issues in one commit get
several lines. The same applies to an issue that turns out to be **already
fixed**: commit the test or the note that proves it and end with `Fixes #NNNN,`
so the issue closes with a reference to where it was fixed.
- **Only when the commit really fixes it.** A commit that improves the
diagnostics, narrows the cause, or fixes one of several reported problems
references the issue in the body (`#6585 comment 5276581923`) and does NOT
carry the trailer — closing an issue whose reporter is still stuck is worse
than leaving it open. If an issue was closed and then REOPENED for follow-up
items (as #6586 was), it is fixed again only when those items are done.
- **A commit with no issue behind it has no trailer** — the email reports, the
release-tooling fixes found in build logs, refactors. `Thanks to ... and xet7 !`
still names whoever reported it.
### Making a release — no version number needed **[maintainer only]**
All publishing / release steps below are maintainer-only. Contributors never run them.
@@ -342,7 +483,7 @@ All publishing / release steps below are maintainer-only. Contributors never run
version reference, then the release jobs tag `v<new>` and publish the GitHub Release).
An explicit `oldversion newversion` pair still overrides. Adding entries under Upcoming
is the only hand step.
- FerretDB: run `./build.sh release-ferretdb` from the `FerretDB` subdirectory (no
- FerretDB: run `./build.sh release-ferretdb` from `.tools/FerretDB` (no
version). It renames `## Upcoming FerretDB release` to the next version with the
correct git-tag link, commits + tags + pushes, then triggers `release-all.yml` (which
in turn triggers `docker.yml` for the multi-arch image). It refuses to re-release an
+7568 -36
View File
File diff suppressed because it is too large. Load diff
+178 -27
View File
@@ -2,7 +2,35 @@
Claude Code reads this file at the repo root before doing work here. Follow it.
## First: are you the maintainer or a contributor?
## First: who maintains this, and who is committing?
**WeKan, the `wekan/` repositories cloned under `.tools/`, and
[Secretchronicles/TSC](https://github.com/Secretchronicles/TSC) are all maintained by
Lauri Ojansivu (xet7) `<x@xet7.org>`** — [wekan/wekan](https://github.com/wekan/wekan),
[wekan/FerretDB](https://github.com/wekan/FerretDB),
[wekan/node-patches](https://github.com/wekan/node-patches),
[wekan/mongo-tools-patches](https://github.com/wekan/mongo-tools-patches) and TSC,
which is under the **Secretchronicles** organisation rather than **wekan** and is his
all the same — his GitHub profile, [xet7](https://github.com/xet7), says exactly that:
*"WeKan and TSC maintainer"*. Work done on the maintainer's behalf is committed as
**`Lauri Ojansivu <x@xet7.org>`** — that author, in every one of those repositories,
every time. Two rules follow from it and neither has an exception:
- **Never attribute a commit to an AI.** No `Co-Authored-By:` trailer, no "Generated
with", no assistant or model name — not in the commit message, not in a pull-request
body, not in the CHANGELOG. [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) is where this
comes from: *"For pull requests, mention only those participants that are
**human**."* A `Thanks to ... and xet7 !` line credits people — the issue reporter
and xet7 — never a tool.
- **If the git identity is missing or wrong in one of these checkouts, set it; do not
commit under something else.** The `.tools/` clones are made by `build.sh` inside
this checkout and can come up with no `user.name`/`user.email` of their own, which
would silently author a commit as whatever the machine's default is:
```
git -C .tools/<repo> config user.name 'Lauri Ojansivu'
git -C .tools/<repo> config user.email 'x@xet7.org'
```
Check the current git identity before committing or releasing:
@@ -10,16 +38,25 @@ Check the current git identity before committing or releasing:
git config user.name && git config user.email
```
- **Maintainer mode** — ONLY when the identity is exactly
`Lauri Ojansivu <x@xet7.org>` (name `Lauri Ojansivu`, email `x@xet7.org`). Then, and
only then: commit **directly to the current branch** as `Lauri Ojansivu <x@xet7.org>`
with no AI trailer and no pull request, and the **publishing / release steps** below
are available.
- **Contributor mode** — any other git identity. Then: do **not** commit directly to
the branch and do **not** run any release/publishing step. Make changes on a branch
and open a **pull request** for the maintainer to review. The "commit as Lauri
Ojansivu", "commit directly", and all release instructions below are **maintainer-only
and do not apply to you**.
- **Maintainer mode** — the identity is `Lauri Ojansivu <x@xet7.org>` (name
`Lauri Ojansivu`, email `x@xet7.org`), or it is unset in a checkout of one of the
repositories above, which means it is to be SET to that as above rather than worked
around. Then: commit **directly to the current branch** as
`Lauri Ojansivu <x@xet7.org>` with no AI trailer and no pull request, and the
**publishing / release steps** below are available.
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
own. Then: do **not** commit directly to the branch and do **not** run any
release/publishing step. Make changes on a branch and open a **pull request** for
the maintainer to review, and keep that pull request free of AI attribution too.
The "commit as Lauri Ojansivu", "commit directly", and all release instructions
below are **maintainer-only and do not apply to you**.
Maintainer mode covers TSC as well: commit directly to its `devel` branch, no pull
request, same author and no AI attribution. What it does NOT bring along is WeKan's
house style — TSC keeps its own **GNU ChangeLog** `CHANGELOG` and its own release
process, because a project is read on its own terms (see the CHANGELOG section below).
The one repository under `.tools/` that is somebody else's is `sandstorm-io/sandstorm`,
cloned for reference only.
Everything below marked as maintainer-specific (committing directly, the exact commit
author, and the entire "Making a release" / publishing flow) applies only in maintainer
@@ -96,6 +133,38 @@ translations** and general **kanban terminology** for the language as the refere
earlier `machine-translate.mjs` that called LibreTranslate/DeepL is removed on purpose
(it did not work and needed a password). There is **no `WEKAN_MT*` env var** anymore.
- **EVERY language gets translated — including the ones nobody has volunteered for.**
Klingon, Volapük, Wolof, Venda, Tamazight, Acehnese and the rest are not exceptions:
WeKan cannot find a speaker for every language, and a file left in English stays in
English for years. **Look the words up.** Any dictionary, word list, grammar or
Wiktionary page on the Internet is a legitimate source — read it, take the terms it
gives, and write the translation from them. Where a file is already written in ANOTHER
language (several are seeded from French, German, Malay or Zulu), complete it in the
language that file is actually in rather than leaving half of it English.
- **Add the languages WeKan does not have yet.** The 154 files under
`imports/i18n/data/` are not the list of languages worth supporting - they are the
list somebody happened to start. When a language has a dictionary, a word list or a
Wiktionary anywhere on the Internet, it can have a WeKan. Adding one is three edits,
and all three are needed or the language is invisible:
1. `imports/i18n/data/<tag>.i18n.json` — the strings, in `en.i18n.json`'s key ORDER
(`tests/boardItemLinks.test.cjs` checks that), 2-space indent.
2. `imports/i18n/languages.js` — `{ code, tag, name, load, rtl }`. The **name is
written in that language itself** ("Suomi", "tlhIngan Hol"), the way every entry
there already is, because it is read by somebody who does not read English. `rtl:
true` for Arabic, Hebrew, Persian, Uyghur and the rest that are written
right-to-left.
3. `client/components/users/userHeader.js` — the `flagMap` in `languageFlag()`, so the
picker shows a **flag** beside the name. A language without a country of its own
takes the flag of where it is spoken, and a constructed one (Esperanto, Klingon,
Volapük) keeps the `🌐` fallback rather than being given somebody's country.
- **A bad translation beats no translation.** An imperfect string is readable, it is
obviously improvable, and it is an *invitation*: somebody who speaks the language sees
it is wrong and fixes it, which an English placeholder never provokes. So: do not skip
a language because the result would be imperfect. Translate it, say in the commit which
ones were done with low confidence, and let a human correct it — the merge rules below
guarantee that when that human translation arrives on Transifex, it REPLACES the filled
one and is never overwritten by it.
- `node releases/translations/fill-translations.mjs --missing` — per-language count of
strings still needing translation (English + `en-*` variants are skipped: English by
design). Also printed at the end of `pull-translations.sh`.
@@ -121,26 +190,75 @@ pushed to Transifex as if it were human.
"Co-Authored-By" or any other AI trailer, directly to the `main` branch of WeKan and
the `main-v1` branch of the FerretDB fork. **Do not make pull requests.** (Contributors
do the opposite: work on a branch and open a pull request — see the top section.)
- Lauri Ojansivu (xet7) maintains WeKan (https://wekan.fi) and the FerretDB v1 fork.
This is the same rule as the top section, restated where the release work is: one
author, `Lauri Ojansivu <x@xet7.org>`, and no AI attribution anywhere.
- Lauri Ojansivu (xet7) maintains WeKan (https://wekan.fi), the FerretDB v1 fork, and
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.
- `../w/wekan.fi` — the WeKan website.
- `.tools/` — companion repos cloned INSIDE this checkout, in one directory
that `.gitignore` and `.meteorignore` already exclude (they used to be one
ignored subdirectory each at the repo root). `build.sh`'s `ensure_tool_repo`
clones one on demand, so a fresh checkout needs no manual setup:
`.tools/FerretDB`, `.tools/node-patches`, `.tools/mongo-tools-patches`.
- `.tools/FerretDB` (cloned on demand) — https://github.com/wekan/FerretDB, its own
`CHANGELOG.md`. FerretDB `.go` files must contain **no** application-specific names
(say "the client" / "a Meteor 3 driver" / a bare `#NNNN`); its `CHANGELOG.md` may
use `wekan/wekan#NNNN`.
- `.tools/sandstorm` (when present) — https://github.com/sandstorm-io/sandstorm.
- `.tools/` — everything that is NOT part of this repository but is needed to
build, test and release it, in ONE directory that `.gitignore` and
`.meteorignore` already exclude (each used to need its own ignore entry at
the repo root), so nothing in it can reach a commit or a Meteor rebuild. Two
different kinds of thing live there, and the difference matters:
**Companion git repositories.** Separate repositories with their own history,
branches, changelog and release flow — a commit here is never a commit there.
`build.sh`'s `ensure_tool_repo` clones one on demand (SSH first, HTTPS
second), so a fresh checkout needs no manual setup:
| Path | Repository | Branch | What it is |
| --- | --- | --- | --- |
| `.tools/FerretDB` | wekan/FerretDB | `main-v1` | the FerretDB v1 fork WeKan ships as its default database |
| `.tools/node-patches` | wekan/node-patches | `main` | patches to upstream Node.js; builds the `node-<platform>` binaries the bundles, the Docker image and the snap embed |
| `.tools/mongo-tools-patches` | wekan/mongo-tools-patches | `main` | patches to the MongoDB Database Tools; builds `<tool>-<arch>` |
| `.tools/TSC` | Secretchronicles/TSC | `devel` | the game xet7 also maintains — same author and maintainer mode, its own GNU ChangeLog and release process |
| `.tools/sandstorm` | sandstorm-io/sandstorm | — | upstream Sandstorm, when present — somebody else's project, cloned for reference |
**Unpacked toolchains and caches.** Downloads, not repositories — put there by
the sandbox instructions in `docs/Security/Sandboxes/vscode/README.md`, deleted
and re-fetched freely, never committed anywhere:
`node-v<version>-linux-<arch>/` (the Node.js the test suites are run with),
`go/` with `gopath/`, `gomodcache/` and `gocache/` (FerretDB's Go builds),
`.meteor/` when `HOME` is pointed at `.tools`, and the `TSC*` AppImage.
- **Do NOT add a `CLAUDE.md` or an `AGENTS.md` to any repository under
`.tools/`.** node-patches and mongo-tools-patches each had a pair and they were
REMOVED on purpose: the rules are the same for every one of these repositories,
and a second copy of a rule drifts from the first. THIS file, and its `AGENTS.md`
twin beside it, are where they live for all of them. Something true of only one
of those repositories goes in that repository's own `README.md` or `docs/`,
never in a new instruction file — and if you find one there, remove it rather
than updating it.
- `.tools/FerretDB` specifics — FerretDB `.go` files must contain **no**
application-specific names (say "the client" / "a Meteor 3 driver" / a bare
`#NNNN`); its `CHANGELOG.md` may use `wekan/wekan#NNNN`.
### CHANGELOG
- **Every repository writes its changelog in the format that repository's own file
already uses.** Open its changelog, read the entries above the place you are
adding one, and match them — never import another project's shape into it. The
five that come up here:
| Repository | File | Format |
| --- | --- | --- |
| `wekan/wekan` | `CHANGELOG.md` | the WeKan format this section describes: `# Platforms`, `# TODO Later`, 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.` |
| `Secretchronicles/TSC` | `CHANGELOG` (no extension) | **GNU ChangeLog** format: a `YYYY-MM-DD Name <email>` header line, then TAB-indented `* Version …` / `* Fix: …` / `* Misc: …` entries, wrapped and continued with further tabs, each ending `(by Name)` |
The reason is the reader, not consistency for its own sake: a FerretDB release is
read beside upstream FerretDB's releases, and a TSC entry beside a decade of GNU
ChangeLog entries. A WeKan-shaped `<details>` block in either would be the odd one
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.
- During development, add entries under a new `# Upcoming WeKan ® release` section above
the newest release (FerretDB uses `## Upcoming FerretDB release`). Do **not** hand-edit
the newest release (FerretDB uses `## Upcoming FerretDB release`; the patch repos use
`# Upcoming <repo> release`). Do **not** hand-edit
`package.json` or any other version reference — the release workflow bumps those.
- **The file's shape, top to bottom** — keep it exactly as it is now:
1. `# Platforms` — the line `Newest WeKan at these platforms:` and the Install /
@@ -336,6 +454,24 @@ Fixes #1234,
Fixes #1235.
```
- **A commit that fixes a GitHub issue ENDS with `Fixes #NNNN`** — last lines of
the message, one per issue, comma after each and a full stop on the last. That
trailer is what closes the issue when the commit is pushed; a fix that only
*mentions* the number in prose leaves the issue open, and somebody has to close
it by hand later or it stays open forever. Several issues in one commit get
several lines. The same applies to an issue that turns out to be **already
fixed**: commit the test or the note that proves it and end with `Fixes #NNNN,`
so the issue closes with a reference to where it was fixed.
- **Only when the commit really fixes it.** A commit that improves the
diagnostics, narrows the cause, or fixes one of several reported problems
references the issue in the body (`#6585 comment 5276581923`) and does NOT
carry the trailer — closing an issue whose reporter is still stuck is worse
than leaving it open. If an issue was closed and then REOPENED for follow-up
items (as #6586 was), it is fixed again only when those items are done.
- **A commit with no issue behind it has no trailer** — the email reports, the
release-tooling fixes found in build logs, refactors. `Thanks to ... and xet7 !`
still names whoever reported it.
### Making a release — no version number needed **[maintainer only]**
All publishing / release steps below are maintainer-only. Contributors never run them.
@@ -343,10 +479,25 @@ All publishing / release steps below are maintainer-only. Contributors never run
- WeKan: run `./releases/release-all.sh` (no arguments). It renames
`# Upcoming WeKan ® release` to the next version (same increment as the last release;
9.99 → 10.00) dated today, commits + pushes, and triggers
`.github/workflows/release-all.yml` (its `bump` job bumps `package.json` and every
version reference, then the release jobs tag `v<new>` and publish the GitHub Release).
An explicit `oldversion newversion` pair still overrides. Adding entries under Upcoming
is the only hand step.
`.github/workflows/release-all.yml` — whose `bump` job bumps `package.json` and every
version reference and rebuilds the API docs, whose `prepare` job then pushes the tag
`v<new>` and checks the notes exist, and whose `release` job publishes the GitHub
Release the bundle jobs attach to. An explicit `oldversion newversion` pair still
overrides. Adding entries under Upcoming is the only hand step.
- **A release that FAILED and one that shipped BROKEN are handled differently, and
the difference is whether anything was PUBLISHED.** Both happened with v10.92, so
neither is hypothetical:
- **Nothing published** (the workflow died before the GitHub Release existed): there
is no release for that number, so `# v<new> …` must not claim there is one. Rename
the heading back to `# Upcoming WeKan ® release`, add the fix under it, and run
`release-all.sh` again — it will take the same number, since the newest release is
still the previous one.
- **It published, and something in it is broken**: the release exists and people can
download it, so its section STAYS as it is — a released section is a record, not a
draft. Add a NEW `# Upcoming WeKan ® release` above it with the fix, exactly as
during ordinary development, and the next run takes the next number.
What decides it is the GitHub Release, not the tag: `prepare` pushes the tag early,
so a tag can exist for a release that never published.
- FerretDB: run `./build.sh release-ferretdb` from `.tools/FerretDB` (no
version). It renames `## Upcoming FerretDB release` to the next version with the
correct git-tag link, commits + tags + pushes, then triggers `release-all.yml` (which
+83 -4
View File
@@ -3,6 +3,14 @@
# arch this image targets - amd64, arm64, 386, arm/v7, ppc64le, riscv64, s390x -
# so ONE base covers them all, and it is the same base WeKan's per-arch .zip
# bundles are already built in (releases/... build-extra-arches).
#
# The ONE it does not ship is arm/v6: debian:trixie's manifest list has arm/v5
# and arm/v7 and nothing between them. That matters because containerd treats a
# LOWER ARM variant as compatible, so a linux/arm/v6 request does not fail - it
# quietly resolves to the arm/v5 (armel, SOFT-float) image, whose loader and
# glibc cannot run the hard-float node-armv6 in the armv6 bundle. The docker job
# therefore asks this base what it publishes and drops a platform it lacks; see
# docs/Platforms/FOSS/Container/Docker/CPU-platforms.md.
FROM debian:trixie
LABEL maintainer="wekan"
LABEL org.opencontainers.image.ref.name="debian"
@@ -12,13 +20,14 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=10.78
ARG VERSION=10.97
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
ENV \
DEBUG=false \
DDP_TRANSPORT=sockjs \
NODE_VERSION=v24.19.0 \
METEOR_RELEASE=METEOR@3.5-rc.2 \
USE_EDGE=false \
@@ -180,6 +189,31 @@ ENV \
# never end up on Node.js from different places. It is copied in rather than
# reimplemented here; a second copy of that order would drift from the first.
COPY --chmod=755 releases/resolve-node-source.sh /tmp/resolve-node-source.sh
# It asks nodejs.org and github.com which builds exist, through releases/fetch.sh
# - which retries a 503 instead of reading it as "that build does not exist".
# The two travel together: without this line the resolve step dies on the first
# lookup. tests/releaseDownloads.test.cjs pins the pair.
COPY --chmod=755 releases/fetch.sh /tmp/fetch.sh
# The bundle's `npm install` leaves node-gyp's whole tree - 83 of the 120
# packages in programs/server/node_modules - in a bundle that compiles nothing at
# run time, and a scan of the published image reads it as what it is. The same
# script runs in every per-arch leg of release-all.yml, so the .zip bundles and
# this image are pruned identically. tests/imageBuildOnlyModules.test.cjs pins it.
COPY --chmod=755 releases/prune-build-only-modules.mjs /tmp/prune-build-only-modules.mjs
# Its companion, and its manifest: the npm packages Meteor's own packages bundle,
# which nothing in package.json can reach. The .zip is built with these already
# applied; what this run has to redo is the part `npm install` puts back.
COPY --chmod=755 releases/bump-bundle-npm-deps.mjs /tmp/bump-bundle-npm-deps.mjs
COPY --chmod=644 releases/bundle-npm-security-bumps.json /tmp/bundle-npm-security-bumps.json
# And the third of the same kind: WeKan talks DDP over sockjs on every platform,
# so no bundle carries uWebSockets.js. ddp-server requires that module only
# inside the uws transport's setup(), which a sockjs server never calls, and it
# is 121M of prebuilt binaries for OS/CPU/ABI combinations one machine cannot
# use. The entrypoint coerces DDP_TRANSPORT=uws to sockjs, so an existing
# compose file that asks for uws keeps working rather than crash-looping.
COPY --chmod=755 releases/bundle-trim.mjs /tmp/bundle-trim.mjs
# Its companion: the same idea applied to programs/server/npm/node_modules.
COPY --chmod=755 releases/prune-unreachable-npm.mjs /tmp/prune-unreachable-npm.mjs
RUN <<EOR
set -o xtrace
@@ -210,7 +244,22 @@ case "${TARGETARCH}" in
"s390x") WEKAN_ARCH="s390x" ;;
"riscv64") WEKAN_ARCH="riscv64" ;;
"386") WEKAN_ARCH="i386" ;;
"arm") WEKAN_ARCH="armhf" ;;
# BOTH linux/arm/v6 and linux/arm/v7 arrive here as TARGETARCH=arm - the CPU
# generation is in TARGETVARIANT, not in TARGETARCH - so this branch MUST read
# the variant. Mapping "arm" straight to armhf, as it did, would hand an ARMv6
# board (Raspberry Pi 1, Zero) the armhf bundle, which is built to Debian's
# ARMv7-A baseline and whose instructions that board cannot execute.
"arm")
case "${TARGETVARIANT}" in
"v6") WEKAN_ARCH="armv6" ;;
# v7, and an unset variant, are Debian's armhf port: ARMv7-A,
# VFPv3-D16 hard-float. That is what linux/arm/v7 runs.
"v7"|"") WEKAN_ARCH="armhf" ;;
# v5 is armel: FerretDB and the MongoDB tools publish it (they are Go),
# but Node.js does not exist for ARMv5 at all, so there is no bundle to
# put in an image and refusing is the only honest answer.
*) echo "Unsupported 32-bit ARM variant: ${TARGETVARIANT} (only v6 and v7 have a WeKan bundle)"; exit 1 ;;
esac ;;
*) echo "Unsupported architecture: ${TARGETARCH}${TARGETVARIANT:+/${TARGETVARIANT}}"; exit 1 ;;
esac
@@ -256,8 +305,12 @@ esac
chmod +x /usr/local/bin/node
# npm + npx from the official amd64 tarball of the SAME version; extract only
# those paths, not the amd64 node binary.
wget "https://nodejs.org/dist/${node_full}/node-${node_full}-linux-x64.tar.gz"
wget "https://nodejs.org/dist/${node_full}/SHASUMS256.txt"
# Same flags as the download above: a bare wget treats a 503 as fatal, and
# nodejs.org has them.
wget --tries=20 --waitretry=20 --retry-on-http-error=403,500,502,503 \
"https://nodejs.org/dist/${node_full}/node-${node_full}-linux-x64.tar.gz"
wget --tries=20 --waitretry=20 --retry-on-http-error=403,500,502,503 \
"https://nodejs.org/dist/${node_full}/SHASUMS256.txt"
grep " node-${node_full}-linux-x64.tar.gz\$" SHASUMS256.txt | sha256sum -c -
tar xzf "node-${node_full}-linux-x64.tar.gz" -C /usr/local --strip-components=1 --no-same-owner \
"node-${node_full}-linux-x64/lib/node_modules/npm" \
@@ -288,6 +341,21 @@ wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 "${WEKA
unzip "wekan-${VERSION}-${WEKAN_ARCH}.zip"
rm "wekan-${VERSION}-${WEKAN_ARCH}.zip"
npm install --prefix ./bundle/programs/server
# node-gyp and @mapbox/node-pre-gyp compiled nothing here - every native module
# in the bundle is a prebuilt .node - and nothing in boot.js reaches them. Their
# tree is what shipped `tar` 6.2.1 (CRITICAL) and npm's networking stack to the
# published image, so it goes now that the install is done.
node /tmp/prune-build-only-modules.mjs ./bundle
# The .zip already carries the bumped meteor/ tree; this install put back
# meteor-dev-bundle's own underscore 1.13.7 pin (CVE-2026-27601) over it, so the
# same pass runs here.
node /tmp/bump-bundle-npm-deps.mjs ./bundle
# No uWebSockets.js, no legacy client, no source maps: this image runs sockjs,
# serves web.browser to every browser, and has no debugger attached to it.
node /tmp/bundle-trim.mjs ./bundle --transport sockjs --drop-legacy-client
# And the npm tree rspack cannot tree-shake, because Atmosphere packages load it
# through Npm.require(). Only what it can prove nothing requires.
node /tmp/prune-unreachable-npm.mjs ./bundle
mv /home/wekan/app/bundle /build
# The .zip bundle now ships a self-contained launcher + its own Node.js for the
@@ -302,6 +370,13 @@ rm -f /build/node /build/start-wekan.sh /build/start-wekan.bat
mv $(which tar)~ $(which tar)
# Cleanup
# npm is a BUILD tool in this image and nothing else: the only thing it does is
# the `npm install` above, and the container starts bash + wekan-entrypoint.sh,
# which never calls it. Shipping it shipped npm's own bundled dependencies - its
# `tar` (CRITICAL), `sigstore`, `@sigstore/*`, `ip-address`, `brace-expansion` -
# as image content that no code path can reach. node itself stays, because that
# is what runs WeKan.
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# Remove unused Go-based pebble binary shipped by base image to reduce CVE surface.
apt-get remove --purge --assume-yes pebble || true
rm -f /usr/bin/pebble
@@ -325,6 +400,10 @@ COPY --chmod=755 releases/ferretdb/wekan-entrypoint.sh /build/wekan-entrypoint.s
# #6492: standalone "recovering data" page the entrypoint serves as a brief bridge on
# the web port while a just-restored FerretDB comes back up during a data recovery.
COPY --chmod=644 releases/ferretdb/recovery-bridge.mjs /build/recovery-bridge.mjs
# #6595: the entrypoint asks this whether the database answers yet, so it can
# serve the "waiting for database" page instead of leaving the web port unbound
# for a reverse proxy to time out on.
COPY --chmod=644 releases/ferretdb/db-ready.mjs /build/db-ready.mjs
USER wekan
ENV PORT=8080
+2 -1
View File
@@ -67,7 +67,8 @@ that by providing one-click installation on various platforms.
- WeKan ® is used in [most countries of the world](https://snapcraft.io/wekan).
- WeKan ® largest user has 30k users using WeKan ® in their company.
- WeKan ® has been [translated](https://app.transifex.com/wekan/) to 154 languages.
- WeKan ® has been [translated](https://app.transifex.com/wekan/) to 234 languages,
142 of them essentially complete.
- [Features][features]: WeKan ® has real-time user interface.
- [Platforms](https://wekan.fi/install/): WeKan ® supports many platforms.
WeKan ® is critical part of new platforms Wekan is currently being integrated to.
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v10.78.0"
appVersion: "v10.97.0"
files:
userUploads:
- README.md
+48 -7
View File
@@ -253,6 +253,8 @@ echo 19^) Delete a git tag, locally and on the remote
echo 20^) Move the 'stable' tag to HEAD
echo 21^) Release the wekan-ondra / wekan-gantt-gpl variants, part 1
echo 22^) Release the wekan-ondra / wekan-gantt-gpl variants, part 2
echo 23^) Report ^(or repair^) the Helm chart index for past releases
echo 24^) Rebuild the Helm index.yaml from the chart packages
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" call :rel_run "releases/release-all.sh" ""
@@ -277,6 +279,8 @@ if "%choice%"=="19" call :rel_run "releases/delete-tag.sh" "Version tag, e.g. v1
if "%choice%"=="20" call :rel_cmd "git tag --force stable HEAD && git push --tags --force && git push --follow-tags" ""
if "%choice%"=="21" call :rel_run "releases/release-ondra-1.sh" ""
if "%choice%"=="22" call :rel_run "releases/release-ondra-2.sh" ""
if "%choice%"=="23" call :rel_run "releases/backfill-charts.sh" ""
if "%choice%"=="24" call :rel_run "releases/reindex-charts.py" ""
if "%choice%"=="0" goto menu_releases
goto rel_release
@@ -628,6 +632,8 @@ if /I "%K%"=="release-ln" (set "CMD=bash releases/release-ln.sh" ^& goto cli_go)
if /I "%K%"=="release-x2" (set "CMD=bash releases/release-x2.sh" ^& goto cli_go)
if /I "%K%"=="release-cleanup" (set "CMD=bash releases/release-cleanup.sh" ^& goto cli_go)
if /I "%K%"=="release-charts" (set "CMD=bash releases/release-charts.sh" ^& goto cli_go)
if /I "%K%"=="backfill-charts" (set "CMD=bash releases/backfill-charts.sh" ^& goto cli_go)
if /I "%K%"=="reindex-charts" (set "CMD=python3 releases/reindex-charts.py" ^& goto cli_go)
if /I "%K%"=="release-website" (set "CMD=bash releases/release-website.sh" ^& goto cli_go)
if /I "%K%"=="npm-publish" (set "CMD=bash releases/npm-publish.sh" ^& goto cli_go)
if /I "%K%"=="test-download-urls" (set "CMD=bash releases/test-download-urls.sh" ^& goto cli_go)
@@ -922,6 +928,12 @@ goto end
REM ===========================================================================
:test_all_parallel
REM Clear anything a previous run left listening before starting: cmd cannot
REM trap Ctrl-C, so an interrupted run's database outlives it, and the harness
REM would REUSE it (it reuses a database that answers) with data this run never
REM seeded. build.sh does this with a trap on the way out; here it is done on
REM the way in, which covers the same gap.
call :stop_test_databases
echo Running ALL tests against ONE WeKan server on http://localhost:3000 - all jobs run IN PARALLEL (concurrently). Needs plenty of RAM (fine on 32 GB).
echo Two WeKan servers are involved:
echo :3000 - the PRECOMPILED .build\bundle run as a plain Node server (Meteor's mongod on :3001, db "meteor")
@@ -1030,9 +1042,7 @@ if "!ALLDONE!"=="0" (
)
echo.
echo Stopping WeKan test server (bundle node :3000).
taskkill /FI "WINDOWTITLE eq WekanTestServer*" /T /F >nul 2>&1
if "!MONGOD_STARTED!"=="1" ( echo Stopping test MongoDB ^(mongod :3001^). & taskkill /FI "WINDOWTITLE eq WekanTestMongo*" /T /F >nul 2>&1 )
call :stop_test_databases
REM Final pass/fail per job (RUN means it never wrote a flag = treat as FAIL).
if "!S_mocha!"=="FAIL" set "FAILED=1"
@@ -1060,6 +1070,12 @@ goto end
REM ===========================================================================
:test_all_sequential
REM Clear anything a previous run left listening before starting: cmd cannot
REM trap Ctrl-C, so an interrupted run's database outlives it, and the harness
REM would REUSE it (it reuses a database that answers) with data this run never
REM seeded. build.sh does this with a trap on the way out; here it is done on
REM the way in, which covers the same gap.
call :stop_test_databases
echo Running ALL tests against ONE WeKan server on http://localhost:3000 - all jobs run SEQUENTIALLY (one at a time).
echo Two WeKan servers are involved (they do NOT run tests in parallel; the suites run one at a time):
echo :3000 - the PRECOMPILED .build\bundle run as a plain Node server (Meteor's mongod on :3001, db "meteor")
@@ -1153,9 +1169,7 @@ set "S_e2e=SKIP" & set "S_chromium=SKIP" & set "S_firefox=SKIP" & set "S_webkit=
:server_jobs_done
echo.
echo Stopping WeKan test server (bundle node :3000).
taskkill /FI "WINDOWTITLE eq WekanTestServer*" /T /F >nul 2>&1
if "!MONGOD_STARTED!"=="1" ( echo Stopping test MongoDB ^(mongod :3001^). & taskkill /FI "WINDOWTITLE eq WekanTestMongo*" /T /F >nul 2>&1 )
call :stop_test_databases
REM Final pass/fail per job (RUN means it never wrote a flag = treat as FAIL).
if "!S_mocha!"=="FAIL" set "FAILED=1"
@@ -1463,7 +1477,7 @@ exit /b 0
:set_dev_env
REM Common dev-server environment (caller sets ROOT_URL afterwards).
set "DEFAULT_METEOR_REACTIVITY_ORDER=changeStreams,oplog,polling"
set "DDP_TRANSPORT=uws"
set "DDP_TRANSPORT=sockjs"
set "DEBUG=true"
set "WRITABLE_PATH=.."
set "WITH_API=true"
@@ -1562,6 +1576,33 @@ REM %1 = port. Kill whatever is LISTENING on that TCP port (and its process tree
for /f "tokens=5" %%p in ('netstat -ano ^| findstr /r /c:":%~1 .*LISTENING"') do taskkill /F /T /PID %%p >nul 2>&1
exit /b 0
:stop_test_databases
REM Stop what a test run started: the bundle server on :3000, the test mongod on
REM :3001 when THIS run launched it, and any database-conformance container.
REM
REM Called at the end of both all-tests flows, and again before a run starts,
REM which is the batch answer to build.sh's trap. cmd cannot trap Ctrl-C, so an
REM interrupted run leaves its database behind; clearing before starting means
REM the next run does not inherit it. That matters because the harness reuses a
REM database only when it ANSWERS - a leftover mongod answers, so it would be
REM reused, holding a database this run never seeded, and the failures would
REM land somewhere else entirely.
echo Stopping WeKan test server (bundle node :3000).
taskkill /FI "WINDOWTITLE eq WekanTestServer*" /T /F >nul 2>&1
if "!MONGOD_STARTED!"=="1" ( echo Stopping test MongoDB ^(mongod :3001^). & taskkill /FI "WINDOWTITLE eq WekanTestMongo*" /T /F >nul 2>&1 )
REM The conformance stage runs each engine in a container named
REM wekan-conformance-db-<timestamp> and removes it when that engine is done. An
REM interrupted run leaves the one it was on, and a container still holding 5432
REM or 3306 fails the next run's engine before it starts.
where docker >nul 2>&1
if not errorlevel 1 (
for /f %%c in ('docker ps -aq --filter "name=wekan-conformance-db-" 2^>nul') do (
echo Stopping database-conformance container %%c.
docker rm -f %%c >nul 2>&1
)
)
exit /b 0
:kill_all_dev_servers
REM Kill every dev/test server this script can start, freeing all dev/test ports
REM at once: the dev app (3000) + its Mongo (3001), the Mocha test server (3100)
+142 -32
View File
@@ -744,6 +744,26 @@ function run_playwright_single(){
# Fast, but memory-hungry (fine on a 32 GB machine).
# $1 = "sequential" -> run one job at a time to keep RAM/swap usage low on
# smaller machines.
# Does a real MongoDB answer on this port? A TCP connect proves only that
# something accepted the socket; this asks the database itself. Uses the driver
# already in the built bundle, so it needs nothing installed.
mongo_answers() {
local port="$1"
local modules="$BUNDLE_DIR/programs/server/node_modules"
[ -d "$modules" ] || return 1
[ -x "$NODE_BIN" ] || return 1
NODE_PATH="$modules" "$NODE_BIN" -e '
const { MongoClient } = require("mongodb");
const url = "mongodb://127.0.0.1:" + process.argv[1] + "/meteor";
const client = new MongoClient(url, { serverSelectionTimeoutMS: 3000, connectTimeoutMS: 3000 });
client.connect()
.then(() => client.db("admin").command({ ping: 1 }))
.then(() => client.close(true))
.then(() => process.exit(0))
.catch(() => process.exit(1));
' "$port" >/dev/null 2>&1
}
function run_all_tests(){
local RUN_MODE="${1:-parallel}"
local modeword; [ "$RUN_MODE" = parallel ] && modeword="in parallel (concurrently)" || modeword="one at a time (sequential)"
@@ -856,6 +876,51 @@ function run_all_tests(){
MONGOD_PID=""
STATDIR="$(mktemp -d)"
BPIDS=""
# STOP WHAT THIS RUN STARTED, on EVERY way out - not only the happy path.
#
# The cleanup at the end of this function is reached when the run finishes.
# It is not reached when the run is interrupted (Ctrl-C is the usual one) or
# when one of the early `return 1`s above fires, and then the test mongod on
# :3001 and the bundle server on :3000 outlive the run. That is not tidy but
# harmless in itself; what it costs is the NEXT run, which finds the port
# taken. The harness reuses a database only when it ANSWERS - a port held by
# something else moves it aside - so a leftover mongod is silently reused
# instead, holding a database this run did not seed, and the failures land
# somewhere else entirely.
#
# So the stopping is a trap, and the function's own cleanup calls the same
# code. It is idempotent: each half clears its PID, so running twice stops
# nothing twice, and a database this run did NOT start (a reused one, whose
# PID was never recorded) is still left alone.
stop_test_databases() {
if [ -n "$TEST_SERVER_PID" ]; then
echo
echo "Stopping WeKan test server (bundle node :3000)."
kill "$TEST_SERVER_PID" >/dev/null 2>&1 || true
wait "$TEST_SERVER_PID" >/dev/null 2>&1 || true
TEST_SERVER_PID=""
fi
if [ -n "$MONGOD_PID" ]; then
echo "Stopping test MongoDB (mongod :${TEST_DB_PORT:-3001})."
kill "$MONGOD_PID" >/dev/null 2>&1 || true
wait "$MONGOD_PID" >/dev/null 2>&1 || true
MONGOD_PID=""
fi
# The database-conformance stage runs each engine in a container named
# wekan-conformance-<engine> and removes it when that engine is done. An
# interrupted run leaves the one it was on, and a container holding port
# 5432 or 3306 fails the NEXT run's engine before it starts.
if command -v docker >/dev/null 2>&1; then
local leftovers
leftovers=$(docker ps -aq --filter "name=^wekan-conformance-" 2>/dev/null)
if [ -n "$leftovers" ]; then
echo "Stopping database-conformance container(s)."
docker rm -f $leftovers >/dev/null 2>&1 || true
fi
fi
}
trap 'stop_test_databases' EXIT INT TERM
# Start one test job in the background: record its exit code in STATDIR/<key>
# and send all of its output to $RUN_LOGDIR/wekan-alltests-<key>.log. In "parallel"
# mode every job runs at once; in "sequential" mode we wait for each job to
@@ -988,27 +1053,78 @@ function run_all_tests(){
fi
fi
# MongoDB on :3001. Reuse one that is already listening (e.g. a dev server's mongo);
# otherwise start Meteor's bundled mongod with a persistent test dbpath.
if (exec 3<>/dev/tcp/127.0.0.1/3001) 2>/dev/null; then
echo "==> Reusing the MongoDB already listening on :3001 (not started or stopped by this run)."
# WHICH PORT the test database is on. 3001 by default - that is what Meteor's
# own dev mongo used and what the E2E helpers default to - but it is not ours
# to insist on: on this machine 3001 turned out to belong to an "Omi Server"
# speaking HTTP, and every run either died on "Topology is closed" or stopped
# at the check below. So the port is a variable, the run moves to a free one
# when the default is taken by something that is not a database, and it tells
# the tests where it went (they already read WEKAN_MONGO_URL:
# tests/playwright/helpers/db.js).
local TEST_DB_PORT="${WEKAN_TEST_DB_PORT:-3001}"
if (exec 3<>/dev/tcp/127.0.0.1/"$TEST_DB_PORT") 2>/dev/null && ! mongo_answers "$TEST_DB_PORT"; then
local taken="$TEST_DB_PORT"
local candidate
for candidate in 3011 3021 3031 3041 3051; do
if ! (exec 3<>/dev/tcp/127.0.0.1/"$candidate") 2>/dev/null; then
TEST_DB_PORT="$candidate"; break
fi
done
if [ "$TEST_DB_PORT" = "$taken" ]; then
echo "ERROR: :$taken is held by something that is not MongoDB, and none of the"
echo " fallback ports (3011 3021 3031 3041 3051) is free either."
echo " Free one of them, or set WEKAN_TEST_DB_PORT to a port that is."
rm -rf "$STATDIR"; return 1
fi
echo "==> :$taken is held by something that is not MongoDB (it did not answer a ping)."
echo " Using :$TEST_DB_PORT for the test database instead; the tests are told where it is."
fi
local TEST_MONGO_URL="mongodb://127.0.0.1:$TEST_DB_PORT/meteor"
export WEKAN_MONGO_URL="$TEST_MONGO_URL"
# MongoDB on $TEST_DB_PORT. Reuse one that is already listening (e.g. a dev
# server's mongo); otherwise start Meteor's bundled mongod with a persistent
# test dbpath.
#
# "Listening" is not the same as "working". A TCP connect only proves that
# SOMETHING accepted, and a mongod that is shutting down - the one this script
# kills a few lines earlier, on its way out - accepts for a moment longer.
# The 2026-08-14 run took this branch, started no mongod of its own, and the
# test server died on its first query:
#
# MongoTopologyClosedError: Topology is closed
# [An error occurred when creating an index for collection "users: Topology is closed]
#
# ...with no wekan-test-mongod.log to say otherwise, because none was started.
# So ASK it: a `ping` through the bundle's own driver. Only an answer counts as
# a database to reuse.
if (exec 3<>/dev/tcp/127.0.0.1/"$TEST_DB_PORT") 2>/dev/null && mongo_answers "$TEST_DB_PORT"; then
echo "==> Reusing the MongoDB already listening on :$TEST_DB_PORT (not started or stopped by this run)."
elif (exec 3<>/dev/tcp/127.0.0.1/"$TEST_DB_PORT") 2>/dev/null; then
# Only reachable when the port was taken between the check above and here.
echo "ERROR: something is listening on :$TEST_DB_PORT but does not answer a MongoDB ping."
echo " That is usually a mongod still shutting down, or another program that"
echo " took the port just now. Nothing was started, because the port is taken."
echo " Wait a few seconds and run this again, or free it:"
echo " fuser -k $TEST_DB_PORT/tcp # or: lsof -ti :$TEST_DB_PORT | xargs kill"
rm -rf "$STATDIR"; return 1
else
if [ -z "$MONGOD_BIN" ] || [ ! -x "$MONGOD_BIN" ]; then
echo "ERROR: nothing is listening on :3001 and no mongod was found (Meteor dev_bundle or PATH)."
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-3001"
local DBPATH="../mongodb-test-$TEST_DB_PORT"
mkdir -p "$DBPATH"
echo "==> Starting MongoDB (Meteor's mongod) on :3001, dbpath $DBPATH."
{ echo "===== mongod :3001 - started $(date '+%Y-%m-%d %H:%M:%S %Z') ====="; "$MONGOD_BIN" --port 3001 --dbpath "$DBPATH" --bind_ip 127.0.0.1 --nounixsocket; } > "$RUN_LOGDIR/wekan-test-mongod.log" 2>&1 &
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') ====="; "$MONGOD_BIN" --port "$TEST_DB_PORT" --dbpath "$DBPATH" --bind_ip 127.0.0.1 --nounixsocket; } > "$RUN_LOGDIR/wekan-test-mongod.log" 2>&1 &
MONGOD_PID=$!
local db_ready=0
for i in $(seq 1 60); do
if (exec 3<>/dev/tcp/127.0.0.1/3001) 2>/dev/null; then db_ready=1; break; fi
if (exec 3<>/dev/tcp/127.0.0.1/"$TEST_DB_PORT") 2>/dev/null && mongo_answers "$TEST_DB_PORT"; then db_ready=1; break; fi
sleep 1
done
if [ "$db_ready" -ne 1 ]; then
echo "ERROR: MongoDB did not become ready on :3001 (see $RUN_LOGDIR/wekan-test-mongod.log)."
echo "ERROR: MongoDB did not become ready on :$TEST_DB_PORT (see $RUN_LOGDIR/wekan-test-mongod.log)."
kill "$MONGOD_PID" >/dev/null 2>&1 || true; MONGOD_PID=""; rm -rf "$STATDIR"; return 1
fi
fi
@@ -1032,8 +1148,8 @@ function run_all_tests(){
# mongodb://127.0.0.1:3001/meteor (tests/playwright/helpers/db.js). Using any other
# db name (e.g. /wekan) makes the app read an empty database while the tests seed a
# different one, so every test that needs seeded data fails.
{ echo "===== WeKan test server [bundle node :3000 db :3001/meteor] - started $(date '+%Y-%m-%d %H:%M:%S %Z') ====="; echo; \
MONGO_URL="mongodb://127.0.0.1:3001/meteor" ROOT_URL="http://localhost:3000" PORT=3000 \
{ echo "===== WeKan test server [bundle node :3000 db :$TEST_DB_PORT/meteor] - started $(date '+%Y-%m-%d %H:%M:%S %Z') ====="; echo; \
MONGO_URL="$TEST_MONGO_URL" ROOT_URL="http://localhost:3000" PORT=3000 \
WRITABLE_PATH="$WRITABLE_ABS" WITH_API=true RICHER_CARD_COMMENT_EDITOR=false \
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" \
"$NODE_BIN" "$BUNDLE_DIR/main.js"; } >> "$RUN_LOGDIR/wekan-test-server.log" 2>&1 &
@@ -1130,19 +1246,11 @@ function run_all_tests(){
done
rm -rf "$STATDIR"
if [ -n "$TEST_SERVER_PID" ]; then
echo
echo "Stopping WeKan test server (bundle node :3000)."
kill "$TEST_SERVER_PID" >/dev/null 2>&1 || true
wait "$TEST_SERVER_PID" >/dev/null 2>&1 || true
fi
# Stop the mongod we started (leave a pre-existing/reused one alone: MONGOD_PID
# is only set when this run launched it).
if [ -n "$MONGOD_PID" ]; then
echo "Stopping test MongoDB (mongod :3001)."
kill "$MONGOD_PID" >/dev/null 2>&1 || true
wait "$MONGOD_PID" >/dev/null 2>&1 || true
fi
# The same stopping the trap does, here on the normal path so the summary
# below is printed with the databases already down. Idempotent, so the trap
# firing afterwards on EXIT does nothing. A pre-existing/reused database is
# left alone either way: its PID was never recorded.
stop_test_databases
echo
echo "==================== TEST SUMMARY ===================="
@@ -1867,6 +1975,8 @@ RELEASE_SCRIPTS=( "Release|Release ALL platforms: push CHANGELOG, trigger releas
"Release|Move the 'stable' tag to HEAD|!git tag --force stable HEAD && git push --tags --force && git push --follow-tags|||stable-tag"
"Release|Release the wekan-ondra / wekan-gantt-gpl variants, part 1|releases/release-ondra-1.sh|||"
"Release|Release the wekan-ondra / wekan-gantt-gpl variants, part 2|releases/release-ondra-2.sh|||"
"Release|Report (or repair) the Helm chart index for past releases|releases/backfill-charts.sh|||"
"Release|Rebuild the Helm index.yaml from the chart packages|releases/reindex-charts.py|||"
"Snap|Build the snap from snapcraft.yaml|releases/snap-build.sh|||"
"Snap|Install the locally built .snap|releases/snap-install.sh|||"
"Snap|Push one .snap to the Snap Store|releases/snap-push-to-store.sh|Path to the .snap file||"
@@ -2282,7 +2392,7 @@ for _once in 1; do
#---------------------------------------------------------------------
# Logging of terminal output to console and to ../log/wekan-log.log at end of this line: 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
#WARN_WHEN_USING_OLD_API=true NODE_OPTIONS="--trace-warnings"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=uws DEBUG=true WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL=http://localhost:3000 meteor run --port 3000 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG=true WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL=http://localhost:3000 meteor run --port 3000 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
#---------------------------------------------------------------------
break
;;
@@ -2294,7 +2404,7 @@ for _once in 1; do
#Not in use, could increase RAM usage: NODE_OPTIONS="--max_old_space_size=4096"
#---------------------------------------------------------------------
# Logging of terminal output to console and to ../log/wekan-log.log at end of this line: 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=uws DEBUG=true WARN_WHEN_USING_OLD_API=true NODE_OPTIONS="--trace-warnings --max-old-space-size=$_heap_mb" WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL=http://localhost:3000 meteor run --port 3000 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG=true WARN_WHEN_USING_OLD_API=true NODE_OPTIONS="--trace-warnings --max-old-space-size=$_heap_mb" WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL=http://localhost:3000 meteor run --port 3000 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
#---------------------------------------------------------------------
break
;;
@@ -2306,7 +2416,7 @@ for _once in 1; do
#---------------------------------------------------------------------
#Logging of terminal output to console and to ../log/wekan-log.log at end of this line: 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
#WARN_WHEN_USING_OLD_API=true NODE_OPTIONS="--trace-warnings"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=uws DEBUG=true WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL=http://localhost:3000 meteor run --port 3000 --extra-packages bundle-visualizer --production 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG=true WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL=http://localhost:3000 meteor run --port 3000 --extra-packages bundle-visualizer --production 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
#---------------------------------------------------------------------
break
;;
@@ -2325,7 +2435,7 @@ for _once in 1; do
#---------------------------------------------------------------------
#Logging of terminal output to console and to ../log/wekan-log.log at end of this line: 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
#WARN_WHEN_USING_OLD_API=true NODE_OPTIONS="--trace-warnings"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=uws DEBUG=true WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL=http://$IPADDRESS:3000 meteor run --port 3000 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG=true WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL=http://$IPADDRESS:3000 meteor run --port 3000 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
#---------------------------------------------------------------------
break
;;
@@ -2344,7 +2454,7 @@ for _once in 1; do
#---------------------------------------------------------------------
#Logging of terminal output to console and to ../log/wekan-log.log at end of this line: 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
#WARN_WHEN_USING_OLD_API=true NODE_OPTIONS="--trace-warnings"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=uws DEBUG=true MONGO_URL=mongodb://127.0.0.1:27019/wekan WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL=http://$IPADDRESS:3000 meteor run --port 3000 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG=true MONGO_URL=mongodb://127.0.0.1:27019/wekan WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL=http://$IPADDRESS:3000 meteor run --port 3000 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
#---------------------------------------------------------------------
break
;;
@@ -2358,7 +2468,7 @@ for _once in 1; do
# ROOT_URL differ. Logging of terminal output to console and to
# ../log/wekan-log.log at the end of the line: 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
#---------------------------------------------------------------------
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=uws DEBUG=true WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL="$DEV_ROOT_URL" meteor run --port "$DEV_PORT" 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG=true WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL="$DEV_ROOT_URL" meteor run --port "$DEV_PORT" 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
#---------------------------------------------------------------------
break
;;
@@ -2377,7 +2487,7 @@ for _once in 1; do
#---------------------------------------------------------------------
#Logging of terminal output to console and to ../log/wekan-log.log at end of this line: 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
#WARN_WHEN_USING_OLD_API=true NODE_OPTIONS="--trace-warnings"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=uws DEBUG=true WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL=http://$IPADDRESS:$PORT meteor run --port $PORT 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
DEFAULT_METEOR_REACTIVITY_ORDER="changeStreams,oplog,polling" DDP_TRANSPORT=sockjs DEBUG=true WRITABLE_PATH=.. WITH_API=true RICHER_CARD_COMMENT_EDITOR=false ROOT_URL=http://$IPADDRESS:$PORT meteor run --port $PORT 2>&1 | tee "$WEKAN_LOG_ROOT/wekan-log.log"
#---------------------------------------------------------------------
break
;;
+8 -16
View File
@@ -1,13 +1,19 @@
/* The board sidebar's Activities heading. It is the section's own control now -
a caret, the icon and the name, clicked to show the activities under it - so
it is a row of three things beside each other rather than a heading with a
toggle pushed to the far end. client/components/sidebar/sidebar.jade */
.activity-title {
margin: 0 10px 9px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.4em;
cursor: pointer;
}
.reactions-popup .add-comment-reaction {
display: inline-block;
cursor: pointer;
border-radius: 10px;
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
text-align: center;
line-height: 1.3;
width: 70px;
@@ -15,20 +21,6 @@
.reactions-popup .add-comment-reaction:hover {
background-color: #b0c4de;
}
/* Eye / eye-slash icon toggle for showing/hiding activities. Open eye
(.eye-icon) = activities shown; crossed eye (.eye-slash-icon) = hidden.
Matches the password-visibility toggle used on the login/register pages. */
.activities-eye-toggle {
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.4em;
text-decoration: none;
}
.activities-eye-toggle .eye-icon,
.activities-eye-toggle .eye-slash-icon {
line-height: 1;
}
.activities {
clear: both;
}
+50 -23
View File
@@ -6,6 +6,7 @@ import { sanitizeHTML, sanitizeText } from '/imports/lib/secureDOMPurify';
import { TAPi18n } from '/imports/i18n';
import { Utils } from '/client/lib/utils';
import { getSidebarInstance } from '/client/features/sidebar/service';
import { activityCardLinkData } from '/models/lib/activityCardLink';
// #6480/#6481: 500 was far more than the sidebar/card activity feed ever shows
// at once, and with FerretDB (no oplog) every live cursor is re-run on a timer —
@@ -87,9 +88,20 @@ function _showActivities(data) {
const currentCard = Utils.getCurrentCard();
ret = currentCard.showActivities ?? false;
} else if (mode === 'card') {
// For card mode, get showActivities from data context or fallback to current card
// The card's Activities section is COLLAPSED by default and renders this
// template only when its caret has been opened - so being here means the
// reader asked for the history, and the history is what they get.
//
// Until the section had a caret, the card carried a `showActivities` flag
// toggled by an eye beside the heading, and `false` - the default -
// subscribed to comments ONLY (server/publications/activities.js). The eye
// is gone: one control for one idea. Nothing is fetched at all while the
// section is closed, which is the cheaper half of what the flag was for,
// and opening it now means the whole history rather than a filtered part
// of it. `card.showActivities` still overrides when it was explicitly set
// to false, so a card somebody had set that way keeps its setting.
const card = data?.card || Utils.getCurrentCard();
ret = card?.showActivities ?? false;
ret = card?.showActivities ?? true;
} else {
ret = Utils.getCurrentBoard().showActivities ?? false;
}
@@ -150,23 +162,23 @@ Template.activity.helpers({
return TAPi18n.__('this-card');
}
else if (this.mode !== 'board') {
return createCardLink(this.activity.card(), null);
return createCardLink(this.activity, this.activity.card(), null);
}
else if (currentBoardId != this.activity.boardId) {
return createCardLink(this.activity.card(), this.activity.board().title);
return createCardLink(this.activity, this.activity.card(), this.activity.board().title);
}
return createCardLink(this.activity.card(), null);
return createCardLink(this.activity, this.activity.card(), null);
},
cardLink() {
const currentBoardId = Session.get('currentBoard');
if (this.mode !== 'board') {
return createCardLink(this.activity.card(), null);
return createCardLink(this.activity, this.activity.card(), null);
}
else if (currentBoardId != this.activity.boardId) {
return createCardLink(this.activity.card(), this.activity.board().title);
return createCardLink(this.activity, this.activity.card(), this.activity.board().title);
}
return createCardLink(this.activity.card(), null);
return createCardLink(this.activity, this.activity.card(), null);
},
receivedDate() {
@@ -365,21 +377,36 @@ Template.commentReactions.helpers({
}
})
function createCardLink(card, board) {
if (!card) return '';
let text = card.title;
if (board) text = `${board} > ` + text;
return (
card &&
Blaze.toHTML(
HTML.A(
{
href: card.originRelativeUrl(),
class: 'action-card',
},
sanitizeHTML(text),
),
)
// #3144: the card is not always here. An ARCHIVED card is not published to the
// client, so `activity.card()` returned nothing and every sentence that named it
// was rendered around a hole - "Activities for archived card displayed as
// undefined on board settings".
//
// The activity itself recorded the title in most cases (`cardTitle`), and a card
// URL can be built from the ids it carries, so the link survives the card not
// being here. models/lib/activityCardLink.js decides what to show;
// this turns it into the anchor, and marks an archived card the way the reporter
// asked for: "{{ card title }} [archived]".
function createCardLink(activity, card, boardTitle) {
const link = activityCardLinkData(activity, card, card ? card.board && card.board() : null);
if (!link) {
// Nothing named the card - an old activity from before the titles were
// recorded, on a card this client cannot see. Say "this card" rather than
// leaving a gap in the middle of a sentence.
return sanitizeHTML(TAPi18n.__('this-card'));
}
let text = link.title;
if (link.archived) text = `${text} [${TAPi18n.__('archived')}]`;
if (boardTitle) text = `${boardTitle} > ` + text;
if (!link.url) return sanitizeHTML(text);
return Blaze.toHTML(
HTML.A(
{
href: link.url,
class: 'action-card',
},
sanitizeHTML(text),
),
);
}
+2 -2
View File
@@ -110,7 +110,7 @@
}
.comments .comment .comment-desc .reactions .open-comment-reaction-popup span {
display: inline-block;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
font-weight: 500;
line-height: 1;
margin-inline-start: 4px;
@@ -129,7 +129,7 @@
background-color: #b0c4de;
}
.comments .comment .comment-desc .reactions .reaction .reaction-count {
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
}
.comments .comment .comment-desc .comment-meta {
font-size: 0.8em;
@@ -47,7 +47,7 @@
.board-conversion-header h3 {
margin: 0 0 8px 0;
color: #333;
font-size: 20px;
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
font-weight: 500;
}
@@ -59,7 +59,7 @@
.board-conversion-header p {
margin: 0;
color: #666;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
}
.board-conversion-content {
@@ -116,14 +116,14 @@
text-align: center;
font-weight: 600;
color: #2196F3;
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
}
.conversion-status {
text-align: center;
margin-bottom: 16px;
color: #333;
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
}
.conversion-status i {
@@ -134,7 +134,7 @@
.conversion-time {
text-align: center;
color: #666;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
background-color: #f5f5f5;
padding: 8px 12px;
border-radius: 4px;
@@ -155,7 +155,7 @@
.conversion-info {
text-align: center;
color: #666;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
line-height: 1.4;
}
@@ -179,6 +179,6 @@
}
.board-conversion-header h3 {
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
}
}
+2 -2
View File
@@ -578,7 +578,7 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
justify-content: center;
align-items: center;
margin: 0;
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
}
.modal-footer {
display: flex;
@@ -594,7 +594,7 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
position: absolute;
top: 5px;
inset-inline-end: 5px;
font-size: 25px;
font-size: calc(25px * var(--wekan-ui-font-scale, 1));
cursor: pointer;
}
File diff suppressed because it is too large. Load diff
+50 -50
View File
@@ -41,18 +41,18 @@
}
.mobile-mode .minicard {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
padding: 11px 21px 5px;
min-height: 24px;
}
.mobile-mode .list-header-name {
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
}
.mobile-mode .board-header-btn {
padding: 9px 21px;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
}
/* Comprehensive Mobile Mode Styles - Works on all screen sizes */
@@ -111,7 +111,7 @@
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
padding: 1rem !important;
/* No margin under the header bar: it is a fixed band of the swimlane's lighter
grey between the bar and the first list, with nothing in it. */
@@ -150,7 +150,7 @@
min-width: 100% !important;
max-width: 100% !important;
padding: 1rem !important;
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
display: grid !important;
/* title | caret | add-list + | drag handle. The grid follows the INLINE axis, so
under dir="rtl" column 1 is on the right and the whole row mirrors by itself. */
@@ -174,7 +174,7 @@
}
.mobile-mode .list-header .list-header-name {
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
font-weight: bold !important;
grid-row: 1 !important;
grid-column: 2 !important;
@@ -185,7 +185,7 @@
}
.mobile-mode .list-header .cardCount {
font-size: 14px !important;
font-size: calc(14px * var(--wekan-ui-font-scale, 1)) !important;
grid-row: 2 !important;
grid-column: 2 !important;
align-self: center !important;
@@ -202,7 +202,7 @@
grid-row: 1/3 !important;
grid-column: 3 !important;
padding: 14px !important;
font-size: 24px !important;
font-size: calc(24px * var(--wekan-ui-font-scale, 1)) !important;
text-align: center !important;
display: block !important;
visibility: visible !important;
@@ -215,7 +215,7 @@
grid-row: 1/3 !important;
grid-column: 4 !important;
padding: 14px !important;
font-size: 24px !important;
font-size: calc(24px * var(--wekan-ui-font-scale, 1)) !important;
text-align: center !important;
display: block !important;
}
@@ -228,7 +228,7 @@
grid-row: 1/3 !important;
grid-column: 5 !important;
padding: 14px !important;
font-size: 28px !important;
font-size: calc(28px * var(--wekan-ui-font-scale, 1)) !important;
text-align: center !important;
display: block !important;
visibility: visible !important;
@@ -247,7 +247,7 @@
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
font-size: 16px !important;
font-size: calc(16px * var(--wekan-ui-font-scale, 1)) !important;
padding: 11px 21px 5px !important;
min-height: 24px !important;
margin-bottom: 0.5rem !important;
@@ -256,16 +256,16 @@
}
.mobile-mode .minicard .minicard-title {
font-size: 16px !important;
font-size: calc(16px * var(--wekan-ui-font-scale, 1)) !important;
font-weight: bold !important;
}
.mobile-mode .minicard .minicard-members {
font-size: 12px !important;
font-size: calc(12px * var(--wekan-ui-font-scale, 1)) !important;
}
.mobile-mode .minicard .minicard-lists {
font-size: 12px !important;
font-size: calc(12px * var(--wekan-ui-font-scale, 1)) !important;
}
/* Desktop Mode Styles */
@@ -293,12 +293,12 @@
width: auto !important;
min-width: auto !important;
padding: 23px 21px 5px !important;
font-size: 14px !important;
font-size: calc(14px * var(--wekan-ui-font-scale, 1)) !important;
display: block !important;
}
.desktop-mode .list-header .list-header-name {
font-size: 14px !important;
font-size: calc(14px * var(--wekan-ui-font-scale, 1)) !important;
display: inline !important;
grid-row: auto !important;
grid-column: auto !important;
@@ -306,7 +306,7 @@
}
.desktop-mode .list-header .cardCount {
font-size: 12px !important;
font-size: calc(12px * var(--wekan-ui-font-scale, 1)) !important;
grid-row: auto !important;
grid-column: auto !important;
align-self: auto !important;
@@ -320,7 +320,7 @@
grid-row: auto !important;
grid-column: auto !important;
padding: 14px !important;
font-size: 40px !important;
font-size: calc(40px * var(--wekan-ui-font-scale, 1)) !important;
}
.desktop-mode .list-header .list-header-handle {
@@ -331,7 +331,7 @@
grid-row: auto !important;
grid-column: auto !important;
padding: 7px !important;
font-size: 16px !important;
font-size: calc(16px * var(--wekan-ui-font-scale, 1)) !important;
}
.desktop-mode .list-body {
@@ -343,27 +343,27 @@
.desktop-mode .minicard {
width: auto !important;
min-width: auto !important;
font-size: 12px !important;
font-size: calc(12px * var(--wekan-ui-font-scale, 1)) !important;
padding: 5px 11px !important;
min-height: auto !important;
margin-bottom: 9px !important;
}
.desktop-mode .minicard .minicard-title {
font-size: 12px !important;
font-size: calc(12px * var(--wekan-ui-font-scale, 1)) !important;
}
.desktop-mode .minicard .minicard-members {
font-size: 10px !important;
font-size: calc(10px * var(--wekan-ui-font-scale, 1)) !important;
}
.desktop-mode .minicard .minicard-lists {
font-size: 10px !important;
font-size: calc(10px * var(--wekan-ui-font-scale, 1)) !important;
}
/* Additional Mobile Mode Styles for Other Elements - Works on all screen sizes */
.mobile-mode .swimlane-header .swimlane-title {
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
font-weight: bold !important;
display: block !important;
visibility: visible !important;
@@ -371,21 +371,21 @@
}
.mobile-mode .swimlane-header .swimlane-description {
font-size: 14px !important;
font-size: calc(14px * var(--wekan-ui-font-scale, 1)) !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .board-header {
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
padding: 1rem !important;
width: 100% !important;
max-width: 100% !important;
}
.mobile-mode .board-header .board-header-title {
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
font-weight: bold !important;
display: block !important;
visibility: visible !important;
@@ -393,14 +393,14 @@
}
.mobile-mode .board-header .board-header-description {
font-size: 14px !important;
font-size: calc(14px * var(--wekan-ui-font-scale, 1)) !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .board-header .board-header-btn {
font-size: 14px !important;
font-size: calc(14px * var(--wekan-ui-font-scale, 1)) !important;
padding: 9px 21px !important;
display: inline-block !important;
visibility: visible !important;
@@ -408,7 +408,7 @@
}
.mobile-mode .board-header .board-header-btn i {
font-size: 14px !important;
font-size: calc(14px * var(--wekan-ui-font-scale, 1)) !important;
display: inline !important;
visibility: visible !important;
opacity: 1 !important;
@@ -430,7 +430,7 @@
grid-row: 1/3 !important;
grid-column: 3 !important;
padding: 14px !important;
font-size: 24px !important;
font-size: calc(24px * var(--wekan-ui-font-scale, 1)) !important;
text-align: center !important;
}
@@ -438,7 +438,7 @@
grid-row: 1/3 !important;
grid-column: 4 !important;
padding: 14px !important;
font-size: 28px !important;
font-size: calc(28px * var(--wekan-ui-font-scale, 1)) !important;
text-align: center !important;
}
@@ -513,7 +513,7 @@
min-width: 100% !important;
margin: 0 !important;
padding: 1rem !important;
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
font-weight: bold !important;
border-bottom: 2px solid #ccc !important;
}
@@ -819,12 +819,12 @@
.mobile-mode .board-header .board-header-btn {
height: 32px !important;
line-height: 32px !important;
font-size: 15px !important;
line-height: calc(32px * var(--wekan-ui-font-scale, 1)) !important;
font-size: calc(15px * var(--wekan-ui-font-scale, 1)) !important;
}
.mobile-mode .board-header .board-header-btn i.fa {
line-height: 32px !important;
line-height: calc(32px * var(--wekan-ui-font-scale, 1)) !important;
}
/* Copy mobile media query styles to mobile mode for consistent appearance */
@@ -844,8 +844,8 @@
.mobile-mode .board-header .board-header-btn {
height: 32px !important;
line-height: 32px !important;
font-size: 15px !important;
line-height: calc(32px * var(--wekan-ui-font-scale, 1)) !important;
font-size: calc(15px * var(--wekan-ui-font-scale, 1)) !important;
margin: 0 2px !important;
padding: 4px 8px !important;
border-radius: 4px !important;
@@ -866,8 +866,8 @@
}
.mobile-mode .board-header .board-header-btn i.fa {
line-height: 32px !important;
font-size: 15px !important;
line-height: calc(32px * var(--wekan-ui-font-scale, 1)) !important;
font-size: calc(15px * var(--wekan-ui-font-scale, 1)) !important;
margin: 0 !important;
padding: 0 !important;
}
@@ -978,7 +978,7 @@ body.board-view.mobile-mode #header #header-main-bar .board-header-sidebar-toggl
margin: 0 2px;
min-width: 44px;
height: 44px;
line-height: 44px;
line-height: calc(44px * var(--wekan-ui-font-scale, 1));
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1003,33 +1003,33 @@ body.board-view.mobile-mode #header #header-main-bar .board-header-sidebar-toggl
/* Additional Desktop Mode Styles for Other Elements */
.desktop-mode .swimlane-header .swimlane-title {
font-size: 14px !important;
font-size: calc(14px * var(--wekan-ui-font-scale, 1)) !important;
}
.desktop-mode .swimlane-header .swimlane-description {
font-size: 12px !important;
font-size: calc(12px * var(--wekan-ui-font-scale, 1)) !important;
}
.desktop-mode .board-header {
font-size: 14px !important;
font-size: calc(14px * var(--wekan-ui-font-scale, 1)) !important;
padding: 23px 21px 5px !important;
}
.desktop-mode .board-header .board-header-title {
font-size: 14px !important;
font-size: calc(14px * var(--wekan-ui-font-scale, 1)) !important;
}
.desktop-mode .board-header .board-header-description {
font-size: 12px !important;
font-size: calc(12px * var(--wekan-ui-font-scale, 1)) !important;
}
.desktop-mode .board-header .board-header-btn {
font-size: 12px !important;
font-size: calc(12px * var(--wekan-ui-font-scale, 1)) !important;
padding: 5px 11px !important;
}
.desktop-mode .board-header .board-header-btn i {
font-size: 12px !important;
font-size: calc(12px * var(--wekan-ui-font-scale, 1)) !important;
}
/* #6419: make the board-header action buttons touch-friendly by VIEWPORT WIDTH,
@@ -1042,9 +1042,9 @@ body.board-view.mobile-mode #header #header-main-bar .board-header-sidebar-toggl
min-height: 40px;
padding-top: 8px;
padding-bottom: 8px;
font-size: 15px;
font-size: calc(15px * var(--wekan-ui-font-scale, 1));
}
.board-header-btns .board-header-btn i.fa {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
}
}
+91 -143
View File
@@ -1,13 +1,7 @@
//- The pencil that renames the board, beside the board's name in the FIRST
//- header bar. The name is there, so the thing that edits it belongs next to it
//- rather than in a bar of its own. Board admins only; everybody else sees the
//- name without a pencil. docs/Design/Page/Header.md
template(name="boardEditTitleButton")
if currentUser.isBoardAdmin
with currentBoard
a.board-header-btn.js-edit-board-title(title="{{_ 'edit'}}" value=title)
i.fa.fa-pencil-square-o
//- There is no pencil beside the board's name any more: the NAME renames the
//- board. It is one target instead of a word and an icon that did the same
//- thing, and the icon was the smaller of the two. client/components/main/header.jade
//-
//- Everything else the board's second header bar held, as icons in the FIRST
//- bar, to the left of the notification bell.
//-
@@ -22,69 +16,81 @@ template(name="boardHeaderButtons")
if currentBoard
if currentUser
.board-header-btns.board-header-controls
with currentBoard
a.board-header-btn(
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
title="{{_ currentBoard.permission}}")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
//- The SAME expression the tooltip uses, so the two cannot say
different things - and both follow the board from Private to
Public without a second list of words to keep in step.
span.board-header-btn-label {{_ currentBoard.permission}}
//- The board's controls fold into one caret at the leading edge of the
group - left of the lock in a left-to-right page, right of it in Arabic,
because the caret's direction comes from the same rule as every other
one. Folded, the seven controls after it are gone; open, they are back.
The buttons a board's own Rules put in this bar are NOT folded away with
them: somebody added those to this board on purpose.
client/lib/foldState.js
a.board-header-btn.js-toggle-fold(data-fold="board-controls"
title="{{#if isFoldOpen 'board-controls'}}{{_ 'collapse'}}{{else}}{{_ 'uncollapse'}}{{/if}}" role="button" tabindex="0")
i.fa(class="{{foldCaret 'board-controls'}}")
if isFoldOpen 'board-controls'
with currentBoard
a.board-header-btn(
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
title="{{_ currentBoard.permission}}")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
//- The SAME expression the tooltip uses, so the two cannot say
different things - and both follow the board from Private to
Public without a second list of words to keep in step.
span.board-header-btn-label {{_ currentBoard.permission}}
unless watchDisabled
a.board-header-btn.js-watch-board(title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
span.board-header-btn-label {{_ watchLevel }}
unless watchDisabled
a.board-header-btn.js-watch-board(title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
span.board-header-btn-label {{_ watchLevel }}
//- 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/Design/Page/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}}
if isSortActive
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
i.fa.fa-times-thin
//- 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/Design/Page/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}}
if isSortActive
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
i.fa.fa-times-thin
+boardButtons
a.board-header-btn.js-open-filter-view(
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
class="{{#if Filter.isActive}}js-filter-active{{/if}}")
i.fa.fa-filter
span.board-header-btn-label {{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}
if Filter.isActive
a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
a.board-header-btn.js-open-search-view(title="{{_ 'search'}}")
i.fa.fa-search
span.board-header-btn-label {{_ 'search'}}
a.board-header-btn.js-toggle-dependencies(
title="{{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}"
class="{{#if currentBoard.showDependencies}}emphasis{{/if}}")
i.fa.fa-link
span.board-header-btn-label {{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}
if canModifyBoard
a.board-header-btn.js-multiselection-activate(
title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
class="{{#if MultiSelection.isActive}}emphasis{{/if}}")
i.fa.fa-check-square-o
//- Named, like the board's Multi-Selection on All Boards: a
check-box outline says nothing about what the button does.
span.board-header-btn-label {{_ 'multi-selection'}}
if MultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'multi-selection-off'}}")
if isFoldOpen 'board-controls'
a.board-header-btn.js-open-filter-view(
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
class="{{#if Filter.isActive}}js-filter-active{{/if}}")
i.fa.fa-filter
span.board-header-btn-label {{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}
if Filter.isActive
a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
a.board-header-btn.js-open-search-view(title="{{_ 'search'}}")
i.fa.fa-search
span.board-header-btn-label {{_ 'search'}}
a.board-header-btn.js-toggle-dependencies(
title="{{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}"
class="{{#if currentBoard.showDependencies}}emphasis{{/if}}")
i.fa.fa-link
span.board-header-btn-label {{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}
if canModifyBoard
a.board-header-btn.js-multiselection-activate(
title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
class="{{#if MultiSelection.isActive}}emphasis{{/if}}")
i.fa.fa-check-square-o
//- Named, like the board's Multi-Selection on All Boards: a
check-box outline says nothing about what the button does.
span.board-header-btn-label {{_ 'multi-selection'}}
if MultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'multi-selection-off'}}")
i.fa.fa-times-thin
else
a.board-header-btn.js-log-in(title="{{_ 'log-in'}}")
i.fa.fa-sign-in
@@ -187,7 +193,18 @@ template(name="boardChangeViewPopup")
if $eq Utils.boardView "board-view-stats"
i.fa.fa-check
template(name="createBoard")
//- The Create Board form: a title, what the board is visible to, and Create.
Four templates drew it letter for letter - the one on the All Boards page and
three popups, one of which creates a TEMPLATE board and says so with a Session
flag rather than with different markup.
//-
//- The two things it shows about visibility are passed IN. The state itself
(setupCreateBoardState) and the handlers stay with each of the four: an event
in here bubbles to whichever one included it, which is the one holding the
state, and that is what keeps `createTemplateContainerPopup` creating a
template board and the others an ordinary one.
client/components/boards/boardHeader.js
template(name="createBoardForm")
form
label
| {{_ 'title'}}
@@ -214,87 +231,18 @@ template(name="createBoard")
| /
a.js-board-template {{_ 'template'}}
template(name="createBoard")
+createBoardForm(visibility=visibility visibilityMenuIsOpen=visibilityMenuIsOpen)
template(name="createBoardPopup")
form
label
| {{_ 'title'}}
input.js-new-board-title(type="text" placeholder="{{_ 'bucket-example'}}" autofocus required)
if visibilityMenuIsOpen
+boardVisibilityList
else
p.quiet
if $eq visibility 'public'
span.fa.fa-globe.colorful
= " "
| {{{_ 'board-public-info'}}}
else
span.fa.fa-lock.colorful
= " "
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
input.primary.wide(type="submit" value="{{_ 'create'}}")
unless importDisabled
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
+createBoardForm(visibility=visibility visibilityMenuIsOpen=visibilityMenuIsOpen)
template(name="headerBarCreateBoardPopup")
form
label
| {{_ 'title'}}
input.js-new-board-title(type="text" placeholder="{{_ 'bucket-example'}}" autofocus required)
if visibilityMenuIsOpen
+boardVisibilityList
else
p.quiet
if $eq visibility 'public'
span.fa.fa-globe.colorful
= " "
| {{{_ 'board-public-info'}}}
else
span.fa.fa-lock.colorful
= " "
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
input.primary.wide(type="submit" value="{{_ 'create'}}")
unless importDisabled
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
+createBoardForm(visibility=visibility visibilityMenuIsOpen=visibilityMenuIsOpen)
// New popup for Template Container creation; shares the same form content
template(name="createTemplateContainerPopup")
form
label
| {{_ 'title'}}
input.js-new-board-title(type="text" placeholder="{{_ 'bucket-example'}}" autofocus required)
if visibilityMenuIsOpen
+boardVisibilityList
else
p.quiet
if $eq visibility 'public'
span.fa.fa-globe.colorful
= " "
| {{{_ 'board-public-info'}}}
else
span.fa.fa-lock.colorful
= " "
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
input.primary.wide(type="submit" value="{{_ 'create'}}")
unless importDisabled
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
+createBoardForm(visibility=visibility visibilityMenuIsOpen=visibilityMenuIsOpen)
//template(name="listsortPopup")
//
+48 -18
View File
@@ -17,6 +17,7 @@ import {
import { MultiSelection } from '/client/lib/multiSelection';
import { getSidebarInstance } from '/client/features/sidebar/service';
import { Utils } from '/client/lib/utils';
import { toggleFold } from '/client/lib/foldState';
/*
const DOWNCLS = 'fa-sort-down';
@@ -173,7 +174,18 @@ function toggleSidebarView(view, mustStayOpen) {
}
Template.boardHeaderButtons.events({
'click .js-edit-board-title': Popup.open('boardChangeTitle'),
// The board's controls fold into the caret at the start of the group. Named
// by the words the app already has for it - Collapse and Uncollapse - rather
// than a key of its own in 147 language files. client/lib/foldState.js
'click .js-toggle-fold'(event) {
event.preventDefault();
toggleFold(event.currentTarget.dataset.fold);
},
'keydown .js-toggle-fold'(event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleFold(event.currentTarget.dataset.fold);
},
'click .js-change-visibility': Popup.open('boardChangeVisibility'),
'click .js-watch-board': Popup.open('boardChangeWatch'),
// Boards in Archive is a SECTION of All Boards, not a page of its own: the
@@ -484,18 +496,38 @@ Template.boardChangeWatchPopup.helpers({
Template.boardChangeWatchPopup.events({
'click .js-select-watch'() {
const level = this;
if (typeof level === 'string') {
Meteor.call(
'watch',
'board',
Session.get('currentBoard'),
level,
(err, ret) => {
if (!err && ret) Popup.back();
},
);
}
// `this` is the data context of the clicked row - the string from the
// enclosing {{#with "watching"}}. Blaze can hand that back as a boxed
// String, so read it through String() rather than refusing anything that is
// not typeof 'string'.
const level = this === null || this === undefined ? '' : String(this);
if (!level) return;
Meteor.call(
'watch',
'board',
Session.get('currentBoard'),
level,
(err, ret) => {
if (!err && ret) {
Popup.back();
return;
}
// AN ERROR MUST NOT BE SILENT. It was: the callback closed the popup on
// success and did nothing at all otherwise, so a refusal looked exactly
// like a broken button - "Silent does not respond. If we try to change
// it does not change. Nothing happens." (email, 2026-08-13). The server
// refuses for two reasons an admin can act on: the watch feature is
// turned off in the Admin Panel, and the board is not visible to this
// user.
const reason = err && (err.error || err.reason || err.message);
const message = reason === 'error-watch-disabled'
? TAPi18n.__('error-watch-disabled')
: TAPi18n.__('error-board-notAMember');
// eslint-disable-next-line no-alert
if (typeof window !== 'undefined' && window.alert) window.alert(message);
if (process.env.DEBUG === 'true') console.error('watch failed:', err);
},
);
},
});
@@ -634,8 +666,6 @@ Template.boardViewMenu.helpers({
},
});
// The pencil is its own template in the first header bar, beside the board's
// name, so its click is handled here. docs/Design/Page/Header.md
Template.boardEditTitleButton.events({
'click .js-edit-board-title': Popup.open('boardChangeTitle'),
});
// The board is renamed by clicking its NAME in the first header bar, which is
// handled where that bar is: client/components/main/header.js. The pencil that
// used to be here is gone with the template it lived in.
+220 -67
View File
@@ -154,7 +154,7 @@
background: #ddd;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
margin-inline-start: 8px;
}
@@ -235,9 +235,9 @@
.board-list .board-list-item-bookmark .bookmark-url {
display: block;
margin-top: 4px;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: 400;
line-height: 16px;
line-height: calc(16px * var(--wekan-ui-font-scale, 1));
opacity: 0.85;
/* An address is one string and must not be hyphenated into something that
reads like a different address. */
@@ -291,13 +291,13 @@
border: 2px dashed #d0d0d0;
border-radius: 4px;
color: #808080;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
/* Nothing to grab: this is where a drag ENDS. */
cursor: default;
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.home-remove-target .fa {
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
}
/* Under the icon. The colour is the answer to "what happens if I let go", so it
is the destructive one - and it is only ever shown while something is over
@@ -321,7 +321,7 @@
border: 2px dashed #b3b3b3;
border-radius: 4px;
color: #808080;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
line-height: 1.4;
text-align: center;
}
@@ -381,7 +381,7 @@
padding-block: 8px 4px;
padding-inline-start: 18px;
padding-inline-end: 10px;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
@@ -435,7 +435,7 @@
text-decoration: none;
cursor: pointer;
opacity: 0.7;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
}
.workspace-collapse-indicator:hover {
opacity: 1;
@@ -512,7 +512,7 @@
.workspace-drag-handle {
cursor: grab;
color: #999;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
padding: 0 4px;
user-select: none;
}
@@ -537,7 +537,7 @@
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
line-height: 1;
vertical-align: middle;
}
@@ -597,7 +597,7 @@
background: #ddd;
padding: 2px 6px;
border-radius: 10px;
font-size: 11px;
font-size: calc(11px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
min-width: 20px;
text-align: center;
@@ -613,7 +613,7 @@
border-radius: 3px;
cursor: pointer;
text-decoration: none;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
opacity: 0.6;
transition: opacity 0.2s;
}
@@ -629,7 +629,7 @@
border-radius: 3px;
cursor: pointer;
text-decoration: none;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
opacity: 0.7;
transition: opacity 0.2s;
}
@@ -766,12 +766,53 @@
min-height: 45px;
}
/* THE CONTROLS THEMSELVES. Every page's controls template - Rules, My Cards,
Due Cards, Global Search - draws its entries as `.board-header-btn`, because
that is what they were when they lived in the page's own second header bar.
Every rule for that class is scoped to `#header-quick-access` (header.css),
so in here they had NO styling at all: three bare links jammed together on
one line, icons run into words, nothing to click but the text.
They are sidebar rows now - one per line, the width of the panel, with the
icon in a fixed column so every label starts at the same x. The board's own
sidebar rows are `.sidebar-list li > a` and `.sidebar-btn`; this gives the
same shape to the class these pages actually use. */
.all-boards-sidebar .sidebar-content .board-header-btn,
.page-sidebar .sidebar-content .board-header-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
box-sizing: border-box;
margin: 0 0 4px;
padding: 8px 10px;
border-radius: 3px;
color: inherit;
cursor: pointer;
}
.all-boards-sidebar .sidebar-content .board-header-btn:hover,
.page-sidebar .sidebar-content .board-header-btn:hover {
background: rgba(0, 0, 0, 0.06);
}
/* A fixed column for the icon, so a row with a caret and a row without one
still line their words up - the same reason the board sidebar gives its
checkbox glyphs a width. */
.all-boards-sidebar .sidebar-content .board-header-btn i.fa,
.page-sidebar .sidebar-content .board-header-btn i.fa {
width: 16px;
text-align: center;
flex-shrink: 0;
margin: 0;
}
/* The sidebar's own rows. `.sidebar-btn` and `.sidebar-list` come from the
board sidebar's stylesheet, which is loaded on every page, so only what is
particular to this one is here. */
.all-boards-sidebar .sidebar-multiselection-hint {
color: #666;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
margin: 8px 0;
}
@@ -887,9 +928,21 @@
overflow: hidden;
background-color: inherit; /* Inherit board color from parent li.js-board */
color: #f6f6f6;
min-height: 72px; /* #6465: thinner tiles so more boards fit in the overview */
font-size: 16px;
line-height: 22px;
/* The number IS the rendered height, padding and border folded in - the same
thing the mobile rules below do for the same reason (#6523).
It used to be a 72px floor with 42px of padding added outside it, which came
to the same 114px for an ordinary tile and to 122px for a BORDERED one: the
bookmarks in All Boards / Starred and the Template Container tile both carry
`border: 4px solid #fff`, and on a content-box element those 8px are added
to the height rather than taken out of it. So a starred page sat 8px taller
than the board beside it and than the grey "+ Add Board" tile, and the row
it was in inherited the extra height.
114px is what an ordinary tile already rendered at (72 + 24 + 18), so
nothing moves except the bordered tiles, which now match. */
box-sizing: border-box;
min-height: 114px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
line-height: calc(22px * var(--wekan-ui-font-scale, 1));
border-radius: 0; /* No border-radius - parent .js-board has it */
display: block;
font-weight: 700;
@@ -908,7 +961,7 @@
transform: translateX(-50%);
z-index: 3;
color: #fff;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
pointer-events: none;
@@ -929,16 +982,16 @@
.board-list .board-list-item-sub-name {
color: rgba(255,255,255,0.5);
display: block;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
font-weight: 400;
line-height: 22px;
line-height: calc(22px * var(--wekan-ui-font-scale, 1));
}
.board-list .board-list-item-desc {
color: #fff;
display: block;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
font-weight: 400;
line-height: 18px;
line-height: calc(18px * var(--wekan-ui-font-scale, 1));
}
.board-list .js-add-board {
text-align: center;
@@ -948,8 +1001,11 @@
/* Match the board tiles (#6465 thinner tiles) so the grey "+ Add Board" /
"+ Add Template Container" box is the same height as a board icon:
same min-height, padding and line-height as .board-list-item above. */
line-height: 22px;
min-height: 72px;
line-height: calc(22px * var(--wekan-ui-font-scale, 1));
/* Border-box and the full height, like .board-list-item above: the two are
compared by eye on the same row, so they are stated the same way. */
box-sizing: border-box;
min-height: 114px;
display: flex;
align-items: center;
justify-content: center;
@@ -978,9 +1034,9 @@
.board-list .is-star-active,
.board-list .is-not-star-active {
top: 0;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
height: 18px;
line-height: 18px;
line-height: calc(18px * var(--wekan-ui-font-scale, 1));
opacity: 0;
padding: 9px 9px;
position: absolute;
@@ -990,9 +1046,9 @@
}
.board-list .fa-circle {
bottom: 0;
font-size: 10px;
font-size: calc(10px * var(--wekan-ui-font-scale, 1));
height: 10px;
line-height: 10px;
line-height: calc(10px * var(--wekan-ui-font-scale, 1));
padding: 9px 9px;
position: absolute;
inset-inline-end: 0;
@@ -1011,9 +1067,9 @@
.board-list .fa-clone {
position: absolute;
bottom: 0;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
height: 18px;
line-height: 18px;
line-height: calc(18px * var(--wekan-ui-font-scale, 1));
opacity: 0;
inset-inline-end: 0;
padding: 9px 9px;
@@ -1023,9 +1079,9 @@
.board-list .fa-archive {
position: absolute;
bottom: 0;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
height: 18px;
line-height: 18px;
line-height: calc(18px * var(--wekan-ui-font-scale, 1));
opacity: 0;
inset-inline-start: 0;
padding: 9px 9px;
@@ -1049,7 +1105,7 @@
.board-list li:hover a .fa-clone:hover,
.board-list li:hover a .fa-archive:hover,
.board-list li:hover a .is-not-star-active:hover {
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
opacity: 1;
}
.board-list li:hover a .is-star-active,
@@ -1067,7 +1123,7 @@
top: 50%;
inset-inline-end: 6px;
transform: translateY(-50%);
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
color: #fff;
/* Transparent background so only the drag-handle icon shows (not a box). */
background: transparent;
@@ -1136,7 +1192,7 @@
font-family: FontAwesome;
content: "\f00c";
color: #fff;
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
}
@@ -1183,7 +1239,7 @@
justify-content: center;
}
.board-backgrounds-list .board-background-select .background-box i.fa-check {
font-size: 25px;
font-size: calc(25px * var(--wekan-ui-font-scale, 1));
color: #3cb500;
}
/* Mobile view styles - applied when isMiniScreen is true (iPhone, etc.) */
@@ -1193,7 +1249,8 @@
(e.g. a laptop in mobile mode) gets more columns instead of two stretched ones. */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(46%, 200px), 1fr));
gap: 8px;
/* The same single spacing value as the phone grid above, in both directions. */
gap: 10px;
/* #6488: sized by the column it lives in (the flex chain in the phone block
below), not by the viewport minus a guess at the bars above it. */
flex: 1 1 auto;
@@ -1210,7 +1267,8 @@
width: auto; /* the grid controls the column width */
float: none;
display: block;
margin-bottom: 0.5rem;
/* The grid gap is the spacing; this added a second one, downwards only. */
margin-bottom: 0;
/* NO handle reserve here. The drag handle is positioned INSIDE the tile, and
the tile already keeps its text clear of it - so 32px here was a second,
invisible reserve on top of that one. On a phone the tile is only ~97px
@@ -1246,7 +1304,7 @@
top: 50%;
transform: translateY(-50%);
inset-inline-end: 6px;
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
color: #fff;
background: rgba(0,0,0,0.3);
border-radius: 50%;
@@ -1340,18 +1398,42 @@
.board-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
/* ONE spacing value, in both directions. It used to be an 8px gap PLUS a
`margin-bottom: 0.5rem` on each li, so the tiles were 16px apart vertically
and 8px apart horizontally - which reads as "the icons are too close
together" side to side even though there is space between the rows. The
margin is gone (below) and the gap is the whole answer. */
gap: 10px;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 0 0.25rem;
margin: 0;
/* #6488. Without these a grid's rows default to `align-content: stretch` and
its items to `align-items: stretch`, so when the list is SHORTER than the
column it is given - one or two rows of boards - the leftover height is
divided among the rows and each tile is grown to fill it. A board tile
paints its board colour over the whole cell, so a single row of boards
became one band several times the height of a normal icon: "some board
icons are 4x normal height or more".
This was fixed once, but only on `.board-list.mobile-view` - the class the
template sets from Utils.isMiniScreen(). A narrow WINDOW that is not a mini
screen (a desktop browser resized under 800px, a tablet in desktop mode)
matches this media query, becomes a grid here, and never got the fix. It
belongs on the rule that creates the grid. */
align-content: start;
align-items: start;
}
.board-list li {
width: auto; /* the grid controls the column width */
float: none;
display: block;
margin-bottom: 0.5rem;
/* The grid gap spaces the tiles; a margin here spaced them a second time,
and only downwards. */
margin-bottom: 0;
/* ...and the tile is its own height, not the height of a stretched cell. */
align-self: start;
height: auto;
/* Same as `.board-list.mobile-view li` above: the tile keeps its own text
clear of the handle, so reserving the width a second time out here only
narrowed the title. */
@@ -1426,7 +1508,7 @@
top: 50%;
transform: translateY(-50%);
inset-inline-end: 6px;
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
color: #fff;
background: rgba(0,0,0,0.3);
border-radius: 50%;
@@ -1462,7 +1544,7 @@
top: 50%;
transform: translateY(-50%);
inset-inline-end: 6px;
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
color: #fff;
background: rgba(0,0,0,0.3);
border-radius: 50%;
@@ -1523,7 +1605,7 @@
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
display: inline-flex;
align-items: center;
gap: 6px;
@@ -1535,7 +1617,7 @@
}
#resetBtn.filter-reset-btn .reset-icon {
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
}
.js-board {
@@ -1559,24 +1641,66 @@
margin: 0 !important; /* grid gap spaces the tiles now (#6523) */
}
/* Apply board colors to li.js-board parent instead of just the link */
.board-list .board-color-nephritis { background-color: #27ae60; }
.board-list .board-color-pomegranate { background-color: #c0392b; }
.board-list .board-color-belize { background-color: #2980b9; }
.board-list .board-color-wisteria { background-color: #8e44ad; }
.board-list .board-color-midnight { background-color: #2c3e50; }
.board-list .board-color-pumpkin { background-color: #e67e22; }
.board-list .board-color-moderatepink { background-color: #cd5a91; }
.board-list .board-color-strongcyan { background-color: #00aecc; }
.board-list .board-color-limegreen { background-color: #4bbf6b; }
.board-list .board-color-dark { background-color: #2c3e51; }
.board-list .board-color-relax { background-color: #27ae61; }
.board-list .board-color-corteza { background-color: #568ba2; }
.board-list .board-color-clearblue { background-color: #3498db; }
.board-list .board-color-natural { background-color: #596557; }
.board-list .board-color-modern { background-color: #2a80b8; }
.board-list .board-color-moderndark { background-color: #2a2a2a; }
.board-list .board-color-exodark { background-color: #222; }
/* The board colour of a tile is painted by the THEME, not from here.
This used to be a hand-copied list of seventeen `.board-list
.board-color-<name> { background-color: ... }` lines, and a copied list is a
list that goes stale: the five colour SLIDE themes added later - clearpink,
cleargreen, clearorange, clearpurple, clearred - were never added to it, so
their tiles stayed the default #999 grey while the slide was drawn on the
inner link, which is inset by the tile's own padding. That is what a
clearpink board looked like in All Boards: a pink rectangle floating in a
grey tile. clearblue WAS in the list, as a flat #3498db, so it did not show
its slide either.
Each theme now paints `.board-list li.board-color-<name>` in
boardColors.css, beside everything else that theme colours, and a tile is
the whole tile. tests/boardTileTheme.test.cjs holds every theme to it. */
/* Member Settings / Change color, beside "Default (no override)": "All Boards".
Every tile in the overview takes the THEME's lighter colour instead of the
colour its own board was given, so the page reads as one list rather than as a
palette - which is what #6593 asked for by making every tile white for
everybody, and this is the same thing as a per-user choice, because "the tile
colours are noise" and "the tile colours are how I find my board" are both
true, of different people.
THE ACCENT ITSELF, the same colour and the same white text as the selected row
in the left menu beside it - `.boards-left-menu .menu-item.active a` reads the
same variable. It was the accent under a flat white veil, one shade up, which
made the tiles a lighter version of the highlight they sit beside rather than
the same colour as it; two shades of one accent on one page read as two
colours, not as one page.
`--theme-accent-fill` rather than `--theme-accent`, and that is the whole
difference on a colour-SLIDE theme: the accent is ONE colour - the solid end
of the slide - so a tile painted from it came out flat beside a left-menu row
that slid. The fill is the theme's slide where it has one and its accent
where it does not (models/lib/themeAccents.js), which is what every other
themed control here reads.
It is the `background` SHORTHAND on purpose: a slide theme paints the tile
with a shorthand of its own, and setting only the colour would leave that
gradient sitting on top of the accent.
A board with a background IMAGE keeps its image: that is a picture somebody
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));
}
body.has-theme-board-tiles .board-list > li.js-board .board-list-item:not(.has-background-image) {
background-color: transparent;
background-image: none;
color: #fff;
}
body.has-theme-board-tiles .board-list > li.js-board .board-list-item-name,
body.has-theme-board-tiles .board-list > li.js-board .board-list-item-desc {
color: #fff;
}
/* The count pill keeps its contrast on the accent the same way the left menu's
does: a translucent white pill rather than the mid-grey that only works on
white. */
body.has-theme-board-tiles .board-list > li.js-board .board-list-card-count {
background: rgba(255, 255, 255, 0.3);
color: #fff;
}
.minicard-members {
padding: 6px 0 6px 8px;
@@ -1661,6 +1785,35 @@
flex: 0 0 auto;
}
/* ...and so does the pane's heading. Without this it is a flex item with the
default `flex-shrink: 1`, so in a column too short for its contents the
TITLE is what gets squeezed instead of the scroller below it growing a
scrollbar. */
.boards-right-grid > .admin-pane-title {
flex: 0 0 auto;
}
/* #6488. The right column has TWO branches, not one: the board icons, and -
when the view is Table - `+tablePage`, which is a `.table-page` (see
boardsList.jade). Only the icons were ever given a scroller, so in Table view
the table simply grew past the bottom of the column, and `#content .wrapper`
is `overflow: hidden`, so the rows past the fold were clipped with no way to
reach them. Every section of All Boards can be shown as a table - Starred,
Templates, Remaining, Workspaces, Archive - so that was the whole page in
one of its two views.
The same shape as the board list: take what the column has left, be allowed
to be shorter than the content, and scroll inside. The table is also WIDER
than a phone, so this one scrolls in both directions. */
.boards-right-grid > .table-page {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
touch-action: pan-x pan-y;
}
/* The left menu (Starred / Templates / Remaining / Workspaces / Add board) is its
own vertical scroll area, so when it is taller than the screen you can drag-scroll
down to its lower items — the board icons already scroll in their own column on the
@@ -1977,10 +2130,10 @@
}
.workspace-node .workspace-drag-handle {
padding: 0 2px;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
}
.workspace-node .workspace-icon {
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
}
.workspace-node .js-open-workspace-menu {
padding: 2px 4px;
@@ -1988,7 +2141,7 @@
.workspace-node .workspace-count {
min-width: 16px;
padding: 2px 4px;
font-size: 10px;
font-size: calc(10px * var(--wekan-ui-font-scale, 1));
}
}
@@ -2005,7 +2158,7 @@
min-width: 44px;
min-height: 44px;
padding: 8px;
font-size: 20px;
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
line-height: 1;
}
}
+17 -4
View File
@@ -68,16 +68,29 @@
/* #3392: card detail "Dependencies" rows */
.card-details-dependencies-list .card-dependency {
display: flex;
align-items: center;
/* The title WRAPS now, so the row can be two or three lines tall. Centring
the icon and the controls against that block would float them halfway down
it; they belong beside the first line. */
align-items: flex-start;
gap: 6px;
margin-bottom: 4px;
}
.card-details-dependencies-list .card-dependency-link {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* A card's title is a sentence, and the card details pane is narrow. It used
to be one line with an ellipsis, so everything past the width of the pane -
which is most of a real title, sitting right of the coloured icon - could
not be read at all. There is no reason to hide it: the row can be tall.
`min-width: 0` is what makes that work. A flex item's default is
`min-width: auto`, which refuses to shrink below its content, so the text
overflowed the row instead of wrapping inside it - and no amount of
`white-space: normal` alone would have fixed that. */
min-width: 0;
white-space: normal;
/* A title can be a URL or an id with no spaces in it; break inside the word
rather than let one long token push the controls off the row. */
overflow-wrap: anywhere;
}
.card-details-dependencies-list .js-dependency-type {
+96
View File
@@ -0,0 +1,96 @@
//- The checkbox list - WHAT an export includes, and what an import brings in.
Its own template so the board popup can borrow it into a pane of its own
layout while the swimlane, list and card popups get it inside the pane
exportScopeBody builds for them, with no copy of the markup in either place.
The helpers are registered on BOTH templates from one object (exportScope.js);
the click handlers stay on exportScopeBody, because an event from here bubbles
to the template that includes it.
template(name="exportScopeSelect")
p.quiet {{_ 'export-select-what-to-include'}}
ul.pop-over-list
li(class="{{#if cardDetailsChecked}}active{{/if}}")
a.js-export-card-details-toggle(href="#")
i.fa.fa-check
| {{_ 'export-card-details'}}
each exportFields
li(class="{{#if checked}}active{{/if}}")
a.js-export-field-toggle(data-field="{{field}}" href="#")
i.fa.fa-check
| {{_ label}}
//- The body of an export popup - ALL of them (#1173). What to include in one
pane and what to export it to in the other, side by side when the window has
room and stacked when it does not.
It was two menus: the board popup wrote nineteen formats of its own in
sidebar.jade under four subheadings, and the swimlane, list and card popups
wrote five here in one narrow column - so "the export popup" looked like two
different features depending on which menu opened it, and a format added to
one was missing from the other. The formats are one table now
(EXPORT_FORMAT_GROUPS, exportScope.js); each entry says which scopes it
applies to and whether it is a link or an action, and this lays them out.
template(name="exportScopeBody")
.export-board-panes
.export-board-pane.export-board-pane-select
+exportScopeSelect
.export-board-pane.export-board-pane-formats
if isImportMode
//- #1173: the same popup, the other direction. The checkboxes in the
other pane say what goes OUT of an export and what comes IN from an
import - one selection, so "no comments" means the same thing
whichever way the file is moving.
Asked HERE as well as on the menu row that opens it: the row is hidden
from a reader who may not write, and a popup that trusts its caller is
a popup that offers the file chooser the first time somebody reaches
it another way.
if canImport
p.quiet {{_ 'import-here-instruction'}}
ul.pop-over-list
li
label.js-import-file-label
i.fa.fa-download
| {{_ 'import'}} / JSON, .zip
input.js-import-file(type="file" accept=".json,application/json,.zip,application/zip" hidden)
if importBusy
p.quiet {{_ 'importing'}}
if importError
.warning {{_ importError}}
if importDone
p.quiet {{_ 'import-done'}} {{importDone}}
else
each formatGroups
if heading
hr
h4.pop-over-list-subheading {{heading}}
ul.pop-over-list
each entries
li
if action
a(class="{{action}}")
i.fa(class="{{icon}}")
| {{label}}
else
a(href="{{url}}" download="{{filename}}")
i.fa(class="{{icon}}")
| {{label}}
template(name="exportSwimlanePopup")
+exportScopeBody(swimlaneId=_id title=title)
template(name="exportListPopup")
+exportScopeBody(listId=_id title=title)
template(name="exportChecklistPopup")
+exportScopeBody(checklistId=_id title=title)
//- The IMPORT half of each menu. Same body, same table, same selection - the
mode decides whether the second pane offers the formats to write out or the
file to read in. A menu says what it is about, so its two rows are named
"Export" and "Import" and nothing more.
template(name="importSwimlanePopup")
+exportScopeBody(swimlaneId=_id title=title mode="import")
template(name="importListPopup")
+exportScopeBody(listId=_id title=title mode="import")
template(name="importCardPopup")
+exportScopeBody(cardId=_id listId=listId title=title mode="import")
+430
View File
@@ -0,0 +1,430 @@
// ITS OWN TEMPLATE FIRST. This module is imported by sidebar.js, by
// cardDetails.js and by the import page, and any of them can be evaluated
// before client/features/boards.js gets to the .jade - at which point
// `Template.exportScopeBody` is undefined and `.helpers()` on it throws at
// module scope. That does not break this popup, it breaks THE WHOLE BUNDLE:
// evaluation stops, every template registered after it never registers, and
// the sign-in page comes up with "no template passwordInput found".
//
// A component whose .js is imported by other components cannot rely on the
// central import order in client/features/*.js. Importing the template here
// makes the order a fact rather than a hope; the module cache means the second
// import costs nothing.
import './exportScope.jade';
import { ReactiveDict } from 'meteor/reactive-dict';
import { Template } from 'meteor/templating';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { Meteor } from 'meteor/meteor';
import { Session } from 'meteor/session';
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { Accounts } from 'meteor/accounts-base';
import { BOARD_EXPORT_FIELDS } from '/models/lib/exportFields';
import { exportLocaleParams } from '/client/lib/exportLocale';
// The "what do you want in it" half of an export, for a board, a swimlane or a
// list (#1173 "Add Feature: Print Board with Params").
//
// One body, used by three popups. A board export, a swimlane export and a list
// export differ by ONE query parameter - which cards are in scope - so writing
// the checkbox list and the two download links three times would be three places
// for a section to go missing from. `exportScopeBody` is included by each popup
// with its own scope, and everything else is shared.
//
// The SELECTION is shared too, and deliberately: it is module-level, so ticking
// "no attachments, no comments" once applies to the next export as well, in the
// same session. Somebody printing a board rarely wants a different shape for
// each list of it.
const selection = new ReactiveDict();
BOARD_EXPORT_FIELDS.forEach(({ field }) => selection.set(field, true));
// `card-details` is what makes an export the card layout rather than the
// spreadsheet table; it starts on, like everything else.
selection.set('card-details', true);
function selectedFields() {
return ['card-details', ...BOARD_EXPORT_FIELDS.map(({ field }) => field)]
.filter((field, index, all) => all.indexOf(field) === index)
.filter(field => selection.get(field));
}
// The scope a popup was opened with: {} for a board, {swimlaneId} or {listId}
// for the other two.
function currentScope() {
const data = Template.currentData() || {};
const scope = {};
if (data.swimlaneId) scope.swimlaneId = data.swimlaneId;
if (data.listId) scope.listId = data.listId;
return scope;
}
function scopeTitle() {
const data = Template.currentData() || {};
return data.title || 'export';
}
// ONE url builder for every format and every scope. A second one would be a
// second place for a query parameter to go missing from - which is how the card
// popup's checkboxes ended up driving the Excel download and not the PDF.
// A CARD has export routes of its own - /lists/:listId/cards/:cardId/exportPDF
// and exportExcel - which produce the one card rather than a board document with
// one card in it. They are the same renderers underneath, so the popup uses them
// when it is a card being exported and the board routes otherwise, instead of
// there being two ways to ask for the same file.
function routeFor(path) {
const data = Template.currentData() || {};
if (!data.cardId || !data.listId) return path;
if (path.endsWith('exportPDF')) {
return '/api/boards/:boardId/lists/:listId/cards/:cardId/exportPDF';
}
if (path.endsWith('exportExcel')) {
return '/api/boards/:boardId/lists/:listId/cards/:cardId/exportExcel';
}
return path;
}
function exportUrl(path, extra = {}) {
const boardId = Session.get('currentBoard');
const data = Template.currentData() || {};
const route = routeFor(path);
const params = { boardId };
if (route.includes(':listId')) params.listId = data.listId;
if (route.includes(':cardId')) params.cardId = data.cardId;
return FlowRouter.path(route, params, {
authToken: Accounts._storedLoginToken(),
fields: selectedFields().join(','),
...currentScope(),
...exportLocaleParams(),
...extra,
});
}
function exportFilename(extension) {
const name = String(scopeTitle())
.replace(/[^a-z0-9._-]+/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'export';
return `${name}.${extension}`;
}
// The import half's state. Module-level like the selection, because the popup is
// destroyed and recreated as it is reopened and a half-finished import should
// still be able to say how it went.
const importState = new ReactiveDict();
importState.set('busy', false);
importState.set('error', '');
importState.set('done', '');
// A WeKan export is either the document itself or a .zip with the document
// inside it. Reading the .zip in the BROWSER is what keeps the server out of it:
// the method takes the same object either way, so there is one import path and
// not two.
async function readExportFile(file) {
const name = String(file.name || '').toLowerCase();
if (!name.endsWith('.zip')) {
// A .json already carries its attachments as base64 under `file`, which is
// what the importer reads.
return JSON.parse(await file.text());
}
const JSZip = (await import('jszip')).default;
const zip = await JSZip.loadAsync(file);
const entry = zip.file('wekan.json') || zip.file(/wekan\.json$/)[0];
if (!entry) throw new Error('import-not-wekan-export');
const doc = JSON.parse(await entry.async('string'));
// The FILES beside the document. The export writes them as
// `attachments/<attachmentId>-<name>`, and the id is what ties a file back to
// its metadata row - two attachments called "photo.png" are two entries.
//
// They go onto the SAME `file` field a .json export uses, so the server has
// one import path rather than one per container. That is also the tradeoff to
// know about: the archive is unpacked in the browser and sent as base64, so a
// .zip full of large attachments costs memory on the way in. It is the shape
// the board import has always used, and the reason to reach for a .zip is
// still the same - the JSON of a board with many attachments may be too large
// for one string, while its archive is not.
const byId = new Map(
(Array.isArray(doc.attachments) ? doc.attachments : [])
.filter(attachment => attachment && attachment._id)
.map(attachment => [attachment._id, attachment]),
);
const files = zip.file(/^attachments\//);
for (const archived of files || []) {
const base = archived.name.slice('attachments/'.length);
const dash = base.indexOf('-');
const attachmentId = dash === -1 ? base : base.slice(0, dash);
const attachment = byId.get(attachmentId);
// A file whose row is not in the document is one the selection left out, or
// a hand-made archive: skipping it is better than inventing a row for it.
if (!attachment) continue;
attachment.file = await archived.async('base64');
}
return doc;
}
// A .json is small enough to travel as a document: it is parsed here and sent
// over DDP, which is the path the per-menu import has always used.
function importJsonDocument(doc, target) {
return new Promise((resolve, reject) => {
Meteor.call('importScoped', target, doc, selectedFields(),
(err, res) => (err ? reject(err) : resolve(res)));
});
}
// POST the archive itself to /api/import/zip. `fetch` streams a File body, so
// the browser does not hold a copy either.
async function uploadZipImport(file, target) {
const params = new URLSearchParams({
authToken: Accounts._storedLoginToken() || '',
boardId: target.boardId,
fields: selectedFields().join(','),
});
for (const key of ['swimlaneId', 'listId', 'cardId', 'checklistId']) {
if (target[key]) params.set(key, target[key]);
}
const response = await fetch(`/api/import/zip?${params.toString()}`, {
method: 'POST',
headers: { 'Content-Type': 'application/zip' },
body: file,
});
const answer = await response.json().catch(() => ({}));
if (!response.ok || !answer.ok) {
throw new Error(answer.error || 'import-scoped-failed');
}
return answer.counts;
}
// EVERY format this popup offers, in one table, for every scope.
//
// It was two markups: the board popup wrote its own list of nineteen formats in
// sidebar.jade under four subheadings, and the swimlane / list / card popups
// wrote five in exportScope.jade - so "the export popup" meant two different
// menus with two different looks, and a format added to one was missing from
// the other. One table, rendered by one template, is the answer to both.
//
// `scopes: ['board']` marks a format that only makes sense for a whole board -
// the HTML archive, the dependency graph, the CSV columns, and the exports
// written for another tool. Everything else is offered wherever the popup is,
// because `exportUrl` already carries the scope: a swimlane, a list and a card
// each differ from a board by a query parameter, not by a route.
//
// An entry is either a LINK (`path`, downloaded straight from the API) or an
// ACTION (`action`, a class an event handler is bound to). Nothing else varies.
const BOARD_ONLY = ['board'];
const EXPORT_FORMAT_GROUPS = [
{
key: 'files',
entries: [
{ key: 'pdf', icon: 'fa-file-pdf-o', label: 'PDF', path: 'exportPDF', ext: 'pdf' },
{ key: 'excel', icon: 'fa-file-excel-o', label: 'Excel', path: 'exportExcel', ext: 'xlsx' },
{ key: 'html', icon: 'fa-archive', label: 'HTML', action: 'html-export-board', scopes: BOARD_ONLY },
],
},
{
key: 'dependencies',
headingKey: 'card-dependencies',
scopes: BOARD_ONLY,
entries: [
{ key: 'dep-json', icon: 'fa-link', label: 'JSON', action: 'js-export-dependencies-json' },
{ key: 'dep-svg', icon: 'fa-link', label: 'SVG', action: 'js-export-dependencies-svg' },
],
},
{
key: 'csv',
heading: 'CSV',
scopes: BOARD_ONLY,
entries: [
{ key: 'csv', icon: 'fa-upload', label: '(,)', path: 'export/csv', ext: 'csv', query: { delimiter: ',' } },
{ key: 'scsv', icon: 'fa-upload', label: '(;)', path: 'export/csv', ext: 'csv', query: { delimiter: ';' } },
{ key: 'tsv', icon: 'fa-upload', label: 'TSV', path: 'export/csv', ext: 'tsv', query: { delimiter: '\t' } },
],
},
{
key: 'json',
heading: 'JSON',
entries: [
{ key: 'json', icon: 'fa-file-code-o', label: 'JSON', path: 'export', ext: 'json' },
{
key: 'json-no-attachments',
icon: 'fa-file-code-o',
labelKey: 'export-board-without-attachments',
labelPrefix: 'JSON',
path: 'export',
ext: 'json',
query: { attachments: 'false' },
},
{
key: 'zip',
icon: 'fa-file-archive-o',
labelKey: 'attachments',
labelPrefix: '.zip',
path: 'exportZip',
ext: 'zip',
},
{ key: 'kanboard', icon: 'fa-upload', label: 'Kanboard', path: 'export/kanboard', ext: 'json', scopes: BOARD_ONLY },
...[
['trello', 'Trello'], ['jira', 'Jira'], ['deck', 'NextCloud Deck'],
['openproject', 'OpenProject'], ['github', 'GitHub'], ['gitlab', 'GitLab'],
['gitea', 'Gitea'], ['forgejo', 'Forgejo'], ['asana', 'Asana'],
['zenkit', 'Zenkit'],
].map(([format, label]) => ({
key: format,
icon: 'fa-upload',
label,
path: `export/${format}`,
ext: 'json',
scopes: BOARD_ONLY,
})),
],
},
];
// Is this popup a whole board, or a swimlane / list / card inside one?
function isBoardScope() {
const data = Template.currentData() || {};
// checklistId too: the checklist popup is a scope of its own, and a scope
// that is not named here would be read as "a whole board" and offered the
// HTML archive, the dependency graph and the CSV columns of one.
return !data.swimlaneId && !data.listId && !data.cardId && !data.checklistId;
}
function entryApplies(entry) {
return !entry.scopes || entry.scopes.includes('board') === isBoardScope();
}
// The table above, resolved for the scope this popup was opened with: each entry
// carries the URL and the filename it downloads, so the template only lays them
// out. A group whose every entry is out of scope is dropped, heading and all.
function resolvedFormatGroups() {
return EXPORT_FORMAT_GROUPS
.filter(group => !group.scopes || group.scopes.includes('board') === isBoardScope())
.map(group => ({
key: group.key,
heading: group.headingKey ? TAPi18n.__(group.headingKey) : group.heading || '',
entries: group.entries.filter(entryApplies).map(entry => ({
...entry,
label: entry.labelKey
? `${entry.labelPrefix} (${TAPi18n.__(entry.labelKey)})`
: entry.label,
url: entry.path ? exportUrl(`/api/boards/:boardId/${entry.path}`, entry.query || {}) : '',
filename: entry.ext ? exportFilename(entry.ext) : '',
})),
}))
.filter(group => group.entries.length);
}
// Import writes to the board, so unlike export it is offered only to somebody
// who may change it. A named function rather than a helper body, because the
// menus that offer the Import row have to ask the same question - four copies of
// four permission checks is how one of them ends up offering a row that then
// refuses to do anything.
function canImportIntoBoard() {
const user = ReactiveCache.getCurrentUser();
return Boolean(user && !user.isWorker && !user.isCommentOnly
&& !user.isReadOnly && !user.isReadAssignedOnly);
}
Template.registerHelper('canImportIntoBoard', canImportIntoBoard);
const scopeHelpers = {
canImport() {
return canImportIntoBoard();
},
// Which half of the popup this is. One body, two modes: EXPORT shows the
// formats to download, IMPORT shows the file to read - and both keep the
// "what to include" pane, because that selection means the same thing in
// both directions (#1173).
isImportMode() {
const data = Template.currentData() || {};
return data.mode === 'import';
},
importBusy() { return importState.get('busy'); },
importError() { return importState.get('error'); },
importDone() { return importState.get('done'); },
exportFields() {
return BOARD_EXPORT_FIELDS.map(({ field, label }) => ({
field,
label,
checked: selection.get(field),
}));
},
cardDetailsChecked() {
return selection.get('card-details');
},
// Every format, resolved for this popup's scope - see EXPORT_FORMAT_GROUPS.
// #5870's "JSON without the base64 attachment data" is one of them, offered
// wherever an export is offered rather than only on the board menu.
formatGroups() {
return resolvedFormatGroups();
},
};
// One helper object, registered on both halves: the checkbox list is its own
// template so the layout can put it in a pane, and it asks the same questions.
Template.exportScopeBody.helpers(scopeHelpers);
Template.exportScopeSelect.helpers(scopeHelpers);
Template.exportScopeBody.events({
async 'change .js-import-file'(event) {
const file = event.currentTarget.files && event.currentTarget.files[0];
if (!file) return;
const data = Template.currentData() || {};
const target = {
boardId: Session.get('currentBoard'),
...currentScope(),
};
// A card's menu imports below THAT card, so the target is the card the popup
// was opened on - the same id the export side uses as its scope.
if (data.cardId) target.cardId = data.cardId;
importState.set('error', '');
importState.set('done', '');
importState.set('busy', true);
try {
const counts = String(file.name || '').toLowerCase().endsWith('.zip')
// A .zip goes to the server AS A FILE. Unpacking it here and sending
// base64 over DDP is what made a large archive expensive: 2 GB of
// attachments became 2.7 GB in one message. The route streams it to
// disk, reads the archive's directory, and pipes one attachment at a
// time into storage.
? await uploadZipImport(file, target)
: await importJsonDocument(await readExportFile(file), target);
importState.set('done', Object.entries(counts || {})
.filter(([, n]) => n > 0)
.map(([what, n]) => `${n} ${what}`)
.join(', ') || '0');
} catch (error) {
const message = error && error.message === 'import-not-wekan-export'
? 'import-not-wekan-export'
: 'import-scoped-failed';
importState.set('error', message);
// The reason, for somebody reading the console - the popup has room for a
// sentence, not for a stack.
console.error('scoped import failed', error);
} finally {
importState.set('busy', false);
event.currentTarget.value = '';
}
},
'click .js-export-field-toggle'(event) {
// The popup stays open: choosing five sections should not be five reopens.
event.preventDefault();
const field = event.currentTarget.dataset.field;
selection.set(field, !selection.get(field));
},
'click .js-export-card-details-toggle'(event) {
event.preventDefault();
selection.set('card-details', !selection.get('card-details'));
},
});
// `exportUrlFor` is what the board popup uses to build the same URLs with the
// same selection - one query string, built in one place, whichever popup asks.
export {
selectedFields,
selection,
readExportFile,
exportUrl as exportUrlFor,
exportFilename as exportFilenameFor,
};
@@ -100,7 +100,7 @@
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-size: calc(11px * var(--wekan-ui-font-scale, 1));
font-weight: 500;
text-transform: uppercase;
}
@@ -112,7 +112,7 @@
.entity-id {
color: #6c757d;
font-size: 11px;
font-size: calc(11px * var(--wekan-ui-font-scale, 1));
font-family: monospace;
}
@@ -123,12 +123,12 @@
.original-position-description {
color: #495057;
margin-bottom: 6px;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
}
.original-title {
color: #6c757d;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
margin-bottom: 6px;
padding: 4px 6px;
background-color: #e9ecef;
@@ -141,7 +141,7 @@
.original-position-date {
color: #6c757d;
font-size: 11px;
font-size: calc(11px * var(--wekan-ui-font-scale, 1));
}
.no-original-positions {
@@ -152,7 +152,7 @@
}
.no-original-positions i {
font-size: 24px;
font-size: calc(24px * var(--wekan-ui-font-scale, 1));
margin-bottom: 10px;
display: block;
color: #adb5bd;
+2 -2
View File
@@ -16,7 +16,7 @@
.stats-view-title {
margin: 0 0 16px;
font-size: 22px;
font-size: calc(22px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
}
@@ -26,7 +26,7 @@
.stats-view-heading {
margin: 0 0 8px;
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
border-bottom: 2px solid #dbdbdb;
padding-bottom: 4px;
+6 -6
View File
@@ -125,7 +125,7 @@
border-radius: 4px;
padding: 12px;
margin: 8px 0;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
}
.upload-progress-header {
@@ -187,7 +187,7 @@
.upload-progress-success {
display: flex;
align-items: center;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: 500;
}
@@ -208,7 +208,7 @@
.minicard-upload-progress {
margin: 4px 0;
padding: 8px;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
}
.minicard-upload-progress .upload-progress-item {
@@ -217,7 +217,7 @@
}
.minicard-upload-progress .upload-progress-filename {
font-size: 11px;
font-size: calc(11px * var(--wekan-ui-font-scale, 1));
}
/* Card details specific styles */
@@ -227,7 +227,7 @@
}
.card-details-upload-progress .upload-progress-header {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
margin-bottom: 12px;
}
@@ -237,7 +237,7 @@
}
.card-details-upload-progress .upload-progress-filename {
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
}
/* Drag over state for minicards */
+14 -41
View File
@@ -7,6 +7,7 @@ import uploadProgressManager from '../../lib/uploadProgressManager';
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
import Attachments from '/models/attachments';
import { Utils } from '/client/lib/utils';
import { buildAttachmentUploadConfig } from '/client/lib/attachmentUploadConfig';
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
import { formatDateTime } from '/imports/lib/dateUtils';
import { EscapeActions } from '/client/lib/escapeActions';
@@ -445,38 +446,16 @@ export async function handleFileUpload(card, files) {
continue;
}
const fileId = new ObjectId().toString();
let fileName = sanitizeText(file.name);
// If sanitized filename is not same as original filename,
// it could be XSS that is already fixed with sanitize,
// or just normal mistake, so it is not a problem.
// That is why here is no warning.
if (fileName !== file.name) {
// If filename is empty, only in that case add some filename
if (fileName.length === 0) {
fileName = 'Empty-filename-after-sanitize.txt';
}
}
const config = {
file: file,
fileId: fileId,
fileName: fileName,
// The id, the sanitized name, the chunk size and the transport all come
// from one builder - the file id has to be stamped into `meta` for
// Attachments' namingFunction to find it, and the transport has to be DDP
// on Sandstorm and HTTP everywhere else. Both are easy to leave out by hand
// and neither says so until an upload silently does not arrive.
// client/lib/attachmentUploadConfig.js
const config = buildAttachmentUploadConfig({
file,
meta: Utils.getCommonAttachmentMetaFrom(card),
chunkSize: 'dynamic',
// Use HTTP transport (a dedicated fetch POST per chunk) rather than DDP,
// which floods the WebSocket channel and causes repeated reconnects in
// Safari (the "Loading, please wait" offline banner, stalling at ~95%).
// EXCEPT on Sandstorm: the grain's sandstorm-http-bridge strips Meteor-Files'
// custom upload headers — x-start/x-mtok/x-chunkid/x-fileid/x-eof are not on
// Sandstorm's request-header whitelist — so the server never sees x-start,
// treats every request as a chunk continuation, and fails with "Can't
// continue upload, session expired" [408]. DDP uploads go over method calls
// (no custom HTTP headers), so they work through the bridge.
transport: Meteor.settings?.public?.sandstorm ? 'ddp' : 'http',
};
config.meta.fileId = fileId;
});
try {
const uploader = await Attachments.insertAsync(
@@ -559,18 +538,12 @@ Template.previewClipboardImagePopup.events({
if (pastedResults && pastedResults.file) {
const file = pastedResults.file;
window.oPasted = pastedResults;
const fileId = new ObjectId().toString();
const config = {
// Same builder as the file-picker upload above; a pasted image has no
// name of its own, which the builder answers with the image's type.
const config = buildAttachmentUploadConfig({
file,
fileId: fileId,
meta: Utils.getCommonAttachmentMetaFrom(card),
fileName: file.name || file.type.replace('image/', 'clipboard.'),
chunkSize: 'dynamic',
// DDP on Sandstorm (its bridge strips Meteor-Files' x-* upload headers),
// HTTP elsewhere — see the note in the other upload handler above.
transport: Meteor.settings?.public?.sandstorm ? 'ddp' : 'http',
};
config.meta.fileId = fileId;
});
const uploader = await Attachments.insertAsync(
config,
false,
+17 -20
View File
@@ -1,16 +1,25 @@
//- The card's custom fields, from the hamburger at the end of the Custom Fields
heading: every field the BOARD has, with a tick when it is on this card, a
pencil to edit the field itself, and - under a rule - the one that makes a new
one. Everything about custom fields, in the place they are shown.
//-
//- Edit and Add open in this same pop-over, on top of this list, so the back
arrow returns to it. They are the board's own forms
(client/components/sidebar/sidebarCustomFields.jade), not second copies.
template(name="cardCustomFieldsPopup")
ul.pop-over-list
each board.customFields
li.item(class="")
a.name.js-select-field(href="#")
li.item
a.name.js-select-field(href="#" title="{{_ 'show-field-on-card'}}")
i.fa(class="{{#if hasCustomField}}fa-check-square-o{{else}}fa-square-o{{/if}}")
span.full-name
= name
if hasCustomField
i.fa.fa-check
a.js-edit-custom-field(title="{{_ 'edit'}}")
i.fa.fa-pencil
hr
a.quiet-button.full.js-settings
i.fa.fa-cog
span {{_ 'settings'}}
a.quiet-button.full.js-open-create-custom-field
i.fa.fa-plus
span {{_ 'createCustomField'}}
template(name="cardCustomField")
+Template.dynamic(template=getTemplate)
@@ -99,19 +108,7 @@ template(name="cardCustomField-date")
| {{showWeek}}
template(name="cardCustomField-datePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
+editDateForm(showDate=showDate showTime=showTime error=error)
template(name="cardCustomField-dropdown")
if canModifyCard
+13 -9
View File
@@ -30,8 +30,6 @@ import {
import { CustomFieldStringTemplate } from '/client/lib/customFields'
import { getCurrentCardFromContext } from '/client/lib/currentCard';
import { formatNumberValue } from '/imports/lib/customNumberFormat';
import { EscapeActions } from '/client/lib/escapeActions';
import { getSidebarInstance } from '/client/features/sidebar/service';
Template.cardCustomFieldsPopup.helpers({
hasCustomField() {
@@ -50,13 +48,19 @@ Template.cardCustomFieldsPopup.events({
card.toggleCustomField(customFieldId);
event.preventDefault();
},
'click .js-settings'(event) {
EscapeActions.executeUpTo('detailsPane');
const sidebar = getSidebarInstance();
if (sidebar) {
sidebar.setView('customFields');
}
event.preventDefault();
// Editing a field, and making one, open in this same pop-over on top of the
// list, so the back arrow returns to it and the card stays open behind. They
// are the board's own forms - the same templates the sidebar list opens - so
// there is one create form and one edit form, not a second pair for cards.
// client/components/sidebar/sidebarCustomFields.jade
'click .js-edit-custom-field': Popup.open('editCustomField'),
// "Add custom field" sits OUTSIDE the list, so its data context is this
// popup's own - which is the CARD. The form reads its context as the field
// being edited, and a card has an `_id`, so it was read as an edit of a
// custom field that does not exist and the new field was never inserted.
// A new field is made from nothing, so it is handed nothing.
'click .js-open-create-custom-field'(event) {
Popup.open('createCustomField').call({}, event);
},
});
+41 -239
View File
@@ -1,289 +1,91 @@
template(name="dateBadge")
//- One date badge: the coloured date on a card or a minicard. Twelve templates
drew this, letter for letter - Received, Start, Due and End on the card and
again on the minicard, a vote's end, a poker's end and a custom field's date -
and each of them still has its own helpers and its own click, which is the
part that differs. The markup did not.
//-
//- It takes what it draws as ARGUMENTS: a helper is looked up on the template
it is written in, not on the one including it, so passing the values is what
lets one piece of markup serve twelve templates without moving any of their
state.
//-
//- `baseClass` is the class the caller's own markup carried - `card-date`, plus
`due-date` and friends on a minicard. A custom field's date passes NONE: it
was never a `.card-date` and must not become one now.
//- `canModifyCard` decides whether the badge is a link that opens the date
editor; a custom field's date is never one, and passes nothing.
template(name="dateBadgeBody")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
a.js-edit-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{baseClass}} {{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
a(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{baseClass}} {{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
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")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
+dateBadgeBody(canModifyCard=canModifyCard baseClass="card-date" showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="cardStartDate")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
+dateBadgeBody(canModifyCard=canModifyCard baseClass="card-date" showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="cardDueDate")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
+dateBadgeBody(canModifyCard=canModifyCard baseClass="card-date" showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="cardEndDate")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
+dateBadgeBody(canModifyCard=canModifyCard baseClass="card-date" showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="dateCustomField")
a(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
+dateBadgeBody(showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="cardCustomFieldDate")
a(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
+dateBadgeBody(showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="voteEndDate")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
+dateBadgeBody(canModifyCard=canModifyCard baseClass="card-date" showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="pokerEndDate")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
+dateBadgeBody(canModifyCard=canModifyCard baseClass="card-date" showTitle=showTitle classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="minicardReceivedDate")
if canModifyCard
a.js-edit-date.card-date.received-date(title="{{_ 'card-received'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date.received-date(title="{{_ 'card-received'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
+dateBadgeBody(canModifyCard=canModifyCard showTitle="{{_ 'card-received'}}" baseClass="card-date received-date" classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="minicardStartDate")
if canModifyCard
a.js-edit-date.card-date.start-date(title="{{_ 'card-start'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date.start-date(title="{{_ 'card-start'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
+dateBadgeBody(canModifyCard=canModifyCard showTitle="{{_ 'card-start'}}" baseClass="card-date start-date" classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="minicardDueDate")
if canModifyCard
a.js-edit-date.card-date.due-date(title="{{_ 'card-due'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date.due-date(title="{{_ 'card-due'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
+dateBadgeBody(canModifyCard=canModifyCard showTitle="{{_ 'card-due'}}" baseClass="card-date due-date" classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="minicardEndDate")
if canModifyCard
a.js-edit-date.card-date.end-date(title="{{_ 'card-end'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date.end-date(title="{{_ 'card-end'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
+dateBadgeBody(canModifyCard=canModifyCard showTitle="{{_ 'card-end'}}" baseClass="card-date end-date" classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="minicardCustomFieldDate")
a(title="{{_ 'date'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
+dateBadgeBody(showTitle="{{_ 'date'}}" classes=classes showISODate=showISODate showDate=showDate showWeekOfYear=showWeekOfYear showWeek=showWeek)
template(name="editCardReceivedDatePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
+editDateForm(showDate=showDate showTime=showTime error=error)
template(name="editCardStartDatePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
+editDateForm(showDate=showDate showTime=showTime error=error)
template(name="editCardDueDatePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
+editDateForm(showDate=showDate showTime=showTime error=error)
template(name="editCardEndDatePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
+editDateForm(showDate=showDate showTime=showTime error=error)
template(name="editVoteEndDatePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
+editDateForm(showDate=showDate showTime=showTime error=error)
template(name="editPokerEndDatePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
+editDateForm(showDate=showDate showTime=showTime error=error)
+156 -19
View File
@@ -4,7 +4,7 @@
}
.card-details-item-date-format .card-details-item-title {
font-size: 15px;
font-size: calc(15px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
margin-bottom: 6px;
color: #333;
@@ -17,7 +17,7 @@
border: 1px solid #ddd;
border-radius: 5px;
background-color: #fff;
font-size: 15px;
font-size: calc(15px * var(--wekan-ui-font-scale, 1));
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
@@ -267,7 +267,7 @@ body.desktop-mode .card-details.card-details-collapsed {
/* Collapse toggle triangle */
.card-details .card-details-header .card-collapse-toggle {
float: inline-start;
font-size: 20px;
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
padding: 7px 10px;
margin-inline-start: -10px;
margin-inline-end: 5px;
@@ -279,7 +279,7 @@ body.desktop-mode .card-details.card-details-collapsed {
}
.card-details .card-details-header .card-drag-handle {
font-size: 20px;
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
padding: 8px 10px;
margin-inline-end: 10px;
cursor: move;
@@ -304,7 +304,7 @@ body.desktop-mode .card-details.card-details-collapsed {
.card-details .card-details-header .close-card-details,
.card-details .card-details-header .maximize-card-details,
.card-details .card-details-header .minimize-card-details {
font-size: 24px;
font-size: calc(24px * var(--wekan-ui-font-scale, 1));
padding: 5px 10px 5px 10px;
margin-inline-end: -8px;
cursor: pointer;
@@ -315,37 +315,37 @@ body.desktop-mode .card-details.card-details-collapsed {
}
.card-details .card-details-header .close-card-details-mobile-web,
.card-details .card-details-header .card-mobile-desktop-toggle {
font-size: 24px;
font-size: calc(24px * var(--wekan-ui-font-scale, 1));
padding: 5px;
margin-inline-end: 5px;
cursor: pointer;
user-select: none;
}
.card-details .card-details-header .card-copy-button {
font-size: 17px;
font-size: calc(17px * var(--wekan-ui-font-scale, 1));
padding: 10px;
margin-inline-end: 10px;
}
.card-details .card-details-header .card-copy-mobile-button {
font-size: 17px;
font-size: calc(17px * var(--wekan-ui-font-scale, 1));
padding: 10px;
margin-inline-end: 10px;
}
.card-details .card-details-header .card-details-menu {
font-size: 17px;
font-size: calc(17px * var(--wekan-ui-font-scale, 1));
padding: 10px;
vertical-align: middle;
line-height: 1.2;
}
.card-details .card-details-header .card-details-menu-mobile-web {
font-size: 17px;
font-size: calc(17px * var(--wekan-ui-font-scale, 1));
padding: 10px;
margin-inline-end: 30px;
}
.card-details .card-details-header .card-mobile-desktop-toggle,
.card-details .card-details-header .card-zoom-in,
.card-details .card-details-header .card-zoom-out {
font-size: 24px;
font-size: calc(24px * var(--wekan-ui-font-scale, 1));
padding: 5px 10px 5px 10px;
margin-inline-end: 5px;
cursor: pointer;
@@ -362,7 +362,7 @@ body.desktop-mode .card-details.card-details-collapsed {
font-size: 1em;
}
.card-details .card-details-header .card-details-watch {
font-size: 17px;
font-size: calc(17px * var(--wekan-ui-font-scale, 1));
padding-inline-start: 7px;
color: #a6a6a6;
}
@@ -375,6 +375,46 @@ body.desktop-mode .card-details.card-details-collapsed {
vertical-align: middle;
line-height: 1.3;
letter-spacing: 0.01em;
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.
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). */
.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%;
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. */
.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%;
}
.card-details .card-details-header .linked-card-location {
font-style: italic;
@@ -454,7 +494,7 @@ body.desktop-mode .card-details.card-details-collapsed {
padding-inline-start: 10px;
}
.card-details .card-details-item-title {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
color: #4d4d4d;
}
@@ -920,7 +960,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
box-sizing: border-box;
padding: 5px;
margin: 3px;
font-size: 20px;
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
text-shadow: #2d2d2d 1px 1px 0;
box-shadow: 0 0 5px #aaa;
@@ -946,8 +986,15 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
}
/* Sticker picker + card-detail stickers + due-complete toggle */
/* The picker fills the popup it is in rather than stopping at eight columns:
the popup is wide now (popup.css), and a fixed 320px there would have left
the extra width empty while the icons still ran off the bottom. A grid that
fits as many 40px cells as there is room for turns that width into rows the
reader does not have to scroll past. */
.card-stickers-picker {
max-width: 320px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
justify-items: center;
}
.card-sticker-select {
display: inline-flex;
@@ -957,7 +1004,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
height: 36px;
margin: 2px;
border-radius: 3px;
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
color: #4d4d4d;
cursor: pointer;
position: relative;
@@ -973,18 +1020,18 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
position: absolute;
bottom: 1px;
inset-inline-end: 2px;
font-size: 10px;
font-size: calc(10px * var(--wekan-ui-font-scale, 1));
color: #3cb500;
}
.card-stickers .card-sticker {
display: inline-flex;
align-items: center;
margin: 0 8px 4px 0;
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
}
.card-sticker .card-sticker-remove {
margin-inline-start: 3px;
font-size: 11px;
font-size: calc(11px * var(--wekan-ui-font-scale, 1));
color: #999;
cursor: pointer;
}
@@ -1146,3 +1193,93 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
user-select: none;
margin: 8px 0;
}
/* #1591 gave every FIELD of the opened card a caret of its own on its title,
and hid everything after that title when it was folded. The section carets
replaced it - one handle per section, on the heading - and the two ran at the
same time: clicking a section's caret also folded the field the heading was
drawn on, and with the field's own caret gone there was nothing left to open
it. That is where the Date Format select went. Both halves are gone from
here; client/components/cards/cardDetails.js says the same at the handler. */
/* Collapsible card sections.
Every section of an opened card - Labels, Date Format, Members, Dependencies,
Sort, Custom Fields, Description, Checklists, Subtasks, Attachments, Comments
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 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
gone with the grouping; a field that is its own section is a group of one. */
.card-details .card-details-section-rule {
width: 100%;
margin: 14px 0 6px 0;
}
.card-details h3.card-details-item-title.js-toggle-card-section {
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 6px;
}
/* The hamburger that opens a section's settings, at the FAR end of its heading
- `margin-inline-start: auto` in a flex row, so it mirrors itself in a
right-to-left page. It is inside a heading that folds the section when
clicked, so its own handler stops the click there. */
.card-details h3.card-details-item-title .card-details-section-menu {
margin-inline-start: auto;
padding: 0 4px;
color: #808080;
cursor: pointer;
}
.card-details h3.card-details-item-title .card-details-section-menu:hover {
color: #000;
}
/* The caret is the widest part of the heading that must not move as the label
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 {
width: 12px;
text-align: center;
flex: 0 0 auto;
}
.card-details h3.card-details-item-title.js-toggle-card-section:hover {
opacity: 0.85;
}
/* The checklist "3/7" line that used to be a second h3 under the Checklists
heading: a subtitle, so it is not read as another section. */
.card-details .card-details-item-subtitle {
margin: 0 0 4px 0;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
font-weight: 400;
opacity: 0.8;
}
/* A GROUP of card fields - Labels with Stickers and Location, Date Format with
the four dates, Members with Assignee and Creator, and so on.
The group's own title carries the caret that folds it, and the fields inside
it do NOT: #1591 gives every .card-details-item a caret of its own, drawn on
the title by the rule further up this file, and inside a group that is a
second caret on every row saying the same thing in a smaller way. One handle
per group is what was asked for, and it is the group's.
The fields keep sharing rows inside the group, which is how they were laid
out - the group is a full-width row, the fields inside it wrap within it. */
.card-details .card-details-items .card-details-group {
flex: 0 0 100%;
width: 100%;
}
.card-details .card-details-group-body {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
}
.card-details .card-details-group-body .card-details-item {
flex-grow: 1;
}
File diff suppressed because it is too large. Load diff
+201 -128
View File
@@ -1,5 +1,6 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { ReactiveDict } from 'meteor/reactive-dict';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { ReactiveVar } from 'meteor/reactive-var';
import {
@@ -72,6 +73,8 @@ import { EscapeActions } from '/client/lib/escapeActions';
import { MultiSelection } from '/client/lib/multiSelection';
import { Utils } from '/client/lib/utils';
import autosize from 'autosize';
import { cardMenuSource, setCardMenuSource } from '/client/lib/cardMenuSource';
import { caretClassFor } from '/client/lib/sectionCaret';
// Id of the location currently being edited in the cardLocationsPopup; null
// when adding a new location.
@@ -828,7 +831,101 @@ 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.
// 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.
//
// The state is module-level, so it survives closing and reopening a card in the
// same session: somebody who works with the description collapsed wants it
// collapsed on the next card too, not just on this one.
//
// ACTIVITIES starts CLOSED and everything else open. A card is opened to read
// the card; its history is the thing you go looking for.
const CARD_SECTIONS = [
'labels', 'date-format', 'members', 'dependencies', 'sort', 'custom-fields',
'description', 'checklists', 'subtasks', 'attachments', 'comments', 'activities',
];
const COLLAPSED_BY_DEFAULT = ['activities'];
const cardSectionState = new ReactiveDict();
CARD_SECTIONS.forEach(section => {
cardSectionState.set(section, !COLLAPSED_BY_DEFAULT.includes(section));
});
function isCardSectionOpen(section) {
const state = cardSectionState.get(section);
// An unknown section is open: a new one must show up, not hide silently.
return state === undefined ? true : state;
}
Template.registerHelper('isSectionOpen', isCardSectionOpen);
// "Attachments (3)" - the count the heading carried before it became a section,
// and only when the board asks for it.
Template.registerHelper('attachmentCount', function attachmentCount() {
const card = Utils.getCurrentCard();
const board = card && card.board && card.board();
if (!board || !board.allowsAttachmentCountOnCard) return '';
const attachments = card.attachments && card.attachments();
return attachments && attachments.length ? attachments.length : '';
});
// Which way the caret points. The rule - and the reason - is in
// client/lib/sectionCaret.js, shared with the board sidebar's Activities, which
// is the same control and must not point the other way in the same language.
Template.registerHelper('sectionCaret', section =>
caretClassFor(isCardSectionOpen(section)));
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) {
event.preventDefault();
event.stopPropagation();
Popup.open('boardSubtaskSettings')(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
// context, which is what the ticks and the toggle read.
// client/components/cards/cardCustomFields.jade
'click .js-open-custom-fields-settings'(event) {
event.preventDefault();
event.stopPropagation();
Popup.open('cardCustomFields').call(Utils.getCurrentCard(), event);
},
'click .js-toggle-card-section'(event) {
event.preventDefault();
const section = event.currentTarget.dataset.section;
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.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
const section = event.currentTarget.dataset.section;
if (section) cardSectionState.set(section, !isCardSectionOpen(section));
},
});
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
// heading, and every field lives in a section now. Both were still running,
// and they fought - clicking a section's caret also ran the old handler, so
// the field it was drawn on was left `is-collapsed` with nothing to open it
// again, and the Date Format select vanished for good. The old half is gone;
// the same store is still the CHECKLISTS' (client/components/cards/checklists.js),
// which is why it is not.
[`${CSSEvents.transitionend} .js-card-details`](event, tpl) {
tpl.isLoaded.set(true);
},
@@ -878,11 +975,23 @@ Template.cardDetails.events({
$(document).on('mouseup', onMouseUp);
},
'mousedown .js-card-title-drag-handle'(event) {
// Allow dragging from title for ReadOnly users
// Don't interfere with text selection
// The title bar drags the window. Don't interfere with text selection.
if (event.target.tagName === 'A' || $(event.target).closest('a').length > 0) {
return; // Don't drag if clicking on links
}
// ...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');
@@ -969,7 +1078,12 @@ Template.cardDetails.events({
window.localStorage.setItem('dateFormat', dateFormat);
}
},
'click .js-open-card-details-menu': Popup.open('cardDetailsActions'),
// The opened card's own hamburger. It says so, so the menu's first entry is
// "Show on Card" rather than the minicard's "Show on Minicard".
'click .js-open-card-details-menu'(event) {
setCardMenuSource('card');
Popup.open('cardDetailsActions').call(this, event);
},
// Mobile: switch to desktop popup view (maximize)
'click .js-mobile-switch-to-desktop'(event) {
event.preventDefault();
@@ -1165,7 +1279,6 @@ Template.cardDetails.events({
'click .js-end-date': Popup.open('editCardEndDate'),
'click .js-show-positive-votes': Popup.open('positiveVoteMembers'),
'click .js-show-negative-votes': Popup.open('negativeVoteMembers'),
'click .js-custom-fields': Popup.open('cardCustomFields'),
'mouseenter .js-card-details'(event, tpl) {
const boardBody = getBoardBodyInstance(tpl);
if (boardBody === null) return;
@@ -1189,10 +1302,6 @@ Template.cardDetails.events({
const card = Template.currentData();
await card.toggleHideCheckedChecklistItems();
},
async 'click .js-toggle-show-activities-card'() {
const card = Template.currentData();
await card.toggleShowActivities();
},
'click #toggleCustomFieldsGridButton'() {
Meteor.call('toggleCustomFieldsGrid');
},
@@ -1394,90 +1503,9 @@ Template.cardDetailsPopup.helpers({
},
});
// Ordered list of Excel export field keys and their i18n label keys.
// Must match ALL_FIELDS in models/server/ExporterExcelCard.js.
const EXCEL_EXPORT_FIELDS = [
{ field: 'labels', label: 'labels' },
{ field: 'people', label: 'export-card-field-people' },
{ field: 'board-info', label: 'export-card-field-board-info' },
{ field: 'dates', label: 'export-card-field-dates' },
{ field: 'description', label: 'description' },
{ field: 'checklists', label: 'checklists' },
{ field: 'subtasks', label: 'export-card-subtasks' },
{ field: 'comments', label: 'comments' },
{ field: 'attachments', label: 'attachments' },
];
Template.exportCardPopup.onCreated(function () {
// Track which Excel sections the user wants to include (all on by default)
const initial = {};
EXCEL_EXPORT_FIELDS.forEach(({ field }) => { initial[field] = true; });
this.excelFields = new ReactiveDict(initial);
});
Template.exportCardPopup.helpers({
exportUrlCardPDF() {
const card = getCurrentCardFromContext({ ignorePopupCard: true }) || this;
const params = {
boardId: card.boardId || Session.get('currentBoard'),
listId: card.listId,
cardId: card._id || card.cardId,
};
return FlowRouter.path(
'/api/boards/:boardId/lists/:listId/cards/:cardId/exportPDF',
params,
{ authToken: Accounts._storedLoginToken() },
);
},
exportFilenameCardPDF() {
const card = getCurrentCardFromContext({ ignorePopupCard: true }) || this;
return `${String(card.title || 'export-card')
.replace(/[^a-z0-9._-]+/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'export-card'}.pdf`;
},
// Returns the field list with current checked state — reactive
excelExportFields() {
const instance = Template.instance();
return EXCEL_EXPORT_FIELDS.map(f => ({
field: f.field,
label: f.label,
checked: instance.excelFields.get(f.field),
}));
},
exportUrlCardExcel() {
const instance = Template.instance();
const card = getCurrentCardFromContext({ ignorePopupCard: true }) || this;
const params = {
boardId: card.boardId || Session.get('currentBoard'),
listId: card.listId,
cardId: card._id || card.cardId,
};
const selectedFields = EXCEL_EXPORT_FIELDS
.map(f => f.field)
.filter(f => instance.excelFields.get(f));
return FlowRouter.path(
'/api/boards/:boardId/lists/:listId/cards/:cardId/exportExcel',
params,
{ authToken: Accounts._storedLoginToken(), fields: selectedFields.join(','), lang: TAPi18n.getLanguage() },
);
},
exportFilenameCardExcel() {
const card = getCurrentCardFromContext({ ignorePopupCard: true }) || this;
return `${String(card.title || 'export-card')
.replace(/[^a-z0-9._-]+/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'export-card'}.xlsx`;
},
});
Template.exportCardPopup.events({
'click .js-excel-field-toggle'(event, instance) {
event.preventDefault();
const field = event.currentTarget.dataset.field;
instance.excelFields.set(field, !instance.excelFields.get(field));
},
});
// #1173: the card export popup is client/components/boards/exportScope.js now -
// one body, one field list and one url builder for the card, the list, the
// swimlane and the board. What was here was a second copy of all three.
// only allow number input
Template.editCardSortOrderForm.onRendered(function () {
@@ -1580,6 +1608,12 @@ Template.cardDetailsActionsPopup.helpers({
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
},
// Which of the two hamburgers opened this menu, so its first entry can be
// about the thing the user is looking at. client/lib/cardMenuSource.js
isMinicardMenu() {
return cardMenuSource() === 'minicard';
},
showListOnMinicard() {
return this.showListOnMinicard;
},
@@ -1599,13 +1633,24 @@ Template.cardDetailsActionsPopup.events({
if (!url) return;
Utils.showCopied(Utils.copyTextToClipboard(url), tpl.$('.copied-tooltip'));
},
// The board's Card Settings, one column of them, titled by the key the app
// already has for that column - so no `showOnCardPopup-title` has to be added
// to 147 language files to say a phrase they have already translated.
'click .js-show-on-card': Popup.open('showOnCard', { titleKey: 'show-on-card' }),
// "Custom Fields" is ONE entry: it opens the picker for which of the board's
// fields are on THIS card, and that popup's own Settings cog opens the
// board's list of fields, where one is created, renamed or deleted. Two
// entries for the two halves put the general one above the particular one and
// made the menu ask which you wanted before you had seen either.
// client/components/cards/cardCustomFields.jade
'click .js-show-on-minicard': Popup.open('showOnMinicard', { titleKey: 'show-on-minicard' }),
'click .js-export-card': Popup.open('exportCard'),
'click .js-import-card': Popup.open('importCard'),
'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-custom-fields': Popup.open('cardCustomFields'),
'click .js-received-date': Popup.open('editCardReceivedDate'),
'click .js-start-date': Popup.open('editCardStartDate'),
'click .js-due-date': Popup.open('editCardDueDate'),
@@ -1911,47 +1956,75 @@ Template.editCardAssignerForm.events({
},
});
// The four selects of the move/copy dialogs, in one template. The dialog comes
// from the popup that includes it and is kept on THIS instance: inside
// `each boards` the data context is a board, so a helper reaching into the
// context for it would find nothing there.
Template.cardDestinationPicker.onCreated(function () {
this.autorun(() => {
const data = Template.currentData();
this.dialog = data && data.dialog;
});
});
Template.cardDestinationPicker.helpers({
boards() {
return Template.instance().dialog.boards();
},
swimlanes() {
return Template.instance().dialog.swimlanes();
},
lists() {
return Template.instance().dialog.lists();
},
cards() {
return Template.instance().dialog.cards();
},
isDialogOptionBoardId(boardId) {
return Template.instance().dialog.isDialogOptionBoardId(boardId);
},
isDialogOptionSwimlaneId(swimlaneId) {
return Template.instance().dialog.isDialogOptionSwimlaneId(swimlaneId);
},
isDialogOptionListId(listId) {
return Template.instance().dialog.isDialogOptionListId(listId);
},
isSelectedBoardId(boardId) {
return Template.instance().dialog.isSelectedBoardId(boardId);
},
isSelectedSwimlaneId(swimlaneId) {
return Template.instance().dialog.isSelectedSwimlaneId(swimlaneId);
},
isSelectedListId(listId) {
return Template.instance().dialog.isSelectedListId(listId);
},
isDialogOptionCardId(cardId) {
return Template.instance().dialog.isDialogOptionCardId(cardId);
},
isTitleDefault(title) {
return Template.instance().dialog.isTitleDefault(title);
},
});
/**
* Helper: register standard board/swimlane/list/card dialog helpers and events
* for a template that uses BoardSwimlaneListCardDialog.
*/
/**
* Helper: register standard board/swimlane/list/card dialog helpers and events
* for a template that uses BoardSwimlaneListCardDialog.
*
* The MARKUP those helpers feed is one template - `cardDestinationPicker` in
* cardDetails.jade, included by all four popups - so the helpers are registered
* on it, once, below. What each popup needs of its own is the `dialog` to hand
* that template, and the events: a change or a click inside the picker bubbles
* up to the popup that includes it, which is the one holding the dialog.
*/
function registerCardDialogTemplate(templateName) {
Template[templateName].helpers({
boards() {
return Template.instance().dialog.boards();
},
swimlanes() {
return Template.instance().dialog.swimlanes();
},
lists() {
return Template.instance().dialog.lists();
},
cards() {
return Template.instance().dialog.cards();
},
isDialogOptionBoardId(boardId) {
return Template.instance().dialog.isDialogOptionBoardId(boardId);
},
isDialogOptionSwimlaneId(swimlaneId) {
return Template.instance().dialog.isDialogOptionSwimlaneId(swimlaneId);
},
isDialogOptionListId(listId) {
return Template.instance().dialog.isDialogOptionListId(listId);
},
isSelectedBoardId(boardId) {
return Template.instance().dialog.isSelectedBoardId(boardId);
},
isSelectedSwimlaneId(swimlaneId) {
return Template.instance().dialog.isSelectedSwimlaneId(swimlaneId);
},
isSelectedListId(listId) {
return Template.instance().dialog.isSelectedListId(listId);
},
isDialogOptionCardId(cardId) {
return Template.instance().dialog.isDialogOptionCardId(cardId);
},
isTitleDefault(title) {
return Template.instance().dialog.isTitleDefault(title);
dialog() {
return Template.instance().dialog;
},
});
+28 -5
View File
@@ -54,12 +54,12 @@ textarea.js-edit-checklist-item {
float: inline-start;
width: 30px;
height: 30px;
font-size: 18px;
line-height: 30px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
line-height: calc(30px * var(--wekan-ui-font-scale, 1));
}
.checklist-title .title {
font-size: 18px;
line-height: 25px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
line-height: calc(25px * var(--wekan-ui-font-scale, 1));
}
.checklist-title .checklist-stat {
margin: 0 0.5em;
@@ -93,7 +93,7 @@ textarea.js-edit-checklist-item {
}
.checklist-item {
margin: 0 0 0 0.1em;
line-height: 18px;
line-height: calc(18px * var(--wekan-ui-font-scale, 1));
font-size: 1.1em;
margin-top: 3px;
display: flex;
@@ -191,3 +191,26 @@ textarea.js-edit-checklist-item {
.edit-controls label.toggle-label {
margin-inline-start: 2px;
}
/* #1591: the fold caret on a checklist title. Sized and coloured like the
actions menu beside it so the two read as one row of controls, and given a
real hit area - it is a single glyph, and a caret you have to aim at is worse
than no caret. */
.checklist-title .checklist-collapse {
/* Logical, so the caret sits on the reading-start side under dir=rtl too
(tests/rtl.test.js enforces this across component CSS). */
float: inline-start;
margin-block-start: 2px;
margin-inline-end: 4px;
padding: 2px 4px;
color: #808080;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
line-height: calc(14px * var(--wekan-ui-font-scale, 1));
cursor: pointer;
text-decoration: none;
}
.checklist-title .checklist-collapse:hover,
.checklist-title .checklist-collapse:focus-visible {
color: #2980b9;
}
+29 -4
View File
@@ -1,8 +1,9 @@
template(name="checklists")
//- The NAME of this section is drawn by +cardSectionHeader in cardDetails.jade,
with the caret that collapses it and the rule above it. What stays here is
the control that belongs to the list itself: add a checklist.
.checklists-title
h3.card-details-item-title
i.fa.fa-check
| {{_ 'checklists'}}
if canModifyCard
+inlinedForm(autoclose=false classNames="js-add-checklist" cardId = cardId
position="top")
@@ -38,6 +39,15 @@ template(name="checklistDetail")
else
.checklist-title
span
//- #1591: fold a long checklist out of the way. A caret rather than
another entry in the actions menu, because the whole point is to do it
at a glance while reading the card - and it is a view preference of
this user, so it does not touch the card.
a.fa.checklist-collapse.js-collapse-checklist(
class="{{#if checklistCollapsed}}fa-caret-right{{else}}fa-caret-down{{/if}}"
title="{{#if checklistCollapsed}}{{_ 'expand-checklist'}}{{else}}{{_ 'collapse-checklist'}}{{/if}}"
role="button" tabindex="0"
aria-expanded="{{#if checklistCollapsed}}false{{else}}true{{/if}}")
if canModifyCard
a.fa.fa-navicon.checklist-details-menu.js-open-checklist-details-menu(title="{{_ 'checklistActionsPopup-title'}}")
@@ -52,12 +62,15 @@ template(name="checklistDetail")
+viewer
= checklist.title
//- The progress bar stays visible when folded: it is the summary of what was
folded away, so hiding it too would leave a title that says nothing.
if $gt finishedPercent 0
.checklist-progress-bar-container
.checklist-progress-text {{finishedPercent}}%
.checklist-progress-bar
.checklist-progress(style="width:{{finishedPercent}}%")
+checklistItems(checklist = checklist card = card)
unless checklistCollapsed
+checklistItems(checklist = checklist card = card)
template(name="checklistDeletePopup")
p {{_ 'confirm-checklist-delete-popup'}}
@@ -155,6 +168,14 @@ template(name='checklistItemDetail')
= item.title
template(name="checklistActionsPopup")
//- #1173: a checklist exports like everything else - the same popup body, with
the checklist as its scope. Reading, so it is above the rows that change it.
ul.pop-over-list
li
a.js-export-checklist
i.fa.fa-exchange
| {{_ 'export'}} / {{_ 'import'}} ...
hr
ul.pop-over-list
li
a.js-delete-checklist.delete-checklist
@@ -188,7 +209,11 @@ template(name="checklistActionsPopup")
i.fa.fa-th-list
| {{_ "show-on-minicard"}} ...
.material-toggle-switch(title="{{_ 'show-on-minicard'}}")
if checklist.showChecklistAtMinicard
//- The switch shows what is ON THE MINICARD, not the raw field. Drawn
from the field it read OFF beside a checklist that was plainly showing,
because the board default (allowsChecklistsOnMinicard, on out of the
box) put it there - reported by email.
if shownAtMinicard
input.toggle-switch(type="checkbox" id="toggleShowChecklistAtMinicard_{{checklist._id}}" checked="checked")
else
input.toggle-switch(type="checkbox" id="toggleShowChecklistAtMinicard_{{checklist._id}}")
+67 -1
View File
@@ -8,6 +8,7 @@ import { BoardSwimlaneListCardDialog } from '/client/lib/dialogWithBoardSwimlane
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import autosize from 'autosize';
import { isChecklistShownAtMinicard } from '/models/lib/minicardChecklistVisibility';
// SubsManager removed for Meteor 3 migration
const { calculateIndexData } = Utils;
@@ -82,6 +83,47 @@ Template.checklistDetail.helpers({
const ret = this.checklist.finishedPercent();
return ret;
},
/** #1591: is this checklist folded for THIS user?
*
* Per-user, keyed by card and checklist, exactly like collapsed lists and
* swimlanes - not the checklist's own hideAllChecklistItems, which is a field
* on the checklist and so changes what everyone on the board sees. `null` from
* the getter means "never set", which is what makes the default (expanded)
* distinguishable from somebody having deliberately expanded it. */
checklistCollapsed() {
const checklist = this.checklist;
if (!checklist || !checklist._id) return false;
const user = ReactiveCache.getCurrentUser();
if (!user) return false;
const cardId = (this.card && this.card._id) || checklist.cardId;
const stored = user.getCollapsedCardSection(
cardId, user.checklistSectionKey(checklist._id));
return stored === true;
},
});
Template.checklistDetail.events({
'click .js-collapse-checklist'(event) {
// The caret sits inside the title, which opens the inline rename form when
// clicked - so this must not reach it.
event.preventDefault();
event.stopPropagation();
const checklist = this.checklist;
const user = ReactiveCache.getCurrentUser();
if (!checklist || !checklist._id || !user) return;
const cardId = (this.card && this.card._id) || checklist.cardId;
const key = user.checklistSectionKey(checklist._id);
const collapsed = user.getCollapsedCardSection(cardId, key) === true;
user.setCollapsedCardSection(cardId, key, !collapsed);
},
'keydown .js-collapse-checklist'(event) {
// It is a link acting as a button, so it has to answer the keys a button
// answers or it is unreachable without a mouse.
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
$(event.currentTarget).trigger('click');
},
});
Template.checklists.helpers({
@@ -256,7 +298,26 @@ Template.addChecklistItemForm.events({
},
});
// The board default this checklist's own setting overrides. Read here rather than
// in the model helper, because the model runs on the server too, where looking a
// board up is asynchronous - and a Blaze helper has to answer now.
function boardAllowsChecklistsOnMinicard(checklist) {
if (!checklist) return false;
const board = ReactiveCache.getBoard(checklist.boardId);
return !!(board && board.allowsChecklistsOnMinicard);
}
Template.checklistActionsPopup.helpers({
// What the "Show on minicard" switch draws: whether the checklist is on the
// minicard right now, board default included.
shownAtMinicard() {
const checklist = this.checklist;
return isChecklistShownAtMinicard(checklist, boardAllowsChecklistsOnMinicard(checklist));
},
});
Template.checklistActionsPopup.events({
'click .js-export-checklist': Popup.open('exportChecklist'),
'click .js-delete-checklist': Popup.afterConfirm('checklistDelete', function () {
Popup.back(2);
const checklist = this.checklist;
@@ -279,7 +340,12 @@ Template.checklistActionsPopup.events({
},
'click .js-show-checklist-at-minicard'(event) {
event.preventDefault();
Template.currentData().checklist.toggleShowChecklistAtMinicard();
const checklist = Template.currentData().checklist;
// The board's setting is the default this one overrides, so the toggle has to
// flip what is ON SCREEN, not the raw field. Flipping the field is what made
// the first click do nothing while the board default was on (false -> true,
// still shown) - reported by email.
checklist.toggleShowChecklistAtMinicard(boardAllowsChecklistsOnMinicard(checklist));
Popup.back();
},
});
+4 -4
View File
@@ -17,7 +17,7 @@
background: #fff;
color: #333;
cursor: pointer;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
line-height: 1.4;
text-decoration: none;
}
@@ -41,7 +41,7 @@
background: #fff;
color: #333;
cursor: pointer;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
line-height: 1.4;
text-decoration: none;
}
@@ -82,7 +82,7 @@
color: #fff;
display: inline-block;
font-weight: 700;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
margin-inline-end: 4px;
margin-bottom: 5px;
padding: 3px 8px;
@@ -238,7 +238,7 @@
width: 24px;
}
.edit-labels .labels-static .card-label {
line-height: 30px;
line-height: calc(30px * var(--wekan-ui-font-scale, 1));
margin-bottom: 4px;
position: relative;
top: auto;
+61 -6
View File
@@ -58,13 +58,13 @@
.minicard-details-menu-with-handle {
float: inline-end;
padding-inline-start: 10px;
font-size: 14px;
font-size: 0.875rem;
padding: 0;
z-index: 1;
}
.minicard-details-menu {
float: inline-end;
font-size: 14px;
font-size: 0.875rem;
padding-inline-start: 10px;
}
@media print {
@@ -237,12 +237,12 @@
}
body.mobile-mode .minicard .handle .drag-handle,
body.mobile-mode .minicard .handle .fa {
font-size: 22px;
font-size: 1.375rem;
}
}
.minicard .handle .drag-handle {
font-size: 16px;
font-size: 1rem;
color: #ccc;
display: inline-block;
width: 1.4em;
@@ -253,7 +253,7 @@
"Add card" affordance below (13px). It used to inherit the 16px document
size, which made a card title exactly as loud as the heading of the column
holding it. See the note on .list-header-name in lists/list.css. */
font-size: 14px;
font-size: 0.875rem;
margin-inline-end: 21px;
/* #6444: isolate each title's bidirectional context. The title is a
dir="auto" .viewer; under a global dir="rtl" document, non-isolated
@@ -263,6 +263,53 @@
reverts on refresh). Isolation makes each title a self-contained run. */
unicode-bidi: isolate;
}
/* #4990: the title text is the edit target. It is a block so that the whole
line, not only the words, is that target: a click on the empty space after a
short title is still a click on the title. */
.minicard .minicard-title .minicard-title-text {
display: block;
position: relative;
}
/* ...and with no drag handles, only HALF of it. The card is then dragged by its
own body, so a title that is entirely an edit target leaves nowhere on that
line to take hold of - a grab that moves a few pixels is a click, and the
editor opens instead of the card moving.
The zone is 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. It sits on the
LEADING half - `inset-inline-start`, so it is the left in English and the
right in Arabic without a second rule - and the trailing half is left for
dragging. Dragging still works over the zone too (jQuery UI sortable's handle
is the whole `.minicard`); what the zone decides is where a CLICK edits. */
.minicard .minicard-title .minicard-title-edit-zone {
position: absolute;
top: 0;
bottom: 0;
inset-inline-start: 0;
width: 50%;
cursor: text;
/* Above the text, so the click lands here and not on the `.viewer`. */
z-index: 1;
}
/* With handles ON the handle is the only drag source, so nothing has to be
reserved and the whole title is the edit target again. */
.minicard.minicard-with-handle .minicard-title .minicard-title-edit-zone {
width: 100%;
}
/* The editor replaces the title in place and is the width of the card. */
.minicard .js-minicard-title-form textarea.minicard-title-editor {
font-size: 0.875rem;
width: 100%;
min-height: 40px;
max-height: 200px;
margin-bottom: 4px;
overflow-y: auto;
resize: none;
/* Same bidi isolation as the title it stands in for. */
unicode-bidi: isolate;
}
.minicard .minicard-title .card-number {
color: #b3b3b3;
display: inline-block;
@@ -855,8 +902,16 @@
transition: all 0.2s ease;
}
/* List name display on minicard */
/* The name of the LIST this card is in, under the badges - "Show on Minicard /
List title", for reading a card away from the column it lives in (a
swimlane, search results, My Cards).
`clear` is the whole rule: `.minicard .badges` is `float: inline-start`, so
without it this block shares the badges' line and reads as one more badge
rather than as the line it is. Logical `inline-start`, so it clears the
floats on whichever side the page starts. */
.minicard-list-name {
clear: inline-start;
font-size: 0.75em;
color: #8c8c8c;
margin-top: 2px;
+78 -38
View File
@@ -72,33 +72,55 @@ template(name="minicard")
i.fa.fa-check
span {{_ 'upload-completed'}}
.minicard-title
if showDueComplete
if canModifyCard
a.minicard-complete-toggle.js-toggle-card-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
if $eq 'prefix-with-full-path' currentBoard.presentParentTask
.parent-prefix
| {{ parentString ' > ' }}
if $eq 'prefix-with-parent' currentBoard.presentParentTask
.parent-prefix
| {{ parentCardName }}
if isLinkedBoard
a.js-linked-link
span.linked-icon
i.fa.fa-folder
else if isLinkedCard
a.js-linked-link
span.linked-icon
i.fa.fa-id-card
if getArchived
span.linked-icon.linked-archived
i.fa.fa-archive
+viewer
if showCardNumber
span.card-number
| ##{getCardNumber} &nbsp;
= getTitle
//- #4990: the title is edited where it is read - on the board - exactly
like a list's title is. Clicking the title text opens this form in its
place; everything else on the card still opens the card.
+inlinedForm(classNames="js-minicard-title-form")
+editMinicardTitleForm
else
.minicard-title
if showDueComplete
if canModifyCard
a.minicard-complete-toggle.js-toggle-card-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
if $eq 'prefix-with-full-path' currentBoard.presentParentTask
.parent-prefix
| {{ parentString ' > ' }}
if $eq 'prefix-with-parent' currentBoard.presentParentTask
.parent-prefix
| {{ parentCardName }}
if isLinkedBoard
a.js-linked-link
span.linked-icon
i.fa.fa-folder
else if isLinkedCard
a.js-linked-link
span.linked-icon
i.fa.fa-id-card
if getArchived
span.linked-icon.linked-archived
i.fa.fa-archive
//- Only the TEXT is the edit target, and only for somebody who may
write: the checkbox, the linked-card icons and the card number keep
doing what they did.
//-
And only HALF of the text, when there are no drag handles. With handles
off the card is dragged by its own body, so a title that is entirely an
edit target leaves nowhere on that line to take hold of: a grab that
moves a few pixels is a click, and the editor opens instead of the card
moving. The edit target is the LEADING half - left in English, right in
Arabic, because it is an inline edge - and the trailing half is there to
drag from. With handles ON the handle is the only drag source, so
nothing has to be reserved and the whole title edits.
client/components/cards/minicard.css
span.minicard-title-text(class="{{#if canModifyCard}}is-editable{{/if}}")
if canModifyCard
span.minicard-title-edit-zone.js-open-inlined-form(title="{{_ 'edit'}}")
+viewer
if showCardNumber
span.card-number
| ##{getCardNumber} &nbsp;
= getTitle
if showLabels
if labels
.minicard-labels(class="{{#if hiddenMinicardLabelText}}minicard-labels-no-text{{/if}}")
@@ -257,6 +279,16 @@ template(name="minicard")
.parent-subtext
| {{ parentCardName }}
//- The card's title editor on the board, the same shape as a list's: the text
as a textarea, Save, and an X that closes without saving.
template(name="editMinicardTitleForm")
textarea.minicard-title-editor.js-edit-minicard-title(rows='1' autofocus dir="auto")
= getTitle
.edit-controls.clearfix
button.primary.confirm.js-submit-edit-minicard-title(type="submit") {{_ 'save'}}
a.js-close-inlined-form(title="{{_ 'close'}}")
i.fa.fa-times-thin
template(name="editCardSortOrderPopup")
input.js-edit-card-sort-popup(type='text' autofocus value=sort dir="auto")
.edit-controls.clearfix
@@ -265,6 +297,13 @@ template(name="editCardSortOrderPopup")
template(name="minicardChecklist")
.minicard-checklist
.checklist-header
//- #1591: the same caret as on the opened card, and the same stored key,
so folding a checklist folds it in both places.
a.fa.checklist-collapse.js-collapse-checklist(
class="{{#if checklistCollapsed}}fa-caret-right{{else}}fa-caret-down{{/if}}"
title="{{#if checklistCollapsed}}{{_ 'expand-checklist'}}{{else}}{{_ 'collapse-checklist'}}{{/if}}"
role="button" tabindex="0"
aria-expanded="{{#if checklistCollapsed}}false{{else}}true{{/if}}")
if canModifyCard
+inlinedForm(classNames="js-edit-checklist-title" checklist=checklist)
+editChecklistForm(checklist=checklist)
@@ -275,16 +314,17 @@ template(name="minicardChecklist")
if canModifyCard
a.checklist-menu.js-open-checklist-menu(title="{{_ 'checklistActionsPopup-title'}}")
i.fa.fa-bars
.checklist-items.js-checklist-items
each visibleItems
+inlinedForm(classNames="js-edit-checklist-item" item=. checklist=checklist)
+editChecklistItemForm(type='item' item=. checklist=checklist)
unless checklistCollapsed
.checklist-items.js-checklist-items
each visibleItems
+inlinedForm(classNames="js-edit-checklist-item" item=. checklist=checklist)
+editChecklistItemForm(type='item' item=. checklist=checklist)
else
+checklistItemDetail(item=. checklist=checklist card=card)
if canModifyCard
+inlinedForm(autoclose=false classNames="js-add-checklist-item" checklist=checklist)
+addChecklistItemForm(checklist=checklist showNewlineBecomesNewChecklistItem=false)
else
+checklistItemDetail(item=. checklist=checklist card=card)
if canModifyCard
+inlinedForm(autoclose=false classNames="js-add-checklist-item" checklist=checklist)
+addChecklistItemForm(checklist=checklist showNewlineBecomesNewChecklistItem=false)
else
a.add-checklist-item.js-open-inlined-form(title="{{_ 'add-checklist-item'}}")
i.fa.fa-plus
a.add-checklist-item.js-open-inlined-form(title="{{_ 'add-checklist-item'}}")
i.fa.fa-plus
+91 -26
View File
@@ -7,11 +7,17 @@ import { Utils } from '/client/lib/utils';
import ChecklistItems from '/models/checklistItems';
import Cards from '/models/cards';
import { resolveCoverId } from '/models/lib/linkedCardCover';
import { isChecklistShownAtMinicard } from '/models/lib/minicardChecklistVisibility';
import {
parseChecklistItemTitles,
buildChecklistItemPayload,
} from '/models/lib/checklistItemTitles';
import { normalizeDependencies } from '/models/metadata/dependencies';
import { setCardMenuSource } from '/client/lib/cardMenuSource';
import {
hiddenMinicardLabelText,
toggleMinicardLabelText,
} from '/client/lib/minicardLabelText';
function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
if (!board) return false;
@@ -166,16 +172,7 @@ Template.minicard.helpers({
return !!(board && board.allowsChecklistCountBadgeOnMinicard);
},
hiddenMinicardLabelText() {
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser) {
return (currentUser.profile || {}).hiddenMinicardLabelText;
} else if (window.localStorage.getItem('hiddenMinicardLabelText')) {
return true;
} else {
return false;
}
},
hiddenMinicardLabelText,
cover() {
// #5666: for a linked card the cover lives on the real card it points at, so
// resolve the cover id through it (mirroring getTitle/getDue/...); a plain
@@ -231,14 +228,13 @@ Template.minicard.helpers({
const visibleChecklists = [];
checklists.forEach(checklist => {
// Show checklist if either:
// 1. Board-wide setting is enabled, OR
// 2. This specific checklist has the setting enabled
// #5565: use the same board field the sidebar toggle writes
// (`allowsChecklistsOnMinicard`); this previously checked a different,
// UI-less field (`allowsChecklistAtMinicard`), so the board toggle to show
// checklists on minicards had no effect.
if (currentBoard.allowsChecklistsOnMinicard || checklist.showChecklistAtMinicard) {
// The board setting is the DEFAULT and the checklist's own setting is an
// OVERRIDE. This was an OR - board || checklist - and the board setting
// (`allowsChecklistsOnMinicard`, #5565) defaults to TRUE, so unchecking
// "Show on minicard" on a checklist could never hide it: reported by email
// with a screenshot of a checklist still on the minicard after the toggle
// was switched off. See models/lib/minicardChecklistVisibility.js.
if (isChecklistShownAtMinicard(checklist, currentBoard.allowsChecklistsOnMinicard)) {
visibleChecklists.push(checklist);
}
});
@@ -270,6 +266,31 @@ function moveCardBy(card, delta) {
}
Template.minicard.events({
// #4990: the title is edited in place. The minicard sits inside the wrapper
// LINK to the card, so every click inside the open form would otherwise
// navigate away mid-edit. Cancelling the click's default stops that - except
// on the save button, whose own default IS the submit (the innermost element
// with an activation behaviour is the one that runs, so the link does not
// follow when the button is clicked).
'click .js-minicard-title-form'(event) {
event.stopPropagation();
if (!$(event.target).closest('button[type=submit]').length) {
event.preventDefault();
}
},
async 'submit .js-minicard-title-form'(event, templateInstance) {
event.preventDefault();
event.stopPropagation();
const title = templateInstance
.$('.js-edit-minicard-title')
.val()
?.trim();
// An empty title would leave a card with nothing to click, so an empty
// save is a no-op rather than a way to lose the card - same as a list.
if (title && title !== this.getTitle()) {
await this.setTitle(title);
}
},
'click .js-linked-link'() {
if (this.isLinkedCard()) Utils.goCardId(this.linkedId);
else if (this.isLinkedBoard())
@@ -297,23 +318,35 @@ Template.minicard.events({
}
this.setDueComplete(!this.getDueComplete());
},
// The minicard's own copy of this only ever wrote localStorage, so a
// logged-in user toggling it here set something nothing reads. One module
// now, for reading and for writing. client/lib/minicardLabelText.js
'click .js-toggle-minicard-label-text'() {
if (window.localStorage.getItem('hiddenMinicardLabelText')) {
window.localStorage.removeItem('hiddenMinicardLabelText'); //true
} else {
window.localStorage.setItem('hiddenMinicardLabelText', 'true'); //true
}
toggleMinicardLabelText();
},
'click span.badge-icon.fa.fa-sort, click span.badge-text.check-list-sort' : Popup.open("editCardSortOrder"),
'click .minicard-labels'(event, tpl) {
if (tpl.find('.js-card-label:hover')) {
Popup.open("cardLabels")(event, {dataContextIfCurrentDataIsUndefined: Template.currentData()});
// A label on a minicard opens the card's labels, and ONLY that. The click used
// to reach the minicard as well, so the card details opened behind the popup:
// two things for one click, and the one you asked for on top of the one you
// did not.
//
// Which label was clicked is read from the EVENT rather than from `:hover`.
// `:hover` answers about the pointer, and on a touch screen it can still be
// true for whatever was tapped last - so a tap anywhere in the labels area
// could open the labels of a label nobody touched.
'click .minicard-labels'(event) {
if (!$(event.target).closest('.js-card-label').length) {
return; // not a label - let the click be the card's, as before
}
event.preventDefault();
event.stopPropagation();
Popup.open("cardLabels")(event, {dataContextIfCurrentDataIsUndefined: Template.currentData()});
},
'click .js-open-minicard-details-menu'(event, tpl) {
event.preventDefault();
event.stopPropagation();
const card = Template.currentData();
setCardMenuSource('minicard');
Popup.open('cardDetailsActions').call({currentData: () => card}, event);
},
// Drag and drop file upload handlers
@@ -375,6 +408,18 @@ Template.minicard.events({
});
Template.minicardChecklist.helpers({
/** #1591: folded for THIS user, under the same key the opened card uses, so
* the two agree. `this.checklist || this` because this template is called both
* with an explicit checklist and with one as the data context. */
checklistCollapsed() {
const checklist = this.checklist || this;
if (!checklist || !checklist._id) return false;
const user = ReactiveCache.getCurrentUser();
if (!user) return false;
const cardId = (this.card && this.card._id) || checklist.cardId;
return user.getCollapsedCardSection(
cardId, user.checklistSectionKey(checklist._id)) === true;
},
visibleItems() {
const checklist = this.checklist || this;
const items = checklist.items();
@@ -416,6 +461,26 @@ function addMinicardChecklistItems(textarea, checklist) {
}
Template.minicardChecklist.events({
'click .js-collapse-checklist'(event) {
// A minicard is a link to the card, and the title opens the rename form -
// neither must happen because somebody folded a checklist.
event.preventDefault();
event.stopPropagation();
const data = Template.currentData();
const checklist = (data && data.checklist) || data || this;
const user = ReactiveCache.getCurrentUser();
if (!checklist || !checklist._id || !user) return;
const cardId = (data && data.card && data.card._id) || checklist.cardId;
const key = user.checklistSectionKey(checklist._id);
const collapsed = user.getCollapsedCardSection(cardId, key) === true;
user.setCollapsedCardSection(cardId, key, !collapsed);
},
'keydown .js-collapse-checklist'(event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
$(event.currentTarget).trigger('click');
},
'click .js-convert-checklist-item-to-card'(event) {
event.stopPropagation();
const formData = Blaze.getData(event.currentTarget);
+5 -5
View File
@@ -33,12 +33,12 @@ textarea.js-edit-subtask-item {
flex-shrink: 0;
width: 30px;
height: 30px;
font-size: 18px;
line-height: 30px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
line-height: calc(30px * var(--wekan-ui-font-scale, 1));
}
.subtask-title .title {
font-size: 18px;
line-height: 25px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
line-height: calc(25px * var(--wekan-ui-font-scale, 1));
flex: 1;
order: 1;
}
@@ -121,7 +121,7 @@ textarea.js-edit-subtask-item {
}
.subtasks-item {
margin: 0 0 0 0.1em;
line-height: 18px;
line-height: calc(18px * var(--wekan-ui-font-scale, 1));
font-size: 1.1em;
margin-top: 3px;
display: flex;
+5 -3
View File
@@ -1,7 +1,6 @@
template(name="subtasks")
h3.card-details-item-title
i.fa.fa-globe
| {{_ 'subtasks'}}
//- No title here: +cardSectionHeader draws it, with the caret and the rule.
Two headings for one section is what a card showed before.
if currentUser.isBoardAdmin
if toggleDeleteDialog.get
.board-overlay#card-details-overlay
@@ -107,4 +106,7 @@ template(name="subtaskActionsPopup")
a.js-delete-subtask.delete-subtask
i.fa.fa-trash
| {{_ "delete"}} ...
//- The board's subtask SETTINGS are not here: they belong to the section, not
to one subtask, and they are behind the hamburger at the end of the
Subtasks heading. client/components/cards/cardDetails.jade
@@ -3,7 +3,7 @@
margin: 5px 0;
padding: 8px;
border-radius: 4px;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
line-height: 1.4;
}
@@ -57,7 +57,7 @@
.original-title {
color: #6c757d;
font-size: 11px;
font-size: calc(11px * var(--wekan-ui-font-scale, 1));
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid #e9ecef;
@@ -78,7 +78,7 @@
/* Responsive adjustments */
@media (max-width: 768px) {
.original-position-info {
font-size: 11px;
font-size: calc(11px * var(--wekan-ui-font-scale, 1));
padding: 6px;
}
+29 -14
View File
@@ -1,14 +1,29 @@
template(name="datepicker")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
//- The date-and-time form every "edit a date" popup is made of: the card's
//- Received, Start, Due and End dates, a vote's and a planning poker's end
//- date, and a date custom field. Eight popups drew this same form, letter for
//- letter, in three different files.
//-
//- It takes what it shows as ARGUMENTS rather than reading helpers: a helper
//- inside a template is looked up on that template, not on the one including
//- it, and all eight popups keep their own state and handlers
//- (client/lib/datepicker.js, which they already shared - it was only the
//- markup that was copied). So this template has no code of its own at all;
//- it renders three values and the buttons.
//-
//- The buttons are handled where the state is: `js-submit-date` and
//- `js-delete-date` bubble to the popup that includes this, whose events come
//- from datePickerEvents().
template(name="editDateForm")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
-16
View File
@@ -1,16 +0,0 @@
import {
setupDatePicker,
datePickerRendered,
datePickerHelpers,
datePickerEvents,
} from '/client/lib/datepicker';
Template.datepicker.onCreated(function () {
setupDatePicker(this);
});
Template.datepicker.onRendered(function () {
datePickerRendered(this);
});
Template.datepicker.helpers(datePickerHelpers());
+15 -15
View File
@@ -307,7 +307,7 @@ textarea::-moz-placeholder {
}
.edit-controls .fa-times-thin,
.add-controls .fa-times-thin {
font-size: 20px;
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
margin: 5px 21px;
}
/* CJK (Japanese/Chinese/Korean) labels contain no spaces, so browsers may
@@ -388,10 +388,10 @@ textarea::-moz-placeholder {
border-radius: 3px;
color: #fff;
display: none;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: 700;
height: 17px;
line-height: 17px;
line-height: calc(17px * var(--wekan-ui-font-scale, 1));
margin: 0;
padding: 2px 4px;
position: absolute;
@@ -445,8 +445,8 @@ textarea::-moz-placeholder {
.button-link.setting .label {
color: #222;
display: block;
font-size: 12px;
line-height: 14px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
line-height: calc(14px * var(--wekan-ui-font-scale, 1));
margin-bottom: 0;
}
.button-link.setting:hover .label {
@@ -454,8 +454,8 @@ textarea::-moz-placeholder {
}
.button-link.setting .value {
display: block;
font-size: 18px;
line-height: 24px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
line-height: calc(24px * var(--wekan-ui-font-scale, 1));
overflow: hidden;
text-overflow: ellipsis;
}
@@ -598,8 +598,8 @@ button.loud-text-button:hover {
padding: 11px;
position: relative;
text-decoration: none;
font-size: 16px;
line-height: 20px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
line-height: calc(20px * var(--wekan-ui-font-scale, 1));
}
.big-link .text {
text-decoration: underline;
@@ -641,9 +641,9 @@ button.loud-text-button:hover {
width: 40px;
}
.big-link.avatar-changer .member .member-initials {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
height: 40px;
line-height: 40px;
line-height: calc(40px * var(--wekan-ui-font-scale, 1));
max-height: 40px;
}
.show-more {
@@ -675,24 +675,24 @@ button.loud-text-button:hover {
.uploader .realfile {
cursor: pointer;
height: 34px;
line-height: 34px;
line-height: calc(34px * var(--wekan-ui-font-scale, 1));
position: absolute;
top: 0;
inset-inline-start: 0;
width: 100%;
z-index: 2;
font-size: 23px;
font-size: calc(23px * var(--wekan-ui-font-scale, 1));
}
.uploader .realfile input[type="file"] {
cursor: pointer;
height: 34px;
line-height: 34px;
line-height: calc(34px * var(--wekan-ui-font-scale, 1));
margin: 0;
opacity: 0;
padding: 0;
width: 100%;
z-index: 2;
font-size: 23px;
font-size: calc(23px * var(--wekan-ui-font-scale, 1));
}
.uploader:hover .fakefile {
background: #318ec4;
+11 -11
View File
@@ -2,25 +2,25 @@
.ganttview-received {
background-color: #dbdbdb !important;
color: #000 !important;
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
font-weight: bold !important;
}
.ganttview-start {
background-color: #90ee90 !important;
color: #000 !important;
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
font-weight: bold !important;
}
.ganttview-due {
background-color: #ffd700 !important;
color: #000 !important;
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
font-weight: bold !important;
}
.ganttview-end {
background-color: #ffb3b3 !important;
color: #000 !important;
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
font-weight: bold !important;
}
/* Gantt View Styles */
@@ -52,7 +52,7 @@
min-width: 800px;
border: 2px solid #666;
font-family: sans-serif;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
background-color: #fff;
}
@@ -81,7 +81,7 @@
padding: 2px 1px; /* half */
text-align: center;
background-color: #f5f5f5;
font-size: 11px;
font-size: calc(11px * var(--wekan-ui-font-scale, 1));
min-width: 15px; /* half of 30px */
font-weight: bold;
height: auto;
@@ -110,9 +110,9 @@
min-width: 15px; /* half of 30px */
height: 32px;
vertical-align: middle;
line-height: 28px;
line-height: calc(28px * var(--wekan-ui-font-scale, 1));
background-color: #ffffff;
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
}
@@ -162,7 +162,7 @@
.gantt-container tbody td.ganttview-block {
background-color: #4CAF50 !important;
color: #fff !important;
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
font-weight: bold !important;
padding: 2px !important;
border-radius: 2px;
@@ -171,7 +171,7 @@
/* Responsive adjustments */
@media (max-width: 768px) {
.gantt-container table {
font-size: 11px;
font-size: calc(11px * var(--wekan-ui-font-scale, 1));
}
.gantt-container thead td {
@@ -187,7 +187,7 @@
.gantt-container tbody td:first-child {
width: 100px;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
}
}
+69 -1
View File
@@ -134,7 +134,7 @@ a.show-mapping {
}
.trello-progress-percent {
text-align: end;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
color: #666;
font-weight: 600;
margin-bottom: 8px;
@@ -165,3 +165,71 @@ a.show-mapping {
font-size: 0.85em;
margin: 2px 0 4px;
}
/* ── The import page's two questions ────────────────────────────────────────
WHICH SOURCE, and WHAT TO TAKE FROM IT. They are asked in that order and
answered in either, so on a wide page they are two columns: fourteen sources
in one and two dozen parts in the other, both on screen at once instead of
the parts living below a list you scroll past. `auto-fit` with a minimum, so
a narrow window stacks them without a second rule. */
.import-page .import-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
align-items: start;
}
.import-page .import-columns h2 {
font-size: 1.1em;
margin: 0 0 8px;
}
.import-page .import-source-list,
.import-page .import-part-list {
list-style: none;
margin: 0;
padding: 0;
}
/* A row is EXACTLY the row Admin Panel / Announcement draws:
`a.flex > .materialCheckBox + span`, and no geometry of its own. That matters
more than it looks: `.materialCheckBox.is-checked` becomes the tick by
turning two borders transparent, rotating 40deg and shifting itself
(`top: -4px; inset-inline-start: -3px; margin-inline-end: 6px`) - offsets
written for the plain flow `.flex` gives it. An `align-items: center` and a
`gap` on top of that fight those offsets, and the tick lands on the first
word of the label, which is what this page did before.
So only the things that move nothing: something to click against, and a
pointer. forms.css does the rest, the same way it does on the Announcement
page. */
.import-page .import-source-list a.flex,
.import-page .import-part-list a.flex {
padding: 4px 4px;
border-radius: 3px;
color: inherit;
cursor: pointer;
}
.import-page .import-source-list a.flex:hover,
.import-page .import-part-list a.flex:hover {
background: rgba(0, 0, 0, 0.05);
}
/* A space between the box and its label, in BOTH states.
`.materialCheckBox` is 13px plus a 2px border on each side, and carries no
margin, so an unchecked row's text starts right against the box. Checked, it
is a different shape entirely - 7px wide, shifted `inset-inline-start: -3px`,
with `margin-inline-end: 6px` - so its text starts 8px + 6px in.
Lining the two up is arithmetic: 17 + 8 = 25 for the unchecked box, and
8 + 17 = 25 for the checked one, so a row's label sits in the same place
whether it is ticked or not and ticking one no longer nudges its text. */
.import-page .import-source-list a.flex .materialCheckBox,
.import-page .import-part-list a.flex .materialCheckBox {
margin-inline-end: 8px;
}
.import-page .import-source-list a.flex .materialCheckBox.is-checked,
.import-page .import-part-list a.flex .materialCheckBox.is-checked {
margin-inline-end: 17px;
}
+48 -4
View File
@@ -1,10 +1,46 @@
template(name="import")
.wrapper
.wrapper.import-page
if error.get
.warning {{_ error.get}}
if zipImporting
.warning {{_ 'import-trello-zip-progress'}}
+Template.dynamic(template=currentTemplate)
//- #1173: ONE import page. Every source used to be its own address reached
from its own link in the board sidebar, and the page never said which
others existed - so "where do I import a Jira export" was answered by a
menu somewhere else. The source is picked HERE, the parts are picked with
the same checkboxes every export uses, and the boards inside a file that
holds several are picked below that.
//- The two questions side by side when the page is wide enough: which
source, and what to take from it. They are asked in that order and
answered in either, so reading them as two columns beats scrolling past
fourteen sources to reach the parts. client/components/import/import.css
.import-columns
.import-sources
h2 {{_ 'import-source-heading'}}
ul.import-source-list
each importSources
li
//- The same green tick every checkbox in the app uses
(`.materialCheckBox`, forms.css) - the one Admin Panel /
Announcement has. Nothing is chosen to begin with, and choosing
one un-chooses the last: an import reads ONE file in one format,
so this is a radio wearing the app's own checkbox.
a.flex.js-select-import-source(data-source="{{key}}" href="#")
.materialCheckBox(class="{{#if selected}}is-checked{{/if}}")
span {{name}}
if hasImportSource
.import-parts
h2 {{_ 'export-select-what-to-include'}}
p.quiet {{_ 'import-parts-instruction'}}
ul.import-part-list
each importParts
li
a.flex.js-import-part-toggle(data-field="{{field}}" href="#")
.materialCheckBox(class="{{#if checked}}is-checked{{/if}}")
span {{_ label}}
if hasImportSource
hr
+Template.dynamic(template=currentTemplate)
template(name="importTextarea")
form
@@ -16,6 +52,14 @@ template(name="importTextarea")
else
textarea.js-import-json(id='import-textarea' placeholder="{{_ importPlaceHolder}}" autofocus)
| {{jsonText}}
if isWekanImport
//- #1173: a WeKan export can be a .json or a .zip - the .zip is the one to
use when a board's attachments are too large to sit inside one JSON
string. Both land in the same place; the .zip is unpacked in the browser
and its files are put back on their metadata rows.
p.import-file-row
label(for='import-wekan-file') {{_ 'import-wekan-file'}}
input.js-import-json-file(id='import-wekan-file' type="file" accept=".json,.zip,application/json,application/zip")
if isTrelloImport
p.import-file-row
label(for='import-json-file') {{_ 'import-trello-json-file'}}
@@ -29,7 +73,7 @@ template(name="importTextarea")
label(for='import-workspace-name') {{_ 'import-trello-workspace'}}
input.js-import-workspace-name(id='import-workspace-name' type="text" placeholder="{{_ 'import-trello-workspace-placeholder'}}")
input.primary.wide(type="submit" value="{{_ 'import'}}")
button.wide.js-import-without-mapping(type="button") {{_ 'import-without-mapping-members'}}
button.primary.wide.js-import-without-mapping(type="button") {{_ 'import-without-mapping-members'}}
if isTrelloImport
hr
+importTrelloApi
@@ -158,7 +202,7 @@ template(name="importMapMembers")
.mapping-item.ghost-item
form
input.primary.wide(type="submit" value="{{_ 'done'}}")
button.wide.js-import-skip-mapping(type="button") {{_ 'import-without-mapping-members'}}
button.primary.wide.js-import-skip-mapping(type="button") {{_ 'import-without-mapping-members'}}
else
+spinner
+87 -3
View File
@@ -8,6 +8,14 @@ import { kanboardGetMembersToMap } from './kanboardMembersMapper';
import getSlug from 'limax';
import { UserSearchIndex } from '/models/users';
import { Utils } from '/client/lib/utils';
import { productNameOrDefault } from '/models/lib/productName';
import { BOARD_EXPORT_FIELDS } from '/models/lib/exportFields';
import {
selection as importSelection,
selectedFields,
readExportFile,
} from '/client/components/boards/exportScope';
import { pruneImportDocument } from '/models/lib/importParts';
import { TAPi18n } from '/imports/i18n';
import TrelloImportJobs from '/models/trelloImportJobs';
@@ -233,10 +241,17 @@ Template.import.onCreated(function () {
// exports are awkward to paste) or from the textarea.
let input = this.find('.js-import-json').value;
const jsonFileEl = this.find('.js-import-json-file');
let dataObject = null;
if (jsonFileEl && jsonFileEl.files && jsonFileEl.files[0]) {
input = await jsonFileEl.files[0].text();
// #1173: a .zip is read the same way the per-menu import reads one -
// the document out of `wekan.json`, and every file under `attachments/`
// put back on the metadata row it belongs to, so an export that was
// taken as a .zip imports with its files rather than without them.
// readExportFile handles a .json unchanged.
dataObject = await readExportFile(jsonFileEl.files[0]);
} else {
dataObject = JSON.parse(input);
}
const dataObject = JSON.parse(input);
// Guard against importing a broken/old WeKan export that has no board
// content (see wekanExportIsEmpty): warn the user to re-export rather than
// silently creating an empty board with only a Default swimlane.
@@ -350,9 +365,13 @@ Template.import.onCreated(function () {
settled = true;
this.setError('import-timeout');
}, timeoutMs);
// #1173: the parts the page's checkboxes did not tick are taken OUT of the
// document here, before any creator sees it. A creator that never sees a
// comment cannot import one, which is how one selection works for five
// sources without teaching each of them a selection of its own.
Meteor.call(
'importBoard',
importedData,
pruneImportDocument(importedData, selectedFields()),
{ membersMapping: mappingById },
this.importSource,
Session.get('fromBoard'),
@@ -371,10 +390,56 @@ Template.import.onCreated(function () {
};
});
// #1173: every import source, in one list, so the page says what it can read
// instead of the answer living in a menu somewhere else. The order is the one
// the board sidebar's links were in - the familiar one - and the WeKan entry is
// named for the PRODUCT NAME this instance is branded with, because "a previous
// export of ..." should say the name the person sees at the top of their screen.
const IMPORT_SOURCES = [
{ key: 'wekan', product: true },
{ key: 'trello', name: 'Trello' },
{ key: 'csv', name: 'CSV / TSV' },
{ key: 'excel', name: 'Excel' },
{ key: 'jira', name: 'Jira' },
{ key: 'kanboard', name: 'Kanboard' },
{ key: 'deck', name: 'NextCloud Deck' },
{ key: 'openproject', name: 'OpenProject' },
{ key: 'github', name: 'GitHub' },
{ key: 'gitlab', name: 'GitLab' },
{ key: 'gitea', name: 'Gitea' },
{ key: 'forgejo', name: 'Forgejo' },
{ key: 'asana', name: 'Asana' },
{ key: 'zenkit', name: 'Zenkit' },
];
Template.import.helpers({
error() {
return Template.instance().error;
},
importSources() {
const setting = ReactiveCache.getCurrentSetting();
const product = productNameOrDefault(setting && setting.productName);
const current = Session.get('importSource');
return IMPORT_SOURCES.map(source => ({
key: source.key,
// "a previous export of <Product name>", which for an unbranded WeKan is
// "WeKan" and for a rebranded one is whatever it was rebranded to.
name: source.product ? `${product} (JSON, .zip)` : source.name,
selected: source.key === current,
}));
},
hasImportSource() {
return Boolean(Session.get('importSource'));
},
// The SAME selection the export popups use - one list, one meaning: on an
// export it is what goes out, on an import it is what comes in.
importParts() {
return BOARD_EXPORT_FIELDS.map(({ field, label }) => ({
field,
label,
checked: importSelection.get(field),
}));
},
currentTemplate() {
return Template.instance().steps[Template.instance()._currentStepIndex.get()];
},
@@ -383,6 +448,21 @@ Template.import.helpers({
},
});
Template.import.events({
'click .js-select-import-source'(event) {
event.preventDefault();
const source = event.currentTarget.dataset.source;
// The address still names the source, so an import page can be linked and
// the back button works - the picker sets it rather than replacing it.
FlowRouter.go(`/import/${source}`);
},
'click .js-import-part-toggle'(event) {
event.preventDefault();
const field = event.currentTarget.dataset.field;
importSelection.set(field, !importSelection.get(field));
},
});
Template.importTextarea.helpers({
instruction() {
const importSource = Session.get('importSource');
@@ -426,6 +506,10 @@ Template.importTextarea.helpers({
isTrelloImport() {
return Session.get('importSource') === 'trello';
},
// #1173: a previous export of this WeKan, as a .json or as a .zip.
isWekanImport() {
return Session.get('importSource') === 'wekan';
},
isExcelImport() {
return Session.get('importSource') === 'excel';
},
+35 -31
View File
@@ -346,7 +346,7 @@ body.mobile-mode [id^="swimlane-"] .list[style*="--list-width"]:not(.list-collap
margin: 5px auto;
z-index: 30;
padding: 5px;
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
white-space: nowrap;
display: block;
width: auto;
@@ -403,7 +403,7 @@ body.mobile-mode [id^="swimlane-"] .list[style*="--list-width"]:not(.list-collap
overflow: hidden;
white-space: normal;
display: block !important;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
line-height: 1.2;
color: #333;
padding: 0;
@@ -476,8 +476,12 @@ form.inlined-form .list-composer {
the cards. The scale is now heading 16 > card title 14 >= add-card link 13,
kept in that order by tests/listTypeScale.test.cjs. Mobile keeps its own
larger sizes (the !important rules further down) - a phone is read at arm's
length. */
font-size: 16px;
length.
#6587: written in rem, not px. The three sizes are the same as before at the
default root size (1rem = 16px), but a px size cannot follow Member Settings
/ Font size, which sets the root font size - so a board's type was the one
part of the UI that ignored the setting entirely. */
font-size: 1rem;
line-height: 1.2;
margin: 0;
font-weight: bold;
@@ -497,7 +501,7 @@ form.inlined-form .list-composer {
background: transparent;
color: #8c8c8c;
font-weight: bold;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
vertical-align: middle;
}
.list-rotated {
@@ -550,7 +554,7 @@ form.inlined-form .list-composer {
border-radius: 0;
background-color: transparent;
cursor: pointer;
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
line-height: 1.2;
min-width: 30px;
text-align: center;
@@ -621,7 +625,7 @@ form.inlined-form .list-composer {
}
.list.list-collapsed .list-header .list-rotated h2.list-header-name {
width: 100%;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
height: 100%;
line-height: 1.2;
padding: 0;
@@ -672,7 +676,7 @@ form.inlined-form .list-composer {
}
.list.list-collapsed .list-header .list-rotated h2.list-header-name {
width: 100%;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
height: 100%;
line-height: 1.2;
padding: 0;
@@ -723,7 +727,7 @@ form.inlined-form .list-composer {
}
.list.list-collapsed .list-header .list-rotated h2.list-header-name {
width: 100%;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
height: 100%;
line-height: 1.2;
padding: 0;
@@ -774,7 +778,7 @@ form.inlined-form .list-composer {
.list-header .cardCount {
color: #8c8c8c;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
}
.list-header .list-header-plus-top,
@@ -784,7 +788,7 @@ form.inlined-form .list-composer {
padding-inline-start: 4px;
}
.js-open-list-menu {
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
vertical-align: middle;
line-height: 1.2;
}
@@ -828,7 +832,7 @@ form.inlined-form .list-composer {
"Add" button was the biggest thing on the board while typing. 34px is a
comfortable touch target that still reads as a control rather than a slab. */
min-height: 34px;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
}
.list-body .open-minicard-composer {
border-radius: 2px;
@@ -837,7 +841,7 @@ form.inlined-form .list-composer {
/* Smaller than a card title, because it is not a card: it is the affordance
for making one. At the inherited 16px it was the largest text in the list
body, level with the board's real content. */
font-size: 13px;
font-size: 0.8125rem;
padding: 7px 10px;
position: relative;
text-decoration: none;
@@ -956,7 +960,7 @@ form.inlined-form .list-composer {
}
.list.mobile-view .list-header .list-header-menu-icon {
padding: 14px;
font-size: 40px !important;
font-size: calc(40px * var(--wekan-ui-font-scale, 1)) !important;
text-align: center;
/* Force positioning for iPhone */
position: absolute !important;
@@ -967,7 +971,7 @@ form.inlined-form .list-composer {
}
.list.mobile-view .list-header .list-header-handle {
padding: 14px;
font-size: 48px !important;
font-size: calc(48px * var(--wekan-ui-font-scale, 1)) !important;
text-align: center;
/* Force positioning for iPhone */
position: absolute !important;
@@ -985,7 +989,7 @@ form.inlined-form .list-composer {
grid-row: 1;
grid-column: 2;
align-self: end;
font-size: 20px !important;
font-size: calc(20px * var(--wekan-ui-font-scale, 1)) !important;
font-weight: bold;
line-height: 1.2;
padding-bottom: 2px;
@@ -997,7 +1001,7 @@ form.inlined-form .list-composer {
text-align: start;
padding-inline-start: 0;
margin-inline-start: 0;
font-size: 16px !important;
font-size: calc(16px * var(--wekan-ui-font-scale, 1)) !important;
line-height: 1.2;
}
.list.mobile-view .list-header .list-header-menu {
@@ -1097,7 +1101,7 @@ form.inlined-form .list-composer {
}
.list-header .list-header-menu-icon {
padding: 14px;
font-size: 40px;
font-size: calc(40px * var(--wekan-ui-font-scale, 1));
text-align: center;
/* iOS Safari fallback positioning */
position: absolute;
@@ -1107,7 +1111,7 @@ form.inlined-form .list-composer {
}
.list-header .list-header-handle {
padding: 14px;
font-size: 48px;
font-size: calc(48px * var(--wekan-ui-font-scale, 1));
text-align: center;
/* iOS Safari fallback positioning */
position: absolute;
@@ -1129,7 +1133,7 @@ form.inlined-form .list-composer {
grid-row: 1;
grid-column: 2;
align-self: end;
font-size: 20px;
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
line-height: 1.2;
padding-bottom: 2px;
@@ -1138,7 +1142,7 @@ form.inlined-form .list-composer {
grid-row: 2;
grid-column: 2;
align-self: start;
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
line-height: 1.2;
}
.list-header .list-header-menu {
@@ -1184,7 +1188,7 @@ form.inlined-form .list-composer {
grid-row: 1/3 !important;
grid-column: 3 !important;
padding: 14px !important;
font-size: 40px !important;
font-size: calc(40px * var(--wekan-ui-font-scale, 1)) !important;
text-align: center !important;
}
@@ -1198,7 +1202,7 @@ form.inlined-form .list-composer {
grid-row: 1/3 !important;
grid-column: 4 !important;
padding: 14px !important;
font-size: 48px !important;
font-size: calc(48px * var(--wekan-ui-font-scale, 1)) !important;
text-align: center !important;
}
@@ -1206,7 +1210,7 @@ form.inlined-form .list-composer {
grid-row: 1 !important;
grid-column: 2 !important;
align-self: end !important;
font-size: 20px !important;
font-size: calc(20px * var(--wekan-ui-font-scale, 1)) !important;
font-weight: bold !important;
line-height: 1.2 !important;
padding-bottom: 2px !important;
@@ -1219,7 +1223,7 @@ form.inlined-form .list-composer {
text-align: start !important;
padding-inline-start: 0 !important;
margin-inline-start: 0 !important;
font-size: 16px !important;
font-size: calc(16px * var(--wekan-ui-font-scale, 1)) !important;
line-height: 1.2 !important;
}
@@ -1249,7 +1253,7 @@ form.inlined-form .list-composer {
grid-row: 1/3 !important;
grid-column: 3 !important;
padding: 14px !important;
font-size: 40px !important;
font-size: calc(40px * var(--wekan-ui-font-scale, 1)) !important;
text-align: center !important;
}
@@ -1263,7 +1267,7 @@ form.inlined-form .list-composer {
grid-row: 1/3 !important;
grid-column: 4 !important;
padding: 14px !important;
font-size: 48px !important;
font-size: calc(48px * var(--wekan-ui-font-scale, 1)) !important;
text-align: center !important;
}
@@ -1271,7 +1275,7 @@ form.inlined-form .list-composer {
grid-row: 1 !important;
grid-column: 2 !important;
align-self: end !important;
font-size: 20px !important;
font-size: calc(20px * var(--wekan-ui-font-scale, 1)) !important;
font-weight: bold !important;
line-height: 1.2 !important;
padding-bottom: 2px !important;
@@ -1281,7 +1285,7 @@ form.inlined-form .list-composer {
grid-row: 2 !important;
grid-column: 2 !important;
align-self: start !important;
font-size: 16px !important;
font-size: calc(16px * var(--wekan-ui-font-scale, 1)) !important;
line-height: 1.2 !important;
}
@@ -1618,7 +1622,7 @@ form.inlined-form .list-composer {
.list.list-collapsed .list-header-name-collapsed {
writing-mode: vertical-rl;
text-align: center;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
color: #333;
margin: 0;
padding: 0;
@@ -1642,7 +1646,7 @@ form.inlined-form .list-composer {
display: block !important;
align-items: initial;
justify-content: initial;
font-size: 16px !important;
font-size: calc(16px * var(--wekan-ui-font-scale, 1)) !important;
box-shadow: none;
margin: 5px auto;
z-index: 10;
+40 -2
View File
@@ -423,8 +423,46 @@ Template.list.onRendered(function () {
scrolled = true;
}
}
// vertical auto-scroll stays on the board canvas
const canvas = document.querySelector('.board-canvas');
// #6584: VERTICALLY, the thing to scroll is the LIST the pointer is over,
// not the board canvas. `.list-body` is `overflow-y: scroll` (list.css) and
// is what holds the cards; `.board-canvas` is `overflow-y: auto` and holds
// the swimlanes. Scrolling the canvas moves the whole board, which is what
// the report describes: "The whole Page/Site scrolls down and not the Line".
//
// It only showed up DOWNWARDS, and the asymmetry is the tell. Dragging up,
// the canvas is usually already at scrollTop 0, so computeEdgeScroll
// returned null, nothing here fired, and jQuery UI's own `scroll` option -
// which acts on the placeholder's scrollParent, i.e. the list body - scrolled
// the list, exactly as expected. Dragging down, the canvas nearly always HAS
// room, so this fired first and scrolled the board instead of the list.
//
// The canvas is still scrolled, but only once the list under the pointer
// cannot go further that way - so a drag down a long list scrolls the list,
// and a drag past the end of it moves on to the board. Same shape as the
// horizontal case above, which already picks the lane under the pointer.
let scrolledList = false;
const listBodies = document.querySelectorAll('.list-body');
const listRects = Array.prototype.map.call(listBodies, el =>
el.getBoundingClientRect());
// findLaneUnderPointer is a plain rectangle hit-test; the name is about
// where it was first used, not what it can be given.
const listIndex = findLaneUnderPointer(listRects, event.clientX, event.clientY);
if (listIndex !== -1) {
const nextListTop = computeEdgeScroll({
pointer: event.clientY,
lowEdge: listRects[listIndex].top,
highEdge: listRects[listIndex].bottom,
scrollPos: listBodies[listIndex].scrollTop,
scrollSize: listBodies[listIndex].scrollHeight,
clientSize: listBodies[listIndex].clientHeight,
});
if (nextListTop !== null) {
listBodies[listIndex].scrollTop = nextListTop;
scrolled = true;
scrolledList = true;
}
}
const canvas = scrolledList ? null : document.querySelector('.board-canvas');
if (canvas) {
const crect = canvas.getBoundingClientRect();
const nextTop = computeEdgeScroll({
+72 -2
View File
@@ -53,6 +53,45 @@ function openCardWindow(cardId) {
}
}
// #6465: "Clicking the mini card again to close the popout is still not
// possible."
//
// On a desktop-sized screen the card details are NOT the route: clicking a
// minicard only writes `openCards`, and boardBody renders one draggable window
// per id in that list. The URL stays on the board. So closing by navigating to
// the board - which is what the second click did - cleared `currentCard` and
// left the window on screen, because nothing had taken the card OUT of
// `openCards`. Only the window's own X button did that, which is why closing
// worked from the card and not from the minicard.
//
// This is the close button's logic (cardDetails.js, `click
// .js-close-card-details`), so both ways of closing a card do the same thing.
function cardWindowIsOpen(cardId) {
if (!Utils.isMiniScreen() && (Session.get('openCards') || []).includes(cardId)) {
return true;
}
return Session.equals('currentCard', cardId);
}
function closeCardWindow(cardId) {
Session.set(
'openCards',
(Session.get('openCards') || []).filter(id => id !== cardId),
);
if (Session.equals('currentCard', cardId)) {
Session.set('currentCard', null);
}
Session.delete('popupCardId');
Session.delete('popupCardBoardId');
// A card opened by its own URL IS the route, and then the board has to be
// navigated back to. A card opened by clicking a minicard is not, and
// navigating would reset the board view for nothing.
const boardId = Session.get('currentBoard');
if (FlowRouter.current()?.params?.cardId === cardId && boardId) {
Utils.goBoardId(boardId);
}
}
Template.listBody.onCreated(function () {
// for infinite scrolling
this.cardlimit = new ReactiveVar(InfiniteScrollIter);
@@ -252,10 +291,35 @@ Template.listBody.onCreated(function () {
return;
}
// #4990: clicking the title TEXT edits it on the board, the way a list's
// title is edited. The minicard's own inlinedForm has already opened by the
// time this runs (it is delegated deeper in the DOM), so all that is left
// here is to not open the card on top of the editor - and to stop the
// wrapper link navigating away from the card being renamed. The rest of the
// minicard still opens it, and so does a middle-click or Ctrl-click on the
// link, which never reaches this handler.
const clickedEditableTitle =
clickedTitle && $target.closest('.js-open-inlined-form').length > 0;
if (clickedEditableTitle || $target.closest('.js-minicard-title-form').length > 0) {
evt.stopImmediatePropagation();
evt.preventDefault();
return;
}
// Title clicks should open the regular board card details view.
if (clickedTitle && !clickedLinkedReference) {
evt.stopImmediatePropagation();
evt.preventDefault();
// #6465: clicking the OPEN card again closes it. The toggle below has
// always been here, but this branch returned before ever reaching it - and
// the title covers most of the minicard, so in practice the second click
// almost always lands here and just re-opened the card that was already
// open. That is why it read as a missing feature rather than a dead
// branch. Same call as below, so both click targets behave alike.
if (cardWindowIsOpen(card._id)) {
closeCardWindow(card._id);
return;
}
Session.delete('popupCardId');
Session.delete('popupCardBoardId');
Session.set('currentCard', card._id);
@@ -265,12 +329,18 @@ Template.listBody.onCreated(function () {
if (Utils.isMiniScreen()) {
evt.preventDefault();
// ...and on a phone, where the card is a popup rather than a pane, the
// second click closes that popup for the same reason.
if (Session.equals('popupCardId', card._id) && Popup.isOpen()) {
Popup.back();
return;
}
Session.set('popupCardId', card._id);
this.cardDetailsPopup(evt);
} else if (Session.equals('currentCard', card._id)) {
} else if (cardWindowIsOpen(card._id)) {
evt.stopImmediatePropagation();
evt.preventDefault();
Utils.goBoardId(Session.get('currentBoard'));
closeCardWindow(card._id);
} else {
// Allow normal href navigation, but if it's the same card URL,
// we'll handle it by directly setting the session
+29 -23
View File
@@ -138,6 +138,22 @@ template(name="listActionPopup")
| {{_ 'copy-link-to-clipboard'}}
span.copied-tooltip {{_ 'copied'}}
hr
//- #1173: exporting a list is the board export restricted to it. Reading, like
the link above it, so it is not behind the edit permissions below. The menu
already says what it is about, so the rows are named "Export" and "Import"
and not "Export list" - the noun was in the heading above them either way.
Import WRITES, so it is offered only to somebody who may change the board;
`canImportIntoBoard` is the same question the popup itself asks
(client/components/boards/exportScope.js).
ul.pop-over-list
li: a.js-export-list
i.fa.fa-upload
span {{_ 'export'}}
if canImportIntoBoard
li: a.js-import-list
i.fa.fa-download
span {{_ 'import'}}
hr
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
ul.pop-over-list
@@ -319,9 +335,13 @@ template(name="setListColorPopup")
button.primary.confirm.js-submit {{_ 'save'}}
button.js-remove-color.negate.wide.right {{_ 'unset-color'}}
template(name="copyListPopup")
label(for='copy-list-title') {{_ 'title'}}:
input#copy-list-title.full-line(type="text" value="{{title}}" autofocus)
//- WHERE a list goes: the board, the swimlane, which list to put it left or
right of, and Done. Copy list and Move list asked the same question with the
same form; only the title field above it differs, so that stays with each.
The dialog comes from the popup that includes this and is read from the
template INSTANCE, because inside `each boards` the data context is a board.
client/components/lists/listHeader.js
template(name="listDestinationPicker")
label {{_ 'boards'}}:
select.js-select-boards
each boards
@@ -343,29 +363,15 @@ template(name="copyListPopup")
.edit-controls.clearfix
button.primary.confirm.js-done {{_ 'done'}}
template(name="copyListPopup")
label(for='copy-list-title') {{_ 'title'}}:
input#copy-list-title.full-line(type="text" value="{{title}}" autofocus)
+listDestinationPicker(dialog=dialog)
template(name="moveListPopup")
label(for='move-list-title') {{_ 'title'}}:
input#move-list-title.full-line(type="text" value="{{title}}" autofocus)
label {{_ 'boards'}}:
select.js-select-boards
each boards
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
each swimlanes
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
label {{_ 'lists'}}:
select.js-select-lists
each lists
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
div
input(type="radio" name="list-position" value="left" checked id="list-position-left" style="display: inline")
label(for="list-position-left") {{_ 'left-of-list'}}
div
input(type="radio" name="list-position" value="right" id="list-position-right" style="display: inline")
label(for="list-position-right") {{_ 'right-of-list'}}
.edit-controls.clearfix
button.primary.confirm.js-done {{_ 'done'}}
+listDestinationPicker(dialog=dialog)
template(name="addListPopup")
form.js-add-list-form
+44 -20
View File
@@ -314,6 +314,8 @@ Template.listActionPopup.events({
}
Popup.back();
},
'click .js-export-list': Popup.open('exportList'),
'click .js-import-list': Popup.open('importList'),
'click .js-add-list': Popup.open('addList'),
'click .js-set-list-width': Popup.open('setListWidth'),
'click .js-set-color-list': Popup.open('setListColor'),
@@ -442,28 +444,50 @@ Template.listMorePopup.events({
}),
});
// The three selects of the copy/move list dialogs, in one template. The dialog
// is kept on THIS instance: inside `each boards` the data context is a board,
// so a helper reaching into the context for it would find nothing.
Template.listDestinationPicker.onCreated(function () {
this.autorun(() => {
const data = Template.currentData();
this.dialog = data && data.dialog;
});
});
Template.listDestinationPicker.helpers({
boards() {
return Template.instance().dialog.boards();
},
swimlanes() {
return Template.instance().dialog.swimlanes();
},
lists() {
return Template.instance().dialog.lists();
},
isDialogOptionBoardId(boardId) {
return Template.instance().dialog.isDialogOptionBoardId(boardId);
},
isDialogOptionSwimlaneId(swimlaneId) {
return Template.instance().dialog.isDialogOptionSwimlaneId(swimlaneId);
},
isDialogOptionListId(listId) {
return Template.instance().dialog.isDialogOptionListId(listId);
},
isTitleDefault(title) {
return Template.instance().dialog.isTitleDefault(title);
},
});
function registerListDialogTemplate(templateName) {
// The markup those helpers feed is one template - `listDestinationPicker` in
// listHeader.jade, included by both popups - so the helpers are registered on
// it, once, below. Each popup provides the `dialog` and keeps the events: a
// change or a click inside the picker bubbles up to the popup that includes
// it, which is the one holding the dialog.
Template[templateName].helpers({
boards() {
return Template.instance().dialog.boards();
},
swimlanes() {
return Template.instance().dialog.swimlanes();
},
lists() {
return Template.instance().dialog.lists();
},
isDialogOptionBoardId(boardId) {
return Template.instance().dialog.isDialogOptionBoardId(boardId);
},
isDialogOptionSwimlaneId(swimlaneId) {
return Template.instance().dialog.isDialogOptionSwimlaneId(swimlaneId);
},
isDialogOptionListId(listId) {
return Template.instance().dialog.isDialogOptionListId(listId);
},
isTitleDefault(title) {
return Template.instance().dialog.isTitleDefault(title);
dialog() {
return Template.instance().dialog;
},
});
+1 -1
View File
@@ -81,7 +81,7 @@
}
.accessibility-page h2 {
font-size: 24px;
font-size: calc(24px * var(--wekan-ui-font-scale, 1));
margin-bottom: 20px;
color: #4d4d4d;
}
+32
View File
@@ -47,6 +47,38 @@
margin: 0 0 4px;
padding: 0;
}
/* Two switches, one row: what the theme IS on the start side ("Default (no
override)"), and where it ALSO applies on the end side ("All Boards", which
paints the overview's tiles in the theme's lighter colour). They are one
decision about the same theme, so they are one row rather than two stacked
menus, and each takes half the width so neither reads as the heading of the
other. On a narrow popup the row wraps and they stack, which is the same
layout one item at a time. */
.theme-color-picker ul.pop-over-list.theme-scope-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.theme-color-picker ul.pop-over-list.theme-scope-row > li {
flex: 1 1 12em;
min-width: 0;
}
/* The chosen one is filled, not merely ticked: the tick alone is easy to miss on
a row that is otherwise identical to the one beside it. Same fill as the
selected row of the All Boards left menu and of the Admin Panel - one
selected-thing look everywhere.
`--theme-accent-fill`, not `--theme-accent`: the accent is one colour - the
solid end of a colour-slide theme - so on clearorange this button was flat
orange while the menu row behind the popup slid, which is two looks for one
selected thing. The fill is the slide where the theme has one. */
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a,
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a:hover {
background: var(--theme-accent-fill, var(--theme-accent, #2980b9));
color: #fff !important;
}
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a i {
color: #fff !important;
}
/* As many colour swatches visible at once as the width allows.
*
+2
View File
@@ -372,6 +372,7 @@ Template.globalSearch.helpers({
operator_debug: TAPi18n.__('operator-debug'),
operator_org: TAPi18n.__('operator-org'),
operator_team: TAPi18n.__('operator-team'),
operator_number: TAPi18n.__('operator-number'),
predicate_overdue: TAPi18n.__('predicate-overdue'),
predicate_archived: TAPi18n.__('predicate-archived'),
predicate_all: TAPi18n.__('predicate-all'),
@@ -414,6 +415,7 @@ Template.globalSearch.helpers({
['\n- ', 'globalSearch-instructions-operator-creator'],
['\n- ', 'globalSearch-instructions-operator-org'],
['\n- ', 'globalSearch-instructions-operator-team'],
['\n- ', 'globalSearch-instructions-operator-number'],
['\n- ', 'globalSearch-instructions-operator-due'],
['\n- ', 'globalSearch-instructions-operator-created'],
['\n- ', 'globalSearch-instructions-operator-modified'],
@@ -110,6 +110,20 @@ Meteor.startup(() => {
}
}
// Member Settings / Change color, beside "Default (no override)": paint the All
// Boards tiles in the theme's lighter colour instead of each board's own. It is
// a class on <body> rather than one on the list, because the preference is the
// user's and follows them to every page that shows board tiles.
Tracker.autorun(() => {
const user = Meteor.user();
const on = !!(user && user.profile && user.profile.allBoardsThemeTiles);
try {
document.body.classList.toggle('has-theme-board-tiles', on);
} catch (_) {
// document.body may not exist yet in exotic embeddings; ignore.
}
});
Tracker.autorun(() => {
const user = Meteor.user();
const globalColor = user && user.profile && user.profile.globalThemeColor;
+49 -26
View File
@@ -40,7 +40,7 @@
row-gap: 4px;
}
#header #header-main-bar h1 {
font-size: 20px;
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
/* A flex box with its own centred line, so the title sits on the bar's middle with
everything else instead of on the middle of a line box far taller than its text. */
display: flex;
@@ -127,11 +127,11 @@
color: #f2f2f2;
padding: 0 6px;
height: 28px;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
display: inline-flex;
align-items: center;
gap: 4px;
line-height: 28px;
line-height: calc(28px * var(--wekan-ui-font-scale, 1));
margin: 0 4px;
white-space: nowrap;
}
@@ -209,10 +209,10 @@
color: #f2f2f2;
padding: 0;
height: 28px;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
float: inline-start;
overflow: hidden;
line-height: 28px;
line-height: calc(28px * var(--wekan-ui-font-scale, 1));
margin: 0 12px;
}
/* The sidebar hamburger sits at the far right; drop its wide 12px side margins so
@@ -228,7 +228,7 @@
#header #header-main-bar .board-header-btn i.fa {
float: inline-start;
display: block;
line-height: 28px;
line-height: calc(28px * var(--wekan-ui-font-scale, 1));
color: #f2f2f2;
margin: 0 10px;
}
@@ -246,7 +246,7 @@
#header #header-main-bar .board-header-btn .board-header-btn-icon {
float: inline-start;
display: block;
line-height: 28px;
line-height: calc(28px * var(--wekan-ui-font-scale, 1));
color: #27ae60;
margin: 0 10px;
cursor: pointer;
@@ -299,7 +299,7 @@ body:not(.board-view) #header #header-main-bar h1 {
the same, by naming this bar in the selector list the main bar already has
(boardColors.css) rather than by restating each hex a second time. */
background: #2980b9;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
display: flex;
z-index: 1000;
padding: 10px var(--wekan-header-gutter);
@@ -348,6 +348,29 @@ body:not(.board-view) #header #header-main-bar h1 {
margin-block: 0;
}
/* The page title beside the house. On a board, and for a board admin, it is
also the rename button (client/components/main/header.js) - so it says so on
hover, the way a list's heading does.
`min-width` is the part that is easy to miss: a board whose title is EMPTY
renders no text at all, and an element with no content is zero pixels wide
and cannot be clicked. The minimum keeps a strip of the bar clickable, so an
empty title can still be given one. `min-height` does the same for the other
axis, and the padding makes the target bigger than the glyphs. */
#header-quick-access .header-page-title.is-editable {
cursor: text;
display: inline-block;
min-width: 4em;
min-height: 1.3em;
padding: 2px 4px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
#header-quick-access .header-page-title.is-editable:hover {
background-color: rgba(255, 255, 255, 0.1);
}
#header-quick-access .home-icon {
display: flex;
align-items: center;
@@ -382,7 +405,7 @@ body:not(.board-view) #header #header-main-bar h1 {
}
#header-quick-access .home-icon .fa-home {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
margin-inline-end: 4px;
}
@@ -397,7 +420,7 @@ body:not(.board-view) #header #header-main-bar h1 {
The gap to the logo beside it is the 4 + 4 of the two margins, like every
other pair of neighbours in this bar. docs/Design/Page/Header.md */
#header-quick-access .allBoards {
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
padding-block: 4px;
padding-inline: 0;
}
@@ -421,7 +444,7 @@ body.mobile-mode #header-quick-access .home-icon .header-home-link {
body.mobile-mode #header-quick-access .home-icon .header-home-link .fa-home,
body.mobile-mode #header-quick-access .home-icon .fa-home {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
margin-inline-end: 0;
}
@@ -649,7 +672,7 @@ body.mobile-mode #header-quick-access .allBoards {
line-height: normal !important;
margin: 0 !important;
transition: all 0.2s ease !important;
font-size: 14px !important;
font-size: calc(14px * var(--wekan-ui-font-scale, 1)) !important;
}
#header-quick-access .mobile-mode-toggle .board-header-btn i.active {
@@ -762,11 +785,11 @@ body.mobile-mode #header-quick-access .allBoards {
readable-font minimum (~44px / 16px). Enlarged vertically + bigger font;
width is unchanged so this does not worsen the horizontal icon crowding. */
height: 44px;
line-height: 44px;
font-size: 16px;
line-height: calc(44px * var(--wekan-ui-font-scale, 1));
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
}
#header #header-main-bar .board-header-btn i.fa {
line-height: 32px;
line-height: calc(32px * var(--wekan-ui-font-scale, 1));
}
#header #header-main-bar .board-header-btn i.fa + span {
display: none;
@@ -786,7 +809,7 @@ body.mobile-mode #header-quick-access .allBoards {
}
#header-quick-access .home-icon .fa-home {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
margin-inline-end: 4px;
}
@@ -796,7 +819,7 @@ body.mobile-mode #header-quick-access .allBoards {
two things, not the inset. docs/Design/Page/Header.md */
#header-quick-access .home-icon .header-home-link {
padding: 4px 8px 4px 6px;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
}
/* Ensure All Boards text is visible on mobile */
@@ -808,12 +831,12 @@ body.mobile-mode #header-quick-access .allBoards {
/* Adjust for very small screens */
@media screen and (max-width: 480px) {
#header-quick-access .home-icon .header-home-link {
font-size: 11px;
font-size: calc(11px * var(--wekan-ui-font-scale, 1));
padding: 3px 6px;
}
#header-quick-access .home-icon .fa-home {
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
margin-inline-end: 3px;
}
}
@@ -1079,7 +1102,7 @@ body.mobile-mode #header-quick-access .allBoards {
}
#header-quick-access .home-icon .header-home-link {
font-size: 16px !important; /* Much larger text */
font-size: calc(16px * var(--wekan-ui-font-scale, 1)) !important; /* Much larger text */
/* 6px at the START here too - the tap target grows, the INSET does not.
At 12px the house began 6px further in on a small phone than at every
other width. docs/Design/Page/Header.md */
@@ -1087,7 +1110,7 @@ body.mobile-mode #header-quick-access .allBoards {
}
#header-quick-access .home-icon .fa-home {
font-size: 20px !important; /* Much larger icon */
font-size: calc(20px * var(--wekan-ui-font-scale, 1)) !important; /* Much larger icon */
margin-inline-end: 6px !important;
}
@@ -1098,11 +1121,11 @@ body.mobile-mode #header-quick-access .allBoards {
/* Make mobile mode toggle larger */
#header-quick-access .mobile-mode-toggle .board-header-btn {
padding: 7px 17px !important;
font-size: 16px !important;
font-size: calc(16px * var(--wekan-ui-font-scale, 1)) !important;
}
#header-quick-access .mobile-mode-toggle .board-header-btn i {
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
}
}
@@ -1419,7 +1442,7 @@ body.mobile-mode #header-quick-access .allBoards {
}
.iphone-device #header-quick-access .home-icon .header-home-link .fa-home,
.iphone-device #header-quick-access .home-icon .fa-home {
font-size: 16px !important;
font-size: calc(16px * var(--wekan-ui-font-scale, 1)) !important;
margin-inline-end: 0 !important;
}
/* No auto margin: everything in this bar packs from the START and wraps
@@ -1554,7 +1577,7 @@ body.mobile-mode #header-quick-access .allBoards {
.iphone-device #header-quick-access .home-icon .fa-home,
.iphone-device .wrapper ~ #header-quick-access .home-icon .fa-home,
.iphone-device body:not(.board-view) #header-quick-access .home-icon .fa-home {
font-size: 16px !important;
font-size: calc(16px * var(--wekan-ui-font-scale, 1)) !important;
margin-inline-end: 0 !important;
padding: 0 !important;
}
@@ -1731,7 +1754,7 @@ body.mobile-mode #header-quick-access .allBoards {
padding: 0 !important;
min-width: 44px !important;
height: 44px !important;
line-height: 44px !important;
line-height: calc(44px * var(--wekan-ui-font-scale, 1)) !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
+8 -4
View File
@@ -27,14 +27,18 @@ template(name="header")
strip always on screen and already short of width. The root names the
place and the tooltip carries the rest.
client/components/main/header.js
span.header-page-title(title="{{headerTitleFullPath}}")
//- The board's NAME is the button that renames it: clicking the title
text opens the same "Change title" popup the pencil beside it used to
open, so there is one thing to click instead of a word and an icon
that do the same job. Board admins only - everybody else gets the
plain title. The `is-editable` class also gives the element a minimum
width, so a board whose title is EMPTY still has an area to click.
span.header-page-title(title="{{headerTitleFullPath}}"
class="{{#if canEditBoardTitle}}js-edit-board-title is-editable{{/if}}")
if headerTitleKey
| {{_ headerTitleKey}}
else
= headerTitleText
//- The pencil that renames the board, immediately right of its name.
if isBoardPage
+boardEditTitleButton
// Logo - visible; on mobile constrained by CSS
unless currentSetting.hideLogo
+20
View File
@@ -136,6 +136,14 @@ Template.header.helpers({
isBoardPage() {
return Boolean(Utils.getCurrentBoardId());
},
// True when the title in this bar is a BOARD's title and the user may rename
// it: the title text itself is then the rename button. The pencil that used
// to sit beside it is gone - one thing to click, not two that did the same.
canEditBoardTitle() {
return Boolean(
Utils.getCurrentBoardId() && ReactiveCache.getCurrentUser()?.isBoardAdmin(),
);
},
isAllBoardsPage() {
return ALL_BOARDS_VIEW_ROUTES.includes(FlowRouter.getRouteName());
},
@@ -342,6 +350,18 @@ Template.header.events({
// `no-title` pop-over with nothing to shut it but clicking away.
'click .js-open-starred-boards': Popup.open('starredBoards', { titleKey: 'allboards.starred' }),
// Rename the board by clicking its name. The popup is the EXISTING
// `boardChangeTitlePopup` - the same form the pencil opened, with the title
// and the description in it - so this changes only what you click to get
// there. It is opened with the board as its data context, because this bar's
// context is the page and not the board, and the form fills its fields from
// `title` and `description`.
'click .js-edit-board-title'(evt) {
const board = Utils.getCurrentBoard();
if (!board || !ReactiveCache.getCurrentUser()?.isBoardAdmin()) return;
Popup.open('boardChangeTitle').call(board, evt);
},
// Star the page you are on, or unstar it. The title stored with it is the
// one in the browser tab - "Product name - All Boards / Remaining" - because
// that is what the dropdown lists, and a row saying `/allboards/remaining`
+1 -1
View File
@@ -12,7 +12,7 @@
.shortcuts-list .shortcuts-list-item .shortcuts-list-item-keys kbd {
padding: 5px 8px;
margin: 5px;
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
}
.shortcuts-list .shortcuts-list-item .shortcuts-list-item-action {
font-size: 1.4em;
+48 -19
View File
@@ -189,8 +189,15 @@ button {
6.09's 14px — which is the "everything is way too big" regression. Mobile
readability is handled by the mobile media queries, not by inflating the
desktop base font. */
font: 14px Roboto, Poppins, "Helvetica Neue", Arial, Helvetica, sans-serif;
line-height: 18px;
/* Family and SIZE separately, not the `font` shorthand. The shorthand's size
is what Member Settings / Font / Size has to be able to move, and it moves
it the same way every other size in the app moves: through the scale
variable (client/components/main/uiFont.css). As a shorthand it also reset
`line-height` to normal and the line below set it again, so splitting it
changes nothing else. */
font-family: Roboto, Poppins, "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
line-height: calc(18px * var(--wekan-ui-font-scale, 1));
color: #4d4d4d;
/* Improve text rendering */
-webkit-font-smoothing: antialiased;
@@ -243,8 +250,30 @@ body.mobile-mode #content {
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
height: calc(100vh - 48px);
height: calc(100dvh - 48px);
/* #6488, and the reason that issue kept coming back after being closed three
times: THE LAST PIECE OF VIEWPORT ARITHMETIC. This said
`height: calc(100dvh - 48px)`, and 48px is a guess at how tall the header is.
The header is not 48px and is not any one number - Utils publishes
--wekan-header-height from a ResizeObserver for exactly this reason, because
the quick-access bar WRAPS to a second and third row depending on the
language and the window width, and its own comment says "every fixed number
for it has been wrong for some of those".
On a phone, where the bar does wrap, #content was therefore taller than the
room under the header by however much the header exceeded 48px, so its
bottom sat BELOW the bottom of the screen. body.mobile-mode is
`position: fixed` and the phone rules make it `overflow: hidden`, so that
strip is unreachable: everything below it inherits the error through the
`height: 100%` chain (boardsList.css), and the last rows of the board list
end up where no gesture can reach them. That is "in mobile board menu you
cant scroll boards" - the list scrolls, it just ends off screen.
Nothing has to be computed here. body is a flex COLUMN of exactly one
viewport and this is its `flex: 1` item (below), so the space left under the
header - whatever the header turns out to be this paint - IS this box. The
arithmetic was overriding a correct answer with a wrong one.
`min-height: 0` so it can be SHORTER than its content: a flex item will not
shrink below its content otherwise, and an inner scroller whose ancestor
refuses to shrink never scrolls. */
min-height: 0;
overscroll-behavior: contain;
}
#content {
@@ -286,7 +315,7 @@ body.mobile-mode #content {
#modal .modal-content .modal-close-btn {
display: block;
float: inline-end;
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
}
#modal .modal-content-wide {
width: min(800px, 95vw);
@@ -303,7 +332,7 @@ body.mobile-mode #content {
#modal .modal-content-wide .modal-close-btn {
display: block;
float: inline-end;
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
}
/* Archived boards modal: full-width panel that extends to the bottom of the browser
window (so archived boards show full width with maximum vertical space). It is the
@@ -329,12 +358,12 @@ body.mobile-mode #content {
/* #6465: heading sizes back to the 6.09 values (22/18/16px) — the vw clamps
resolved to 24/20/18px on desktop and compounded the density regression. */
h1 {
font-size: 22px;
font-size: calc(22px * var(--wekan-ui-font-scale, 1));
line-height: 1.2em;
margin: 0 0 9px;
}
h2 {
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
line-height: 1.2em;
margin: 0 0 7px;
}
@@ -342,7 +371,7 @@ h3,
h4,
h5,
h6 {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
line-height: 1.25em;
margin: 0 0 5px;
}
@@ -602,11 +631,11 @@ a:not(.disabled).is-active i.fa {
max-width: min(600px, 90vw);
}
.big-message h1 {
font-size: 20px;
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
margin-bottom: 27px;
}
.big-message p {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
line-height: 1.3;
}
.gutter {
@@ -655,7 +684,7 @@ a:not(.disabled).is-active i.fa {
}
.basicTabs-container .tabs-list .tab-item {
font-weight: 600;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
display: inline-block;
padding: 13px 15px;
margin: 0;
@@ -723,13 +752,13 @@ a:not(.disabled).is-active i.fa {
min-height: 44px;
min-width: 44px;
padding: 12px 16px;
font-size: 16px; /* Prevent zoom on iOS */
font-size: calc(16px * var(--wekan-ui-font-scale, 1)); /* Prevent zoom on iOS */
touch-action: manipulation;
}
/* Form elements */
input, select, textarea {
font-size: 16px; /* Prevent zoom on iOS */
font-size: calc(16px * var(--wekan-ui-font-scale, 1)); /* Prevent zoom on iOS */
padding: 12px;
min-height: 44px;
touch-action: manipulation;
@@ -817,7 +846,7 @@ a:not(.disabled).is-active i.fa {
padding: 4px 6px;
}
#header-quick-access .home-icon a .fa-home {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
margin-inline-end: 0;
}
/* `.allBoards` is the same element as `.home-icon`, and it carries no inline
@@ -876,7 +905,7 @@ a:not(.disabled).is-active i.fa {
/* Table mobile optimization */
table {
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
width: 100%;
display: block;
overflow-x: auto;
@@ -975,20 +1004,20 @@ a:not(.disabled).is-active i.fa {
/* Large displays and digital signage (1920px+) */
@media screen and (min-width: 1920px) {
body {
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
}
button, .btn, .js-toggle, .js-color-choice, .js-reaction, .close {
min-height: 56px;
min-width: 56px;
padding: 16px 20px;
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
}
.minicard {
min-height: 56px;
padding: 16px;
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
}
.list {
+153 -14
View File
@@ -48,7 +48,7 @@
}
.pop-over .header .header-title {
display: block;
line-height: 36px;
line-height: calc(36px * var(--wekan-ui-font-scale, 1));
padding-top: 5px;
margin: 0 18px;
font-weight: bold;
@@ -272,10 +272,25 @@
* Change one and change the other. */
@media screen and (min-width: 801px) {
.pop-over[data-popup='changeColorPopup'],
.pop-over[data-popup='boardChangeColorPopup'] {
.pop-over[data-popup='boardChangeColorPopup'],
/* Stickers, for the same reason as the colours: the picker is a GRID, and
width buys columns. At the default 380px it was eight icons per row and a
hundred and fifty stickers made a column taller than the screen, so
choosing one meant scrolling past most of them. */
.pop-over[data-popup='cardStickersPopup'] {
width: min(90vw, 720px) !important;
max-width: 90vw !important;
}
/* Show on Card / Show on Minicard: two dozen settings, and with one of the
two checkbox columns hidden each row is half as wide as it was. Width here
buys COLUMNS of rows (sidebar.css), which is what turns a list you scroll
into a table you read. */
.pop-over[data-popup='showOnCardPopup'],
.pop-over[data-popup='showOnMinicardPopup'] {
width: min(90vw, 900px) !important;
max-width: 90vw !important;
}
}
/* Make language popup extend to bottom of browser window */
@@ -387,7 +402,7 @@
padding: 8px !important;
border: 1px solid #ccc !important;
border-radius: 4px !important;
font-size: 14px !important;
font-size: calc(14px * var(--wekan-ui-font-scale, 1)) !important;
box-sizing: border-box !important;
}
@@ -626,7 +641,7 @@
margin: 0;
text-decoration: none;
overflow: hidden;
line-height: 33px;
line-height: calc(33px * var(--wekan-ui-font-scale, 1));
display: flex;
/* flex-wrap:wrap;*/
gap: 5px;
@@ -642,9 +657,44 @@
width: auto;
padding-inline-end: 22px;
}
/* Custom Fields, from the card's own section: a row is the field's name with the
checkbox that puts it on this card, and the pencil that edits the field at the
far end of the same line. Every other pop-over list has ONE anchor per row, so
`li` is a block and the anchor fills it; two anchors in a block stack, which
put the pencil on a line of its own under the name. The row is the flex
container here, and the name takes the space the pencil does not.
client/components/cards/cardCustomFields.jade */
.pop-over[data-popup='cardCustomFieldsPopup'] .pop-over-list li.item {
display: flex;
align-items: center;
}
.pop-over[data-popup='cardCustomFieldsPopup'] .pop-over-list li.item > a.name {
flex: 1 1 auto;
min-width: 0;
}
.pop-over[data-popup='cardCustomFieldsPopup'] .pop-over-list li.item > a.js-edit-custom-field {
flex: 0 0 auto;
}
/* The highlighted row is the SAME highlight the All Boards left menu gives its
selected entry, and the Admin Panel gives its own: the theme accent, with the
label AND the icon in white. It was a fixed dark navy (#005377), so Member
Settings highlighted "My Cards" in a colour that appeared nowhere else on the
page and stayed navy however the user had themed WeKan.
The fallback is the WeKan header blue, so with no chosen theme the row matches
the bar above it exactly. `!important` on the colour because the base rule
above pins every row's text to `#000 !important`; the icon is named separately
for the same reason. */
.pop-over-list li > a:not(.disabled):hover {
background-color: #005377;
color: #fff;
background-color: var(--theme-accent, #2980b9);
color: #fff !important;
}
.pop-over-list li > a:not(.disabled):hover i,
.pop-over-list li > a:not(.disabled):hover i.fa {
color: #fff !important;
}
.pop-over-list li > a:not(.disabled):hover .sub-name,
.pop-over-list li > a:not(.disabled):hover .quiet {
@@ -656,9 +706,9 @@
.pop-over-list li > a .sub-name {
color: #8c8c8c;
display: block;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: 400;
line-height: 15px;
line-height: calc(15px * var(--wekan-ui-font-scale, 1));
}
/* A menu whose rows are "icon - name - description" (Change Visibility, Change
@@ -813,7 +863,7 @@
top: 0px;
}
.pop-over .header .header-title {
font-size: 20px;
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
font-weight: normal;
padding-top: 8px;
}
@@ -828,7 +878,7 @@
padding: 10px 12px;
}
.pop-over .header .close-btn i.fa {
font-size: 24px;
font-size: calc(24px * var(--wekan-ui-font-scale, 1));
color: #fff;
}
.pop-over .content-wrapper {
@@ -893,14 +943,29 @@ body.mobile-mode .pop-over {
inset-inline-end: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
max-width: 100vw !important;
/* #6488. `100vh` is the viewport with the browser's toolbars HIDDEN, so a
`position: fixed` box sized that way reaches under them: on a phone the
bottom of this popup is off screen, and being fixed there is no gesture that
brings it back. `100dvh` is the viewport as it is right now. The `vh` pair
stays first as the fallback where `dvh` is unknown - the same fix
body.mobile-mode already carries in layouts.css. */
height: 100vh !important;
max-height: 100vh !important;
height: 100dvh !important;
max-height: 100dvh !important;
}
body.mobile-mode .pop-over .content-wrapper {
width: 100% !important;
height: calc(100vh - 48px) !important;
max-height: calc(100vh - 48px) !important;
/* #6488. This was `calc(100vh - 48px)`, which is two guesses at once: the
viewport measured with the toolbars hidden, minus a fixed 48px for the
popup's own title bar. When the title wraps - a long board name, a
translated label - the bar is taller than 48px and the list inside this
wrapper ends below the screen, so its last rows cannot be scrolled to.
Nothing needs measuring: `.pop-over` above is a flex column of exactly one
viewport, `.pop-over .header` is `flex-shrink: 0`, and this wrapper is
already `flex: 1 1 auto; min-height: 0` - so it is whatever the title bar
leaves, at whatever height the title bar really is. */
overflow-y: auto !important;
overflow-x: hidden !important;
}
@@ -968,9 +1033,83 @@ body.mobile-mode .pop-over .content-wrapper {
not a row you can click. docs/Features/Board/Starred.md */
.pop-over .pop-over-list-title {
padding: 4px 12px;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.6;
}
/* EVERY export popup - board, swimlane, list, card - two panes when the window
has room for them. They are one popup with one scope each (#1173), so the
layout is written once, against the `.export-board-panes` the shared body
draws, rather than against the board popup's name.
WHAT to include on one side, what to export TO on the other, so the whole
menu is visible at once instead of being a column somebody scrolls past
twenty entries of. The same idea as the language list - width buys layout -
taken one step further, because this menu has two different KINDS of thing in
it and they read better side by side than stacked.
They are grid COLUMNS, which is what makes the right-to-left case free: a
mirrored page puts the first column on the right, so the selection lands on
the right and the formats on the left with no second rule to write or forget.
Desktop only. Below 800px popup.css lays every popup out as a full-screen
sheet and the panes stack, which is what a phone should get anyway.
1100px is mirrored in client/lib/popupOffset.js, which clamps a popup into
the viewport using its width - computed for the default 380px, a wide popup
opened near the right edge lands most of the way off the screen. Change one
and change the other. */
@media screen and (min-width: 801px) {
.pop-over[data-popup='exportBoardPopup'],
.pop-over[data-popup='exportSwimlanePopup'],
.pop-over[data-popup='exportListPopup'],
.pop-over[data-popup='exportCardPopup'] {
/* The FULL width of the window, less the 10px gutter every popup is kept
inside - not "up to 1100px". It is a panel with two panes, and anchored
to its button at 1100 its trailing edge went past the edge of the window,
taking the pop-over's own X with it. client/lib/popupOffset.js pins it to
that same gutter at the top of the viewport, so the whole panel - header,
X and both panes - is always on screen. The 20px is the 10px inset on
each side; keep it in step with `viewportPadding` there. */
width: calc(100vw - 20px) !important;
max-width: calc(100vw - 20px) !important;
}
.pop-over .export-board-panes {
display: grid;
/* The selection is a checkbox list and stays narrower than the formats,
which are three groups under three headings. */
grid-template-columns: minmax(260px, 0.8fr) minmax(340px, 1.2fr);
column-gap: 18px;
align-items: start;
}
/* A rule between the panes rather than under each section: the two sides are
one menu, and the line says where one ends. `border-inline-start` mirrors
itself, like everything else here. */
.pop-over .export-board-pane-formats {
border-inline-start: 1px solid rgba(0, 0, 0, 0.12);
padding-inline-start: 18px;
}
/* Inside a pane, lists still fill their width with as many columns as fit -
so a very wide window gives the formats two columns rather than one long
one, and the checkboxes two or three. */
.pop-over .export-board-pane .pop-over-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
align-content: start;
column-gap: 4px;
}
.pop-over .export-board-pane .pop-over-list li {
min-width: 0;
}
}
/* The rule above each subheading in the Export board menu. Tighter than the
default so a group's heading stays visually attached to the list under it
rather than floating between two gaps. */
.pop-over .export-board-pane hr {
margin: 10px 0 0 0;
border: none;
border-top: 1px solid rgba(0, 0, 0, 0.12);
}
@@ -21,18 +21,18 @@
}
.recovery-maintenance-title {
font-size: 22px;
font-size: calc(22px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
margin: 0 0 12px;
color: #fff;
}
.recovery-maintenance-message {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
margin: 0 0 12px;
}
.recovery-maintenance-note {
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
opacity: 0.8;
}
+1 -1
View File
@@ -3,7 +3,7 @@
height: 50px;
margin: auto;
text-align: center;
font-size: 10px;
font-size: calc(10px * var(--wekan-ui-font-scale, 1));
}
.sk-spinner-wave div {
background-color: #333;
+14 -1
View File
@@ -6,13 +6,26 @@
template(name="themeColorPicker")
form.theme-color-picker.edit-label
if isGlobal
ul.pop-over-list
//- Two switches on ONE row: what the theme IS on the start side, and where
it also applies on the end side. "Default (no override)" clears the
user's theme; "All Boards" paints the overview's tiles in the theme's
lighter colour instead of each board's own (models/users.js
profile.allBoardsThemeTiles). Only the user's own scope has the second
one - a site admin does not decide this for everybody.
ul.pop-over-list.theme-scope-row
li(class="{{#if isNoneSelected}}active{{/if}}")
a.js-theme-none
i.fa.fa-ban
| {{_ 'theme-default'}}
if isNoneSelected
i.fa.fa-check
if isUserScope
li(class="{{#if allBoardsTilesOn}}active{{/if}}")
a.js-theme-all-boards
i.fa.fa-th-large
| {{_ 'all-boards'}}
if allBoardsTilesOn
i.fa.fa-check
each themeGroups
.theme-category-group
.theme-category-label {{label}}
+49 -10
View File
@@ -70,6 +70,22 @@ Template.themeColorPicker.onCreated(function () {
this.customColors = new ReactiveVar((cur.custom || []).slice());
});
// WHICH category's custom colours this picker is offering.
//
// The category of the theme that is selected - and when NOTHING is selected, the
// FLAT one. That case is Member Settings / Change Color and Admin Panel /
// Visibility sitting on "Default (no override)", which is where they open: the
// custom-colour wheel was hidden until a named theme had been picked, so those
// two pages looked as though they had no custom colour at all, while Board
// Settings - where a board always has a colour, and the first one is flat -
// always showed it. A custom colour is now offered in all three, and choosing
// one from "Default" applies it over the first flat theme, which is the base
// its single wheel describes.
function customCategory(tpl) {
const cur = tpl.color.get();
return cur ? categoryOf(cur) : THEME_CATEGORY_ORDER[0];
}
Template.themeColorPicker.helpers({
// The "Default theme" row - clearing the override - belongs to every scope that
// HAS a weaker layer under it. A board always has a colour, so it has no such row.
@@ -80,6 +96,16 @@ Template.themeColorPicker.helpers({
const tpl = Template.instance();
return tpl.scope !== 'board' && !tpl.color.get();
},
// "All Boards" sits beside "Default (no override)" and belongs to the USER's own
// theme only: it is a preference about the overview page, not something a site
// admin sets for everybody, and a board has no overview of its own.
isUserScope() {
return Template.instance().scope === 'global';
},
allBoardsTilesOn() {
const u = ReactiveCache.getCurrentUser();
return !!(u && u.hasAllBoardsThemeTiles && u.hasAllBoardsThemeTiles());
},
// Visible swatches grouped by category, each group labelled with its category name.
themeGroups() {
const cur = Template.instance().color.get();
@@ -90,13 +116,11 @@ Template.themeColorPicker.helpers({
}));
},
showCustom() {
const cur = Template.instance().color.get();
return cur ? allowsCustomColor(categoryOf(cur)) : false;
return allowsCustomColor(customCategory(Template.instance()));
},
customWheels() {
const tpl = Template.instance();
const cur = tpl.color.get();
const n = cur ? customColorCount(categoryOf(cur)) : 0;
const n = customColorCount(customCategory(tpl));
const cc = tpl.customColors.get();
const wheels = [];
for (let i = 0; i < n; i += 1) {
@@ -112,9 +136,8 @@ Template.themeColorPicker.helpers({
// colors until the CSS-variable refactor; the preview swatch shows them directly).
previewStyle() {
const tpl = Template.instance();
const cur = tpl.color.get();
const cat = cur ? categoryOf(cur) : null;
if (!cat || !allowsCustomColor(cat)) return '';
const cat = customCategory(tpl);
if (!allowsCustomColor(cat)) return '';
const cc = tpl.customColors.get();
if (!cc.some(Boolean)) return '';
if (customColorCount(cat) === 2 && cc[0] && cc[1]) {
@@ -129,9 +152,8 @@ Template.themeColorPicker.helpers({
// which case read every wheel's current value (untouched ones contribute their shown
// default) so the result is a complete set of the category's expected count.
function gatherCustom(tpl) {
const color = tpl.color.get();
const cat = color ? categoryOf(color) : null;
if (!cat || !allowsCustomColor(cat)) return [];
const cat = customCategory(tpl);
if (!allowsCustomColor(cat)) return [];
if (!tpl.customColors.get().some(Boolean)) return [];
const n = customColorCount(cat);
const wheels = Array.from(tpl.findAll('.js-theme-wheel'))
@@ -186,8 +208,25 @@ Template.themeColorPicker.events({
// ...and apply the custom color when the wheel is committed (avoids spamming the
// server on every intermediate value during the drag).
'change .js-theme-wheel'(event, tpl) {
// From "Default (no override)" there is no theme under the colour yet.
// applySelection() already falls back to the first flat theme, so the SAME
// fallback is written into the picker's own state: otherwise the wheel would
// save a theme the page does not show as chosen, and the next click would
// read the selection back as "none".
if (!tpl.color.get()) tpl.color.set(colorsInCategory(THEME_CATEGORY_ORDER[0])[0]);
applySelection(tpl);
},
// Paint the All Boards tiles in the theme's lighter colour, or give them back
// their own colours. Applies immediately, like everything else in this popup,
// and the popup stays open so the effect can be seen and undone in one place.
'click .js-theme-all-boards'(event) {
event.preventDefault();
Meteor.call('toggleAllBoardsThemeTiles', err => {
if (err && process.env.DEBUG === 'true') {
console.error('toggleAllBoardsThemeTiles error', err);
}
});
},
// Clear the global override (Default) — applies immediately too.
'click .js-theme-none'(event, tpl) {
event.preventDefault();
+52 -17
View File
@@ -14,27 +14,62 @@ body.has-ui-font .wekan-form-control {
/* Font size preset (#4759): scale the root font size. Elements using relative units
* (em/rem/%) scale with it; the variable is a percentage set by uiFont.js only for a
* known preset. 'default'/unset removes it. */
body.has-ui-font-size {
* known preset. 'default'/unset removes it.
*
* #6587: this is on <html>, and it used to be on <body>. `rem` is measured against
* the ROOT element, so a size on body never reached it — and a board's type is
* written in rem (px would not follow this setting at all, and vw would change with
* the window), which is why minicards were the part of the UI that ignored the
* preset. Everything relative to body still scales, because body inherits from the
* root; putting the size on BOTH would compound the two. */
html.has-ui-font-size {
font-size: var(--wekan-ui-font-size) !important;
}
/* Text color (#4759): the variable is a validated hex set by uiFont.js. Icon fonts
* keep their own color rules; this recolors ordinary UI text + form controls. */
/* ...and that percentage is only half of it, because it can only reach text
* whose size is written in a RELATIVE unit. WeKan writes most of its sizes in
* px, and px is absolute: no root percentage moves it. So the setting scaled the
* minicards and the page headings - the parts written in rem - and left the
* header bar, the left menu, the lists and every popup exactly as they were.
* `html, body, input, select, textarea, button` even re-stated 14px, so the body
* took the stock size straight back off the scaled root.
*
* Every px `font-size` and `line-height` in the client stylesheets is therefore
* written
*
* font-size: calc(14px * var(--wekan-ui-font-scale, 1));
*
* and uiFont.js publishes the preset as that number. The fallback is 1 and the
* variable is REMOVED for 'default', so an instance where nobody touched the
* setting computes exactly the sizes it always did. line-height is scaled with
* the size on purpose: 21px of type in an 18px line box is the same setting
* half-applied. tests/uiFonts.test.cjs fails on any bare px text size, so a new
* one cannot quietly opt out of the setting. */
/* Text color (#4759): the variable is a validated hex set by uiFont.js.
*
* EVERY element, not just <body> and the form controls. Colour is inherited, so
* setting it on the body reached only text that had no colour of its own - and
* WeKan gives most of its text a colour of its own: the header bar's buttons,
* the left menu's rows, a minicard's title, a list header. Choosing green
* recoloured the page heading and the menu and left the rest exactly as it was,
* which is the same "half a setting" the size preset had.
*
* `.fa` is left out because it is not text: those elements are Font Awesome
* GLYPHS, drawn with `color` because that is how an icon font works. The ones
* that are meant to follow their label already say `color: inherit` and so
* follow this anyway; the ones with a colour of their own - a red alert, a
* green tick - keep saying what they mean. */
body.has-ui-text-color,
body.has-ui-text-color button,
body.has-ui-text-color input,
body.has-ui-text-color textarea,
body.has-ui-text-color select,
body.has-ui-text-color .wekan-form-control {
body.has-ui-text-color *:not(.fa) {
color: var(--wekan-ui-text-color) !important;
}
/* Text background color (#4759). */
body.has-ui-bg-color,
body.has-ui-bg-color input,
body.has-ui-bg-color textarea,
body.has-ui-bg-color select,
body.has-ui-bg-color .wekan-form-control {
background-color: var(--wekan-ui-bg-color) !important;
}
/* There was a "text background color" here (#4759), a colour painted behind the
* text. It is REMOVED. A background needs elements to sit on, and neither choice
* of them looks right: on the boxes (its first form, `body`) it painted the whole
* window - the board canvas, the space under the lists - which is a page
* background and not a text one; on the elements that carry text it striped every
* heading, menu row and paragraph with a full-width band. A setting that cannot be
* made to look good is worse than no setting, so it is gone rather than tuned
* again. The text COLOUR above stays. */
+31 -4
View File
@@ -1,6 +1,6 @@
import { Meteor } from 'meteor/meteor';
import { Tracker } from 'meteor/tracker';
import { fontFamilyValue, fontSizeValue, colorValue } from '/models/lib/uiFonts';
import { fontFamilyValue, fontSizeValue, fontScaleValue, colorValue } from '/models/lib/uiFonts';
// #4759: apply the user's chosen UI font AND font-size preset to the whole UI by
// setting CSS variables on :root and toggling marker classes on <body> (uiFont.css
@@ -9,14 +9,28 @@ import { fontFamilyValue, fontSizeValue, colorValue } from '/models/lib/uiFonts'
// saved values are also validated server-side. Unset => nothing applied (defaults).
Meteor.startup(() => {
// #6587: the font-size preset has to land on the ROOT element, not on <body>.
// A size on <body> is inherited only by children that ask for a relative size,
// and it leaves `rem` — which is measured against the root — untouched. So a
// board's type, which states its sizes in rem so it does not change with the
// window, ignored the setting completely: "The font size setting in the user
// menu (Settings -> Font Size) has no effect on mini cards, while it correctly
// applies to other UI elements."
//
// Only ONE element may carry it. With the size on both html and body, a 130%
// preset would compound to 169% for anything relative to body, so this marks
// the root for font-size and <body> for everything else (the font-family and
// colour rules match on body, and form controls are listed there explicitly).
const ROOT_CLASSES = new Set(['has-ui-font-size']);
function toggle(varName, className, value) {
try {
const el = ROOT_CLASSES.has(className) ? document.documentElement : document.body;
if (value) {
document.documentElement.style.setProperty(varName, value);
document.body.classList.add(className);
if (className) el.classList.add(className);
} else {
document.documentElement.style.removeProperty(varName);
document.body.classList.remove(className);
if (className) el.classList.remove(className);
}
} catch (_) {
// document not ready in exotic embeddings; ignore.
@@ -28,7 +42,20 @@ Meteor.startup(() => {
const profile = (user && user.profile) || {};
toggle('--wekan-ui-font', 'has-ui-font', fontFamilyValue(profile.uiFont));
toggle('--wekan-ui-font-size', 'has-ui-font-size', fontSizeValue(profile.uiFontSize));
// ...and the same preset as a number, which is what actually moves most of
// the UI: the percentage above only reaches text written in a relative unit,
// and WeKan writes most of its sizes in px. Every px font-size and
// line-height in the stylesheets is multiplied by this variable
// (uiFont.css), so one preset moves the whole interface instead of the part
// of it that happened to be written in rem. No class goes with it - a
// variable that is not set falls back to 1 in the calc itself.
toggle('--wekan-ui-font-scale', null, fontScaleValue(profile.uiFontSize));
toggle('--wekan-ui-text-color', 'has-ui-text-color', colorValue(profile.uiTextColor));
toggle('--wekan-ui-bg-color', 'has-ui-bg-color', colorValue(profile.uiTextBgColor));
// There was a `--wekan-ui-bg-color` here, for "text background color". It is
// removed: a colour behind the text needs elements to sit on, and there is no
// set of them that looks right - on the boxes it painted the page, on the text
// carriers it striped headings and menu rows with bands. Nothing reads
// `profile.uiTextBgColor` any more, and setUiColors unsets it, so a value
// stored before this is gone rather than dormant.
});
});
@@ -23,7 +23,7 @@
margin: 8px 0 0;
width: 1.2em;
height: 1.2em;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
display: block;
color: #000;
}
@@ -3,7 +3,7 @@
}
#notifications .notifications-drawer-toggle {
display: block;
line-height: 28px;
line-height: calc(28px * var(--wekan-ui-font-scale, 1));
color: #f2f2f2;
margin: 0 10px;
width: 28px;
@@ -41,10 +41,10 @@ section#notifications-drawer .header {
align-items: center;
}
section#notifications-drawer .header .notification-menu-toggle {
font-size: 20px;
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
cursor: pointer;
color: #000 !important; /* black hamburger menu (override themed white link colour) */
line-height: 24px;
line-height: calc(24px * var(--wekan-ui-font-scale, 1));
}
section#notifications-drawer .header .notification-menu-toggle:hover {
color: #2980b9;
@@ -95,7 +95,7 @@ section#notifications-drawer .header .notification-menu .menu-item .check-icon {
}
section#notifications-drawer .header .notification-menu .menu-item .menu-icon {
margin-inline-end: 8px;
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
}
/* Menu-item icons (envelope / copy / trash / check) black. */
section#notifications-drawer .header .notification-menu .menu-item .menu-icon,
@@ -115,9 +115,9 @@ section#notifications-drawer .header .close {
otherwise pulls the X out of the header row and drops it below the title. */
position: static;
margin-inline-start: 8px;
font-size: 24px;
font-size: calc(24px * var(--wekan-ui-font-scale, 1));
height: 24px;
line-height: 24px;
line-height: calc(24px * var(--wekan-ui-font-scale, 1));
opacity: 1;
color: #000 !important; /* black X (override themed white link colour) */
}
+13 -13
View File
@@ -62,23 +62,23 @@
}
.rules-workflow .workflow-node-label {
display: block;
font-size: 11px;
font-size: calc(11px * var(--wekan-ui-font-scale, 1));
text-transform: uppercase;
color: #888;
}
.rules-workflow .workflow-node-text {
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
}
.rules-workflow .workflow-arrow {
display: flex;
align-items: center;
font-size: 20px;
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
color: #888;
}
/* Drag-and-drop workflow editor */
.rules-workflow .workflow-help {
color: #727479;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
margin-bottom: 9px;
}
.rules-workflow .workflow-builder {
@@ -254,7 +254,7 @@
width: 50px;
height: 50px;
text-align: center;
font-size: 25px;
font-size: calc(25px * var(--wekan-ui-font-scale, 1));
position: relative;
}
.triggers-content .triggers-body .triggers-side-menu ul li i {
@@ -277,7 +277,7 @@
width: 95%;
}
.triggers-content .triggers-body .triggers-side-menu ul li a span {
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
}
.triggers-content .triggers-body .triggers-main-body {
padding: 0.1em 1em;
@@ -299,11 +299,11 @@
inset-inline-start: 10px;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-content .trigger-text {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
display: inline-block;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-content .trigger-inline-button {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
display: inline;
padding: 6px;
border: 1px solid #eee;
@@ -347,7 +347,7 @@
border-radius: 4px;
box-shadow: inset -1px -1px 3px rgba(0,0,0,0.05);
text-align: center;
font-size: 20px;
font-size: calc(20px * var(--wekan-ui-font-scale, 1));
inset-inline-end: 10px;
}
.triggers-content .triggers-body .triggers-main-body .trigger-item .trigger-button i {
@@ -371,7 +371,7 @@
top: unset;
position: unset;
transform: unset;
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
width: auto;
padding-inline-start: 10px;
padding-inline-end: 10px;
@@ -383,10 +383,10 @@
/* Rules import/export popup */
.rules-import-export h3 {
margin: 0.6em 0 0.3em;
font-size: 15px;
font-size: calc(15px * var(--wekan-ui-font-scale, 1));
}
.rules-import-export .rules-ie-hint {
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
color: #727479;
margin-bottom: 0.3em;
}
@@ -407,5 +407,5 @@
padding: 6px 8px;
background: #e8f0fe;
border-radius: 4px;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
}
@@ -365,6 +365,13 @@ Template.adminReports.helpers({
showDatabase() {
return Template.instance().showDatabase;
},
// Filesystem integrity. Without this helper the pane rendered NOTHING: the
// menu entry set tmpl.showIntegrity, the template asked for `showIntegrity.get`,
// and an undefined helper is simply falsy - so the branch never ran, the page
// was blank, and Summary went on counting the problems it could not show.
showIntegrity() {
return Template.instance().showIntegrity;
},
showCpu() {
return Template.instance().showCpu;
},
+9 -1
View File
@@ -1347,8 +1347,16 @@ Template.moveAttachments.helpers({
const lm = getLastMove();
if (!lm) return '';
const cancelled = lm.cancelled ? ` (${TAPi18n.__('move-progress-cancel')})` : '';
// #6596: how many of the total actually moved. A record whose binary is not
// in the bucket has nothing to move and is SKIPPED, which is not the same
// as a failure and must not be silent either - a run that says only "done"
// while a dozen attachments stayed behind is how that bug went unnoticed.
const counts = [];
if (lm.skipped) counts.push(`${lm.skipped} skipped`);
if (lm.failed) counts.push(`${lm.failed} failed`);
const detail = counts.length ? ` - ${counts.join(', ')}` : '';
return `${storageLabel(lm.source)} → ${storageLabel(lm.dest)} ` +
`(${scopeLabel(lm.scope)}) ${formatDateTime(lm.at)}${cancelled}`;
`(${scopeLabel(lm.scope)}) ${formatDateTime(lm.at)}${cancelled}${detail}`;
},
repairLoading() {
return Template.instance().repairLoading.get();
@@ -0,0 +1,60 @@
/* ── Admin Panel / Settings / Version ────────────────────────────────────────
ONE table, in five categories - Platform, OS, Meteor, Database, Node - where
each category is a row of its own that spans both columns.
WHY ONE TABLE. It was five, one per category, and five tables size their
columns independently: "WeKan ® Version" made the first one's label column
wide and "OS Type" made the next one's narrow, so the values started at a
different x in every group and the pane read as five unrelated things. One
table puts every label and every value in the same place. */
/* Two EQUAL columns, and they stay equal whatever the longest label happens to
be. `table-layout: fixed` makes the browser take the widths from the colgroup
instead of measuring the content, and the two `col` rules below are that
colgroup - written here rather than as inline width attributes so a theme can
override them in one place.
The `max-width: none` undoes settingBody.css's 240px cap on admin table
headers, which exists to stop a long label blowing a column up; here the
column width is stated outright, so the cap has nothing to protect and would
only fight the 50%. Both rules are scoped through .info-table, so no other
admin table is touched. */
.setting-content .content-body .main-body table.info-table {
table-layout: fixed;
}
.setting-content .content-body .main-body table.info-table > colgroup > col {
width: 50%;
}
.setting-content .content-body .main-body table.info-table th,
.setting-content .content-body .main-body table.info-table td {
max-width: none;
}
/* The category row. It spans both columns, so it is not a label with a value
beside it - it is a line that says what the rows under it are about.
BOLD and start-aligned, at the table's own font size: it names a group of rows
inside the pane, and the pane title above it (`.admin-pane-title`, 1.25rem)
names the pane. Growing these to that size would read as five pane titles and
"Version" would be lost among them. `color: inherit` for the same reason it is
on the pane title: the panel is themeable and a fixed colour goes unreadable
on a dark one. */
.setting-content .content-body .main-body table.info-table tr.info-category > th {
/* start, not left: a th centres by default, and this must sit over the label
column - which is the RIGHT edge in Arabic and Hebrew (tests/rtl.test.js). */
text-align: start;
font-weight: 700;
color: inherit;
/* Space above the group, none below it: the separation belongs BETWEEN
groups, and a gap under the title would push it away from the rows it
introduces. */
padding-top: 18px;
}
/* ...except the first one, which sits directly under the pane title and needs no
second gap on top of the 12px that title already brings. */
.setting-content .content-body .main-body table.info-table tr.info-category:first-child > th {
padding-top: 0;
}
+89 -40
View File
@@ -5,49 +5,45 @@
//- templates are global) inside the Settings page, which brings the left menu, the
//- pane title and the admin check with it. The /information URL redirects to
//- Settings, the same way the old /translation URL does.
//-
//- ONE table, in FIVE categories. This was a single flat list of 40 rows in which
//- the WeKan version, the OS load average, the DDP transport and a V8 heap counter
//- were the same kind of thing, read top to bottom, with no way to jump to the part
//- you came for. It is grouped now - what this IS (version and packaging), what it
//- runs ON, how Meteor is talking to the client, what the database is, and finally
//- Node's own memory - and each group is introduced by a row of its own that SPANS
//- BOTH COLUMNS, in bold.
//-
//- One table rather than five, because five tables size their columns
//- independently: "WeKan ® Version" made the first one's label column wide and
//- "OS Type" made the next one's narrow, so the values started at a different x
//- in every group and the pane read as five unrelated things. One table with a
//- colgroup of two 50% columns puts every label and every value in the same place
//- (.info-table in informationBody.css).
template(name='statistics')
table
table.info-table
//- Two equal columns, stated once here rather than left to the widest cell:
with table-layout fixed these are what the browser uses, so the labels and
the values line up down the whole pane no matter which group is longest.
colgroup
col
col
tbody
tr.info-category
th(colspan="2") {{_ 'Platform'}}
tr
th WeKan ® {{_ 'info'}}
td {{statistics.version}}
//- The LABEL is translated; the VALUE never is. bundle.zip, Snap, Docker
and Sandstorm are the names of the things themselves - a package
format, a store, a product - not words describing them, and a reader
reporting an issue, searching the docs or grepping a log needs the same
four strings in every language. See models/lib/platformPackaging.js.
tr
th {{_ 'Meteor_version'}}
td {{statistics.meteor.meteorVersion}}
tr
th {{_ 'Node_version'}}
td {{statistics.process.nodeVersion}}
tr
th {{_ 'Database_type'}}
td {{statistics.mongo.databaseType}}
tr
th {{_ 'MongoDB_version'}}
td {{statistics.mongo.mongoVersion}}
tr
th {{_ 'Database_commit'}}
td {{statistics.mongo.databaseCommit}}
if statistics.mongo.ferretdbVersion
tr
th {{_ 'FerretDB_version'}}
td {{statistics.mongo.ferretdbVersion}}
tr
th {{_ 'FerretDB_commit'}}
td {{statistics.mongo.ferretdbCommit}}
tr
th {{_ 'MongoDB_storage_engine'}}
td {{statistics.mongo.mongoStorageEngine}}
tr
th {{_ 'MongoDB_Oplog_enabled'}}
td {{formatBoolean statistics.mongo.mongoOplogEnabled}}
tr
th {{_ 'Reactivity_mode'}}
td {{statistics.mongo.reactivity}}
tr
th {{_ 'Reactivity_order'}}
td {{statistics.mongo.reactivityOrder}}
tr
th {{_ 'DDP_transport'}}
td {{statistics.mongo.ddpTransport}}
th {{_ 'package'}}
td {{statistics.platform.packaging}}
tr.info-category
th(colspan="2") {{_ 'OS'}}
tr
th {{_ 'OS_Type'}}
td {{statistics.os.type}}
@@ -75,6 +71,62 @@ template(name='statistics')
tr
th {{_ 'OS_Cpus'}}
td {{statistics.os.cpus.length}}
tr.info-category
th(colspan="2") {{_ 'Meteor'}}
tr
th {{_ 'Meteor_version'}}
td {{statistics.meteor.meteorVersion}}
//- Reactivity and DDP are Meteor's, not the database's: what is LIVE
(reactivity), what was ASKED for (reactivity order), and how the client
is connected (DDP transport). Whether an OpLog exists to tail is a
property of the database and stays in that table.
tr
th {{_ 'Reactivity_mode'}}
td {{statistics.mongo.reactivity}}
tr
th {{_ 'Reactivity_order'}}
td {{statistics.mongo.reactivityOrder}}
tr
th {{_ 'DDP_transport'}}
td {{statistics.mongo.ddpTransport}}
tr.info-category
th(colspan="2") {{_ 'Database'}}
tr
th {{_ 'Database_type'}}
td {{statistics.mongo.databaseType}}
tr
th {{_ 'MongoDB_version'}}
td {{statistics.mongo.mongoVersion}}
tr
th {{_ 'Database_commit'}}
td {{statistics.mongo.databaseCommit}}
//- Only when FerretDB is what answered: on MongoDB these two rows have
nothing to say, and an empty row reads as a missing value.
if statistics.mongo.ferretdbVersion
tr
th {{_ 'FerretDB_version'}}
td {{statistics.mongo.ferretdbVersion}}
tr
th {{_ 'FerretDB_commit'}}
td {{statistics.mongo.ferretdbCommit}}
tr
th {{_ 'MongoDB_storage_engine'}}
td {{statistics.mongo.mongoStorageEngine}}
tr
th {{_ 'MongoDB_Oplog_enabled'}}
td {{formatBoolean statistics.mongo.mongoOplogEnabled}}
unless isSandstorm
tr
th {{_ 'Mongo_sessions_count'}}
td {{statistics.session.sessionsCount}}
tr.info-category
th(colspan="2") {{_ 'Node'}}
tr
th {{_ 'Node_version'}}
td {{statistics.process.nodeVersion}}
//- A Sandstorm grain has no V8 heap figures collected for it (see
server/statistics.js), so the version above is all this table can say
there.
unless isSandstorm
tr
th {{_ 'Node_heap_total_heap_size'}}
@@ -121,6 +173,3 @@ template(name='statistics')
tr
th {{_ 'Node_memory_usage_external'}}
td {{fileSize statistics.nodeMemoryUsage.external}}
tr
th {{_ 'Mongo_sessions_count'}}
td {{statistics.session.sessionsCount}}
@@ -47,13 +47,13 @@
.migration-progress-title {
margin: 0;
font-size: 18px;
font-size: calc(18px * var(--wekan-ui-font-scale, 1));
font-weight: 600;
}
.migration-progress-close {
cursor: pointer;
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
opacity: 0.8;
transition: opacity 0.2s ease;
}
@@ -74,7 +74,7 @@
font-weight: 600;
color: #333;
margin-bottom: 8px;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
}
.migration-progress-overall-bar {
@@ -112,7 +112,7 @@
.migration-progress-overall-percentage {
text-align: end;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
color: #666;
font-weight: 600;
}
@@ -125,7 +125,7 @@
font-weight: 600;
color: #333;
margin-bottom: 8px;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
}
.migration-progress-step-bar {
@@ -146,7 +146,7 @@
.migration-progress-step-percentage {
text-align: end;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
color: #666;
font-weight: 600;
}
@@ -163,12 +163,12 @@
font-weight: 600;
color: #333;
margin-bottom: 5px;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
}
.migration-progress-status-text {
color: #555;
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
line-height: 1.4;
}
@@ -184,12 +184,12 @@
font-weight: 600;
color: #1976d2;
margin-bottom: 5px;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
}
.migration-progress-details-text {
color: #1565c0;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
line-height: 1.4;
}
@@ -202,7 +202,7 @@
.migration-progress-note {
text-align: center;
color: #666;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
font-style: italic;
}
@@ -222,7 +222,7 @@
}
.migration-progress-title {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
}
}
@@ -292,13 +292,13 @@
}
.migration-spinner {
font-size: 24px;
font-size: calc(24px * var(--wekan-ui-font-scale, 1));
color: #007cba;
margin-bottom: 8px;
}
.migration-text {
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
color: #666;
text-align: center;
}
@@ -3,6 +3,11 @@
* Displays detailed progress for comprehensive board migration
*/
// Its own template first: boardBody.js imports this module, so it can be
// evaluated before client/features/settings.js reaches the .jade - and
// registering on a template that does not exist yet throws at module scope,
// which stops the whole bundle rather than just this component.
import './migrationProgress.jade';
import { ReactiveVar } from 'meteor/reactive-var';
import { ReactiveCache } from '/imports/reactiveCache';
import { productNameOrDefault } from '/models/lib/productName';
+2 -2
View File
@@ -119,7 +119,7 @@ table:not(.table-page-table) td:first-child {
.divLockedUsersFilter .people-filter {
margin-bottom: 0;
color: #777;
line-height: 34px;
line-height: calc(34px * var(--wekan-ui-font-scale, 1));
}
.divLockedUsersFilter .user-filter {
@@ -225,7 +225,7 @@ table:not(.table-page-table) td:first-child {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: 500;
}
+30 -2
View File
@@ -134,7 +134,7 @@
.setting-content .content-body .side-menu ul li.left-menu-heading {
padding-block: 8px 4px;
padding-inline-start: 18px;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
@@ -156,7 +156,7 @@
width: 95%;
}
.setting-content .content-body .side-menu ul li a span {
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
}
.setting-content .content-body .side-menu ul li a i {
margin-inline-end: 20px;
@@ -456,6 +456,34 @@ body.left-menu-resizing-active {
/* …and exactly .materialCheckBox.is-checked: the square becomes the tick, which is
what the transition above animates. */
/* #6465: THE FOCUS RING MUST NOT BE DRAWN ON THE ROTATED BOX.
The checked state below turns the square into a tick by making two borders
transparent and rotating the element 40deg. A browser draws its focus ring
around the element as it is - rotated - so a checkbox that is both checked and
focused (which is exactly what a checkbox you just clicked is) comes out as a
blue DIAMOND sitting where the tick should be. That is the glitch reported on
the Problems page.
The ring is not simply removed: keyboard users need to see where they are. It
moves to the row that CONTAINS the checkbox, which is not rotated - every one
of these lives inside a label (problemsSummary.jade: `label.admin-problem-item
> input.js-problem-check`) - so the indicator is a normal rectangle around the
whole row, which is easier to see than a 13px box anyway.
`:focus-visible` rather than `:focus`, so clicking with a mouse does not leave
a ring behind; `:focus-within` is the fallback where :has() is not supported,
and lands on the same element. */
.setting-content input[type="checkbox"]:focus,
.setting-content input[type="checkbox"]:focus-visible {
outline: none;
}
.setting-content label:has(> input[type="checkbox"]:focus-visible) {
outline: 2px solid #2980b9;
outline-offset: 2px;
border-radius: 3px;
}
.setting-content input[type="checkbox"]:checked {
top: -4px;
inset-inline-start: -3px;
+7 -7
View File
@@ -4,10 +4,10 @@
margin-inline-start: 20px;
padding-inline-end: 10px;
height: 28px;
font-size: 13px;
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
float: inline-start;
overflow: hidden;
line-height: 28px;
line-height: calc(28px * var(--wekan-ui-font-scale, 1));
margin: 0 2px;
}
#header #header-main-bar .setting-header-btn.active,
@@ -17,7 +17,7 @@
#header #header-main-bar .setting-header-btn i.fa {
float: inline-start;
display: block;
line-height: 28px;
line-height: calc(28px * var(--wekan-ui-font-scale, 1));
color: #f2f2f2;
margin: 0 10px;
}
@@ -62,19 +62,19 @@
#header #header-main-bar .setting-header-btns .setting-header-btn,
#header-quick-access .setting-header-btns .setting-header-btn span,
#header #header-main-bar .setting-header-btns .setting-header-btn span {
font-size: 15px !important;
font-size: calc(15px * var(--wekan-ui-font-scale, 1)) !important;
}
#header-quick-access .setting-header-btns .setting-header-btn i.fa,
#header #header-main-bar .setting-header-btns .setting-header-btn i.fa {
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
}
}
.iphone-device #header-quick-access .setting-header-btns .setting-header-btn,
.iphone-device #header-quick-access .setting-header-btns .setting-header-btn span {
font-size: 15px !important;
font-size: calc(15px * var(--wekan-ui-font-scale, 1)) !important;
}
.iphone-device #header-quick-access .setting-header-btns .setting-header-btn i.fa {
font-size: 18px !important;
font-size: calc(18px * var(--wekan-ui-font-scale, 1)) !important;
}
/* Admin Panel problem banner (Reports → Security/Speed/Tests new-problem info).
+169 -22
View File
@@ -13,7 +13,7 @@
position: absolute;
inset-inline-end: 0px;
top: 0px;
font-size: 25px;
font-size: calc(25px * var(--wekan-ui-font-scale, 1));
padding: 10px;
}
.sidebar-xmark:hover {
@@ -51,6 +51,18 @@
.sidebar .sidebar-content h3 i.fa {
margin-inline-end: 3px;
}
/* A sidebar heading that folds its section - Members, Labels - is a button:
the caret in front of it says which way it is, and it looks and behaves like
something to click. The same control an opened card's sections have.
client/lib/foldState.js */
.sidebar .sidebar-content h3.js-toggle-fold {
display: flex;
align-items: center;
gap: 0.3em;
cursor: pointer;
user-select: none;
}
.sidebar .sidebar-content hr {
margin: 13px 0;
}
@@ -79,10 +91,30 @@
flex-shrink: 0;
}
/* The Custom Fields form is a POP-OVER, and a pop-over renders in the global
popup container - not inside `.sidebar`. So its checkbox rows never got the
`align-items: center` above, and sat exactly as #6465 described before that
fix: the box on one line and its label on another, because `.flex` on its own
is only `display: flex`. Beside the Admin Panel / Announcement checkbox, which
is the same `a.flex > .materialCheckBox + span` markup inside a page that does
align it, this is what "not the animated one" looks like - the morph is there,
but it plays on a box that is not next to its own label.
The rows are aligned here, and the box's own states are spelled out below, so
the square, the green tick and the `transition: 0.2s` between them are the
announcement's - whichever stylesheet a pop-over happens to inherit.
client/components/sidebar/sidebarCustomFields.jade */
.pop-over[data-popup='createCustomFieldPopup'] a.flex,
.pop-over[data-popup='editCustomFieldPopup'] a.flex {
align-items: center;
}
/* Use checklist-style green checkboxes for all sidebar checkboxes */
.sidebar .materialCheckBox.is-checked,
.boardCardSettingsPopup .materialCheckBox.is-checked,
.boardSubtaskSettingsPopup .materialCheckBox.is-checked {
.boardSubtaskSettingsPopup .materialCheckBox.is-checked,
.pop-over[data-popup='createCustomFieldPopup'] .materialCheckBox.is-checked,
.pop-over[data-popup='editCustomFieldPopup'] .materialCheckBox.is-checked {
top: -4px !important;
inset-inline-start: -3px !important;
width: 7px !important;
@@ -98,22 +130,25 @@
transform-origin: 100% 100% !important;
}
/* Card Settings 3-column grid layout */
.card-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr 2fr;
gap: 10px;
margin-bottom: 10px;
/* Keep the "Show on Card / Show on Minicard / Description" titles visible
while the rows below scroll (the popup scrolls in .pop-over .content-wrapper). */
position: sticky;
top: 0;
z-index: 2;
background: #fff;
padding: 5px 0;
border-bottom: 2px solid #ddd;
/* …and the square it morphs FROM. The checked half above is spelled out for
these popups; without the unchecked half the transition would run between a
box some other rule sized and the tick, which is the animation looking wrong
rather than missing. Same 13px square, same 0.2s, as forms.css draws it. */
.pop-over[data-popup='createCustomFieldPopup'] .materialCheckBox,
.pop-over[data-popup='editCustomFieldPopup'] .materialCheckBox {
position: relative;
width: 13px;
height: 13px;
border: 2px solid #5a5a5a;
border-radius: 1px;
transition: 0.2s;
margin: 0;
flex-shrink: 0;
cursor: pointer;
}
/* Card Settings 3-column row layout. There is no heading row above these: see
the note on boardCardSettingsPopup in sidebar.jade. */
.card-settings-row {
display: grid;
grid-template-columns: 1fr 1fr 2fr;
@@ -123,6 +158,58 @@
border-bottom: 1px solid #eee;
}
/* One side of the same table. Card menu / Show on Card and Minicard menu /
Show on Minicard open this popup asking for one column; the other one is
hidden here, so there is still ONE list of settings and one set of handlers.
A hidden grid item takes no track, so the two that remain fill the two
columns declared below. client/components/sidebar/sidebar.jade */
.board-card-settings.show-card-only .card-settings-row,
.board-card-settings.show-minicard-only .card-settings-row {
grid-template-columns: 1fr 2fr;
}
/* Two dozen settings in one column is a list nobody sees the end of: "Show on
Card" opened, and the answer to "is Description on?" was somewhere below the
fold. With one column of checkboxes hidden the rows are half as wide as they
were, so the width the popup already has is enough for two or three of them
side by side - and popup.css gives these two popups more of it.
The FORM becomes the grid and each ROW is one of its cells; the row keeps its
own "checkbox | name" grid inside that cell. `auto-fill` rather than a fixed
count, so a narrow window still gets one column and a wide one gets four
without a media query per size. */
@media screen and (min-width: 801px) {
.board-card-settings.show-card-only,
.board-card-settings.show-minicard-only {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
column-gap: 18px;
align-items: start;
}
}
/* "Labels text" is the user's own setting, not the board's: it shows in the
minicard's own view and nowhere else - not in Board Settings / Card Settings,
where every other row is board-wide, and not in Show on Card, which is about
the opened card. And when the reader is not a board admin, it is the only row
they may change, so it is the only row they get. */
.board-card-settings .card-settings-row-personal {
display: none;
}
.board-card-settings.show-minicard-only .card-settings-row-personal {
display: grid;
}
.board-card-settings.show-personal-only .card-settings-row:not(.card-settings-row-personal) {
display: none;
}
.board-card-settings.show-card-only .card-settings-row > .card-settings-column:nth-child(2),
.board-card-settings.show-minicard-only .card-settings-row > .card-settings-column:nth-child(1) {
display: none;
}
.card-settings-column {
display: flex;
align-items: center;
@@ -135,7 +222,7 @@
.card-settings-column h4 {
margin: 0;
font-size: 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
text-align: center;
}
@@ -278,7 +365,7 @@ body.sidebar-resizing-active {
}
.sidebar-tongue i.fa {
padding: 3px 9px;
font-size: 24px;
font-size: calc(24px * var(--wekan-ui-font-scale, 1));
transition: transform 0.5s;
}
.sidebar-accessibility {
@@ -380,18 +467,18 @@ body.sidebar-resizing-active {
small text is not tiny — keep them close to the board's text size so board
and sidebar read at one consistent size. */
.board-sidebar.sidebar .sidebar-content {
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
}
.board-sidebar.sidebar .sidebar-content h2,
.board-sidebar.sidebar .sidebar-content h3 {
font-size: 16px;
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
}
.board-sidebar.sidebar .sidebar-content ul.sidebar-list li > a,
.board-sidebar.sidebar .sidebar-content .card-label,
.board-sidebar.sidebar .sidebar-content .card-label .card-label-name,
.board-sidebar.sidebar .sidebar-content p,
.board-sidebar.sidebar .sidebar-content span {
font-size: 14px;
font-size: calc(14px * var(--wekan-ui-font-scale, 1));
}
.board-sidebar .sidebar-content .hide-btn {
width: 40px;
@@ -411,7 +498,7 @@ body.sidebar-resizing-active {
}
.board-sidebar .sidebar-content .hide-btn i.fa {
padding: 8px 16px;
font-size: 24px;
font-size: calc(24px * var(--wekan-ui-font-scale, 1));
font-weight: bold;
}
.sidebar-tongue {
@@ -471,6 +558,30 @@ body.sidebar-resizing-active {
margin-inline-start: 40px;
}
/* Save and Unset, side by side under the Background Image URL - the two things
to do with the address above them. Unset used to be five <br>s and a rule
below its Save. `.buttonsContainer` is already `display: flex` (peopleBody.css
styles it for every popup that uses it); this gives the pair a gap and lets
them share the width instead of one of them being `.wide`. */
.board-background-image-buttons {
gap: 8px;
align-items: stretch;
margin-bottom: 8px;
}
.board-background-image-buttons input[type="submit"],
.board-background-image-buttons button {
flex: 1 1 0;
margin: 0;
}
/* The upload button under them, full width: it is the same question - what
picture goes behind this board - answered from this machine rather than from
a URL. client/components/sidebar/sidebar.jade */
.board-background-upload {
margin-top: 8px;
}
/* Board backgrounds management popup */
.board-backgrounds-manage .board-backgrounds-grid {
display: flex;
@@ -486,6 +597,12 @@ body.sidebar-resizing-active {
.board-bg-item.is-active {
border-color: #3cb500;
}
/* The picture is the button that applies it, so it says so. */
.board-bg-thumb,
.board-bg-name {
cursor: pointer;
}
.board-bg-thumb {
height: 80px;
background-size: cover;
@@ -502,6 +619,18 @@ body.sidebar-resizing-active {
border-radius: 50%;
padding: 2px;
}
/* The picture's name under its thumbnail. A grid of thumbnails with no names
cannot be read: two photos of the same holiday look the same at 80px, and
after an upload there was nothing that said WHICH of them just arrived. */
.board-bg-name {
padding: 2px 2px 0;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
color: #4d4d4d;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.board-bg-actions {
display: flex;
justify-content: space-around;
@@ -518,3 +647,21 @@ body.sidebar-resizing-active {
display: inline-block;
margin-bottom: 8px;
}
/* Subheadings inside a pop-over list - "Dependencies", "CSV", "JSON" in the
board's Export menu. The list under one of them is a family of the same
thing, and naming the family once is what lets the entries be "(,)", "(;)"
and "TSV" rather than "Export / CSV (,)" three times over. */
.pop-over-list-subheading {
margin: 10px 0 2px 0;
padding: 0 12px;
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.65;
}
.pop-over-list-subheading + .pop-over-list {
margin-top: 0;
}
+195 -181
View File
@@ -40,10 +40,9 @@ template(name='homeSidebar')
hr
+labelsWidget
hr
ul#cards.label-text-hidden
a.flex.js-toggle-minicard-label-text(title="{{_ 'hide-minicard-label-text'}}")
i.fa(class="{{#if hiddenMinicardLabelText}}fa-check{{else}}fa-square-o{{/if}}")
span {{_ 'hide-minicard-label-text'}}
//- "Hide minicard label text" was here, under the People/Organizations tabs.
It is a setting about the MINICARD, so it is in the minicard's own menu now:
Minicard menu / Show on Minicard. See showOnMinicardPopup below.
if currentUser
ul#cards.vertical-scrollbars-toggle
a.flex.js-vertical-scrollbars-toggle(title="{{_ 'enable-vertical-scrollbars'}}")
@@ -55,18 +54,15 @@ template(name='homeSidebar')
span {{_ 'show-week-of-year'}}
hr
if currentUser.isBoardAdmin
h3.activity-title
//- A caret in front of the heading, like every collapsible section of an
opened card: clicking the heading shows the activities under it, clicking
it again hides them. The eye and the words "Show activities" beside it
said the same thing a second time, which is one control too many for one
setting. client/lib/sectionCaret.js
h3.activity-title.js-toggle-show-activities(role="button" tabindex="0" title="{{_ 'activities'}}")
i.fa(class="{{activitiesCaret}}")
i.fa.fa-comment-o
| {{_ 'activities'}}
if showActivities
a.flex.activities-eye-toggle.js-toggle-show-activities(title="{{_ 'hide-activities'}}" aria-label="{{_ 'hide-activities'}}")
i.fa.fa-eye.eye-icon
span {{_ 'hide-activities'}}
else
a.flex.activities-eye-toggle.js-toggle-show-activities(title="{{_ 'show-activities'}}" aria-label="{{_ 'show-activities'}}")
i.fa.fa-eye-slash.eye-slash-icon
span {{_ 'show-activities'}}
+activities(mode="board")
template(name="membersWidget")
@@ -77,10 +73,16 @@ template(name="membersWidget")
i.fa.fa-cog
| {{_ 'boardMenuPopup-title'}}
hr
h3
//- The heading is the button, with a caret in front of it, like every
collapsible section of an opened card: folding Members hides the People /
Organizations / Teams / Domains tabs and the avatars in them.
client/lib/foldState.js
h3.js-toggle-fold(data-fold="members" role="button" tabindex="0")
i.fa(class="{{foldCaret 'members'}}")
i.fa.fa-users
| {{_ 'members'}}
+basicTabs(tabs=tabs)
if isFoldOpen 'members'
+basicTabs(tabs=tabs)
+tabContent(slug="people")
unless currentUser.isWorker
p.quiet
@@ -156,8 +158,13 @@ template(name="boardChangeColorPopup")
the flat/clear categories, shared with Member Settings / Change Color.
+themeColorPicker(scope="board")
template(name="boardBackgroundsPopup")
.board-backgrounds-manage
//- Uploading a background image. It is its own template because it belongs
with the URL field rather than with the list of images already uploaded:
both put a picture behind the board, one from the web and one from this
machine, so they are the two halves of one question and are read together.
The grid below is what you have; this is how you add to it.
template(name="boardBackgroundUpload")
.board-background-upload
input.js-bg-upload-input.hide(type="file" name="file" accept="image/*")
a.primary.wide.js-bg-upload-button
i.fa.fa-plus
@@ -167,16 +174,27 @@ template(name="boardBackgroundsPopup")
+spinner
if error.get
.warning {{_ error.get}}
//- The pictures uploaded to this board, with their NAMES: an upload that shows
nothing afterwards is indistinguishable from one that failed, which is what
uploading looked like while this list lived behind a menu entry of its own.
It is drawn inside Change Background Image, under the upload that adds to it,
and that entry - "Board backgrounds" - is gone with the popup it opened.
template(name="boardBackgroundList")
.board-backgrounds-manage
.board-backgrounds-grid.clearfix
each backgrounds
//- The PICTURE is the button: clicking a thumbnail, or its name, puts
that one behind the board. It used to be a small check icon in the row
below, which is a 14px target beside an 80px picture of the thing it
applies - so the obvious click did nothing. The row keeps what the
picture cannot say: download it, or delete it.
.board-bg-item(class="{{#if isActiveBackground}}is-active{{/if}}")
.board-bg-thumb(style="background-image: url('{{link}}');")
.board-bg-thumb.js-set-board-background(style="background-image: url('{{link}}');" title="{{_ 'set-as-active'}}")
if isActiveBackground
i.fa.fa-check.board-bg-active-check
.board-bg-name.js-set-board-background(title="{{_ 'set-as-active'}}")= name
.board-bg-actions
unless isActiveBackground
a.js-set-board-background(title="{{_ 'set-as-active'}}")
i.fa.fa-check-circle-o
a.js-download-board-background(href="{{link}}?download=true" download="{{name}}" title="{{_ 'download'}}")
i.fa.fa-arrow-down
a.js-delete-board-background(title="{{_ 'delete'}}")
@@ -193,16 +211,19 @@ template(name="boardChangeBackgroundImagePopup")
label
| {{_ 'board-background-image-url'}}
input.js-board-background-image-url(type="text" value="{{backgroundImageURL}}" autofocus)
div.buttonsContainer
input.primary.wide(type="submit" value="{{_ 'save'}}")
br
br
br
br
br
//- Save and Unset side by side, in that order: they are the two things to
do with the address above them, and Unset was five blank lines below the
Save it belongs beside.
.buttonsContainer.board-background-image-buttons
input.primary(type="submit" value="{{_ 'save'}}")
button.js-remove-background-image.negate.card-details-red(type="button") {{_ 'unset-color'}}
hr
div
button.js-remove-background-image.negate.wide.card-details-red.right {{_ 'unset-color'}}
//- ...and the same question answered from this machine instead of from a
URL, with what has been uploaded to this board under it - so an upload is
seen to have arrived, and can be applied, without going to another popup
to find out.
+boardBackgroundUpload
+boardBackgroundList
template(name="boardInfoOnMyBoardsPopup")
form.board-info-on-my-boards
@@ -240,15 +261,58 @@ template(name="boardInfoOnMyBoardsPopup")
i.fa.fa-arrows-h
| {{_ 'personal-list-width'}}
//- The Card Settings, from the CARD's own menu: the same popup with the
"Show on Minicard" column hidden, because the card menu is opened while
looking at a card. One list of settings, one set of handlers, one place a new
setting has to be added. client/components/cards/cardDetails.jade
template(name="showOnCardPopup")
+boardCardSettingsPopup(side="card")
//- ...and from the MINICARD's menu, the other column.
//-
//- "Labels text" - the one PERSONAL setting in that column - is a row of the
//- same table, under Labels, so it is read where the rest of the minicard's
//- settings are. It is offered to everybody, because it is stored on the user's
//- profile (or in this browser when there is nobody logged in) and changes
//- nothing for anyone else; a user who is not a board admin sees that row
//- alone. client/lib/minicardLabelText.js
template(name="showOnMinicardPopup")
if currentUser.isBoardAdmin
+boardCardSettingsPopup(side="minicard" card=this)
else
+boardCardSettingsPopup(side="minicard" personalOnly=true card=this)
//- No heading row. It used to name the columns of a ROW - "Show on Card" over
the checkbox and "Description" over the name - which is what they label, but
not what they LOOK like: the rows flow into as many columns as the window
fits, so two headings sat above the first of three or four columns and read as
if they named those. The first of them also repeated the popup's own title,
which is "Show on Card" or "Show on Minicard" already. A row is a checkbox and
the name of a setting, and needs neither.
template(name="boardCardSettingsPopup")
form.board-card-settings
.card-settings-grid
.card-settings-column
h4 {{_ 'show-on-card'}}
.card-settings-column
h4 {{_ 'show-on-minicard'}}
.card-settings-column
h4 {{_ 'description'}}
form.board-card-settings(class=settingsSideClass)
//- "List title": the name of the LIST this card is in, on its minicard.
A per-CARD setting, unchecked by default, and the first row of this
column because it is about the card as a whole rather than one of its
fields. It was a line in the card's menu reading "Show list on minicard";
it is a checkbox among the other things a minicard can show, which is
where somebody looking for it would look. Only for a user who may change
the card - the same permission the menu entry had.
The board-wide "Show lists" row further down turns it on for EVERY card;
this one is for a single card that needs it, which is what it is for on a
swimlane, in search results and in My Cards, where a card is read away
from the column it lives in. client/components/cards/minicard.jade
if canModifyCard
.card-settings-row.card-settings-row-personal
.card-settings-column
.card-settings-column
a.flex.js-toggle-show-list-on-minicard(title="{{_ 'list'}} {{_ 'title'}}" class="{{#if showsListOnMinicard}}is-checked{{/if}}")
i.fa(class="{{#if showsListOnMinicard}}fa-check{{else}}fa-square-o{{/if}}")
.card-settings-column
span
i.fa.fa-list
| {{_ 'list'}}
| {{_ 'title'}}
.card-settings-row
.card-settings-column
a.flex.js-field-has-receiveddate(title="{{_ 'card-received'}}" class="{{#if allowsReceivedDate}}is-checked{{/if}}")
@@ -393,6 +457,26 @@ template(name="boardCardSettingsPopup")
span
i.fa.fa-tag
| {{_ 'labels'}}
//- "Labels text": the labels on a minicard as coloured WORDS, or as
coloured bars with the words left out. Directly under Labels, because it
is about what those labels look like.
It is the one PERSONAL row of this table - the user's own profile, or this
browser when nobody is logged in - so it is shown only in the minicard's
own view (Minicard menu / Show on Minicard) and not in the board-wide
Card Settings, where every other row belongs to the board. Checked by
default: the text is what a minicard shows unless somebody turns it off.
client/lib/minicardLabelText.js
.card-settings-row.card-settings-row-personal
.card-settings-column
.card-settings-column
a.flex.js-toggle-minicard-label-text(title="{{_ 'labels'}} {{_ 'custom-field-text'}}" class="{{#if showsMinicardLabelText}}is-checked{{/if}}")
i.fa(class="{{#if showsMinicardLabelText}}fa-check{{else}}fa-square-o{{/if}}")
.card-settings-column
span
i.fa.fa-tag
i.fa.fa-file-text-o
| {{_ 'labels'}}
| {{_ 'custom-field-text'}}
.card-settings-row
.card-settings-column
a.flex.js-field-has-card-number(title="{{_ 'card'}} {{_ 'number'}}" class="{{#if allowsCardNumber}}is-checked{{/if}}")
@@ -518,7 +602,12 @@ template(name="boardCardSettingsPopup")
//
| {{_ 'activities'}}
template(name="boardSubtaskSettingsPopup")
//- The board's Subtasks Settings. Its own template, because the same settings
are read from two places: Board Settings, and the menu of a subtask on an
opened card - which is where somebody looking at a subtask is when they want
to change what subtasks do. One list, one set of handlers.
client/components/cards/subtasks.jade
template(name="boardSubtaskSettingsBody")
form.board-subtask-settings
h3 {{_ 'show-parent-in-minicard'}}
a#prefix-with-full-path.flex.js-field-show-parent-in-minicard(title="{{_ 'prefix-with-full-path'}}" class="{{#if $eq presentParentTask 'prefix-with-full-path'}}is-checked{{/if}}")
@@ -568,36 +657,26 @@ template(name="boardSubtaskSettingsPopup")
else
option(value=_id) {{title}}
template(name="boardSubtaskSettingsPopup")
+boardSubtaskSettingsBody
//- #1173: the fourteen per-source links that were here are the import PAGE now
- one full-width page that lists every source, offers the same "what to
include" checkboxes every export offers, and keeps its own address per source
so the old links still work. What is left in the popup is the one import that
is not a board: card dependencies.
template(name="chooseBoardSourcePopup")
ul.pop-over-list
li
a(href="{{pathFor '/import/trello'}}") {{_ 'import'}} / Trello
li
a(href="{{pathFor '/import/wekan'}}") {{_ 'import'}} / JSON
li
a(href="{{pathFor '/import/csv'}}") {{_ 'import'}} / CSV-TSV
li
a(href="{{pathFor '/import/excel'}}") {{_ 'import'}} / Excel
li
a(href="{{pathFor '/import/jira'}}") {{_ 'import'}} / Jira
li
a(href="{{pathFor '/import/kanboard'}}") {{_ 'import'}} / Kanboard
li
a(href="{{pathFor '/import/deck'}}") {{_ 'import'}} / NextCloud Deck
li
a(href="{{pathFor '/import/openproject'}}") {{_ 'import'}} / OpenProject
li
a(href="{{pathFor '/import/github'}}") {{_ 'import'}} / GitHub
li
a(href="{{pathFor '/import/gitlab'}}") {{_ 'import'}} / GitLab
li
a(href="{{pathFor '/import/gitea'}}") {{_ 'import'}} / Gitea
li
a(href="{{pathFor '/import/forgejo'}}") {{_ 'import'}} / Forgejo
li
a(href="{{pathFor '/import/asana'}}") {{_ 'import'}} / Asana
li
a(href="{{pathFor '/import/zenkit'}}") {{_ 'import'}} / Zenkit
//- "Import board", and nothing else. It used to read "Import board
(Trello, Jira, WeKan export, CSV, Excel, ...)" - the whole list of
sources, in a menu row, above a page that asks which source it is and
lists every one of them with room to. Naming them twice makes the menu
the thing to read and the page the thing to confirm.
client/components/import/import.jade
a.js-open-import-page(href="{{pathFor '/import'}}")
i.fa.fa-download
| {{_ 'import-board-c'}}
li
a.js-import-dependencies {{_ 'import'}} / {{_ 'card-dependencies'}} (JSON/SVG)
@@ -662,11 +741,10 @@ template(name="boardMenuPopup")
a.js-open-rules-view(title="{{_ 'rules'}}")
i.fa.fa-magic
| {{_ 'rules'}}
if currentUser.isBoardAdmin
li
a.js-custom-fields
i.fa.fa-file-text-o
| {{_ 'custom-fields'}}
//- "Custom Fields" was here. The board's list of them is in the card's own
menu now - Card menu / Custom Fields, whose Settings cog opens it - which
is where somebody looking at a card's fields already is.
client/components/cards/cardDetails.jade
li
a.js-open-archives
i.fa.fa-archive
@@ -680,10 +758,10 @@ template(name="boardMenuPopup")
a.js-change-background-image
i.fa.fa-picture-o
| {{_ 'board-change-background-image'}}
li
a.js-manage-board-backgrounds
i.fa.fa-image
| {{_ 'board-backgrounds'}}
//- "Board backgrounds" was here. It was the list of pictures uploaded to
this board, and that list is drawn inside Change Background Image now -
under the upload that adds to it - so a second entry to the same list
was a second place to look for one thing.
//Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
//if currentUser.isBoardAdmin
//
@@ -705,20 +783,26 @@ template(name="boardMenuPopup")
li
a.js-export-board
i.fa.fa-upload
| {{_ 'export-board'}}
| {{_ 'export'}}
if canImportIntoBoard
li
a.js-import-into-board
i.fa.fa-download
| {{_ 'import'}}
if currentUser.isBoardAdmin
li
a.js-outgoing-webhooks
i.fa.fa-globe
| {{_ 'outgoing-webhooks'}}
li
a.js-card-settings
i.fa.fa-id-card
| {{_ 'card-settings'}}
li
a.js-subtask-settings
i.fa.fa-globe
| {{_ 'subtask-settings'}}
//- "Card Settings" was here. Every setting in it is now in the menu of
the thing it is about: the card's menu has "Show on Card" and a
minicard's has "Show on Minicard", each with that column of the same
table. A board-wide entry as well would be a third way to the same list.
client/components/cards/cardDetails.jade
//- "Subtasks Settings" was here. What subtasks do on this board is behind
the hamburger at the end of the Subtasks heading on an opened card,
which is where somebody looking at subtasks already is.
client/components/cards/cardDetails.jade
unless currentBoard.isTemplatesBoard
if currentUser.isBoardAdmin
hr
@@ -736,107 +820,37 @@ template(name="boardMenuPopup")
| {{_ 'archive-board'}}
template(name="exportBoardPopup")
ul.pop-over-list
li
a.download-json-link(href="{{exportUrl}}", download="{{exportJsonFilename}}")
i.fa.fa-upload
| {{_ 'export'}} / JSON
li
a.download-json-link(href="{{exportUrlNoAttachments}}", download="{{exportJsonFilename}}")
i.fa.fa-upload
| {{_ 'export'}} / JSON ({{_ 'export-board-without-attachments'}})
li
a(href="{{exportUrlExcel}}", download="{{exportFilenameExcel}}")
i.fa.fa-upload
| {{_ 'export'}} / Excel
li
a(href="{{exportCsvUrl}}", download="{{exportCsvFilename}}")
i.fa.fa-upload
| {{_ 'export'}} / CSV (,)
li
a(href="{{exportScsvUrl}}", download="{{exportCsvFilename}}")
i.fa.fa-upload
| {{_ 'export'}} / CSV (;)
li
a(href="{{exportTsvUrl}}", download="{{exportTsvFilename}}")
i.fa.fa-upload
| {{_ 'export'}} / TSV
li
a(href="{{exportUrlPDF}}", download="{{exportFilenamePDF}}")
i.fa.fa-upload
| {{_ 'export'}} / PDF
li
a(href="{{exportUrlKanboard}}", download="{{exportFilenameKanboard}}")
i.fa.fa-upload
| {{_ 'export'}} / JSON / Kanboard
li
a(href="{{exportUrlExternal 'trello'}}", download="{{exportFilenameExternal 'trello'}}")
i.fa.fa-upload
| {{_ 'export'}} / JSON / Trello
li
a(href="{{exportUrlExternal 'jira'}}", download="{{exportFilenameExternal 'jira'}}")
i.fa.fa-upload
| {{_ 'export'}} / JSON / Jira
li
a(href="{{exportUrlExternal 'deck'}}", download="{{exportFilenameExternal 'deck'}}")
i.fa.fa-upload
| {{_ 'export'}} / JSON / NextCloud Deck
li
a(href="{{exportUrlExternal 'openproject'}}", download="{{exportFilenameExternal 'openproject'}}")
i.fa.fa-upload
| {{_ 'export'}} / JSON / OpenProject
li
a(href="{{exportUrlExternal 'github'}}", download="{{exportFilenameExternal 'github'}}")
i.fa.fa-upload
| {{_ 'export'}} / JSON / GitHub
li
a(href="{{exportUrlExternal 'gitlab'}}", download="{{exportFilenameExternal 'gitlab'}}")
i.fa.fa-upload
| {{_ 'export'}} / JSON / GitLab
li
a(href="{{exportUrlExternal 'gitea'}}", download="{{exportFilenameExternal 'gitea'}}")
i.fa.fa-upload
| {{_ 'export'}} / JSON / Gitea
li
a(href="{{exportUrlExternal 'forgejo'}}", download="{{exportFilenameExternal 'forgejo'}}")
i.fa.fa-upload
| {{_ 'export'}} / JSON / Forgejo
li
a(href="{{exportUrlExternal 'asana'}}", download="{{exportFilenameExternal 'asana'}}")
i.fa.fa-upload
| {{_ 'export'}} / JSON / Asana
li
a(href="{{exportUrlExternal 'zenkit'}}", download="{{exportFilenameExternal 'zenkit'}}")
i.fa.fa-upload
| {{_ 'export'}} / JSON / Zenkit
li
a.html-export-board
i.fa.fa-archive
| {{_ 'export'}} / HTML
li
a.js-export-dependencies-json
i.fa.fa-link
| {{_ 'export'}} / {{_ 'card-dependencies'}} / JSON
li
a.js-export-dependencies-svg
i.fa.fa-link
| {{_ 'export'}} / {{_ 'card-dependencies'}} / SVG
//- The same popup as Export swimlane / list / card, with the same panes and
the same formats table - a board just happens to be the widest scope, so the
entries marked `scopes: ['board']` (HTML, the dependency graph, CSV, and the
exports written for another tool) appear here and nowhere else.
client/components/boards/exportScope.js
+exportScopeBody(title=boardTitle)
template(name="importBoardIntoPopup")
//- Import INTO this board - the same body in its import mode. (All Boards has
an "Import board" of its own, which makes a NEW board from a file; this one
adds to the board you are looking at.)
+exportScopeBody(title=boardTitle mode="import")
template(name="labelsWidget")
.board-widget.board-widget-labels
h3
//- Same caret as Members above: folding Labels hides the labels under it.
h3.js-toggle-fold(data-fold="labels" role="button" tabindex="0")
i.fa(class="{{foldCaret 'labels'}}")
i.fa.fa-tag
| {{_ 'labels'}}
.board-widget-content
each currentBoard.labels
a.card-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}"
class="{{#if currentUser.isNotCommentOnly}}{{#if currentUser.isNotWorker}}js-label{{/if}}{{/if}}")
span.card-label-name
+viewer
= name
if currentUser.isBoardAdmin
a.card-label.add-label.js-add-label(title="{{_ 'label-create'}}")
i.fa.fa-plus
if isFoldOpen 'labels'
.board-widget-content
each currentBoard.labels
a.card-label(class="{{labelColorClass color}}" style="{{labelColorStyle color}}"
class="{{#if currentUser.isNotCommentOnly}}{{#if currentUser.isNotWorker}}js-label{{/if}}{{/if}}")
span.card-label-name
+viewer
= name
if currentUser.isBoardAdmin
a.card-label.add-label.js-add-label(title="{{_ 'label-create'}}")
i.fa.fa-plus
template(name="memberPopup")
.board-member-menu
+213 -208
View File
@@ -6,8 +6,10 @@ import { TAPi18n } from '/imports/i18n';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
const { allBoardsPath, SECTION_ARCHIVE } = require('/models/lib/allBoardsUrls');
import { InfiniteScrolling } from '/client/lib/infiniteScrolling';
import '/client/components/boards/exportScope';
import AccessibilitySettings from '/models/accessibilitySettings';
import Boards from '/models/boards';
import Cards from '/models/cards';
import Attachments from '/models/attachments';
import { generateUniversalAttachmentUrl } from '/models/lib/universalUrlGenerator';
import Integrations from '/models/integrations';
@@ -20,6 +22,13 @@ import {
exportDependenciesSvg,
} from '/client/lib/exportDependencies';
import { parseDependencyLines } from '/client/lib/importDependencies';
import { caretClassFor } from '/client/lib/sectionCaret';
import { buildAttachmentUploadConfig } from '/client/lib/attachmentUploadConfig';
import { toggleFold } from '/client/lib/foldState';
import {
hiddenMinicardLabelText,
toggleMinicardLabelText,
} from '/client/lib/minicardLabelText';
import {
clearSidebarInstance,
setSidebarInstance,
@@ -308,24 +317,27 @@ Template.sidebar.helpers({
});
Template.sidebar.events({
// Members and Labels fold by their headings, the way an opened card's
// sections do. Delegated here, on the sidebar, because the two headings live
// in two child templates and the rule is one rule.
// client/lib/foldState.js
'click .js-toggle-fold'(event) {
event.preventDefault();
toggleFold(event.currentTarget.dataset.fold);
},
// A heading with role="button" and a tabindex has to answer the keys a button
// answers, or it cannot be folded without a mouse.
'keydown .js-toggle-fold'(event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleFold(event.currentTarget.dataset.fold);
},
'click .js-hide-sidebar'(event, tpl) {
tpl.hide();
},
'click .js-back-home'(event, tpl) {
tpl.setView();
},
'click .js-toggle-minicard-label-text'() {
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser) {
Meteor.call('toggleMinicardLabelText');
} else if (window.localStorage.getItem('hiddenMinicardLabelText')) {
window.localStorage.removeItem('hiddenMinicardLabelText');
location.reload();
} else {
window.localStorage.setItem('hiddenMinicardLabelText', 'true');
location.reload();
}
},
'click .js-shortcuts'() {
FlowRouter.go('shortcuts');
},
@@ -362,16 +374,6 @@ Template.sidebar.events({
Blaze.registerHelper('Sidebar', () => Sidebar);
Template.homeSidebar.helpers({
hiddenMinicardLabelText() {
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser) {
return (currentUser.profile || {}).hiddenMinicardLabelText;
} else if (window.localStorage.getItem('hiddenMinicardLabelText')) {
return true;
} else {
return false;
}
},
isVerticalScrollbars() {
const user = ReactiveCache.getCurrentUser();
return user && user.isVerticalScrollbars();
@@ -385,10 +387,25 @@ Template.homeSidebar.helpers({
let ret = Utils.getCurrentBoard().showActivities ?? false;
return ret;
},
// The caret in front of the Activities heading - the same one an opened
// card's sections carry, from the same function, so the two cannot point
// different ways in one language. client/lib/sectionCaret.js
activitiesCaret() {
return caretClassFor(Utils.getCurrentBoard()?.showActivities ?? false);
},
});
Template.homeSidebar.events({
async 'click .js-toggle-show-activities'() {
async 'click .js-toggle-show-activities'(event) {
event.preventDefault();
await Utils.getCurrentBoard().toggleShowActivities();
},
// A heading that can be operated with the mouse can be operated with the
// keyboard: it carries role="button" and a tabindex, so Enter and Space have
// to do what a click does.
async 'keydown .js-toggle-show-activities'(event) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
await Utils.getCurrentBoard().toggleShowActivities();
},
});
@@ -479,12 +496,6 @@ Template.boardMenuPopup.events({
});
}
},
'click .js-custom-fields'() {
if (Sidebar) {
Sidebar.setView('customFields');
}
Popup.back();
},
'click .js-open-archives'() {
if (Sidebar) {
Sidebar.setView('archives');
@@ -493,7 +504,6 @@ Template.boardMenuPopup.events({
},
'click .js-change-board-color': Popup.open('boardChangeColor'),
'click .js-change-background-image': Popup.open('boardChangeBackgroundImage'),
'click .js-manage-board-backgrounds': Popup.open('boardBackgrounds'),
'click .js-board-info-on-my-boards': Popup.open('boardInfoOnMyBoards'),
'click .js-change-language': Popup.open('changeLanguage'),
'click .js-delete-duplicate-lists': Popup.afterConfirm('deleteDuplicateLists', function() {
@@ -552,9 +562,11 @@ Template.boardMenuPopup.events({
}),
'click .js-outgoing-webhooks': Popup.open('outgoingWebhooks'),
'click .js-import-board': Popup.open('chooseBoardSource'),
'click .js-subtask-settings': Popup.open('boardSubtaskSettings'),
'click .js-card-settings': Popup.open('boardCardSettings'),
'click .js-export-board': Popup.open('exportBoard'),
// Import INTO this board. `js-import-board` above is All Boards' own row,
// which makes a NEW board from a file - a different question with a nearly
// identical name, so the classes are kept apart.
'click .js-import-into-board': Popup.open('importBoardInto'),
});
Template.boardMenuPopup.onCreated(function() {
@@ -937,140 +949,18 @@ Template.outgoingWebhooksPopup.events({
},
});
// The board popup is the shared export body (client/components/boards/exportScope.js)
// with the widest scope: every format, every filename and the URL builder behind
// them live in that one table, so this popup has nothing of its own to compute
// except the NAME of what is being exported. The nineteen helpers that used to
// build those URLs here are gone with the markup that called them - two lists of
// formats was how the board menu and the other three menus drifted apart.
Template.exportBoardPopup.helpers({
withApi() {
return Template.instance().apiEnabled.get();
},
exportUrl() {
const params = {
boardId: Session.get('currentBoard'),
};
const queryParams = {
authToken: Accounts._storedLoginToken(),
};
return FlowRouter.path('/api/boards/:boardId/export', params, queryParams);
},
// #5870: JSON export omitting base64 attachment data, so very large boards
// (whose inlined attachments would otherwise overflow the JSON serializer)
// can still be exported.
exportUrlNoAttachments() {
const params = {
boardId: Session.get('currentBoard'),
};
const queryParams = {
authToken: Accounts._storedLoginToken(),
attachments: 'false',
};
return FlowRouter.path('/api/boards/:boardId/export', params, queryParams);
},
exportUrlExcel() {
const params = {
boardId: Session.get('currentBoard'),
};
const queryParams = {
authToken: Accounts._storedLoginToken(),
};
return FlowRouter.path(
'/api/boards/:boardId/exportExcel',
params,
queryParams,
);
},
exportUrlPDF() {
const params = {
boardId: Session.get('currentBoard'),
};
const queryParams = {
authToken: Accounts._storedLoginToken(),
};
return FlowRouter.path('/api/boards/:boardId/exportPDF', params, queryParams);
},
exportFilenamePDF() {
const boardId = Session.get('currentBoard');
return `export-board-${boardId}.pdf`;
},
exportUrlKanboard() {
const params = {
boardId: Session.get('currentBoard'),
};
const queryParams = {
authToken: Accounts._storedLoginToken(),
};
return FlowRouter.path('/api/boards/:boardId/export/kanboard', params, queryParams);
},
exportFilenameKanboard() {
const boardId = Session.get('currentBoard');
return `export-board-kanboard-${boardId}.json`;
},
// Generalized export URL/filename for the external tools (Deck, OpenProject,
// GitHub, GitLab, Gitea, Forgejo).
exportUrlExternal(format) {
return FlowRouter.path(
`/api/boards/:boardId/export/${format}`,
{ boardId: Session.get('currentBoard') },
{ authToken: Accounts._storedLoginToken() },
);
},
exportFilenameExternal(format) {
return `export-board-${format}-${Session.get('currentBoard')}.json`;
},
exportFilenameExcel() {
const boardId = Session.get('currentBoard');
return `export-board-excel-${boardId}.xlsx`;
},
exportCsvUrl() {
const params = {
boardId: Session.get('currentBoard'),
};
const queryParams = {
authToken: Accounts._storedLoginToken(),
delimiter: ',',
};
return FlowRouter.path(
'/api/boards/:boardId/export/csv',
params,
queryParams,
);
},
exportScsvUrl() {
const params = {
boardId: Session.get('currentBoard'),
};
const queryParams = {
authToken: Accounts._storedLoginToken(),
delimiter: ';',
};
return FlowRouter.path(
'/api/boards/:boardId/export/csv',
params,
queryParams,
);
},
exportTsvUrl() {
const params = {
boardId: Session.get('currentBoard'),
};
const queryParams = {
authToken: Accounts._storedLoginToken(),
delimiter: '\t',
};
return FlowRouter.path(
'/api/boards/:boardId/export/csv',
params,
queryParams,
);
},
exportJsonFilename() {
const boardId = Session.get('currentBoard');
return `export-board-${boardId}.json`;
},
exportCsvFilename() {
const boardId = Session.get('currentBoard');
return `export-board-${boardId}.csv`;
},
exportTsvFilename() {
const boardId = Session.get('currentBoard');
return `export-board-${boardId}.tsv`;
// #1173: the shared export body names the file after what was exported, and
// for this popup that is the board.
boardTitle() {
const board = ReactiveCache.getBoard(Session.get('currentBoard'));
return (board && board.title) || 'export-board';
},
});
@@ -1241,26 +1131,103 @@ Template.boardChangeBackgroundImagePopup.helpers({
},
});
// Manage the board's stored background images (upload / set active / download /
// delete). Backgrounds are board-level Attachments (meta.boardId, no cardId,
// Uploading one. It sits with the Background Image URL field
// (boardChangeBackgroundImagePopup) rather than with the list of images already
// uploaded: a URL and a file are the same question answered two ways.
// Backgrounds are board-level Attachments (meta.boardId, no cardId,
// meta.source === 'board-background') in the default attachments storage.
Template.boardBackgroundsPopup.onCreated(function () {
Template.boardBackgroundUpload.onCreated(function () {
this.uploading = new ReactiveVar(false);
this.error = new ReactiveVar('');
const board = Utils.getCurrentBoard();
this.boardId = board && board._id;
});
Template.boardBackgroundUpload.helpers({
uploading() {
return Template.instance().uploading;
},
error() {
return Template.instance().error;
},
});
Template.boardBackgroundUpload.events({
'click .js-bg-upload-button'(event, tpl) {
event.preventDefault();
tpl.find('.js-bg-upload-input').click();
},
async 'change .js-bg-upload-input'(event, tpl) {
const file = event.currentTarget.files && event.currentTarget.files[0];
if (!file) return;
tpl.error.set('');
tpl.uploading.set(true);
// The config comes from the shared builder, which is the whole reason this
// upload works: it stamps the generated `fileId` into `meta.fileId`, and
// Attachments' namingFunction reads the stored file's NAME out of there.
// Written by hand here, without it, every background upload was stored
// under `undefined` and never arrived. client/lib/attachmentUploadConfig.js
// allow re-selecting the same file later - done FIRST, so a failure below
// does not also leave the picker refusing to offer the same file again.
const input = event.currentTarget;
try {
const uploader = await Attachments.insertAsync(
buildAttachmentUploadConfig({
file,
meta: { boardId: tpl.boardId, source: 'board-background' },
}),
false,
);
// A finished upload puts itself BEHIND THE BOARD. "Add background image"
// is asked for by somebody who wants that picture there; an upload that
// only lands in a list, with the board unchanged, reads as one that did
// not work - which is exactly how this looked. The list under it still
// has the check that switches between the pictures already uploaded.
uploader.on('uploaded', async (err, fileRef) => {
if (err || !fileRef || !fileRef._id) return;
const board = Utils.getCurrentBoard();
if (!board) return;
await board.setBackgroundImage(fileRef._id);
Utils.setBackgroundImage();
});
uploader.on('end', (err) => {
tpl.uploading.set(false);
if (err) {
console.error('board background upload failed', err);
tpl.error.set(err.reason || err.message || 'upload-failed');
}
});
uploader.on('error', (err) => {
tpl.uploading.set(false);
console.error('board background upload failed', err);
tpl.error.set((err && (err.reason || err.message)) || 'upload-failed');
});
uploader.start();
} catch (error) {
// An upload can fail BEFORE there is an uploader to listen to - a config
// the collection refuses, a name its namingFunction cannot build. That
// used to reject into nothing: the spinner stopped, no message appeared,
// and the picture simply never showed up in the list. Say so instead.
tpl.uploading.set(false);
console.error('board background upload failed', error);
tpl.error.set((error && (error.reason || error.message)) || 'upload-failed');
}
input.value = '';
},
});
// The board's stored background images: set active, download, delete. Drawn in
// BOTH the Change Background Image popup (under the upload that adds to it) and
// the Board backgrounds popup, from this one template and one subscription.
Template.boardBackgroundList.onCreated(function () {
const board = Utils.getCurrentBoard();
this.boardId = board && board._id;
if (this.boardId) {
this.subscribe('boardBackgrounds', this.boardId);
}
});
Template.boardBackgroundsPopup.helpers({
uploading() {
return Template.instance().uploading;
},
error() {
return Template.instance().error;
},
Template.boardBackgroundList.helpers({
backgrounds() {
// Raw collection docs don't carry the .link() helper, so compute the URL.
return Attachments.collection
@@ -1281,38 +1248,14 @@ Template.boardBackgroundsPopup.helpers({
},
});
Template.boardBackgroundsPopup.events({
'click .js-bg-upload-button'(event, tpl) {
Template.boardBackgroundList.events({
// Clicking a picture - the thumbnail or its name - puts that one behind the
// board, and the URL field above the list follows, because setBackgroundImage
// writes both the id and the address it resolves to.
async 'click .js-set-board-background'(event) {
event.preventDefault();
tpl.find('.js-bg-upload-input').click();
},
async 'change .js-bg-upload-input'(event, tpl) {
const file = event.currentTarget.files && event.currentTarget.files[0];
if (!file) return;
tpl.error.set('');
tpl.uploading.set(true);
const uploader = await Attachments.insertAsync(
{
file,
chunkSize: 'dynamic',
meta: { boardId: tpl.boardId, source: 'board-background' },
},
false,
);
uploader.on('end', (err) => {
tpl.uploading.set(false);
if (err) tpl.error.set(err.reason || 'upload-failed');
});
uploader.on('error', (err) => {
tpl.uploading.set(false);
tpl.error.set((err && err.reason) || 'upload-failed');
});
uploader.start();
// allow re-selecting the same file later
event.currentTarget.value = '';
},
async 'click .js-set-board-background'() {
const board = Utils.getCurrentBoard();
if (!board || !this._id) return;
await board.setBackgroundImage(this._id);
Utils.setBackgroundImage();
},
@@ -1427,7 +1370,11 @@ Template.boardInfoOnMyBoardsPopup.events({
},
});
Template.boardSubtaskSettingsPopup.onCreated(function() {
// The settings themselves - drawn by Board Settings and by the menu of a
// subtask on an opened card, from this one template with its own state and
// handlers, so neither place needs code of its own.
// client/components/sidebar/sidebar.jade
Template.boardSubtaskSettingsBody.onCreated(function() {
// Same reactive-snapshot fix as boardCardSettingsPopup (#6385): the
// allowsSubtasks toggle reads tpl.currentBoard, so keep it current in an
// autorun so the setting can be reversed without a page refresh.
@@ -1436,7 +1383,7 @@ Template.boardSubtaskSettingsPopup.onCreated(function() {
});
});
Template.boardSubtaskSettingsPopup.helpers({
Template.boardSubtaskSettingsBody.helpers({
allowsSubtasks() {
// Get the current board reactively using board ID from Session
const boardId = Session.get('currentBoard');
@@ -1522,7 +1469,7 @@ Template.boardSubtaskSettingsPopup.helpers({
},
});
Template.boardSubtaskSettingsPopup.events({
Template.boardSubtaskSettingsBody.events({
'click .js-field-has-subtasks'(evt, tpl) {
evt.preventDefault();
const newValue = !tpl.currentBoard.allowsSubtasks;
@@ -1579,7 +1526,50 @@ Template.boardCardSettingsPopup.onCreated(function() {
});
});
// The card this popup was opened for, when it was opened for one. Board
// Settings / Card Settings has no card - it is about every card of the board -
// so the two rows that belong to a card are only ever drawn from the minicard's
// own menu, which passes it in. client/components/sidebar/sidebar.jade
function settingsCard() {
const passed = (Template.currentData() || {}).card;
return passed && passed._id ? ReactiveCache.getCard(passed._id) : null;
}
Template.boardCardSettingsPopup.helpers({
// Board Settings / Card Settings shows both columns - "Show on Card" and
// "Show on Minicard" beside each other. The card's own menu and the
// minicard's menu open the SAME popup asking for one of them, and the other
// column is hidden by this class rather than by a second copy of the list:
// twenty-four settings written out twice would drift the first time one is
// added. client/components/sidebar/sidebar.css
settingsSideClass() {
const data = Template.currentData() || {};
const classes = [];
if (data.side === 'card') classes.push('show-card-only');
if (data.side === 'minicard') classes.push('show-minicard-only');
// A user who is not a board admin may still set the one PERSONAL row of
// this table - "Labels text" - and none of the board's own. They get that
// row alone rather than a table of checkboxes the server would refuse.
if (data.personalOnly) classes.push('show-personal-only');
return classes.join(' ');
},
// Checked means the labels on a minicard show their TEXT, which is what they
// do unless somebody turns it off - so the stored "hidden" flag is read the
// other way round here. A checkbox that is unticked by default for the
// default behaviour reads as broken. client/lib/minicardLabelText.js
showsMinicardLabelText() {
return !hiddenMinicardLabelText();
},
// "List title" is the CARD's own setting, not the board's, so the card is
// passed in with the side (showOnMinicardPopup) rather than looked up: a
// minicard's menu is opened from the board, where there is no "current card".
// Re-read from the collection so the checkbox follows its own click.
showsListOnMinicard() {
const card = settingsCard();
return Boolean(card && card.showListOnMinicard);
},
allowsReceivedDate() {
const boardId = Session.get('currentBoard');
const currentBoard = ReactiveCache.getBoard(boardId);
@@ -1866,6 +1856,21 @@ Template.boardCardSettingsPopup.helpers({
});
Template.boardCardSettingsPopup.events({
// The one row of this table that is the user's own, not the board's.
'click .js-toggle-minicard-label-text'(evt) {
evt.preventDefault();
toggleMinicardLabelText();
},
// ...and the one that is this CARD's. The board-wide "Show lists" row further
// down turns the list name on for every card; this turns it on for one.
'click .js-toggle-show-list-on-minicard'(evt) {
evt.preventDefault();
const card = settingsCard();
if (!card) return;
Cards.update(card._id, {
$set: { showListOnMinicard: !card.showListOnMinicard },
});
},
'click .js-field-has-receiveddate'(evt, tpl) {
evt.preventDefault();
const newValue = !tpl.currentBoard.allowsReceivedDate;
@@ -17,6 +17,12 @@ template(name="customFieldsSidebar")
i.fa.fa-plus
span {{_ 'createCustomField'}}
//- The board's list of custom fields as a right-sidebar view. Nothing opens it
any more: every custom field of the board is listed - with a tick when it is
on this card, a pencil, and "Add custom field" - by the hamburger at the end
of the card's own Custom Fields heading, which is where the fields are shown.
The templates below are the forms both used, and they are still opened from
there. client/components/cards/cardCustomFields.jade
template(name="createCustomFieldPopup")
form
label
@@ -244,8 +244,16 @@ Template.createCustomFieldPopup.events({
};
const currentData = Template.currentData();
// insert or update
if (!currentData._id) {
// Insert or update, decided by whether this form was opened ON a custom
// field - not by whether its context merely HAS an `_id`. Opened from the
// card's Custom Fields popup the context was the card, whose `_id` is a
// card's, and the update below then wrote to a custom field that does not
// exist: the new field was silently never created. An id that names no
// custom field is a new field.
const editing =
currentData._id && ReactiveCache.getCustomField(currentData._id);
if (!editing) {
data.boardIds = [Session.get('currentBoard')];
CustomFields.insert(data);
} else {
+18 -35
View File
@@ -240,8 +240,15 @@ template(name="disambiguateMultiMemberPopup")
button.wide.js-unassign-member {{_ 'unassign-member'}}
button.wide.js-assign-member {{_ 'assign-member'}}
template(name="moveSelectionPopup")
h3 {{_ 'moveSelectionPopup-title'}}
//- WHERE THE SELECTED CARDS GO: board, swimlane, list, above or below which
card, and Done. Move selection and Copy selection asked it with the same
form; only the heading above it and what happens on Done differ, and both of
those stay with the popups (sidebarFilters.js).
//-
//- `idSuffix` is the one thing the two copies really did differ in: the radio
buttons' ids, so a label clicks its OWN radio. The name stays `position` in
both, which is what the handler reads.
template(name="selectionDestinationPicker")
label {{_ 'boards'}}:
select.js-select-boards
each boards
@@ -263,46 +270,22 @@ template(name="moveSelectionPopup")
option(value="{{_id}}") {{add @index 1}}. {{title}}
div
input(type="radio" name="position" value="above" checked id="position-above-move" style="display: inline")
label(for="position-above-move") {{_ 'above-selected-card'}}
input(type="radio" name="position" value="above" checked id="position-above-{{idSuffix}}" style="display: inline")
label(for="position-above-{{idSuffix}}") {{_ 'above-selected-card'}}
div
input(type="radio" name="position" value="below" id="position-below-move" style="display: inline")
label(for="position-below-move") {{_ 'below-selected-card'}}
input(type="radio" name="position" value="below" id="position-below-{{idSuffix}}" style="display: inline")
label(for="position-below-{{idSuffix}}") {{_ 'below-selected-card'}}
.edit-controls.clearfix
button.primary.confirm.js-done {{_ 'done'}}
template(name="moveSelectionPopup")
h3 {{_ 'moveSelectionPopup-title'}}
+selectionDestinationPicker(dialog=dialog idSuffix="move")
template(name="copySelectionPopup")
h3 {{_ 'copySelectionPopup-title'}}
label {{_ 'boards'}}:
select.js-select-boards
each boards
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
each swimlanes
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
label {{_ 'lists'}}:
select.js-select-lists
each lists
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
label {{_ 'cards'}}:
select.js-select-cards
each cards
option(value="{{_id}}") {{add @index 1}}. {{title}}
div
input(type="radio" name="position" value="above" checked id="position-above-copy" style="display: inline")
label(for="position-above-copy") {{_ 'above-selected-card'}}
div
input(type="radio" name="position" value="below" id="position-below-copy" style="display: inline")
label(for="position-below-copy") {{_ 'below-selected-card'}}
.edit-controls.clearfix
button.primary.confirm.js-done {{_ 'done'}}
+selectionDestinationPicker(dialog=dialog idSuffix="copy")
template(name="setSelectionColorPopup")
h3 {{_ 'setSelectionColorPopup-title'}}
+143 -249
View File
@@ -343,53 +343,19 @@ Template.disambiguateMultiMemberPopup.events({
},
});
Template.moveSelectionPopup.onCreated(function() {
this.selectedBoardId = new ReactiveVar(Session.get('currentBoard'));
this.selectedSwimlaneId = new ReactiveVar('');
this.selectedListId = new ReactiveVar('');
this.selectedCardId = new ReactiveVar('');
this.position = new ReactiveVar('above');
this.getBoardData = function(boardId) {
const self = this;
Meteor.subscribe('board', boardId, false, {
onReady() {
const sameBoardId = self.selectedBoardId.get() === boardId;
self.selectedBoardId.set(boardId);
if (!sameBoardId) {
self.setFirstSwimlaneId();
self.setFirstListId();
}
},
});
};
this.setFirstSwimlaneId = function() {
try {
const board = ReactiveCache.getBoard(this.selectedBoardId.get());
const swimlaneId = board.swimlanes()[0]._id;
this.selectedSwimlaneId.set(swimlaneId);
} catch (e) {}
};
this.setFirstListId = function() {
try {
const boardId = this.selectedBoardId.get();
const swimlaneId = this.selectedSwimlaneId.get();
const lists = getListsForBoardSwimlane(boardId, swimlaneId);
const listId = lists[0] ? lists[0]._id : '';
this.selectedListId.set(listId);
this.selectedCardId.set('');
} catch (e) {}
};
this.getBoardData(Session.get('currentBoard'));
this.setFirstSwimlaneId();
this.setFirstListId();
// The four selects of the move/copy selection dialogs, in one template. The
// dialog - the popup's own template instance - is kept on THIS instance,
// because inside `each boards` the data context is a board and a helper
// reaching into the context for it would find nothing there.
Template.selectionDestinationPicker.onCreated(function() {
this.autorun(() => {
const data = Template.currentData();
this.dialog = data && data.dialog;
});
});
Template.moveSelectionPopup.helpers({
Template.selectionDestinationPicker.helpers({
boards() {
return ReactiveCache.getBoards(
{
@@ -403,30 +369,30 @@ Template.moveSelectionPopup.helpers({
);
},
swimlanes() {
const board = ReactiveCache.getBoard(Template.instance().selectedBoardId.get());
const board = ReactiveCache.getBoard(Template.instance().dialog.selectedBoardId.get());
return board ? board.swimlanes() : [];
},
lists() {
const instance = Template.instance();
const instance = Template.instance().dialog;
return getListsForBoardSwimlane(
instance.selectedBoardId.get(),
instance.selectedSwimlaneId.get(),
);
},
cards() {
const instance = Template.instance();
const instance = Template.instance().dialog;
const list = ReactiveCache.getList(instance.selectedListId.get());
if (!list) return [];
return list.cards(instance.selectedSwimlaneId.get()).sort((a, b) => a.sort - b.sort);
},
isDialogOptionBoardId(boardId) {
return Template.instance().selectedBoardId.get() === boardId;
return Template.instance().dialog.selectedBoardId.get() === boardId;
},
isDialogOptionSwimlaneId(swimlaneId) {
return Template.instance().selectedSwimlaneId.get() === swimlaneId;
return Template.instance().dialog.selectedSwimlaneId.get() === swimlaneId;
},
isDialogOptionListId(listId) {
return Template.instance().selectedListId.get() === listId;
return Template.instance().dialog.selectedListId.get() === listId;
},
isTitleDefault(title) {
if (
@@ -449,215 +415,143 @@ Template.moveSelectionPopup.helpers({
},
});
Template.moveSelectionPopup.events({
'change .js-select-boards'(event) {
const boardId = $(event.currentTarget).val();
Template.instance().getBoardData(boardId);
},
'change .js-select-swimlanes'(event) {
const instance = Template.instance();
instance.selectedSwimlaneId.set($(event.currentTarget).val());
instance.setFirstListId();
},
'change .js-select-lists'(event) {
const instance = Template.instance();
instance.selectedListId.set($(event.currentTarget).val());
instance.selectedCardId.set('');
},
'change .js-select-cards'(event) {
Template.instance().selectedCardId.set($(event.currentTarget).val());
},
'change input[name="position"]'(event) {
Template.instance().position.set($(event.currentTarget).val());
},
async 'click .js-done'() {
const instance = Template.instance();
const boardId = instance.selectedBoardId.get();
const swimlaneId = instance.selectedSwimlaneId.get();
const listId = instance.selectedListId.get();
const cardId = instance.selectedCardId.get();
const position = instance.position.get();
/**
* Move selection and Copy selection: the same dialog, twice.
*
* Both ask where the selected cards should go - board, swimlane, list, above or
* below which card - keep the same four reactive selections while you answer,
* and end by walking the selection in order. 145 of the 152 lines were the same
* in both; what differs is the seven in the middle, which is what each does to
* a card once the destination is known. That is `applyToCard`.
*
* The MARKUP is one template too - `selectionDestinationPicker` in
* sidebarFilters.jade - and its events bubble up to whichever popup includes
* it, which is the one holding these selections.
*/
function registerSelectionDialogTemplate(templateName, applyToCard) {
Template[templateName].onCreated(function() {
this.selectedBoardId = new ReactiveVar(Session.get('currentBoard'));
this.selectedSwimlaneId = new ReactiveVar('');
this.selectedListId = new ReactiveVar('');
this.selectedCardId = new ReactiveVar('');
this.position = new ReactiveVar('above');
const selectedCards = getSelectedCardsSorted();
const targetCard = cardId ? ReactiveCache.getCard(cardId) : null;
const sortIndexes = buildInsertionSortIndexes(
selectedCards.length,
targetCard,
position,
listId,
swimlaneId,
);
this.getBoardData = function(boardId) {
const self = this;
Meteor.subscribe('board', boardId, false, {
onReady() {
const sameBoardId = self.selectedBoardId.get() === boardId;
self.selectedBoardId.set(boardId);
for (let i = 0; i < selectedCards.length; i += 1) {
await selectedCards[i].move(boardId, swimlaneId, listId, sortIndexes[i]);
}
EscapeActions.executeUpTo('multiselection');
},
});
if (!sameBoardId) {
self.setFirstSwimlaneId();
self.setFirstListId();
}
},
});
};
Template.copySelectionPopup.onCreated(function() {
this.selectedBoardId = new ReactiveVar(Session.get('currentBoard'));
this.selectedSwimlaneId = new ReactiveVar('');
this.selectedListId = new ReactiveVar('');
this.selectedCardId = new ReactiveVar('');
this.position = new ReactiveVar('above');
this.setFirstSwimlaneId = function() {
try {
const board = ReactiveCache.getBoard(this.selectedBoardId.get());
const swimlaneId = board.swimlanes()[0]._id;
this.selectedSwimlaneId.set(swimlaneId);
} catch (e) {}
};
this.getBoardData = function(boardId) {
const self = this;
Meteor.subscribe('board', boardId, false, {
onReady() {
const sameBoardId = self.selectedBoardId.get() === boardId;
self.selectedBoardId.set(boardId);
this.setFirstListId = function() {
try {
const boardId = this.selectedBoardId.get();
const swimlaneId = this.selectedSwimlaneId.get();
const lists = getListsForBoardSwimlane(boardId, swimlaneId);
const listId = lists[0] ? lists[0]._id : '';
this.selectedListId.set(listId);
this.selectedCardId.set('');
} catch (e) {}
};
if (!sameBoardId) {
self.setFirstSwimlaneId();
self.setFirstListId();
}
},
});
};
this.getBoardData(Session.get('currentBoard'));
this.setFirstSwimlaneId();
this.setFirstListId();
});
this.setFirstSwimlaneId = function() {
try {
const board = ReactiveCache.getBoard(this.selectedBoardId.get());
const swimlaneId = board.swimlanes()[0]._id;
this.selectedSwimlaneId.set(swimlaneId);
} catch (e) {}
};
// The picker is a template of its own, and a helper is looked up on the
// template it is written in - so what it needs is this instance, handed to it
// as `dialog`. Everything it draws is read from there.
Template[templateName].helpers({
dialog() {
return Template.instance();
},
});
this.setFirstListId = function() {
try {
const boardId = this.selectedBoardId.get();
const swimlaneId = this.selectedSwimlaneId.get();
const lists = getListsForBoardSwimlane(boardId, swimlaneId);
const listId = lists[0] ? lists[0]._id : '';
this.selectedListId.set(listId);
this.selectedCardId.set('');
} catch (e) {}
};
Template[templateName].events({
'change .js-select-boards'(event) {
const boardId = $(event.currentTarget).val();
Template.instance().getBoardData(boardId);
},
'change .js-select-swimlanes'(event) {
const instance = Template.instance();
instance.selectedSwimlaneId.set($(event.currentTarget).val());
instance.setFirstListId();
},
'change .js-select-lists'(event) {
const instance = Template.instance();
instance.selectedListId.set($(event.currentTarget).val());
instance.selectedCardId.set('');
},
'change .js-select-cards'(event) {
Template.instance().selectedCardId.set($(event.currentTarget).val());
},
'change input[name="position"]'(event) {
Template.instance().position.set($(event.currentTarget).val());
},
async 'click .js-done'() {
const instance = Template.instance();
const boardId = instance.selectedBoardId.get();
const swimlaneId = instance.selectedSwimlaneId.get();
const listId = instance.selectedListId.get();
const cardId = instance.selectedCardId.get();
const position = instance.position.get();
this.getBoardData(Session.get('currentBoard'));
this.setFirstSwimlaneId();
this.setFirstListId();
});
Template.copySelectionPopup.helpers({
boards() {
return ReactiveCache.getBoards(
{
archived: false,
'members.userId': Meteor.userId(),
_id: { $ne: ReactiveCache.getCurrentUser().getTemplatesBoardId() },
},
{
sort: { sort: 1 },
},
);
},
swimlanes() {
const board = ReactiveCache.getBoard(Template.instance().selectedBoardId.get());
return board ? board.swimlanes() : [];
},
lists() {
const instance = Template.instance();
return getListsForBoardSwimlane(
instance.selectedBoardId.get(),
instance.selectedSwimlaneId.get(),
);
},
cards() {
const instance = Template.instance();
const list = ReactiveCache.getList(instance.selectedListId.get());
if (!list) return [];
return list.cards(instance.selectedSwimlaneId.get()).sort((a, b) => a.sort - b.sort);
},
isDialogOptionBoardId(boardId) {
return Template.instance().selectedBoardId.get() === boardId;
},
isDialogOptionSwimlaneId(swimlaneId) {
return Template.instance().selectedSwimlaneId.get() === swimlaneId;
},
isDialogOptionListId(listId) {
return Template.instance().selectedListId.get() === listId;
},
isTitleDefault(title) {
if (
title.startsWith("key 'default") &&
title.endsWith('returned an object instead of string.')
) {
const translated = `${TAPi18n.__('defaultdefault')}`;
if (
translated.startsWith("key 'default") &&
translated.endsWith('returned an object instead of string.')
) {
return 'Default';
}
return translated;
}
if (title === 'Default') {
return `${TAPi18n.__('defaultdefault')}`;
}
return title;
},
});
Template.copySelectionPopup.events({
'change .js-select-boards'(event) {
const boardId = $(event.currentTarget).val();
Template.instance().getBoardData(boardId);
},
'change .js-select-swimlanes'(event) {
const instance = Template.instance();
instance.selectedSwimlaneId.set($(event.currentTarget).val());
instance.setFirstListId();
},
'change .js-select-lists'(event) {
const instance = Template.instance();
instance.selectedListId.set($(event.currentTarget).val());
instance.selectedCardId.set('');
},
'change .js-select-cards'(event) {
Template.instance().selectedCardId.set($(event.currentTarget).val());
},
'change input[name="position"]'(event) {
Template.instance().position.set($(event.currentTarget).val());
},
async 'click .js-done'() {
const instance = Template.instance();
const boardId = instance.selectedBoardId.get();
const swimlaneId = instance.selectedSwimlaneId.get();
const listId = instance.selectedListId.get();
const cardId = instance.selectedCardId.get();
const position = instance.position.get();
const selectedCards = getSelectedCardsSorted();
const targetCard = cardId ? ReactiveCache.getCard(cardId) : null;
const sortIndexes = buildInsertionSortIndexes(
selectedCards.length,
targetCard,
position,
listId,
swimlaneId,
);
for (let i = 0; i < selectedCards.length; i += 1) {
const card = selectedCards[i];
const newCardId = await Meteor.callAsync(
'copyCard',
card._id,
boardId,
swimlaneId,
const selectedCards = getSelectedCardsSorted();
const targetCard = cardId ? ReactiveCache.getCard(cardId) : null;
const sortIndexes = buildInsertionSortIndexes(
selectedCards.length,
targetCard,
position,
listId,
true,
{ title: card.title },
swimlaneId,
);
if (!newCardId) continue;
const newCard = ReactiveCache.getCard(newCardId);
if (!newCard) continue;
for (let i = 0; i < selectedCards.length; i += 1) {
await applyToCard(selectedCards[i], {
boardId, swimlaneId, listId, sortIndex: sortIndexes[i],
});
}
EscapeActions.executeUpTo('multiselection');
},
});
}
await newCard.move(boardId, swimlaneId, listId, sortIndexes[i]);
}
EscapeActions.executeUpTo('multiselection');
},
// Move: the card itself goes to the destination.
registerSelectionDialogTemplate('moveSelectionPopup', async (card, to) => {
await card.move(to.boardId, to.swimlaneId, to.listId, to.sortIndex);
});
// Copy: a new card is made there, and then put in its place. A copy that could
// not be made is skipped rather than stopping the rest of the selection.
registerSelectionDialogTemplate('copySelectionPopup', async (card, to) => {
const newCardId = await Meteor.callAsync(
'copyCard',
card._id,
to.boardId,
to.swimlaneId,
to.listId,
true,
{ title: card.title },
);
if (!newCardId) return;
const newCard = ReactiveCache.getCard(newCardId);
if (!newCard) return;
await newCard.move(to.boardId, to.swimlaneId, to.listId, to.sortIndex);
});
@@ -67,6 +67,18 @@ template(name="swimlaneActionPopup")
| {{_ 'copy-link-to-clipboard'}}
span.copied-tooltip {{_ 'copied'}}
hr
//- #1173: exporting a swimlane is the board export restricted to it, and
like copying a link it is READING - so it sits above the rows that change
the board and is not guarded by them.
ul.pop-over-list
li: a.js-export-swimlane
i.fa.fa-upload
span {{_ 'export'}}
if canImportIntoBoard
li: a.js-import-swimlane
i.fa.fa-download
span {{_ 'import'}}
hr
unless currentUser.isCommentOnly
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
@@ -111,6 +111,8 @@ Template.swimlaneActionPopup.events({
if (!url) return;
Utils.showCopied(Utils.copyTextToClipboard(url), tpl.$('.copied-tooltip'));
},
'click .js-export-swimlane': Popup.open('exportSwimlane'),
'click .js-import-swimlane': Popup.open('importSwimlane'),
'click .js-add-swimlane': Popup.open('swimlaneAdd'),
'click .js-add-list-from-swimlane': Popup.open('addList'),
'click .js-set-swimlane-color': Popup.open('setSwimlaneColor'),
Loaded 100 of 690 files, more files were not shown because too many files have changed in this diff. Show more