Compare commits

...
492 Commits
Author SHA1 Message Date
github-actions[bot] 149944726e Bump versions for v10.78 2026-08-09 15:27:00 +00:00
Lauri Ojansivu bf0b757862 Prepare v10.78 release 2026-08-09 18:25:59 +03:00
Lauri Ojansivu 1c7de782f0 CHANGELOG: the snap and release-notes fixes from the v10.77 build logs.
Thanks to xet7 !
2026-08-09 18:21:43 +03:00
Lauri Ojansivu a820b056b2 Snap: libcurl4 does not exist on armhf, so name the package noble ships.
The armhf snap failed all three attempts in v10.77 while s390x, ppc64el
and riscv64 each built on attempt 1. The Launchpad build log says why,
one line into the mongodb part:

    Stage package not found in part 'mongodb': libcurl4.
    Build failed

Ubuntu 24.04's 64-bit time_t transition renamed the library, and
`libcurl4` is not a real package on ANY architecture in noble - the
binary is `libcurl4t64` everywhere. On the 64-bit architectures the
renamed package keeps the old name alive:

    Package: libcurl4t64
    Architecture: amd64
    Provides: libcurl4 (= 8.5.0-2ubuntu10)

and the armhf libcurl4t64 has no Provides line at all, because there the
ABI really changed. That is what makes this class of bug reach exactly
one architecture, which is also what makes it look like flakiness on
that architecture.

It had already happened, to libssl3 and libgoogle-perftools4 in v10.71;
the comment above this list describes it exactly. libcurl4 was the same
transition and was left behind. Every other stage-package was checked
against the noble armhf main+universe indices at the same time - all 14
of them - and libcurl4 was the only one that did not resolve.

The workflow's own guidance made it worse. snapcraft ends a missing
stage-package as a "Stopped" build with no artifact, which from the
outside is indistinguishable from Launchpad cancelling a build, so the
message said "often an OOM in the Meteor npm install, or a transient
build-farm reset ... re-run, and if it persists on this arch open a
Launchpad question" - the wrong advice for a failure that will never
succeed on a re-run. The step now looks for the line first, names the
package that is missing, says it is NOT transient, explains the t64
Provides asymmetry, and gives the one-line archive query that checks a
name.

New test tests/snapStagePackages.test.cjs, 6 checks: no stage-package is
a known pre-t64 name, libcurl is asked for by the name noble publishes,
the two fixed in v10.71 have not regressed, and - negatively - a name
that was never renamed keeps its plain name, because the rule is not
"add t64 to everything". It also pins that the reason stays written down
beside the list. Verified it fails when libcurl4 is put back.

Thanks to xet7 !
2026-08-09 18:20:42 +03:00
Lauri Ojansivu 6dce9dda79 Release notes and snap logs: report what really happened.
Four things the v10.77 run got wrong, all of them silent - nothing
errored, the reports were simply not what they claimed to be.

THREE GOOD SNAPS WERE REPORTED AS FAILED. s390x, ppc64el and riscv64
each printed "Remote build <arch> succeeded on attempt 1" and downloaded
their .snap, and each job then ended FAILED - because the step that
SAVES the build log could not upload it:

    The path for one of the files in artifact is not valid:
    /snapcraft-wekan-f82a93c2…_s390x_2026-08-09T11:39:05.txt.
    Contains the following character:  Colon :

snapcraft names a remote-build log after the recipe and an ISO
timestamp, and upload-artifact refuses a colon because NTFS cannot hold
one. The colons become dashes now, so the timestamp is kept rather than
the name thrown away - and the upload is continue-on-error, because a
DIAGNOSTIC upload must never be able to fail the build it is diagnosing.
The snap is the deliverable; the renaming is not the last thing that
could ever make a log file unuploadable.

EVERY ROW OF THE PROVENANCE TABLE APPEARED TWICE. provenance-table.sh
globbed `provenance/**/*.tsv provenance/*.tsv`, and with globstar `**/`
matches ZERO or more directories, so the first pattern already covered
the second and every file was read twice. It is one pattern now, and
rows are also deduplicated on the WHOLE line - a retried step or a job
that ran again records an identical line, and nothing tells the copies
apart. Deliberately not `sort -u -k1,1 -k2,2`: two rows sharing a bundle
and a binary but differing elsewhere are not a duplicate, they are a
real disagreement about which Node.js went into a bundle, and hiding one
at random is worse than showing both.

AMD64 WAS MISSING FROM THAT TABLE - the platform most people download.
Its step runs `cd .build` before `mkdir -p provenance`, so the rows went
to .build/provenance while upload-artifact looked at provenance/ from
the workspace root and found nothing: "No files were found with the
provided path: provenance/". Every other build job records from the
workspace root, which is why only this one was affected. It uses
$GITHUB_WORKSPACE now.

SIX PLATFORMS SAID VERSION "LATEST". amd64, arm64, win64, win32,
mac-arm64 and mac-x64 passed the literal string; only the
extra-architecture job asked which version `latest` actually was, and it
did so with its own inline curl+python. "Which FerretDB did v10.77 ship"
is the one question that column exists to answer. All seven sites now
call the new releases/ferretdb-latest-tag.sh, which asks once per job
and caches, authenticates when there is a token so a shared 60/hour
limit is not what makes it fail, checks that the answer is shaped like a
tag before printing it into a markdown cell, and prints NOTHING and
exits 0 when it cannot find out - the caller's ${FERRET_TAG:-latest} is
then exactly what it had before. A release note must never fail a build
that produced a good bundle.

The table's own prose linked [wekan/node](https://github.com/wekan/node);
the binaries come from wekan/node-patches, which is what the rows
themselves already linked.

New test tests/provenanceTable.test.cjs, 20 checks, most of them running
the scripts rather than reading them. Verified in both directions: with
the doubled glob restored the glob guard fails, with amd64's relative
path restored its guard fails, and with the literal 'latest' restored
its guard fails.

Thanks to xet7 !
2026-08-09 18:15:22 +03:00
Lauri Ojansivu 47c81cd478 CHANGELOG: rewrap the Upcoming summary.
Thanks to xet7 !
2026-08-09 16:01:11 +03:00
Lauri Ojansivu bc426ba7c2 CHANGELOG: the Upcoming binaries table is FerretDB v1.48.0, not v10.77's v1.45.0.
Thanks to xet7 !
2026-08-09 15:58:58 +03:00
Lauri Ojansivu a0f445c723 CHANGELOG: the Docker provenance and bundled-binary freshness work.
Thanks to xet7 !
2026-08-09 15:51:44 +03:00
Lauri Ojansivu ce69db8c5f Pin that every bundled binary is fetched as the newest one.
WeKan ships binaries other projects publish - FerretDB, the MongoDB
Database Tools, Node.js - and fetches each from a release URL. There are
many such URLs, across workflows, release scripts and compose files, and
they must all agree: one that names a fixed version stops receiving
security fixes silently, and nothing about the build fails when it does.

That is not hypothetical. A Quay scan of the v10.77 image reported Go
advisories in the FerretDB binary baked into the bundle - stdlib 1.25.9
wanting 1.25.11, golang.org/x/sys v0.38.0 wanting 0.44.0. The source was
already fixed and v1.48.0 was already published carrying go1.25.11 and
x/sys v0.46.0; the image had simply captured an older latest at build
time. Had any of these URLs been pinned instead, the rebuild would not
have fixed it either.

The test finds the URLs by searching the repository rather than from a
list, so a build site added later is checked too, and it accepts three
shapes: latest/download/<asset>, the bare latest API endpoint that
release-all.yml asks which version latest resolved to for the provenance
table, and ${VAR} whose default is checked separately to be latest.
Node.js is a different mechanism with the same effect - NODE_VERSION is
the bare major 24 and resolve-node-source.sh answers with that CPU's
newest 24.x from whichever of nodejs.org, unofficial-builds or
wekan/node-patches has one - so it is asserted to stay that way.

Thanks to xet7 !
2026-08-09 15:50:11 +03:00
Lauri Ojansivu 5b31695343 Docker images: full build provenance, at every site that pushes one.
BuildKit attaches MINIMAL provenance by default - a build id and a timestamp -
which is what those `unknown on unknown` rows on quay.io are: the OCI marker for
"not a runnable image", one per platform, so `docker pull` never selects them.
mode=max records what actually answers a supply-chain question instead: which
commit, which base image, which build arguments.

WeKan pushes images from FOUR places - docker-publish.yml's build-push-action,
two `docker buildx build --push` in release-all.yml, and one in
releases/docker-build.sh - and none of them said anything about provenance, so
all four took the default. All four ask for mode=max now. The local `--load`
check build in docker-build.sh deliberately does not: the docker exporter cannot
carry an attestation at all.

SBOM stays off, and the test says why so it is a decision rather than a drift:
it enumerates every OS package and npm dependency, taking the attestation from
~10 KiB per platform to megabytes.

tests/dockerProvenance.test.cjs finds the build sites by SEARCHING for them
rather than from a list, so a fifth one cannot quietly fall back to the default.

It also pins a trap I fell into twice while writing this, once in the shell
script and once in a YAML `run:` block:

    docker buildx build \
      # explain the next flag
      --provenance=mode=max \
      --platform ... \

A `#` after a line continuation comments out the REST OF THE JOINED LINE, so
that is `docker buildx build` with every argument swallowed - no platforms, no
tags, no push. `bash -n` accepts it, because it is valid syntax; it is simply a
different command. Both comments live above their command now, and the test
fails on any comment sitting among a continued command's arguments - while
ignoring a comment block that merely quotes a multi-line example, which is what
it flagged first.

Thanks to xet7 !
2026-08-09 15:38:53 +03:00
Lauri Ojansivu 4a8fbd954e CHANGELOG: the Flatpak fix, beside the AppImage one it repeats.
Same group, because it is the same area and the same one-line cause - a gh call
without --repo in a job that does not check out. The subsection is 'bugs' now
rather than 'bug'.

Thanks to xet7 !
2026-08-09 15:25:28 +03:00
Lauri Ojansivu 04fb6e3381 Flatpak: attach the bundles to the release, and only the bundles.
The same one-line bug AppImage.yml had, and a second one beside it.

    gh release upload 'v10.77' assets/* --clobber
    failed to run git: fatal: not a git repository

Both flatpaks built and were uploaded as artifacts; the job that attaches them
does not check the repository out - it only downloads artifacts - so `gh` had no
git remote to infer the repository from. Every gh call in this workflow passes
--repo now, including the ones in jobs that DO check out.

The second: `assets/*` is not the bundles. The artifacts also carry the ostree
repository the flatpak was exported through - config, objects/, refs/,
summaries/, summary, summary.idx - which is build scaffolding, and attaching it
to a release would have put a few hundred directories there beside the two
files anyone wants. The upload names the bundles and their checksums instead.
TSC hit this exact thing and fixed it the same way.

No architectures to add, and the header now says why rather than leaving it to
be rediscovered. A flatpak runs against a RUNTIME, so an architecture exists
here only if freedesktop.org publishes org.freedesktop.Platform for it: x86_64
and aarch64, the i386 and arm runtimes having been discontinued. That is the
difference from AppImage.yml, which could grow to four - an AppImage carries its
own runtime binary, and those exist for i686 and armhf too.

Thanks to xet7 !
2026-08-09 15:13:39 +03:00
Lauri Ojansivu 5957fa0ad8 CHANGELOG: the two AppImage commits, under a new Upcoming section.
v10.77 was cut while they were being written, so they get their own Upcoming
above the release, and the links point at the hashes the rebase onto the version
bump produced rather than the ones that will be collected.

Thanks to xet7 !
2026-08-09 15:09:54 +03:00
Lauri Ojansivu 97e57e531b AppImage: add the other two architectures a runtime exists for.
x86_64 and aarch64 become four, with i686 and armhf. That is the whole set: an
AppImage BEGINS with a runtime binary the kernel executes, and runtimes exist
for exactly x86_64, aarch64, i686 and armhf. ppc64le, s390x, riscv64 and loong64
have none, so they cannot have an AppImage at all however the job is written -
they are served by the bundle zip and the .deb, and the matrix now says so where
somebody would otherwise try.

The two are not equally safe, and they are not treated as though they were.

i686 builds on the x86_64 runner, which runs 32-bit x86 natively: no emulation,
no multiarch, and appimagetool-i686 runs as-is. It is an ordinary job.

armhf builds on the aarch64 runner, which runs 32-bit ARM only if the kernel has
CONFIG_COMPAT and the 32-bit loader is installed. GitHub promises neither, so it
is continue-on-error - the same treatment TSC gives its emulated armhf job. If
it proves reliable, drop the flag; if it never works, drop the entry. What it
must not do is decide whether the other three reach the release.

The smoke test needed the distinction too. It starts the AppImage and waits for
WeKan to answer on port 8080, which a 64-bit runner cannot do for a 32-bit build
it cannot execute. A CHECK THAT CANNOT RUN IS NOT A FAILED CHECK: when the
binary will not execute at all AND it is not this machine's architecture, the
AppImage is as good as anything here knows, so it warns, says it is uploaded
unchecked, and moves on. A build that DOES start and then does not answer still
fails - that is the bug the step exists for.

The publish job's two hardcoded architecture lists learned all four, so a
missing one warns instead of going unnoticed, and the base-bundle check looks
for wekan-<v>-{i386,armhf}.zip as well.

Untested here: this is a workflow, so the first real run is the test. The two
new jobs are the only ones that can fail, and armhf cannot take the release with
it.

Thanks to xet7 !
2026-08-09 15:08:23 +03:00
Lauri Ojansivu fa296d0c80 AppImage: attach the built AppImages to the release again.
Both AppImages built, both passed the smoke test that starts them and waits for
WeKan to answer on port 8080, both uploaded as artifacts, both downloaded by the
attach job with matching digests - and then nothing reached the release:

    gh release upload 'v10.77' assets/* --clobber
    failed to run git: fatal: not a git repository

The attach job does not check the repository out. It has no reason to - it only
downloads artifacts - so `gh` has no git remote to infer the repository from.
Every other gh call in this workflow already passes --repo; these two did not.

Every gh call passes it now, including the two in jobs that DO check out. A call
that relies on an inferred remote breaks the day somebody removes the checkout,
and the error it gives names neither gh nor the workflow.

Worth recording for whoever reads the failed run: the log looks far worse than
the bug. `::error::` lines appear in the build jobs' output for the bundle-file
check and the port-8080 check, and in the attach job for "no architecture
produced an AppImage" - none of which fired. They are the SCRIPT being echoed by
the ##[group] header, not output. The real failure is one line, in the last step
of the last job.

Thanks to xet7 !
2026-08-09 15:08:23 +03:00
github-actions[bot] 040c18bc23 Bump versions for v10.77 2026-08-09 11:16:31 +00:00
Lauri Ojansivu 7241570491 Prepare v10.77 release 2026-08-09 14:15:35 +03:00
Lauri Ojansivu 979ad3a5cd CHANGELOG: the two snap commits, under a new Upcoming section.
v10.76 was cut while they were being written, so they get their own Upcoming
above the release.

Thanks to xet7 !
2026-08-09 14:09:41 +03:00
Lauri Ojansivu 3c9291338c Snap CPU platforms: say which six, why not the other two, and all four channels.
The page listed five architectures and omitted armhf, which snapcraft.yaml has
built all along. It said the release publishes candidate, beta and edge and that
"the stable channel is published manually later" - which is no longer true and
was the reason a build reached three channels of four. And it said nothing about
the two architectures that are bundles but not snaps, so "missing" and "cannot
be there" looked identical.

The matrix is the six `build-for:` entries with the bundle name beside each,
because the two names are not always the same, and a new section explains the
three ways they differ:

  ppc64le / ppc64el   the same 64-bit POWER under two names - the bundles use
                      the kernel's, the store uses Debian's. The only rename.

  armhf / armv7       NOT a rename, and getting this wrong ships a snap that
                      crashes. node-patches builds armhf to the Debian baseline
                      (hard-float, VFPv3-D16, no NEON assumed) so it runs on any
                      ARMv7-A, and armv7 with NEON for boards that have it. The
                      store has ONE 32-bit ARM architecture serving every such
                      device, so it must carry the BASELINE build; the NEON one
                      would be an illegal instruction on a board without NEON.
                      armv7 ships as a bundle only.

  i386                cannot have a new snap at all, and this is categorically
                      different from a missing Node.js build. node-patches
                      patches SOURCE so a binary can be built; here the BASE SNAP
                      does not exist, because Ubuntu 24.04 has no i386 port. No
                      patch set can produce a base Canonical does not publish.
                      The last base with one was core18, end-of-life.

Then what to do about it: the three snaps, all four channels, and why stable is
possible - core24 is a released base, so the snap carries grade: stable, which a
grade: devel core26 snap cannot. A table of what each snap has in the store
today and what is still to upload, including the two fossils that channel
promotion cannot fix: wekan-ondra's armhf at 0.22 and its i386 at 0.X-ci, which
have no newer revision to promote.

And the caveat that matters when running it: without a pinned version the newest
revision of each architecture is promoted, and edge is often ahead of stable, so
a bare run publishes edge builds to stable users.

The armv7 note in models/lib/snapArchitectures.js said "different CPUs", which
was the right conclusion from the wrong reason. It now says what node-patches
actually builds, and a test cross-checks it against that repo's own workflow so
the explanation cannot drift from the binaries.

Thanks to xet7 !
2026-08-09 14:05:17 +03:00
Lauri Ojansivu a5d8a1fd97 Snap: release every snap, every architecture, to all four channels.
The helper that did this released ONE snap, ONE revision, to THREE channels:

    snapcraft release wekan $1 edge,beta,candidate

wrong three ways at once. It names only `wekan`, leaving wekan-ondra and
wekan-gantt-gpl to be done by hand. It takes ONE revision number, and revisions
are PER ARCHITECTURE - the store's own listing shows wekan at 3601 on amd64 and
3600 on arm64 for the same 10.76 - so one number can only ever be right for one
of them. And it leaves out `stable`, so a build reached three channels of four
and somebody had to remember the fourth.

releases/snap-release-all-channels.sh resolves the revision per (snap,
architecture) from the store itself, so no revision number is ever typed, and
releases it to all four channels in ONE call - a revision reaches all of them or
none. A (snap, architecture) with no revision is reported and skipped rather
than failing the run: the three snaps genuinely have different architecture
sets, and one architecture failing must not stop the rest.

THE MAPPING IS THE HAZARD, and it is now in one place with both directions
tested (models/lib/snapArchitectures.js):

  ppc64le and ppc64el ARE the same hardware. The bundles at
  https://github.com/wekan/wekan name it the way Node.js and the kernel do; the
  Snap Store names it the way Debian does. Nothing warns when the wrong one is
  used - an unrecognised --arch is simply an architecture the store has never
  heard of, so it looks like it worked.

  armhf and armv7 are NOT. armhf is 32-bit Raspberry Pi OS; armv7 is the
  ODroid-U3. Different CPUs, different bundles. The store has ONE 32-bit ARM
  architecture, armhf, so that is what is published; mapping armv7 onto it would
  hand Raspberry Pi users the ODroid build. The negative test for that is the
  most important one in the suite.

The six architectures are exactly snapcraft.yaml's `build-for` list, and a test
compares the two so the table cannot drift from what is actually built. i386 and
armv7 are recorded as not-built, with their reasons, and the script prints them
on every run - "it is missing" and "it cannot be there" look identical from the
outside.

tests/snapArchitectures.test.cjs: 23 checks including that the newest revision
for THAT architecture wins, that a version filter publishes what was built
rather than whatever was uploaded last, that `snapcraft list-revisions` parses
when a revision has no channels, and that junk never reaches snapcraft.

Thanks to xet7 !
2026-08-09 14:05:17 +03:00
github-actions[bot] ab29f74f6a Bump versions for v10.76 2026-08-09 02:36:55 +00:00
Lauri Ojansivu 48d8dc9de8 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-09 05:35:11 +03:00
Lauri Ojansivu 983aba8488 Prepare v10.76 release 2026-08-09 05:35:10 +03:00
Lauri Ojansivu 988e2f0794 CHANGELOG: the three round-trip fixes, under a new Upcoming section.
v10.75 was cut while they were being written, so they get their own Upcoming
above the release. The FerretDB one is summarised here and told in full in that
repo's CHANGELOG, since it is the database that changed.

Thanks to xet7 !
2026-08-09 05:35:10 +03:00
Lauri Ojansivu 90a41dbb22 Two queries per request instead of two per card.
Neither of these gave a wrong answer. They asked the right question one document
at a time, and each await is a full round-trip that starts only once the last
one finished - invisible on a board with four cards, and the whole cost of the
request on a real one.

The bulk label endpoint (PUT .../cards/labels) awaited a getCard per id and then
an update per id. At BULK_CARDS_MAX = 500 that is ONE THOUSAND sequential
round-trips for a single request. The reads are all the same question, so they
are one `$in` query indexed into a Map; the writes genuinely differ - each card
merges its own labelIds - so they stay individual updates, but they are issued
together and awaited once instead of each waiting for the last.

Two details that had to survive: the loop still iterates cardIds rather than the
query result, because a batch read comes back in the database's order and
without the ids that matched nothing - iterating it would silently reorder
`updated` and lose `notFound`. And every write is awaited before the 200 is
sent, so the response still means what it says.

The bulk DELETE beside it is deliberately left alone: its per-card work is real
(cardRemover runs the sub-item hooks, each card gets its own activity), so
batching the reads would save one query inside a loop that does far more than
query.

Global search resolved each `user:`/`member:`/`assignee:`/`creator:` name with
its own awaited findOne, so `member:ann member:bob member:carol` was three
serial round-trips before the search itself could start. They are all the same
question - which of these names is an account - so it is asked once, with `$in`,
and answered from a map. A name typed under two operators is one lookup now, not
two. Unknown names are still reported against the operator they were typed
under, because "ann is not a user" is not useful without saying where ann was
typed.

tests/queryBatching.test.cjs pins both, including the negatives: no per-card
read remains in the label handler, no per-username query remains in search, the
response order and notFound list are preserved, and the delete handler is still
per-card on purpose.

Thanks to xet7 !
2026-08-09 05:35:10 +03:00
github-actions[bot] 8da91adaa7 Bump versions for v10.75 2026-08-09 01:48:39 +00:00
Lauri Ojansivu 80830cd43e Prepare v10.75 release 2026-08-09 04:46:10 +03:00
Lauri Ojansivu f2edde0651 CHANGELOG: a new Upcoming section for the two commits after v10.74.
The release script renamed the old Upcoming to v10.74, and two commits landed
after it: the linked-card cache that could not see a card added later, and the
build-script parity fix. They get a fresh Upcoming section above the release,
which is where development entries go.

Both were rebased onto the version bump, so their hashes changed; the links
point at the ones that will be pushed, not the ones that will be collected.

Thanks to xet7 !
2026-08-09 04:44:59 +03:00
Lauri Ojansivu ada480c93a Linked-card ids must not be cached for the life of a subscription.
The helper the five linked-card cursors share memoized its result per board, to
spare them the duplicate queries the five copies of that preamble used to make.
That is wrong: publishComposite re-runs a child's find() when the parent
document changes, and a cache living for the whole subscription then serves the
ids computed the FIRST time forever - a linked card added later would never be
published, and one removed would go on being published.

The five cursors each ran these same queries before the helper existed, so
computing per call is exactly the cost they always had, and it is correct. The
guard that pinned the memoization now pins its absence, with the reason.

Thanks to xet7 !
2026-08-09 04:43:30 +03:00
Lauri Ojansivu 114d6c0599 Build script parity: git pull and git push, on Windows too.
The one red suite in the test run, and it was right twice over.

`Setup: "git pull" and "git push" that finish the job` added two entries to
build.sh's menu and left build.bat behind, so `build.bat git-pull` answered
nothing. It answers to both names now, with its own :gitpull/:gitpush labels -
the ones its interactive menu already used.

The guard also had to learn what a `!` entry is. It marks something build.sh
runs ITSELF rather than by executing a file in releases/. Most are raw commands
and the .bat runs those the same way, so those still compare as before; the
exception is an entry naming a build.sh FUNCTION, which is shell the .bat has no
way to call. Comparing those as scripts is what made this suite fail the moment
"Update git" was replaced. A function entry is now exempt from the script
comparison and checked separately: build.bat must implement a label of the same
name, so one dropping out of a menu still fails.

The node suites are 339 run, 0 failed for the first time in this branch.

Thanks to xet7 !
2026-08-09 04:43:30 +03:00
github-actions[bot] fc0ce5137b Bump versions for v10.74 2026-08-09 01:01:29 +00:00
Lauri Ojansivu c3c95cefb9 Prepare v10.74 release 2026-08-09 04:00:21 +03:00
Lauri Ojansivu aea9811a76 CHANGELOG: link the follow-up commit the ParentBleed entry describes.
The five linked-card cursors are its own commit, and its story is told inside
the ParentBleed entry - so a reader could read about the fix and had no way to
reach it.

Thanks to xet7 !
2026-08-09 03:54:58 +03:00
Lauri Ojansivu 5a1227da6c CHANGELOG: the dependency bumps and the two tooling commits, and the subsections in the order CLAUDE.md gives.
Six dependabot bumps and two developer-facing commits since v10.73 had no entry
at all. Adding them showed the subsections were also out of order - fixes came
before features - so they are now CRITICAL SECURITY, features, dependencies,
bug fixes, developer-facing, which is what CLAUDE.md specifies.

Thanks to dependabot and xet7 !
2026-08-09 03:50:24 +03:00
Lauri Ojansivu 8a5ca2c2de CHANGELOG: the canary tokens, the filesystem integrity check and the two Database problems fixes, under Upcoming.
Thanks to xet7 !
2026-08-09 03:44:21 +03:00
Lauri Ojansivu 30e8e38f6c Fix the two bugs Admin Panel / Problems / Database problems was reporting.
The page was doing its job and nobody had acted on it. Two real WeKan bugs were
sitting there as rows marked "unknown / unclassified", whose advice was to add a
rule to models/lib/databaseErrors.js. Both are fixed, and both now classify.

1. moveSwimlane, four times over a week:

     Error: update is not available on the server. Please use updateAsync()

   Board.ensureDefaultSwimlaneId() called the SYNCHRONOUS Swimlanes.upsert().
   Meteor 3 removed it on the server, so every move that reached the default
   swimlane self-heal threw. The async twin already existed one function below.
   This getter is synchronous and cannot await, so the upsert is now STARTED and
   not waited for: the self-heal still happens, and the call returns what is in
   the cache - which every caller already had to handle, because it was
   undefined before the self-heal existed at all. A caller that needs the
   swimlane back in the same tick uses ensureDefaultSwimlaneIdAsync().

2. moveList:

     ValidationError: Failed validation
     Cannot read properties of undefined (reading 'title')

   List.move() inserted a new list with `title: this.title`. When a list has no
   title, `title` is required by the schema, so the insert fails validation - and
   collection2's own error formatter then reads a property of the undefined field
   and throws, which is why the admin saw a crash naming neither the list nor the
   real problem. It now says what is wrong before the insert: "This list has no
   title, so it cannot be moved to another board. Give it a title first." The two
   console.log lines that used to sit there printed the title to a log nobody
   reads and did not stop the crash; they are gone.

And two classifier rules so neither reads as "unknown" again. Both say plainly
that this is WeKan's bug rather than the database's or the admin's - there is
nothing to configure - and send the reporter to the right place: upgrade, then
open an issue with the method name. Severity `warning`, the vocabulary this
stream uses.

Thanks to xet7 !
2026-08-09 03:43:24 +03:00
Lauri Ojansivu 2cc0d42ddf Filesystem integrity: is that still the file WeKan stored, and did this server stop cleanly?
Attachments and avatars are files under WRITABLE_PATH, and the database holds one
document per file. NOTHING checked that the two still agree. A file can be
replaced, truncated, back-dated or deleted by anything that reaches the
filesystem - a bad restore, a sync tool, a container rebuild, a shell on the
volume - and WeKan would keep serving whatever is there now, silently.

A baseline is recorded per file in the existing WeKan database (no new files
under WRITABLE_PATH): path, size, modification time, and md5, sha256 AND sha512.
Three digests, because they answer different questions. md5 is what other tools
print, so an admin can compare against a backup with what they already have - it
is never alone, its collision resistance is gone. sha256 is the working digest.
sha512 earns its place on the day the two DISAGREE: two digests over the same
bytes cannot, so when they do, the bytes were not read the same way twice - a
failing disk, a partial write, a truncated copy - which is a different problem
from a substitution and is its own critical finding.

ed25519 is the fourth check and it is NOT a hash - it is a signature, and it
answers the one thing three digests cannot: who says these are the right hashes?
Anybody who can rewrite a file can rewrite a row of hashes. Each entry is signed
over a canonical line (fixed field order, no JSON, whose escaping is not
guaranteed stable) and verified on every scan. A record that does not match its
signature is critical and is NEVER explained away by an ordinary edit. The key
comes from WEKAN_INTEGRITY_PRIVATE_KEY, which WeKan never stores - that is the
configuration that means something against an attacker who reaches the database
too - or is generated and kept in the database, which detects filesystem-only
tampering and is honest about being no more than that. A MALFORMED supplied key
is reported rather than silently replaced by a generated one, which would look
like it was working while checking nothing the operator meant.

The scan is paced by policy, not by hope: once a day, checked hourly; nothing
starts or continues at or above 60% CPU; 50 ms between files plus 20 ms per
megabyte; a fifteen-minute budget, after which it stops and continues tomorrow;
and ONE read per file for all three digests. A run that stopped early does not
report the files it never reached as missing.

The finding is a change WITH NO RECORD SAYING WHY. Files change when people use
WeKan, and those changes are accounted for, reported once at info and
re-baselined so they do not repeat daily. A change with nothing to explain it is
at least medium and is deliberately NOT re-baselined, so it keeps showing until
somebody looks. A modification time that moved BACKWARDS is called out
separately: nothing does that by accident.

Crashes and downtime, in the same stream, because a crash leaves nothing behind:
the process is gone, so it cannot write a message about being gone. A heartbeat
every minute is the only thing that works, and the NEXT start reads it. A first
run and a clean stop record nothing - reporting every deliberate restart is how
a Problems page becomes one nobody opens. A short gap with no clean-shutdown
mark is a note; a long one is a high-severity "it stopped without shutting down
cleanly, and here is how long it was down". Uncaught exceptions and unhandled
rejections land here too, with their cause, before the process goes.

Admin Panel / Problems / Filesystem integrity is the new stream, with the
Username / IP address / Attempts columns. The baseline itself is never published
and never client-writable: it is a map of every file on the server, and the key
document is a private key.

tests/fileIntegrity.test.cjs, 34 checks: every digest case including same-size
substitution and disagreeing digests, the ed25519 forgery attempt, the canonical
line, explained vs unexplained, and the pacing - including that an unreadable
clock does not start a scan, which it did until the test said so.

Thanks to xet7 !
2026-08-09 03:43:07 +03:00
Lauri Ojansivu d577f07700 Canary tokens: who tried to override permissions, and from where.
The security event log said what a guard DID - a request blocked, a filename
sanitized - which answers "is WeKan defending itself" and not the question an
admin asks next: who did that, and from where. It also could not tell a browser
that got confused from somebody working through the Hall of Fame one entry at a
time.

A CANARY is a tripwire at a point that only a permission-override attempt
reaches. Ordinary use never gets there - the code path runs when a request asks
for something the rules forbid - so a trip is not noise, and it is worth an
admin's attention with the actor attached. Design:
docs/Security/Remediation/WeKan.md §12.

Three properties, each enforced by a test:

SILENT. Tripping one changes NOTHING the caller can observe. tripCanary()
always returns false and tripCanaryDeny() always returns true, so a call site
reads as the refusal it replaces:

    if (forbidden) return false;                          // before
    if (forbidden) return tripCanary('card.vote-field', { userId });

The return value is decided before any reporting is attempted and the reporting
is wrapped, so a canary can never throw into, delay or disturb the request it
watches. The REST handler re-throws the ORIGINAL error object. This is not
politeness: a canary that announces itself is a map of which paths are watched,
and a probe would simply avoid them.

BOUNDED. A canary sits where an attacker can loop, so one row per attempt would
be a denial of service they get for free. models/lib/canaryTokens.js - pure, no
Meteor, no clock of its own - records the first trip of a (canary, actor) pair
at once, COUNTS the rest of the window, flushes one summary carrying the total
when the window closes, and stops writing after 60 summaries for a pair that
never gives up. The tracked-pair map is capped at 5000 and evicts the LEAST
RECENTLY seen, so a long-running attacker is not pushed out by a passing one and
a botnet varying its address cannot grow it. Idle pairs are swept on a timer. A
thousand attempts in a minute cost ONE row, and a suppressed trip costs one map
lookup. The username lookup is cached and capped, and neither it nor the insert
is awaited on the caller's path.

ATTRIBUTED. Every event carries userId, username, ip and count. The username is
denormalised at write time on purpose: it is what the account was CALLED when it
tried, so a rename does not rewrite history and a deletion does not erase it.
The address uses the same spoofing-safe resolver as the login throttle -
X-Forwarded-For only as far as HTTP_FORWARDED_COUNT says to trust it - or an
attacker could write a colleague's address into the security log by sending a
header. `detail` says what was attempted in the words of the feature, never the
payload.

Seventeen canaries are wired at the permission checks that refuse the attempts
behind BoardBleed, ParentBleed, ChecklistBleed, PathBleed and CommentBleed, plus
the direct vote/poker field writes and a stored path that points outside the
storage root. All server-side: no Playwright, no browser, nothing to install.

Admin Panel / Problems / Security gains Username, IP address and Attempts
columns, and both new columns are searchable - the thing an admin does with one
security event is pivot on it.

tests/canaryTokens.test.cjs (25 checks) drives the limiter through bursts, an
endless probe, eviction order and junk input; tests/canaryCoverage.test.cjs (18)
pins every canary to its call site and asserts none of them changes what the
caller gets back.

Thanks to xet7 !
2026-08-09 03:29:20 +03:00
Lauri Ojansivu 839d3a820c CHANGELOG: the audit findings and the security-test coverage, under Upcoming.
The five linked-card cursors and the streaming attachment read join the
ParentBleed and PathBleed entries they extend; the comment-reaction ownership
fix and the security-test coverage guard get subsections of their own.

Thanks to Alpastx and xet7 !
2026-08-09 02:58:35 +03:00
Lauri Ojansivu a4a9c03b1f Security tests: say which vulnerability you guard, and check the list.
"Are the security tests enough to prevent what is in the Hall of Fame" was
unanswerable. WeKan publishes 58 named vulnerabilities at
https://wekan.fi/hall-of-fame/ ; some suites name the one they belong to, most
did not, and the Hall of Fame lives in a different repository that CI never
checks out. Answering it meant reading 58 pages against 300 suites by hand,
which means it was never answered - and a regression test that quietly stops
existing is worth nothing, the failure mode tests/testsAreRegistered.test.cjs
already caught once when two *bleed suites drifted out of the mocha index.

So the list lives in the repository that has the tests, and
tests/securityRegressionCoverage.test.cjs keeps the two in step. Every published
vulnerability is either GUARDED - named by a suite that still exists - or
RECORDED, a gap with a written reason, which is the CHANGELOG's "TODO Later"
pattern applied to tests. The gap count is pinned, so a vulnerability cannot be
published with neither a test nor a note; a gap that turns out to be guarded
after all fails too, so coverage is never understated; and a file that merely
REGISTERS suites is not accepted as coverage, or the guard would pass itself.

Getting there meant naming vulnerabilities in the suites that already guarded
them, which is most of what this commit is:

- tests/securityMeifukun.test.cjs guards eight reports and named one. Its
  sections are RedirectBleed, SourceBleed, LiveBleed, CasBleed, OidcBleed,
  MetricsBleed, ImpersonateBleed and InviteBleed - seven vulnerabilities that
  looked untested and were not.
- tests/noIdentityReplacement.test.cjs guards IdentityBleed and PatternBleed,
  the same CodeQL finding twice.
- ExportBleed, CrashBleed, MimeBleed and the four LockoutBleed suites now say so.
- tests/crossBoardParentCardLeak.test.cjs also guards BoardBleed's cross-board
  move deny, and now checks it on Lists and Swimlanes too, not only Cards.

The count that comes out of it: 29 of 58 published vulnerabilities have a named
regression test, and 29 are recorded gaps - mostly older fixes from before WeKan
tested its security fixes at all. They are not known to be unprotected; they are
known to be unchecked, which is a different and more honest statement, and each
one now says what it would take to close it.

Thanks to xet7 !
2026-08-09 02:55:13 +03:00
Lauri Ojansivu 156121c4bc Comment reactions: react as yourself, not as somebody else.
Found while auditing for more of GHSA-pqr4-rxgp-hv2m (CommentBleed - the REST
comment delete that checked board membership and nothing about WHOSE comment it
was). A CardCommentReactions document holds

    { cardCommentId, reactions: [ { reactionCodepoint, userIds: [...] } ] }

and the whole array is ONE field, whose allow rule was board membership for
insert, update and remove alike. So any member could $set `reactions` to
anything: add a colleague's userId to a reaction they never made, or remove one
they did. `toggleReaction()` only ever touches Meteor.userId(), so no legitimate
client sends anything else - the rule simply never said so.

Integrity rather than confidentiality: reactions are visible to the whole board
already. But it puts words in another person's mouth, and it is the same shape
as the advisory - a per-user object gated only by "are you on this board".

A deny rule now refuses an update that changes any OTHER user's presence in any
reaction. The decision is pure and lives in models/lib/reactionOwnership.js:
it compares MEMBERSHIP rather than array order, because the client rebuilds the
array on every toggle and a reordered array with the same membership is the same
set of reactions. The modifier forms that cannot be checked that way -
$push/$pull/$addToSet/$unset, or a dotted `reactions.0.userIds` - are refused
outright, since toggleReaction() always $sets the whole array and letting them
through would be a hole the size of the one being closed.

The membership rule it sits on top of is unchanged: read-only and no-comment
members still may not react at all.

tests/reactionOwnership.test.cjs covers both directions - adding and removing my
own reaction, the first reaction and the last one going away, a reordered array,
several of my own toggled at once - and the attacks: adding somebody else,
deleting somebody else's, wiping an array that was not only mine, moving
somebody else between reactions, an anonymous caller, and junk in either array.

Thanks to xet7 !
2026-08-09 02:54:56 +03:00
Lauri Ojansivu 2c4e81ae34 ParentBleed, five cursors further: linked cards leaked the same way.
GHSA-jvv9-498p-hxrg was reported against the ANCESTOR cursor of the `board`
publication - a cross-board `parentId` made it publish a private board's cards
to another board's subscribers. Auditing for more of the same found the
linked-card cursors beside it with the identical hole and a wider blast radius:
five of them, publishing the linked card, its comments, its attachments, its
checklists and its checklist items.

A `cardType-linkedCard` names a card by id exactly as `parentId` does, and that
card may live on any board. Creating one requires read access to that board -
but the person who created it is not the person the publication is sending to.
Every subscriber of the board carrying the linked card was getting the source
card's full document and all of its children, whether or not they may see the
board it lives on.

The same answer as the ancestor cursor: a linked card whose source board the
subscriber cannot see is not sent. Cards on THIS board are their own answer,
since they are already going to this subscriber. The five cursors repeated the
same fifteen-line preamble verbatim; they share one helper now, memoized per
board, which is also what stops the sixth from being written without the check.

Two more findings from the same audit, in the same file and its neighbour:

- Three further hand-written copies of the board-visibility `$or` (the boards
  publication, getAllBoardsPage, getAllBoardsTileData). All three were correct,
  which is exactly the state RevokeBleed's copy was in before it drifted. They
  call boardVisibilitySelectors() now, so there is no hand-written copy left in
  the file.

- The STREAMING attachment export read `versions.original.path` with nothing but
  an existsSync. The attachment allow rule refuses a client-supplied path, so
  this is not the PathBleed hole - but it was the last place a stored path was
  read without the containment check, and a path is only as trustworthy as every
  way it could have been written.

tests/crossBoardParentCardLeak.test.cjs grows six checks: all five cursors go
through the helper, the helper asks the same visibility question, no cursor
builds its own linkedId list any more, the assigned-only member narrowing
survived the de-duplication, the helper is memoized, and no hand-written
visibility array remains.

Thanks to Alpastx and xet7 !
2026-08-09 02:54:41 +03:00
Lauri Ojansivu b37adda8a4 CHANGELOG: the four Alpastx security reports, under Upcoming.
PathBleed, RevokeBleed, ParentBleed and CommentBleed, each linking its own fix
commit and its Hall of Fame page, under a CRITICAL SECURITY ISSUES header.

Thanks to Alpastx and xet7 !
2026-08-09 02:27:23 +03:00
Lauri Ojansivu a8fa6cfa3f CommentBleed: the REST API let any board member delete anyone's comment.
Over DDP, deleting a comment is author-or-board-admin, and a board that sets
`restrictCommentEditing` takes even the admin's ability away (#5906). That rule
was enforced in a CardComments collection hook keyed off the Meteor userId, with
an early `if (!userId) return` for the genuine server-internal callers - board
copy, cleanup, migrations - that run with no authenticated user.

An HTTP request carries no Meteor userId into the invocation context. So

    DELETE /api/boards/:boardId/cards/:cardId/comments/:commentId

which checked only `Authentication.checkBoardAccess` - board MEMBERSHIP - reached
the collection with no user, the hook took its "server-internal, trust it" path,
and any normal member could delete any comment on the board. HTTP 200, the
comment gone, restrictCommentEditing or not, while the same deletion over DDP
was correctly refused (CWE-639, CWE-863). Broken object-level authorization:
harassment and evidence destruction on a shared board.

The fix does not rely on the hook seeing something it cannot see. The handler
loads the comment - 404 when there is none - and applies the same rule itself,
with req.userId, before removing anything. That rule is now an exported function
(`assertCanMutateComment` in models/cardComments.js) that the hooks and the REST
handler both call, so DDP and HTTP cannot enforce different things; it carries a
403 statusCode so a refusal answers Forbidden rather than 500. The no-userId
path stays, documented for the internal callers it was written for.

tests/restCommentDeleteAcl.test.cjs pins that the handler loads the comment,
that the ACL runs BEFORE the removal, that membership alone is no longer
sufficient, that the DDP hooks still call the same function, and - so a future
edit route cannot repeat this - that no REST PUT for comments exists without the
same check.

Thanks to Alpastx and xet7 !

Fixes GHSA-pqr4-rxgp-hv2m.
2026-08-09 02:20:52 +03:00
Lauri Ojansivu 35daaaa279 ParentBleed: one card id bridged a private board into another board's feed.
A card's `parentId` may name a card on ANOTHER board, and setting it was
authorized only against the CHILD board's write ACL - nothing asked whether the
person setting it, or the people subscribed to the child board, were allowed to
see the other board at all.

The board publication walks the whole ancestor chain, because the
'prefix-with-full-path' subtask setting renders a subtask's complete path
(#3453), and it published the complete ancestor card DOCUMENTS to every
subscriber of the child board. So a member who could write on shared board B and
knew a card id on private board A could point a card at it and have A's card -
title, description, custom fields - delivered over DDP to people who are not
members of A (CWE-200, CWE-862).

Closed at both ends, because either alone leaves half the hole open.

The WRITE refuses a parent whose board the actor cannot see. That is one
question, asked in one place (server/lib/visibleBoardIds.js) with the same
selectors the publication and All Boards use - so an ACTIVE org, team or domain
share counts and a revoked one does not. It is enforced on the REST card create,
on the REST card update (the PUT the report used), and on DDP by a deny rule
covering both insert and update, beside the cross-board MOVE deny that
GHSA-gm7v-pc38-53jr added. A parent that does not exist is refused too. Clearing
or omitting a parent is untouched.

The PUBLICATION sends only the ancestors whose board the subscriber may see. The
board being published is its own answer, so a normal same-board subtask path is
unchanged; an ancestor on a board the subscriber cannot see is simply not sent,
and a chain that yields nothing publishes nothing.

This is the shape of the check the linked-card path already made - creating a
linked card requires read access to the source card's board - applied to the
field that did not have it.

tests/crossBoardParentCardLeak.test.cjs pins the filtered cursor, that the
unfiltered publish of every ancestor is gone, that both REST paths validate
before writing, that the deny rule covers insert as well as update, and that
clearing a parent and the pre-existing cross-board move deny still work.

Thanks to Alpastx and xet7 !

Fixes GHSA-jvv9-498p-hxrg.
2026-08-09 02:20:37 +03:00
Lauri Ojansivu 08baf7fd1b RevokeBleed: revoking an org, team or domain share did not revoke it.
`isActive: false` is how a board admin REVOKES a share with an organization, a
team or an email domain. The All Boards list honoured it - it matches shares
with `$elemMatch: { ..., isActive: true }` - so a revoked user watched the board
disappear from their list and everything looked correct.

The `board` publication did not. That is the publication that sends the board
document and its lists, swimlanes, cards, comments and attachments, and it
matched shares with a dotted selector instead:

    $or.push({ 'orgs.orgId': { $in: orgsIds } });
    $or.push({ 'teams.teamId': { $in: teamsIds } });
    $or.push({ 'domains.domain': { $in: emailDomains } });

A dotted path matches an element of the array without saying anything about
that element's other fields, so isActive was never consulted. Anyone who still
knew the boardId - a bookmark, a browser tab, a note - could subscribe and
receive the whole private board after their access had been taken away
(CWE-639, CWE-863). A revoke the primary data publication does not honour is
not a revoke.

The rule was written out twice and the two copies disagreed, so it is written
once now: models/lib/boardVisibilitySelectors.js builds the `$or`, both
Boards.userBoards and the `board` publication call it, and every share kind is
matched with `$elemMatch` requiring `isActive: true`. `includePublic: false`
still drops the public clause and only that clause, which is what the search
over all boards needs.

tests/boardShareRevokeBypass.test.cjs pins each share kind's selector shape,
that no clause anywhere matches a share without requiring isActive, that the
three dotted selectors are not produced at all, and that junk in the id lists is
dropped rather than handed to Mongo. tests/searchBoardScope.test.cjs asked its
question of the old array literal; it now asks it of the builder - the
behaviour it protects is unchanged.

Thanks to Alpastx and xet7 !

Fixes GHSA-gwc4-fw7p-gw58.
2026-08-09 02:20:13 +03:00
Lauri Ojansivu 0a9eb0ef4f PathBleed: an avatar could name any file on disk, and board export read it.
Attachments blocked client-supplied `versions.*.path`; avatars did not. The
avatar allow rule was the single line `update: isOwner` - being the owner let
you write ANY field of your own avatar document, `versions.original.path`
included. That field is server-managed metadata: it says where on disk the
bytes are.

Board export then read it. For every member whose avatar is a local WeKan file,
the exporter opens `versions.original.path` and embeds the bytes as base64 in
`profile.avatarFile`, so an authenticated user could point their own avatar at
/etc/passwd - or at anything else under WRITABLE_PATH - export a board they are
a member of, and decode the file out of the JSON. Arbitrary file read as the
WeKan OS user (CWE-22, CWE-73), from any account that can own an avatar.

Both halves are closed.

The WRITE: the guards that attachments had are now in one module,
models/lib/fileVersionFields.js, and BOTH permission files import it - avatars
had gone without them precisely because each collection wrote its own copy.
Avatars refuse an insert carrying versions.*.path or .storage, refuse any update
touching the versions subtree, and restrict updates to the same field whitelist
attachments use. Being the owner is still required; it is no longer sufficient.

The READ: nothing is read from a stored path unless the path RESOLVES to
somewhere inside WeKan's own storage (models/lib/storagePathContainment.js).
That is the half that also holds when a path is poisoned some other way - a
document written before this fix, a restored backup, a bad migration. The check
sits at getBase64Data, the one place the export turns a stored path into bytes
for attachments and avatars alike, and at both avatar call sites. The download
path in fileStoreStrategy.js has always checked containment this way; its
private copy of the function is gone and it uses the shared one, so the download
and the export ask the same question.

tests/avatarVersionPathTraversal.test.cjs pins the payload being refused on
insert and on update, that renaming an avatar still works, that ..-escapes are
resolved rather than string-matched, that a sibling directory with the root as a
prefix is not inside it, and that no unguarded read of a stored version path
remains in the exporter.

Thanks to Alpastx and xet7 !

Fixes GHSA-4mxf-m8pq-xc9p.
2026-08-09 02:19:15 +03:00
Lauri Ojansivu b2ef10d9d1 Merge pull request #6579 from wekan/dependabot/github_actions/actions/upload-artifact-7
Bump actions/upload-artifact from 4 to 7
2026-08-08 23:41:43 +03:00
Lauri Ojansivu ad62101e10 Merge pull request #6578 from wekan/dependabot/npm_and_yarn/aws-sdk/lib-storage-3.1104.0
Bump @aws-sdk/lib-storage from 3.1085.0 to 3.1104.0
2026-08-08 23:41:27 +03:00
Lauri Ojansivu a1c500e33b Merge pull request #6577 from wekan/dependabot/npm_and_yarn/markdown-it-15.0.0
Bump markdown-it from 14.3.0 to 15.0.0
2026-08-08 23:41:09 +03:00
Lauri Ojansivu 454ec04b97 Merge pull request #6576 from wekan/dependabot/npm_and_yarn/tests/playwright/playwright/test-1.62.1
Bump @playwright/test from 1.62.0 to 1.62.1 in /tests/playwright
2026-08-08 23:40:53 +03:00
Lauri Ojansivu f4e5843016 Merge pull request #6575 from wekan/dependabot/github_actions/actions/checkout-7
Bump actions/checkout from 4 to 7
2026-08-08 23:40:40 +03:00
Lauri Ojansivu 0dbcfc44e0 Merge pull request #6574 from wekan/dependabot/github_actions/actions/download-artifact-8
Bump actions/download-artifact from 4 to 8
2026-08-08 23:40:26 +03:00
dependabot[bot] 0612630836 Bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:33:36 +00:00
dependabot[bot] eab75eab70 Bump @aws-sdk/lib-storage from 3.1085.0 to 3.1104.0
Bumps [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) from 3.1085.0 to 3.1104.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.1104.0/lib/lib-storage)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:33:15 +00:00
dependabot[bot] aa8f591839 Bump markdown-it from 14.3.0 to 15.0.0
Bumps [markdown-it](https://github.com/markdown-it/markdown-it) from 14.3.0 to 15.0.0.
- [Changelog](https://github.com/markdown-it/markdown-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/14.3.0...15.0.0)

---
updated-dependencies:
- dependency-name: markdown-it
  dependency-version: 15.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:32:49 +00:00
dependabot[bot] e16ffbc12b Bump @playwright/test from 1.62.0 to 1.62.1 in /tests/playwright
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.62.0 to 1.62.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.62.0...v1.62.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:32:48 +00:00
dependabot[bot] d911d71e53 Bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:32:46 +00:00
dependabot[bot] 79b10a1b43 Bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:32:35 +00:00
Lauri Ojansivu 8c2565e996 releases/: delete two superseded scripts, and two exemptions that outlived them.
An audit of releases/ against who actually calls each script. It holds 89 of
them, and they divide cleanly:

  20  invoked by .github/workflows - CI checks out the repo and runs the FILE, so
      these cannot move anywhere;
  65  reachable from the build.sh / build.bat Releases menu, which is what the
      menu is for;
   1  sourced by another script (ensure-tools.sh);
   3  referenced by nothing that runs.

Of those last three, wekan-entrypoint.sh is the Docker image's entrypoint - named
by the Dockerfile, not by a caller in this repo - so it stays.
old-build-bundle-arm64.sh and translations/old-pull-translations.sh are the other
two, and both were already recorded as superseded, by build-bundle-arm64.sh and
pull-translations.sh. Nothing referenced them but the exemption list that excused
them from being menu entries. They are deleted, and their exemptions with them.

Two more exemption notes were wrong rather than stale-adjacent:
fix-changelog-hashes.sh said "Setup -> Update git runs it", an option that no
longer exists - it is Setup -> git pull and git push now, both of which run it -
and resolve-fork-node-tag.sh excused a file that is not in the tree at all. An
exemption naming a missing script is an exemption nobody can check.

Checked after: every remaining releases/ script is either a menu entry or exempt
with a reason, and no exemption names a file that does not exist.

Thanks to xet7 !
2026-08-08 14:22:12 +03:00
Lauri Ojansivu 6def3a14dd Setup: "git pull" and "git push" that finish the job, replacing "Update git".
"Update git" fetched, rebased, ran the CHANGELOG hash repair, printed git status
and left the rest to the reader - and the rest is where the mistakes were. A
rebase rewrites local commits, so CHANGELOG links written before it name commits
that no longer exist; the repair fixes the file but commits nothing, so the next
push carries a dirty tree; and a push rejected as non-fast-forward leaves a repo
that needs a pull the caller has to know to run. Three manual steps after an
option whose name did not say what it did.

Two options now, one per direction, each ending in a state that is either correct
or unchanged:

  git pull  - fetch; fast-forward when there is nothing local to replay (no
              commit is rewritten, so no link can go stale); otherwise rebase,
              then repoint the CHANGELOG links the rebase moved AND commit that,
              so the tree is clean. A conflict is the one thing a script must not
              paper over - a half-applied rebase IS the wrong state - so the
              rebase is aborted, the repo is exactly as it was, and the message
              says what to run by hand.
  git push  - repair the links BEFORE publishing them, since a stale link that
              reaches GitHub 404s for every reader; say so when there is nothing
              to push; and on the one rejection with an obvious answer, origin
              moved, pull and retry ONCE. Never force: force-pushing a branch
              somebody has pulled rewrites their history too, and a second
              rejection is a different problem that retrying does not fix.

build.bat had a 41-line batch REIMPLEMENTATION of the old flow. It is gone: both
options there call `bash ./build.sh git-pull` / `git-push`, so there is one
implementation of this logic and Windows cannot drift from it. The same two are
CLI commands (./build.sh git-pull, git-push), so the menu, the .bat and the
command line are the same code.

Verified against throwaway repositories - an origin plus two clones - rather than
by reading: already-up-to-date does nothing; origin-only-moved fast-forwards and
rewrites no commit; both-sides-moved rebases, and a CHANGELOG link to a rewritten
commit is repointed to the replayed commit with the same subject and committed,
leaving the tree clean; a conflicting rebase aborts with HEAD, the working tree
and the index exactly as before and no rebase in progress; a rejected push pulls
and lands on the second try; and pushing with nothing to push says so.

Thanks to xet7 !
2026-08-08 14:22:12 +03:00
github-actions[bot] 0409b348af Bump versions for v10.73 2026-08-08 06:39:43 +00:00
Lauri Ojansivu 466bf21a57 Prepare v10.73 release 2026-08-08 09:38:41 +03:00
Lauri Ojansivu c2ded4a599 CHANGELOG: the CodeQL fix belongs in Upcoming, not inside the released v10.72.
v10.72 was tagged and published at 08:32; the PatternBleed fix was committed at
09:05. Its entry went into the section directly above the previous release, and
by then that section had been renamed from "Upcoming" to "v10.72" by the release
commit - so the changelog claimed a released version contains a fix that is not
in its tag. Checked rather than assumed: the v10.72 tag points at "Bump versions
for v10.72", and the fix is not an ancestor of it.

There is a "# Upcoming WeKan ® release" section above v10.72 again, holding the
security subsection, and v10.72's first subsection reads "This release updates
the following dependencies:" once more instead of the "and updates" it was left
with. The Upcoming section carries the shape CLAUDE.md prescribes: the
**In short:** paragraph, then the binaries table - v10.72's rows, since nothing
here rebuilds them, and the paragraph says so - then the subsection, then the
closing thanks line.

The entry's commit link is repointed. `git pull` rebased the three commits after
the release onto it, which gave them new hashes, so the link written before the
rebase named a commit that no longer exists.

And a real bug in the guard that was supposed to notice all this:

    /^(This release|and ) .*:$/

The space is INSIDE the alternative AND after it, so matching an "and" header
needs "and  updates" with two spaces. Every `and ...:` subsection header
therefore failed to match, and the grouping check never reset its current group
at a subsection boundary: entries under a later subsection were attributed to the
last group line above them, which is exactly the mistake that loop exists to
catch. With it fixed, the section reports 22 grouped entries and one loose - the
one-entry security subsection, which CLAUDE.md keeps flat.

Thanks to GitHub CodeQL code scanning and xet7 !
2026-08-08 09:31:19 +03:00
Lauri Ojansivu 9dbdcbefc0 CHANGELOG: name the CodeQL finding PatternBleed and link its Hall of Fame page.
CLAUDE.md's security process is the Hall of Fame as well as the CHANGELOG: the
name, the row in hall-of-fame/index.html and the page under it. I had written the
entry without them.

Thanks to GitHub CodeQL code scanning and xet7 !
2026-08-08 09:27:28 +03:00
Lauri Ojansivu 74223ce69f CHANGELOG: the CodeQL identity-replacement fix, under Upcoming.
Its own SECURITY ISSUES subsection, first in the release as CLAUDE.md prescribes,
so the dependency subsection that used to open it becomes an "and updates".

Thanks to GitHub CodeQL code scanning and xet7 !
2026-08-08 09:27:28 +03:00
Lauri Ojansivu f3a12533d7 CodeQL js/identity-replacement: a replacement that replaced a hyphen with itself.
Code scanning alert #431, tests/releaseNodeSources.test.cjs:

    new RegExp(`^\s*${p.replace('-', '-')}\)\s+nodename=`, 'm')

It reads as "escape the hyphen before interpolating this into a regex" and does
nothing whatsoever, so the value went in raw. Nothing failed, because a hyphen
outside a character class needs no escaping - but the guard it looked like was
not there, and a platform name carrying a `.` or a `+` would have matched the
wrong row or thrown. CWE-116, and CodeQL is right to call it out: the usual cause
of the shape is a mistyped backslash escape, where '\"' is silently just '"'.

The platform name is escaped for real now, with the same escapeRegExp the other
guards in tests/ use.

tests/noIdentityReplacement.test.cjs catches the class rather than the instance,
because code scanning reports these days later in a web UI and the node suites
report in fifteen seconds. Three things it took to make it honest:

  * it compares the two sides as VALUES, not as source text. '\"' IS '"' - that
    is the entire point of CodeQL's example - so a text comparison would call
    them different and miss the mistake the guard exists for.
  * the two quote styles are separate alternatives, not a backreference with a
    character class excluding both quotes: `replace('"', '\"')` contains a double
    quote inside a single-quoted literal, so the first shape of the pattern could
    not match the very example above. Caught by asserting the pattern against
    that line rather than trusting it.
  * comments are stripped and the guard skips its own file: this one and the file
    it was written for both QUOTE the bad line to explain it, and a guard that
    reports the sentence describing a bug as the bug gets switched off.

Verified in both directions: the repository is clean now, the same scan against
the previous commit reports the offending line, all three bad shapes are detected
and four working replacements - including a real escapeRegExp - are not.

Thanks to xet7 !
2026-08-08 09:27:28 +03:00
github-actions[bot] 0369b5042c Bump versions for v10.72 2026-08-08 05:24:23 +00:00
Lauri Ojansivu 70a3ac84c3 Prepare v10.72 release 2026-08-08 08:23:19 +03:00
Lauri Ojansivu 6234956437 CHANGELOG: the suite catching up, under Upcoming.
The two commits that fixed what the companion-repo move and the cross-board card
fix left failing, and the Playwright retry loop that could not reach its own
error. The run after them was green: 328 node suites, 518 mocha tests,
249/243/243 in the three browsers, 98 conformance cases with none differing, and
FerretDB's own suites.

Thanks to xet7 !
2026-08-08 08:14:22 +03:00
Lauri Ojansivu 96daaac3db The last two from the run: an ignore comment, and a retry loop that outlived its test.
Down from five failures to none. Both of these are the same kind of thing - a
check that described the world before a change, and a loop that could not reach
its own error.

swcLegacyHelpers still required the .gitignore comment block that named each
clone: `- node/ : clone of the Node.js ...`. That comment went with the entries
it described. What has to be explained now is the one directory that replaced
them, so .gitignore says what .tools/ holds - companion repos, cloned inside the
checkout so they work in a Flatpak sandbox that shares only this directory, with
the entries it replaced named so the change is legible - and the guard requires
THAT. An ignore of a whole directory with no explanation is the kind nobody dares
remove later. The rule about the shape of a per-repo entry stays: if one is ever
re-added it must be rooted and a directory, since a bare `node` matches anything
a package happens to call that.

The webkit failure was one test of 243, in the shared boardPage fixture rather
than in an assertion:

    Test timeout of 60000ms exceeded while setting up "boardPage".
    Error: page.waitForTimeout: Target page, context or browser has been closed

openBoard retries five times, waiting up to 20s for a list each time with a
second between - about 105 seconds, against a 60 second test timeout. So the loop
could never reach its own error: Playwright killed the test first, and the report
said the page had closed instead of saying the board never rendered. Retrying
past the point where the result can still be used is not resilience, it is a
worse error message.

The loop is bounded by a deadline now, 45s, leaving room for the rest of the
fixture inside the 60s timeout. The first attempt keeps the full 20s look, later
attempts get whatever is left, and it stops rather than starting a wait it cannot
finish - so a slow board still gets one long look, and a board that will not
render fails with "did not render any lists within 45s". Simulated across three
cases: never renders (gives up at 45s, 3 attempts), renders at 4s and at 19s
(both on the first attempt) - all inside the test timeout, where the old shape
overran it by 45 seconds.

Thanks to xet7 !
2026-08-08 06:52:35 +03:00
Lauri Ojansivu 5548029088 The five failures the .tools move and the card-move fix left behind.
An EVERYTHING run found four node suites and one mocha test failing. All five
are consequences of the two changes before them, and each one is a guard doing
its job.

Three broke on the companion-repo move:

  * meteorignoreScanScope listed the foreign checkouts by name - FerretDB, node,
    mongo-tools, TSC, gitea, the two WeKan variants - one .meteorignore entry
    each. They are in .tools/ now, one entry that covers all of them, so a list
    of names is a list of history: it fails for the ones that moved and says
    nothing about the next repo somebody clones. It asks the property instead:
    .tools is excluded, and nothing at the top of this checkout that is its own
    git repository is left for Meteor to walk. The variant check became the
    stronger version of itself - no directory with client/, server/ and models/
    is reachable from the top - since a second WeKan is loaded EAGERLY, not
    merely scanned.
  * swcLegacyHelpers checked .gitignore for the same list; one entry now.
  * cpuMonitorWiring reads FerretDB's Go source and still opened FerretDB/. It
    reads .tools/FerretDB, and skips with a note when the clone is not there -
    it is a separate repository, and not every checkout has cloned it.

dockerComposeBackends caught a real omission: the LDAP_GROUP_BASEDN block from
#5539 went into docker-compose.yml only, and every compose file's wekan service
must be identical - what a user reads when they edit their settings must not
depend on which backend they picked. The block is in all of them now.

The mocha failure is the one worth reading twice.
cards.dependencies.move.tests.js pinned move() issuing the inbound-dependency
cleanup itself: a multi-document update with a compound selector. That is
exactly what #6572 removed, because this helper runs in the client bundle where
Meteor allows updates only by id - it threw "Untrusted code may only updateAsync
documents by ID" on every cross-board move, before the move ran. So the test was
pinning the bug. It now asserts the contract that replaced it: move() clears the
moved card's own dependencies, and makes NO update that is not by id - not a
selector, not multi. `{ _id: x }` still counts as by id, since Meteor's rule is
"by id", not "not an object". The inbound half is the server hook, pinned by
tests/cardMoveUntrustedUpdate.test.cjs.

Verified against the tree: all four node suites' conditions hold, the four
compose files the guard compares are identical again, and the FerretDB source
the CPU guard reads is present at the new path with every pattern it asserts.

Thanks to xet7 !
2026-08-08 03:46:39 +03:00
Lauri Ojansivu e7e7a700c6 CHANGELOG: the webhook username, under Upcoming.
Thanks to xet7 !
2026-08-08 02:50:33 +03:00
Lauri Ojansivu 5b83e8e256 Webhooks send the username too, and TODO Later loses three settled entries.
An audit of the TODO Later backlog against the open issues and the current
source. Three no longer belong there.

#3113 - an outgoing webhook said who did something by DISPLAY name and had no
field for the login name. params.user comes from getActivityUserName(), which
prefers getName() because the same params feed the e-mail notification text,
where "Lauri Ojansivu commented on ..." is what a person wants to read; a webhook
consumer needs the identifier, received "Lauri Ojansivu" where it needed "xet7",
and matching users by display name is wrong the moment two people share one.
Changing what `user` means would break every consumer already reading it, which
is what left this recorded as needing a maintainer decision. It needs none: the
username travels as its OWN field. `user` is untouched, `username` is beside it
in the default WEBHOOKS_ATTRIBUTES list, and a deployment that pinned its own
list still gets exactly the fields it asked for.

#6542 - "Import finished. Users imported: 60, but only 25 are listed". The Admin
Panel People pane paginates and passes its total to the shared table page
(totalLabelKey: 'people-number'), so the 60 the importer reported is on screen
next to the 25 rows of the current page. That is what the report was about, and
it is already true in this source.

#6500 - Docker `latest` not loading in a browser: already closed upstream, so it
was only sitting in the backlog.

The rest of the backlog stays, each with its reason: they need an SMTP server, an
LDAP directory, a container, a browser to drag in, or a maintainer's decision on
an intended contract - none of which a source reading can settle.

tests/webhookUsername.test.cjs pins both halves of the webhook change: that the
username is there and set from the same user document as the display name, and
that `user` still means what it always did, including the notification text that
depends on it. Three of its seven assertions fail on the previous commit.

Thanks to xet7 !

Fixes #3113,
Fixes #6542.
2026-08-08 02:50:15 +03:00
Lauri Ojansivu ebd8a2d1d9 CHANGELOG: companion repos in .tools, under Upcoming.
Thanks to xet7 !
2026-08-08 02:41:43 +03:00
Lauri Ojansivu 40fff29d08 Companion repos live in .tools/, and the build scripts clone them there.
wekan/FerretDB is a separate git repository that WeKan's test runs need: the
conformance run builds it from source, and "Run all FerretDB tests" runs its own
suites. It was cloned as a subdirectory of the repo root, which is why
.gitignore and .meteorignore carried an entry per companion repo - nine of them,
each one a chance for a clone to reach a commit or a Meteor rebuild. They live
in .tools/ now, one directory both files already exclude.

build.sh gains ensure_tool_repo: it returns the path to .tools/<name>, cloning
wekan/<name> if it is not there - SSH first, since a maintainer pushes, HTTPS
after, so everyone else still gets a working clone. Its messages go to stderr,
because the path is what it prints. The directory comes from the script's own
location rather than the caller's cwd, so a clone cannot land in whatever
directory somebody happened to be in.

EVERYTHING's FerretDB stage calls it rather than assuming stage 3 already
cloned - a whole-run must not depend on the order of its own stages -
db-conformance.sh builds from .tools/FerretDB and makes .tools before cloning,
and build.bat does the same on Windows: it clones into .tools\FerretDB with the
same SSH-then-HTTPS fallback instead of printing instructions and stopping, and
says so plainly when git itself is missing.

CLAUDE.md's directory list describes the new layout, including that a companion
repo is cloned on demand, so a fresh checkout needs no manual setup.

tests/toolsDirectoryRepos.test.cjs pins both halves: that nothing still reaches
for the old path and both scripts clone, and that a repo INSIDE .tools still
finds its way back out - FerretDB's own build.sh used to reach WeKan's log
directory by counting ../, which lands one level short from .tools; it searches
for the checkout now (fixed in the FerretDB repo, same commit message).

Thanks to xet7 !
2026-08-08 02:41:24 +03:00
Lauri Ojansivu 909a5d9d5b From .gitignore and .meteorignore remove other repos that will be kept at .tools directory that is already ignored.
Thanks to xet7 !
2026-08-08 02:30:40 +03:00
Lauri Ojansivu e5d3935bc2 CHANGELOG: the LDAP group base and the list dates API, under Upcoming.
Thanks to leandro-cyberproject, mimZD and xet7 !
2026-08-08 02:20:03 +03:00
Lauri Ojansivu 4319fc72db CHANGELOG: TODO Later gains the card-drag flicker and the invite email.
#6430's remaining flicker and #5707's invitation email both need something this
environment does not have - a browser to drag in, and a real SMTP server - so
they are recorded with the reason and what was already established, rather than
guessed at.

Thanks to xet7 !
2026-08-08 02:19:08 +03:00
Lauri Ojansivu 50083104c7 API: a list says when it changed, and when its cards last did.
#5251 asked for the last change date of a list, to build an offline Android
client that syncs only what moved. Neither half existed: GET
/api/boards/:boardId/lists returned {_id, title} and no dates at all, and while
the single-list endpoint returns the whole document - modifiedAt included - that
date answers the wrong question. It moves when the LIST document changes (title,
sort, archived); a card being added, edited or archived does not touch it, and
those are the changes the client is asking about.

The list endpoint now reports both:

  modifiedAt      - the list document itself
  cardsModifiedAt - the newest change among the cards in it, null when none

One query for the board's cards and a reduction in memory, not a query per list -
a query per list is what made this expensive enough to ask about. Archived cards
are included on purpose: archiving is one of the changes named in the issue.

The reduction is models/lib/listActivityDates.js, a pure helper, so it is
testable without Meteor and so the handler stays readable. It reads
dateLastActivity as well as modifiedAt and takes whichever is newer, because
boards written by older WeKan carry only the former; a card with no usable date
is skipped rather than counted as now or as zero; and a list whose cards carry no
date is ABSENT from the result, so the endpoint reports null rather than an
invented time - a client polling on a wrong date either never syncs or syncs
forever.

tests/listActivityDates.test.cjs exercises the helper directly: newest wins, per
list, legacy field, string and numeric dates, an undated card cannot drag the
answer backwards, and a negative case feeding it nulls, strings, numbers, a card
with no listId and an Invalid Date - none of which may throw, since the handler
passes whatever the database holds. It also pins the endpoint's shape: both
fields, one cards query, only the three fields it reduces over, and no
`archived: false` on that query.

Thanks to mimZD and xet7 !

Fixes #5251,
2026-08-08 02:19:08 +03:00
Lauri Ojansivu 8b2e0038df LDAP: groups may live in their own subtree, not only under the user base.
#5539: WeKan could not authenticate LDAP users whose groups sit in a different
ou than the users themselves.

Both group searches - getUserGroups, which feeds the login restriction, admin
status sync, group->role sync and org/team sync, and isUserInGroup - searched
this.options.BaseDN. That is the USER base. A directory that keeps ou=groups
beside ou=people, which is a common layout and the reporter's, has no groups
under it, so every group search came back empty; with LDAP_GROUP_FILTER_ENABLE
on, isUserInGroup concluded "not a member" and refused the login. Nothing in the
package could say where the groups were - there was no setting for it.

LDAP_GROUP_BASEDN says where. Unset, or set to a blank string, it falls back to
BaseDN, so a directory that keeps users and groups in one subtree behaves exactly
as it did; a present-but-empty env var is one somebody meant to fill in, not a
search base, and searching "" would silently search the directory root.

The three USER searches - searchUsers, getUserById, getUserByUsername - keep
BaseDN. Pointing those at a group subtree would break login for everyone, so the
test pins which searches moved and which did not, in both directions.

Documented beside the settings it belongs with, in docs/Login/LDAP.md and
docker-compose.yml: a setting nobody can find is one that does not exist.

tests/ldapGroupBaseDn.test.cjs pins all of it, including the fallback and the
documentation. Six of its nine assertions fail on the previous commit; the three
that pass there are the user searches, which is the point of asserting them.

Not verified against a directory - this sandbox has none. The defect is read from
the source: two group searches, one user base, no way to say otherwise.

Thanks to leandro-cyberproject and xet7 !

Fixes #5539,
2026-08-08 02:16:04 +03:00
Lauri Ojansivu 244375ce13 CHANGELOG: the five misplaced controls, under Upcoming.
Thanks to Alishara and xet7 !
2026-08-08 02:05:34 +03:00
Lauri Ojansivu c687daadf4 Five controls that had drifted away from what they belong to.
The screenshots in #6465 (comment 5187985815, images only, each with the fault
circled):

"Checkbox is misplaced" - Admin Panel people table. The select-all box and the
"all users" it labels were two loose inline nodes in a centred table heading: the
box is replaced content with its own baseline, and at the width that column
happens to have, it landed on the word. They are one <label for> now, an
inline-flex row with a gap that cannot collapse - which also makes the word click
the box.

"This checkbox is crazy" - Member settings. The rescue-card-description row
carried a `b &nbsp;` spacer and `.left`, which is float: inline-start. A float
inside a flex container is ignored and the spacer became a stray flex item, so
the box drifted up beside the "Card settings" heading instead of sitting with its
own text. It is now the same shape every other checkbox row in that file uses:
.flex anchor, .materialCheckBox, span.

"(2/5) <-- move this up here" and "Make this same height as the lanes left and
right" - one cause, not two. The WIP counter is already inside the h2 right after
the title; the title is a `+viewer`, and .viewer is display: block with a 22px
min-height, so it pushed everything after it onto a second line AND reserved a
band under it, which is what made that list's header taller than its neighbours'.
Inline in the heading, the counter stays where the markup already puts it. Same
treatment the quick-access header and the table view already give their viewers.

"Please move the button down. This is too close ... panic ;-)" - the backup
schedule's Save sat directly under the 1..28 day buttons, close enough to hit
while aiming for a date. The gap goes on the form-group that FOLLOWS the day
grid, so it applies where that grid is and nowhere else.

"The alignment is out of place" - the attachment move button. This one is
normalisation rather than a measured fix: the row is align-items: flex-end, so a
margin of the button's own offsets it from the controls it acts on; that margin
is zeroed and the button aligns with the controls explicitly. Whether that is the
whole of what was circled needs a browser, and the test says so.

tests/uiSpacingReports.test.cjs pins each cause, including the premise behind the
WIP fix (.viewer really is a 22px block). Six of its nine assertions fail on the
previous commit; the three that pass there are the premises, which is the point
of stating them separately.

Not verified in a browser: this sandbox has none. Each fix is read off the markup
and CSS against what the screenshots show.

Thanks to Alishara and xet7 !
2026-08-08 02:05:14 +03:00
Lauri Ojansivu 0b7aed89bd CHANGELOG: the OIDC null userinfo, under Upcoming.
Thanks to Esther125 and xet7 !
2026-08-08 02:00:00 +03:00
Lauri Ojansivu a50b483ff9 OIDC: a provider that refuses the request says so, instead of "'ocs' of null".
#5174: an OIDC login against a provider that rejected the scopes failed with
"Error in OAuth Server: Cannot read property 'ocs' of null", which says nothing
about the refusal that caused it.

getTokenContent() returns NULL for a token it cannot parse, and the ADFS/B2C
branch assigns that straight into userinfo. The next line was the Nextcloud hack,
`if (userinfo.ocs)`, so the first thing to touch the failed response was a
property read on null. Every claim read after it had the same problem - that line
was only first by accident of ordering, which is why the fix is not one null
check but an order: the provider's answer is validated once, as a whole, before
anything reads a field off it.

Checked, in this order, each naming what failed:

  * the token response is an object at all;
  * it carries an access_token or an id_token - a 200 with neither is the
    provider answering with a complaint, so the error lists the fields that DID
    come back (keys only: the values are secrets, and JSON.stringify of a token
    response in a log is how refresh tokens leak);
  * userinfo is a non-null object, saying WHICH path produced nothing (claims
    expected in the access token, or the userinfo endpoint) and pointing at
    OAUTH2_REQUEST_PERMISSIONS, which is what was actually wrong in #5174;
  * the ocs and metadata hacks unwrap to something - `ocs` present with no
    `ocs.data` used to set userinfo to undefined and fail on the next line;
  * B2C's userinfo["emails"][0] is an array before it is indexed: a tenant that
    does not release the claim made that undefined[0];
  * expires_in parses to a finite number - parseInt(undefined) is NaN, and NaN
    propagated into the account's expiry silently. Falls back to the OAuth2
    default hour.

tests/oidcNullUserinfo.test.cjs pins the order rather than the individual checks:
it asserts each guard sits BEFORE the read it protects, since an edit that reads
a claim earlier would restore the original bug without touching a check. It also
pins that the no-token error names keys and never stringifies the response. All
eight assertions fail on the previous commit.

Not reproduced against a real provider - this sandbox has none. The null path is
read from the source: getTokenContent returns null for a falsy token, and the
ADFS/B2C branch passes its result to the line that threw.

Thanks to Esther125 and xet7 !

Fixes #5174,
2026-08-08 01:59:42 +03:00
Lauri Ojansivu de8814a7cd CHANGELOG: the board type scale, under Upcoming.
Thanks to xet7 !
2026-08-08 01:52:15 +03:00
Lauri Ojansivu a95451c67b Board type scale: a list heading outranks its cards again.
Reported by email with a screenshot: the "Add card" link and the "Add" button
are too big, and things should be sized in relation to each other the way an h1
is bigger than an h2.

The measurements agreed. A list heading was 14px, while a minicard title and the
"Add card" link both set no size at all and inherited the document's 16px, and
the composer's Add button was a 50px-tall slab. So the column was labelled in
fine print, the affordance for making a card was as loud as the cards
themselves, and the biggest thing on screen while typing was a button.

The scale is now heading 16 > card title 14 >= add-card link 13, each stating
its own size instead of inheriting, and the Add button is a 34px control rather
than a slab. Mobile keeps its own larger sizes - the .mobile-view rules with
!important, and the @media blocks - because a phone is read at arm's length.

tests/listTypeScale.test.cjs pins the ORDER rather than the numbers, since that
is what "in relation to each other" means, plus that each of the three states a
size of its own: an inherited size is exactly how the link and the card title
both landed on 16px, level with each other and above their heading. It reads the
base scale only, skipping @media blocks, and takes the LAST rule for a selector -
.list-header .list-header-name has two rules, and reading the first one finds the
one that sets no size at all. Verified against the previous commit: heading 14,
card title and add-card link unset, button 50px - it fails on all four counts.

Not verified in a browser: this sandbox has none. The sizes are read from the
source, and the relative order is what the guard holds.

Thanks to xet7 !
2026-08-08 01:52:00 +03:00
Lauri Ojansivu f049503ee5 CHANGELOG: the cross-board card move, under Upcoming.
Thanks to jullbo and xet7 !
2026-08-08 01:24:11 +03:00
Lauri Ojansivu 24927c3a7a Moving a card to another board works again, and the REST route cannot corrupt one.
#6572: every cross-board move failed immediately with "Not permitted. Untrusted
code may only updateAsync documents by ID. [403]", whether or not the card had
any dependencies, and before the move itself ran - so the card stayed put.

models/cards.js is isomorphic, so its helpers run in the client bundle, and
client code calls card.move() directly (list.js, swimlanes.js, minicard.js,
globalSearch.js, sidebarFilters.js). Meteor lets untrusted code update only BY ID
- a bare id or an { _id: ... } selector. The cross-board branch of move() cleaned
up inbound "Red Strings" (#3392) with a compound selector and multi: true, which
is rejected on the client every time, including when it would have matched
nothing. addDependency, setDependencyProps and removeDependency in the same file
each carry a comment saying updates must be by _id only; this was the one place
that did not follow it.

The card's own dependencies are still cleared by the move (that rides along in
the by-id update). The inbound half - the links pointing AT the card from the
board it left - is now a Cards.after.update hook in server/models/cards.js, where
a selector is allowed, and being server-side it also covers the REST API and
import paths that never called the helper at all. It pulls both stored shapes:
{ cardId, ... } objects, and the bare id strings older data still holds, which
normalizeDependencies hides on read.

The issue also reports that the REST workaround corrupts the card, and it does.
A PUT of boardId/listId/swimlaneId naming the DESTINATION board is not a board
move - isBoardMove needs newBoardId, newSwimlaneId and newListId - so that branch
never ran, while the same-board swimlane and list branches did: the card kept its
old boardId and got the other board's listId and swimlaneId written onto it,
belonging to a list and a swimlane on a board it was not on, showing on neither,
undone only by a hand-written database update. Those two branches now verify the
target belongs to the board in the URL and otherwise refuse with 400 naming the
parameters to use, and they are skipped during a board move - they would rewrite
listId before the isBoardMove update, whose selector pins the card's original
listId, so that update would match nothing and silently do nothing: the same
broken card by another route.

tests/cardMoveUntrustedUpdate.test.cjs pins both halves: every Cards update in
the isomorphic model is by _id and none is multi-document (it reads the whole
call, because multi is the third argument and a check that stopped at the first
one could never fail), the inbound cleanup is server-side and keyed on a real
board change, and the REST branches are board-checked and skipped during a move.
Verified against the previous commit: the model guard reports the compound
selector and the multi update, and 6 of the 8 REST assertions fail.

Thanks to jullbo and xet7 !

Fixes #6572,
2026-08-08 01:23:22 +03:00
Lauri Ojansivu 75d1e74715 CHANGELOG: the Windows Playwright ALL logs, under Upcoming.
Thanks to xet7 !
2026-08-08 00:39:07 +03:00
Lauri Ojansivu f138b3124f build.bat: the "Playwright ALL browsers" option writes logs again.
The EVERYTHING run's WeKan stage failed on one node suite:
dbConformanceWiring, "build.bat: playwright-all must be logged". It is a real
gap, and splitting the whole-suite browser job into three uncovered it rather
than caused it.

`call :onelog playwright-all` existed in exactly one place: inside the combined
three-project browser line of the sequential all-tests flow, which is gone now
that each browser is its own job with its own log. Where it did exist it never
worked - the call sat inside the `cmd /c` string of a STARTED child process,
which has no build.bat labels to call, so %ONELOG% was empty and Tee-Object was
handed an empty path.

Meanwhile the option the name was supposed to cover, Tests -> 11 "Playwright ALL
browsers", wrote no log at all: one `playwright test --project=chromium
--project=firefox --project=webkit` call straight to the terminal, nothing left
to read afterwards. That is the exact thing the guard is about, and build.sh's
same option has always written one log per browser.

So the option now runs the three browsers one at a time - unchanged, and still
sequential because three at once against one dev server exhausts RAM on smaller
machines - each through the same :onelog helper as every other Tests option, and
each with its own --output so Playwright does not clear another browser's traces
at startup. The guard drops `playwright-all`, which named an implementation that
is gone and was broken, and gains what it was reaching for: that the ALL-browsers
option logs, and that it covers all three.

Verified: every build.bat label reference resolves, all seven menus still print
exactly what they dispatch, the build.sh menu entries all still match a case arm,
and the whole-suite per-browser jobs are untouched. Mocha ran in this run for the
first time since it was fixed - 518 passing - and the other stages were green:
320 node suites with this one failure, 249/243/243 in the three browsers, the
conformance run and all of FerretDB's own tests.

Thanks to xet7 !
2026-08-08 00:38:50 +03:00
Lauri Ojansivu fdc85961a2 CHANGELOG: the menu option that quit the script, under Upcoming.
Thanks to xet7 !
2026-08-07 23:37:10 +03:00
Lauri Ojansivu b55cbda20d build.sh: Tests -> EVERYTHING runs again instead of quitting the script.
Renaming the menu entry broke it. choose() shows the SHORT labels and sets $opt
to the FULL DESCRIPTION of the one picked, and the dispatcher hundreds of lines
below matches that description as a case arm - so the description is an
identifier written twice, and the rename in the previous commit changed only one
of them. With no arm matching and no catch-all, the case fell through, the
`for _once` loop ended and the script EXITED: choosing Tests -> 1 printed nothing
and ran nothing.

The arm now carries the same string as the menu entry, so EVERYTHING runs its
four stages again. Two guards so it cannot come back:

  * tests/buildScriptParity.test.cjs checks every one of the 25 choose() entries
    against the case arms, and fails on a description that matches none. It fails
    on the previous commit's build.sh.
  * the dispatcher has a *) arm that says which option has no handler and that
    nothing was run, so if one ever does get past the test, the person in front
    of the menu is told rather than dropped back to the shell.

build.bat was checked for the same thing and cannot fail this way: its menus
dispatch on the NUMBER typed, not on a sentence, and its EVERYTHING hands the run
to releases/run-everything.sh rather than reimplementing it. Verified anyway,
both directions: every printed menu number has an if-dispatch in all seven menus,
and every goto/call target resolves to a label. That is now pinned too.

Thanks to xet7 !
2026-08-07 23:36:26 +03:00
Lauri Ojansivu 94940ada13 CHANGELOG: the card-drag list resize and the build-script run, under Upcoming.
Also repoints six entries at the commits that exist on this branch. The board
export and mocha entries were written against a commit that was amended after
they were filled in, and the four snap entries against one that was amended the
same way, so each of them linked a commit the amend had orphaned. A CHANGELOG
entry cannot carry the hash of the commit it is in; it goes in its own commit
afterwards, linking the code commit.

Thanks to xet7 !
2026-08-07 19:21:46 +03:00
Lauri Ojansivu c6b0af16eb Card drag no longer resizes every list, and EVERYTHING runs every check.
#6573: picking up a card made every list on the board expand horizontally to fill
the window, and dropping it snapped them back, which makes landing a card in the
intended list a guess. Two correct changes met. A list's custom width is an
inline --list-width custom property, and what turns it into a width is a rule in
list.css whose selector needs an ancestor: .js-swimlane, .dragscroll, or a
swimlane-<id> id. In lists view the container is
.swimlane.list-group.js-lists.dragscroll - no js-swimlane class, no such id - so
.dragscroll was the only one of the three that matched, and every list's width
there hung on that one class. #6558 then taught a card drag to stop the board
panning under the same pointer, and it does that by REMOVING the dragscroll class
for the duration of the drag. So the drag deleted the class the width rule
matched on: flex: none and the width declarations switched off and the lists
re-flowed, for exactly as long as the drag lasted. It also explains the
workaround in the report - dragging one list's edge first sets that list's width
through the resize path, so that column stopped jumping while the others did not.

A layout rule may not hang on a class that an interaction removes. .js-lists is
on the container in both views and nothing takes it off, so the width rules,
their mobile-mode counterpart and the resize rules name it now.
tests/listWidthDuringDrag.test.cjs pins that every width rule still matches in
lists view with dragscroll gone - it fails on the previous CSS - and pins the
premise that suspending the pan really does remove that class.

build.sh -> Tests -> EVERYTHING is what a release is tested with, so a check that
is in the Tests menu and in no whole-run is a check that runs when somebody
remembers it. The floating-promises guard was that: menu entry 12, in no run. It
is the first of four stages now - seconds, so an unawaited permission check is
reported before an hour of browsers - and its return code is in the verdict like
the others. What runs there is checks only: the menu entry may install ripgrep
and the @typescript-eslint packages and write the rule into .eslintrc.json, which
is a person setting the guard up, while EVERYTHING runs unattended and must
modify nothing - a run that rewrites .eslintrc.json is no longer testing the
commit it started from. The two checks are one shared function using grep rather
than ripgrep, so they also work where nothing may be installed.

build.bat ran the three browsers as one Playwright invocation into one
wekan-alltests-browsers.log, where neither "which browser failed" nor "what did
WebKit print" was answerable afterwards, and CLAUDE.md's "check the newest test
logs" names the per-browser files. It starts chromium, firefox and webkit as
three jobs with a log, a status and a summary row each now, with a per-browser
--output because Playwright clears its output directory at startup. EVERYTHING is
not reimplemented there: Windows hands the run to releases/run-everything.sh,
which calls build.sh --run-everything, so the new stage arrives on both platforms
at once. The parity guard pins all of it, including that the shared checks
install nothing.

Verified without node or meteor, which this sandbox has neither of: the extracted
checks were run for real against the tree (clean) and against fixtures with an
unawaited auth call, a missing rule and no .eslintrc.json at all (all three fail
as they should); the new and changed guards were ported assertion-for-assertion
to Python and run against the same files, including that the #6573 guard fails on
the pre-fix CSS; build.sh passes bash -n and every build.bat call now resolves to
a label.

Thanks to yulqen and xet7 !

Fixes #6573,
2026-08-07 19:21:24 +03:00
Lauri Ojansivu ee16a41a99 Board export: pin every section, and fix the mocha stage that stopped running.
The newest test run had two failures.

tests/changelogFormat.test.cjs failed on "the binaries table follows the
summary": the Upcoming section written after v10.71 had lost the binaries table
that every release section carries. It is restored from what v10.71 shipped, and
two group lines that had wrapped onto a second line are one line again, which is
what the grouping guard requires.

The mocha stage did not run at all. client/lib/tests/boardTriggersClass.tests.js
reads boardTriggers.jade and boardTriggers.js off disk with fs and path, which
worked only while node-polyfill-webpack-plugin's browser shims were in the client
bundle; the @meteorjs/rspack 2.1.0 update dropped that plugin and the client
build stopped with "Cannot find module 'path' for matched aliased key 'path'".
That is not one suite failing - the bundle does not build, so every mocha suite
is skipped and the stage reports a crash where its results should be. The suite
was on the wrong side rather than wrong: it now lives in server/lib/tests, and
tests/clientTestsNoNodeBuiltins.test.cjs is the mirror of the existing guard that
keeps browser-only packages out of the server bundle.

Issue #6274, "export includes only comments from current year", and its fix in
PR #6275 were then checked against the current source. The fix is intact and
survived the streaming-export rewrite that came after it: both exporters select
comments by cardId and activities by boardId OR cardId. The follow-up its
reporter left on that PR - "the export is now missing the lists part" - is not
reproducible: result.lists has been written by the JSON exporter continuously
since the CSV/TSV export was added, both JSON writers emit the same fourteen
sections, and the flat formats carry each list's name on the card's own row. So
no export code changed; what was missing was the guard, and
tests/exportBoardSections.test.cjs pins the section list, that the two JSON
writers agree on it, that no exporter selects comments by board again, and the
two smaller fixes from that PR.

Not run here: this sandbox has no node, meteor or any other JS runtime, so the
new and changed guards were verified by porting them assertion-for-assertion to
Python and running that against the same files - changelogFormat 16/16, the
export and client-bundle guards clean. The mocha suites behind the fixed build
have not executed yet; the next run is what shows them.

Thanks to Tuphal, KhaoulaMaleh and xet7 !
2026-08-07 18:20:23 +03:00
Lauri Ojansivu 3c7926662c Snap: fix the three failures that left v10.71 without armhf, s390x, ppc64el, riscv64 and gantt-gpl snaps. 2026-08-07 15:04:39 +03:00
Lauri Ojansivu 9773312176 Updated ChangeLog. 2026-08-07 13:58:47 +03:00
Lauri Ojansivu 6cada892d9 Upgrade to jquery 4.
Thanks to jquery developers !
2026-08-07 13:44:12 +03:00
Lauri Ojansivu f053619d80 Merge remote-tracking branch 'remotes/origin/dependabot/npm_and_yarn/jquery-4.0.0' 2026-08-07 13:34:14 +03:00
Lauri Ojansivu 4063cd20ee Merge remote-tracking branch 'remotes/origin/dependabot/npm_and_yarn/babel/parser-8.0.4' 2026-08-07 13:30:46 +03:00
Lauri Ojansivu a74aee2e45 Updated translations. 2026-08-07 13:27:14 +03:00
Lauri Ojansivu e30eb57e1a Updated dependencies.
Thanks to developers of dependencies !
2026-08-07 13:22:20 +03:00
Lauri Ojansivu 0878de010e Updated rspack and other dependencies.
Thanks to developers of dependencies !
2026-08-07 13:15:19 +03:00
Lauri Ojansivu 65984b912e Upgraded to Meteor 3.5.1.
Thanks to Meteor developers !
2026-08-07 13:10:42 +03:00
Lauri Ojansivu a7f840be48 CLAUDE.md: drop the do-not-push standing rule.
Remove the 'commit only, do not push' guidance from both the maintainer-mode
intro and General practices; the maintainer decides when to push, directly on
the branch (no PR, no branches).
2026-08-06 08:02:35 +03:00
Lauri Ojansivu 2f29b56f36 CHANGELOG: record the snap-launchpad LP_CREDENTIALS false-positive fix under Upcoming. 2026-08-06 08:01:35 +03:00
Lauri Ojansivu 8ebeb85832 snap-launchpad: stop misreporting working LP_CREDENTIALS on every Stopped Launchpad build.
When a Launchpad remote build ends as 'Stopped' with no .snap, the job scans
the build log to tell a real credential failure from a build cancelled mid-run
(an OOM in the Meteor npm install, a build-farm reset). The scan pattern was
'unauthoriz|401|403', and a bare 401/403 matches any three digits in a large
build log - a package size, a hash, an 'attempt 4/6' line - so it fired on
almost every Stopped build and told the maintainer to re-create credentials
that were working: the build had reached 'Building:', which needs valid
credentials. The pattern now matches those codes only in an HTTP-error context
(HTTP Error 401, 403 Forbidden) or an explicit phrase (invalid credentials,
not logged in, bad credentials), so the credential hint appears only for a real
authorization failure.
2026-08-06 08:00:37 +03:00
github-actions[bot] 5337b17ec6 Bump versions for v10.71 2026-08-06 02:56:05 +00:00
Lauri Ojansivu 2f79371fc3 Prepare v10.71 release 2026-08-06 05:54:53 +03:00
Lauri Ojansivu 210233d8bf Updated ChangeLog. 2026-08-06 05:51:39 +03:00
Lauri Ojansivu c09e538fea Make the release scripts run on macOS bash 3.2.
releases/release-all.sh and releases/version.sh read a command's output
into an array with `mapfile`. `mapfile` (a.k.a. `readarray`) is a bash 4+
builtin, and macOS still ships bash 3.2, so `./releases/release-all.sh`
failed on a Mac with `mapfile: command not found` and could not trigger a
release from there. `brew install mapfile` finds nothing because mapfile
is a shell builtin, not a program.

Each `mapfile -t VAR < <(cmd)` is replaced with the portable
`VAR=(); while IFS= read -r line; do VAR+=("$line"); done < <(cmd)`, which
builds the same array on bash 3.2 and on the bash 5 the Ubuntu release
runner uses. Converted: release-all.sh's RELEASED version list, and
version.sh's three reads - the Node.js files, the MongoDB files, and the
two newest CHANGELOG release lines.

Thanks to xet7 !
2026-08-06 05:49:36 +03:00
Lauri Ojansivu 97ce77350c Updated ChangeLog. 2026-08-06 04:47:24 +03:00
Lauri Ojansivu 354d356a0f Take the MongoDB Database Tools from wekan/mongo-tools-patches.
Every place that downloaded bsondump, mongodump, mongoexport, mongofiles,
mongoimport, mongorestore, mongostat and mongotop from wekan/mongo-tools now
downloads them from wekan/mongo-tools-patches: the amd64 base bundle, the
per-arch overwrite in the Linux bundles and in repack-bundle-for-arch.sh, the
Windows and macOS bundle jobs, check-arch-binaries.sh, require-binaries.sh,
test-download-urls.sh, the Dockerfile, both snapcraft files, the Flatpak and
AppImage workflows, the Sandstorm build-deps note and the CPU-platforms docs.

wekan/mongo-tools was a fork of a large Go project that changed none of its
source - its six commits were all the build workflow. wekan/mongo-tools-patches
keeps that build and drops the fork: it clones the newest upstream
mongodb/mongo-tools release, applies the patches in dist/ (there are none yet,
which is the honest state of it), and publishes the same <tool>-<arch>[.exe]
assets with a .sha256sum beside each - the same model wekan/node-patches
replaced the wekan/node fork with.

Nothing else changes: the asset names, the per-tool tolerance for an
architecture that has no binary, and the checksum verification are what they
were, because the build script came across unchanged.

The new checkout is excluded from git and from Meteor's scan the same way
node-patches and the old mongo-tools clone are.

Thanks to xet7 !
2026-08-06 04:47:24 +03:00
github-actions[bot] 22c4a710f7 Bump versions for v10.70 2026-08-05 18:28:57 +00:00
Lauri Ojansivu 74fd91eba4 Prepare v10.70 release 2026-08-05 21:28:01 +03:00
Lauri Ojansivu 8df11cf8f9 Updated ChangeLog. 2026-08-05 21:22:07 +03:00
Lauri Ojansivu b9eff391b9 Take Node.js from official, then unofficial, then wekan/node-patches.
WeKan's bundle .zip, Docker image and snap now resolve their Node.js from
three sources in order - https://nodejs.org/dist/ , then
https://unofficial-builds.nodejs.org/download/release/ , then the
wekan/node-patches releases - instead of only from the retired wekan/node
fork. A platform none of the three publishes a Node.js for is NOT BUILT that
release rather than failing it, and returns by itself on the first run after
one appears. Sandstorm is amd64 only and untouched.

releases/resolve-node-source.sh is the one place that order and the
platform-name mapping live; the bundle jobs, the extra-architecture preflight
and the Dockerfile all ask it, so the image and the .zip of one CPU cannot be
built on Node.js from different places. The snap takes its node out of the
bundle, so it follows without a change of its own.

Thanks to xet7 !
2026-08-05 21:21:51 +03:00
Lauri Ojansivu 1fb7674f84 Updated ChangeLog. 2026-08-05 18:02:31 +03:00
Lauri Ojansivu 59e54e384a win32/mac-x64: resolve the fork version, and cross-build ia32 native modules against the fork's node.lib.
The win32 and mac-x64 preflights checked .../releases/download/${NODE_VERSION}/…
with NODE_VERSION=24 (the bare MAJOR), which 404s - the fork tags by full version
(v24.19.0) - so both jobs ALWAYS skipped. They now resolve the newest v24.x fork
tag carrying the asset via a shared helper, releases/resolve-fork-node-tag.sh
(authenticated with the workflow GITHUB_TOKEN, so the GitHub API is not
rate-limited); the win32 preflight hands the resolved node_full to the rebuild.

And the win32 native-module rebuild is completed. npm_config_arch=ia32 alone was
not enough: an ia32 addon must LINK against an ia32 node.lib, and nodejs.org
dropped 32-bit Windows in Node 23, so node-gyp cannot fetch win-x86/node.lib. The
rebuild now assembles a nodedir node-gyp CAN use - node headers (arch-independent,
from nodejs.org) plus the fork's own node-win32.lib (now published beside
node-win32.exe) at the two paths node-gyp looks in - and points node-gyp at it
with npm_config_nodedir, so bcrypt cross-builds to ia32 against the fork's Node.

(The inline python that resolved the tag broke YAML - a multi-line heredoc cannot
be indented into a `run: |` block - which is why it is a helper script, reused by
both preflights and mirroring embed-verified-node.sh / check-arch-binaries.sh.)

Thanks to xet7 !
2026-08-05 17:59:39 +03:00
Lauri Ojansivu 1c4e05158d Updated ChangeLog. 2026-08-05 17:38:53 +03:00
Lauri Ojansivu b46acc47c5 Extra-arch bundles: make ppc64le and riscv64 best-effort too, so an emulated CPU that crashes cannot fail the release.
Every extra arch is built by running the fork's target-CPU node UNDER QEMU to
rebuild the native modules, and qemu-user does not run every Node.js binary
perfectly. ppc64le crashed at node startup deserializing its V8 snapshot:

    # Check failed: IsFreeSpaceOrFiller(filler).
    ... v8::Isolate::Initialize ...
    qemu: uncaught target signal 5 (Trace/breakpoint trap) - core dumped
    install-node-for-arch.sh: line 144: npm install ... Error 133

so the ppc64le leg failed. With ppc64le (and riscv64) still marked REQUIRED, one
such emulated-CPU failure failed the whole build-extra-arches matrix job.

They join s390x/i386/armhf/armv7/loong64 as best-effort (optional: true): a leg
that cannot run this release SKIPS with a warning instead of failing, so the
matrix never fails on one exotic CPU, the docker job (which now builds only the
bundles that landed) is never dragged down, and the release ships whatever built.
amd64 and arm64 (native, not in this matrix) remain the required core. ppc64le
returns the release it runs cleanly again - its qemu/snapshot crash is a
node-fork/emulation issue to chase separately, not something this bundle build can
fix inline.

Thanks to xet7 !
2026-08-05 17:38:53 +03:00
Lauri Ojansivu d093486766 Native bundles: authenticate the fork-version lookup, tolerate a missing checksum, and cross-build win32 ia32.
Three separate native-bundle failures in one release run:

* mac-arm64 failed the major->tag resolution. embed-verified-node.sh and
  check-arch-binaries.sh call api.github.com to find the fork's newest v24.x tag,
  but no step passed a token, so the call was UNauthenticated - 60 requests/hour
  per IP, shared across runners - and 403-rate-limited intermittently. That is why
  amd64 (which ran first) resolved fine while mac-arm64 (later) did not. A
  workflow-level GITHUB_TOKEN: ${{ github.token }} authenticates every such call
  (5000/hour), fixing the native jobs and the extra-arch preflight alike.

* win64 failed because the fork published node-win64.exe WITHOUT its
  node-win64.exe.sha256sum, and the embed helper 404'd on the sidecar. The
  checksum is a belt-and-suspenders check on top of an already-authenticated HTTPS
  download from the fork, so a MISSING one is now a warning and the binary ships
  UNVERIFIED (a checksum that is present and does NOT match is still fatal). This
  matches how check-arch-binaries.sh already treats a missing fork checksum.

* win32 failed at setup-node: "Unable to find Node version '24' for platform win32
  and architecture x86" - there is NO 32-bit Windows Node 24 (the reason the fork
  exists). It now runs the runner's x64 Node to DRIVE node-gyp and cross-builds
  the native modules (bcrypt) to ia32 via npm_config_arch=ia32; the shipped
  node.exe is still the fork's 32-bit node-win32.exe. tests/releaseBundleCoverage
  is updated for the ia32 cross-build.

Thanks to xet7 !
2026-08-05 17:38:53 +03:00
Lauri Ojansivu 9d9d8535b3 Updated ChangeLog. 2026-08-05 17:38:53 +03:00
Lauri Ojansivu 0aa74076a2 docker: build the image for whatever bundles landed, so one failed exotic arch never skips it.
The docker job `needs: build-extra-arches`, a matrix. When any leg failed - a
single exotic CPU like ppc64le - the whole matrix job was "failed", and a job
whose `needs` dependency failed is SKIPPED. So one failed extra-arch skipped the
ENTIRE multi-arch image, even though amd64/arm64 and the other arches were fine.

Two changes make the image resilient:

  * The job now runs on `always() && needs.release.result == 'success'`, so a
    partially-failed build-extra-arches no longer skips it. It still lists
    build-extra-arches in `needs`, so it waits for the successful legs to upload
    their bundles first.

  * A new "Decide which platforms" step probes which wekan-<version>-<arch>.zip
    bundles actually landed on the release. amd64 and arm64 are required (no image
    without them, and the release would have failed already); the exotic arches
    (ppc64le, s390x, riscv64, 386, arm/v7) are included only if their bundle is
    present, and a missing one is a ::warning that drops just that platform. The
    decided set drives the wait loop, buildx --platform "${PLATFORMS}" and the
    push-verify want="${WANT}" from one place, so they always agree.

The result: a failed or best-effort-skipped exotic arch drops only itself from
the image and returns the next release that builds it; the image is never skipped
and never fails because of one CPU. tests/releaseDockerPlatforms.test.cjs and the
docker cross-check in tests/releaseSnapArches.test.cjs are updated for the
decided-set design (required amd64/arm64, optional exotics, loong64 excluded).

Thanks to xet7 !
2026-08-05 17:38:53 +03:00
github-actions[bot] b0990fa063 Bump versions for v10.69 2026-08-05 11:44:23 +00:00
Lauri Ojansivu e8134c4276 Prepare v10.69 release 2026-08-05 14:43:10 +03:00
Lauri Ojansivu 1345747020 Updated ChangeLog. 2026-08-04 23:23:22 +03:00
Lauri Ojansivu 96e3c94864 Updated ChangeLog. 2026-08-04 23:17:23 +03:00
Lauri Ojansivu 414594ce99 Native bundles: resolve the fork's full Node version from the pinned major, so the download URL is right.
build-amd64 (and the other native jobs) call
  embed-verified-node.sh .build/bundle/node node-x64 "$NODE_VERSION"
but $NODE_VERSION is the pinned MAJOR, '24', while the wekan/node fork tags its
releases by full version, v24.19.0. So the helper built the URL
  https://github.com/wekan/node/releases/download/24/node-x64
which 404s, and the amd64 bundle failed at the Node embed step.

embed-verified-node.sh now resolves a bare major to the newest fork tag v<major>.x
that actually carries the asset (the same GitHub-API walk check-arch-binaries.sh
uses), and takes a full tag (with or without the leading v) as given. If no
v<major>.x release has the asset it stops with a message naming the fork asset to
build, rather than 404-ing on a bad URL.

Note: this fixes the URL; a build still needs the fork to HAVE published the
asset. As of now the fork's v24.19.0 carries only node-mac-x64/node-win32.exe/
node-win64.exe - its node-x64/node-arm64/node-mac-arm64 and the Linux arches were
dropped when the "release all missing" run replaced the assets, so those bundles
cannot build until the fork republishes them (a wekan/node-side fix).

Thanks to xet7 !
2026-08-04 23:17:23 +03:00
github-actions[bot] d6d8170726 Bump versions for v10.68 2026-08-04 19:15:38 +00:00
Lauri Ojansivu 2ece549e21 Prepare v10.68 release 2026-08-04 22:14:00 +03:00
Lauri Ojansivu e632add20d Updated ChangeLog. 2026-08-04 22:10:46 +03:00
Lauri Ojansivu a461e0df39 Updated ChangeLog. 2026-08-04 21:37:17 +03:00
Lauri Ojansivu bff53e93ad Add the win32 and mac-x64 .zip bundles, so every wekan/node fork platform has a WeKan bundle.
The wekan/node fork builds Node.js for 13 platforms and WeKan takes its Node.js
only from the fork, so every one of those platforms should have a WeKan .zip
bundle. Twelve did; 32-bit Windows (win32) and Intel macOS (mac-x64) did not -
the fork builds node-win32.exe and node-mac-x64 (nodejs.org has neither a 32-bit
Windows nor, going forward, a place WeKan will take Intel macOS from), but there
was no job to turn them into bundles. This adds both.

build-win32 mirrors build-win64 on a windows runner, with two differences: it
installs a 32-bit (x86) Node via setup-node so the native modules it rebuilds
(bcrypt) are ia32, matching the fork's 32-bit node.exe the bundle ships, and it
takes ferretdb-win32.exe. build-mac-x64 mirrors build-mac-arm64 on a macos-13
Intel runner, taking node-mac-x64 and ferretdb-mac-amd64.

Both are BEST-EFFORT: a preflight step checks whether the fork has published the
platform's node binary for this version and, if not, SKIPS the job with a warning
instead of a red failure - the fork builds these on its own schedule, and the
bundle appears the run after the fork publishes the binary. Every build step is
gated on that skip. release-notes waits for both so their provenance rows land in
the table. tests/releaseBundleCoverage.test.cjs pins that all six native fork
assets and all seven extra-arch legs map to a bundle; the cpu-exec and snap-arch
guards learn about the two new non-Linux bundles and the armv7 leg.

Thanks to xet7 !
2026-08-04 21:37:17 +03:00
Lauri Ojansivu 41d4eaf7e8 Add the armv7 .zip bundle: the wekan/node fork builds node-armv7, so it gets its own bundle.
The wekan/node fork builds a generic ARMv7 Node.js (node-armv7) as well as
Debian's hard-float one (node-armhf); each is a real fork platform, so each gets
its own WeKan .zip bundle. This adds an armv7 leg to build-extra-arches: it runs
in the same linux/arm/v7 emulated container as armhf, takes its Node.js from the
fork's node-armv7, and shares FerretDB's armhf binary (FerretDB has no distinct
armv7). It is best-effort (optional), like the other 32-bit ARM/x86 bundles - if
the fork's node-armv7 is ever absent for this major it skips rather than failing
the matrix job. The result is wekan-<version>-armv7.zip alongside the armhf one.
tests/releaseArchSkipAndBaseAttach.test.cjs pins armv7 as a best-effort matrix
entry.

Thanks to xet7 !
2026-08-04 21:37:17 +03:00
Lauri Ojansivu e9171ff7d1 Extra-arch bundles and the Docker image take Node.js only from the wekan/node fork.
WeKan now takes its Node.js from the wekan/node fork for EVERY platform and from
nowhere else - the .zip bundles, the emulated cross-builds and the multi-arch
Docker image alike. The reason is control: the fork is Node built from source, so
a Node bug can be PATCHED and rebuilt, which cannot be done with nodejs.org's or
unofficial-builds' opaque binaries, and one source means a set of artifacts is
never half built on one Node.js and half on another depending on the CPU. The
fork already covers the CPUs the other two do not build at all (32-bit x86/ARM,
loong64, ...), so it is the only source that fits every platform.

check-arch-binaries.sh no longer walks nodejs.org -> unofficial-builds -> fork;
it lists the fork's OWN releases for this major, newest first, and takes the
newest that carries node-<arch>, verifying it against that release's
node-<arch>.sha256sum. install-node-for-arch.sh drops the official/unofficial
tarball branch and always installs the fork's bare node binary (grafting npm -
arch-independent JavaScript - from the official amd64 tarball, a build-time tool,
not the shipped node). The Dockerfile maps every TARGETARCH to a fork asset
(node-x64/arm64/ppc64le/s390x/riscv64/i386/armhf) and installs it the same way.

s390x becomes best-effort (optional) like i386/armhf/loong64: the fork has not
published node-s390x for this major yet, so the preflight SKIPS it - a warning,
exit 0 - instead of failing the whole matrix job and skipping docker with it, and
it returns on its own the release after the fork publishes node-s390x. ppc64le
and riscv64 stay required - the fork builds them reliably.
tests/releaseArchSkipAndBaseAttach.test.cjs pins the fork-only sourcing (no
official/unofficial fallback for the node binary) and the s390x skip.

Thanks to xet7 !
2026-08-04 21:37:17 +03:00
Lauri Ojansivu d71f030527 Fix the docker release verify: read the CPU variant so linux/arm/v7 is not misread as linux/arm.
The docker job built and pushed the multi-arch image for all seven platforms
correctly; the step AFTER it, which inspects each registry's manifest to prove
every platform actually arrived, then failed the run over a manifest that was
right.

buildx builds linux/arm/v7, but a registry records that manifest as architecture
"arm" with variant "v7" in a separate field. The verify step's imagetools
--format printed only `{{.Platform.OS}}/{{.Platform.Architecture}}`, so the entry
read back as bare "linux/arm", and the check for "linux/arm/v7" in want= never
matched it - "wekanteam/wekan:v10.67 has no linux/arm/v7 in its manifest (it has:
... linux/arm ...)". The image was complete; the check was blind to the variant.

The --format now appends `/{{.Platform.Variant}}` when a variant is present, so
32-bit ARM reads back as linux/arm/v7, and a trailing /v8 is normalised away so
arm64 (which a registry may record as arm64/v8) still matches want=, which names
it the way everyone writes it, linux/arm64. tests/releaseDockerPlatforms.test.cjs
pins the variant-aware format and the /v8 normalisation so an OS/arch-only format
cannot come back.

Thanks to xet7 !
2026-08-04 21:37:17 +03:00
Lauri Ojansivu ac899ece2d Native bundles take Node.js from the wekan/node fork, not nodejs.org.
WeKan takes its Node.js from the wekan/node fork for EVERY platform now, not
only the arches nobody else builds. The reason is control: the fork is Node
built from source, so a Node bug can be patched and rebuilt - which cannot be
done with nodejs.org's opaque binaries - and one source means a set of bundles
is never half built on one Node.js and half on another. The fork also already
covers the arches nodejs.org and unofficial-builds do not build at all (32-bit
x86/ARM, loong64, win32), so it is the only source that fits every platform.

The four native bundles (amd64, arm64, win64, mac-arm64) embedded a verified
nodejs.org tarball; embed-verified-node.sh now downloads the fork's bare
node-<asset> binary (node-x64 / node-arm64 / node-win64.exe / node-mac-arm64)
and verifies it against the node-<asset>.sha256sum the fork publishes beside it.
That is all a bundle needs - it ships the node binary and runs `node main.js`;
the BUILD still uses the runner's own node+npm, so no npm is grafted here (the
Docker image and the emulated cross-builds, which run npm, graft it separately).
Provenance records source wekan/node and the fork's SHA256.
tests/releaseNodeVerified.test.cjs pins the fork source for all four.

The extra-arch bundles, the Docker image and a new win32 bundle move to the fork
too, in following commits.
2026-08-04 21:37:17 +03:00
github-actions[bot] f1eb4ce64e Bump versions for v10.67 2026-08-04 17:22:36 +00:00
Lauri Ojansivu 67a23e4a15 Prepare v10.67 release 2026-08-04 20:20:28 +03:00
Lauri Ojansivu 78f4955cad Updated ChangeLog.
Repoint the docker 386/arm/v7 link after the v10.66 version-bump rebase:
a0f573bce -> 703527a322 .
2026-08-04 20:18:18 +03:00
Lauri Ojansivu 77efd38dac Updated ChangeLog.
For commit a0f573bce4bbd06cb5790a0888438ef611152144 .
2026-08-04 20:16:39 +03:00
Lauri Ojansivu 703527a322 docker: add linux/386 and linux/arm/v7 on a debian:trixie base, Node from the fork.
Adds two Docker platforms that were shipping as .zip bundles only.

Base image: ubuntu:26.04 -> debian:trixie. Ubuntu publishes no linux/386
image (which is why 386 could never build); Debian does, and debian:trixie
carries every arch this image targets - amd64, arm64, 386, arm/v7, ppc64le,
riscv64, s390x - so one base covers them all, and it is the same base the
per-arch .zip bundles already build in. (The Dockerfile installs no MongoDB
server - every arch defaults to the bundle's FerretDB - so nothing here is
Ubuntu-specific; sha checks switched from `shasum` to `sha256sum`, which is
coreutils and always present.)

Node.js: nodejs.org and unofficial-builds ship no Node 24 for 32-bit x86 or
ARM, but the wekan/node fork does - as a BARE node binary (node-i386 /
node-armhf) plus a .sha256sum, not a tarball. A new `fork` branch in the arch
case downloads that binary, verifies it against its .sha256sum, and grafts npm
+ npx from the official amd64 tarball (npm is arch-independent JavaScript that
runs on the fork's node). Debian's 32-bit ARM port is armhf (ARMv7 VFPv3-D16),
which is what linux/arm/v7 runs, so node-armhf is the match.

linux/386 and linux/arm/v7 go back into the buildx --platform list and the
want= verify list. linux/loong64 stays out: no Docker base publishes it and the
registries do not agree on its manifest yet. tests/releaseDockerPlatforms.test.cjs
now checks the debian base, that 386/arm/v7 are in and loong64 out, and that
every built platform has a matching branch in the Dockerfile arch case.

NOT built here (no buildx/emulation) - confirm on CI. Depends on the fork having
published node-i386 / node-armhf and the bundle having wekan-<v>-{i386,armhf}.zip
for the release.
2026-08-04 20:16:39 +03:00
github-actions[bot] 57f2105bdd Bump versions for v10.66 2026-08-04 16:42:02 +00:00
Lauri Ojansivu c2e7dc2212 Prepare v10.66 release 2026-08-04 19:40:40 +03:00
Lauri Ojansivu fdfe131344 Updated ChangeLog.
For commit 370c091cf9 .
2026-08-04 19:38:40 +03:00
Lauri Ojansivu 370c091cf9 docker: drop linux/arm/v7 too - the Dockerfile has no Node 24 for armv7l.
With linux/386 gone, the multi-arch build got past the base image and then
failed inside the Dockerfile RUN on linux/arm/v7:
  + echo Unsupported architecture: arm
  + exit 1
Docker's TARGETARCH for linux/arm/v7 is `arm`, and the Dockerfile's arch case
handles only amd64/arm64/ppc64le/s390x/riscv64 - it deliberately has no `arm`
branch, because it installs Node.js from nodejs.org / unofficial-builds and
NEITHER ships a Node 24 for armv7l (the Dockerfile comment says so). But
linux/arm/v7 was still in the buildx --platform list, so the RUN hit the
"Unsupported architecture" guard and the whole build failed.

linux/arm/v7 is removed from --platform and from the want= verify list, joining
linux/386 and linux/loong64: all three ship as .zip bundles (armhf's Node comes
from the wekan/node fork there) but not as Docker images, because the image
sources its Node from nodejs.org/unofficial only. tests/releaseDockerPlatforms.test.cjs
now pins arm/v7 out of both lists too.
2026-08-04 19:37:49 +03:00
Lauri Ojansivu 45ba822041 Updated ChangeLog.
Repoint the docker linux/386 link after the v10.65 release rebase:
160c3eb6c -> a2d47df8c2 .
2026-08-04 18:58:36 +03:00
Lauri Ojansivu 15778b7a20 Updated ChangeLog.
For commit 160c3eb6c0a9726657af1e5413d7cb961f5df03b .
2026-08-04 18:57:57 +03:00
Lauri Ojansivu a2d47df8c2 docker: drop linux/386 from the image platforms - ubuntu:26.04 has no i386.
With loong64 no longer skipping build-extra-arches, the docker job ran again
for the first time in a while and failed at the base image:
  #2 ERROR: no match for platform in manifest: not found
  ubuntu:26.04: failed to resolve source metadata ... no match for platform
The image is FROM ubuntu:26.04, and Ubuntu publishes no i386 image, so
`docker buildx build --platform ...,linux/386,...` cannot resolve the base for
that one platform and the whole multi-arch build stops - the workflow's own
bundle-build comment already notes `docker run --platform linux/386
ubuntu:26.04` answers "no matching manifest".

linux/386 is removed from the build's --platform list and from the want= list
that verifies the pushed manifest, exactly as linux/loong64 already was: i386
ships as a .zip bundle (built on debian:trixie, which has 386) but not as a
Docker image. tests/releaseDockerPlatforms.test.cjs pins that 386 and loong64
are out of both lists, that the two lists match, and that the base is
ubuntu:26.04.
2026-08-04 18:57:57 +03:00
github-actions[bot] 5c7dfa7a8d Bump versions for v10.65 2026-08-04 15:11:02 +00:00
Lauri Ojansivu e729efa068 Prepare v10.65 release 2026-08-04 18:08:28 +03:00
Lauri Ojansivu efab4624b1 Updated ChangeLog.
Group the Upcoming entries by area, per CLAUDE.md - a subsection with more
than one entry is grouped, not loose: "Extra-architecture bundles" (loong64)
and "The Launchpad snap builds" (the flatten and the licence-hint fix). The
In short now accounts for the third entry in a closing clause.
2026-08-04 18:07:37 +03:00
Lauri Ojansivu d4ca61b95c Updated ChangeLog.
For commit 646c5db79d .
2026-08-04 18:01:02 +03:00
Lauri Ojansivu 646c5db79d snap-launchpad: stop the failure hint blaming the licence when it is already set.
The Launchpad project xet7-craft-remote-build is set to MIT / X/Expat, but the
snap-launchpad failure hint printed "check its Licence, 'None specified' ... set
the licence to MIT" on EVERY failure - so an s390x build that Launchpad stopped
for another reason read as a licence problem that was already fixed, and the
time went to re-checking a correct setting.

The hint now says the licence should already be MIT (re-set it only if the page
shows "None specified") and, for the real remaining case, explains that a
"Stopped" build with a BUILDING (not FAILED) log that ends mid-step is Launchpad
cancelling the build as it runs - typically an OOM in the memory-heavy Meteor
npm install, or a transient build-farm reset, both of which the three retries
already cover. The large-history push that also broke these builds is fixed
separately by the repo flatten (previous commit).
2026-08-04 18:00:06 +03:00
Lauri Ojansivu 0d1b61b8b1 Updated ChangeLog.
For commit 50e2e14d99 .
2026-08-04 17:52:21 +03:00
Lauri Ojansivu 50e2e14d99 snap-launchpad: flatten history before remote-build so the Launchpad push fits.
The ppc64el (and before it v10.55 riscv64) snap-launchpad build failed with
"Git operation failed with: Could not push 'HEAD' to git.launchpad.net/...
snapcraft-wekan-<hash>", ~4-5 minutes into the push, on every one of the three
attempts. snapcraft remote-build pushes the project's git repository to
Launchpad and builds it there; it rejects a SHALLOW clone (which is why the
checkout is fetch-depth: 0), but it does not need the history, and WeKan's full
history is large enough that the push times out or is refused mid-upload.

After the full checkout, the repo is now re-initialised as ONE commit of the
tagged tree: git rev-parse --is-shallow-repository is still false, so
remote-build accepts it, but the push carries the source tree (tens of MB)
instead of the whole history (hundreds). The snap version comes from
snapcraft.yaml (verified in the next step), not git-describe, so dropping the
history changes nothing about what is built. tests/releaseSnapLaunchpadFlatten.test.cjs
pins the flatten, its order, and that the checkout stays full-depth.
2026-08-04 17:51:10 +03:00
Lauri Ojansivu 62ee7ef17a Updated ChangeLog.
For commit 27da39f958 .
2026-08-04 17:31:28 +03:00
Lauri Ojansivu 27da39f958 Best-effort loong64: skip it when its base image is missing, so docker is not skipped.
The docker job needs build-extra-arches, and GitHub skips a job whose needed
job failed. build-extra-arches is a matrix, and its loong64 leg hard-failed:
no debian/ubuntu image publishes linux/loong64 (node-loong64 and
ferretdb-loong64 exist, the container does not), so the preflight stopped it.
One failed matrix leg makes the whole build-extra-arches job "failure", which
SKIPPED docker - and through docker the charts, ucs and nextcloud jobs that
need it. That is what left docker skipped in run #209.

loong64 is now optional: true, like i386 and armhf, and check-arch-binaries.sh
skips a best-effort arch whose BASE IMAGE is missing - not only one whose
Node.js is missing - with a warning and exit 0 emitting skip=true, gated the
same way every other best-effort skip is. So the matrix job succeeds and docker
runs; loong64 stays visible as a skip and returns to a real build the day a
linux/loong64 base image is published.
2026-08-04 17:29:17 +03:00
Lauri Ojansivu 44b05c3b75 Updated ChangeLog.
Repoint the release-job checkout-order link after the version-bump rebase:
b6f2e13e7 -> ea4a7a5eb8 .
2026-08-04 16:44:24 +03:00
Lauri Ojansivu 6e2ffa42e9 Updated ChangeLog.
For commit b6f2e13e71f14c81a54b2e74a3161d7e24f71a9d .
2026-08-04 16:43:08 +03:00
Lauri Ojansivu ea4a7a5eb8 Release job: check out BEFORE downloading bundles, so it stops deleting them.
The release job in v10.63 and v10.64 shipped with no amd64/arm64 bundle, and
every downstream snap/docker/AppImage job 404'd on wekan-<version>-amd64.zip.
The cause was not softprops (v10.63's guess) but the checkout: the job
downloaded the bundles into the workspace and THEN ran actions/checkout for the
provenance script. The workspace is not a git repo at that point, so checkout's
first act is "Deleting the contents of '<workspace>'" to make room for a fresh
clone - and it does this even with `clean: false`, which only skips the
`git clean` in an already-checked-out repo. The just-downloaded
wekan-<version>-{amd64,arm64}.zip were deleted before the attach step, which
then failed with "wekan-<version>-amd64.zip is missing or empty".

The checkout now runs FIRST, into the empty workspace, and the bundles are
downloaded on top of the checked-out tree, where nothing removes them.
tests/releaseBundlesSurviveCheckout.test.cjs now pins the ORDER (checkout
before the bundle download) rather than clean: false, which was never enough.

Thanks to xet7 !
2026-08-04 16:43:08 +03:00
github-actions[bot] f60982169c Bump versions for v10.64 2026-08-04 13:24:19 +00:00
Lauri Ojansivu 9300afaa03 Prepare v10.64 release 2026-08-04 16:22:46 +03:00
Lauri Ojansivu d725d9d5b4 Updated ChangeLog.
For commit c5a4092418 .
2026-08-04 16:14:03 +03:00
Lauri Ojansivu c5a4092418 Admin reports by URL: keep the subscription the autorun's count.set killed.
Opening an admin report by its address - /admin/problems/files typed,
bookmarked or refreshed - drew the column headers, "No results" and a
"1 / 1" pager over data that was plainly there, while the count method
reported five. 78b2f9ebc fixed one half of this (re-subscribe once the
login lands) but the report still came up empty, because the deeper cause
was untouched.

The onCreated autorun opens the pane and subscribes when problemsOpenPane
or the logged-in user changes. It called openReportPane()/loadReport()
directly in its reactive body, so it became reactive on cfg.count -
loadReport reads it through pageInfo - and loadReport's own count method
then did cfg.count.set(...). That re-ran the autorun, and a Meteor.subscribe
made inside an autorun is AUTO-CANCELLED when the autorun re-runs; the
re-run took the "same user, same pane" branch, did not re-subscribe, and
left the report with no subscription at all - "attachments in minimongo: 0".
Reached from the menu it worked, because switchMenu opens the pane from an
EVENT, not a computation, so that subscribe was never auto-managed; only
the URL path, which runs inside the autorun, hit it.

The autorun now reads only paneId and userId reactively and runs its body
inside Tracker.nonreactive, so cfg.count.set no longer re-runs it and the
subscribe is not auto-cancelled. The subscription's lifetime is managed by
loadReport (stops the previous) and a new onDestroyed (stops the last)
instead of the autorun. tests/adminReportsSubscriptionLifetime.test.cjs
pins the nonreactive body and the onDestroyed cleanup.

Thanks to xet7 !
2026-08-04 16:12:49 +03:00
Lauri Ojansivu 003eab9c29 Updated ChangeLog.
Repoint the Upcoming section's commit links after the version-bump rebase
moved the two code commits to new hashes:
081d50b45 -> e802d52eed and
17d7493d6 -> 55463f685a .
2026-08-04 15:58:24 +03:00
Lauri Ojansivu e6ae24e6e3 Updated ChangeLog.
For commit 17d7493d6f66b8363271cc5ae89ddee0d692fd9e .
2026-08-04 15:56:15 +03:00
Lauri Ojansivu 55463f685a Attach the base bundles robustly, and skip Node-less best-effort arches.
Two release-build failures from the v10.63 run (logs in ../log/wekan3):

1. The release shipped NO amd64/arm64 bundle, so every snap job (native,
   wekan-ondra, wekan-gantt-gpl) 404'd on wekan-10.63-amd64.zip. The base
   bundles were the only ones attached by the central `release` job with
   softprops `files:`, which does not fail on an unmatched file - so a run
   that produced no zip created a release with none and reported success,
   and softprops never listed the .sha256sum either. Every other bundle
   self-attaches with `gh release upload --clobber`, loud on a missing file
   and verified from the release side. The release job now attaches amd64
   and arm64 the same way - failing the release if a base bundle is missing
   instead of 404'ing ten downstream jobs - and checksums the exact bytes it
   attaches so the base bundles get a .sha256sum like the rest.

2. build-extra-arches failed on i386 and armhf: Node has no linux-x86 build
   at all, and no source builds Node 24 for armv7l, so there is nothing to
   build a bundle against. They are now marked best-effort (optional: true);
   when its Node.js is absent everywhere, check-arch-binaries.sh emits
   skip=true (a warning, exit 0) and the build steps are gated on it, so the
   arch is skipped rather than failing the run. It returns on its own the
   first release after wekan/node publishes node-i386 / node-armhf. A
   required arch whose Node.js is missing is still fatal.

tests/releaseArchSkipAndBaseAttach.test.cjs pins both; the base-attach guard
in tests/releaseBundlesSurviveCheckout.test.cjs is updated to the new path.

Thanks to xet7 !
2026-08-04 15:56:14 +03:00
Lauri Ojansivu 863b54d471 Updated ChangeLog.
For commit 081d50b45d3650d909915b92f84f118e7f65c25f .
2026-08-04 15:56:14 +03:00
Lauri Ojansivu e802d52eed Ship a named, checksummed Node.js and FerretDB in every native bundle.
The four native bundles (amd64, arm64, win64, mac-arm64) shipped Node.js
with `cp $(command -v node)` - the runner's node. It has no published
checksum, so the release provenance table could only say "no checksum
published" and nobody could tell WHICH Node.js build a platform carried -
the exact thing needed when a Node.js CVE lands. Worse, build-arm64 has
no setup-node, so ubuntu-24.04-arm's DEFAULT Node 22 went into the arm64
bundle instead of the pinned Node 24, silently.

Each native bundle now downloads the pinned Node.js for its OS+CPU from
nodejs.org and verifies it against SHASUMS256.txt
(releases/embed-verified-node.sh), and verifies FerretDB against the
.sha256sum wekan/FerretDB now publishes beside every binary. Provenance
records nodejs.org + the exact version + the verified SHA256; win64 and
mac-arm64 now upload provenance too, so every platform is traceable. The
emulated arches already did this via install-node-for-arch.sh.
tests/releaseNodeVerified.test.cjs pins that no native bundle goes back
to the runner's node or an unverified download.

Thanks to xet7 !
2026-08-04 15:56:14 +03:00
github-actions[bot] 1888836994 Bump versions for v10.63 2026-08-04 12:00:55 +00:00
Lauri Ojansivu d01ce5f41f Prepare v10.63 release 2026-08-04 14:59:00 +03:00
Lauri Ojansivu b31a57609e Updated ChangeLog. 2026-08-04 14:58:24 +03:00
Lauri Ojansivu 85d2ec6dad AppImage/Flatpak: keep the extension in the checksum name, so they are not rebuilt.
releases/expected-assets.sh looks for WeKan-<v>-<arch>.AppImage.sha256sum and
WeKan-<v>-<arch>.flatpak.sha256sum (asset name + .sha256sum, the same convention
the wekan-<v>-<arch>.zip.sha256sum bundles use). But the builds wrote
WeKan-<v>-<arch>.sha256sum - extension DROPPED - so "what the release is missing"
never found the checksum and rebuilt the AppImage and Flatpak on every run even
when they were already published. The checksum (and md5sum) now keep the
.AppImage / .flatpak extension, matching what the check expects.
2026-08-04 14:56:20 +03:00
github-actions[bot] 8175ba8549 Bump versions for v10.62 2026-08-04 11:40:48 +00:00
Lauri Ojansivu 4b8f2e0218 Prepare v10.62 release 2026-08-04 14:39:26 +03:00
Lauri Ojansivu 27e35c1236 Updated ChangeLog. 2026-08-04 14:37:04 +03:00
Lauri Ojansivu 809dc794f2 Release: stop checkout wiping the bundles, so the release actually ships them.
This is the root cause of the snap / docker / AppImage 404s on
wekan-<version>-amd64.zip. The `release` job downloads the per-arch bundles to
the workspace root, then checks the repo out (for the provenance script) BEFORE
creating the GitHub Release. actions/checkout defaults to clean: true, whose
`git clean -ffdx` deleted those untracked zips - so the "Create GitHub Release"
step's `files: wekan-<version>-{amd64,arm64}.zip` matched nothing. softprops does
not fail on unmatched files, so the release was created (job "success") WITH NO
BUNDLES, and every downstream job that downloads a bundle 404'd. The checkout now
sets clean: false, so the bundles survive and are attached.

Also: the snapcraft `wekan` part downloaded the bundle with a single wget that
treated a 404 as fatal, so it also broke on the brief CDN lag right after an
upload. It now retries (--tries/--retry-on-http-error), like the curl downloads
elsewhere in the release.

tests/releaseBundlesSurviveCheckout.test.cjs pins that a checkout after the
bundle download keeps clean: false; it fails on the pre-fix workflow.
2026-08-04 14:33:20 +03:00
github-actions[bot] f8dd16aaa5 Bump versions for v10.61 2026-08-04 10:58:01 +00:00
Lauri Ojansivu 928ec27b0e Prepare v10.61 release 2026-08-04 13:54:15 +03:00
Lauri Ojansivu a889ae1070 Test: releaseSnapArches expects no i386 snap (core24 has no i386 port).
The snap i386 platform was removed because core24 cannot build it; this guard
still pinned i386 as a snap platform and a Launchpad arch. Updated to expect the
six buildable snap platforms (amd64, arm64, armhf, ppc64el, riscv64, s390x) and
to treat i386 like loong64 - an arch that has a wekan-<arch>.zip BUNDLE but is
deliberately not a snap - with the reason recorded in the test.
2026-08-04 13:52:35 +03:00
Lauri Ojansivu c5be008a00 Updated ChangeLog. 2026-08-04 13:48:45 +03:00
Lauri Ojansivu 8790b66806 Scaffold a Debian type:base snap so i386 (etc.) can ship as a snap.
core24 has no i386 port, so `base: core24` cannot build an i386 snap (removed in
the previous commit). Debian still ships i386 and is glibc, so - unlike an
Alpine/musl base - WeKan's existing binaries run unchanged. Since the WeKan snap
only assembles the prebuilt wekan-<arch>.zip (and wekan-i386.zip already exists),
the only missing piece for an i386 snap is an i386-capable base.

snap-base-debian/ adds `wekan-base-debian13` (type: base), a trimmed Debian
trixie rootfs, starting with i386 - which builds NATIVELY on an amd64 runner (no
qemu, no Launchpad). It is isolated from the working core24 snapcraft.yaml so it
can never break the mainstream builds.

This is a documented SCAFFOLD, not a tested artefact: a custom base snap is
advanced snapcraft and needs a real `snapcraft pack` + local install + a WeKan
snap running on top before it is wired into CI or sent for the (manual) store
review. README.md carries the full plan, the local test loop, the publish/ACL
steps, and the known pitfalls.
2026-08-04 13:42:35 +03:00
Lauri Ojansivu bbc3578747 Release build: fix the win64, mac-arm64, extra-arch and snap jobs.
The v10.60 release job succeeded (the release-notes shell-injection fix landed),
but four of the per-platform build jobs failed, each for its own reason:

- build-win64: the "Check the binaries" step ran `set -euo pipefail` under the
  Windows default shell (PowerShell), which answered "A parameter cannot be found
  that matches parameter name 'euo'". It is a bash script; it now says
  `shell: bash`.

- build-mac-arm64: the zip step ran `sha256sum`, which does not exist on macOS
  (that is the GNU coreutils name), so the job died with exit 127. Use
  `shasum -a 256`, the BSD/macOS spelling, which writes the same format.

- build-extra-arches: the job downloaded wekan-<version>-amd64.zip but never
  unpacked it, so the container mounted an EMPTY /bundle and the native-module
  rebuild died with "ENOENT ... /bundle/programs/server/package.json" (exit 254).
  Added the unzip the arm64/win64/mac-arm64 jobs already do.

- snap-native / snap-launchpad: snapcraft.yaml declared an i386 platform, but
  core24 (Ubuntu 24.04) has no i386 port, so snapcraft rejected it with "trying
  to build snap in one of ['i386'], but none of these build architectures are
  supported" - a PARSE error that failed EVERY snap build, not only i386's.
  Removed the i386 platform and its snap-launchpad matrix entry; i386 users are
  served by the .deb and AppImage, and an i386 snap is not buildable on a modern
  base.

loong64 still reports its documented "debian:trixie has no linux/loong64 image"
gap (there is no loong64 userland to rebuild in), which is intentional and left
visible.
2026-08-04 13:31:01 +03:00
Lauri Ojansivu 43ff0e7a5c Docker + AppImage: skip gracefully when the release bundle is not published yet.
These jobs do not build WeKan from source — they download the prebuilt release
zip (wekan-<version>-<arch>.zip) that release-all.yml publishes, and fail hard
if it is missing. So they failed whenever they ran before the release was
complete: dockerimage.yml / docker-publish.yml on a push or a tag pushed early
in the release, and AppImage.yml when release-all-missing asked it to wrap a
version whose base bundle was not published (`gh release download` → "no assets
to download"). The v10.59 release itself had failed (the release-notes shell
injection, fixed separately), so every one of these downstream jobs 404'd.

Each now checks whether the release asset it needs is published and SKIPS with a
notice (a green run) when it is not, instead of failing:
- dockerimage.yml / docker-publish.yml gate the image build on the pinned
  version's amd64 zip being on the release;
- AppImage.yml flips its per-arch BUILD_THIS off when that arch's bundle is
  absent, and its publish job treats "no AppImage, and no base bundle either" as
  nothing-to-do rather than an error.

The real images/AppImages are still built by release-all.yml's own jobs once the
release is up; these standalone/gap-filling runs simply stop failing in the
meantime.
2026-08-04 13:12:30 +03:00
github-actions[bot] bd32ae644a Bump versions for v10.60 2026-08-04 09:46:56 +00:00
Lauri Ojansivu da5496260e Prepare v10.60 release 2026-08-04 12:45:40 +03:00
Lauri Ojansivu d6ff3909a0 Updated ChangeLog. 2026-08-04 12:40:13 +03:00
Lauri Ojansivu 541dc8b7b6 Release notes: pass the CHANGELOG through env, not inline into the shell.
The release and release-notes jobs built the GitHub Release body with
`printf '%s\n' "${{ needs.prepare.outputs.changelog }}"`. Interpolated inline,
the CHANGELOG becomes part of the shell source before bash parses it, so every
backtick in it runs as a command. The v10.59 notes are full of `code` spans, so
the release job failed with "Incorrect: command not found",
"loginFailureDecision.js: Permission denied", "POST: command not found" and
published no release. The changelog now reaches both steps through the
environment (env: CHANGELOG), and the scripts use "$CHANGELOG", where the shell
treats it as data and never parses its backticks, $( ) or quotes.

tests/releaseNotesNoShellInjection.test.cjs pins that outputs.changelog is only
ever consumed as an env: assignment, never inline in a run: script; it fails on
both pre-fix printf lines.

Thanks to xet7.
2026-08-04 12:40:13 +03:00
github-actions[bot] ecba538c35 Bump versions for v10.59 2026-08-04 09:18:56 +00:00
Lauri Ojansivu 2662382aa3 Prepare v10.59 release 2026-08-04 12:17:59 +03:00
Lauri Ojansivu 23eaea752a Updated translations 2026-08-04 12:17:26 +03:00
Lauri Ojansivu c1ea40b64e Updated ChangeLog. 2026-08-04 12:09:37 +03:00
Lauri Ojansivu 3e8c5a89e2 Release build: check the repo out in the arm64, mac-arm64 and win64 jobs.
The binary pre-check `bash releases/require-binaries.sh`, wired into the build
jobs, needs the repo on disk, but three jobs did not have it there:

- build-arm64 and build-mac-arm64 download a prebuilt bundle artifact and never
  checked the repo out at all, so the "Check the binaries ..." step died with
  exit 127 ("releases/require-binaries.sh: No such file or directory") before a
  bundle was ever assembled. Each now checks the tag out first, before
  download-artifact drops the bundle into the same workspace.

- build-win64 checks the repo out into src/ (for start-wekan.bat and
  snapcraft.yaml) but called the script at the workspace root. It now calls
  src/releases/require-binaries.sh, the same src/ path its start-wekan.bat copy
  already uses.

tests/releaseBuildJobsCheckout.test.cjs pins, for every build job that runs
require-binaries.sh, that the job checks the repo out where the call looks for it
(root vs src/) and that a root checkout precedes download-artifact so it cannot
wipe the downloaded bundle. It fails on all three pre-fix breakages.

Thanks to xet7.
2026-08-04 12:09:37 +03:00
Lauri Ojansivu 642e5b300b Updated ChangeLog. 2026-08-04 12:09:37 +03:00
Lauri Ojansivu c3742f2ed7 Fix LockoutBleed: user enumeration and unthrottled password brute-force.
GHSA-2g94-9x3m-hv37. Two verified weaknesses on the login path chained into
account takeover, and both are fixed here.

1. The bundled wekan-accounts-lockout brute-force protection was completely
   inert. Its two Accounts.validateLoginAttempt hooks decided whether an attempt
   failed by comparing the error's reason string ('Incorrect password' /
   'User not found'), but Meteor's accounts-base ships ambiguousErrorMessages
   defaulting to true, so _handleError rewrites every credential failure to
   "Something went wrong. Please check your credentials." before those hooks run.
   The literals never matched, both hooks returned early, the failure counter
   was never incremented, and no account ever locked. The hooks now decide from
   the attempt's structural fields (new loginFailureDecision.js): any genuine
   password failure counts, while the benign 'no-2fa-code' step (password already
   correct, second factor still to come) never counts, so 2FA users are not
   locked out of their own sign-in.

2. The login path leaked which accounts exist by timing: a real user runs bcrypt
   (~50 ms) while a missing one returns in ~2 ms. A dummy bcrypt comparison
   against a fixed cost-10 hash (loginTimingDefense.js) equalises the missing-user
   path on the DDP login method (loginTimingNormalization.js, a handler that runs
   ahead of the built-in one and never authenticates) and on the REST endpoint.

The REST POST /users/login twin also enumerated by message (a distinct
"...not found." string) and never ran the lockout hooks at all. It now fails
missing-user and wrong-password identically, runs the timing equaliser, and
throttles failed attempts per client address (loginAttemptThrottle.js), keyed
safely behind HTTP_FORWARDED_COUNT, resetting on success.

Tests: loginFailureDecision, loginTimingDefense, loginAttemptThrottle and
loginBruteForceEnumerationWiring (four new plain-Node suites).

Thanks to NinjaGPT and xet7 !

Fixes GHSA-2g94-9x3m-hv37.
2026-08-04 12:09:37 +03:00
github-actions[bot] ffb2636024 Bump versions for v10.58 2026-08-03 23:45:37 +00:00
Lauri Ojansivu 852ecd7b16 Prepare v10.58 release 2026-08-04 02:44:39 +03:00
Lauri Ojansivu 9bd8c49502 Updated ChangeLog. 2026-08-04 02:31:05 +03:00
Lauri Ojansivu 18c6dcc68f Guard the other way a workflow fails before it starts.
`uses: ./.github/workflows/X.yml` fails at STARTUP - taking the calling
workflow down with it, so nothing runs and there is no job log to read - when
X.yml does not exist, has no `workflow_call` trigger, or is handed a `with:`
key it never declared. That is the same class of failure as the job-level
`if:` reading matrix, and just as invisible: the YAML is valid, and only
GitHub's loader objects.

It is worth pinning now because release-all-missing.yml calls AppImage.yml
and Flatpak.yml here, TSC's calls five workflows, and the `only` input they
all take was added by hand to each of them. One typo in a `with:` key would
stop the whole run with "error parsing called workflow".

Parsed by indentation rather than with js-yaml, which is not a dependency of
this repository. These files are written in a fixed shape, and the two things
this needs - which workflows a job calls with which `with:` keys, and which
inputs a workflow declares under workflow_call - are read from it directly.

Verified against all three shapes by breaking each in turn:

  passes 'onlyy' which AppImage.yml does not declare
  calls missing Nope.yml
  AppImage.yml has no workflow_call

and checked across every repository first - TSC, wekan, both variants, node,
FerretDB, mongo-tools and gitea - where all reusable-workflow calls already
match.

Thanks to xet7 !
2026-08-04 02:16:27 +03:00
Lauri Ojansivu 603a31cc9b Updated ChangeLog. 2026-08-04 02:13:55 +03:00
Lauri Ojansivu 76a4e9cf5b Updated ChangeLog. 2026-08-04 02:07:50 +03:00
Lauri Ojansivu dcdda5f0f8 A job-level if: cannot see matrix. Decide it in the job env instead.
The `only` filter I added to the build workflows was written as a job-level
condition:

    if: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.arch) }}

GitHub refuses to load a workflow that does this:

    Invalid workflow file
    (Line: 109, Col: 9): Unrecognized named-value: 'matrix'

`matrix` is available to a job's runs-on, env, name, container, services,
continue-on-error, timeout-minutes, strategy and steps - but NOT to
`jobs.<id>.if`, which is evaluated before the matrix is expanded. It looks
entirely reasonable, which is why it was written in five workflows in one
sitting.

It is worse than a job that does not run: a workflow that will not load takes
every workflow that CALLS it with it, so TSC's release-all-missing.yml failed
at startup with "error parsing called workflow" and built nothing at all.

The decision moves to the job's `env:`, which CAN see matrix, and every step
asks `if: ${{ env.BUILD_THIS == 'true' }}`. Steps that already had a
condition keep it, ANDed inside parentheses - matrix.emulated,
matrix.mode == 'windows' and the rest are unchanged.

Fixed in AppImage.yml and Flatpak.yml here, and in the same commit's worth of
changes in TSC (Deb, AppImage, Flatpak, Windows, Mac), wekan/node,
wekan-ondra and wekan-gantt-gpl. Twelve files.

tests/workflowExpressions.test.cjs is the guard, and it exists because a YAML
parser is perfectly happy with every one of these: the file is valid YAML and
only GitHub's expression evaluator rejects it, when the workflow is
dispatched. It pins that no job-level `if:` reads matrix or steps, that every
${{ }} is closed, and that a workflow declaring an `only` input actually
consults it - a filter that is declared and ignored would quietly build
everything, which is the failure that looks like success.

Verified by putting the bad line back: the guard fails with
"job-level if: cannot see matrix" and names AppImage.yml:107.

The closed-brace check counts differently than it first did, because three
real lines broke it: these workflows run docker with Go templates
(--format '{{range .Manifest.Manifests}}...'), which are full of `}}` that
close nothing of GitHub's. It strips complete expressions and looks for a
leftover opener instead of counting braces.

Thanks to xet7 !
2026-08-04 02:07:50 +03:00
Lauri Ojansivu c831a423ed Merge pull request #6569 from wekan/dependabot/npm_and_yarn/fast-uri-3.1.5
Bump fast-uri from 3.1.4 to 3.1.5
2026-08-04 02:07:30 +03:00
Lauri Ojansivu 72237e5db6 Merge pull request #6568 from wekan/dependabot/npm_and_yarn/postcss-8.5.25
Bump postcss from 8.5.22 to 8.5.25
2026-08-04 02:07:16 +03:00
Lauri Ojansivu 38b299b17f Merge pull request #6567 from wekan/dependabot/npm_and_yarn/socket.io-parser-4.2.7
Bump socket.io-parser from 4.2.6 to 4.2.7
2026-08-04 02:06:47 +03:00
Lauri Ojansivu d61e53d8a3 Merge pull request #6566 from wekan/dependabot/npm_and_yarn/brace-expansion-5.0.9
Bump brace-expansion from 5.0.8 to 5.0.9
2026-08-04 02:06:33 +03:00
Lauri Ojansivu 06236f1d97 Updated ChangeLog. 2026-08-04 01:55:14 +03:00
dependabot[bot] c560cb405e Bump fast-uri from 3.1.4 to 3.1.5
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 22:52:33 +00:00
dependabot[bot] 2dac7f4813 Bump postcss from 8.5.22 to 8.5.25
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.22 to 8.5.25.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.22...8.5.25)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.25
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 22:52:30 +00:00
dependabot[bot] 76f4c78573 Bump socket.io-parser from 4.2.6 to 4.2.7
Bumps [socket.io-parser](https://github.com/socketio/socket.io) from 4.2.6 to 4.2.7.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.6...socket.io-parser@4.2.7)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-version: 4.2.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 22:52:29 +00:00
dependabot[bot] a0209bdf96 Bump brace-expansion from 5.0.8 to 5.0.9
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.8 to 5.0.9.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.8...v5.0.9)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 22:52:27 +00:00
Lauri Ojansivu d62c4f3766 Add release-all-missing.yml: finish a release without remaking it.
release-all.yml is one run that bumps the version, tags, builds every
platform, and publishes Docker images, snaps, the website and the charts. It
is the right thing for MAKING a release and the wrong thing for FINISHING
one. When the v10.57 run ended with all six extra architectures missing -
four on a shell-quoting bug, two on a base image that is not built for their
CPU - there was no way to get those six except to run the whole thing again,
version bump and all.

This finishes a release that already exists. It never bumps a version, never
tags, and never touches Docker, snap, the website or the charts.

What makes it possible: only build-amd64 runs Meteor. Every other bundle
WeKan ships is that same bundle with its native modules rebuilt, its Node.js
swapped and its database binaries replaced. So this downloads the PUBLISHED
wekan-<v>-amd64.zip - verifying its checksum, since it is the bundle every
other architecture is cut from - and repacks it with
releases/repack-bundle-for-arch.sh, the same script release-all.yml's
build-extra-arches runs. A bundle added to a release months later is
therefore built exactly like the ones already on it.

It builds the six extra architectures and calls AppImage.yml and
Flatpak.yml, which are reusable workflows now and take the same `only`
filter.

It does NOT claim to build everything, and says which: amd64 is the Meteor
build, arm64/win64/mac-arm64 each need their own kind of runner and their own
steps, and the Sandstorm .spk is signed. When one of those is missing the run
names it and points at release-all.yml, as a warning rather than a failure.

What a complete release looks like is releases/expected-assets.sh, in one
place, with the kind of each asset saying which build makes it. Present means
the .sha256sum is there too - a bundle whose checksum upload failed is
half-published - except the .spk, which is signed rather than checksummed.

Verified against the real v10.57 release: every one of its five assets
matches a name this predicts, with nothing left unaccounted for. Three
states, through the plan's own logic:

  v10.57 as published   6 extra + 4 AppImage/Flatpak to build; amd64, arm64,
                        win64 and mac-arm64 reported as needing
                        release-all.yml (they predate the checksums, which is
                        noted in the script)
  only the 6 extra      extra=[all six], appimage=[], flatpak=[], nothing
  arches missing        manual - the case this exists for
  complete release      everything empty, nothing built

Thanks to xet7 !
2026-08-04 01:47:51 +03:00
Lauri Ojansivu 78b2f9ebcd Admin Panel reports: subscribe again once the login lands.
Opening a report BY ITS ADDRESS - /admin/problems/files typed, bookmarked or
refreshed - drew the column headers, "No results" and a "1 / 1" pager over
data that was plainly there. Reached by clicking the menu entry it worked,
so this only ever happened to the URL.

A full page load resumes the login from localStorage ASYNCHRONOUSLY, and the
route sets `problemsOpenPane` before that lands. The subscription was
therefore made with no user; the publication's isAdmin check answered
`this.ready()` with no rows; and nothing re-subscribed, because the autorun
that opens the pane did not depend on the user. The count METHOD, called
later from the same page, happily reported five - which is what made this
look like a publication bug rather than a timing one.

The autorun depends on Meteor.userId() now. openReportPane() returns early
when the pane is already open, so re-running it after the login would do
nothing at all - hence the second branch, which re-subscribes the report
that is already open now that there is a user to subscribe as.

Found by the e2e test's own diagnosis, added in the previous run for exactly
this purpose: "attachments in minimongo: 0; report_pages index: no
report-files index doc" - neither half arrived, which of the three
possibilities it lists is the one meaning the publication returned early or
never ran.

ALSO, a release-build bug of my own from earlier today:
build-extra-arches had no actions/checkout. It calls
releases/check-arch-binaries.sh and releases/record-provenance.sh and mounts
$(pwd)/releases into the container - none of which exist without one, so
every one of the six extra architectures would have stopped at "No such file
or directory". The checkout goes before the artifact download, because
actions/checkout cleans the workspace and would remove the bundle.

And releases/repack-bundle-for-arch.sh: the amd64 bundle turned into one
architecture's zip, lifted out of that job so it is one script rather than
one job's inline steps. Only build-amd64 runs Meteor; arm64, win64,
mac-arm64 and the six extra architectures are all this same bundle with its
native modules rebuilt, its Node.js swapped and its database binaries
replaced. That is what lets a release be completed one architecture at a
time without rebuilding the rest, and it had better be the same script or a
bundle added later would differ from the ones beside it.

Thanks to xet7 !
2026-08-04 01:27:49 +03:00
Lauri Ojansivu d80d8de1a0 Mobile All Boards: the left menu must be the width of its column.
On a phone the left menu was drawn 260px wide inside a 157.5px grid track
and lay OVER the board icons. The grid itself was right: 42% of a 375px
phone is 157.5, plus the 8px gap puts the board column at x=165.5, which is
exactly where the boards start. The menu simply did not fit in its track.

260px is --wekan-left-menu-width, the DESKTOP width the drag-grip sets -
leftMenu.js does not even offer the grip below 800px. The menu carried it on
a phone anyway, because the phone rules never said otherwise and the
`max-width: 100%` on the base rule does not do what its comment claimed. A
percentage width on a grid item does not resolve against the track, so it
capped nothing. That is not a browser quirk: Chromium, Firefox and WebKit
all drew the same 260px.

`width: auto` in the phone rules instead. A grid item with an auto width
stretches to its grid area (justify-self defaults to stretch), so it fills
the track exactly with no percentage to resolve. The base rule keeps its
max-width, which is still right where this menu is not a grid item, and its
comment now says what it does rather than what it was hoped to do.

Measured, not guessed: decoding the failing test's screenshot pixel by pixel
shows the menu background #f7f7f7 running from x=3 to x=255 with the blue
board tiles painted on top of it from x=170, and the page background #dedede
only from x=260.

Also: when the Files report draws its headers and then "No results", the
test now says which half is missing. A row needs the page rows AND the
report_pages index that names them; the server count it already checks
proves only that the query matches. Rows but no index, index but no rows,
and neither are three different bugs that all show up as "element(s) not
found". That report is still failing - the server counts 5 and the pane
renders none, with every static link between them verified correct - so the
next run should name the layer instead of another read-through.

Thanks to xet7 !
2026-08-04 00:38:34 +03:00
Lauri Ojansivu aab81e8ffd Make the DnsBleed response stub a real stream, like IncomingMessage is.
Seven of the DnsBleed mocha tests failed with "Timeout of 2000ms exceeded" -
every one of the ALLOW cases, while every block case passed, which is what
hid it.

The stub was at fault, not fetchSafe. It faked http.IncomingMessage with a
bare EventEmitter that emitted 'data' and 'end' from a process.nextTick,
into the void if nothing was listening yet. No real response does that: an
IncomingMessage is a PAUSED Readable that buffers its body until a listener
attaches or it is resumed, so reading late cannot lose data.

That only started to matter when fetchSafe was split into resolving the
response and then reading it, which the redirect handling needs - it has to
see the status before deciding whether to read a body at all. The nextTick
queue drains BEFORE promise microtasks, so the fake had already fired 'end'
by the time the awaited continuation attached its listeners.

Verified rather than assumed, both halves:

  - against a real server: a request whose 'data' listener is attached two
    nextTicks, a setImmediate and 20ms after the response callback still
    receives the whole body.
  - against the real fetchSafe, driven by each stub in turn: the old
    EventEmitter stub times out after 2s; the Readable-backed one returns
    status=200, the body, the pinned IP 93.184.216.34 and Host
    example.com.

The security assertions still hold through the new stub, checked the same
way: a 302 with the default maxRedirects:0 is refused, a host resolving to
127.0.0.1 is refused with ZERO requests sent, a followed redirect pins each
hop to its own resolved IP, the abandoned hop reports destroyed=true, and
Authorization and Cookie are dropped on the cross-origin hop.

destroy()/destroyed are now the stream's own rather than a hand-rolled flag,
so "fetchSafe abandoned this body" is asserted against real stream
behaviour.

Thanks to xet7 !
2026-08-04 00:24:05 +03:00
Lauri Ojansivu f47018c455 Ignore the gitea fork checkout, in both .gitignore and .meteorignore.
wekan/gitea, forked from go-gitea/gitea, is cloned to gitea/ here: a whole
Go + web application, 1,401 directories, 6,209 files, 384 MB. Nothing in
WeKan imports it.

Left out of .meteorignore it would have gone straight back into the build
scan - about a fifth again of everything Meteor legitimately walks in this
repository, and that scan is what ran the build out of memory. Left out of
.gitignore it would fill git status.

The guard added for exactly this caught it: with the entry missing from
.meteorignore, `every git repository cloned in here is excluded from BOTH
files` fails with

  cloned repositories Meteor would still walk - add them to .meteorignore
  actual: [ 'gitea' ]

It is named in the explicit list as well as found by the walk, because the
walk can only check a clone that is present - a checkout without gitea/ must
still not lose the entry.

Thanks to xet7 !
2026-08-03 23:28:02 +03:00
Lauri Ojansivu 361e74cc5b Updated ChangeLog. 2026-08-03 23:06:15 +03:00
Lauri Ojansivu c6e3e2ef15 _build is the handoff to Meteor, not leftovers - do not ignore it.
Excluding /_build/ and /_build-local-test/ in .meteorignore was wrong and
broke the build, with an error that does not mention .meteorignore at all:

  error: Could not find mainModule for 'os' architecture:
  _build/main-prod/server-meteor.js
  Check the "meteor" section of your package.json file?

They look exactly like build output that should be ignored - they are
gitignored, and they are the first two entries of the 296 KB pattern list
that the heap snapshot turned up. They are not leftovers. rspack compiles
the app INTO _build/main-prod/ (and _build/test/ for a test run), and Meteor
then reads server-meteor.js and client-meteor.js from there as the
application's main modules: package.json names client/main.js and
server/main.js, and the rspack integration repoints mainModule at its own
output. Ignoring them hides the files Meteor is about to be handed.

They are 3 directories each, so there was nothing to win by excluding them
and a build to lose. The rest of the excludes - the Node.js fork checkout,
mongo-tools, TSC, the TSC website and the two variant WeKan checkouts - are
where the 5,860 directories were, and they stay.

The guard now asserts the OPPOSITE for these two, so the mistake cannot be
made again by anyone reading .gitignore and concluding the obvious thing.
The general "gitignored at the top level must be in .meteorignore" rule
gains them as documented exceptions: .gitignore happens to write them
unanchored today, so that rule skipped them by luck rather than by design,
and normalising .gitignore would have turned the guard itself into the thing
that breaks the build.

Thanks to xet7 !
2026-08-03 23:05:50 +03:00
Lauri Ojansivu 2be8defd28 Updated ChangeLog. 2026-08-03 22:58:53 +03:00
Lauri Ojansivu 062f9127a8 A cloned repository must be in both .gitignore and .meteorignore.
The guard added with the .meteorignore fix derived its list FROM .gitignore,
so it only caught a clone that had got half way: one added to NEITHER file
was invisible to it. That is the state every one of these arrived in, so it
is the state worth failing on.

The check walks the tree looking for a directory with a .git of its own -
existsSync, because a submodule's .git is a file, not a directory - rather
than comparing against a list, so a clone nobody thought to name is caught
too. Anything it finds must be ignored by git (asked of `git check-ignore`,
not of a hand-parsed .gitignore) and, when it is a top-level directory,
listed in .meteorignore. It does not descend into a repository it has
already found: that repository's own submodules ride along with it.

A directory with a .git of its own is another project. It is not WeKan's
source and it is not WeKan's history, so it belongs in .gitignore to keep
`git status` readable and in .meteorignore to keep the build from walking
it.

Verified by planting a directory with a .git in it: the guard fails and
names it.

Thanks to xet7 !
2026-08-03 22:58:39 +03:00
Lauri Ojansivu 4726a4c3e3 Updated ChangeLog. 2026-08-03 22:54:18 +03:00
Lauri Ojansivu cf178e5948 Stop Meteor scanning the other repositories cloned beside the app.
`meteor build` ran out of JavaScript heap, and raising --max-old-space-size
never helped because the ceiling was not the variable.

A heap snapshot taken as the build died says what it was: of a 4.1 GB heap,
980 MB was 14,267,543 IgnoreRule objects, 667 MB sliced strings, 308 MB
concatenated strings and 274 MB in 945 copies of one 296 KB pattern list -
about 2.5 GB of ignore machinery, and only 945 directories in.

Meteor builds one ignore matcher per directory it descends into, each
carrying the whole accumulated pattern list. There were 6,867 directories
under the app; roughly 1,000 of them were WeKan. The rest were repositories
cloned in beside it - the Node.js fork checkout (4,132 directories, 45,527
files, 2.3 GB), mongo-tools, TSC, the TSC website, two more WeKan checkouts
- plus Meteor's own _build/ and _build-local-test/ output, which it was
scanning as source to produce. All of them were in .gitignore, which Meteor
does not read; it reads .meteorignore, which listed only .tools/, FerretDB/
and tests/. So the cost was very nearly seven times what the snapshot
caught.

The two variant checkouts were the worst of them: wekan-ondra and
wekan-gantt-gpl contain client/, server/ and models/, and Meteor loads
server/ and client/ eagerly - a second and third copy of the whole app
pulled into the build.

Excluding all of it takes the scan back to WeKan itself.

Also: build.sh exited 0 while printing "ERROR: the WeKan build failed".
build_wekan returns 1, the menu called it bare and threw that away, so
anything driving it non-interactively saw a green run and a missing bundle.

tests/meteorignoreScanScope.test.cjs pins the excludes, that they are
anchored to the repo root rather than matching a directory of that name at
any depth, and - for the next clone dropped in here - fails on any new
top-level directory that .gitignore excludes while .meteorignore does not,
which is exactly how each of these arrived.

Thanks to xet7 !
2026-08-03 22:52:45 +03:00
Lauri Ojansivu 317b50b8a7 Read the heap snapshot: the build is recompiling an ignore list hundreds of times
The capped snapshot worked - 6 GB written at a 4096 MB heap - and
releases/analyse-heapsnapshot.py (added here) says what is in it. Of 4,120 MB:

    980 MB   14,267,543 x  object                IgnoreRule
    667 MB   21,869,662 x  sliced string
    308 MB   10,105,340 x  concatenated string
    274 MB          945 x  string   "_build/test/** _build/main-dev/** node_modules/** .babelrc ..."
    237 MB    millions  x  string   "t(?=$|\/$)", "s(?=$|\/$)", "e(?=$|\/$)" ...

Around 2.5 GB of the 4.1 GB heap is one thing: ignore-rule machinery. The
regexes are the `ignore` npm package's own rule compilation, IgnoreRule is its
rule class, and the 274 MB string is the pattern list it is compiling.

The arithmetic says what is happening. 274 MB across 945 copies is 296 KB per
copy - a pattern list of about 15,000 entries - and 14,267,543 rules across 945
constructions is 15,097 rules each. So a ~15,000-pattern ignore list is being
compiled from scratch 945 times, and the sliced and concatenated strings are the
paths it is built from. The repo has 3,030 tracked files and 104 top-level
entries, so 15,000 patterns is not "one per file": the list is accumulating
across those rebuilds.

That is the memory. It is not the minifier, not the heap ceiling, and not
anything in WeKan's own code - it is build tooling recompiling a growing ignore
list once per unit of work.

WHAT I COULD NOT DETERMINE HERE: which component does it. `ignore` is not a
WeKan dependency except through eslint, which does not run during a build, and
~/.meteor is not visible from this sandbox - so the caller is inside meteor-tool
or @meteorjs/rspack and needs a look from a shell that can read it:

    grep -rl IgnoreRule ~/.meteor/packages/meteor-tool/*/mt-os.linux.*/dev_bundle/lib/node_modules/

releases/analyse-heapsnapshot.py is kept rather than left in /tmp, because this
will be wanted again. It streams, so it answers on a machine that cannot open
the file in DevTools, and it explains in its header why it reports self size
rather than retained size.

Two bugs in my own reporting, found by reading the output it produced:

  * the failure said "allowed 15542 MB and reached 4280.0 MB" - it quoted the
    COMPUTED limit while the snapshot mode had capped the build at 4096. It
    quotes what the build was actually given now.
  * it told you to run WEKAN_BUILD_HEAP_SNAPSHOT=1 even when that is what you
    had just run. When a snapshot was requested it now points at the file and
    the command that reads it.

Heap.*.heapsnapshot is gitignored: Node writes it into the repo root because
that is the build's working directory, and it is as large as the heap.

300 node suites pass, 0 failed.
2026-08-03 22:29:56 +03:00
Lauri Ojansivu 38c5764f75 Cap the heap when taking a snapshot, so the snapshot can actually be written
The first heap-snapshot run was killed by the kernel - "Päätetty" - and left a
0-byte Heap.20260803.220733.2327163.0.001.heapsnapshot behind. The build log
ends mid-rspack with no GC dump at all, because the process never got to fail
its own way.

Writing a snapshot costs memory ON TOP of the heap being dumped. Asking a
15.5 GB heap to write a 15.5 GB snapshot, on a 30 GB machine with 12 GB already
in use, does not fit - the OOM killer arrives before the file does. So the flag
as written could not do the one thing it existed for.

WEKAN_BUILD_HEAP_SNAPSHOT=1 now caps the heap at 4096 MB rather than using the
computed limit. The build fails sooner, which is the point: a snapshot is not
more useful for being bigger. What is wanted is WHAT is retaining memory, and
whatever grows to 15 GB is already the largest thing on the heap at 4 GB - it
just gets there sooner and writes a file that fits in RAM and on disk.
WEKAN_BUILD_HEAP_SNAPSHOT_MB overrides it if 4096 turns out to be too early to
be representative.

The message says all of this, including that the build WILL fail and that a
full-size snapshot was already tried and killed, so nobody repeats it.

300 node suites pass, 0 failed.
2026-08-03 22:13:01 +03:00
Lauri Ojansivu bc3029aac7 Point the heap failure at the command that diagnoses it, and log the diagnosis
The fourth run died the same way - 15526.3 MB of 15542 - and produced no more
information than the third, because the heap snapshot is opt-in and nothing told
anyone it existed. The flag was added in the last commit and the failure path
was not updated to mention it, which makes it a flag nobody will use.

The error now names the exact command:

    WEKAN_BUILD_HEAP_SNAPSHOT=1 ./build.sh

and says what it produces, where, and how to read it.

It also says plainly what NOT to try, because the obvious move is wrong and has
now been wrong twice: the limit has already been raised once, 8192 to 15542, and
the build used the extra; removing the second JS minifier moved the peak by 6 MB.
Both of those are cheap to try again and neither will work, so the message says
so rather than leaving the next person to repeat them.

And the diagnosis is appended to the build log, not only printed. The point of
that log is that "check the newest test logs" answers the question - a diagnosis
that lives only in terminal scrollback is one the next reader does not have, and
the last four runs are exactly the case: the log ends on a V8 stack trace and
the explanation was somewhere above it on a terminal since closed.

Verified by rendering the message against the real log of the run that prompted
it: it reads back 15526.3 MB of 15542.

300 node suites pass, 0 failed.
2026-08-03 21:57:45 +03:00
Lauri Ojansivu 6ba8d647fb Changelog Upcoming: record the heap-snapshot option and the reverted minifier.
Thanks to xet7 !
2026-08-03 21:23:35 +03:00
Lauri Ojansivu 03b677f285 Put standard-minifier-js back, and let a build leave a heap snapshot
The theory was that rspack minifies for production itself and standard-minifier-js
was running Meteor's minifier over that same output a second time, in exactly
the phase where `meteor build` runs out of memory. The run settles it:

  with the minifier      peak 15526.6 MB   died after both rspack compiles
  without it             peak 15520.4 MB   died after both rspack compiles

Six megabytes apart, which is noise, and it died in the same place. The minifier
was not the consumer, so it goes back. An unverified change to the release
artifact that demonstrably fixes nothing does not belong in the tree - and
because the build never completes, there was no way to confirm the resulting
bundle was still correctly minified either. Both .meteor/packages and
.meteor/versions are restored exactly as they were, byte for byte.

Three guesses is enough. build.sh takes WEKAN_BUILD_HEAP_SNAPSHOT=1 now, which
adds Node's --heapsnapshot-near-heap-limit=1 to the build so it writes a heap
snapshot just before it would die, rather than only dying. Off by default,
because the snapshot is about as large as the heap limit; when it is on the
build prints where the file lands and how to read it - Chrome DevTools, Memory,
Load, sorted by retained size.

That turns the next run into evidence instead of a fourth theory. Whatever is
retaining 15 GB in Meteor's bundle-writing phase will be at the top of that
list.

300 node suites pass, 0 failed.
2026-08-03 21:23:20 +03:00
Lauri Ojansivu cea2ff6f7d Drop standard-minifier-js: rspack already minifies, and Meteor was doing it again
Two builds died in the same place - after both rspack compiles report done, in
the phase where Meteor writes the production bundle - the second with twice the
heap of the first:

  => Compiled Rspack client app:      done
  => Compiled Rspack server app:      done
  <--- Last few GCs --->              8146 MB of 8192, then 15526 MB of 15542

So it was not a ceiling set too low. rspack is the bundler here and it minifies
for production itself - the asset-size warnings a build prints are about
rspack's own minified output, 4.797 MiB of client-rspack.js and its chunks.
standard-minifier-js then ran Meteor's minifier plugin over that same output a
second time, in exactly the phase that runs out of memory.

.meteor/packages says so in a comment rather than leaving the removal to look
like an accident, including what to check first if this turns out to be wrong: a
bundle that is broken rather than merely unminified twice means put it back.
The stale pin is dropped from .meteor/versions so the two files agree;
minifier-js stays, because it is a library other packages depend on rather than
the plugin that was running.

Four guards failed on this and on the last few commits, and all four were
pinning something that deliberately changed. Each keeps its assertion and gains
the reason:

  changelogFormat      - three Upcoming summaries of mine had no commit link,
                         which every summary in a release must have, and the
                         section was not grouped by area. That one was a real
                         defect in the CHANGELOG, not in the guard: the entries
                         are linked and grouped now, under "The extra-
                         architecture bundles", "Which Node.js a bundle carries"
                         and "Running the tests locally".
  buildScriptParity    - looked for `rm -rf .build` in the first 400 characters
                         of build_wekan. That window was an accident of how long
                         the comment above it happened to be, and it broke when
                         build_wekan gained an explanation of where its log
                         goes. It searches the whole function. It also needed
                         the five release-workflow helpers exempted from "every
                         script is a menu entry": they are called by a build job
                         with its matrix and environment, and would have nothing
                         to do on a developer's machine.
  cpuExecWiring        - looked for the qemu handling inline in release-all.yml.
                         It moved into releases/install-node-for-arch.sh, which
                         is the fix for the apostrophe-in-a-single-quoted-string
                         bug, so the guard looks there and also pins that the
                         workflow runs the file.
  dbConformanceWiring  - pinned ../log literally in three places. The root is
                         WEKAN_LOG_ROOT now, because ../log is not writable in a
                         Flatpak sandbox; what is pinned is that the same
                         question is asked and that WEKAN_LOGDIR still wins.

300 node suites pass, 0 failed.

NOT verified: the build itself - Meteor is not installable in this sandbox, so
whether dropping the minifier gets past the OOM is the next run's answer, and
whether the resulting bundle is correct is the thing to look at if it does.
2026-08-03 21:08:32 +03:00
Lauri Ojansivu c0d9581df4 Say when a build died of heap exhaustion, and how much it had
The build failed again, and the log now says why on its own - but its last word
was a V8 stack trace, which reads as a crash rather than as the resource limit
it is. Running out of heap and failing to compile look identical at the level
build_wekan reports at, and they have nothing in common.

When the build log contains "JavaScript heap out of memory", the error now says
so, with the limit the build was given and the peak it actually reached, pulled
out of the last Mark-Compact line:

  The build ran out of JavaScript heap. It was allowed 15542 MB and reached
  15526.6 MB.

and points at the last line before the GC dump, which is the stage it got to.

It also says what NOT to do. Raising the number again is the obvious move and is
wrong once the figure is already large: this build used 15.5 GB, and the run
before it died the same way with half as much, so the ceiling is not what is
short. That belongs in the message rather than in somebody's memory.

Verified against the real log of the run that prompted it: the extraction finds
15526.6 MB from the final Mark-Compact line, and the sentence renders.
2026-08-03 20:58:46 +03:00
Lauri Ojansivu 581475a0d5 Work the Node heap limit out from the machine instead of fixing it at 8 GB
The build log added last commit earned its keep on the first run: the WeKan
stage failed again, and this time ../log/<datetime>/wekan-build.log says why,
on its last lines.

  FATAL ERROR: Ineffective mark-compacts near heap limit
  Allocation failed - JavaScript heap out of memory

at 8146 MB of an 8192 MB limit, on a machine with 30 GiB of RAM. The build had
not run out of memory. It had run out of the ceiling this script gave it -
--max-old-space-size=8192, hard-coded at the top with a comment saying to lower
it if your machine has less RAM, which is only half the problem: a constant is
simultaneously too small on a large machine and too large on a small one, and
nobody edits it until a build dies.

It is half of total RAM now, clamped to [4096, 16384]:

  8 GiB  ->  4096   (the floor - a small machine is not handed something unusable)
  16 GiB ->  8192   (exactly the old constant, so nothing changes there)
  30 GiB -> 15542   (this machine, nearly double what just failed)
  64 GiB -> 16384   (the ceiling - past this, a heap that big means something is
                     wrong rather than something is big)

Half is the share that leaves the rest of the machine usable while a build runs.
Total RAM comes from /proc/meminfo, or hw.memsize on macOS, and falls back to
assuming 16 GiB if neither answers. The chosen size is printed at startup so the
log says what the build was given, and exporting TOOL_NODE_FLAGS or NODE_OPTIONS
yourself still wins.

Computed unconditionally rather than only when neither variable is set, which
was the first version of this and was wrong: with only ONE of them exported, the
other would have been handed a bare "--max-old-space-size=".

The dev server on :3000 had the same 8192 written into its command line and now
uses the same figure, so the two cannot drift.

Verified by running the block with /proc/meminfo stubbed at 8, 16, 30 and 64
GiB, giving the four values above; and with TOOL_NODE_FLAGS exported alone, which
keeps the exported value and still gives NODE_OPTIONS a real one.
2026-08-03 20:24:54 +03:00
Lauri Ojansivu 76f278871c Put the logs somewhere writable, so a run in a sandbox keeps them
Every log build.sh writes goes into a log/<datetime>/ directory, and the root of
those was hard-coded to ../log - one level up from the repo. That is the right
default and stays the default: a test run then does not appear in git status,
and the docs and the release process read it there.

But the parent of the repo is not always there to write into. A Flatpak sandbox
shares only the repository directory, so `..` is missing or read-only and
`mkdir -p ../log` fails - after which every redirection in the script either
failed outright or quietly dropped its log into the repo root. That is the worst
of both: the run looks like it logged something, and git status fills up with
stray files that are not part of the tree.

WEKAN_LOG_ROOT is resolved once at startup and used everywhere the path was
built by hand: ../log when the parent is writable, ./log inside the repo when it
is not, and whatever you set if you set it. The shape is log/<datetime>/ either
way, so nothing that reads these logs has to know which happened, and the chosen
path is printed when a run starts - including a line saying why, when it is the
fallback.

Everywhere means everywhere: the whole-run directory, one_log for the individual
test options, the Playwright per-browser runs, the EVERYTHING orchestrator, and
the wekan-log.log the whole session is teed into. releases/db-conformance.sh
makes the same choice when it is run on its own rather than under build.sh, and
the thirteen menu entries that told the reader "logs in ../log/<datetime>/" now
say "log/<datetime>/" - those strings are also the case labels the menu
dispatches on, so both copies had to move together.

/log/ is gitignored, for the case it lands inside the repo.

Verified both branches by running the resolver in a temporary tree: with a
writable parent it chooses ../log and creates it; with the parent chmod'ed
read-only it prints the note, chooses ./log inside the repo, creates that, and
does NOT create ../log. build.sh, db-conformance.sh and run-everything.sh all
pass bash -n.
2026-08-03 20:03:16 +03:00
Lauri Ojansivu 4b77eb655f Log the WeKan build, so a run that fails in it leaves something to read
The newest test run ended

  FAIL   WeKan tests (sequential)
  PASS   Database conformance (all databases for this CPU)
  PASS   FerretDB tests (unit, vet, integration)

and ../log/2026-08-03_19-32-26/ held twelve conformance and FerretDB logs and
NOTHING about WeKan. Not one line about why WeKan never got as far as running a
test. The build writes to the terminal only, so the one question that run raised
was the one question its logs could not answer - and "check the newest test
logs" is how these runs are meant to be read.

What can be said from the tree it left: node_modules is 654 MB, so
`meteor npm install` finished; .meteor/local is 43 MB where a completed build is
gigabytes, and .build does not exist at all, so `meteor build .build --directory`
started and died partway. Why, nothing recorded. Meteor is not installable in
the sandbox this is being fixed from, so it cannot be reproduced here either.

build_wekan tees into wekan-build.log in the run's own log directory now, and
prints the path before it starts. An interactive build still scrolls past
exactly as it did.

Two more things found while in there.

build_wekan did not check whether the build worked. `meteor build` failing left
the function returning success, and the caller learned about it only afterwards
by noticing .build/bundle was missing - which is how this failure was reported,
one level away from the thing that failed. It checks the exit status and the
bundle, and says which log to read.

run_all_tests minted its own ../log/<datetime>/ even when a larger run had
already set WEKAN_LOGDIR. That is exactly the split-across-two-directories the
per-run directory exists to prevent; it landed in the same second as the outer
one this time, so nothing showed, and on a slower machine half a run's logs
would have gone somewhere else. It uses the outer directory when there is one
and exports its own when there is not, so every job of a run agrees on it
instead of each calling date() again.

Verified: build.sh passes bash -n, and the tee construct was exercised on its
own - a failing command inside the group produces exit 1 from the function, the
ERROR path, and a complete log file, rather than tee's own zero.
2026-08-03 19:48:33 +03:00
Lauri Ojansivu c429ea71d9 Checksum every bundle, and put the binaries' provenance atop the release notes
Two things a WeKan release did not say, and now does.

WHAT IT SHIPS. Each bundle zip now has a wekan-<version>-<arch>.zip.sha256sum
beside it on the release, so a download can be checked. The bundles were the one
thing in this chain with no checksum at all - the binaries inside them are
verified where their sources publish one, and the zip carrying them was not.

WHERE ITS BINARIES CAME FROM. A bundle is assembled out of a Node.js, a
FerretDB and the MongoDB tools, and WHICH source serves each varies per release
and per CPU: nodejs.org builds some architectures, unofficial-builds others, the
wekan/node fork the ones neither of them does, and not all of them publish a
checksum. None of that was visible anywhere except a build log that expires, so
"where did the node in the arm64 bundle come from, and was it checked" had no
answer.

Each build job records a row per binary - bundle, binary, source, version,
whether it was checked, the SHA256 - and the release notes open with the table.
A row saying "no checksum published" is not a failed check but a source that
publishes nothing to check against, and the table says so, because those are the
ones worth fixing at the source rather than mistaking for failures.

The table is written twice, and that is deliberate. The release is CREATED
before win64, mac-arm64 and the extra architectures are built - those jobs need
a release to upload their bundles to - so the table it is created with can only
cover amd64 and arm64. A new release-notes job runs after all of them and
rewrites the notes with every bundle in. It is always(), so a platform that
failed does not cost the release the table for the platforms that succeeded.

FerretDB is now verified too, where it can be: wekan/FerretDB publishes a
.sha256sum beside each binary as of the commit in that repository alongside
this one, and the extra-arches job checks the download against it. An older
release without one is recorded as unverified rather than failing the build -
the binary IS available, which is the test that matters.

Nothing new fails a build. A missing checksum is a warning, a missing MongoDB
tool is a warning; only a binary that does not exist at all stops the run, which
is what it did before.

Verified: every script parses under bash -n; release-all.yml is valid YAML with
its nineteen jobs and the new one wired to all five build jobs; and the table
renders correctly from real rows - three of them, two sources, one with a
checksum and one without - producing the markdown checked by eye.
2026-08-03 19:26:24 +03:00
Lauri Ojansivu 22183ef0d4 Changelog Upcoming: record the verifiability order and the checksum check.
Replaces the fork-first entry, which this supersedes: the order is official,
then unofficial, then the fork, by how verifiable each build is - and the
checksum is now actually checked, with a retry before it is fatal.

Thanks to xet7 !
2026-08-03 19:13:28 +03:00
Lauri Ojansivu 1a263e9e21 Prefer the verifiable Node.js builds, and actually verify them
The three sources do not offer the same assurances, and this was checked rather
than assumed:

  nodejs.org         SHASUMS256.txt, and SHASUMS256.txt.sig / .asc signed with
                     the Node.js release keys
  unofficial-builds  SHASUMS256.txt, no signature
  wekan/node fork    neither, until the commit in that repo alongside this one

So the order goes back to official, then unofficial, then the fork - descending
verifiability, with the fork as the backstop for what the other two do not
build. A binary somebody else signed is worth more than one this project built,
and the fork's own fixes are build-configuration fixes upstream's builds do not
need: they are what makes the CPUs upstream does NOT build compile at all.

Preferring a source because it publishes a checksum and then not checking it
would be preferring it for nothing, so the checksum is now looked up in the
preflight - from the source's own SHASUMS256.txt, or from the fork's per-binary
.sha256sum - and the download step refuses a file that does not match it.

A mismatch is retried before it is fatal. The overwhelmingly likely cause is a
truncated or corrupted transfer, a CDN edge that served a partial file or a
connection cut mid-stream, and that is fixed by asking again. What retrying must
not do is give up quietly: after three attempts the build stops, because at that
point the file being served is not the file that was published.

Where no checksum exists the log says so in as many words, rather than leaving
the reader to assume a check was made. FerretDB and the MongoDB tools are in
that position today - neither wekan/FerretDB nor wekan/mongo-tools publishes
checksums for its release binaries, which is the next gap to close.

Verified by exercising download_and_verify directly: a matching checksum
returns 0, a missing one returns 0 with the file named as unverified, and a
mismatching one retries three times and exits 1. And by resolving the real
checksums for every architecture against the live releases - s390x and ppc64le
from nodejs.org, riscv64 and loong64 from unofficial-builds - then downloading
the riscv64 tarball and confirming its hash matches the published one.
2026-08-03 19:13:03 +03:00
Lauri Ojansivu 05f99fe4e4 Changelog Upcoming: record the fork-first Node.js source.
Thanks to xet7 !
2026-08-03 19:04:20 +03:00
Lauri Ojansivu 77d38e099f Take Node.js from the wekan/node fork first, on every architecture
The fork is not "the CPUs nobody else builds" any more - it builds all thirteen
platforms WeKan ships a bundle for, and it carries fixes upstream does not: the
ICU genccode architecture name, the x86 /SAFESEH opt-out, the zlib SSE2 and NEON
flags, the V8 template disambiguator. Those are build-configuration fixes that
any platform benefits from, and a set of bundles should not be half built on one
Node.js and half on another depending on which CPU it is for.

So the probe order is fork, then nodejs.org, then unofficial-builds - the last
two now being fallbacks for the case the fork has not published a version yet,
rather than the other way round.

The rest of the rule is unchanged and still matters: the newest Node.js that
exists FOR THIS CPU, walking versions from newest down and asking all three
sources at each one. Preferring the fork does not mean pinning to whatever the
fork last built.

Checked against the live releases, which show the transition in progress - the
fork has v24.18.1 for the architectures it already built and has not yet run for
v24.19.0:

  i386     v24.18.1  fork
  loong64  v24.18.1  fork        was coming from unofficial-builds before this
  s390x    v24.19.0  official    fork has not built v24.19.0 yet
  ppc64le  v24.19.0  official    likewise
  riscv64  v24.18.1  unofficial  likewise
  armhf    -         none        still nothing anywhere

Once node.yml has run for v24.19.0 with its new matrix, every one of these
resolves to the fork; until then the fallback chain keeps the bundles building
instead of blocking on it.
2026-08-03 19:04:05 +03:00
Lauri Ojansivu deff51f20c Changelog Upcoming: record the newest-per-CPU Node.js rule.
Splits the version-fallback out of the binary-check entry, because it is its own
change and its own reason: the fallback now applies to all three sources rather
than only the fork, which is what makes riscv64 build again.

Thanks to xet7 !
2026-08-03 18:54:41 +03:00
Lauri Ojansivu 60c9e55d2f Use the newest Node.js that exists FOR THIS CPU, not the newest that exists
The version fallback added with the preflight check was only wired to the
wekan/node fork. nodejs.org and unofficial-builds were still pinned to the exact
newest release, so a CPU that HAD a perfectly good build, one patch version
back, was reported as having none at all.

riscv64 was exactly that. unofficial-builds publishes riscv64 up to v24.18.1 and
has not got to v24.19.0; the check looked only at v24.19.0, found 404s, and
stopped the job saying "no Node.js for riscv64 exists anywhere" - while
node-v24.18.1-linux-riscv64.tar.xz was sitting there.

The rule is now "the newest Node.js that exists for this CPU". It walks the 24.x
releases from newest down and asks all three sources at each one - official,
then unofficial, then the fork - and the first hit wins, which is by
construction the newest build that exists anywhere for that architecture. Those
are the same thing on amd64 and arm64 and regularly are not anywhere else:
nodejs.org builds a handful of architectures, unofficial-builds adds a few, the
fork builds the rest, each on its own schedule, so the further a CPU is off the
beaten path the further behind its newest build tends to be.

When the answer is behind the newest, the log says which version it got, from
where, which version it wanted, and what to build to bring it in line. It is a
warning, not an error: the alternative is no bundle at all for that CPU.

The walk stops after 12 releases. A CPU whose newest build is a dozen releases
old is not slightly behind, it is unmaintained, and saying so is more use than
quietly shipping something from last year.

Re-ran the check against the live releases for all six architectures:

  s390x    v24.19.0  official     newest
  ppc64le  v24.19.0  official     newest
  riscv64  v24.18.1  unofficial   was failing before this change
  i386     v24.18.1  fork
  loong64  v24.18.1  unofficial   the fork has it too; unofficial is tried first
  armhf    -         none         exit 1, naming node-armhf

and checked armhf by hand across six releases back to v24.10.0 at all three
sources, to be sure "nothing anywhere" is the truth about armhf and not a bug in
the walk. It is: every one is a 404.

Four of the six now come out newer or working where they did not before, and the
one that still fails fails for a real reason.
2026-08-03 18:54:21 +03:00
Lauri Ojansivu cbf5228cd2 Changelog Upcoming: record the release-build fixes.
The six extra-architecture jobs that all failed in the v10.57 run, grouped under
the area they belong to: the shell quoting bug that emptied the CPU name out of
the Node.js URL, the base image that is not built for i386 or loong64, and the
checks that now run before any of it and name the missing file.

Thanks to xet7 !
2026-08-03 18:41:49 +03:00
Lauri Ojansivu 92198b0671 Release build: fix all six extra-architecture jobs, and check binaries first
Every one of the six extra-architecture jobs failed in the v10.57 run, and none
of the six failures was about WeKan.

FOUR OF THEM - ppc64le, riscv64, s390x, armhf - died on the same 404, at a URL
with no architecture in it at all:

  curl: (22) The requested URL returned error: 404
  No Node.js for  at official (https://nodejs.org/dist/v24.18.1/node-v24.18.1-linux-.tar.xz)

The container script was passed as `bash -c '...'` and contained apostrophes, in
comments like "# Where this CPU\'s Node.js comes from". A single-quoted shell
string cannot hold an apostrophe - \' does not escape it, it ENDS the string -
so the script was chopped into words at the first one, and ${NODE_ARCH} landed
in a region the RUNNER's shell expanded rather than the container's. The runner
has no NODE_ARCH, so it expanded to nothing. Reproduced here before changing
anything: that construct really does deliver only the fragment up to the
apostrophe and leave the rest as separate arguments.

The script is a file now, releases/install-node-for-arch.sh, mounted into the
container. A file has no quoting layer to get wrong, and `bash -n` can check it.

THE OTHER TWO - i386 and loong64 - died earlier still, on

  docker: no matching manifest for linux/386 in the manifest list entries

ubuntu:26.04 publishes amd64, arm/v7, arm64, ppc64le, riscv64 and s390x. Not
386. Not loong64. The base image is named per architecture in the matrix now and
is debian:trixie, which publishes 386 as well - checked against the registry
rather than assumed.

loong64 still cannot be built, and that is now said rather than discovered: no
image on Docker Hub publishes linux/loong64 at any tag, so there is no loong64
userland to rebuild the native modules in. node-loong64 and ferretdb-loong64
both exist; the container does not. It stays in the matrix so it stays visible.

AND UNDERNEATH BOTH: nothing checked that the binaries these bundles are
assembled from had been published at all. A bundle takes FerretDB from
wekan/FerretDB, the MongoDB tools from wekan/mongo-tools, and on the CPUs nobody
else builds for, Node.js from wekan/node - and any of those can be absent
because a build has not finished. releases/check-arch-binaries.sh now runs
before anything is built and checks the base image, the Node.js, FerretDB and
the tools; releases/require-binaries.sh does the same for the amd64, arm64,
win64 and mac-arm64 bundles. Each missing file gets a line naming it and the
repository that should publish it, and the job stops there. The MongoDB tools
stay a warning: FerretDB is the database and the launcher does not need them.

It also handles wekan/node running behind. The fork builds the architectures
nobody else does, one release at a time, so the newest Node.js is regularly a
version it has not reached - today nodejs.org is at v24.19.0 and the fork at
v24.18.1. Asking for the exact newest version would fail for exactly the CPUs
the fork exists to serve, so the check falls back to the fork's newest release
that HAS that CPU and says out loud that the bundle is a patch version behind.

Verified by running the check against the real releases, for all six:

  s390x    Node.js v24.19.0, official        exit 0
  ppc64le  Node.js v24.19.0, official        exit 0
  i386     Node.js v24.18.1, wekan/node      exit 0
  loong64  Node.js v24.18.1, wekan/node      exit 0
  armhf    nothing anywhere                  exit 1, naming node-armhf
  riscv64  nothing anywhere                  exit 1, naming node-riscv64

armhf and riscv64 are the two the fork has not finished; the log now says which
file to publish instead of a bare 404. i386 and loong64 resolve because
node-i386 and node-loong64 were added to the fork's v24.18.1 release.

NOT verified: the builds themselves, which need the runners. What was checked:
that release-all.yml is still valid YAML and every job has its check step; that
the three new scripts pass `bash -n`; that the quoting bug reproduces and the
file form does not; that debian:trixie really publishes linux/386 and
ubuntu:26.04 really does not, from the Docker registry; and the six preflight
results above, run against the live releases.
2026-08-03 18:41:10 +03:00
Lauri Ojansivu 73e06940c3 AppImage and Flatpak workflows, built from the bundle a release already carries.
Two more ways to install WeKan, and neither of them rebuilds it.

release-all.yml already produces a bundle that runs offline with nothing
installed: the Meteor server, the bundled Node.js the release was built
against, the wekan/FerretDB v1 fork with its embedded pure-Go SQLite backend,
the wekan/mongo-tools binaries, the cpu-exec/qemu-user shim from #6458 and
start-wekan.sh to bring them up. That bundle is already relocatable and already
self-contained - it is the offline zip people download today. So both workflows
take wekan-<version>-<arch>.zip off the release and wrap it, which means the
AppImage and the Flatpak are byte-for-byte the same server, Node and FerretDB
as the zip beside them, rather than second and third builds that could drift
from it and from each other.

For the person installing, both mean the same thing: one file, no MongoDB to
install, no Node to install, no database server to run. FerretDB v1 keeps
everything in SQLite files.

AppImage.yml wraps the bundle in an AppDir with WeKan's own icon and an AppRun
that hands over to start-wekan.sh, so every variable that launcher documents -
WRITABLE_PATH, PORT, ROOT_URL, MONGO_URL, WEKAN_DB - still works. It defaults
WRITABLE_PATH to ~/.local/share/wekan, because an AppImage is read-only and may
be sitting on a USB stick, so the data cannot live beside it. After packing it
STARTS the AppImage and waits for WeKan to answer on its port before uploading
anything: an AppImage that packs is not an AppImage that runs, and a broken
bundle should be caught here rather than by whoever downloads it.

Flatpak.yml uses `flatpak build` rather than flatpak-builder, because the
bundle is the build and there is nothing to compile. Its sandbox gets
--share=network and --persist=.wekan and nothing else: WeKan is a server
reached in a browser, so it needs the network and a place for its data, not
X11, audio or the user's files. It produces both a single-file bundle per
architecture and a multi-architecture repository - the bundle is what you
install by hand, the repository is what makes `flatpak update` work - and the
repository is static files, so a website can serve it with no server to run.

The Flatpak repository is unsigned. Signing needs a GPG key, which is a secret
rather than something a workflow can invent, so the README written into the
repository sets out the four steps to sign it when a key exists: make the key,
put the private half in GPG_PRIVATE_KEY, add --gpg-sign to the export and the
update-repo, and publish the public half beside the .flatpakrepo.

Both cover x86_64 and aarch64, which is where the two constraints meet: AppImage
and the freedesktop runtime have those architectures, and WeKan publishes
amd64 and arm64 Linux bundles. Each builds on a runner of its own CPU, because
appimagetool packs for the architecture it runs on. If a bundle for another
architecture appears on a release, adding a matrix line wraps it the same way.

NOT verified: neither workflow has been run - that needs the runners. What was
checked: that both are valid YAML and their matrices resolve; that the icon
they use exists in public/; that the four files they assert on
(bundle/main.js, bundle/node, bundle/ferretdb, bundle/start-wekan.sh) are what
release-all.yml actually puts in the bundle; and that the newest release really
does carry wekan-10.57-amd64.zip and wekan-10.57-arm64.zip under those names.
2026-08-03 18:19:04 +03:00
Lauri Ojansivu 33e193f14b Changelog: the three places its own shape was not followed.
CLAUDE.md sets the shape of a `<details>` block precisely - `<details>` and
`</details>` each on their own line, the whole `<summary>` on ONE line, a blank
line under it, the body, a blank line, the close - and three blocks were missing
the blank line before `</details>`: the Version block in `# Platforms`, the
`Carried to a future release.` block at the top of `# TODO Later`, and one entry
in v10.55.

They render either way, which is why they survived; the rule is there so every
block looks the same in the file, and a block that does not is the one somebody
copies next.

Also rewrapped the one line in the `# TODO Later` intro that had grown to 152
characters, against the 80 the file is wrapped at. It carries no link, so
nothing stopped it being wrapped.

Thanks to xet7 !
2026-08-03 17:58:34 +03:00
Lauri Ojansivu 2e51e7ceec gitignore: secretchronicles.github.io, cloned under this working copy.
The TSC website (Secretchronicles/secretchronicles.github.io), cloned inside
this repo to be worked on beside the game itself. Like the others it is a whole
git repository of its own, so git would not commit it into this one, but it
showed up as untracked noise in every status.

Thanks to xet7 !
2026-08-03 17:53:50 +03:00
Lauri Ojansivu b742621225 gitignore: TSC, cloned under this working copy.
git@github.com:Secretchronicles/TSC, cloned inside this repo to be worked on.
It is a whole git repository of its own, so git would not commit it into this
one, but it showed up as untracked noise in every status.

Listed beside the other local-only working copies - node, FerretDB, sandstorm,
mongo-tools, mongosh and the two wekan variants - with the same one-line note
saying what it is a clone of.

Thanks to xet7 !
2026-08-03 17:53:50 +03:00
github-actions[bot] 147f056e97 Bump versions for v10.57 2026-08-03 12:41:32 +00:00
Lauri Ojansivu 037a9d8fd3 Prepare v10.57 release 2026-08-03 15:40:26 +03:00
Lauri Ojansivu 965b506a71 Changelog Upcoming: record FollowBleed and TransitBleed.
Two reported bypasses of the SSRF guard, so the section now leads with the
CRITICAL SECURITY ISSUES header and the documentation reorganisation becomes
"and reorganises the documentation:". Both entries are grouped under "The SSRF
guard - what it checks, and what it was deciding from", because that is the one
thing both reports found from two directions.

The "In short" paragraph is rewritten around them: what each bypass was, in one
clause each, then the documentation work accounted for in the closing sentence.

Also records the Sandstorm bridge guard reading the page at its new path, which
the docs reorganisation left pointing at a directory.

Thanks to RandomGenerator, tonghuaroot and xet7 !
2026-08-03 15:34:18 +03:00
Lauri Ojansivu 4c183b4d59 GHSA-j9p2-jm73-p549 and GHSA-c5xr-mg26-vq5w: two ways past the SSRF guard.
Two separately reported bypasses of the same defence, fixed together because
they are the same mistake in the two halves of it: the guard decided from
something it did not control.

FollowBleed (GHSA-j9p2-jm73-p549, reported by RandomGenerator against v10.53) -
the live Trello import validated the attachment URL with validateAttachmentUrl(),
which is the fix that closed LiveBleed / CVE-2026-30844, and then downloaded it
with the platform fetch(), which FOLLOWS REDIRECTS. So the guard only ever saw
the request, and the target gets to answer:

  1. the attacker puts http://<public-host>/attachment.txt on a Trello card
  2. validateAttachmentUrl() resolves it, sees a public IP, allows it
  3. that host answers  302 Location: http://127.0.0.1:18080/secret
  4. fetch() follows, and the loopback body is stored as the imported
     attachment, readable back through WeKan

Non-blind SSRF against loopback services, internal admin panels, cloud metadata
and anything else reachable from the container - the exact thing the validation
was added to stop, reached through the response instead of the request.

A guard on the URL alone cannot hold, so fetchSafe() now guards EVERY HOP.
`maxRedirects` (default 0) is how many redirects a caller is willing to follow:
0 keeps the old behaviour of refusing any 3xx outright, which is right for
outgoing webhooks and avatar downloads because a legitimate one never redirects.
A caller that must follow one passes a small number, and each hop goes through
the same protocol allowlist, blocked-range check and DNS pinning as the original
URL before a packet is sent to it. Credentials are dropped on a cross-origin
redirect, so following Trello's 302 to S3 cannot hand the API key and token to
whoever the redirect names.

All three live-import sinks - downloadAttachmentBase64, inlineBoardBackground,
inlineMemberAvatars - download through it. Refusing every redirect was not an
option: Trello's own attachment endpoint answers with a 302 to a signed S3 URL,
so that would have meant importing no attachments at all.

The offline importers had the same hole and were not in the report. They handed
the validated URL to Attachments.loadAsync(), and Meteor-Files downloads with
the platform fetch() too, so a pasted Trello or WeKan board export reached
127.0.0.1 by exactly the same 302. models/lib/importAttachmentDownload.js
validates, downloads through fetchSafe with every hop checked, and hands back
the bytes, which are stored with the writeAsync() both importers already use for
an attachment that arrived inline.

TransitBleed (GHSA-c5xr-mg26-vq5w, reported by tonghuaroot) - isIpBlocked() in
models/lib/attachmentUrlValidation.js is the one block-list behind BOTH the
input-time validator and the delivery-time guard, and its IPv6 half classified
an address by its spelling: startsWith('::ffff:'), startsWith('2001:db8'), and
the first hextet parsed out of the string. IPv6 has several standard ways to
write "this packet goes to an IPv4 address" and none of them looks like
`::ffff:`:

  2002:a9fe:a9fe::       6to4 (RFC 3056)     -> 169.254.169.254
  64:ff9b::c0a8:101      NAT64 (RFC 6052)    -> 192.168.1.1
  2001:0:...             Teredo (RFC 4380)   -> complement of the low 32 bits
  0:0:0:0:0:ffff:7f00:1  IPv4-mapped, spelled out -> 127.0.0.1

On a host with a 6to4 relay or a NAT64 gateway, which is ordinary in cloud and
Kubernetes networks, the packet arrives at that IPv4 address. So
http://[2002:a9fe:a9fe::]/latest/meta-data/ read cloud metadata straight through
the guard whose whole job was to stop it, and even a plain IPv4-mapped address
written out in full missed the check.

The address is EXPANDED to its 16 bytes once and every check reads those bytes,
so notation cannot change the answer, and every transition form has its embedded
IPv4 extracted and re-checked with the IPv4 rules - 6to4, NAT64 (well-known and
RFC 8215 local-use prefixes), Teredo through both its server and its obfuscated
client address, IPv4-mapped, IPv4-translated, IPv4-compatible and ISATAP under
any routing prefix, not only the link-local one. The deprecated fec0::/10
site-local range is blocked too. A transition address wrapping a PUBLIC IPv4 is
still allowed, because a guard that blocks everything gets switched off.

Tests. tests/followbleed.test.cjs replays the reported attack against a stubbed
transport - a public host that 302s to 127.0.0.1 - and asserts the second hop is
never sent, then covers redirects to a hostname resolving to a private IP, to
metadata, to a non-http scheme, relative Location, the chain limit, credential
stripping across origins, 303/307 method handling, and that a legitimate
public-to-public redirect is still followed with each hop pinned. It also pins
the sinks: the only bare fetch() left in the live import is the hardcoded
api.trello.com call, and neither offline importer uses loadAsync any more.
tests/transitbleed.test.cjs checks every transition form in both directions.
Both are in the plain-node suite; the guard's own dnsbleed suite gained the same
cases. 300 node suites pass.

Thanks to RandomGenerator and tonghuaroot for the coordinated disclosures, and
xet7 !
2026-08-03 15:30:15 +03:00
Lauri Ojansivu 6cd64284dc Sandstorm bridge guard: read the page at its new path.
The docs reorganisation (f628a698) turned
`docs/Platforms/FOSS/Container/Sandstorm` from a page into a directory, and
this test reads that page at run time to pin what it documents. So it did not
merely go stale - `fs.readFileSync` on a directory throws EISDIR and the suite
died before its assertions ran.

It reads the directory's landing page, `README.md`, which is where the
migration-bridge documentation ended up. Both assertions are unchanged and both
pass.

Thanks to xet7 !
2026-08-03 15:29:43 +03:00
Lauri Ojansivu a6104aab28 Updates 2026-08-03 13:47:50 +03:00
Lauri Ojansivu 2aa777df2c Updated ChangeLog. 2026-08-03 13:24:41 +03:00
Lauri Ojansivu aaab92b9f4 Fixed formatting 2026-08-03 13:09:07 +03:00
Lauri Ojansivu f628a69889 Reorganized docs Platforms etc.
Thanks to xet7 !
2026-08-03 13:07:12 +03:00
github-actions[bot] 3cb4cab095 Bump versions for v10.56 2026-08-02 00:22:30 +00:00
Lauri Ojansivu 52cee0c4f4 Prepare v10.56 release 2026-08-02 03:21:14 +03:00
Lauri Ojansivu 92fc66e929 Changelog: the aligned board counts, and the snap build that says why it failed
Two entries for the two commits since the last changelog update, and the bug
subsection is grouped by area now that it holds three: the snap, and All Boards.
Grouping meant rewording two summaries - an entry under a group must not repeat
the group's own name, and both began with it.

The In short paragraph follows: it named two of the three fixes and now names
all of them.

The counts entry goes under All Boards with the left-menu one; the remote-build
diagnostics are release tooling rather than a bug in WeKan itself, so they get
their own subsection after the fixes, as CLAUDE.md orders them.

Thanks to xet7 !
2026-08-02 02:54:28 +03:00
Lauri Ojansivu 852ad1714b All Boards: the board counts line up in one column
Starred, Home, Templates and Archive have labels of four different lengths, and
on a phone the count chip sat immediately after its label - so the four numbers
landed at four different x positions and the column of counts read as ragged
rather than as a column at all.

That packing was deliberate (#6523): the number beside the text rather than
across a gap, on the grounds that a landscape phone's menu is wide enough for
the gap to look like a mistake. Reversed here, because the alignment is what a
reader is actually using - the counts are compared with each other down the
column, and four x positions is what stops that.

The row keeps the space-between and the flex: 1 label the base rule gives it, at
every width, so every count sits at the end of its own row and they line up. The
phone override is gone rather than re-tuned; there is no width at which the
ragged version was wanted.

Guarded both ways in mobileAllBoardsFit.test.cjs: the row must spread its two
children apart and the label must take the slack, AND no narrower width may take
either back - a phone override is exactly how they came to be ragged.

Thanks to xet7 !
2026-08-02 02:48:55 +03:00
Lauri Ojansivu 8dc6bc7edd Snap: a remote build that never starts now says why it did not
The v10.55 riscv64 leg failed three times in sixteen minutes, and the whole of
what it printed was:

    Git operation failed with: Could not push 'HEAD' to
      git.launchpad.net/.../+git/snapcraft-wekan-<hash>
      with refspec 'HEAD:refs/heads/main'

This is a different failure from the other two arches. It is not the staging bug
- that happens minutes into a build, on the builder. Here the build never
started: remote-build uploads the source to a Launchpad git repository first,
and the upload is what failed. ppc64el and s390x pushed fine in their own runs,
with the same credentials.

Why it is still unconfirmed: snapcraft swallows git's own error, writes it to
its execution log, names that log's path in the output, and nobody reads it. So
"rejected", "timeout", "auth" and "too big" all look identical from the job log
- which is why three runs have not narrowed it down.

Two changes, both about being able to tell next time:

The failed-attempt handler prints snapcraft's own execution log, not only the
Launchpad build log. The Launchpad one does not exist when the build never
started, which is exactly this case.

And the retry clears ~/.cache/snapcraft/remote-build before waiting. snapcraft
keeps a local clone of the Launchpad repository there, and a retry that reuses a
half-pushed one repeats the same failure - three identical attempts sixteen
minutes apart is what that looks like.

The leading suspect is recorded in ../log/snap/TODO.txt rather than guessed at
here: remote-build pushes HEAD:refs/heads/main with no force, WeKan's history
was rewritten between v10.54 and v10.55 by the release rebase, and a `main` left
behind by the previous run is then no ancestor of today's HEAD. If the log the
job now prints says "rejected", deleting that scratch repository is the fix, and
the longer-term answer is for the workflow to delete it or to use a fresh one
per run.

Thanks to xet7 !
2026-08-02 02:38:57 +03:00
Lauri Ojansivu fdd84f78ba Changelog: the platforms, the snap staging fix and the phone left menu.
The three commits since v10.55, each linking the commit it describes: the
platform expansion as a new feature, and the snap that could not finish staging
on any FerretDB-only architecture and the left menu lying over the boards on a
phone as bug fixes.

One style fix with it: the dependency bullets in v10.55 wrote their link text as
[update] where v10.54 and everything before it write [Update].

The hashes were checked rather than assumed - every commit link in the new
section resolves AND is an ancestor of HEAD, which is the property that matters
after a rebase: a hash that still exists locally but has left the branch is a
404 the moment it is pushed. The rest of the file was checked the same way and
needed nothing.

Left alone: hash LENGTH. The two newest releases happen to use 9-character
prefixes, which is what a sample of them says, but the file as a whole is 2811
forty-character hashes against 399 short ones - so the newest entries are the
outliers, not the rule, and normalising the file to match a sample of itself
would have rewritten every release ever published.

Thanks to xet7 !
2026-08-02 02:08:15 +03:00
Lauri Ojansivu 12ae908ddf Release: build every Linux platform FerretDB does, with a Node.js to match.
Every non-amd64, non-arm64 platform already used FerretDB v1 - MongoDB ships no
server for any of them - but only three of them were built, because only three
had a Node.js runtime anyone published. wekan/node now builds the rest, so the
gap is gone and the release follows FerretDB's list.

New bundles: i386, armhf and loong64, beside the ppc64le, s390x and riscv64
already there. They go into the snap (i386 and armhf; loong64 is not a snap
architecture) and into the multi-arch image (linux/386 and linux/arm/v7;
buildx and the three registries do not agree on loong64 yet).

WHERE THE NODE.JS COMES FROM is now resolved at build time rather than declared:
nodejs.org, then unofficial-builds, then the wekan/node fork, in that order, and
the log says which one served. The first two ship a tarball; the fork ships the
bare binary it built, because that is the only part missing - so when the fork
serves, npm comes from the official amd64 tarball of the same version. npm is
JavaScript and runs on whatever node executes it, so an npm built for one CPU
drives a node built for another.

THREE VOCABULARIES meet in this matrix and they disagree: Node says x86 and
armv7l where Debian, snap and FerretDB say i386 and armhf, and snap says ppc64el
where everyone else says ppc64le. Each row now names all three - arch, node_arch
and ferretdb_arch - because a row that named one of them would download another
CPU's binary and nothing would notice until somebody ran it.

The MongoDB Database Tools are per-tool tolerant now: wekan/mongo-tools does not
publish every arch, and a missing mongodump is a missing convenience rather than
a broken bundle - FerretDB is the database. It removes the inherited amd64 one
instead of shipping it, because a tool for the wrong CPU is worse than no tool.

armel is the one FerretDB target left out, and deliberately: V8 has not
supported ARMv5 for many years, so there is no runtime to put in the bundle and
the fork cannot build one either.

A new guard reads the four lists that have to agree - the bundle matrix, the
snap platforms block and its arch mapping, the snap-launchpad matrix and the
Docker platform list - and checks every snap arch has a bundle behind it, every
bundle row names all three vocabularies, and buildx builds exactly the platforms
whose push is then verified. Two older guards pinned the five-platform snap list
and now pin the seven.

Written up in docs/Design/Autoupdate/Platforms.md.

Not verified by a real release run: no workflow here can be executed, and the
first run is what will say whether the loongarch64 cross toolchain is available
on the runner and whether each platform's Node.js resolves where expected.

Thanks to xet7 !
2026-08-02 02:02:17 +03:00
Lauri Ojansivu 2d17d2627a Snap: the mongodb part cannot stage bin as a symlink any more.
The s390x snap died in the STAGE step of both its Launchpad builds, right after
"Staging mongodb":

    /build/snapcraft-wekan-<id>/stage/bin: Is a directory
    IsADirectoryError: filename: '/build/.../stage/bin'

The mongodb part's stage-packages unpack an Ubuntu 24.04 merged-/usr layout,
which puts everything under usr/ and leaves `bin` in the part as a SYMLINK to
usr/bin. On amd64 and arm64 the part's build then downloads MongoDB and copies
its binaries into $SNAPCRAFT_PART_INSTALL/bin, which replaces that symlink with
a real directory. On s390x, ppc64el and riscv64 MongoDB ships no server, so the
build exits early - "No MongoDB server for ...; FerretDB-only arch" - and the
symlink survives.

mongodb is staged LAST of the parts that carry a bin/, so stage/bin is already a
real directory by then, from wekan, caddy and helpers. Copying a symlink onto an
existing directory is the IsADirectoryError, and it fails the whole snap rather
than that one part.

An override-stage turns a `bin` SYMLINK into an empty real directory before
staging, then stages normally. It tests for a symlink specifically, so the
arches where mongod really is there are untouched; it removes before mkdir,
because mkdir -p FOLLOWS a symlink and would change nothing; and an empty real
directory merges into stage/bin without changing anything else.

ppc64el is the same FerretDB-only branch and would hit this too - its own logs
never got that far, because snapcraft's monitor loop died fetching the
in-progress build log from launchpad.net with "[Errno 101] Network is
unreachable" while the build itself was still compiling, and each retry then
submitted a NEW build instead of recovering the running one. That half is
infrastructure, not this file; it is written up with the rest in
../log/snap/TODO.txt.

riscv64 takes the same branch, so this covers it as well.

Not verified against a real Launchpad builder: the fix is read off the failure,
and the s390x leg has to be re-run to confirm it now reaches "Snapped".

Thanks to xet7 !
2026-08-02 02:02:17 +03:00
Lauri Ojansivu 5b91c99b11 The left menu fits its column on a phone, and node/ is ignored.
Spec 43 (All Boards on a phone) still failed in all three browsers with the same
numbers as before: the menu 260px wide over a 157px column, lying across the
boards.

max-width: 100% was not enough, and that is the part worth knowing: a grid
item's default min-width is `auto`, which is its content's intrinsic minimum,
and a MINIMUM BEATS A MAXIMUM. The cap did nothing until the item was allowed to
shrink to it. `min-width: 0` is the same pair the right-hand column beside it
has carried all along, for the same reason and with the same comment. Nothing
inside the menu needs the intrinsic width held open - the workspace name already
ellipses through its own min-width: 0.

Two guards followed the format rather than the other way round. Both were
written against a release with forty entries and rejected v10.55 for being four
dependabot bullets and nothing else: CLAUDE.md keeps a dependency batch as plain
bullets, so a release can legitimately have no <details> at all, and padding one
bump into a block whose body repeats its summary would be the noise that rule
exists to prevent. They count entries OR bullets now.

The empty Files report (spec 41) is NOT fixed - it is made legible. The helper
that fills that table caught every exception and returned [] in silence, which
renders exactly like "there is nothing to show": headers, "No results", and
nothing anywhere recording that it threw. It logs now. The publication says one
line when it matches nothing, whatever DEBUG is set to, because a publication
answering with nothing and a publication never running look identical from the
outside - which is why three runs have not told us which this is.

And node/ - a 2.2G clone of the Node.js 24.x fork the runtime is built from -
joins the other local-only clones in .gitignore, rooted like they are.

Thanks to xet7 !
2026-08-02 02:02:17 +03:00
github-actions[bot] 7def748adc Bump versions for v10.55 2026-08-01 21:56:43 +00:00
Lauri Ojansivu 589e5c9fbc Prepare v10.55 release 2026-08-02 00:55:49 +03:00
Lauri Ojansivu 4adc57edc5 Changelog: the dependency updates since v10.54.
Four dependabot updates have landed since the release, and none of them is in
the shipped bundle: the two halves of typescript-eslint, sinon, and the
docker/login-action step that signs in to the container registries when a
release image is published. They are a plain bullet each, with the package, the
version pair, one line on what it is, its pull request and the commit that
brought it in - the shape CLAUDE.md prescribes for a dependency batch.

`abdcf8134` needs no entry of its own: it reworded a summary inside the v10.54
section, which is already released.

Two guards had to follow. Both were written against a release with forty entries
and failed this one for being small:

  * "the Upcoming section has at least one ENTRY" - a release whose whole
    content is a dependency batch has none, because a batch is bullets, and
    padding a bump into a <details> whose body repeats its summary would be
    noise added to satisfy a test. It asks for at least one change now, entry or
    bullet.

  * "at least ten entries are grouped" - a fixed count fails a small release for
    being small. It is a ratio now: most of what is there is grouped, which is
    what the rule is about, and the loose-entry limit beside it is the other
    half of the same check. Verified it still catches six ungrouped entries.

Thanks to xet7 !
2026-08-02 00:51:56 +03:00
Lauri Ojansivu abdcf81347 Updated ChangeLog. 2026-08-02 00:41:04 +03:00
Lauri Ojansivu 31fb4f5094 Merge pull request #6564 from wekan/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-8.65.0
Bump @typescript-eslint/eslint-plugin from 8.63.0 to 8.65.0
2026-08-02 00:18:06 +03:00
dependabot[bot] 5c328667a9 Bump @typescript-eslint/eslint-plugin from 8.63.0 to 8.65.0
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 8.63.0 to 8.65.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.65.0/packages/eslint-plugin)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 21:17:47 +00:00
Lauri Ojansivu 72ea1282f0 Merge pull request #6563 from wekan/dependabot/npm_and_yarn/sinon-22.1.0
Bump sinon from 22.0.0 to 22.1.0
2026-08-02 00:14:33 +03:00
Lauri Ojansivu d949bc6c04 Merge pull request #6562 from wekan/dependabot/npm_and_yarn/typescript-eslint/parser-8.65.0
Bump @typescript-eslint/parser from 8.63.0 to 8.65.0
2026-08-02 00:14:18 +03:00
Lauri Ojansivu fe185f5c28 Merge pull request #6561 from wekan/dependabot/github_actions/docker/login-action-4.6.0
Bump docker/login-action from 4.5.1 to 4.6.0
2026-08-02 00:13:57 +03:00
github-actions[bot] bd8c279ba7 Bump versions for v10.54 2026-08-01 21:03:10 +00:00
Lauri Ojansivu c8edab777f Prepare v10.54 release 2026-08-02 00:02:03 +03:00
Lauri Ojansivu 02d9734c5a Changelog: nesting workspaces, and the themed Change Password button.
Thanks to xet7 !
2026-08-01 23:54:43 +03:00
Lauri Ojansivu 73a136bedd Workspaces nest: drop into one to make it a sub-workspace, and fold the tree.
A workspace held boards and, in the data, other workspaces - but a drag could
only ever say one thing about them, and the fold was not there at all.

WHERE IN A ROW it is dropped is now what the drop MEANS:

  the top quarter    - it becomes that row's previous sibling
  the bottom quarter - it becomes that row's next sibling
  the middle half    - it becomes that row's LAST CHILD, a sub-workspace

The middle is the biggest target on purpose: reordering can also be reached by
aiming at the neighbouring row's far edge, but nesting has only this one. Coming
back up is a drop like any other - a child dragged onto a root row's edge is a
root again - because nesting has to be undoable or a workspace put one level too
deep is stuck there.

THE PLACEHOLDER IS A SLOT, NOT A LINE. While the pointer is over a row an empty
slot a row high opens above it, below it, or - for "into this one" - INDENTED
underneath it, which is exactly where the workspace will appear. A line between
two rows is a target that has to be aimed at; a slot is a place to drop into.
The slot is a pseudo-element of the row, so the rows below shift down and the
row being aimed at stays where the pointer put it.

A workspace may not be dropped into itself or into its own descendant: the
subtree would be cut off from the root, taking every workspace under it. It is
refused by NOT calling preventDefault(), which is how HTML5 drag and drop says
no, so the cursor refuses while the workspace is still in the air rather than
the drop landing and quietly doing nothing.

AND THE DROP THAT DID NOTHING. With the handles off the drag starts on an
ANCHOR, and a browser puts that anchor's own text into text/plain. The drop read
text/plain first and took the workspace for a BOARD, so it assigned nothing and
the row snapped back - which is why the same drop worked with handles on, where
the drag starts from a span that carries no text. The drag clearData()s before
setting its own type, and the drop decides on the workspace id FIRST.

A CARET FOLDS a workspace's own workspaces away, at the start of the row -
before the drag handle, so the carets of a tree line up whatever the handles
toggle says. It is the control a list has on a board and the one the left menu
itself has, down to the two words in its tooltip, and it answers Enter and Space
because a tree that only opens with a mouse is a tree half the readers cannot
open. A workspace with nothing under it keeps a spacer of the same width, so a
row does not shift sideways the moment it gains its first child.

Open is the default and only the FOLDED ones are stored: a missing key is an
open workspace, so fifty workspaces with two folded is two keys. Remembered in
the same three layers as the rest of this menu - a Session value, then
profile.collapsedWorkspaces, then a cookie for a reader who is not signed in.
The server method takes the id into a DOTTED FIELD PATH and the tree is written
by the client, so the id is checked against ^[A-Za-z0-9_-]{1,64}$ first: one
carrying a `.` or a `$` would address a different field.

The depth is UNLIMITED because nothing counts it: the template draws itself for
a workspace's children, each level indenting by one caret's width with a logical
property, so a right-to-left tree indents from the right by itself.

What a drag does to the tree is a pure module with its own tests -
dropPosition() and moveWorkspace(), the guards included - so the rules can be
proved without a browser, and the page is pinned to calling them rather than
re-deriving them.

Thanks to xet7 !
2026-08-01 23:51:56 +03:00
Lauri Ojansivu 472ed566c8 Change Password's button is themed, like the Save button beside it.
Member Settings / Change Password renders +atForm(state='changePwd') from
useraccounts, and its submit button carries that package's own classes - a
button.at-btn with no .primary of ours on it. So it fell back to the plain grey
button rule, while the Save button one entry above it in the same menu was
painted with the theme accent. Two buttons, one menu, two looks.

It is named in the SAME rules as .primary rather than given a copy of them, so
the accent and the hover and active states keep one home. Scoped to a popup,
because the login page styles that form its own way and is not what this is
about.

The guard in themeAccents.test.cjs pinned `button.primary {` with the background
on the next line, so adding a selector to that list read as the rule having lost
its colour. It looks for the RULE a primary button gets, whatever else shares
its selector list.

Thanks to xet7 !
2026-08-01 23:51:20 +03:00
dependabot[bot] 29326658a2 Bump sinon from 22.0.0 to 22.1.0
Bumps [sinon](https://github.com/sinonjs/sinon) from 22.0.0 to 22.1.0.
- [Release notes](https://github.com/sinonjs/sinon/releases)
- [Changelog](https://github.com/sinonjs/sinon/blob/main/CHANGES.md)
- [Commits](https://github.com/sinonjs/sinon/compare/v22.0.0...v22.1.0)

---
updated-dependencies:
- dependency-name: sinon
  dependency-version: 22.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 20:33:11 +00:00
dependabot[bot] 872cb90f94 Bump @typescript-eslint/parser from 8.63.0 to 8.65.0
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.63.0 to 8.65.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.65.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.65.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 20:33:02 +00:00
dependabot[bot] cf96e27204 Bump docker/login-action from 4.5.1 to 4.6.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.1 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7...dbcb813823bdd20940b903addbd779551569679f)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 20:32:40 +00:00
Lauri Ojansivu a440ef81aa Changelog: the selected workspace's board count.
Thanks to xet7 !
2026-08-01 23:32:15 +03:00
Lauri Ojansivu b50ccc63a8 The selected workspace shows its board count again.
The count was there, at the right of the ⋯ menu where it belongs, and it was
invisible.

The accent fills `.js-select-space` - the icon and the name - and NOT the whole
row: the menu button and the count sit after it, on the panel's own light grey.
A rule gave the count a white pill with white text "for contrast against the
filled row", but the count is not on the filled row, so it was white on light
grey. The selected workspace was the one row whose board count could not be
read, and it is the row you have just asked which boards are in.

The rule is gone rather than re-tinted: there is no accent behind the count to
contrast with, so it keeps the same grey pill every other row has.

While here, the count and the menu button are `flex: 0 0 auto` on the desktop
rules too. The name's anchor is `flex: 1`, so without it a long workspace name
shrinks its two neighbours instead of ellipsing itself - which pushes the count
off the row that is supposed to be showing it. The phone rules had said this
already; the desktop ones had not.

The guard in archivePage.test.cjs pinned the white pill as the correct answer,
which is how this survived. It pins the opposite now, and says why.

Thanks to xet7 !
2026-08-01 23:31:46 +03:00
Lauri Ojansivu 25709577a8 Changelog: the workspace drag source is the icon and the name.
Thanks to xet7 !
2026-08-01 23:25:10 +03:00
Lauri Ojansivu 61bfcc0522 Without drag handles, a workspace is reordered from its icon and name.
With the handles turned off the drag source was the workspace's ICON alone, and
in practice reordering only worked with the handles on, from the handle.

Two reasons, and the second is why it did nothing rather than being merely
awkward:

  * a 16px glyph is a target that has to be aimed at, and the name beside it is
    the part of the row a reader is actually pointing at;

  * the anchor is not opted out of the page-level dragscroll, which takes the
    mousedown, so the drag never begins - and a drag that never begins is
    exactly what "reordering does not work" looks like.

The whole icon-and-name anchor is the drag source now, with `nodragscroll` on it
like the handle has, and `user-select: none` while it is the drag source: the
name is TEXT, and a press-and-move over selectable text starts a SELECTION,
which the browser then owns instead of starting an element drag. The handle
never needed that because a glyph in a span is not text anybody selects.

Clicking that same anchor still opens the workspace - a click and a drag are two
gestures on one element, as they are on a board tile with handles off - and the
cursor stays `pointer` rather than becoming `grab`, because opening it is what a
reader does with the row nearly every time.

Still NOT the whole row: it also holds the ⋯ menu and the board count, and a
drag started on those is a drag of something else. draggable lives on the handle
or on the anchor, and the dragstart handler stays on the row, which works
because the event bubbles up from whichever child started it.

Thanks to xet7 !
2026-08-01 23:24:42 +03:00
Lauri Ojansivu 9cd02bad2d Changelog: the workspace drag handles and the Notifications panel.
Thanks to xet7 !
2026-08-01 23:16:33 +03:00
Lauri Ojansivu 551a44d769 The Notifications panel starts below the header, so the avatar is not over it.
The panel is fixed at top: 48px - a guess at the height of ONE header bar. The
first header bar wraps to a second and a third row, and the user avatar is the
item that wraps last: on a window where it did, the panel covered the row the
avatar was on, and the avatar - which is inside a z-index: 1000 bar - painted
straight over the panel's own header, beside the X that closes it.

It starts at --wekan-header-height now, which is what the header MEASURES itself
to be, kept current by a ResizeObserver because a bar re-wrapping is not a window
resize. The panel's own fixed header - the row the X is in - follows the same
number, and so does the height it may take: the 100vh - 28px - 36px it had was
the same guess written as a subtraction in two pieces.

That variable is what everything else laid out against the viewport already uses
- the phone sidebar, the All Boards layout and its sidebar - so this is the panel
joining them rather than a new mechanism.

Thanks to xet7 !
2026-08-01 23:16:01 +03:00
Lauri Ojansivu c7bf2958a3 A workspace row obeys the drag-handles toggle, and its icon drags without one.
The workspace rows in the All Boards left menu drew a ✥ handle whatever the
"Show desktop drag handles" toggle in the first header bar said, and the handle
was decoration: the whole ROW carried draggable="true", so the drag started
anywhere on it. A row that is draggable as a whole starts a drag on the way to a
click, and a workspace row is a row you click - it is how a workspace is opened.

The handle follows the toggle now, through the same
isTouchScreenOrShowDesktopDragHandles helper the board tiles beside it use, so
the two cannot drift apart. With handles ON the handle is drawn and it is the
only place a workspace drag may start. With handles OFF there is no handle and
the workspace's own ICON is what reorders it - the icon rather than the row, so
the name beside it stays a plain click target.

draggable lives on the handle or on the icon, never on the row. The dragstart
handler stays on the row, which works because dragstart bubbles up from
whichever child started it, so the reordering itself is untouched. The grab
cursor follows the same `draggable` attribute - `[draggable="true"]` in the
selector - rather than a second class, so there is one answer to "is this the
drag source right now" instead of two that can disagree.

Thanks to xet7 !
2026-08-01 23:13:22 +03:00
Lauri Ojansivu 98e2aa0d87 Changelog: where the header bar starts, the view popup, and Archive.
Thanks to xet7 !
2026-08-01 23:10:16 +03:00
Lauri Ojansivu cdb6113401 Archive opens the section of All Boards, not the full-width page.
Member Settings / Archive sent the reader to /archive - the full-width page -
and Boards in Archive is a SECTION of All Boards now: a row in its left menu,
drawn beside it like Starred and Remaining. The page is the thing that section
replaced. Landing on it meant the same list of boards with no menu beside it, no
way across to another section without going back first, and the menu row that
says "Archive" was not the row you had arrived at. A menu entry should land you
on the same Archive the menu itself offers.

All FOUR entry points are the same line in a different menu - the board menu,
the member menu, the board sidebar and the All Boards sidebar - so all four go
to /allboards/archive now, built with allBoardsPath(SECTION_ARCHIVE, []) rather
than by spelling the path out in four places that can drift apart. The member
menu also closes itself behind the click, like every other entry in it that
navigates.

/archive is still a route and still renders, so a bookmark from before does not
break; nothing in the UI points at it any more.

The guard pinned FlowRouter.go('archive') in all four files. The behaviour
deliberately changed, so the guard follows it and says why - and it now checks
that the helper really produces /allboards/archive rather than trusting the
call.

Thanks to xet7 !
2026-08-01 23:09:23 +03:00
Lauri Ojansivu 89d12c6fe6 The All Boards view menu opens a titled popup, like the board's.
The Lists/Table dropdown in the first header bar opened a bare list of two
links, while the board's Swimlanes/Lists dropdown - which asks the same question
about the same kind of page - opens with "Board View" above it. A popup with no
title renders no header background and no heading, so the two looked like two
different kinds of control.

It is titled now, and titled with the BOARD's own key. The convention is
<popupName>-title, which would mean an allBoardsViewPopup-title saying the same
two words a second time - and a new key starts as English in all 142 language
files, so most languages would have shown English for a phrase they have
translated for years. Popup.open's titleKey option exists for exactly this.

The guard in allBoardsPage.test.cjs that reads the menu's event map read the
first 160 characters after it, and the comment explaining this handler pushed
the handler past that. It reads the map to its closing line now: a comment is
not a reason for a guard to fail.

Thanks to xet7 !
2026-08-01 23:05:46 +03:00
Lauri Ojansivu 93042642f4 The All Boards house starts at the same X at every window width.
The house at the start of the first header bar sat further in on a wide window
than on a narrow one, and on none of them on the line the left menu's rows below
it start on.

Its inset is a SUM, and two of the terms were added at some widths only:

  * #header carried side padding of its own - 8px below 800px, 16px between 768
    and 1024, none between 1024 and 1920, 8px above 1920. Four widths, four
    different insets. It has none now, at any width: the inset of this bar is
    the bar's own gutter, once. The gutter is on the BAR rather than on its
    first item because the bar wraps, and padding applies to every row it wraps
    onto while a margin on the first item would indent the first row only.

  * .allBoards is the SAME element as .home-icon - span.home-icon.allBoards - so
    its side padding lands between the icon's margin and the link, and it was
    15px on a desktop against 6px on a phone. It carries padding-block only now.
    That 15px was also the gap to the logo beside it; the gap is the 4 + 4 of
    the two margins, like every other pair of neighbours in this bar.

And the link's own start padding is the same 6px in every rule, phone rules
included: at 8px, and at 12px on a small phone, a bigger tap target was growing
at the START, which is the inset, rather than at the end.

So the house is at 12 (the bar's gutter) + 4 (the icon's margin) + 6 (the link's
padding) = 22px at every width - the same 22px the left menu's rows are indented
by (4px of margin plus 18px of padding), so the two are one line down the page.

The existing guard added up the three terms it knew about and got 22, and passed
while the house still moved, because the two terms above were not in its sum.
The new one pins the other side of it: no rule, at any width, may add a side
inset of its own.

Thanks to xet7 !
2026-08-01 23:02:13 +03:00
Lauri Ojansivu c00103d1cc Changelog: the drag-to-resize tooltip in every language.
Thanks to xet7 !
2026-08-01 22:37:58 +03:00
Lauri Ojansivu 720d451d64 Translate "Drag to resize left menu" into every language.
The grip on the left menu's inner edge got its tooltip this release, so
drag-to-resize-left-menu was English in all 142 languages. It is translated
directly now - no external translation service, API or key - from each
language's OWN existing strings: its drag-to-resize-sidebar sentence gives the
pattern and the verb, and its own words for "left" and "menu" replace the
sidebar, so the two controls read as the pair they are.

Applied through fill-translations.mjs, which writes ONLY into placeholders, so a
human translation cannot be overwritten by this; verify-human-preference.mjs
still passes. Filled strings stay local and are not pushed to Transifex.

Klingon, Volapuk, Venda and Tamazight are the least certain: those files hold
another language's text in some neighbouring keys, so their own sidebar
sentence, not the file's other rows, is what each was built from.

Thanks to xet7 !
2026-08-01 22:37:23 +03:00
Lauri Ojansivu d81739f8d1 Updated translations. 2026-08-01 22:28:50 +03:00
Lauri Ojansivu af7ee58fe6 Changelog: the notifications spec and the header bar it looks for.
Thanks to xet7 !
2026-08-01 22:23:13 +03:00
Lauri Ojansivu e8d9c06a7c The notifications spec looks for the header bar that exists.
Spec 08's "notification indicator appears after being mentioned" waited for
`header, #header` to be visible. The first header bar was rebuilt this release
and there is no <header> element and no #header id any more: the bar is
#header-quick-access[role=navigation], which is what specs 18 and 19 already
address it by. The locator matched nothing, so the test asserted that a
non-existent element was visible and failed in all three browsers.

The guard is what changed, not the app - the bar was deliberately rebuilt - so
it now names the bar that is there, and says so in the test for the next reader.
It also asserts the BELL inside that bar, which is what the test is about: the
count beside it arrives asynchronously, so asserting the count would be timing
rather than behaviour, but a notification the user cannot see the bell for is
not a notification.

Thanks to xet7 !
2026-08-01 22:22:47 +03:00
Lauri Ojansivu f138815063 Changelog: the left menu that folds away and is dragged wider.
Thanks to xet7 !
2026-08-01 22:15:20 +03:00
Lauri Ojansivu 920d42b6db The left menu is resized by dragging its inner edge.
The menu's inner edge - the right one while reading left to right, the left one
under a right-to-left language - now carries a grip, and dragging it changes the
width. It is the same control the right board sidebar has on its own inner edge:
the same 6px strip, the same col-resize cursor, the same nodragscroll so a drag
resizes the menu instead of panning the page behind it, and the same sign flip
on the delta so the widening drag mirrors under RTL rather than needing a second
rule kept in step.

ONE number, on <html>. The width is a CSS custom property,
--wekan-left-menu-width, and everything that needs it reads that: the Admin
Panel's menu, the All Boards one - whose grid track is auto, so the track
follows the menu - and the grip itself, which sits at calc(width - 3px) from the
row's inline start. An inline width on one element could not have done that: the
menu is a different element in a different template on each page.

The default is the number in the stylesheet, and a breakpoint that wants a
different default overrides the VARIABLE, never the menu's width - a rule naming
.side-menu would beat a dragged width, which arrives as an inline property on
<html>, at exactly that one screen size. For the same reason the client REMOVES
the property when nobody has dragged it rather than writing the default into it.
The Admin Panel menu's own width was min(250px, 32vw), which only ever resolved
to 250px above the 800px breakpoint; it is the shared 260px now, because the two
pages draw one menu and a menu has one width.

The grip is positioned against the row/grid around the menu, not inside it: the
menu is its own scroll area, and a handle within it would scroll away with the
entries. Being absolutely positioned it is neither a flex item nor a grid item,
so it adds no column and no gap - which is why the shared menu template can
carry it as a second root and every Admin Panel pane gets it without naming it.

Clamped to 160px..min(600px, window - 240px), with no drag on a phone, where the
menu is full width above the content, and none while it is folded, where there
is no edge to drag. The All Boards menu also takes max-width:100% now, because
the phone rules cap its track at 210px and a fixed width ran out past it.

Remembered in the same three layers as the fold: a Session value first, then
profile.leftMenuWidth, then the wekan-left-menu-width COOKIE when nobody is
signed in - the same cookie mechanism the fold uses rather than the localStorage
the right sidebar's width uses, so one reader's menu is not remembered in two
different places. Saved once, when the drag ends; while dragging, the property
is written directly so the edge follows the pointer without a database write per
pixel. The server method clamps again and refuses NaN, which passes
check(width, Number) and would otherwise store a width no page could recover
from.

Thanks to xet7 !
2026-08-01 22:13:03 +03:00
Lauri Ojansivu 8ef151696d Folded, the left menu is gone - and the pane title brings it back.
Folded, the menu was a narrow strip with the caret alone in it. A strip is still
a column: it holds width, it keeps the page from starting at the window edge,
and a caret alone in it is a target that has to be aimed at.

Folded now means gone - no column and no strip - and the way back is the PANE
TITLE beside it: the caret moves to the inline start of the heading, and the
caret and the title are ONE target, so clicking anywhere on it brings the menu
back. Nothing is lost by drawing nothing, because the way back is the largest
thing on the page rather than the narrowest. The heading is drawn even for a
pane with no title of its own - paneTitle() returns {} when no entry is active -
so a folded menu can never be a menu you have lost.

One caret template still, included in both places on both pages, so they cannot
drift. Beside a title it is handed the pane's text as paneTitleKey / paneLabel
rather than the titleKey / label paneTitle() itself uses, so the caret at the
top of the menu, which inherits whatever context the menu sits in, can never
pick a title up by accident. It is an anchor with no href, so it carries
role="button", tabindex="0" and a keydown handler for Enter and Space: without
them the menu could be folded with a mouse and only with a mouse. Its tooltip
and aria-label are one string from a helper that names the pane as well beside a
title, because an aria-label REPLACES the text inside the element and the bare
word would have silenced the heading.

Which element takes the collapsed class differs by page, and that is the point.
The Admin Panel marks its menu: it is a flex row, and a hidden flex item closes
the row up by itself. All Boards marks the GRID, because display:none takes an
element out of a grid but the TRACK it was placed in remains - hiding only the
menu would have left the boards in the auto column with an empty 1fr one beside
them. The grid drops to one column and hides the menu in the same rule, with
!important, because the phone rules set both tracks with an !important of their
own and a phone would otherwise be the one screen still holding an empty column
open.

Thanks to xet7 !
2026-08-01 22:02:14 +03:00
Lauri Ojansivu a702be8e79 Changelog: the left menu caret that did nothing.
Thanks to xet7 !
2026-08-01 21:48:35 +03:00
Lauri Ojansivu 2526552764 The left menu's caret does something when clicked.
The caret that folds the left menu away - on All Boards and in the Admin Panel -
rendered, pointed down, and did nothing at all when clicked.

Its template draws the caret; the click that folds the menu and the
isLeftMenuCollapsed helper that says whether it is folded live in
client/components/settings/leftMenu.js, and nothing imported that file.
package.json sets meteor.mainModule, so the client is NOT eagerly loaded: a file
nobody imports is not in the bundle at all. The click handler was never
registered, so the caret was a dead control - and an unregistered Blaze helper
is undefined, so the panel never took the collapsed class either, which is why
even the caret itself never turned to point right. The same thing had happened
to adminReports.css, which is why the Admin Panel reports lost their styling.

One import fixes it. The new guard is what stops it happening a third time:
tests/clientBundleImports.test.cjs walks the import graph from client/main.js
and pins that every file under client/components which REGISTERS something with
Blaze - a template's events, helpers or lifecycle, a global helper, a
BlazeComponent - is reachable from the entry point, and that every stylesheet
and template beside them is too. A file that only exports helpers is left alone:
whoever uses it pulls it in. It carries the negative case as well, checking that
removing this very import is reported rather than shrugged off.

Thanks to xet7 !
2026-08-01 21:47:51 +03:00
Lauri Ojansivu e3093f16e8 Changelog: the folding left menu.
Thanks to xet7 !
2026-08-01 15:39:54 +03:00
Lauri Ojansivu 5df6f28421 Left menus fold away, with the caret a list already has.
At the top of both left menus - All Boards and the Admin Panel - there is now a
caret that folds the menu away, and the same caret brings it back. It is the
control a list has on a board: pointing down while the thing is open and right
once it is folded, with the same two words in its tooltip, so there is nothing
new to learn.

Folded, the menu is a strip holding only its caret and the page beside it takes
the width back. Folded rather than hidden outright: a menu with no way back is
a menu you have lost. Everything but the caret goes through a :not() on the
caret rather than a list of names, so a row added later cannot be left sticking
out of a narrow strip. On All Boards the width moved from the grid track onto
the menu itself, because a fixed 260px column would hold the gap open beside a
folded menu.

One state for both pages: they draw one menu, and a reader who folds it away on
one of them has said what they want on the other. It is a Session value first,
so the fold is instant rather than waiting for a round trip, then
profile.leftMenuCollapsed on the user document, and a cookie when nobody is
signed in - a public board has this menu too - through the same cookie helpers
the public list and swimlane collapse states already use.

One caret template for both menus, so the two cannot drift into two different
carets, and the helper that reads the state is global because the panel that
takes the class is a different element in a different template on each page.

Thanks to xet7 !
2026-08-01 15:39:41 +03:00
Lauri Ojansivu 6fbeb4c351 Changelog: the All Boards pane heading.
Thanks to xet7 !
2026-08-01 15:29:40 +03:00
Lauri Ojansivu a88dc8cbba All Boards: every section names itself at the top of its pane.
The right pane began with board tiles and nothing said which list they were.
The page is five lists of boards under one name - Starred, Remaining, Home,
Templates, Archive, and a workspace - so the pane now opens with a heading
naming the one you are looking at.

It is the Admin Panel's own paneTitle template with the same .admin-pane-title
class, so the two pages have one heading at one size and colour rather than two
written twice that drift apart. Only the space below it is set here: the Admin
Panel's gap rule is scoped to its own main-body and does not reach this page.

Drawn once, above the view branch, since the board icons and the Table are two
ways of showing the same section rather than two sections.

Its words are the section's own title key - the same key the first header bar
names the page with and the same one the highlighted menu row carries - so all
three say the same thing. A workspace shows its own name instead, untranslated:
a workspace called "starred" is not the Starred section.

Thanks to xet7 !
2026-08-01 15:29:26 +03:00
Lauri Ojansivu 40325c88fd Left menus keep the neutral panel with the themed selected row.
Both left menus - All Boards and the Admin Panel - are a light grey card, and
the entry you are on is filled with the theme colour with a white label and
icon: the same treatment the selected tab gets in the bar above it. A menu is
chrome, and tinting the whole card fights the content beside it.

The panel, the labels, the hover, the dividers, the group headings and the
counts are all as they were, and every theme's own header rule names the
selected row again - including the colour slide of a clear* theme, which a
--theme-accent value cannot carry.

The guards and docs/Design/Page/Left-Menu.md describe this arrangement.

Thanks to xet7 !
2026-08-01 15:23:42 +03:00
Lauri Ojansivu 13ce5cdb09 Changelog: group Upcoming by area, and open it with a summary.
The Upcoming section is thirty-eight entries, and twelve of them began with
"All Boards" while five more began with "the header bar". The area was repeated
in every summary, so the part that differs started halfway through the line and
the release read as one flat list of forty collapsed blocks.

Each subsection now names an area ONCE, as a bold line with a short description
of what the group covers, and the entries under it drop the prefix: "All Boards
- its sections, its controls, and what the page opens on." then "Home, a
section for the board that opens after login". Not a heading - a ## inside a
release breaks the version list - and the group labels are the areas of the
app, reused across subsections when an area has both a feature and a fix.

Above the first subsection there is now an "In short:" paragraph saying what
the whole release amounts to, with the notable names in bold. It is the first
thing a reader sees, and a release this size otherwise gives no way to tell
what it is about without opening forty blocks.

Every entry, every commit link and every entry body is unchanged; only the
summaries lost their prefixes and the order changed.

CLAUDE.md states both conventions, and two guards check them: that Upcoming
opens with the summary paragraph and that no summary repeats the group it sits
under. The group-line check only reads lines OUTSIDE an entry - an entry body
uses bold for emphasis, and a paragraph opening with one and ending in a full
stop would otherwise be read as a group heading.

Thanks to xet7 !
2026-08-01 15:13:04 +03:00
Lauri Ojansivu 9a2f4a9a4b Changelog: the inverted left menus.
Thanks to xet7 !
2026-08-01 14:59:44 +03:00
Lauri Ojansivu 83e832e9e7 Left menus: the menu carries the theme, and the selected row is the white one.
Both left menus - All Boards and the Admin Panel - were a light grey card with
one row filled in the theme colour. That made the filled row the only part of
the page answering to the theme, so a menu read as a plain grey column with a
coloured stripe in it rather than as a side of the app, next to the header bar
it sits under.

Inverted: the PANEL is the theme colour with white labels and icons, and the
row you are on is a white block cut out of it with dark text. It is also the
easier selection to find - the eye goes to whatever differs from its
surroundings, and one white row in a coloured column differs more than one
coloured row in a white one.

The menu is the colour of the first header bar above it, including the colour
SLIDE of a clear* theme, which a --theme-accent value cannot carry. Every
theme's own header rule now names both menus: before this only six of them
named the All Boards menu, so most themes coloured one menu and left the other
one plain. No theme rule may name the selected row any more - it would paint
that row the same colour as the menu behind it and leave nothing to show which
row is selected - and a guard checks that.

Hover on an unselected row is a wash of white over the theme colour rather than
solid white, which would be indistinguishable from the selected row. The
dividers, the group headings and the counts went with the rows, for the same
reason: a dark rule on a coloured panel is a rule nobody can see.

Two pairs of duplicate count rules were merged while doing this. Each had a
base rule and a colour override with the same selector, which is how a guard
came to read the wrong one.

Documented in docs/Design/Page/Left-Menu.md.

Thanks to xet7 !
2026-08-01 14:59:31 +03:00
Lauri Ojansivu 2fae0d2442 Changelog: bookmarks, and the tab that says where you are.
Thanks to xet7 !
2026-08-01 14:50:08 +03:00
Lauri Ojansivu 1f2044219a Starred: bookmarks to any page, not only boards.
The star group in the first header bar is a bookmarks menu now: caret, count,
star. It held boards only - a board could be starred and reached from the
dropdown, and every other page could not, so the one control for "keep this
where I can get at it" worked on one kind of destination and was absent on the
rest, even though those pages have had their own addresses since the All Boards
and Admin Panel URLs landed.

The star on any other page stars THAT PAGE. A bookmark is a relative URL and
the title from the browser tab: relative so it survives the site moving to
another host, and an absolute or protocol-relative URL is refused rather than
cleaned up, because the stored value goes into an href the reader clicks.

The browser tab now says where you are - "Product name - All Boards /
Remaining" - instead of only the product name, so ten open tabs of one WeKan
are no longer ten identical tabs and a bookmark of any of them is named after
the page. The path is the one the header already computes for the title's
tooltip, published through a leaf module rather than worked out twice.

In All Boards / Starred the bookmarks are tiles beside the starred boards, with
the white border the template-container tile carries, the theme's own colour
behind them, and both the title and the URL - the title says where it goes, the
address says what it is. Each tile has its own unstar button, because the star
in the header stars the page you are on.

Dragging a tile past another reorders them, and that is the order of the header
dropdown: the two are views of one array. The move matches both ends by URL
rather than by index, since the two views are rendered separately and an index
from one is a guess about the other.

The list is capped at 50, oldest dropped, because it is a dropdown.

Documented in docs/Features/Board/Starred.md.

Thanks to xet7 !
2026-08-01 14:49:52 +03:00
Lauri Ojansivu 50ca0c91b4 Changelog: the header bar's indent, and where it comes from.
Thanks to xet7 !
2026-08-01 14:35:40 +03:00
Lauri Ojansivu 28a5973c6b 1st top header bar: start where the left menus start.
The All Boards and Admin Panel left menus indent a row by 4px of row margin
plus 18px of link padding, so a row's icon lands 22px from the window edge. The
first header bar sat flush against the edge, so its home icon started 10px in
and every button under it was a little to the left of the menu below.

The bar now carries a 12px inline gutter: 12 + 4 + 6 = 22, the same x as the
menu rows. It is on the BAR rather than on its first item because the bar
wraps - padding applies to every row it wraps onto, while a margin on the first
item would indent the first row and leave the second starting at the edge.

The number is a variable, --wekan-header-gutter, so it has one home, and the
phone overrides that set the bar's padding use it too rather than putting the
bar back against the edge at the widths where the alignment matters most.

The guard adds the three numbers up out of the three stylesheets rather than
pinning any one of them: a test that asserts "18px" passes while the alignment
is broken by a change to one of the other two. It also checks that the two left
menus indent by the same amount as each other - they are meant to look like one
kind of menu - and that no override sets the bar's inline padding to anything
but the gutter.

Thanks to xet7 !
2026-08-01 14:35:25 +03:00
Lauri Ojansivu aa16115c7d All Boards Home: the launcher's Remove bar, and one destination for the drag.
Home no longer takes the top row of the left menu. The top row stays the one
the page opens on - Starred when anything is starred, Remaining when nothing
is - and Home sits under the two of them: after login you are already in the
Home board, so the row that names it is a place to look, not the place to
start.

Taking a board off Home is now the Android launcher's gesture. Pick the board
up in Home and a Remove bar appears above the tiles, only while the board is
actually in the air; drag the board onto it and it turns red; let go and it
asks, and the question says the board itself is not deleted. An affordance that
shows up when the gesture is possible explains itself, and a trash can sitting
permanently under somebody's boards is a button nobody dares press.

Every other target now REFUSES a board that came from Home, in dragover, by not
calling preventDefault - which is how HTML5 drag and drop says no, so the
cursor says no while the board is still in the air. That check cannot call
getData: the drag data store is protected until the drop and only the list of
types is exposed, so the fact lives in the type's name,
application/x-board-from-home, and its presence is the message.

Before this, a board dragged out of Home onto any other row cleared Home on the
way past. That made every drop a Home drop, and a board could leave Home by
accident while being filed into a workspace. One gesture, one destination.

Also repoints two Upcoming CHANGELOG links at the commits that are actually in
history: amending a commit after writing its hash into the CHANGELOG left both
pointing at objects the branch does not contain.

Thanks to xet7 !
2026-08-01 14:32:45 +03:00
Lauri Ojansivu eea1f2ffed All Boards: Home, a section for the board that opens after login.
One board can be Home: logging in opens it instead of the All Boards page.
That has always existed, set from Multi-Selection's "Set as Home board (opened
after login)", but there was nowhere that said which board it was - the setting
was write-only, and the only way to find out what you had chosen was to log
out.

Home is now a section of All Boards with a row in the left menu, a home icon
and the word Home, a count beside it, and the address /allboards/home. It is
the first row when a Home board is set - it is the board this user starts in -
and still there, further down, when none is, because the place to drop a board
onto has to exist before there is anything in it.

Drop a board on the row to make it Home. A drop replaces rather than toggling:
Home holds one board, and a drop that sometimes set and sometimes cleared would
depend on state the reader cannot see while dragging.

Home is a mark on a board, not a place boards are kept - exactly like a star.
The board stays in Remaining, or in its workspace, and appears in Home as well;
dragging it out of Home onto any other row takes it off Home and leaves it
where it lives. That is why the drag carries where it started.

The server accepts only a board the caller is a member of and that is not
archived, and clears only the board that is actually theirs. Nothing automatic
writes it; Sandstorm's auto-open still persists nothing.

Documented in docs/Features/Board/Home.md.

Thanks to xet7 !
2026-08-01 14:21:16 +03:00
Lauri Ojansivu 50a1d4263e All Boards: open Starred, or Remaining when nothing is starred.
Starred was always the section All Boards landed on. On an account that has
starred nothing that is an empty page with a full one behind it, which reads
as WeKan having lost the boards rather than as a section nobody has filled in.

Now the address that names no section opens Starred when the user has starred
boards and Remaining when they have none, and the left menu puts whichever one
that is on top, so the highlighted row is the first row. Only those two move.

The rule is defaultSection() / menuSectionOrder() in models/lib/allBoardsUrls.js
so the page and its guard read one copy of it. The router no longer answers the
question - it runs before the user document has necessarily loaded - and the
page answers it in an autorun, from the user document's own starred list rather
than from the query that counts boards, whose answer depends on the
subscription and would draw Remaining and then jump to Starred mid-load. An
address that does name a section still wins.

The four menu rows became one row drawn once per section, since an order that
depends on the user cannot be four copies of the same markup.

Thanks to xet7 !
2026-08-01 14:07:12 +03:00
Lauri Ojansivu 91981272eb All Boards: Remaining is the first row of the left menu.
Remaining, then Starred, Templates, and the Archive last.

The order is pinned as a decision rather than left to whichever row happens to
be written first in the markup - the guard reads the four data-types out of the
menu and compares the list, so a reorder is a deliberate edit to a test rather
than something that drifts.

The page still OPENS on Starred: moving a row is not the same as changing which
section is shown by default, and xet7 asked for the first.

Thanks to xet7 !
2026-08-01 13:53:57 +03:00
Lauri Ojansivu 27cfc214b5 All Boards / Archive is board icons, and a board can come back by dragging it.
The Archive had a table of its own, which meant the one place you go to bring a
board back was the one place multi-selection, dragging and the tile layout did
not work. It is drawn with the same board icons every other section uses now,
and the differences are the ones that have to be different: no "Add Board" tile,
because a board cannot be created already archived, and each tile says WHEN it
was archived, which is what tells one old board from another.

Four things had to change for that.

The section's query asks for archived boards - the one section whose query is
the opposite of every other one's - and the menu filter has its own branch for
it, because a workspace assignment survives archiving and the workspace branch
would have hidden most of an archive.

The page subscribes to them, and only while that section is open. The count
beside the menu row comes from a method and says how many there are, not what
they are. The autorun is placed AFTER selectedMenu and boardSearchVar are
assigned: an autorun runs once immediately, so above them it read .get() off
undefined and threw during onCreated - which it did, and xet7 saw it.

The publication sends what a tile draws. It sent seven fields to a template that
reads twelve, which would have rendered grey, nameless tiles.

And the board publication no longer withholds an archived board. It excluded one
unless it was a template, so following a tile's link answered "board not found".
Archived is not a permission: what decides whether the document may be sent is
the membership clause beside it, and that is untouched. The isArchived argument
still governs the CONTENTS, so an archived board opens showing its live lists.

Dropping a board on Remaining brings it back - one board or a whole
multi-selection, which is the point of dragging rather than opening each board's
own menu. It restores through a new restoreBoard method gated exactly as
archiveBoard is: board admin or a global admin. The two must agree, or one of
them is the wrong way round, and the guard checks them against each other rather
than each against a copy of the rule.

Also, from the same screenshots: the home icon and the notification bell now
begin a row at the same x - 4px of margin and 6px of padding each, where the
bell carried 10px from its own stylesheet and the house 8px - and the space
after the username matches the space before the avatar, which it did not on a
page whose bar ends with the name because nothing followed it to provide the
rest.

Thanks to xet7 !
2026-08-01 13:41:12 +03:00
Lauri Ojansivu 81542cfaed All Boards: the left menu says "Archive".
One word, like Starred, Templates and Remaining beside it, and the menu already
says what it is listing - "Boards in Archive" repeated that. It uses 'archives',
an existing key translated in the same 135 languages as the one it replaces, and
the one already used for the archive as a PLACE rather than as an action.

The SECTION title changes with it. A guard pins the two together on purpose -
the header's path names the section and the highlighted menu row names it too -
so renaming only the row would have left "All Boards / Boards in Archive" above
a row saying "Archive", and a reader would have had to work out whether those
are one place. The guard caught exactly that, which is what it is for.

The sidebar's own row keeps its longer label: it sits among Search and
Multi-Selection rather than among one-word sections, and xet7 asked about the
left menu.

One guard fix: the row was sliced out of the markup by a character count, and
the comment explaining this change pushed the label past the end of it. It
slices from the row's own list item to the divider that follows now, so a row
that gains a line does not need the number re-tuned.

Thanks to xet7 !
2026-08-01 13:22:27 +03:00
Lauri Ojansivu c6f266139c A selected workspace highlights, and the username's space is split.
The workspace highlight was styled for a class that never appeared. `active` is
set by `{{#if $eq id selectedWorkspaceId}}` inside `each nodes` - but
selectedWorkspaceId is a template ARGUMENT, not a helper, and inside `each` the
data context is the NODE. So the bare lookup found nothing on the node, `$eq`
compared an id with undefined, and no workspace was ever active. Two commits ago
I gave that class the theme colour and reported it done; the colour was right
and nothing wore it.

`../selectedWorkspaceId` now, in the test AND in the recursive call - the second
matters as much, because passing the bare name handed undefined to every nested
level, so a child workspace could not have highlighted even once the top level
worked.

The username's own space is split 4px each way rather than 8px stacked after the
name, so the divider on its left no longer touches it. And the avatar's end
margin is no longer zeroed on narrow screens: that was written when the name was
hidden there, so nothing followed the avatar and the gap was waste - the name is
shown at every width now and the avatar was touching it. Second rule in this
stylesheet written for a hidden username and left behind when it stopped being
hidden, so the guard walks every rule that touches the avatar rather than
pinning this one.

And a guard of mine was silently blind. ruleWith() splits the stylesheet on
braces, and I had written a comment containing `a { color: inherit }` - a brace
inside a comment breaks that splitting for every rule after it, so the rule
being looked for was simply never seen and the guard failed against correct CSS.
It strips comments from the whole stylesheet before splitting now, which fixes
it for every comment anyone writes later rather than for this one.

Thanks to xet7 !
2026-08-01 13:16:52 +03:00
Lauri Ojansivu b2114f2763 Header bar: the username, the pencil, the avatar, and the gap after the house.
Four things, all in the first bar, and three of them were things going MISSING.

The rename pencil vanished on a narrow window. The rules that collapse the home
link's label were written as '.home-icon a' - and the pencil is an anchor in the
same box, so font-size: 0 reduced its icon to nothing too. The home link carries
'header-home-link' now and all 24 of those selectors name it, so a rule about
one anchor stops applying to its neighbour.

The username was collapsed below 800px. That is gone: the bar WRAPS now, so a
name that does not fit costs a row rather than an avatar shoved off the edge,
and who you are signed in as is worth that row. The class that exempted All
Boards and the Admin Panel went with it, along with its helper - with nothing
left to exempt from, it decided nothing, and a mechanism that decides nothing
still reads as one that does.

The avatar sat low. It was floated and nudged up 5px with position: relative -
a float takes an element out of the flex alignment, so it was being lined up by
hand against a row whose height changes with the buttons in it. Its parent is a
centred flex row now and both hacks are gone.

And the gap between the house icon and the page title was 12px against the 8px
every other pair of neighbours has: the icon carries a 4px end margin and the
link added 8px of its own. The link's END padding is 4px now. Its START padding
stays at 8px - that one is the link's inset from the edge of the bar, not a gap
between two things, and the guard says so rather than pinning both to one value.

Thanks to xet7 !
2026-08-01 13:09:57 +03:00
Lauri Ojansivu 259aa81e2e Header bar: your account and the sidebar toggle sit at the end.
The divider, your avatar and name, the second divider and the sidebar hamburger
are one group now, aligned to the END of the bar - the right edge in a
left-to-right language, the LEFT edge in a right-to-left one. One
margin-inline-start: auto does both, because it is a logical property and the
mirroring is what the browser already does for the writing direction.

This is the one group that is placed rather than packed, and the exception is
the point: everything before it belongs to the PAGE and reads in source order
from the start, while these five are not about the page at all. The divider is
the seam between the two, so the bell stays on the page's side of it.

A box rather than display:contents, unlike the page's button groups. Those
dissolve so their buttons wrap one at a time; these five belong together, and an
avatar left on the first row with its hamburger on the second is worse than the
cluster moving down whole. On a row that wrapped there is no free space for the
auto margin to absorb, so it packs from the start there and fills the row.

The guard that forbids pushing anything in this bar now carries that one
exception by name, and a second guard checks the group holds those five in that
order and that the bell is outside it - so "aligned to the end" cannot quietly
become "everything after the bell", which is a different rule.

Thanks to xet7 !
2026-08-01 12:54:16 +03:00
Lauri Ojansivu b8fb5a95f1 Header bar: the username is the colour the buttons are.
It took whatever the bar passed down, because a { color: inherit }, so it did
not match Notifications and the rest sitting beside it - and it is a control of
the same bar, not a caption. It is #f2f2f2 now, the value the buttons declare,
and the guard compares the two rules rather than pinning the hex, so changing
the buttons' colour without changing this fails instead of drifting apart.

And one last auto margin is gone. #header-user-bar had margin-inline-start: auto
in layouts.css, which pushed it to the far end of the row - the behaviour
removed everywhere else two commits ago. It survived because it lives in a
different stylesheet than the others did, which is exactly why the guard now
looks for it there too.

Thanks to xet7 !
2026-08-01 12:50:35 +03:00
Lauri Ojansivu b064a509fb Header bar: the logo gets the same space on both sides as everything else.
It had 16px on its left and 4px on its right. The page title's margin-inline-end
was 1rem and the logo carried no margin of its own, so the gap on one side came
entirely from its neighbour and the other side got only the phone toggle's 4px.

Both are 4px now, like every item of this bar, so the gap between any two
neighbours is 8px - the drag-handles toggle included, which was the same fault
fixed one commit ago.

The logo needed a class before it could carry a margin: it is drawn by three
different branches - a custom logo that links, a custom one that does not, and
the stock one - and none of them had one. All three carry header-logo now, so
the spacing does not depend on which is configured, and the guard counts all
three rather than trusting that the one it can see is the only one.

Thanks to xet7 !
2026-08-01 12:46:12 +03:00
Lauri Ojansivu f435e55840 Header bar: the same space on both sides of the drag-handles toggle.
It had 18px on its left and 8px on its right, which reads as a hole rather than
as spacing.

The phone/desktop toggle beside it carried 'margin: 0 14px' while everything
else in the bar carries 4px, so the gap to its left was 14 + 4 and the gap to
its right 4 + 4. The toggle carries 4px now, like every other item, so the gap
between any two neighbours in the bar is 8px.

Equalised by making the margins the SAME rather than by picking two numbers that
add up to the same total - the guard checks both, so a future change that
balances the sum by making the parts unequal still fails.

Thanks to xet7 !
2026-08-01 12:43:41 +03:00
Lauri Ojansivu 2fb4f47120 Header bar: the board's controls wrap one button at a time, not as a block.
The first row ended after the star group with half the bar empty while the
second row was full - Private, Muted, Sort Cards and the rest had all moved down
together.

They sit in one wrapper div, .board-header-controls, and a wrapper div is ONE
item to the bar: when the last button of the group did not fit, the group went
to the second row entire. That is exactly the fault .header-quick-access-end
had, one level further in - fixed there weeks of edits ago and left here because
this group is drawn by a different template.

It generates no box now, and so do the Admin Panel's four tabs. #notifications
and the divider-plus-hamburger stay boxes on purpose: the first also contains
the drawer that opens under the bell, and the second is a pair that means
nothing apart.

Both classes are named in the selector. These elements carry .board-header-btns
too, and the flex rule beside it matches them at the SAME weight - so naming
only the modifier would have left which rule applies to source order, and my
first attempt did exactly that and lost. Checked, 120 against 110, rather than
assumed.

Thanks to xet7 !
2026-08-01 12:41:54 +03:00
Lauri Ojansivu 081e4e2881 Pager: stop wiping the gradient the line above just set.
With a colour-slide theme the pager had no background at all - clearorange's
header slid and its < and > sat on nothing.

Every fill was followed by 'background-image: none'. That line was there to
defeat the global button gradient in forms.css, and it was already redundant: a
'background' SHORTHAND resets background-image by itself. Once the fill became
--theme-accent-fill, which is a linear-gradient for the six slide themes, the
redundant line stopped being harmless and started erasing the gradient the
shorthand had just set. A flat theme was unaffected, which is why this survived
being looked at.

All seven are gone, including the ones setting a plain colour: the shorthand
covers them, and leaving four of seven would be a rule a reader has to
rediscover.

The guard walks every rule in that stylesheet which fills from the theme and
fails if any of them resets background-image afterwards - the shape, rather than
the seven places it happened to be.

Thanks to xet7 !
2026-08-01 12:33:32 +03:00
Lauri Ojansivu 08d5f57acc All Boards: line the columns up, and put the theme back in two places.
Three things.

+ Add Board, the archive's search field and its pager all began flush against
the header, so the first tile looked cut off at the top while the highlighted
menu row beside it had a clear edge above it. The right column starts 10px down
now: the menu panel's 8px of top padding plus its rows' own 2px margin. Derived
from the menu rather than picked to look right once, and the guard checks that
arithmetic rather than the number - so changing the menu's padding without
changing this fails loudly instead of drifting apart.

The pager buttons look like the theme again. They ARE filled with it, but a
disabled one was dimmed to 0.55, and dimming a filled button is exactly what
takes the theme back out: the belize blue #2980b9 arrived on the page's grey as
rgb(122,170,202), a pale wash with no family resemblance to the header above -
and on a one-page list, where BOTH arrows are disabled, that is the whole pager.
0.85 keeps it recognisable at rgb(68,142,191) and still visibly flatter than an
enabled button, which is all the dimming is for; pointer-events: none is what
actually makes it unusable.

And a selected WORKSPACE is filled with the theme, like the selected menu row
above it. It is the same kind of selection, and it was simply left behind on the
old flat #f0f0f0 when the rows were themed - so picking a workspace looked like
nothing had been picked. Its hover is listed on the active rule for the reason
the rows' is: the white hover below sits at equal specificity and, being later
in the file, would wash the selection out as soon as the pointer crossed it.
Its count becomes a light pill, because the mid-grey that read on white
disappears on the accent.

Thanks to xet7 !
2026-08-01 12:32:07 +03:00
Lauri Ojansivu 3a391ea813 Header bar: one run of items from the start, and every button keeps its name.
Two things, and the first makes the second possible.

Nothing in the bar is pushed to the far end or centred any more. Three rules did
it: an auto margin on the first button after the drag-handles toggle, which sent
everything from there to the right edge; an auto margin on the user menu on
phones; and an auto margin on EACH side of the bell, which centred it in
whatever space was left. Each of them puts a hole in the middle of the row, and
which items land on which side of the hole changes with the window width. The
bar packs from the start and wraps forward now - left to right in a left-to-right
language, right to left in a right-to-left one, which flexbox does by itself
because it follows the writing direction, so there is no second rule to keep in
step.

.header-quick-access-end stays display:contents. It carries no alignment now,
but that is what makes its buttons items of the BAR: as a flex box of its own it
was a single item, and the whole group dropped to the second row together.

And the button names are shown everywhere, at every width. They were dropped
when the bar ran out of room - first below a fixed 1100px, then by measuring
whether the bar had wrapped - and xet7 has now asked for them on boards too. So
the measurement decides nothing, and it is deleted rather than left inert: 111
lines of utils.js, the CSS rule it drove, and 43 lines of guard that described
it. A mechanism that no longer decides anything still reads as one that does.
The bar wraps, and a wrapped second row is a better answer than a row of
unlabelled pictures.

header-keeps-text stays, because it still has a job: the USERNAME, which the
narrow-screen rules collapse everywhere except All Boards and the Admin Panel.
Its comments say that now rather than talking about labels.

An older guard pinned the phone centring on purpose, with a paragraph on why the
bell sat midway. That decision is reversed, so the guard is rewritten to the new
rule and says what changed - and the new one checks the SHAPE: no rule that
positions something within the bar may use an auto margin or a justify-content.
A justify-content inside a button centres that button's own icon and is not
alignment of the bar's items, so the check is scoped to rules on the bar itself.

Thanks to xet7 !
2026-08-01 12:26:15 +03:00
Lauri Ojansivu f2b8716c8d Stop the header label fit from looping the ResizeObserver.
"ResizeObserver loop completed with undelivered notifications", from the fit I
added: it ran inside the observer's own callback, and measuring means SHOWING
the labels and possibly hiding them again - which resizes the very element being
observed. So every notification produced a write and the write produced another
notification, and the observer never settled.

Two changes, and the first is the one that matters.

The bar is re-measured only when its WIDTH has changed. In the steady state
nothing is written at all: with the labels already hidden and still needing to
be, the old code removed the class and added it back on every single cycle.
Verified by running the real function against a bar that wraps while its labels
are shown - one write to settle, then zero, then zero, and two again only after
a genuine width change.

And the fit is scheduled for the next frame rather than called inline, so the
write is an ordinary one whose resize is delivered normally instead of being
raised from inside the callback that is handling the previous resize.

The opt-out path now shows the labels in its own branch, since it returns before
reaching the measurement. Without that a page that opts out would inherit
whatever the last measured page left on the bar - a link from a narrow board to
All Boards would land there with the names hidden - and the guard checks that
branch rather than the statement order it used to rely on.

Thanks to xet7 !
2026-08-01 12:15:24 +03:00
Lauri Ojansivu 51b04f660b Header: keep the username, name the bell, and clear the grey around All Boards.
Three things.

The username stays at every width on All Boards and the Admin Panel. The
narrow-screen rules collapse it to font-size: 0 - the avatar stays, the name
stays in the DOM for screen readers - because a full name is easily 100px of a
375px bar. These two pages carry four buttons rather than a board's ten and have
the room, and the name is who you are signed in as. Every hiding selector is
answered with its own copy carrying the class: the widest of them carry
.iphone-device, which outweighs an id and a class on its own, so one short
override would have lost on exactly the narrow screens this is about - checked,
250 against 240, rather than assumed.

The class that marks those pages is renamed from header-labels-always to
header-keeps-text, and its helper to keepsText(). It keeps two kinds of text
now, and a name saying "labels" would have lied about half of what it does.

The bell is named beside its icon, from the key its own tooltip already used,
carrying the same label class as every other named button - so it appears and
vanishes with them rather than needing a rule of its own. Its stylesheet makes
it a fixed 28px SQUARE, which is right for a lone icon and clips a word, so in
this bar it sizes to its content and lays icon and name out like its neighbours.
Scoped to this bar: elsewhere the toggle is still just a bell.

And the grey above and to the left of the All Boards menu is gone. It came from
#content > .wrapper, which is margin-top: 10px and padding: 15px as well as the
width inset - the margin was the grey above, the padding the grey to the left.
My earlier override set width and margin only, so the padding survived; and it
weighed exactly as much as the rule it answered, so which applied came down to
stylesheet order. .wrapper.all-boards-wrapper carries one class more, which
settles it, and the padding is zeroed too.

Three guard mistakes of mine here, all caught by running rather than reading,
and all the same mistake: indexOf on a selector matches whichever rule comes
first, and a selector usually appears more than once - the ordinary styling, a
phone override, the rule you meant. Rather than fix each site again, this file
now has ruleWith(css, selector, declaration), which names a declaration too and
asserts it found exactly one. Its own first version failed as well, because the
regex capture runs from the previous closing brace and so carries the comment
written above a rule; it strips comments from the selector now.

Thanks to xet7 !
2026-08-01 12:08:18 +03:00
Lauri Ojansivu 880f8ced93 getArchivedBoardsCount: check the argument before returning for anyone.
Meteor audits that every argument a method was given was handed to check(), and
this one returned 0 for a signed-out caller BEFORE reaching its check(). So
instead of answering 0 it threw

  Did not check() all arguments during call to 'getArchivedBoardsCount'

The bug is older than the caller that found it. Its only caller used to be the
archive page, which asks after its own subscription is ready and therefore
always has a user, so the early return was never taken. All Boards now asks for
the count from onCreated - which can run before the user is established, and
which is the right time to ask, since the count is wanted as soon as the page
exists - and the latent bug became an exception on every load.

check() first, then authorise. Validating the shape of what you were given
before deciding whether the caller may have it is the right order regardless: a
malformed argument is malformed whoever sent it. It is also the order the
sibling getPublicBoardsCount already used, which made this one the odd one out.

tests/methodArgumentChecks.test.cjs scans the server methods that take arguments
and check them, and fails on any that can return before the first check() -
rather than pinning this one method, because the bug is a shape and the next
method to grow an early return would repeat it. I verified the guard by putting
the old ordering back and watching it fail, then restoring the fix.

Thanks to xet7 !
2026-08-01 11:58:21 +03:00
Lauri Ojansivu 040526db51 All Boards and the Admin Panel keep their button names at every width.
Those two carry four buttons each and have room for the words on any window
worth supporting, so the fit measurement leaves them alone. A board carries ten
controls, and is the page the measurement exists for - there it still drops the
names when the bar would wrap.

The header marks the bar `header-labels-always` from the route, and
fitHeaderLabels() returns early when it sees that class. Which page you are on
is the header's business; client/lib/utils.js knows only about boxes, and
teaching it about routes would have put the same question in two places.

It returns AFTER showing the labels, not before. A page that opts out would
otherwise inherit whatever the previous page's measurement had left on the bar -
so following a link from a narrow board to All Boards would land on All Boards
with its names hidden. The guard pins that ordering, because it is invisible
from reading the two lines in isolation.

Thanks to xet7 !
2026-08-01 11:52:18 +03:00
Lauri Ojansivu a82d063544 Header labels: measure the buttons, and measure them by centre.
Two bugs in the measurement I added, both visible in xet7's screenshots: the
labels were hidden on a 2560px window whose bar sits comfortably on one row.

It read bar.children, and the buttons are not among them.
.header-quick-access-end is display:contents - it generates no box, so its
buttons are flex items of the BAR while being its own children in the DOM. What
bar.children returns is the four items before it plus that boxless wrapper:
everything except the things that actually wrap. flexItemsOf() descends through
a contents wrapper now, and skips what is not drawn.

And it compared offsetTop against the first item's. The bar is
align-items: center, so everything on one row shares a CENTRE line while their
tops differ by however much their heights differ - and the page title, with its
text and its rename pencil, is much taller than a bare icon. A one-row bar
therefore looked wrapped. Centres are compared now, from getBoundingClientRect
rather than offsets, because offsets are relative to whichever ancestor happens
to be positioned and these items do not all share one.

The tolerance is 6px against a second row that is a whole row away - the
shortest button is 28px - so there is room on both sides of the test.

Verified by RUNNING the real function against a simulated one-row layout (a 28px
title beside a 20px icon, sharing a centre) and a simulated two-row one, rather
than by reading it: false and true respectively, and four items found through
the contents wrapper.

The guard that pins this reads the CODE with comments stripped. Its first
version searched the whole function body for "offsetTop" - which appears in the
comment explaining what was replaced - so it failed on a correct implementation.
That is the fifth time in this file that a guard has read its own explanation.

Thanks to xet7 !
2026-08-01 11:48:50 +03:00
Lauri Ojansivu 39dcdcb79e Header labels: measure whether they fit, instead of guessing from the width.
The labels were hidden below 1100px, and a width cannot answer the question. A
board carries ten controls, All Boards four, an Admin Panel page four tabs, and
the words are as long as the reader's language makes them. xet7's screenshots
show both failures at once: at 2266px the labels were shown and the bar wrapped
anyway, and at 1810px they were hidden with a whole row to spare.

fitHeaderLabels() shows the labels, asks the bar whether it wrapped - its last
item sitting lower than its first - and hides them if it did. A tolerance rather
than equality, because items of different heights sit on slightly different
offsets within one row and align-items: center moves them.

It always measures from the SHOWN state. Hiding the labels is what makes the bar
fit, so measuring after hiding would answer "it fits", show them again, and do
that once per frame forever. Removing the class before measuring costs one
reflow per resize and is the whole reason this cannot oscillate - the guard pins
that ordering, because it is the one thing here that is not obvious from
reading.

It runs from the same place that publishes --wekan-header-height, which already
has a ResizeObserver on this bar - and wrapping is exactly what that observer
exists to catch.

Also in this commit, from the same screenshots and the same session:

The pager buttons are FILLED with the theme and their arrows white, instead of
an outline on transparent that put the theme in a 1px border and left the button
a ghost beside solidly themed chrome. They read --theme-accent-fill, a new
variable, because --theme-accent is one COLOUR and a colour-slide theme's fill
is a gradient: a button reading the accent came out flat while the header above
it slid. activeFill() resolves it - a custom pair beats the theme's own slide,
the way a custom colour beats an accent - and the pressed state darkens that
fill rather than replacing it, which keeps a slide sliding.

And both left menus run from directly under the header to the bottom of the
window, flush to its left edge. The Admin Panel's row carried a top padding that
put grey above the menu; the main body carries it now, because content is not
meant to be flush against a bar. Both menus lost their border and corner on the
three flush sides: a rounded corner on a flush edge shows the grey behind it,
which is the gap being removed. All Boards' grid uses column-gap, since a gap is
between tracks and never insets the menu from the window edge.

One correction of mine: activeFill's comment claimed it answers null for an
unknown theme. accentOf answers '' and activeFill defers to it, so it answers ''
too. The comment now says so - the two functions sit side by side and a reader
should not have to remember which returns which kind of nothing.

Thanks to xet7 !
2026-08-01 11:40:27 +03:00
Lauri Ojansivu 9e50230b91 All Boards: keep the right edge off the window, and light out of the pager.
Two things, both of them mine.

Taking the left menu to the window's left edge dropped the wrapper's inset on
BOTH sides, so right-hand content ran to the edge and past it: the archive
pager's ">" was half outside the window, and #content is overflow-x: hidden, so
the half outside was cut off rather than scrollable. The padding goes on the
right COLUMN rather than back on the wrapper - the wrapper holds the menu too,
and an inset there would push the menu off the edge it is meant to sit against.
padding-inline-end, so a right-to-left layout insets the side that is actually
the far one.

And a disabled pager button was opacity 0.4. That was chosen when every theme
accent was a deep colour, where four tenths of it still reads on a light page.
The colour slides added yesterday are built on LIGHT bases - cleargreen #4bbf6b,
clearorange #e67e22 - and at 0.4 an outline in one of those on the page's own
near-white all but disappears. On a one-page list, where both arrows are
disabled, the pager looked broken rather than exhausted. 0.55 is still visibly
weaker than an enabled button beside it, which is the whole job of the rule,
without being a ghost.

The guard pins the inset as a logical property, pins that the wrapper stays
flush so the menu keeps its edge, and pins the disabled opacity between "still
readable" and "still obviously disabled" rather than to one number.

Thanks to xet7 !
2026-08-01 11:31:53 +03:00
Lauri Ojansivu 8897be9f05 Five more colour slides: cleargreen, clearorange, clearpink, clearpurple, clearred.
clearblue was the only theme in the "clear" category. Five more join it, each a
slide from a light tint down to an existing FLAT theme's own colour, so the two
categories now cover the same colour families: cleargreen from limegreen,
clearorange from pumpkin, clearpink from moderatepink, clearpurple from
wisteria, clearred from pomegranate. A reader who picks either version of a
colour gets the colour they expected.

The rest of each palette is arithmetic on the base, and it is clearblue's OWN
arithmetic - its numbers turn out to be exact. The border under the bar is
base x 0.8, the one above it base x 0.6, the current tab base x 1.15, a selected
minicard the base mixed toward white. Derived rather than guessed, so no theme
can be half-done and a seventh slide is one line rather than a page of hexes.

Each block was generated from clearblue's with one deliberate exception: its
COMMENTS were dropped. They explain clearblue's own history - the flat bar that
looked like strongcyan, the half-white username - and copied into a green theme
those sentences are simply false. Each block carries a header saying what it is
and what it was derived from instead. Every hex clearblue names was mapped, and
the generator refused to run if one was not: an unmapped colour would have
stayed blue inside a green theme.

Board Settings, Member Settings and Admin Panel / Settings / Visibility are one
picker with a scope, grouping by THEME_CATEGORIES - so adding each theme to the
category and to ALLOWED_BOARD_COLORS puts it in all three at once, gives it the
category's two custom colours (a slide has two ends), and validates it on the
server through the same list.

And a bug of my own, from making Boards in Archive a section of All Boards:
clicking it highlighted Remaining. The workspaces-tree autorun treats anything
that is not a section as a workspace id, and a workspace missing from the tree
was deleted, so it resets to Remaining - but the section names were written out
by hand there, and `archive` was not among them. It reads ALL_BOARDS_SECTIONS
now, the same list the router and the URLs use, so a sixth section cannot go
missing from it.

Guards: every theme in "clear" must be a gradient with two DIFFERENT ends whose
solid end is the accent everything outside a board reads - a slide theme with a
flat bar is a flat theme filed in the wrong drawer, which is exactly what
clearblue's own first bar was until it was fixed. Each must paint the picker
swatch, the board canvas, and both selected menu rows. And the archive-highlight
fix is pinned including its import, because a missing import there is a
ReferenceError at runtime that no unit test would see - which is how it first
showed up.

Thanks to xet7 !
2026-08-01 11:29:06 +03:00
Lauri Ojansivu 145d76c286 Header: show the root, put the path in its tooltip.
The bar showed the whole path - All Boards / Workspaces / Engineering /
Backend, Admin Panel / Settings / Version. The path is what the page IS, but it
grows: a workspace nests as deep as its tree does, and this bar is the one strip
always on screen and already short of width. It shows the root now, and the
whole path is the title's tooltip.

headerTitleFullPath() resolves it to one string, because a title attribute is
plain text and cannot hold the {{_ }} calls the visible version used. It reads
the SAME headerTitle() and the same trail the visible name does, so the two
cannot disagree about where you are, and a workspace's own name still does not
go through the translator.

The trail became a plain function rather than a helper. The first version of the
tooltip called sibling helpers through "this", which in a Blaze helper is the
DATA CONTEXT, not the helpers object - so it would have resolved to nothing at
all. Both callers use the function now, and the guard says why, because that is
a mistake worth only making once.

The headerTitleTrail helper is deleted rather than left: nothing draws the trail
inline any more, so it was a helper no template calls.

Thanks to xet7 !
2026-08-01 11:13:37 +03:00
Lauri Ojansivu e21a2fef1b Themes: put them on the bar that is actually painted.
The theme lived on the second header bar, and most pages no longer render one.
Three themes were wrong once it moved to the first.

clearblue is a colour SLIDE, and the slide was only ever on
'#header #header-main-bar' - the inner element of the second bar. The first bar
took the flat background-color beside it, #00aecc, which is strongcyan's colour
EXACTLY: choosing clearblue painted the header strongcyan. The slide is on
#header-quick-access now.

natural and modern each had a LATER rule repainting the first bar the near-black
shade it wore as a thin strip above the coloured main bar - #2d392b and
#333 !important. Picking Natural painted the header near-black and Modern
charcoal, whatever their own colours were. Both overrides are gone; the shared
rule provides each theme's own colour. Modern's padding and font-size went with
it: those sized the strip, and this bar sizes itself like every other theme's.

clearblue's username is full white. Half-white was right when this bar was a
secondary strip and its username and board links were background furniture; it
is the header now, and no other theme dims them.

And a slide is not a colour. Everything outside a board reads --theme-accent,
which is right for the eighteen flat themes but cannot carry a gradient - so the
All Boards left menu's selected row sat flat #00aecc beside an Admin Panel row
that slid. It is named in clearblue's own rule now, as the Admin Panel's row
always was, and its extra class makes it win over the accent rule.

tests/themeAccents.test.cjs pins every theme painting the first bar, painting it
its own colour, and NO TWO THEMES painting it the same - which is the check that
would have caught clearblue looking like strongcyan.

Two mistakes of mine here, both caught by the guard rather than by reading: the
first version of that guard anchored on a selector prefix the flat rule and the
gradient rule share, so it read the wrong rule and passed; and the first attempt
to add the tests to this file used a harness shape the file does not have, so
they silently never ran.

Recorded and NOT changed: cleanlight's accent is a mid-grey scraped from a
'#header ul li:hover' rule, because it has no plain #header rule at all. Its bar
is near-white and cannot be an accent for white text, so the grey is right by
accident. Choosing a deliberate one is a call about a colour, not a bug fix.

Thanks to xet7 !
2026-08-01 11:07:16 +03:00
Lauri Ojansivu 2790fe5092 Fix the 19 tests my header and menu restructuring broke.
The newest run (../log/2026-08-01_03-14-39) is the first since this work: 18
Playwright failures in every one of the three browsers, and one mocha failure.
The run before it had none. All of them are mine, and all of them are tests
describing markup I moved rather than behaviour that broke.

.js-toggle-sidebar was eight of them. The hamburger moved to the first header
bar and became .js-toggle-page-sidebar - one control that opens whichever
sidebar the page has. The page object and the one spec that named it directly
now name the new one. Its handlers in boardHeader.js and sidebar.js are DELETED
rather than left: markup for them no longer exists anywhere, so they were dead
code that reads as a working feature, and the injected CSS rule in boardBody.js
is repointed at the class that exists.

#header-main-bar was two: the RTL layout test used the second header bar as its
"the page has visible text" anchor, and the three list pages it checks no longer
render one. It anchors on the first bar's page title instead, which is where
those titles went.

The rules controls were three: they are a view of the shared page sidebar now,
so the specs open it first. The All Boards search field was one, for the same
reason. The card's copy-link button was two - it is a row of the card's actions
menu, so the page object opens the menu, and the test asserts what reached the
CLIPBOARD rather than reading an href that no longer exists.

That clipboard is RECORDED, not read back: reading it needs a clipboard-read
permission only Chromium can be granted, so a test that read it would pass on
one browser of three. WeKan copies through navigator.clipboard.writeText, so
wrapping that captures the real value everywhere - and it is a stronger check
than the old test, which accepted "no href, so click it and see that nothing
threw" and would have passed even if the button copied nothing.

Two were addresses: /templates now redirects to /allboards/templates, and the
admin spec navigated to /admin-reports and then clicked a menu row. Every Admin
Panel pane has an address of its own, so it goes straight to
/admin/problems/files - no redirect to race with the menu rendering.

The mocha failure is from the board-roles capability table:
allowIsBoardMemberCommentOnly asks that table now, and the table reads
board.members, while the test's stub offered only the old hasMember/hasReadOnly
methods - so it answered false for everyone. The stub is a members array now,
and a second case pins what the old one could not express: it is the ROLE that
decides, not membership, so isNoComments, isReadOnly and an inactive member are
each false. Every expectation was checked against the real function before
being written down.

Not verified by running: Playwright needs the app server and browsers, which
this sandbox does not have. Every edited file parses, and the 292 node suites
pass.

Thanks to xet7 !
2026-08-01 10:56:37 +03:00
Lauri Ojansivu cb728c334b All Boards left menu: match the Admin Panel's rows, not just its colours.
The menu had the Admin Panel's panel and its accent, but its rows were still
laid out the old way - rounded pills, floating inside a panel that added 8px of
its own padding on top of each row's margin. Two menus wearing the same colours
and reading as two different menus.

The rows now use the same margins (2px 4px), the same block padding and
reading-direction indent (12px / 18px), and NO rounded corners, so a selected
row is a block across the menu rather than a pill inside it. The panel's own
padding goes with them: the rows carry their margin themselves, and padding on
the panel inset every row a second time.

The dividers line up with the rows rather than running past their ends, and
"Workspaces" takes the Admin Panel's group-heading treatment - smaller,
uppercase, quieter - because it names a group and is not an entry of one.

Thanks to xet7 !
2026-08-01 02:29:41 +03:00
Lauri Ojansivu b45e19ef8d All Boards: take the left menu to the window's left and bottom edges.
The menu was styled like the Admin Panel's but did not sit like it. The Admin
Panel gets that for free: .setting-content is OUTSIDE .wrapper, absolutely
positioned at full width and height, so its menu is against the left edge and
runs to the bottom. All Boards is in normal flow inside .wrapper, which is
width: calc(100% - 28px); margin: 0 auto - so the menu floated 14px in from an
edge it is meant to look attached to, and stopped wherever its content did.

This page opts out of that inset, and takes its height from 100vh minus
--wekan-header-height - the height the header MEASURES itself to be, because the
bar wraps to a second row and is not one fixed number. A MINIMUM, not a fixed
height: a page with more boards than fit still grows past it, and grid items
stretch to the row by default, so the menu fills whichever is taller without
asking for a height of its own.

The phone rules already override .boards-layout's height through a higher
specificity selector, so the inner scroller they set up is untouched.

The guard reads the .wrapper declaration across layouts.css rather than inside
the first block it finds: .wrapper is declared more than once there, and the
first block carries only the comment explaining the sizes.

Thanks to xet7 !
2026-08-01 02:24:39 +03:00
Lauri Ojansivu e87e3407b6 Changelog: point the All Boards section entry at its commit.
Thanks to xet7 !
2026-08-01 02:21:03 +03:00
Lauri Ojansivu a05c1ac08a All Boards: the archive is a section of this page, and the menu looks like the Admin Panel's.
Boards in Archive moved again, to under Remaining, and it no longer LEAVES this
page. It opens as a section drawn beside the left menu: selecting something from
a menu and then losing the menu is a menu that throws itself away. Its address is
/allboards/archive, the same shape as the other four sections, and the template
is the archive's own so its search and its pager come with it. The standalone
/archive page stays for a direct link, and the sidebar's row still goes there.

It carries a count like the three lists above it, asked of the SERVER rather than
counted from what the page has - All Boards does not subscribe to archived boards
at all, its query is archived:false, and the archive's publication is paginated
to 30, so counting minimongo would answer 0 on a fresh load and something
arbitrary later. getArchivedBoardsCount is the method the archive's own pager
already uses.

Boards can be dragged onto that row to archive them, from any of the four lists
or from a workspace. The left menu already accepts a drop on Remaining, so this
is the gesture already in the reader's hand; the alternative was three clicks
through Multi-Selection. It asks first, because a drop is easy to make by
accident - a board dragged to reorder that lands one row low - and it refreshes
the count afterwards, which a method call will not do on its own.

The Multi-Selection button in the header bar now shows that it is on and offers
the way off, the same pair the board's own has. It looked identical either way,
so the one control that changes what clicking a board does gave no sign it had
changed it. Its helper is registered on the template that uses it: a Blaze
template cannot see a sibling's helpers, and that failure is a hard render error.

The Sort Boards popup is titled from the key that phrase already has, which is
what gives it a header and with it a close button.

And the left menu is styled like the Admin Panel's - a panel with its own
background, border and rounded corners, a selected row in the per-user theme
accent with white text, a white hover with a shadow, and scrolling inside itself
rather than spilling past its own corner. WeKan has one kind of left menu.

One guard was rewritten rather than widened: it looked for a handler within 400
characters of its event map, which has to be re-tuned every time a comment is
added inside that map. It is scoped to the block now.

Thanks to xet7 !
2026-08-01 02:21:03 +03:00
Lauri Ojansivu 2c39a83b6a All Boards left menu: make the Workspaces rules dark grey.
The rules above and below the Workspaces section were a #e0e0e0 hairline - the
same grey as the menu's own right edge - and too faint to separate anything.

They are 2px #888 now, a grey this stylesheet already uses. A light grey needs
AREA to be seen at all, and area is what turns a divider into something that
reads as another row of the menu; a dark grey is legible as a line, so it stays
a line.

2px rather than 1: at 1px a mid-tone rule can land on a half-pixel boundary and
be drawn as two lighter rows on a fractional-scale display, which is the same
faintness this was meant to fix.

Thanks to xet7 !
2026-08-01 02:06:30 +03:00
Lauri Ojansivu 34d74b2709 Changelog: point the Boards in Archive entry at its commit.
Thanks to xet7 !
2026-08-01 02:03:28 +03:00
Lauri Ojansivu 6d50d9d66b All Boards: Boards in Archive belongs in the left menu.
It was a button in the first top header bar beside Sort, Search and
Multi-Selection. Those three act on the boards in front of you; Boards in
Archive is a PLACE you go instead, so it sits with the other places - a row of
the left menu, below the Workspaces section, drawn like Starred, Templates and
Remaining rather than as a stray link.

The Workspaces section now has a rule above and below it. The left menu is three
kinds of thing in one column - the three board lists, the workspaces tree, and
the archive - and without them the tree ran into its neighbours as if it were
more of the same list. One hairline in the same grey as the menu's own right
edge, not a default hr: that is a beveled 2px ridge and reads heavier than the
border it sits inside. The workspaces header's own top margin goes with it, or
the rule and the margin together read as a gap twice as wide as anything else in
the menu.

The click handler moved WITH the markup, into the map of the template that now
draws it. A Blaze event map only sees events inside its own template, so a
handler left behind in allBoardsHeaderButtons would never fire and the row would
silently do nothing - which is exactly what happened to this button once before.
The guard checks both halves: nothing left in the old map, and a handler in the
new one.

Two existing guards caught the move rather than being edited into agreement with
it, and each now says where the button went.

Thanks to xet7 !
2026-08-01 02:03:28 +03:00
Lauri Ojansivu 574c72370f Search shuts the panel it opened, and the Admin Panel tabs get their names.
Search was the same shape as Filter: it only ever OPENED, so clicking it while
the sidebar was already showing search did nothing visible and the only way back
was the sidebar's own X, somewhere else on screen from the thing you just
clicked.

The two now share one rule. models/lib/filterButton.js is
models/lib/sidebarViewButton.js, because a module named after Filter answering
for Search would be a name that lies, and one toggleSidebarView() helper applies
it - two copies of "which way does this click go" would eventually be two
answers.

The exception stays Filter's alone, and is now named as what it is rather than
assumed: with a filter ON the sidebar is the one place that says what is being
hidden from the board. Search has no such case - its results are inside the
panel and it hides nothing from the board - so it passes false and always
toggles.

The Admin Panel's four tabs carry their name beside the icon, each from the same
key its tooltip uses, and lose it below 1100px with every other label in the
bar.

Thanks to xet7 !
2026-08-01 00:55:39 +03:00
Lauri Ojansivu c48d9e533d All Boards: name its four controls too, and re-wrap the entry they share.
Sort Boards, Search, Multi-Selection and Boards in Archive each carry their
name beside the icon now, from the same translation key their tooltip uses, and
lose it below 1100px with every other label in the bar.

The board's visibility and watch buttons got theirs in the previous commit; the
changelog paragraph that lists which buttons are named has now grown twice, so
it is re-wrapped as a whole rather than patched line by line - patching it left
a ragged 20-character line in the middle.

That paragraph is also what broke tests/changelogFormat: the guard tolerates 250
over-long lines of legacy content and my edit made it 251. It was failing when I
committed the previous change, because I piped the test run through tail and the
pipe swallowed its exit code. Fixed here, and worth remembering: the suite runner
must be checked by its own output, not by a pipeline's status.

Thanks to xet7 !
2026-08-01 00:51:58 +03:00
Lauri Ojansivu 7ac766992f Header bar: name the visibility and watch buttons too.
Private / Public and Watching / Tracking / Muted are the state of the board you
are looking at, and a padlock or a crossed-out bell is a poor way to read it.
Both carry their name beside the icon now, and both lose it below 1100px with
every other label.

The label is the same DYNAMIC expression as the tooltip - {{_
currentBoard.permission}} and {{_ watchLevel}} - so one line of markup covers
all five values. Written out per value it would be two lists of words to keep in
step with each other and with the board.

Thanks to xet7 !
2026-08-01 00:47:35 +03:00
Lauri Ojansivu dfaf196a83 Header bar: name the buttons where there is room, drop the names where there is not.
Sort Cards, Filter, Search and Show Dependencies carry their name beside the
icon now, and so does the board's Multi-Selection alongside All Boards' and the
two view menus. A tooltip is the one place a name cannot be read without
hovering.

Below 1100px every one of those labels goes and the buttons are icons again. A
label is worth several icons' width, so on a narrow window keeping them costs
more buttons off the first row than the names are worth - and the tooltip is
still there for all of them.

All of them together, not some. Half the buttons named and half not reads as a
bar that has been half finished, and WHICH half you got would depend on which
words happen to be short in your language. One rule hides the one class they all
share, so none can be left behind - which is also why the first attempt at this,
an is-optional class on four of the six, was not worth keeping.

The threshold is the window rather than a container query: what a container
query would measure is this bar, and this bar is as wide as the window by
construction.

Each label uses the SAME translation key as its own tooltip, so a button and its
tooltip cannot say different things, and a button whose name changes with its
state - Sort is on, Hide dependencies - says the state in both places. The guard
checks that pairing per button rather than only that a label exists.

Thanks to xet7 !
2026-08-01 00:46:12 +03:00
Lauri Ojansivu 6e06c9150b Changelog: point the header wrapping entry at its commit.
Thanks to xet7 !
2026-08-01 00:42:09 +03:00
Lauri Ojansivu 9a1463d7e0 Header bar: fill each row before starting the next.
The bar wraps when its buttons do not fit, but everything after the
drag-handles toggle was wrapped in .header-quick-access-end, and a nested flex
box is ONE item to the bar - so the whole group moved to the second row
together. The first row ended halfway across with nothing in the rest of it
while the second row was crowded, which is the opposite of what wrapping is
for.

The group is display:contents now: its box is not generated, so its buttons are
items of the BAR and wrap one at a time. The second row takes only what did not
fit on the first.

The group stays in the markup - it is what says where the end of the bar begins
- and the auto margin moves to its first child, the item that has to push the
rest to the end. That still mirrors by itself in a right-to-left language,
because margin-inline-start is logical. On a row that wrapped there is no free
space for the margin to absorb, so those items pack from the start, which is
what fills the row instead of stranding it.

Thanks to xet7 !
2026-08-01 00:42:08 +03:00
Lauri Ojansivu 1a4e87c19f Starred boards popup: a title, and so a close button.
The popup had no title, so it rendered as a no-title pop-over: no header, and
the close button lives in the header, leaving nothing to shut it but clicking
away.

It is titled "Starred Boards" now, from starred-boards - the key the app
ALREADY has for that phrase - through a new titleKey option on Popup.open. The
convention is <popupName>-title, which is right when a title is that popup's own
words and wrong here: adding starredBoardsPopup-title would put a second copy of
one phrase into all 147 language files, English in every one of them at first,
so most languages would show English for something they have already translated.
The convention still applies to every popup that does not pass a key.

The board's own Multi-Selection button carries its name beside the icon now. All
Boards' got its label earlier; this is a different button in a different file and
was left behind - a check-box outline says nothing about what either does.

Thanks to xet7 !
2026-08-01 00:38:50 +03:00
Lauri Ojansivu df28c97bfc Changelog: point the star group entry at its commit.
Thanks to xet7 !
2026-08-01 00:33:13 +03:00
Lauri Ojansivu f62258b5cb Header bar: one star group, named view menus, and a Filter button that shuts.
The bar drew TWO stars in a row - one on the starred-boards dropdown and one
for the board you are on - which reads as a single control somehow drawn
twice. The dropdown has no star now: it is a caret and the COUNT of starred
boards, which is what it is about, and the count is shown even at zero
because it is the button's only label and a bare caret says nothing about
what it opens.

The board's own star sits immediately beside it, and the two are wrapped in a
rounded outline of their own, shaped like the phone/desktop toggle's but
WHITE where that one is black: the toggle is a white box sitting on the bar,
so a dark border shows against it, while these buttons sit on the bar's own
colour and need a light one. Without an outline the two read as unrelated
icons that happen to be adjacent - which is what made the two stars confusing
in the first place.

The star is its own template so the bar can place it here rather than among
the board's other controls further along. A Blaze event map only sees events
inside its own template, so its helpers and its click came with its markup,
and the copies left behind in boardHeaderButtons are gone.

The two view menus and Multi-Selection carry their name beside the icon
again. I had made them icon-only earlier and xet7 asked for the words back;
that is the better call - six view glyphs are six things to learn, a
check-box outline says nothing about multi-selection, and a tooltip is the
one place a name cannot be read without hovering. The bar wraps to a second
row when it runs out of width, which is what makes the word affordable now
and did not before. Sort, Search and Boards in Archive stay icons: those
glyphs are well known. A label does not wrap mid-button - two lines inside a
one-line button is worse than the tooltip was.

Clicking Filter while the filter sidebar is already showing closes it. It
only ever opened, so a second click did nothing visible and the only way back
was the sidebar's own X, somewhere else on screen from the thing you just
clicked. Not while a filter is ON, though: the sidebar is then the one place
that says WHAT is being hidden from the board, and closing it would leave a
board showing a subset of its cards with nothing on screen to say so - so it
stays open, and the X beside the button is what clears the filter.
"Showing the filter" is open AND on that view, because a sidebar open on
Activities is not showing the filter and clicking Filter there has to switch
to it rather than close the panel.

models/lib/filterButton.js holds that rule so "which way does this click go"
is answered once, and the guard checks all four combinations without a DOM.

tests/mobileModeConsistency stopped expecting js-star-board among the board
header buttons and says where it went instead; tests/headerBars pins the
group, its light-against-the-bar outline, the labels, and the filter toggle.

Thanks to xet7 !
2026-08-01 00:33:13 +03:00
Lauri Ojansivu 9fea5edb05 Changelog: point the board-item links entry at its commit.
Thanks to xet7 !
2026-07-31 16:04:18 +03:00
Lauri Ojansivu e755b60b37 Link to a swimlane or a list, the way you can link to a card.
A card has had an address since there has been a card route. A swimlane and a
list had none: "the Backlog list of this board" could only be sent as "open
this board and scroll down". Worse, List.absoluteUrl() answered with the URL
of whichever CARD the cache returned first for that list, so the one place
that did offer a list link - listMorePopup - showed a card's address, and an
empty box for an empty list.

Both have their own now:

  /b/<boardId>/<slug>/swimlane/<swimlaneId>
  /b/<boardId>/<slug>/list/<listId>

FIVE segments against the card route's four, which is the whole reason the
three can coexist: a card URL cannot match these and these cannot match a
card. A guard checks all nine combinations, because losing a segment would
silently turn a link to a list into a link to a card called "list". The kind
is a word in the path rather than a query parameter - it is part of what the
address names, and a reader can see which is which. config/router.js
registers the routes from the same constants the links are built from, so the
two cannot disagree about the path.

Following one brings the thing into view. The route cannot do that: it runs
before the board has rendered, and on a board that is already open it runs
without re-creating anything, so at that moment there is nothing on screen to
scroll. It names what to reveal in a Session value and the board body reveals
it, WAITING for the element rather than assuming it - swimlanes and lists
arrive as their subscriptions do and a large board renders in more than one
pass, so a single look on the next tick finds nothing on a cold load. It
gives up after a few seconds rather than spinning: a link to a list that was
archived names an element that is never going to exist, and the board is
still the right place to have landed.

The Session value is cleared when acted on AND when given up on, so it is a
one-shot instruction about the address you followed rather than a standing
order that re-scrolls on every later render - and cleared BEFORE the scroll,
because scrollIntoView can re-enter the autorun through the reactive read.
Every other board route clears it too: without that, opening a card after a
list link would scroll the board away from the card it had just opened.

The element ids are the ones the templates already carry, held as a map and
not derived by a rule. Deriving one name from another is how
allBoardsMultiselectionSidebar happened, and this failure is silent - nothing
is found and nothing is reported - so a guard checks each id against the
markup that draws it.

All three are copied the same way, from the FIRST row of the hamburger menu,
with the link icon and the name beside it. The card carried this as an icon in
its title header named only by a tooltip, which is the one place a name cannot
be read without hovering; that button is gone, and so are its handler and the
"Copied" tooltip only it used - a handler for markup that no longer exists is
dead code that reads as a working feature.

The copy row sits ABOVE every permission check in those menus. Copying an
address is reading, not editing: somebody who may only read the board can
still tell a colleague which list they mean, while everything else in those
menus changes the board and stays guarded. It copies absoluteUrl(), because
what lands in the clipboard is pasted into a chat and a relative path is only
a link inside this page.

copy-link-to-clipboard is added to all 147 language files, in the same
position in each. 119 are really translated, each derived from that
language's OWN existing copy-strings rather than invented. The 16 that keep
the English placeholder are the ones whose existing copy-strings are already
in the wrong language - Romanian holding Italian, Esperanto holding Spanish,
Hindi holding Gujarati - where deriving from them would only spread the
error; the en_* variants are English by design.

docs/Design/Page/Header.md is written at last. Twenty-seven comments have
been pointing at it since the header restructure began and it had never
existed, so every one of them was a dead reference.

Thanks to xet7 !
2026-07-31 16:04:18 +03:00
Lauri Ojansivu 4bf3dab1c5 Changelog: point the All Boards controls entry at its commit.
Thanks to xet7 !
2026-07-31 15:46:25 +03:00
Lauri Ojansivu 6051a4368b All Boards: four controls into the header bar, and no hamburger to open.
Sort, Search, Multi-Selection and Boards in Archive were rows of the right
sidebar's home view, so reaching any of them meant opening a panel over the
boards first. They are icons in the FIRST top header bar now, left of the
notification bell - exactly where a board's own controls are - each named by
a tooltip, like every other button of that bar. They have now been in three
places: the page's own body, a second top header bar, and that sidebar view.

The home view was the only thing the hamburger opened, so All Boards no
longer offers one, nor the divider before it. A menu to reach what is already
one click away is a step with nothing in it. NO_HAMBURGER_ROUTES in
models/lib/pageSidebar.js is what says so, and a board is deliberately not in
it: what a board's sidebar holds - members, labels, activities, settings - is
not in the bar and has nowhere else to be opened from.

The sidebar is still reachable, and that mattered enough to guard: removing
the hamburger removes the only generic way in, so Search and Multi-Selection
opening it ARE the way in, and if their event map went the panel would become
unreachable and both features would stop existing. The home view is not dead
markup either - it is what the back arrow of the other views leads to.

Boards in Archive is drawn in two places now. A Blaze event map only sees
events inside its own template, so each copy needs its own handler; a copy
with markup and no map is a button that silently does nothing, which is
exactly what happened to that button once before. The guard checks both
copies rather than one.

Three existing guards had to change, and each says why: the page-body check
now scopes itself to the board-list markup rather than the whole file, since
the controls live in that file again; the archive guard checks both copies
instead of asserting the header must NOT handle it; and the hamburger guard
learned that a page can have a sidebar and still not be offered one.

docs/Design/Page/All-Boards.md described a second header bar that no longer
exists, so its controls section is rewritten to what is there.

Thanks to xet7 !
2026-07-31 15:46:25 +03:00
Lauri Ojansivu 1b5375414b Pin that a workspace nests as deep as its tree does.
Sub, sub-sub, sub-sub-sub and further already worked in both places xet7
asked about - the URL is /allboards/workspaces/workspace-name/sub-name/
sub-sub-name, and the first header bar reads All Boards / Workspaces /
Workspace Name / Sub Name / Sub Sub Name - because nothing in the path
counts levels: workspaceSlugPath recurses to the node, splitWorkspacePath
splits on every slash, workspaceIdForSlugPath walks each segment, and
workspaceNamePath names each one. What was missing is a guard saying so, so
nothing later caps it silently. A cap would be invisible until somebody made
a workspace one level deeper than whoever wrote the cap imagined.

Two tests in tests/allBoardsUrls.test.cjs. The first builds a six-level tree
and follows the deepest node all the way round: the URL carries six segments,
the route splits it back into six, it resolves to the LEAF rather than to an
ancestor, and the header names all six in order - on the stub slugifier and
again on limax, which is what actually runs. It also pins the route pattern
itself, `:path*` and not `:path?`, since that is what allows the trail to be
captured whole; with `?` a sub-sub-workspace would quietly resolve to its
parent.

The second walks EVERY node of the fixture tree, ancestors included, not only
the leaves: an ancestor is a workspace you can open too, and its URL must be
its own trail rather than the prefix of one of its children's.

Thanks to xet7 !
2026-07-31 15:37:24 +03:00
Lauri Ojansivu e11394c1b9 Changelog: point the All Boards path entry at its commit.
Thanks to xet7 !
2026-07-31 15:26:13 +03:00
Lauri Ojansivu 1164785d1d All Boards: the header bar names the list, and the workspace inside it.
The bar said "All Boards" on Starred, on Templates, on Remaining and inside
every workspace - it named the page and not the list you were looking at. It
shows the path now: All Boards / Starred, All Boards / Workspaces /
Engineering / Backend.

The section's words are the LEFT MENU's own key, so the title and the row
highlighted beside it always say the same thing. A workspace's words are its
NAME rather than the slug the URL carries - the slug is lowercase and
hyphenated and is not what the workspace is called - and the name is printed
as text rather than translated: it is what a person typed, and a workspace
called "starred" is not the Starred section.

A trail segment that names nothing stops the walk and the title shows what
was still real, so a stale link names the part of the path that still exists
rather than nothing at all.

The header walks the tree itself, from the URL and from
user.profile.boardWorkspacesTree, which is where the All Boards page reads it
from too. It cannot ask the page: the header is a separate Blaze instance,
and importing a page module from here once ran it before its own template was
registered, and the throw aborted every module after it.

The Admin Panel's two segments and these are ONE list now, not a helper per
segment. The two pages do not have the same number of them - the panel always
has two, a workspace has as many as its tree is deep - so a fixed set of
helpers could only ever serve whichever page was written first. Each entry is
one of the two forms a left-menu entry already has: a key for something
translated, a title for text that must not go through the translator.

Guards in tests/headerBars.test.cjs pin every section's key against the left
menu's markup and against en.i18n.json, that an unknown section titles the
default rather than nothing, the name walk including both ways it can stop
early, that the tree comes from the user document, that a workspace name is
pushed as a title and never as a key, and that a board has no path at all
because its own title is the whole name. tests/adminUrls.test.cjs follows the
panel's pane title into the same list.

Thanks to xet7 !
2026-07-31 15:26:12 +03:00
Lauri Ojansivu e3e8aa5f1f Changelog: point the four new entries at the commits they describe.
The summaries carry the hash in the href, so they can only be written once
the commit exists. The two header-bar fixes are the commit before, the Admin
Panel move is this one's parent.

Thanks to xet7 !
2026-07-31 14:40:04 +03:00
Lauri Ojansivu 03cb79bdd7 Admin Panel under /admin, and a header that says where you are.
The four pages sat at the TOP level - /settings, /people, /attachments - as
if they were pages of the app rather than of the Admin Panel, and
/attachments is also the path the file server serves attachments from
(server/routes/universalFileServer.js), so the panel and the files were
claiming one address. They are under /admin now: /admin/settings/version,
/admin/people/login, /admin/problems/database, /admin/attachments/backup.

The default pane is named too. It used to be left implicit in a bare page URL
- /settings rather than /settings/version - so the address of "Settings" and
the address of "Settings showing Version" were one string. The address is
meant to say where you are, and the first pane is somewhere too. The bare
/admin/settings still resolves; it redirects to the pane's own address rather
than being a second name for it.

Every path the panel used to answer on redirects - the bare old address, the
old one with a pane in it, and /setting, /information, /translation. They are
built from the same map the URLs are, so a page cannot be given an address
without also being given its redirect, and a bookmarked
/settings/global-webhooks keeps its pane instead of landing on the top of the
panel.

The title beside the house icon names the same three things: Admin Panel /
Settings / Version. The page's words are the tab's own key and the pane's
words are the menu row's own label, so the title, the tab that is marked
active, and the row that opened the pane cannot say different things about
one place. The pane title is read from the URL rather than from the page,
because the header is a separate Blaze instance from the Admin Panel's pages
and must not import them - doing that once ran a page module before its own
template was registered, and the throw aborted every module after it. That
makes ADMIN_PANE_TITLES a second copy of the menus' labels, and a guard
checks every entry of it against the real menu entry for that pane.

The button of the page you are ON keeps the hover background, permanently and
a shade darker. The tabs had marked themselves .active all along, but the only
rule that drew it was scoped to #header-main-bar - the second bar, which those
tabs no longer live in - so the state had been computed and invisible since
they moved. Darker than the hover rather than equal to it: hovering the tab
you are already on must still look like a hover.

--wekan-header-height measured #header - the SECOND bar - from when every page
had one. Most pages have none now, so on those the variable was 0 and
everything laid out against it started at the top of the WINDOW, underneath
the first bar: the All Boards sidebar covered it. It measures both bars now,
as the BOTTOM of the lowest rather than a sum of heights, so any margin
between them counts and a bar that is absent contributes nothing without a
special case - and each bar has its own ResizeObserver, because the first one
wrapping to a second row is a resize of that element and of nothing else.

A divider after the notification bell. Everything before it belongs to the
page - its controls, its view menu, the panel's tabs; everything after
belongs to you - help, your account, the sidebar toggle. Without it the run
of icons reads as one list of unrelated things.

The URLs went through a hyphenated shape - /admin-settings-version,
/allboards-starred - before xet7 asked for the slash form instead. All
Boards is back to exactly what it was; only the Admin Panel moved.

Guards: tests/adminUrls.test.cjs pins the /admin prefix, that every pane is
named in its URL including the default, that each page records the path it
used to answer on, that the three redirect shapes exist and carry the pane
along, and that every pane title matches the real menu entry's label - both
directions, and a translation key has to be a real one. tests/headerBars
pins the divider and the darker active mark; tests/sidebarBelowHeader pins
that the height covers both bars. tests/adminOldUrlRedirect's route-body
helper now understands a route whose path is an expression, since the panel's
four take theirs from adminRoutePath(page) so the route and the links cannot
disagree.

Thanks to xet7 !
2026-07-31 14:39:49 +03:00
Lauri Ojansivu 49f4e77aae First header bar: wrap rather than clip, and say where you are.
Six things, all in the one bar that is always on screen.

The bar wraps. It was one row with `overflow: hidden`, so a button that did
not fit was not drawn at all - and a button that is not drawn gives no sign
that it exists. It is `flex-wrap: wrap` now with a row gap, and every height
on it became a minimum, including the four phone rules that pinned 40px or
48px: a content box with a fixed height cannot hold two rows, so it would
have cut off exactly what the wrap was for. A phone is where the buttons run
out of room first, which makes it the last place that should hide them.

Everything after the drag-handles toggle hugs the end of the bar, from one
`margin-inline-start: auto` on a new `.header-quick-access-end` wrapper. That
is a LOGICAL property, so a right-to-left language mirrors it by itself
rather than needing a second rule kept in step with the first.

Both view menus are icons now. The board's said "Swimlanes" beside its icon
and All Boards' said "Lists"; the name is in the tooltip instead, where the
other buttons of that bar already keep theirs. `boardViewName` maps the six
views to their own keys, so the tooltip says which view is on.

The button of the page you are ON keeps the hover background, a shade darker
and permanently. The Admin Panel's tabs have marked themselves `.active` all
along, but the only rule that drew it was scoped to `#header-main-bar` - the
second bar, which those tabs no longer live in - so the state was computed
and invisible from the moment they moved. Darker than the hover rather than
equal to it: hovering the tab you are already on must still look like a
hover.

The Admin Panel says which of its four pages is open - "Admin Panel /
Settings". All four of its routes answered the same key, so the bar said the
same three words on every one of them. The second segment uses the same
translation key as the tab's tooltip, so the title and the tab that is marked
active cannot drift apart. Only the Admin Panel has one; a page whose name is
the whole answer gets no slash, and neither does a board, whose own title IS
its name.

A sidebar button is no longer drawn under the close button. `.sidebar-xmark`
is `position: absolute`, so it contributes no height and the row holding it
collapsed to its padding - on a board that row also holds the keyboard
shortcut buttons and so has a height anyway, which is why only the All Boards
and shared page sidebars showed it.

Two mistakes here were reverted rather than patched, because patching them
would have left the damage in the file. A regex rewriting `header.css`
matched inside `min-height` and produced `min-min-height`; and a slice of
`boardHeader.jade` ran backwards, deleting every template between
`boardVisibilityList` and `boardViewMenu` - that is what
`Template.boardChangeTitlePopup is undefined` and `Template
"boardVisibilityList" is set twice` were. Both files were restored from git
and redone with exact replacements.

Guards in tests/headerBars.test.cjs pin all six: that no rule on the bar
forces one row, clips, or fixes a height; that the end group is pushed with a
logical margin and never a physical one; that each view menu names its view
through a real translation key; that the active mark is darker than the hover
and survives being hovered; that the Admin Panel's second segment shares its
key with the tab; and that both sidebars reserve the close button's height.
The wrap guard reads the DECLARATIONS, not the comments - the rules that
replaced these explain themselves by naming `nowrap` and `overflow: hidden`,
and a guard that greps the whole body reads its own explanation.

Thanks to xet7 !
2026-07-31 14:24:06 +03:00
Lauri Ojansivu 3700ce0d0d Translate the two new strings into 41 more languages.
set-selected-unstarred ("Unstar the selected boards") and multi-selection-off
("Turn Multi-Selection off") were added with this session's All Boards work and
went into every language file as English placeholders, which is what the
translation policy does with a string untranslated everywhere. 90 languages were
filled in the first pass; these are the rest that I can write with confidence -
Welsh, Esperanto, Frisian, Valencian, Asturian, Azerbaijani, Armenian, Georgian,
Khmer, Mongolian, Tamil, Telugu, Punjabi, Gujarati, Odia, Uyghur, Uzbek, Xhosa,
Zulu, Yoruba, Breton, Occitan, Walloon, Yiddish, Wolof, Igbo, and the Chinese
variants, with their regional tags.

131 of 142 languages have both strings now.

Each is written from that language's OWN existing translations rather than from
English: the sibling key set-selected-starred is already translated in every one
of these files, and click-to-unstar and multi-selection-on gave the verb and the
noun each language actually uses for starring and for multi-selection.

Eleven are deliberately left as English placeholders - ace, ary, ja-HI, tk_TM,
tlh, ve, ve-CC, ve-PP, vl-SS, vo, zgh. I cannot write those to a standard I
would stand behind, and the policy is explicit that the English source standing
in is the honest state for a string nobody has translated. A guess dressed as a
translation is worse than a placeholder, because nothing marks it as a guess.

Applied with fill-translations.mjs --apply, which writes ONLY into keys that are
still English placeholders, so it cannot overwrite a human translation.
verify-human-preference.mjs passes 10/10, the diff touches these two keys and
nothing else, and every file still holds the same 2333 keys. Nothing is pushed
to Transifex: a filled string must never be able to masquerade as human there.

Thanks to xet7 !
2026-07-31 13:58:57 +03:00
Lauri Ojansivu 0461de062f Board: the second header bar is gone. It was the last one.
Three things, as asked.

NO DUPLICATE BOARD NAME. The second bar drew the board's title in an `h1` of its
own, under the same name in the bar above it. The first bar has said it since
the first step of this work; the h1 is gone.

THE PENCIL IS BESIDE THE NAME. Renaming a board belongs next to the board's
name, not in a bar of its own, so `boardEditTitleButton` renders immediately
right of the title in the first bar. Board admins only, as before.

EVERYTHING ELSE IS AN ICON, LEFT OF THE BELL. Visibility, watch, star, sort,
the board's rule buttons, filter, search, dependencies and Multi-Selection, each
named by a tooltip and none carrying a visible label. The bar is one row shared
with the logo, the starred boards, the view menu, the bell and the user menu;
ten labelled buttons do not fit at any width.

The desktop/mobile duplication went with it. The old bar wrote the whole set out
TWICE - `unless isMiniScreen` and `if isMiniScreen` - because a row of labelled
buttons does not fit on a phone. Icons do, so one copy serves both, and the two
switches that had to be kept in step are gone rather than kept in step.

headerBarControls.jade is deleted. It held one Search button and one
Multi-Selection button, written for this bar and All Boards'; both bars are gone
now, so a template shared by nobody was indirection rather than sharing.

Every route reference goes with the bar: config/router.js names no headerBar at
all any more.

Six guards followed, and four of them existed only to measure this bar: the
title-plus-buttons row that a long board title split across two lines, the
wrapper that fixed it, the phone layout, and the hamburger that shared the first
row with the title. All of that was one problem, and the problem is gone with
the row. Each says so where it used to measure it, rather than being deleted.

Thanks to xet7 !
2026-07-31 13:57:06 +03:00
Lauri Ojansivu 7243ced8d6 Header: hide the empty second bar, and style the buttons that moved up.
Two things the screenshot showed, both consequences of emptying the second bar
that I had not followed through on.

A TALL EMPTY BLUE STRIP under the first bar, on every page. `#header` is a
coloured block with a height of its own, and most pages have no second bar any
more - their title is in the bar above and their controls are in a sidebar - so
it painted an empty band wherever there was nothing to put in it. It renders
only where a page still has a header bar now.

CRAMPED BARE GLYPHS in the first bar. Every rule that styles a
`.board-header-btn` was scoped to `#header-main-bar`, which is the SECOND bar -
so the page's view menu, the starred-boards dropdown, the Admin Panel tabs and
the sidebar hamburger, all of which moved UP into the first bar, had no padding,
no height and no line-height and ran together into one another. The first bar
states the same look for its own buttons, with its own divider.

That is the same mistake as the All Boards search box, which kept matching
`.boards-path-header .board-search` after it had moved: a control that moves
between containers takes its stylesheet with it or stops being styled at all,
and nothing says so until somebody looks.

Thanks to xet7 !
2026-07-31 13:46:37 +03:00
Lauri Ojansivu 79b0950c33 Header: do not import a page module from the header.
The first header bar names three pages that carry a name of their own - Support
and Accessibility can be renamed by an admin, and Import names the source it is
importing from - and it asked for those names by importing each page's own
module.

The header is rendered by the layout, so it loads very early. Importing a page's
module pulls that module into the header's graph and RUNS it, before its .jade
has registered. `Template.import.onCreated` was `undefined.onCreated`: a
TypeError at module load. A module that throws aborts every module after it, so
the visible symptom was not that one page - it was "No such template:
connectionMethod" and a string of others that never got to register at all.

The three titles are in client/lib/pageTitleSources.js now, which registers no
template and touches no DOM, so it is safe to load at any point. The header asks
that; the pages use the same one rather than each keeping a copy.

A guard asserts the header imports nothing from client/components, that the
module it does import registers no template, and that neither page defines a
second copy of its own title. It reads the CODE and not the comments - the
module explains the bug by naming Template.import.onCreated, and the first
version of the guard failed on its own explanation.

Thanks to xet7 !
2026-07-31 13:44:51 +03:00
Lauri Ojansivu e7e52fe661 Updated translations 2026-07-31 13:37:39 +03:00
Lauri Ojansivu af6cdf7dc0 Header: starred boards are a dropdown, not a row of links.
The first bar listed every starred board inline. That is the widest thing in the
bar, and it grows with the number of boards you star, so the bar could not stay
one row - which is what it has to be now that the page title, the view menu, the
Admin Panel tabs and the sidebar hamburger are all in it.

It is one button: a caret, then a star, laid out exactly like the view menu
beside it - caret first, then what the button is about - with the number of
starred boards on it the way a board's own star shows its count. The names are
in the popup it opens, shaped like boardChangeViewPopup because it is the same
kind of menu opened from the same kind of button: a pop-over list of links, with
a check on the board you are looking at, and something to say when none are
starred.

On a phone it is full width. Nothing here asks for that: body.mobile-mode
already makes every pop-over full screen, so using the ordinary popup was enough
- and the guard says so, rather than leaving the next reader to wonder where it
comes from.

One thing is deliberately NOT folded in: on a phone, inside a list, the bar
still shows that board's LISTS. That is a different thing from the starred
boards - it is what tells you where you are while you swipe between lists - so
it stays as it was.

Thanks to xet7 !
2026-07-31 13:33:52 +03:00
Lauri Ojansivu 6e9cb2d33b Header: both view menus move to the first bar.
You asked me to check where the view dropdowns are. They were in two different
wrong places, and neither was the one that makes sense.

A view menu says what you are looking AT. That belongs beside the page's name,
in the bar that is always on screen - not in a second header bar, which is where
the board's Swimlanes / Lists / Calendar / Gantt / Table / Stats menu still was,
and not behind a panel you have to open first, which is where I had put the All
Boards Lists / Table menu one step ago. Both are in the first bar now, one or
the other by page, left of the notification bell beside the Admin Panel tabs.

Each is its own template with its own handler, because a Blaze event map only
sees events inside its own template. The board's keeps its `unless
currentBoard.isTemplatesBoard` - there is nothing to look at six ways on a
templates board - and the All Boards one still names the CURRENT view rather
than itself, the way the board header says "Swimlanes" or "Lists" and never
"Board View".

Public is deliberately not in the list of All Boards routes here: it has no
Lists/Table choice to offer.

Three guards followed the menus, and a new one pins where they are, that each is
handled where it is drawn, and that neither is left behind in the place it came
from.

Remaining: the board's own second header bar, which is now one control lighter.

Thanks to xet7 !
2026-07-31 13:28:35 +03:00
Lauri Ojansivu f83234f0d8 Admin Panel: its four tabs are icons in the first header bar.
Fifth of the header restructure, and the answer to the question I had left open:
the panel's tabs are navigation BETWEEN its four pages rather than controls of
one, so they do not belong in a right sidebar with a page's controls. They
belong where you can always see which of the four you are on - the first header
bar, immediately left of the notification bell.

Icon only, each named by a translated tooltip, because that bar is a single row
shared with the logo, the starred boards, the notification bell and the user
menu; four labelled buttons would not fit beside them. They link by route NAME,
as they did.

The tab bar goes with them, and its four route references. "Admin Panel" is
already what the first bar says on all four routes - models/lib/pageTitles.js
has mapped them since the first step - so the panel is named once, there, rather
than by an h1 of its own.

Which routes ARE the Admin Panel is read from the one place that already lists
them, adminUrls.js, rather than spelled out again in the header.

Three guards followed. The same mistake as last time was caught by the same
guard: settingHeader.js still registered onCreated, onDestroyed and helpers on a
template that no longer existed, which throws at module load and aborts every
module after it - they are on adminPanelTabs now. The security-log guard checked
for a `.setting-header-btn.problems`, and the multitenancy guard checked that
Settings and Problems sit behind the site-admin check; both properties are
unchanged and both now read the markup where it is.

Remaining: the board's own second header bar, which is the last one with
controls in it.

Thanks to xet7 !
2026-07-31 13:22:28 +03:00
Lauri Ojansivu bce972e35f Header: All Boards keeps its controls in its sidebar, and loses its bar.
Fourth of the header restructure. This page had the last second header bar with
controls in it, and it now has none: the first bar names the page, and every
control is a row of the right sidebar the page already had.

Only two things actually needed moving. Sort and the Lists/Table view menu
become sidebar rows, keeping what made them worth looking at - Sort still shows
emphasis while a sort other than the custom order is on, and the view row still
names the CURRENT view rather than itself. Search and Multi-Selection did NOT
move: the sidebar's home view has offered both as rows since it was written, so
carrying the header buttons across would have listed each of them twice.

The handlers moved with their markup, because a Blaze event map only sees events
inside its own template, and the bar's helpers went with the bar.

One consequence worth naming: headerSearchButton and
headerMultiSelectionButton were written for TWO bars, and there is one left. The
board header is the only user now. They stay shared rather than being folded
back into it, because the board's bar is the next one to be emptied - but the
guards say plainly that the sharing has one side today, instead of asserting a
parity that no longer exists.

Six guards followed. The control-order test has now followed these controls
three times - `.path-right`, then the header bar, then nowhere - and says so;
the Public page's "do not print the title twice" test names the FIRST header bar
as what prints it; and the All Boards tests read the sidebar where they used to
read the bar.

Remaining: the board's own second bar, and the Admin Panel's tab bar.

Thanks to xet7 !
2026-07-31 13:13:59 +03:00
Lauri Ojansivu 9f42cb1aaa Header: the six second bars that held only a title are gone.
Third of the header restructure, and the simplest: Support, Shortcuts,
Accessibility, Broken Cards, Import and Boards in Archive had nothing in their
second header bar but the page's name, which the first bar carries now. The bar
and the route reference go with it, so the page is its content and nothing else.

Three of them do not have a FIXED name, and dropping the bar would have lost it:
an admin can rename Support and Accessibility, and Import names the source it is
importing from ("Import / Trello"). Each of the three exports its title, and the
top header bar asks for it - a custom title beats the key and is printed as
text, exactly like a board's. So the title rule is three sources in order: the
board's, then a custom one, then the key.

Shortcuts and Import also had a back arrow to All Boards. The house icon in the
first bar is that link, on every page, so it went with the bar rather than being
carried over as a second one.

The repo's own guard caught the mistake in this: support.js and accessibility.js
still registered onCreated and helpers on templates that no longer existed,
which throws at module load and aborts every module after it. The registrations
are gone; the SUBSCRIPTION each one made is not, because the top header bar
reads the same setting to name the page - it just belongs to the page's own
template now.

Two guards updated: the archive page is named by the top bar rather than by one
of its own (its title has moved three times, and each move left the previous one
to remove or the name is printed twice), and the page-sidebar guard now covers
all ten removed bars rather than the first four.

Remaining: the board's own second bar, All Boards' own, and the Admin Panel's
tab bar.

Thanks to xet7 !
2026-07-31 13:07:40 +03:00
Lauri Ojansivu 705bed7413 Header: a page's controls move into a right sidebar.
Second of the header restructure. The title moved to the first bar last time;
the buttons move here, so a page is its content plus one panel rather than its
content plus a strip of buttons above it.

Four pages had controls in their second header bar: My Cards, Due Cards, Global
Search and Rules. Each group becomes a <page>Controls template, and the shared
page sidebar renders it. Their header bars are gone entirely - title moved,
controls moved, nothing left to hold - and the routes no longer name them, which
they must not: a route naming a template that does not exist renders "No such
template" in that bar.

The handlers followed their markup. A Blaze event map only sees events inside
its OWN template, so Template.dueCardsHeaderBar.events would have fired for
nothing once the buttons were somewhere else.

The sidebar itself is the board sidebar's shell again - the same classes, the
same close button, the same content area - with the two things the All Boards
one had to learn: the theme goes on a WRAPPER, because every themed sidebar rule
is a descendant selector and a class on `.sidebar` matches nothing; and it is
pinned to the viewport below the measured header height, sharing that rule with
the All Boards panel rather than restating it.

Which sidebar the hamburger opens now depends on where you are: a board's own, All
Boards' own, or this shared one. And it is only drawn where it opens something -
a page with no controls has no sidebar and no hamburger, because a panel that
opens empty is worse than no panel.

One guard needed correcting: the All Boards geometry test read a single
selector, and that rule is a selector list now.

What is still NOT done: the board's own second bar and All Boards' own, whose
controls have a sidebar to move into but have not moved yet, and the Admin
Panel's tab bar. The pages that were only a title - Support, Shortcuts,
Accessibility, Broken Cards, Import, Archive - keep their second bar for now;
they have nothing to move, only a title to drop.

Thanks to xet7 !
2026-07-31 13:01:31 +03:00
Lauri Ojansivu ffacc9a7dc Header: one colour, the page's own name, and one hamburger.
First of the header restructure. Three things, all in the bar that is always on
screen.

ONE COLOUR. Each theme painted the two bars two shades apart, so the header read
as two headers stacked. They are one colour now, and per theme it is stated
once: the quick-access bar joins the selector list the main bar already has,
rather than restating each hex a second time. moderndark said it its own way and
was the one theme where the two really differed (#2a2a2a against #333); it is on
the main bar's colour like the rest.

THE PAGE'S NAME, not "All Boards". The first bar said "All Boards" beside its
house icon on every page, whatever page it was - so the bar that is always
visible named a place you were not. The house is still the link home, with that
name as its tooltip; the text beside it is where you ARE, and on a board that is
the board's title. models/lib/pageTitles.js maps a route to a title key, which
is one answer to "what is this page called" in place of the thirteen `h1`s that
each answer it separately. A board title is printed as TEXT and a page title
goes through the translator: a board called "settings" is not the Admin Panel.

ONE HAMBURGER, in this bar, to the right of the username, with the divider that
came with it. It was the last item of the SECOND bar - the bar whose contents
are moving into the sidebar it opens. On a board it toggles the board's own
sidebar; on every other page the shared one.

Two guards had to be corrected, and both were wrong before this change rather
than because of it. The theme-accent guard derived each theme's accent by
matching `.board-color-X#header`, which also matches `#header-quick-access`, so
for clearblue it had recorded the quick-access shade (#2d8ce7) as the accent
instead of the main bar's (#00aecc); it matches `#header` exactly now. And the
new guard first checked that the two bars share a selector LIST, which is a
spelling rather than the property - it compares the colours they resolve to.

What is NOT in this commit: emptying each page's second header bar into a right
sidebar. That is thirteen templates and a shared page sidebar for the pages that
have none, and it is the next step.

Thanks to xet7 !
2026-07-31 12:52:20 +03:00
Lauri Ojansivu a1980ca67e All Boards sidebar: put the theme where a theme rule can see it.
The theme class was on the `.sidebar` element itself. Every themed sidebar rule
is a DESCENDANT selector -

  .board-color-belize .sidebar .sidebar-content .sidebar-btn { ... }

- so a class on that same element matches nothing at all, and the panel stayed
exactly as it was: white icons and white text on a light grey background. On a
board the class sits on a CONTAINER around the sidebar, which is what makes
those rules apply, and that is what was missing here.

.all-boards-sidebar-theme is that container. It wraps only the sidebar, so the
theme cannot bleed into the board icons behind it, and it is out of flow - the
sidebar inside is fixed - so it costs no layout. Boards in Archive, and every
other .sidebar-btn in the panel, now gets the themed background with its white
icon and text on top of it, and the list rows get the themed hover.

The guard passed on the broken version, which is why it shipped: it asked
whether the theme class was somewhere in the file, not whether it was on an
ancestor of the sidebar. It now asserts both halves - that the sidebar element
does NOT carry it, and that a wrapper containing the sidebar does. Verified it
fails on the markup as it was.

Thanks to xet7 !
2026-07-31 12:38:04 +03:00
Lauri Ojansivu 09003f0172 Boards in Archive is a page, not a modal.
It was Modal.open('archivedBoards'), from three different menus, so the one
place that lists every archived board - with its own search and its own pager -
was a box floating over whatever you happened to be looking at. It had no
address, so it could not be linked to a colleague, bookmarked or opened in a
second tab; there was nothing to come back to; and Escape closed it while you
were reading it. "Restore the board I archived last month" is a task, not a
glance.

/archive is a page now, in the default layout, with the same second top header
bar as every other page and the board session state cleared the way the other
non-board pages clear it. The template is the same one the modal used, minus its
own h2: a modal has no header bar to be named in, so it drew its own title, and
on a page that is the title printed twice. archivedBoardsHeaderBar names it
once, where every other page is named. The search, the per-board Restore and
Delete, and the SERVER-side paging all survive the move - the subscription still
publishes one page at a time, so a long archive stays fast.

All four entry points go to it by route NAME rather than by a path spelled out
in four places: the board menu, the member menu, the board sidebar, and the All
Boards sidebar.

That fourth one did not work at all. The row was drawn in the All Boards
sidebar's home view, and clicking it did nothing: the handler it relied on lived
in the header bar's events map and went when that map was rewritten for the
sidebar. A Blaze event map only sees events inside its own template, and the
button had moved out of it. It has its own handler now, and the sidebar closes
behind it - the page it opens replaces the page the panel belongs to.

Written down in docs/Design/Page/Archive.md.

Thanks to xet7 !
2026-07-31 12:34:53 +03:00
Lauri Ojansivu ae80d997eb All Boards sidebar: place it, size it, and give it a theme.
Three things xet7 reported with a screenshot, all of them mine, all of them in
the shell the sidebar borrows from a board.

It was WHITE TEXT ON LIGHT GREY. .sidebar .sidebar-content .sidebar-btn is a
light grey box whose text is white; what makes that readable on a board is a
.board-color-* ancestor replacing the grey with a themed colour. This page has
no board, so nothing did. The shell carries board-color-belize now, the
established default for a themed thing outside a board - globalSearch.js falls
back to the same one. It is a class on the element, so the theme is the same on
a phone and on a desktop; only the geometry below is desktop-only.

It FLOATED OVER THE BOARD ICONS, starting halfway down the page and ending as
soon as its content did. It had inherited position: absolute, which resolves
against the nearest positioned ancestor: on a board that is the board container,
which already starts below the header and runs to the bottom of the window, and
this page has no such container. It is pinned to the viewport now - top is
var(--wekan-header-height), the height the header MEASURES itself to be (it is
not a constant: its buttons wrap to one, two or three rows depending on language
and window width, and utils.js keeps the variable current with a ResizeObserver),
and bottom is the window. Below the second top header bar, full height, like the
board's.

And the boards now move LEFT instead of being covered:
.all-boards-wrapper.sidebar-open .boards-layout gives the panel its 420px back -
the width the sidebar is - so the icons reflow into what is left between the menu
and the panel and every one stays visible.

Desktop only, both of those. On a phone .board-sidebar.sidebar in the board
sidebar's own stylesheet already pins this element - it carries that class - to
the full screen width below the header, and there is nothing to move aside from
a full-width panel.

All three views share the shell, so Search and the hamburger's home menu are
fixed by the same change, not only Multi-Selection.

Thanks to xet7 !
2026-07-31 12:22:48 +03:00
Lauri Ojansivu b156505430 Snap: ask the Snap Store whether SNAP_AUTH works, before building anything.
v10.53 built the amd64 snap, the arm64 snap and both variant snaps - forty
minutes of runner time across four jobs - and then every one of them died at
the upload with the same red X, because the credential was not usable. Nothing
before that point had asked the store anything: the per-job check only tested
that the secret is non-empty and longer than 100 characters, which a revoked or
long-expired credential also is.

snapcraft whoami is the question, asked once, in a job that snap-native,
snap-launchpad and snap-variants all wait for. It costs about a runner-minute,
it answers from the store itself, and its answer distinguishes the causes that
a failure at upload time cannot:

  cannot be parsed  the secret is not an exported login at all - mangled,
                    truncated, or the terminal OUTPUT of export-login instead
                    of the file it wrote. The store was never asked.
  expired           it WAS valid. An exported login lasts one year by default,
                    so a credential that worked for a year stops without
                    anything having changed. This is the cause the upload-time
                    message never mentioned, and the likeliest one when every
                    arch fails identically.
  401 / 403         readable, but without the ACL for this snap name.

It also checks the credential covers all three snap names this release
publishes. One exported for `wekan` alone lets snap-native succeed and
snap-variants fail, which reads as two unrelated bugs; `packages: no
restrictions` covers everything and passes.

The credential is never echoed - only what the store said about it. The whoami
call runs with `set +e` on purpose: `set -e` would kill the step at the failure,
before the case statement that says which failure it was.

build-sandstorm publishes no snap, so it does NOT wait for this - the first
version of the change made it wait, because it happened to have the same needs
line.

Thanks to xet7 !
2026-07-31 12:07:41 +03:00
Lauri Ojansivu d86f8c8042 Changelog: the Admin Panel and All Boards URLs.
Two entries for the two commits that gave every left-menu entry an address:
the Admin Panel's /settings/visibility, /people/roles, /admin-reports/cpu, and
All Boards' /allboards/starred and /allboards/workspaces/<name>/<sub-name>.

Thanks to xet7 !
2026-07-31 07:53:10 +03:00
Lauri Ojansivu 5cc399b473 All Boards: a URL for every left-menu entry, workspaces included.
The page was three addresses - / (Starred), /templates and /remaining - and the
workspaces tree had none at all. Which workspace you had open was a ReactiveVar,
so a workspace could not be linked to a colleague, bookmarked, opened in a
second tab or reached with the back button.

  /allboards/starred
  /allboards/templates
  /allboards/remaining
  /allboards/workspaces
  /allboards/workspaces/engineering/front-end/design-system

The route is '/allboards/:section?/:path*'. :path* is zero or more SEGMENTS -
[^/]+ would stop at the first slash and a workspace two levels down would not
resolve - so a workspace nests as deep as its tree does. / stays the home and
still shows Starred; /templates and /remaining redirect to the new form.

A workspace is addressed by the slugs of its NAMES down the tree, not by its id:
the id is a random string and a URL should say where you are. The slugs come
from getSlug (limax), the same function that gives a board its slug in
models/boards.js, so a workspace and a board turn a name into a URL the same
way, including the scripts where a naive slugifier returns an empty string. When
a name slugifies to nothing anyway - an emoji-only name - the node's id stands
in, because a workspace with no address could not be opened from a link.

The route resolves the section and splits the wildcard; the PAGE resolves the
workspace, in an autorun, once its tree has loaded. The router cannot: the tree
is on the user document, which it has no way to read before the page has it. And
it has to be an autorun rather than a one-shot read, which would always run
before the tree arrived and never select anything. A slug path that names
nothing leaves the Workspaces section selected rather than an empty list.

models/lib/allBoardsUrls.js takes slugify as an ARGUMENT rather than importing
it, so it has no dependencies at all and is unit-tested with a stub - and with
the real limax, for the tests that are about the real one.

One thing this had to fix rather than add: boardsList.js filters boards by
membership only on the All Boards routes, by route NAME, and a route missing
from that list falls through to the public-boards branch - the page would have
shown public boards instead of the user's own. The new name is in the list, and
the guard pins all four.

Written down in docs/Design/Page/All-Boards-URLs.md.

Thanks to xet7 !
2026-07-31 07:52:31 +03:00
Lauri Ojansivu 841479774e Admin Panel: a URL for every left-menu entry.
The panel was four addresses - /setting, /people, /admin-reports, /attachments -
each opening whichever pane its page happened to open first. Which pane you were
looking at was ReactiveVar state and nothing else, so a pane could not be linked
to a colleague, bookmarked, opened in a second tab or reached with the back
button, and /setting always landed on Version even if you had just been in
Global Webhooks.

Every entry has its own now: /settings/visibility, /settings/global-webhooks,
/people/roles, /admin-reports/cpu, /attachments/s3. Lowercase, words separated
by -, and the DEFAULT pane keeps the bare page URL - /settings, not
/settings/version - so there is one address for "the Settings page" rather than
two that show the same thing. The Settings path is plural: /setting was the odd
one out beside /people, /attachments and /admin-reports, and it still resolves,
as a redirect.

The slug is NOT derived from the pane id. The ids are internal and read like it
- tableVisibilityMode-setting, layout-setting, report-cpu - while a URL is
something a person types and pastes into a chat, and a name derived from another
name is wrong the moment the two spellings differ, which is what
allBoardsMultiselectionSidebar was. So models/lib/adminUrls.js is an explicit
map, and the guard checks it against the real menus in BOTH directions: every
slug names a pane the page has, and every menu entry has a slug. Neither failure
shows up until somebody clicks that row. A slug that is not one falls back to
the page's default rather than rendering an empty panel, because a URL is typed.

The route resolves the slug and hands the pane id to the page, which reads it in
an autorun - reactive, not a one-shot read, because following a link to another
pane while the page is open runs the route action again WITHOUT re-creating the
template. A menu click does the same in reverse: open the pane, then go to its
URL. go, not replace, so Back returns to the previous pane instead of leaving
the Admin Panel, and only when the path would actually change. Each page now has
ONE function that opens a pane by id, used by both the URL and the click.

/information and /translation redirected to /setting and handed their pane over
in a Session value the page consumed once. They redirect to the pane's own
address now.

Two guards pinned that Session mechanism and now pin the URLs, each saying why
it changed. Writing the map turned up that the Settings page's pane list still
named registration-setting, the Login pane that moved to People - which its own
guard caught, correctly.

Written down in docs/Design/Page/Admin-Panel-URLs.md, with every URL listed.

Thanks to xet7 !
2026-07-31 07:47:09 +03:00
Lauri Ojansivu 43ce49ce6c Updated translations. 2026-07-31 07:19:54 +03:00
Lauri Ojansivu a8f3b9f151 Changelog: point the shared-controls link at its own commit.
The entry was written before the commit it describes existed, so the amend
made its placeholder stale. All 27 links in the Upcoming section resolve.

Thanks to xet7 !
2026-07-31 07:14:52 +03:00
Lauri Ojansivu fcfed1fd37 All Boards: Search and Multi-Selection become the board header's controls.
All Boards had a search FIELD in its header bar, and a Multi-Selection button
whose actions were four icons beside it. A board has a search BUTTON and a
Multi-Selection button, and both open the right sidebar. Two pages, the same two
control names in the same place, behaving differently - and one of them not the
way the rest of WeKan does. They are the board's now, and they are the same
markup: headerBarControls.jade holds one headerSearchButton and one
headerMultiSelectionButton, included by the board header of the Swimlanes view
and by the All Boards bar. The two copies had already drifted - the close button
that turns Multi-Selection off said "Clear filter" on the board header, which is
what a different control does, and says multi-selection-off in both now.

Only the markup is shared. What a click does is not - a board searches and
selects cards, All Boards searches and selects boards - and it does not need to
be: a Blaze event map catches events from the templates rendered inside it, so
each bar's own map sees the clicks on its own copy. isActive is passed in,
because the two pages keep different selection objects.

All Boards has a right sidebar of its own. Not the board one: that is built
around a board's members, labels, activities and settings, and this page has no
board. It borrows the SHELL - the same .board-sidebar.sidebar classes, the same
close button, the same title-and-back-arrow above a view - so the two look and
behave alike, and it has three views. home, what the hamburger opens, is the
page's menu: Search, Multi-Selection, and Boards in Archive, which had a handler
in the header bar and no way to reach it. search is the field that was in the
bar, still writing the page's own search term so the boards behind it narrow as
you type. multiselection is where the actions on a selection went - star, home,
archive, duplicate and a way out - each with its name beside it instead of being
a crowded icon. Open/closed and which view are separate, so closing does not
forget where you were, and Escape closes it by KEY only: the click half of
EscapeActions would close it on every click, including clicks inside it.

The bar also gained the divider and the hamburger, in their own flex item and
last in the source exactly as the board header has them, which is what keeps the
hamburger in the top right on a phone while the other buttons wrap.

Two things this turned up. A .jade file is not picked up by being on disk - it
has to be imported from client/features/, and the shared controls threw "No such
template: headerSearchButton" on render until they were; a guard now requires
every .jade under client/components to be imported and every +template it
includes to exist, and it found a dangling +subtaskDeleteDialog that has never
had a template (unreachable, so it has never thrown, and it is recorded with
that reason rather than hidden). And deriving a template name from a view name
gave allBoardsMultiselectionSidebar for allBoardsMultiSelectionSidebar - one
letter, renders nothing, no error worth the name - so the names are an explicit
map the guard checks.

Written down in docs/Design/Page/Search.md and Multi-Selection.md, one per
shared control, each covering both pages, with All-Boards.md pointing at them.

Four guards pinned the previous shape and now pin this one, each saying why it
changed: the search field in the bar, the four selection buttons in the bar, the
Multi-Selection parity check that is structural now that there is one template,
and the home-board action that moved to the sidebar.

Thanks to xet7 !
2026-07-31 07:14:52 +03:00
Lauri Ojansivu 2a94ba1b15 Changelog: point the Selected-star link at its own commit.
The entry was written before the commit it describes existed, so the amend
made its placeholder stale. All 26 links in the Upcoming section resolve.

Thanks to xet7 !
2026-07-31 06:47:19 +03:00
Lauri Ojansivu 2537a52ec2 All Boards: make the Selected star a toggle, and name every button.
The "Selected: star" button only ever ADDED stars: it walked the selection and
starred whatever was not starred yet. Once every selected board was starred it
did nothing at all, so there was no way to undo from here what it had just
done, and its tooltip read "Star the selected boards" whatever state the
selection was in. It is a toggle now - none starred stars them all, all starred
unstars them all, and a mixed selection stars the rest, leaving the starred
alone. The mixed case deliberately does not flip each board independently: one
click that starred some boards and un-starred others is not something a button
may do. Only the boards that must change are called, because toggleBoardStar
flips one board and calling it for an already-starred one would un-star it.

The tooltip says which way the button goes right now, set-selected-starred or
the new set-selected-unstarred, and it reads the SAME function the click does -
two copies of "which way does this button go" would eventually be two answers,
and the tooltip would lie about what the click does. The rule itself is
models/lib/selectedStars.js: pure, so it is unit-tested rather than reasoned
about, including the mixed case and an empty or junk selection.

All four buttons on a selection are icon-only now, and they follow the
"Selected:" label in the order star, home, archive, duplicate - the two that
only mark a board first, the two that change what boards exist last. Archive
and duplicate carried their names, which are sentences and pushed the bar onto
a second row.

Checking that each button says what it is turned up one that did not: the close
button that turns Multi-Selection off said "Clear filter", which is what the
OTHER one in this bar does, in the search box. It is multi-selection-off now. A
guard requires every .board-header-btn in the bar to carry a title, and every
title to come out of a translation key rather than being literal English.

Both new keys are in all 147 language files as English placeholders, which is
what the translation policy does with a string untranslated everywhere, so no
language silently loses a tooltip. The seven language files that are symlinks
share their target's copy rather than getting a second one, and the 43 files
that have no trailing newline still have none.

The Font Awesome guard flagged a character in a jade COMMENT, which renders
nothing - the same exemption it already makes for arrows and box drawing. It
skips comment blocks now, tracked by indentation so a continuation line is
skipped too, with a self-check that a rendered line is still caught.

Thanks to xet7 !
2026-07-31 06:47:19 +03:00
Lauri Ojansivu 0ade3ec4ff Changelog: point the header-bar link at its own commit.
The entry was written before the commit it describes existed, so the amend
made its placeholder stale. All 25 links in the Upcoming section resolve.

Thanks to xet7 !
2026-07-31 06:37:15 +03:00
Lauri Ojansivu fa6bcd1f4c All Boards: drop Starred from the bar, and reorder what is left.
Starred was the first button in the header bar, and Starred is a section: the
left menu already lists it beside Templates and Remaining, counts it, and
highlights it when it is the one shown. A second way to reach one section, one
click away from the first, is a control whose only job is to be kept in step
with the menu - so it is gone, and with it the header bar's own js-select-menu
handler and isSelectedMenu helper, which had no other caller there. The left
menu is part of boardList and has always had its own.

Multi-Selection moved to the right of the Lists/Table menu. The bar now reads
left to right as what is shown - Sort, Search, Lists/Table - and then what is
selected: Multi-Selection, followed by the archive, duplicate, star and home
actions that appear with it.

The search box is 150px wide, half of the 300px it was drawn at when it had a
card of its own. Getting there turned up that it had no styling at all: its
rules said .boards-path-header .board-search, the bar it used to live in, so
from the moment the controls moved to the header bar they matched nothing and
the box rendered at the browser's default input size. They are
.all-boards-controls now - and because a white box on a themed bar cannot
inherit that bar's light-on-dark colour without putting white text in a white
box, the input, the magnifier and the clear button each set their own.

The two guards that pinned the old order now pin this one and say why. Both of
them, and the removed-bar guard beside them, had started reading the comments
of the rules they were checking - the new rules explain themselves by naming
the selector they replaced - so they strip comments before asserting.

Thanks to xet7 !
2026-07-31 06:37:15 +03:00
Lauri Ojansivu 4ba5967d72 Changelog: point the removed-bar link at its own commit.
The entry was written before the commit it describes existed, so the amend
made its placeholder stale. All 24 links in the Upcoming section resolve.

Thanks to xet7 !
2026-07-31 06:28:14 +03:00
Lauri Ojansivu 57dd62badf All Boards: remove the white bar above the board icons.
Once every control had moved to the header bar, what was left above "+ Add
Board" was a white strip carrying one thing: a Font Awesome icon for whichever
left-menu section is selected. Three things already say which section that is -
the left menu highlights it, the header bar names the page, and the Starred
control is emphasised while Starred is on - so the strip said nothing and cost
a bar's worth of height on every screen. The board icons start at the top of
the right column now.

It took more with it than the markup. currentMenuPath, sixty-four lines that
resolved a workspace path to an icon and a name, had no other caller. Every
.boards-path-header rule went - the seventeen that styled the bar and its
contents, and the phone media query that had already been reduced to holding
the title. So did the pulse keyframes, whose only user was the multi-selection
hint that lived in the bar. The pager's flex: 0 0 auto was sharing a selector
list with the bar, and is kept on its own: it is still the fixed-height thing
above the scrolling list. Verified the stylesheet's braces still balance and
that it has no empty at-rule left.

Two guards named the bar and now assert it is gone, each saying why: the
scroll-chain guard asked which element above the list keeps its own height,
and the tile-height guard asked whether the bar wraps.

Thanks to xet7 !
2026-07-31 06:28:13 +03:00
Lauri Ojansivu 668f7d1ff7 Changelog: point the build.sh link at its own commit.
The entry was written before the commit it describes existed, so the amend
made its placeholder stale. All 23 links in the Upcoming section resolve.

Thanks to xet7 !
2026-07-31 06:23:17 +03:00
Lauri Ojansivu 65a6a311e2 build.sh: install Node on macOS with nvm, and install the newest 24.x.
"Install WeKan dependencies" ran brew install node@24, which gives whatever
24.x Homebrew currently has bottled rather than what nodejs.org has released,
and which is keg-only - so the branch also wrote PATH, LDFLAGS and CPPFLAGS for
it into ~/.zshrc by hand. It is nvm install 24 now: the major alone, so it
resolves to the newest 24.x every time it runs and never needs bumping, and nvm
puts it on PATH itself. npm comes with the Node it installs, so brew install
npm is gone too.

The nvm installer is fetched from a pinned release tag rather than master,
because the line pipes a downloaded script into a shell, and nvm.sh is sourced
before the first nvm call - nvm is a shell function, not a binary, so without
that every call is "command not found" even straight after a successful
install. An NVM_DIR the caller has already set is honoured, and a failed
install is reported instead of run into.

Two things the old branch did are now actively undone. npm config set prefix
'~/.npm' cannot coexist with nvm: it overrides the per-version prefix, global
installs land outside the Node they were installed for, and nvm refuses to
switch versions while it is set - so the branch clears it, which a machine that
ran the Homebrew path needs. And that prefix directory was created with a
quoted tilde, mkdir "~/.npm", which makes a directory literally named ~ in
whatever directory build.sh was run from.

Linux is untouched and still installs Node with n, which the new guard pins so
the split stays deliberate. Verified the guard fails on the branch as it was -
seven of its nine tests - and passes on this one, and that build.sh still
parses.

Thanks to xet7 !
2026-07-31 06:23:16 +03:00
Lauri Ojansivu 14e8cd32ca Changelog: point the selection-actions link at its own commit.
The entry was written before the commit it describes existed, so the amend
made its placeholder stale. fix-changelog-hashes.sh found the rewritten copy
by subject and author date. All 22 links in the Upcoming section resolve.

Thanks to xet7 !
2026-07-31 06:15:23 +03:00
Lauri Ojansivu f56254e71f All Boards: the actions on a selection move into the header bar too.
The header bar took the controls of the page, but the actions ON a selection -
Move Board to Archive, Duplicate Board, and the "Selected:" star and home -
were left where they were, in a strip above the board icons, on the reasoning
that they are about those boards rather than about the page. That still left
two places to look for a button. They are in the header bar now, to the right
of the controls that are always there, as .board-header-btn like every control
beside them, and .boards-path-header holds the section title and nothing else.

They appear only while something is selected: four buttons that would do
nothing are worse than no buttons. Archive and duplicate carry their names;
star and home stay icon-only under the "Selected:" label that already named
them, because their names are sentences - "Set as Home board (opened after
login)" - that belong in a tooltip, and spelling them out pushes the bar onto
a second row. The label stays a label, with neither the button class nor any
button behaviour.

Two things had to move with the buttons, because Blaze binds both to a single
template: their four click handlers, which an event map on boardList could no
longer see, and hasBoardsSelected, which decides whether they are drawn. The
stylesheet lost .path-right, .selected-action and .selected-actions, which now
select nothing, and the phone media query lost the four rules that flattened a
controls row this page no longer has.

Two guards pinned the previous decision and now pin this one, each saying why
it changed: the All Boards guard asserted the selection actions stay beside
the boards, and the tile-height guard asserted that .path-right wraps - which
the header bar's own button group does instead.

Thanks to xet7 !
2026-07-31 06:15:15 +03:00
Lauri Ojansivu cdbdb7ac47 Changelog: point the All Boards link at its own commit.
The entry was written before the commit it describes existed, so it carried a
placeholder that the amend then made stale.
fix-changelog-hashes.sh found the rewritten copy by subject and author date.
All 21 links in the Upcoming section resolve.

Thanks to xet7 !
2026-07-31 06:06:04 +03:00
Lauri Ojansivu 1049e3e8fc All Boards threw No such function: isAllBoardsView as it rendered.
The page chooses between the board icons and the Table view with
{{#if isAllBoardsView 'table'}}, and that helper was registered on
boardListHeaderBar and on allBoardsViewPopup but not on boardList - the
template that actually asks. A Blaze helper belongs to the template it is
registered on, so the page threw as soon as the router rendered it, right
after login, and All Boards did not come up at all.

The guard did not notice because it read the jade and the JavaScript as two
separate files: it checked that the controls are in the header bar and that
the Table branch calls +tablePage, never that the template asking a question
has the helper that answers it. It now collects every helper this file
registers, and for each template in the jade every helper it uses that this
file defines must be registered on THAT template. Only helpers the file itself
defines are checked - a name it registers nowhere is a model helper on the
data context, like colorClass on a board, and a guard cannot tell one of those
from a typo. The other four templates in the file were clean.

Verified the new test fails on the code as it was and passes on the fix.

Thanks to xet7 !
2026-07-31 06:05:36 +03:00
Lauri Ojansivu 3125daa075 Changelog: the rspack bump, and a summary that ran long.
Updated dependencies. was the twentieth commit since v10.53 and the only one
without a Changelog entry: @meteorjs/rspack 2.0.1 -> 2.1.0-beta.0, the bundler
half of the Meteor 3.5.1-beta.0 upgrade beside it. Its rimraf 6 brings glob 13,
which no longer ships a command line, so twenty-seven packages leave the
lockfile with it - 975 to 948 - and nothing is dropped from WeKan itself.

Checked the whole Upcoming section against the rules in CLAUDE.md: one summary
was 115 characters against the 110 it aims for, and is shorter now. Everything
else holds - one # heading, 80-column wrap outside links, every summary on one
line with its own commit link and no markup in it, no hash or URL as link
text, blank lines around every block, the subsections reading as one sentence,
and the closing Thanks to above GitHub users line. All twenty commit links
resolve in this clone.

Thanks to xet7 !
2026-07-31 06:00:11 +03:00
dependabot[bot] 459ea80d04 Bump jquery from 3.7.1 to 4.0.0
Bumps [jquery](https://github.com/jquery/jquery) from 3.7.1 to 4.0.0.
- [Release notes](https://github.com/jquery/jquery/releases)
- [Changelog](https://github.com/jquery/jquery/blob/main/changelog.md)
- [Commits](https://github.com/jquery/jquery/compare/3.7.1...4.0.0)

---
updated-dependencies:
- dependency-name: jquery
  dependency-version: 4.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 02:58:41 +00:00
Lauri Ojansivu 91e8e05db0 Updated dependencies.
Thanks to developers of dependencies.
2026-07-31 05:56:15 +03:00
Lauri Ojansivu 90f18a6e48 Changelog: every commit since v10.53, in the order the sections go in.
The Upcoming section listed twelve of the nineteen commits made since v10.53,
and put eight of them under "and documents the following:", which described
one of them. It is now the order CLAUDE.md asks for - new features, the user
interface, dependencies, bug fixes, documentation, translations - and it holds
the seven that were missing: the All Boards header bar and Table view, the two
fixes that followed the Public Boards page, the Roles Status translations, the
newest Transifex pull, Meteor 3.5.1-beta.0 and the meteor-node-stubs fork. The
section ends with the "Thanks to above GitHub users" line it was missing.

All nineteen commit links resolve in this clone.

Thanks to xet7 !
2026-07-31 05:55:18 +03:00
Lauri Ojansivu 9c5ff4f9f1 Updated meteor-node-stubs fork.
Thanks to xet7 !
2026-07-31 05:45:59 +03:00
Lauri Ojansivu 2160eccd49 Upgraded to Meteor 3.5.1-beta.0 .
Thanks to Meteor developers !
2026-07-31 05:41:32 +03:00
Lauri Ojansivu 05e9cbc7c6 All Boards: one row of controls in the header bar, and a Table view.
Design: docs/Design/Page/All-Boards.md, whose Table view is the shared table page
(docs/Design/Page/Table.md) and which describes only what is different here.

ONE ROW OF CONTROLS. The page carried its own row above the board icons -
Multi-Selection with its archive and duplicate actions, Sort, the search box - in
.boards-path-header, while the second top header bar above it held only the title.
Two rows of controls on one page, one of them styled like the board header of the
Swimlanes view and one not. Starred, Sort, Search, Multi-Selection and the view
menu are in the header bar now, as .board-header-btn with the same Font Awesome
glyphs and the same emphasis state, so this page agrees with every other page
about where a WeKan page puts its controls.

The actions ON a selection stayed with the boards: Archive, Duplicate and the
"Selected:" star/home actions appear only while something is selected and are
about those rows, not about the page.

SEARCH IS A FIELD, not a button that opens a search view. It filters as you type,
searches across all your boards ignoring the selected section while it has text,
and Escape clears it - the old right-pane behaviour exactly. That is a deliberate
difference from the board header, whose Search opens a search view over cards;
here it filters the list it sits above.

THE VIEW MENU names the CURRENT view - "Lists" or "Table" - not the words "Board
View", the same as the board header, which says "Swimlanes" or "Lists" rather
than naming the menu. Lists is the default and is what an account that has never
chosen sees. The choice is kept in localStorage rather than on the user document:
it is a view preference for one page and changes nothing anybody else can see. A
board's view IS on the profile, because it follows the user between devices; this
deliberately does not.

THE TABLE VIEW is the shared table page, editable - which is what separates it
from the other board table, /public, which is read-only. Columns: Edit, Board
title, Board description. Ten rows a page.

Edit opens the SAME popup the Swimlanes view opens from its board menu, not a
copy of it. That took one change: boardChangeTitlePopup's submit read
Utils.getCurrentBoard(), which is the board you are looking at - and on All Boards
you are not looking at one. It takes the board from its own data context when it
has one and falls back to the current board, so the board header is unchanged.

TWO THINGS THE REFACTOR NEEDED.

The state had to be shared. boardListHeaderBar and boardList are separate Blaze
instances - the bar is rendered into the layout's headerBar region, the page into
its content region - so the search field would have filtered nothing. The search
term and the selected section are module-level ReactiveVars now, assigned onto the
page instance so every `tpl.boardSearchVar.get()` already written keeps working.

And "which boards am I looking at" had to stay one answer: the `boards` helper's
body is `boardsForView(tpl)` now, called by both views, because two copies of a
membership + section + search + sort + paging query would drift.

The view module is split in two for the same reason the others are: which views
exist and which is the default is pure (models/lib/allBoardsView.js) and unit
tested; the ReactiveVars and localStorage are Meteor glue and cannot be loaded in
a plain Node test.

The guard in tests/addBoardTileHeight.test.cjs pinned the ORDER of the three
controls inside .path-right. That row is gone on purpose, so the guard now pins
the order where the controls are - and that the page body does not also carry
them, which is the thing that was wrong.

Thanks to xet7 !
2026-07-31 05:26:30 +03:00
Lauri Ojansivu deab122723 /public: the row had white text on a white background.
The row carried the board's colour CLASS, but nothing in boardColors.css matches
it: every board colour there is written for `.board-list .board-color-X a` - an
anchor inside the All Boards list - and a table row is neither. So the background
never arrived, while publicBoards.css forced `color: #fff` on top of it. White on
white; only the emoji in a title was visible.

Two fixes, and the second is the one that actually mattered.

The colours now reach the row: `.public-board-row.board-color-X` is added to each
of the seventeen existing selector lists in boardColors.css - the same
declaration, one more selector - so the rows follow the board colours and the
theme without a single hex value being repeated in the new stylesheet. The test
counts both lists and fails if a colour reaches the All Boards tile but not the
row.

And a board with NO colour matches none of those rules however they are written,
so the row needs a default background of its own. That is the case that was
reported: without it the row keeps the table's white background and the light
text vanishes. The All Boards tile has the same problem and answers it the same
way - `.board-list-item` falls back to grey so its white text always has
something to sit on - so the row does that too, and the text is the tile's own
#f6f6f6 rather than a hardcoded white.
2026-07-31 05:03:08 +03:00
Lauri Ojansivu 3644b3b0cd /public: one heading, not two.
The route renders `boardListHeaderBar`, whose h1 already says "Public", and the
pane then supplied `titleKey: 'public-boards'` to the shared table page - so
"Public boards" appeared again directly underneath it.

The shared table page prints a title only when it is GIVEN one, for exactly this
reason: docs/Design/Page/Table.md says so in the template itself, and it is why
the Admin Panel panes supply none either - their heading comes from the active
left menu entry. This page had a heading too and asked for a second.

The test now pins it, and reads the LAST `return` of the helper: the earlier one
is the per-board row object, which legitimately has a `title` of its own - the
first version of the guard matched that and failed on the fix.
2026-07-31 04:57:35 +03:00
Lauri Ojansivu de1662146a Translate the Roles Status strings into 111 languages.
The eleven new keys of the Admin Panel / People / Roles pane, written directly -
by the assistant, from each language's own existing translations and general
kanban terminology - which is what CLAUDE.md's translation policy asks for. No
external translation service, API or key is involved, and nothing here is pushed
to Transifex: a filled string must never be able to masquerade as a human one.

Applied through releases/translations/fill-translations.mjs --apply, so the fill
can only ever write into a placeholder. It reported the same thing for nearly
every language - "filled 10, skipped 1 existing human translation" - because
'roles-status-comment' already had a real translation in most files, and the tool
refused to touch it. That is the guarantee working, not a rounding error.

The terms come from each language's own file rather than from English: the word
for a board differs everywhere (Tableau, Tablero, Quadro, Bacheca, Tavla, Taulu,
Tablica, Доска, Pano, Nástenka, 看板, ボード, לוח), and so does 'settings' and
'comment'. Several files are contaminated with other languages - the Greek file
has French and Italian values, the Korean has Japanese, the Thai has Vietnamese -
so those anchors were NOT copied; the strings were written in the actual target
language.

111 of the 142 non-English language files are done. THIRTY-ONE ARE NOT, and are
left as English placeholders on purpose: ace, ary, ast-ES, br, gu-IN, ig, oc,
or_IN, pa, ta, te-IN, tk_TM, tlh, ug, ve, ve-CC, ve-PP, vl-SS, vo, wa, wa-RR, wo,
wuu-Hans, xh, yi, yo, yue_CN, zgh, zh-GB, zu, zu-ZA. A wrong translation of a
permissions table is worse than an English one an admin can still read, and the
policy is explicit that the English source stays when there is no translation.

Regional variants take their base language's text (de-AT/de-CH/de_DE from de, the
eight Spanish variants from es, and so on), and the German wording avoids ß so
de-CH is correct as it stands.

Only the eleven keys changed, in 104 files: the diff touches no other key, key
ORDER still matches en.i18n.json everywhere, and the 43 files with pre-existing
formatting quirks are all files this did not touch.
releases/translations/verify-human-preference.mjs still passes 10/10.
2026-07-31 04:53:05 +03:00
Lauri Ojansivu 5c299d7635 Updated translations. 2026-07-31 04:38:24 +03:00
Lauri Ojansivu 9bd94002b8 Changelog: the Public Boards page. 2026-07-31 04:25:45 +03:00
Lauri Ojansivu 54915db250 /public is its own read-only table page, not All Boards with a different query.
docs/Design/Page/Public.md is the design; it describes only what is different
about this page and links back to docs/Design/Page/Table.md for everything shared,
which is that page's own rule.

WHAT IT WAS. /public rendered `boardList` - the All Boards page - with its query
swapped for `{ permission: 'public' }`. That brought the whole of All Boards with
it: the Starred / Templates / Remaining menu counting the user's OWN boards beside
a grid that was not, the workspaces tree, the org and team filters,
Multi-Selection with its archive and duplicate actions, the sort popup, board
dragging, and an "Add board" tile that made a private board from a page about
public ones. Multi-Selection offered to archive boards the visitor has no rights
to at all.

WHAT IT IS. Only the table. Two columns - board title and board description - ten
rows a page, paged and counted on the server. No left menu, no create, no
selection, no drag, and nothing on the page changes anything: a row's only action
is to open its board.

It supplies a rowTemplate rather than a text-cell column spec, because a row is a
LINK and carries its board's colour class and background image - so a board is
recognised here the way it is on All Boards (#5157) - and a text cell can express
neither. Read-only is about what the page can CHANGE: the row template renders one
anchor and no control.

WHAT A PAGE COSTS. Six fields for ten boards, not ten board documents: title and
description for the columns, slug for the link, color and backgroundImageURL for
the row. `members` is deliberately absent - it is the largest field on a busy
board and this page shows no avatars. The selector is built on the SERVER and
takes nothing from the client: public, not archived, a real board rather than a
template container, and not an internal ^Subtasks^ board. A search goes in as its
own $and term rather than replacing the title clause, or it would drop that last
exclusion.

Not carried over from the tile: member avatars, the per-list card counts, and the
spent-time clock. What a visitor needs of a board they do not belong to is what it
is called and what it is for; the rest is the inside of a board they have not
opened, and each costs a query the page would otherwise not make - the clock in
particular answers by looking for cards, which this page does not publish, so it
would read false for every board on every instance.

client/lib/reportPages.js now declares the DDP-only page-index collection, because
`new Mongo.Collection(name)` throws if the name is taken and it was declared
inside adminReports.js - so the second page to need it could not have one. The
guard in tests/adminPageRows.test.cjs that pinned that declaration is updated with
the reason: what it protects (declared once, from the shared constant, never a
typed string) is unchanged, it just lives in a different file.

tests/publicBoardsPage.test.cjs pins the route, that none of the All Boards chrome
is on the page, that a row carries no control, the two columns and one cell each,
that the labels are translation keys that exist, that paging is ten a page on the
server and the rows drawn are the ids the server named, exactly which six fields
the publication sends, that the selector is server-built and survives a search,
the row colouring, the shared collection, that the files are registered in the
bundle, and that every path the design doc names exists.

Thanks to xet7 !
2026-07-31 04:24:49 +03:00
Lauri Ojansivu 2e1bb799ca Changelog: repoint the links the rebase moved.
The pull rebased the 14 local commits onto the released v10.53, so every commit
link written in the Upcoming section pointed at a hash that no longer exists.
releases/fix-changelog-hashes.sh followed all 11 by subject and repointed them;
only Upcoming is touched, and the released sections are pushed history.
2026-07-31 04:11:32 +03:00
Lauri Ojansivu d92f7e1ef6 Changelog: the board roles work, the search scope, and four UI fixes. 2026-07-31 04:09:41 +03:00
Lauri Ojansivu a4475e57eb Public Boards no longer lists the internal Subtasks boards.
WeKan creates boards of its own to hold machinery - a subtasks board, for one.
They are real boards and the user "owns" them, but nobody chose to make one and
nobody means to open one, so no list of boards shows them (#5582). They are
recognised by their title being wrapped in carets: `^Subtasks^`.

The selector for that was typed out at each list - five copies of the same
`{ $not: { $regex: /^\^.*\^$/ } }` - and the sixth place forgot it. /public
built its own query and listed every public subtasks board on the instance beside
the real ones.

models/lib/helperBoards.js is that selector now, once, and every list uses it -
the four in boardsList.js, userBoards in models/boards.js, and /public, which
gains it. A FUNCTION rather than a shared constant: Mongo selectors get merged and
mutated by their callers, and a shared object would carry one caller's edit into
the next list.

The test fails if a copy of the pattern comes back anywhere, if a board list stops
using the helper, or if /public in particular does - and pins what the pattern
does and does not match, so a board somebody really named "Subtasks" is never
hidden.

Thanks to xet7 !
2026-07-31 04:09:41 +03:00
Lauri Ojansivu 96289e1186 Search All Boards searches all of YOUR boards, not every board on the server.
The board scope came from Boards.userBoards, whose $or lists the ways a user
reaches a board - member, organization, team, e-mail domain - and
`{ permission: 'public' }`. That last one is the odd one out: it is not a
relationship to the user at all, it is "anybody may open this".

That belongs in the boards LIST, where a public board is meant to be
discoverable. In a search it meant every public board on the instance was
searched: on a public server a common word answered with strangers' cards, and
following a hit dropped the user into a board they have no part in. Someone who
wants to look inside a public board can still open it and search there.

`includePublic` is an option on userBoards / userBoardIds, defaulting to TRUE so
every existing caller - the boards list, the lists and comments lookups - is
unchanged. The search publication names it once as SEARCH_BOARD_SCOPE and passes
it to all four board lookups plus the `board:` filter's name resolution: one
missed lookup and that branch of the search still reaches the whole instance,
with nothing looking wrong.

Boards.userSearch takes the same option, because it is what resolves `board:name`
- left unscoped it would name a public board the search itself can return nothing
from, which is a filter that silently empties the results. With public boards
excluded an anonymous caller can reach nothing at all, and an empty $or means
"match nothing" in Mongo but is an error in some backends, so that case is
answered directly instead of being sent to the database.

Only the four real relationships stay unconditional; the test fails if any of them
becomes optional, if a board lookup in the search loses the scope, or if
models/boards.js ever switches public boards off for everybody.

Thanks to xet7 !
2026-07-31 04:09:41 +03:00
Lauri Ojansivu 2f0445102b Remove the "100%" zoom feature from the header.
It scaled the board with a CSS transform, it did not work, and there is no plan to
fix it - WeKan already has a font-size setting (Member Settings / Change Font),
which is what the control was reached for anyway. A control that does nothing is
worse than no control, so it is gone rather than hidden.

What went with it: the pill and its number in the header, the helper and the three
handlers behind it, Utils.getZoomLevel / setZoomLevel / applyZoomLevel and their
three call sites, the `profile.zoomLevel` schema field, its entry in the published
user fields, the model setter and the server method, the `click-to-change-zoom`
translation string, and 57 stylesheet rules spread over four files - header.css,
layouts.css, boardHeader.css and boardsList.css - which is how much of the
stylesheet a broken feature had accumulated.

The card zoom is a DIFFERENT feature and is untouched: getCardZoom / setCardZoom /
applyCardZoom and the +/- buttons in the card header still work.

Three tests existed only to constrain the pill, and each is handled on its own
terms rather than deleted to make the run green:

  * uiDensity's "zoom pill fits inside the 28px quick-access row" has nothing left
    to measure; it is replaced by a guard that the pill has not come back into any
    of the four stylesheets that carried it - it was spread widely enough that a
    partial revival is a real risk.
  * mobileAllBoardsFit's "the quick-access bar gives way" capped the zoom input at
    50px of a 375px phone. The rest of that test - notifications and the user bar
    never shrink, because they are what the user came for - is untouched, and the
    zoom half is now the same "it has not come back" assertion.
  * mobileAllBoardsFit's "the zoom number stays INSIDE its white pill" was a real
    bug once (the pill shrank below its contents and the number ended up beside it
    in 8px type under the notification bell). There is nothing left to lay out, so
    it is removed with a note saying what it used to protect and where the
    replacement guard is.

Thanks to xet7 !
2026-07-31 04:09:41 +03:00
Lauri Ojansivu 95b0acd0b7 Change Color: as many swatch columns as the width allows, like Change Language.
The swatch list is shared with the board-BACKGROUND picker, where it is a
float-based TWO-column grid (boardsList.css: float: inline-start; width: 50%).
Two columns is right for background thumbnails; for the Select Color picker it
meant Flat, Clear, Dark and Special each ran down the popup in a narrow pair, and
most of them were below the fold however wide the browser was.

Auto-filling columns instead - the same answer the Change Language popup already
uses, for the same reason. `repeat(auto-fill, minmax(120px, 1fr))` takes as many
columns as fit and collapses to one on a narrow window, so no media query is
needed and a phone is unaffected. Scoped to .theme-color-picker, so the
board-background picker keeps its pairs; the shared float rules are undone
explicitly inside the grid, because a floated grid item is still floated and
width: 50% would leave every other column half empty.

Width buys columns, so both Change Color popups get more of it on desktop -
Member Settings and Board Settings, which are the two places the picker opens as
a popup. Desktop only: below 800px popup.css lays every popup out as a
full-screen sheet and an !important width would fight that rule and win.

The 720px is in TWO files and has to be, so it is stated in both comments and
checked by the test: client/lib/popupOffset.js clamps a popup into the viewport
using its width, and computed for the default 380px it placed a wide popup opened
near the right edge with a third of itself off the screen - the same class of bug
as #5667, which is what that module exists for. Ordinary popups still clamp
against 380px, which the test also pins.
2026-07-31 04:09:41 +03:00
Lauri Ojansivu 15157cde09 Member Settings / Change Settings: the button is Save, and it has a theme.
It said "Apply" while every other settings form in WeKan says Save, and it was
pure black. Both came from one line: the submit carried no `.primary` class, so
it fell to the BASE button rule in forms.css - whose fallback is `#000` - instead
of the primary rule beside it, which is the themed one
(`var(--theme-accent, #005377)`). The Change Language form directly above it in
the same popup uses `input.primary.wide`, which is the shape this now matches.

The test guards both halves on that one line, so a Save that loses its theme, or a
form that goes back to "Apply", fails.
2026-07-31 04:09:41 +03:00
Lauri Ojansivu efa534fddf Board roles: one capability table, three roles fixed, and a Roles Status pane that shows it.
What each role may do was spelled out THREE times - in the server allow helpers,
in the client's canModify* helpers, and in prose in the docs - and the three had
drifted apart. Every place they disagreed was a role that did not do what its name
says. models/lib/boardRoleCapabilities.js is that table now, in code, and
everything reads it: the allow rules, the UI helpers, the new Admin Panel pane and
the documentation. There is nowhere left for a fourth opinion to appear.

THE THREE GAPS, fixed:

  * "Comment only, assigned" (isCommentAssignedOnly) had FULL WRITE ACCESS.
    Nothing outside the card publications read that flag and it was not in the
    write rule, so the role could create and edit cards, lists and checklists - it
    was "Normal, assigned only" under another name. It is comment-only now, like
    the role it is named after; the two differ only in what they see.

  * "No comments" (isNoComments) could not write ANYTHING. The write rule excluded
    it, so the role blocked editing as well as commenting - a second read-only
    role under a name that says otherwise, and one the UI still offered the edit
    buttons for. It blocks commenting only now, which is what the name and the
    board schema both say it is for.

  * The write rule did not exempt board admins. Every has*() helper on the board
    ignores a flag on an admin; that one read the raw flags, so an admin who also
    carried isNoComments silently lost write access. Not reachable from the Web
    UI, which writes all eight flags at once, but reachable over the REST API,
    which takes them individually. memberRoleOf() resolves isAdmin first.

The UI helpers were part of the same drift - canModifyCard() did not exclude
isNoComments while the server did, and canModifyBoard() excluded neither
isNoComments nor isWorker - so each disagreement was a button offered to somebody
whose write the server then refused. All three ask the same table now.

A FOURTH gap found while building the table, NOT fixed: a Worker cannot move a
card, which is the one thing the role is for. Moving a card is a card UPDATE, so
it goes through the write rule, which excludes Worker. The fix is not a table
edit - it means letting a role write SOME fields of a card and not others, and
"assign himself" means validating that a member change only ever adds the caller.
That is a field-level policy on the path every card update takes and wants
deciding on purpose. It is recorded under "Known gaps" with what it would take.

ROLES STATUS, at Admin Panel / People / Roles, below the Save button: a READ-ONLY
table of what each role may do - which cards it sees, and whether it may comment,
create and edit, or change the board's settings. It is the shared table page
(docs/Design/Page/Table.md), so it looks and behaves like every other Admin Panel
table, with no markup of its own; it has no rowTemplate, no actions and no
editable cell, because a role's capabilities are a property of the code and not a
setting. Every string is a translation key, the Yes/No of each cell included - the
values are booleans, and a hard-coded "Yes" would be English in every language.
The "Invite to board" column reads the pane's WORKING copy, so the table follows
the checkboxes above the Save button as they are ticked, before saving, which is
the point of putting it there.

docs/Design/Page/Table.md listed Roles among the panes that do NOT use the shared
design, "so the gap is visible rather than forgotten" - it has a table now, so it
moved to the list of pages that use it, and the exclusion covers the two panes
that are still nothing but a form.

Two guards were checking the shape of the old code rather than what it guaranteed,
and are updated with the reason in the test: listDragPermission read the flag
exclusions out of canModifyBoard's own body (it has none now, so it checks the
BEHAVIOUR - anonymous is falsy, and each excluded role still cannot drag a list),
and tablePage counted the pager handlers file-wide, which also forbade a handler
on a different template - a separate event map is not a duplicate key.

tests/boardRoles.test.cjs is the suite: the module itself including each fixed
gap, that the server rules and the UI helpers ARE the table and name no flag
themselves, that every cell of the documentation matches it, that the
assigned-only column matches the publication scope, that the remaining gap is
still real and the three fixed ones are recorded as fixed, and that the Roles
Status pane renders from the table, is read-only, is the shared template, follows
the working copy, and translates every string it shows.

Thanks to xet7 !
2026-07-31 04:09:41 +03:00
Lauri Ojansivu 60bc770994 Changelog: the assigned-only window fix, the roles table, and the collection2 update.
Three entries for the commits that had none: the boardCardsWindow assigned-only
restriction, the board-roles table under docs/Features/Members/, and the
aldeed:collection2 4.2.0 -> 4.2.1 bump as a dependency bullet.
2026-07-31 04:09:41 +03:00
Lauri Ojansivu 744df41544 Document what each board role may and may not do, as one table, read from the code.
There was no comparison of the roles anywhere. docs/Features/Members/Members.md
listed three of them - Admin, Normal, Comment only - in one line each, and there
are NINE: board admin, normal, no comments, comment only, worker, read only, and
an assigned-only variant of normal, comment-only and read-only. docs/API/Role.md
shows how to set each flag over the API without saying what any of them does.

docs/Features/Members/Roles.md is the table: for every role, which cards it sees,
whether it may comment, create or edit cards, move cards, edit lists and
swimlanes, and change the board's settings and members. It also says WHERE each
column comes from, because the answer is only two helpers -
allowIsBoardMemberWithWriteAccess() and allowIsBoardMemberCommentOnly() in
server/lib/utils.js - plus isBoardAdmin() and, for visibility, the assigned-only
scope in the card publications. The table is what the SERVER allows, because the
server is the authority and the UI can only hide buttons.

VALIDATED FROM THE CODE, and three things do not match their own names. They are
recorded as gaps, not fixed here, because each needs a decision about which side
is wrong:

  * "Comment only, assigned" (isCommentAssignedOnly) has FULL WRITE ACCESS.
    Nothing outside the card publications ever reads that flag - it is not in
    allowIsBoardMemberWithWriteAccess(), where isCommentOnly is - so the member
    may create and edit cards, lists and checklists. The role is in practice
    "Normal, but only sees the cards assigned to me", which is what
    isNormalAssignedOnly already means.

  * "No comments" (isNoComments) cannot write ANYTHING. That helper excludes it,
    so the role blocks editing as well as commenting - while the schema describes
    it as "is the member not allowed to make comments" and the UI's
    canModifyCard() does not exclude it, so the member is offered the edit
    affordances and the server refuses the write. The name and the schema comment
    both point at the helper being the wrong side.

  * The write helper does not exempt board admins. Every has*() helper on the
    board requires isAdmin: false, so a flag on an admin is ignored, but that one
    reads the raw flags. Not reachable from the Web UI, which writes all eight
    flags at once and never leaves two set - but reachable over the REST API,
    which takes them individually.

A fourth section lists where the UI is more generous than the server, i.e. the
buttons that do nothing: canModifyCard() does not exclude isNoComments, and
canModifyBoard() excludes neither isNoComments nor isWorker. None of these is a
security hole - the server rules hold - but a button that silently fails reads as
a bug to whoever clicks it.

tests/boardRoles.test.cjs keeps the page honest rather than trusting it, which is
the point: a permissions table that quietly goes stale is worse than none, because
it is what an admin decides who to trust with. It parses the table and checks that
every role memberRole() can return has a row and every row names a flag it really
reads, that the "create / edit" and "comment" columns match the flag lists in the
two server helpers, that the "which cards they see" column matches the
assigned-only scope, that only the board-admin row may change settings, that each
gap the table marks is still real AND still explained - so fixing one has to
update the page with it - and that the page is linked from both Members.md and
the API role page.

Thanks to xet7 !
2026-07-31 04:09:41 +03:00
Lauri Ojansivu 885a11f1e3 Update aldeed:collection2 4.2.0 to 4.2.1.
Resolved by the Meteor package resolver; .meteor/versions is the lockfile that
records it. collection2 is what cleans and validates every write against a
collection's SimpleSchema, so it sits on the path of every insert and update
WeKan makes.
2026-07-31 04:09:40 +03:00
Lauri Ojansivu 550cd5c686 An assigned-only member sees only their own cards on a big board too.
Three board-member flags mean the same thing - isReadAssignedOnly,
isNormalAssignedOnly, isCommentAssignedOnly - the member may only see the cards
they are assigned to. The `board` publication has always narrowed its card cursor
with `assignees: { $in: [userId] }` for such a member. `boardCardsWindow`, which
is what ships the cards in LAZY card-loading mode, did not.

So whether the restriction applied at all depended on the board's card-loading
mode. The same member saw only their own cards on a small board and EVERY card in
the window on a big one (or on any board with CARDS_LOADING=lazy) - and with the
cards went their comments, attachments, checklists and checklist items, because
the window's children hang off the same selector.

The restriction is part of the window scope now, and of its COUNT: an
unrestricted `boardListCardCount` still told the member how many cards the list
really holds and offered to scroll in cards that would never arrive.

Two things this had to get right.

The FIELD PROJECTION. publish-composite hands each child the document as the
PARENT cursor published it, and that cursor projected to `{ _id: 1 }` - so
`board.members` was undefined in every child and the restriction would have been
dead code. The parent publishes `members` now, which also makes it reactive:
changing a member's assigned-only flag re-runs the window instead of taking effect
on the next subscribe. The board publication already ships this board's members to
the same client, so nothing new is exposed.

The MERGE. The scope was spread into the client's selector at the top level
because FerretDB v1 (SQLite) does not push a top-level `$and` down to its index -
the wrapped form full-scanned the whole cards table on every poll and cards never
loaded on a big board (10.22). But a top-level spread can only be used when the
two selectors do not both speak for the same key, and the board Filter HAS an
assignee filter, so this collision is reachable from the UI. In the direction the
publication spreads them the client's value would have won and the restriction
would have been silently dropped. The inline `safeCollides` guard covered
boardId/archived only; it is now mergeCardScope in models/lib/boardCardScope.js,
which merges when the keys are disjoint and falls back to `$and` - where BOTH hold
- when they are not. An assigned-only member filtering for someone else now gets
nothing rather than everything, and an unrestricted member keeps the fast path.

tests/boardCardScope.test.cjs covers each of the three flags, an ordinary member,
an INACTIVE assigned-only member document (matching the publication's
isActive: true lookup), a non-member and a logged-out visitor - both deliberately
unrestricted here, because this narrows what a MEMBER sees and board visibility is
decided before it - malformed member arrays, the assignee-filter collision, that
an unrestricted member keeps the top-level merge, that BOTH the window and its
count are narrowed, and that the parent cursor publishes the members its children
read.

Thanks to xet7 !
2026-07-31 04:09:40 +03:00
Lauri Ojansivu 08366abdbb Changelog: a spinner over an empty list, blank minicards, and a filter panel that would not close. 2026-07-31 04:09:40 +03:00
Lauri Ojansivu 1de699aca5 Clicking outside the filter panel closes it.
"If I use any filter, the modal that appears on screen sometimes doesn't
disappear. Ideally, this should close the moment I click anything outside the
modal."

It is the board sidebar showing its filter view, and nothing dismissed it but the
sidebar's own toggle or Escape. The reason is one argument: the document click
handler in client/lib/escapeActions.js runs
`clickExecute(evt.target, 'multiselection')`, and `sidebarView` sits BELOW
`multiselection` in the hierarchy, so `_execute` returns before ever reaching it.
A click could not close the sidebar by design.

The handler lives in the filter template rather than raising that limit, because
raising it would make EVERY sidebar view close on any outside click - Archive,
Settings and Card Settings are panels people work beside on purpose, and only the
filter reads as a thing you open, use and are done with. Escape is untouched: the
existing sidebarView action still returns the sidebar to its default view, and
this change did not have to alter its registration.

Three clicks deliberately do NOT close it. Inside the panel, obviously. A pop-over
the panel opened - the label, member and due-date pickers render outside the
sidebar, so without this, choosing a value in one would close the panel behind it.
And the header button that opens the filter, which would otherwise toggle it shut
in the same gesture that opened it. The handler is also bound on the next tick, so
the opening click - whose flush can still be in flight when the template renders -
cannot reach the handler it just created; a panel destroyed before that tick
clears the timer rather than leaving one behind. It is namespaced and removed by
name in onDestroyed, so it can never outlive the panel and close the sidebar under
some later view.

The sidebar is hidden, not reset: reopening it comes back to the filter you were
using. Escape is the one that returns to the default view, as before.

tests/filterSidebarOutsideClick.test.cjs pins the reason as well as the fix - that
a click really cannot reach sidebarView through EscapeActions, so a later
"simplification" that moves this into escapeActions has to argue with it first -
and each of the three exceptions, the next-tick bind, and that Escape still does
what it did.

Thanks to xet7 !
2026-07-31 04:09:40 +03:00
Lauri Ojansivu 7d9a2e3037 A filter no longer leaves a spinner turning over an empty list, and a half-arrived card is not drawn as a blank box.
Two problems from one report with screenshots, both in how a list decides what to
draw at its bottom edge.

1. "ONCE A FILTER IS APPLIED FOR A MEMBER, THE 3 DOTS CONTINUE TO ANIMATE." The
screenshot shows a list with NO cards under the filter and the load-more spinner
still turning under it - and `_updateList` raising the window limit by ten every
idle callback for as long as it stayed on screen.

The spinner asked "does this list hold more cards than the window I asked for?"
and answered it from a total built somewhere else than the cards being drawn. The
cards come from the filtered selector; the total came either from
`list.cards(swimlaneId).length` or, in lazy card-loading mode, from a count
document published per list/swimlane - whose id was `listId::swimlaneId` and did
NOT include the filter. So changing the filter re-subscribed with a new selector
under the SAME document id, two publications wrote one document, and Meteor's
merge box serves whichever subscription it prefers, which during the changeover is
the older, pre-filter one. The list drew its empty filtered window while its count
still described the unfiltered list.

The count document now carries a short stable key for its selector, so each filter
counts into its own document and a count can never describe another filter's
cards. Both subscribers pass it; the two-argument form still returns the old id
for a caller with no selector to hand.

And the spinner no longer depends on that being right. There is nothing more to
fetch unless the window came back FULL: if we asked for twenty cards and got
three, those three are all there are, whatever any count says. That holds in both
card-loading modes and for a stale count in either direction, which is why it is
worth asking as well as the total.

While there: showSpinner asked `isLazyCards()` with no board while cardsWithLimit
asked `isLazyCards(list.boardId)`, so the two could take different branches for
the same list. Both ask about the board now, and both build the selector the same
way instead of from different sources - that disagreement was the bug.

2. "RANDOM BLANK CARDS ARE APPEARING ON THE BOARD." White minicards with the
handle icon and nothing else - no title, no members, no labels - mixed in among
the real ones. In the screenshot the only two that render are the two carrying the
viewer's own avatar.

A minicard renders blank when its document is in minimongo without its fields:
`Cards.getTitle()` returns null for `title === undefined`, and every badge is
conditional on a field that is not there either. That is NOT a card with an empty
title - the schema declares `title` as optional with `defaultValue: ''`, so a card
that went through it has the key, `''` at worst. A document without the key at all
is a partial replication, and several publications ship cards with a projection:
`openCardData` publishes `{ _id: 1 }` as the parent of its children cursors, the
search publications ship their own field lists, and minimongo merges what every
live publication says about an id.

WHICH of them produced these particular stubs is not settled - it needs the
running board to catch - so this is a guard where the card is drawn rather than a
fix at the source: a document that does not carry the field the minicard is built
around is not drawn. `$exists` is what separates the two cases; filtering on a
falsy title would hide real cards. It cannot hide a card, and when the full
document arrives the card appears, which is what the blank box was standing in for
anyway.

tests/listCardWindow.test.cjs covers the reported case (a filter empties the list
and the total still says 25), a short window in general, that a full window with
more behind it still offers to load more, a stale total in both directions,
missing input, that an empty title is still drawn while a missing one is not, that
the selector stays flat and an existing `title` clause is not silently replaced,
that two filters no longer share a count document while the same filter always
names one whatever order it was built in, and that every subscriber names it the
same way.

Thanks to xet7 !
2026-07-31 04:09:40 +03:00
github-actions[bot] 0ef3d3d2a1 Bump versions for v10.53 2026-07-30 23:23:57 +00:00
Lauri Ojansivu 4c60b12a10 Prepare v10.53 release 2026-07-31 02:23:04 +03:00
Lauri Ojansivu 41446a62a3 Updated translations 2026-07-31 02:22:41 +03:00
Lauri Ojansivu 0c14b4c916 Changelog: the card window docks beside its card again. 2026-07-31 02:02:15 +03:00
Lauri Ojansivu 35e3c8c66c The card window opens beside its card again, on whichever side has more room.
"notice how the card info docks neatly right next to the card" - the 6.09
behaviour asked for in #6465. The window had been moved off the middle of the
board and docked to the end edge instead, which stopped it covering the board but
left it nowhere near the card it belongs to: open a card in the FIRST list of a
wide board and its details are a screen away, with the whole board between them.

It now opens beside the minicard it was opened from, on whichever side has more
room, and always entirely inside the visible area. A card on the left of the
board opens to its right; a card on the right opens to its left.

This is X ONLY. The vertical geometry - the staggered `top` and the `bottom: 8px`
that makes the window full height - is already right, so nothing here writes
`top`, `bottom` or `height`, and a test fails if it starts to.

client/lib/cardDetailsPlacement.js is the decision, pure and unit-testable
without a DOM (like client/lib/cardDragGeometry.js beside it); cardDetails.js is
the DOM half. The width is passed through UNCHANGED unless the viewport itself is
narrower than the window. Shrinking the window to fit the gap beside the minicard
was tried and dropped: on a 600px-wide desktop it turned a 520px window into a
240px one, and this is about where the window is, not how big it is. When neither
side has room it overlaps the card instead, pushed as far towards the roomier side
as the viewport allows - the card is behind it for a moment, which is
recoverable; a window off the edge of the screen is not.

Nothing is placed unless there is something to place it against: a card opened
from a URL, from search, or whose list is scrolled out of view has no minicard on
screen, and keeps the stylesheet's dock. Only the desktop floating window is
touched - the popup form (Board Table, search results) and the mini-screen card
have their own geometry, and the maximized window's insets are !important, so
writing inline styles for it would be a no-op that only looked like it did
something. A window the user has DRAGGED keeps where they put it; the viewport
clamp still applies to it, so a browser window that shrinks cannot leave it
hanging off the edge.

The one vertical side effect that had to be paid for, in cardDetails.css: the
default-position rule is `:not([style*="left"]):not([style*="top"])`, so writing
an inline `left` switches it off - and it carried the `top` as well as the width.
Without a replacement, a SIXTH open window (past the five staggered nth-of-type
rules, reachable with "Open many cards at once") would have had no `top` at all.
The replacement is Y-only and written BEFORE the stagger rules, so cards 1-5 still
take their staggered `top` from those and no vertical position changes anywhere.
The test pins that order, because the same rule written after them would win the
specificity tie and flatten cards 2-5 onto card 1.

Re-placed on resize, coalesced into one animation frame. The inline geometry is
cleared before measuring, so what is measured is the width the stylesheet wants
rather than this function's own previous output - otherwise one run on a narrow
viewport would clamp the width and every later run would re-clamp that clamp, and
widening the browser again would never give the window its full width back.

tests/cardDetailsPlacement.test.cjs covers the placement for a card at each end of
the board, that the side is chosen by which has more room rather than by a fixed
edge, that a tie breaks towards the reading direction, that the window stays on
screen for every anchor position across the viewport including one scrolled
partly out of view, the narrow-viewport and neither-side-fits cases, that the
width is never grown, that a missing anchor places nothing - and the wiring: both
drag handles mark the window as user-placed, the reset happens before the
measure, and no vertical property is ever written.

Thanks to csonkaoszimt, Mintyt and xet7 !

Fixes #6465,
2026-07-31 02:00:55 +03:00
Lauri Ojansivu 733853018a Changelog: a checkbox that promised members, and a card move that lost its keys. 2026-07-31 01:41:47 +03:00
Lauri Ojansivu fe27dc0b8e Fix #6560: moving a card to another board blanked every custom field id.
The values survived the move; their keys did not. Each entry came out with
`_id: ""`, which no board can match to a field definition, so the card arrived on
the destination board with every custom field showing empty. Drag-and-drop, the
Move card popup and the REST API `newBoardId` move all go through
Cards.helpers.move(), and Cards.helpers.copy() had it too.

`mapCustomFieldsToBoard()` was SYNCHRONOUS and called
`ReactiveCache.getCustomField()`, which is async on the SERVER - it awaits
findOneAsync - and synchronous only on the client. So on the server both lookups
returned Promises. A Promise is truthy, so `if (!oldCf)` never fired and the
`if (newCf)` branch always did, assigning `newCf._id`: `undefined` on a Promise.
The schema declares `customFields.$._id` as `optional: true, defaultValue: ''`, so
collection2 cleaned that undefined to `''` on the way to the database. Nothing
threw and nothing was logged.

Both lookups are awaited now, and so is `oldCf.addBoard(boardId)` - also async,
and until the two above were awaited its branch was unreachable, so its own
missing await had never been exercised.

Two things found while fixing it, in the same function:

  * The entries are rebuilt rather than mutated. copy() deliberately works on a
    shallow copy of the card "to avoid mutating the source card in ReactiveCache",
    and `cf._id = newCf._id` reached straight through that copy into the source
    card's own entry objects - re-keying the card being copied FROM.
  * An entry with no id is passed through instead of looked up.
    getCustomField() defaults its selector to `{}`, so `getCustomField(undefined)`
    returns an ARBITRARY custom field, and a blank entry - including one blanked
    by this very bug - would have been re-keyed to whichever field came first.

This is the same defect as #6504 (unawaited ReactiveCache.getBoard in this same
cross-board branch of move(), which gave "newBoard.getNextCardNumber is not a
function"), one line further down. The guard added then named the two getBoard
calls and stopped there, so the third call shipped broken for twenty more
releases; tests/cardMoveAwaitBoard.test.cjs now checks the branch as a whole and
fails on any unawaited async call in it - verified against the pre-fix file, where
it reports mapCustomFieldsToBoard.

tests/cardCustomFieldsMove.test.cjs lifts the real function out of models/cards.js
and runs it against both worlds - an async ReactiveCache (the server, where this
broke) and a synchronous one (the client, where it always worked) - which must
give the same answer: a value whose field exists on the destination board by name
and type is re-keyed to that board's definition, and one whose field does not is
left pointing at the definition it had, which gains the destination board so the
value still resolves. It also pins that addBoard is finished before the array is
returned, that an unknown field id costs no value, that a blank entry is never
matched against an arbitrary field, that the source card keeps its own ids, and
that both call sites await.

Cards moved or copied between boards on an affected release are NOT repaired by
this: the entry kept its value but lost the only reference to which field it
belonged to, so there is nothing left to match it back with. Those values have to
be set again.

Thanks to ChristianMa97 and xet7 !

Fixes #6560,
2026-07-31 01:39:44 +03:00
Lauri Ojansivu 8e89262d00 Fix #6559: the Propagate Members To Boards checkbox passed a selector where an id was wanted.
Ticking "Propagate Members To Boards" for a team in Admin Panel > People > Teams
stored the flag and added nobody. The members only appeared later, when the LDAP
sync cron ran the propagation by its other, correct route.

`setTeamPropagateMembersToBoards(team, value)` receives `team` as the SELECTOR the
client sent - `{ _id: … }`, the same value it hands to `Team.updateAsync` on the
line above - and passed it whole:

  await propagateGroupMembersToBoards('team', team);

`propagateGroupMembersToBoards(kind, groupId)` puts that straight into the member
lookup, so it ran

  Meteor.users.find({ 'teams.teamId': { _id: 'abc' } })

comparing a string field against an object. It matched nobody, the function
returned early with "0 boards updated, 0 members added", and nothing threw. The
checkbox went green and did nothing.

The org column beside it was identical - `setOrgPropagateMembersToBoards` passed
`org` the same way - and is fixed with it, though the report was about teams.

Both call sites pass the id now, and `propagateGroupMembersToBoards` normalises
whatever it is given at the one point every caller goes through: a plain id, or a
document/selector carrying one. Anything it cannot turn into an id is REFUSED AND
LOGGED rather than quietly treated as a group with no members, because a silent
"0 members added" is exactly how this survived a release. A missing group stays
quiet - propagateAllFlaggedGroupsToBoards may legitimately have nothing to pass.

The select-all checkbox in the column header is the same promise to the admin, and
it did not propagate at all, not even wrongly: setAllTeamsFeature /
setAllOrgsFeature set the flag on every row and stopped. They now propagate when
that field is the one being turned ON, and only for their own kind, so ticking the
team column does not act on the org column beside it.
propagateAllFlaggedGroupsToBoards takes an optional kind for this; the default is
unchanged, so the LDAP cron and the propagateOrgTeamMembersToBoards method still
do both.

The two wiring tests in tests/propagateMembers.test.cjs REQUIRED the buggy calls -
they asserted `propagateGroupMembersToBoards('team', team)` verbatim, pinning that
a call happened without asking whether its argument was right, and so held the bug
in place. They require the id now and fail if the whole selector comes back, and
the normaliser is exercised for real: every shape a caller might hold an id in
resolves to it, a selector with no id resolves to null AND warns, and an absent
group is quiet.

Thanks to ChristianMa97 and xet7 !

Fixes #6559,
2026-07-31 01:39:20 +03:00
github-actions[bot] 36bb123988 Bump versions for v10.52 2026-07-30 17:49:26 +00:00
Lauri Ojansivu d737fcf2c4 Prepare v10.52 release 2026-07-30 20:48:26 +03:00
Lauri Ojansivu c2867cb065 Changelog: why three fixes for the Yandex crash missed, and the retry nobody had written. 2026-07-30 16:01:59 +03:00
Lauri Ojansivu 08be34b70d Fix #6533: a busy write is now actually retried, which nothing had ever done.
The crash loop is gone - a transient database error no longer ends the boot
(00startupResilience) or the process (00processErrors) - and the reporter's 10.49
log is the same contention arriving in front of a user instead:

  Exception while invoking method '/users/updateAsync' MongoServerError:
    ... [collection.go:191 sqlite.(*collection).UpdateAll]
    [db.go:151 fsql.(*DB).InTransaction] database is locked (5) (SQLITE_BUSY)

That error goes to the CLIENT. The user's edit fails, and they reload to find out
what happened - which is exactly the complaint, "they only see new cards after a
reload". And it was not supposed to: models/lib/databaseErrors.js has classified a
locked SQLite as { id: 'deadlock', act: 'retry' } with "Retried automatically."
since it was written, and 00processErrors says "the write is retried by whatever
issued it". Nothing read `act`. No code in WeKan retried anything, anywhere.

SQLITE_BUSY is the one database error a client is meant to handle itself. SQLite
has a single writer; a second writer arriving while the lock is held is told to
come back, and nothing was applied - FerretDB takes the lock before the
transaction (_txlock=immediate), so a BUSY answer means the write did not happen.
Retrying the same write is the documented behaviour and stays idempotent: Meteor
has already chosen the _id, so a retry that raced a duplicate is refused by the
unique index rather than inserted twice.

server/00retryBusyWrites.js wraps insertAsync / updateAsync / upsertAsync /
removeAsync on Mongo.Collection.prototype: a transient database error is retried
with exponential backoff and full jitter, bounded at 5 attempts and 2s in total
(WEKAN_DB_RETRY_*). Bounded on purpose - retrying for longer holds a method
invocation and its slot in the connection pool while the contention it is waiting
for gets worse. When it still fails the ORIGINAL error is thrown unchanged, so a
database that is genuinely stuck looks exactly as it did before and nothing is
hidden; anything that is not transient is rethrown on the first attempt, so a full
disk is not waited on four times before being reported.

It is loaded from server/imports.js, which runs after the Meteor packages, so this
wrapper sits OUTSIDE the ones collection2 and collection-hooks put on the same
prototype - and that is how it sees the error at all, because by then the busy
error is collection2's ValidationError with the database's message inside it,
which is the form in the reporter's SyncedCron traces.

Writes only. Every error in the reports is a write, which is what contends for the
one writer; SQLite in WAL mode does not block a reader against a writer, and
wrapping every read would put this on the hottest path in the server for no
reported failure.

Retries are counted and summarised at most once a minute, so a contended database
is visible without the log becoming the new problem, and a write finally given up
on is recorded for Admin Panel / Problems - except an eventlog write, which is how
the recorder writes, and reporting its failure would call the recorder from inside
itself.

The reporter asked outright whether PostgreSQL is an option for a snap
installation. It is, and it has been: the deadlock rule's advice now says how -
snap set wekan wekan-ferretdb-handler=postgresql wekan-ferretdb-url=... , or
docker-compose-ferretdb-v1-postgresql.yml in Docker - instead of only saying that
SQLite has one writer.

tests/sqliteBusyRetry.test.cjs loads the real module against a stub collection
prototype and exercises the behaviour: a busy write is retried and succeeds with
its arguments and its collection unchanged, collection2's ValidationError form is
recognised too, a full disk is thrown at once and never retried, an endlessly busy
database gets the original error after a bounded number of attempts AND a bounded
wall-clock time, the backoff grows and is jittered and capped on the shipped
numbers, the summary is rate-limited, reads are left unwrapped, wrapping twice is
a no-op, and the eventlog write is retried without reporting on itself.

Thanks to Nissulya and xet7 !

Fixes #6533,
2026-07-30 15:58:39 +03:00
Lauri Ojansivu 91bd631072 Fix #6557: Meteor's own SWC transpile emitted the missing helper, not the bundler.
Three attempts at this have added imports to client/lib/swcHelpers.js and 10.51
still failed in Yandex Browser with the same line. The stack says where to look:

  Uncaught Error: Cannot find module '@swc/helpers/_/_possible_constructor_return'
      at a.s [as link] (...)  at client-rspack.js (...:285:985)
      at client-meteor.js (...:285:86)

Read outward: client-meteor.js links client-rspack.js, and the failing link() is
inside client-rspack.js. But the bundle rspack writes contains no @swc/helpers
specifier at all - the built _build/main-prod/client-rspack.js has the helper
bodies INLINED and the string nowhere. So the import is in the copy of that file
that METEOR compiled, and Meteor put it there:

  * package.json says "meteor": { "modern": true }, which turns on Meteor 3.3+'s
    SWC transpiler for every file;
  * packages/babel-compiler/babel-compiler.js sets jsc.externalHelpers: true
    whenever node_modules/@swc/helpers exists - and WeKan depends on it - so the
    output IMPORTS its helpers instead of inlining them;
  * for web.browser.legacy that transpile passes no jsc.target and an env.targets
    down to ie 11, so `class` is lowered to ES5 and the output gains
    import { _ } from "@swc/helpers/_/_possible_constructor_return";
  * that specifier is not in the legacy bundle's module tree and the link()
    throws before anything runs. The modern bundle is unaffected: nothing there is
    lowered that far, and modules-runtime/modern.js resolves
    mainFields ['browser','module','main'] - the esm/ file - where legacy.js
    prefers `main`, which is ../../cjs/_x.cjs.

That is also why the earlier fixes could not work. client/lib/swcHelpers.js is in
the RSPACK graph; rspack resolves its imports and inlines the helper bodies, so
they never become entries in Meteor's module tree, which is the tree the failing
link() searches. No import from app code can satisfy a link that Meteor's
transpiler writes afterwards.

So .swcrc sets jsc.externalHelpers: false and SWC inlines each helper into the
file that needs it: the import is not emitted, so it cannot go missing. Both
readers of that file merge it over their defaults and neither preserves this key -
Meteor's deepMerge keeps only jsc.target, env.targets and module.type, and
@meteorjs/rspack's cleanOmittedPaths only jsc.target - so it reaches the Meteor
transpile and the rspack build alike. It costs a copy of a helper per file that
uses one, which is the right trade for a bundle that does not load at all.

.gitignore's `*.sw*` is for vim's .swp/.swo and matched .swcrc too, so adding it
was silently a no-op; `!.swcrc` follows it, and the test fails if that exception
is removed or moved above the rule that needs it.

Only browsers served the legacy bundle could hit any of this, which is why it read
as a Yandex bug. useragent-ng reports Yandex Browser as family "Yandex Browser",
webapp camel-cases that to yandexBrowser, and modern-browsers has neither a
minimum nor a chrome alias for that name - isModern() is false for a name nobody
declared - so every Yandex Browser user was served ES5. server/modernBrowsers.js
declares yandexBrowser 18: its major version is a year, and 18.x is Chromium 64,
past full ES2015 (51) and dynamic import() (63). Samsung Internet, Vivaldi, Opera
Mobile, Whale, MIUI, UC and QQ Browser are in the same position and are left
alone on purpose - their version numbers do not map onto a Chromium version - and
the file says so rather than looking like they were forgotten.

tests/modernBrowsers.test.cjs reproduces Meteor's decision (webapp's camelCase,
modern-browsers' alias table and lookup) against real user agent strings, so it
fails if the key stops matching the family name or the minimum stops covering the
versions in the reports. tests/swcLegacyHelpers.test.cjs now pins .swcrc, that
the keys the readers preserve are NOT set there, the gitignore exception, and that
swcHelpers.js still says it is a diagnostic rather than the fix.

Thanks to zubzhaaaw and xet7 !

Fixes #6557,
2026-07-30 15:57:58 +03:00
Lauri Ojansivu cf85cba26e Changelog: the four dropped fields of a database problem.
Thanks to xet7 !
2026-07-30 03:50:31 +03:00
Lauri Ojansivu ca0e36e371 Database problems: show what the database actually said.
A database problem arrived in Admin Panel / Problems / Database problems
with an empty Category, Name and Action, a severity of warning, and a
detail that said "The database returned an error WeKan has no rule for.
Read the message below" - with no message anywhere below, or above, or on
the page at all. So the row said that something happened and nothing
about what.

Nothing was wrong with the classifier or the recorder. EventLog has a
SimpleSchema attached, and collection2 cleans every insert against it
with filter:true, so a field the schema does not declare is dropped
silently on the way to the database. The four fields the database stream
uses - type (the rule id), db (which database said it), kind and message
- were never added to that schema, so they were the four things thrown
away, and they are exactly the four the page shows in Category, Name,
Action and the message it told the admin to read. The other four streams
were unaffected because they only ever write schema fields.

The schema declares them now, and a test pins the general rule instead of
the one instance: for each of the five event loggers, every key of the
document it inserts must be declared in the EventLog schema, so the next
field somebody adds to a logger cannot vanish the same way.

The message is shown too - the Detail cell carries WeKan's reading of the
error and then the database's own sentence, which is the one thing an
admin can search for or paste into an issue - and the stream search looks
at message, db, kind and type, so searching for "postgresql" or for a
phrase out of the message finds the row that displays it. The
unclassified advice no longer promises a message "below": the message
follows in the same cell.

Because that message is now stored and displayed, it loses its
credentials first. A database that refuses a login or cannot be reached
quotes the connection URL back, password and all - "failed to connect to
mongodb://wekan:s3cret@mongo:27017" - so classifyDatabaseError redacts
the userinfo of any URL in the message before returning it, keeping the
host, the port and the database name, which are what makes the message
useful. It is done there rather than in the recorder so no future caller
can forget it. The message is also sanitized like every other stream's
detail: one line, control characters out, capped.

Thanks to xet7 !
2026-07-30 03:49:26 +03:00
github-actions[bot] f8f3a7b9a6 Bump versions for v10.51 2026-07-29 01:11:11 +00:00
Lauri Ojansivu e09e5e4899 Prepare v10.51 release 2026-07-29 04:10:14 +03:00
Lauri Ojansivu 725b844c2e Changelog: repoint the links the rebase moved, and finish the Upcoming section.
The rebase that landed v10.50 rewrote three of the commits the Upcoming section
links, so three links pointed at nothing; releases/fix-changelog-hashes.sh
repointed them by subject and author date. All 3024 commit links in the file
resolve to a commit in this clone again.

The section was also three commits short of what has been done since v10.50.
Added: the SNAP_AUTH message that blamed the Snap Store for a credential
snapcraft could not read, the Releases menu that makes every maintainer script
in releases/ reachable from build.sh and build.bat, the twelve one-line wrappers
that became commands plus the command line both scripts now have, and the
variant sync renaming both snapcraft files.

Every commit since v10.50 now has an entry except the changelog commits
themselves, which is the convention.

Thanks to xet7 !
2026-07-29 04:06:51 +03:00
Lauri Ojansivu 74a8f931f1 Twelve one-line wrappers become menu commands, and both menus get a CLI.
The wrappers first. `releases/ufw-enable.sh` was one line - `sudo ufw enable` -
and so were eleven others: ssh-start/stop, snapd-start/stop, docker-start/stop,
multipass-remove, syncloop, git-add-revert, stable-tag and ufw-disable. Nothing
in the repository called them except the menus added yesterday, so they are the
command itself now, in the same menu entries. Checked one by one before
deleting: docker-build-deps.sh looked like the same kind of thing and is NOT
gone, because releases/docker-build.sh runs it.

An entry is "Group|Label|what|prompt|platform|key" now. `what` is a path under
releases/ or, with a leading "!", the command. `platform` is "linux" for the ten
that are systemd, ufw, snap or multipass: build.bat does not offer those,
because Windows cannot do them at all - it is not a parity gap, and the guard
knows the difference. ssh-start ran `systemctl enable ssh` twice; the entry
enables AND starts it, like its stop counterpart always did.

Then the CLI, which is what makes any of this usable from a script or a cron
entry:

  ./build.sh --help                   usage and examples
  ./build.sh --list                   all 84 names, with what each one does
  ./build.sh <name> [arguments]       run one, without the menu

  ./build.sh release-snap 10.50
  ./build.sh push-translation ja
  ./build.sh ufw-enable               what releases/ufw-enable.sh used to do

The name is the file name without its extension, or - for the twelve commands -
the name of the wrapper file it replaced, so anything anybody had in a script
keeps working. Arguments after the name are passed through unchanged. An
unknown name says so and exits 2 instead of doing nothing. build.bat has the
same thing, dispatched before the menu, and both menus have a "CLI commands"
entry that prints the help and the list.

tests/buildScriptParity.test.cjs gains: both scripts must dispatch from the
command line, every name build.sh answers to must work in build.bat unless it
is linux-only, the linux-only ones must NOT be in build.bat, and every example
in the help text must be a real name - which is the guard that catches a help
text teaching a command that fails.

Thanks to xet7 !
2026-07-29 04:04:47 +03:00
Lauri Ojansivu b7d66e97a5 build.sh and build.bat: a Releases menu for the ~90 scripts in releases/.
releases/ holds 90 maintainer scripts and FOUR of them were reachable from a
menu - the test run, the database conformance run, run-everything and the
CHANGELOG hash repair. Everything else existed only for whoever remembered the
file name: the bundle builds per architecture, the whole snap flow, the
Transifex scripts, the Sandstorm packaging, the tag and release helpers, the
Docker image publishing, the VM helpers.

They are one list now, `RELEASE_SCRIPTS` in build.sh, as
"Group|Label|path|argument prompt", in eight groups: Release, Snap, Bundles,
Docker images, Sandstorm, Translations, Git and repo, Server and VM. Pick a
group, pick a script; a script that needs a version, a language code, a branch
or a file is ASKED for it and gets it passed through, so it works from the menu
instead of only from a remembered command line. An .mjs entry is run by node,
everything else by bash.

build.bat has the same eight groups with the same scripts in the same order,
run through Git Bash - and it says so by name when bash is not on PATH, rather
than failing with "not recognized as an internal or external command".

Not in the menu, each for a reason the guard states: run-everything.sh,
db-conformance.sh and fix-changelog-hashes.sh (already in the Tests and Setup
menus), ensure-tools.sh (a helper the others source), ferretdb/* (they run
inside the built snap, bundle or Docker image), the superseded old-*.sh, and
build-bundle-win64.bat, which bash cannot run - the Bundles menu of build.bat
says to run that one directly.

tests/buildScriptParity.test.cjs gains the two guards that keep this true: every
.sh/.mjs/.bat in releases/ must be reachable from BOTH menus unless it is in the
documented skip list, and the two menus must offer the same scripts, in the same
order, asking for the same arguments.

Thanks to xet7 !
2026-07-29 04:04:47 +03:00
Lauri Ojansivu 4f872a68c8 SNAP_AUTH: "could not be parsed" is not "the store refused it".
v10.50 built the amd64 snap, uploaded it, and died on

  Publishing snap "wekan_10.50_amd64.snap"...
  Unsquashing snap file 'wekan_10.50_amd64.snap'.
  Credentials could not be parsed. Expected valid Ubuntu One credentials.

and the job then printed "SNAP_AUTH did not work: the Snap Store refused the
upload". The store refused nothing: snapcraft could not READ the secret. Sent
after the wrong problem again, and this time after a full snap build on every
architecture.

Three changes, no guessing about the credential format - that is snapcraft's
business, not this workflow's:

  * the secrets check now rejects only what is unambiguously not a credential:
    under 100 characters, or containing snapcraft's own "Exported login ..."
    banner, which is what pasting the terminal OUTPUT instead of the file the
    command WROTE looks like. That fails in one line at the top of the job
    instead of after the build;
  * the message after a failed upload no longer claims to know which failure it
    was. It names both, and how to tell them apart from the snapcraft line
    printed just above: "Credentials could not be parsed" is an unreadable
    secret, 401/403 is a credential that lacks the ACL for that snap NAME;
  * every re-export command now names all three snaps, because one release
    publishes wekan, wekan-ondra and wekan-gantt-gpl:

      snapcraft export-login --snaps wekan,wekan-ondra,wekan-gantt-gpl \
        --acls package_access,package_push,package_release,package_update \
        snap-auth.txt
      gh secret set SNAP_AUTH --repo wekan/wekan < snap-auth.txt

The same distinction is in the Launchpad upload message, which had a third
possibility of its own ("is not a valid file" = the build produced nothing).

Thanks to xet7 !
2026-07-29 04:04:47 +03:00
Lauri Ojansivu e2d7574acc Changelog: the three snaps, the manual variant Docker image, and why core24.
Thanks to xet7 !
2026-07-29 04:04:47 +03:00
Lauri Ojansivu bb29383101 Docs: how the variant snaps and the variant Docker images are published now.
Snap-Ondra-Gantt.md described the design as it was planned, not as it works:
the snap depending on the repository push, one architecture, one snapcraft
file renamed. It now says what the workflow does - two independent decisions
(publish the snap / update the repository), amd64 and arm64, both snapcraft
files, and the name check before the upload - and that the two repositories
were synced by hand on 2026-07-29.

It also points at the manual Docker path, which is new: wekanteam/wekan-gantt-gpl
and wekanteam/wekan-ondra are published by .github/workflows/docker-variant.yml
or releases/docker-publish-variant.sh, by retagging the released
wekanteam/wekan manifest.

Thanks to xet7 !
2026-07-29 04:04:47 +03:00
Lauri Ojansivu f3f3d21a36 One release publishes all three snaps: wekan, wekan-ondra, wekan-gantt-gpl.
People are still on the older snap names, so a release that publishes only
`wekan` leaves them on a stale package. Two things stood in the way, and both
were in snap-variants.

The job gated EVERYTHING on WEKAN_REPO_TOKEN being able to push to
wekan/<variant>. Building and publishing a snap needs SNAP_AUTH and nothing
else; keeping the variant GitHub repositories in step with wekan/wekan is a
separate, optional thing. Because the two were tied together, a token without
push rights meant no variant snaps at all - that is why v10.48 and v10.49
published none. Now the guard answers two questions instead of one: `snap` (is
SNAP_AUTH set) decides the build and the publish, `sync` (may the token push)
decides only whether the repository is updated, and every "cannot push" warning
ends with "The snap is still built and published".

The variant tree is also no longer taken from a clone of the variant
repository: it is rsynced from the release tag this job checked out, with the
snap name and title sed into both snapcraft files. So the snap is built from
what was just released, whatever state the variant repository is in - and the
optional push sends that same tree on.

Both variants now build on amd64 AND arm64, the two native runners the default
snap uses, instead of amd64 only, and publish to stable + candidate + beta +
edge like the default snap: base core24, grade stable, so all four channels
accept it.

A last guard before the upload: the built file's name must start with the
variant's snap name. A build that produced wekan_*.snap here would overwrite
the DEFAULT snap in the store with a variant.

SNAP_AUTH must carry the ACL for all three names:

  snapcraft export-login --snaps wekan,wekan-ondra,wekan-gantt-gpl \
    --acls package_access,package_push,package_release,package_update \
    snap-auth.txt

tests/releaseWin64AndVariants.test.cjs pins that the build and publish depend
on `snap` and never on `sync`, that the push is the part that needs the token
and runs once per variant, that both variants cover both architectures, and
that a wrongly named snap is refused.

Thanks to xet7 !
2026-07-29 04:04:47 +03:00
Lauri Ojansivu af2683d564 Say why the snap is core24: it is the base that may publish to stable.
The header of snapcraft.yaml still described an older policy - "the workflow
publishes it to the candidate + beta + edge channels; the stable channel is
published MANUALLY later" - which has not been true for a while: snap-native,
snap-launchpad and snap-variants all publish stable + candidate + beta + edge,
for the default snap and for the wekan-ondra and wekan-gantt-gpl variants.

Both headers now say the actual reason for the base: core24 is released, so the
snap can be grade: stable and the stable and candidate channels accept it.
core26 is still experimental - build-base: devel forces grade: devel, and a
devel-grade snap is refused by stable and candidate, so it could only reach beta
and edge. That is why the release builds core24 and keeps the core26 file for
testing only.

The guard now pins all of it: snapcraft.yaml is base: core24 with grade: stable
and no build-base, every publish in the workflow names all four channels, and
snapcraft-core26.yaml is only ever renamed by the variant sync, never built.

Thanks to xet7 !
2026-07-29 04:04:47 +03:00
Lauri Ojansivu 652a6b8501 The variant sync renames the snap in both snapcraft files, not just one.
snapcraft.yaml is what the snap-variants job builds; snapcraft-core26.yaml is
the same snap on the next base. The sync step seds only the first, so the
core26 file in wekan-ondra and wekan-gantt-gpl kept saying `name: wekan` - and
a core26 build from either repository would have published itself as the
DEFAULT WeKan snap. Both files are renamed now, and the guard pins it.

Also ignores /wekan-ondra/ and /wekan-gantt-gpl/, which is where those two
repositories are cloned when they are synced by hand from this working copy.
They are whole git repositories, so they were never at risk of being committed
into this one, but they do not need to be untracked noise either.

Thanks to xet7 !
2026-07-29 04:04:47 +03:00
github-actions[bot] 1232f16024 Bump versions for v10.50 2026-07-28 23:29:07 +00:00
Lauri Ojansivu 3c857a5484 Prepare v10.50 release 2026-07-29 02:28:19 +03:00
Lauri Ojansivu e7eff63368 Changelog: an Upcoming section again, for the two changes since v10.49.
The release consumed the previous one, so this recreates it with what has been
done since: the SIGPIPE fix that ends the win64 mystery (a matched file
reported as missing, because grep -q closing the pipe makes pipefail report
141), and the #6558 browser test that was measuring the board's edge
auto-scroll instead of panning.

changelogFormat now excludes the closing "Thanks to above GitHub users ..."
sentence from its 80-column count. That line is prescribed word for word by
CLAUDE.md, so it is 92 characters in every release and cannot be wrapped
without changing the established form - and the file had reached exactly the
250-line budget, so writing the line the rules require failed the guard that
checks the rules. It counts the lines that COULD be wrapped now, and there are
249 of them, all in old entries.

Thanks to xet7 !
2026-07-29 02:27:46 +03:00
Lauri Ojansivu 18a2dbda8a The #6558 drag test measured the edge auto-scroll, which is a feature.
Its first run in the 00:12 suite: Chromium 248 passed, 1 failed - this test,
on `expect(during.lane).toBe(120)`, received 0. Everything it exists for
passed: the drag was really in progress, and neither the canvas nor the lane
carried `dragscroll` while it was.

The lane moved because the test grabbed the FIRST card of the FIRST list, which
sits within 40px of the lane's left edge - EDGE_SIZE in
imports/lib/boardAutoScroll.js. Inside that zone the drag's own auto-scroll
takes over by design and moves the lane SCROLL_STEP (15px) per mouse event:
eight moves, 120 - 8*15 = exactly the 0 that was read. That is #443 working
(dragging a card toward an off-screen list scrolls the board), not the panning
this test is about.

Panning is 1:1 with the pointer and happens anywhere; edge auto-scroll happens
only at an edge. So the test now drags in the middle: it measures the lane and
canvas rectangles, picks the minicard with the most room inside a 70px margin
on every side, and moves it along a short diagonal clamped to that box. If no
card can be found that far from every edge - a viewport too small - it skips
with that reason instead of measuring the wrong mechanism. The board is seeded
with cards in four lists so such a card exists.

Thanks to xet7 !
2026-07-29 02:24:51 +03:00
Lauri Ojansivu 9b57dfd8b2 The win64 zip was never broken: SIGPIPE + pipefail turned a MATCH into a failure.
v10.49 printed the listing the previous fix added, and it settles two releases
of guessing:

  7z t: archive is intact.
  7z l -ba listed 51850 entries; the first five:
  2026-07-28 21:58:39 D....        0        0  bundle
  2026-07-28 21:52:07 .....        9        9  bundle\.node_version.txt
  2026-07-28 21:58:36 ....A  8828928  3365469  bundle\bsondump.exe
  2026-07-28 21:58:39 ....A 55313920 26791675  bundle\ferretdb.exe
  2026-07-28 21:52:07 .....      243      168  bundle\main.js
  ##[error]Process completed with exit code 141

bundle\main.js is right there, and the step still failed - with 141, which is
128+13, SIGPIPE. `printf '%s\n' "$listing" | head -5`: head has its five lines
and exits, printf is killed writing to a closed pipe, `set -o pipefail` (which
GitHub's bash sets) makes that the pipeline's status, and `set -e` ends the
step. The evidence printed, then killed the job.

The same mechanism is the ORIGINAL v10.48 failure, and this is the part worth
keeping: `printf '%s' "$listing" | grep -qF -e "bundle\main.js"` - grep -q exits
the instant it MATCHES, printf dies of SIGPIPE, pipefail reports 141, and `if !`
reads that as "not found". So "wekan-10.48-win64.zip has no bundle/main.js" was
printed BECAUSE the file was found. Reproduced both, exactly, in bash.

Every listing now goes through a file and every reader reads the file: the win64
verify, the four `unzip -l "$zip" | grep -q " $want$"` checks on the other
platforms (same trap, only luckier about the pipe buffer so far), and the five
`gh release view ... | grep -qxF` asset checks. No pipe, no SIGPIPE, no
pipefail verdict on a command that did its job.

tests/releaseWin64AndVariants.test.cjs pins that nothing in the workflow pipes a
listing into grep or head, and that the win64 verify reads its listing from a
file.

Thanks to xet7 !
2026-07-29 02:24:51 +03:00
github-actions[bot] b9d8e11b1d Bump versions for v10.49 2026-07-28 21:49:57 +00:00
Lauri Ojansivu cc54e10ace Prepare v10.49 release 2026-07-29 00:48:54 +03:00
Lauri Ojansivu ea5a18c6e2 Launchpad stops the builds because the remote-build project has no licence.
The Launchpad project page answers what three "Stopped" builds and twenty
minutes of build time each could not:

  https://launchpad.net/xet7-craft-remote-build
  Licence: None specified
  "Purchasing a commercial subscription is required ... The licensing of your
   project does not qualify for free hosting."

`snapcraft remote-build` files every build under that auto-generated project
(57 snapcraft-wekan-<hash> repositories, one per invocation), and
auto-generated means created with no licence. A project that does not qualify
for free hosting does not get free builders, so its builds end as "Stopped"
with no artifact - Pending, Building, Stopped, `Artifacts:` empty - with
nothing wrong in the build log, because nothing was wrong with the build.

Setting the project's licence to MIT / X/Expat fixes it, and only tells
Launchpad what LICENSE, package.json and snapcraft.yaml already say. That is a
click on Launchpad, not a change in this repository, so what is added here is
the pointer: when a remote build produces no snap, the job now prints the URL,
what to look at and what to set, because this is the one cause that leaves no
trace in the build log it just printed.

The finding, and the click, are written up in ../log/workflow/TODO.txt item 5.

Thanks to xet7 !
2026-07-29 00:45:22 +03:00
Lauri Ojansivu 89e6189cc3 release-all.yml: fix the four things the v10.48 logs showed, and stop
misattributing the failures.

Every one of these made a release job report the wrong cause, which is worse
than the failure itself: two of them sent the last round of work after the
wrong problem.

1. snap-launchpad never checked whether a .snap existed. In three steps it did

     snaps=( wekan_${VERSION}_<arch>.snap )

   and then counted the array. That string has no wildcard, so it is a literal
   filename, `shopt -s nullglob` cannot empty a literal, and the count was
   ALWAYS 1 whether or not the file was there. So s390x logged an empty
   `Artifacts:` list, then "Remote build s390x succeeded", then

     'wekan_10.48_s390x.snap' is not a valid file
     ##[error]SNAP_AUTH did not work: the Snap Store refused the upload

   The Snap Store never saw an upload. It is a real glob now, the first match
   must also be non-empty, and the upload step says "the Launchpad build
   produced no artifact - this is NOT a SNAP_AUTH problem" for a missing file
   and keeps the credential advice for a refusal by the store.

2. The Launchpad build log was downloaded and thrown away. It is the only thing
   that says why a build ends as "Stopped", and the job printed it only when
   snapcraft's exit code was non-zero - which, after (1), it was not. It is now
   printed whenever there is no snap, along with `ls -la`, and the "exit 0 but
   nothing built" case says so in words.

3. build-win64 verified the zip by listing it into a variable and grepping that,
   so a failure printed the verdict and none of the evidence: "has no
   bundle/main.js" with no way to tell a bad zip from a bad grep. What can be
   checked robustly now is: bundle/main.js, bundle/node.exe and
   bundle/start-wekan.bat exist and are non-empty BEFORE zipping, and the
   archive passes `7z t`. The listing is printed - five lines and a match count
   per file - and a zero count is a warning naming both facts that contradict
   it, not a failed release.

4. The snap-variants pre-flight asked GitHub for `.permissions.push` on the
   variant repository. That field is the authenticated USER's role, not what the
   token may do: it answered true, and the push one step later got

     remote: Permission to wekan/wekan-gantt-gpl.git denied to xet7

   It now asks the receive-pack advertisement - the first thing a real push
   requests, and what GitHub answers 403 to for a token without Contents:write -
   with the token in curl's -u rather than in a URL, and no git command, since
   this step runs before actions/checkout.

What still needs a person is unchanged and written up in ../log/workflow/TODO.txt:
why Launchpad stops the ppc64el and s390x builds after ~20 minutes, and giving
WEKAN_REPO_TOKEN write access to the two variant repositories.

tests/releaseWin64AndVariants.test.cjs is new and pins (3) and (4);
tests/releaseSnapArches.test.cjs gains "every snaps=() must be a real glob" and
"the build log is printed whenever there is no snap", and its
"SNAP_AUTH did not work" assertion is replaced by the two messages that took its
place, with a note saying why. Both read the workflow with comments stripped -
the comments quote the broken lines on purpose.

Thanks to xet7 !
2026-07-29 00:40:21 +03:00
github-actions[bot] 1f3374c74b Bump versions for v10.48 2026-07-28 20:05:08 +00:00
Lauri Ojansivu b6253a96e1 Prepare v10.48 release 2026-07-28 23:04:19 +03:00
Lauri Ojansivu 5ab83910d2 Changelog: the commit-link repair. 2026-07-28 22:56:54 +03:00
Lauri Ojansivu 9d045d3a3a The CHANGELOG hash repair no longer breaks the links it should leave alone.
releases/fix-changelog-hashes.sh - run by build.sh's "Update git ..." after a
rebase and by release-all.sh before a release - decided a link was stale when
its commit was not an ancestor of HEAD. That is the wrong question. A commit
that lives only in an old release TAG is not on this branch, and GitHub serves
it perfectly well; calling it stale and repointing it sends the reader to a
DIFFERENT change, which is worse than the dead link it was trying to fix. Two
2019-era links were repaired that way while fixing the file today, and had to
be put back.

Staleness is now "no ref in this clone - branch or tag - reaches it", read once
from `git rev-list --all` rather than by walking the history per link (which
--all-sections would turn into thousands of walks; the whole file takes about
ten seconds).

When a hash really resolves to nothing, the replacement is now looked for in
order of how strong the evidence is:

  1. same subject AND same author date - a rebase keeps the author date and
     replaces the commit date, so it is the one to compare;
  2. same subject, and only ONE commit has it - a subject like "Updated
     ChangeLog." or "Bump versions for v9.99" repeats, and picking the newest
     of several was a coin toss;
  3. the same patch, by `git patch-id`, which is what survives a reword;
  4. the object is gone entirely: if the first 9-12 characters still resolve,
     the tail was invented (a short hash padded out to 40 characters) and the
     commit the prefix names is the one meant.

Also: only `github.com/wekan/wekan` links are considered, so a link into
wekan/charts or a fork is not matched against this history; and
`--all-sections` (with `--dry-run`) checks the whole file, not only the section
being released - which is how the 106 dead links fixed in the previous commit
were found.

tests/changelogHashFix.test.cjs runs the script over a temporary CHANGELOG and
pins the behaviour: a current link and a tag-only link are untouched, a padded
hash is repointed to the commit its prefix names, an unresolvable hash is
reported rather than guessed, and another repo's link is left alone.
tests/changelogCommitLinks.test.cjs keeps guarding the wiring, with its
ancestry assertion replaced by the reachability one and a note saying why.

Thanks to xet7 !
2026-07-28 22:56:13 +03:00
Lauri Ojansivu 10de45509f Changelog: every commit link now points at a commit that exists.
106 of the 3014 wekan/wekan commit links pointed at nothing. Clicking one
gave GitHub's "This commit does not belong to any branch" 404, and there was
no way to tell from the page which change it had meant.

Three causes, three fixes:

  * a rebase, amend or squash after the entry was written (the case
    releases/fix-changelog-hashes.sh handles, but only for the section being
    released) - the pre-rewrite object is still in the clone, so the commit
    with the same subject and author date on this branch is the same commit,
    and the link now points there;

  * a hash that never existed at all - a 40-character string whose first nine
    characters were right and whose tail was invented. Resolved by prefix
    where the prefix still resolves, and otherwise by matching the entry's own
    text against the commit messages of the release it sits in, one entry at a
    time. The "Part 1 / Part 2" links carry no text of their own, so those
    were read from the bullet around them: Code of Conduct, Add Header Login,
    Updated dependencies, Fix drag drop lists, Red Strings drag-to-connect;

  * two entries whose link is the SAME commit as their summary's - the body
    repeats the link - which now agree with each other.

Checked the other way round too: a link is only rewritten when its hash is
reachable from no ref in this clone. Two links were reverted after that check
(48ebc5f11, bbc68309a): the commits are reachable from release tags, so those
links were always fine. Every one of the 3014 links now resolves to a commit
in the repository.

The 2019-era hashes that are reachable only from a tag are left as they are -
a tag is pushed, so GitHub serves them.

Thanks to xet7 !
2026-07-28 22:50:03 +03:00
Lauri Ojansivu 7e11aad183 Changelog: point the Upcoming entries at the commits that are on the branch. 2026-07-28 22:29:25 +03:00
Lauri Ojansivu e8cbe63e75 Changelog: the card-drag fix and the two guards fixed after the 18:10 run. 2026-07-28 22:25:15 +03:00
Lauri Ojansivu c185f5e982 Moving a card no longer pans the board at the same time.
On a board large enough to have scrollbars in both directions, dragging a
card sometimes moved the card, sometimes scrolled the list, sometimes
scrolled the board, and often several of those at once, so the card could
not be dropped where it was aimed.

A board runs three drag-scroll implementations over the same pointer: the
dragscroll library, bound separately to `.board-canvas`, to every swimlane
and to every lane; the `mousedown .board-canvas` lane pan in swimlanes.js;
and jQuery UI sortable, which is the one that is supposed to move the card.
Nothing stood any of them down while a drag was in progress. swimlanes.js
did try, for list drags, but called `dragscroll.reset()` FIRST and removed
the class afterwards - reset() binds to whatever carries the class at that
moment, so the listeners stayed - and it left the canvas tagged anyway. The
lane pan ignored the `nodragscroll` marker the library honours, so pressing
a minicard, which carries that marker exactly because it is the card's drag
handle, started a horizontal pan that ran alongside the card drag.

Now client/lib/boardDragscroll.js suspends drag-scrolling for the whole
drag - class off, THEN reset - and restores exactly the elements it took it
from, so a board route does not gain panning it deliberately turned off.
Every sortable on the board (cards, lists, swimlanes) suspends in start and
resumes in stop, a window mouseup/touchend/dragend restores in case a drag
never reaches its stop handler, and a swimlane re-rendered mid-drag does not
re-arm panning under the pointer. The lane pan now refuses to start on
`.nodragscroll` or on a drag handle, and gives way if a drag begins after
the press. The drag handles themselves are marked `nodragscroll`: a press on
a handle moves the item, it never pans the board.

tests/boardDragScrollDuringDrag.test.cjs pins the order of the two steps,
the restore-what-was-suspended rule, the safety net, the suspend/resume in
every sortable and the two markers; specs/37-card-drag-sort.e2e.js drags a
card sideways on a 12-list board and pins that neither the lane nor the
canvas scrolls while it moves, and that panning is back afterwards.

Thanks to mueschel and xet7 !

Fixes #6558,
2026-07-28 22:25:15 +03:00
Lauri Ojansivu 31ba78a317 Fix the last two guards, one of which was right about the changelog.
The 18:10 run: 267 node suites, 2 failed. All three browsers clean, Mocha 500
passing, FerretDB unit and integration green - and the conformance run has all
FOUR backends answering identically now (see below).

changelogFormat was right, and about MY text: two lines in the entries I wrote
today START with `#`, which markdown renders as a heading and which splits the
version list - the exact trap CLAUDE.md warns about. The issue numbers are
escaped (`\#3138`) and the lines rewrapped so none begins with one.

boardsVisibilitySettings pinned a Yes/No radio in the account-access Save that is
not in that pane: "display the authentication method" is a checkbox with its own
handler, which writes the toggle directly. The guard now checks what that Save
really does - both fields guarded on being on screen, and nothing written when
neither is - and that the display toggle writes itself.

And the news from the same run: sqlite, postgresql, MYSQL and MARIADB all answer
identically on 98 of 100 cases, with ZERO differences. The two exceptions are the
$slice / $elemMatch projections that no backend implements. This morning MySQL
answered "Error 1064" to every filtered query and MariaDB could not create a
collection.

Thanks to xet7 !
2026-07-28 22:25:15 +03:00
github-actions[bot] 2699605627 Bump versions for v10.47 2026-07-28 15:11:19 +00:00
Lauri Ojansivu 54dbb3b270 Prepare v10.47 release 2026-07-28 18:10:21 +03:00
Lauri Ojansivu 7c10754d40 Changelog: the three commits since v10.46 that had no entry.
The Upcoming section had the CodeQL fixes and the backlog update - their hashes
were corrected after the rebase - but nothing for what came after them:

  * the #6533 fix, which is the one users feel: `quave:synced-cron` installs a
    process-wide unhandledRejection handler that calls process.exit(1), so one
    write losing a race for the SQLite write lock ended the whole server and
    systemd restarted it into a busier database. WeKan decides now, and a
    transient database error is logged and survived;
  * the four guards that pinned an older shape, and the browser test that
    reported "class missing" when the board had simply not arrived yet;
  * the translations update, as a plain bullet - there is nothing more to say
    about it than that it happened.

Checked against the rules: the security subsection keeps "This release", each
later one starts with "and", every summary is one line under 120 characters and
links a commit that exists in this repository, and every commit since the tag is
accounted for except the changelog-only ones.

Thanks to xet7 !
2026-07-28 18:08:30 +03:00
Lauri Ojansivu dd6ad1a6fe Updates translations 2026-07-28 18:03:51 +03:00
Lauri Ojansivu efcf46e005 Updated ChangeLog. 2026-07-28 18:00:30 +03:00
Lauri Ojansivu 9978219b27 The background-image tile test says which half failed.
Under a three-browser run WebKit reported "element(s) not found" for
`.board-list-item.has-background-image`, which cannot tell "the board has not
arrived in minimongo yet" from "it arrived without the class". It waits for THIS
board's tile first, found by its id in the seeded title, and only then for the
class - so a future failure names the actual problem instead of both at once.

Thanks to xet7 !
2026-07-28 17:59:17 +03:00
Lauri Ojansivu edd2904334 Fix #6533 for good: a rejected promise no longer ends the server, and four more guards.
The reporter's 10.45 log shows the startup work landing - "[startup] a startup
step could not finish because the database was busy; WeKan continues" and
"[checklists] Backfilled boardId on 0 row(s); 21 row(s) have no card to take it
from" instead of a 130k-card scan - and then the server dying anyway:

  SyncedCron: Fatal error encountered (unhandledRejection): ValidationError:
    ... sqlite.(*collection).UpdateAll ... database is locked (5) (SQLITE_BUSY)
  SyncedCron: Received UNHANDLED_REJECTION signal - cleaning up running jobs
  systemd: Main process exited, code=exited, status=1/FAILURE

`quave:synced-cron` installs a process-wide unhandledRejection handler that calls
process.exit(1). So ANY unhandled rejection - including one write losing a race
for the SQLite write lock, which is transient by definition - killed the whole
server, and systemd restarted it into a database made busier by the restart. The
counter reached 73.

server/00processErrors.js takes that decision back. The package is deliberately
polite about it: it only exits `if (process.listenerCount('unhandledRejection')
=== 1)`, i.e. when nothing else has an opinion, so having one is the documented
way to stop it. A transient database error is logged and recorded for Admin
Panel / Problems and WeKan keeps serving; anything else is logged with its full
stack and WeKan still keeps serving, because ending everybody's session over one
rejected promise is a larger failure than the one being reported. An uncaught
EXCEPTION still exits unless it is a transient database error - a process that
threw out of a place nobody handled can hold half-applied state.

The four guards the same run surfaced, each a later assertion:

  * boardsVisibilitySettings: the Visibility saves go through
    visibilityTextFields(), which does the "is this input on screen" check once
    for every field instead of per call site.
  * changelogFormat: "the newest release" now means the newest RELEASE (an
    Upcoming section may sit above it), the Upcoming section is checked by its
    own test, and a release needs at least ONE entry - "more than five" measured
    the day's workload rather than the format, and v10.46 carried two fixes.
  * leftMenu: /information redirects the FlowRouter way, like /translation.
  * narrowWindowLayout: the phone rules carry !important, because the desktop
    rule they override is more specific.

Thanks to Nissulya and xet7 !

Fixes #6533,
2026-07-28 17:59:17 +03:00
Lauri Ojansivu 4595802ed5 Changelog: the CodeQL fixes and the backlog update.
Thanks to GitHub CodeQL code scanning and xet7 !
2026-07-28 17:59:17 +03:00
Lauri Ojansivu 11c946301a TODO Later: drop the ten issues that are no longer open, and say what FerretDB testing found.
Every issue in the backlog was checked against GitHub. Ten are closed and are
gone from the list: #3138, #3252, #3276, #3378, #3748, #3828, #4055, #4774,
#5149 and #6511. The "already correct in the current code" category went with
them - it held only #4774 and #4055, both closed - so an empty category does not
sit there looking like work.

#6509 is not a bug report any more, it is "please test FerretDB v1 with MySQL,
MariaDB and SAP HANA", and it is mostly answered: the conformance harness runs
one catalogue of 100 queries against every backend with an image for this
machine, and MariaDB now answers identically to SQLite on 98 of them - the two
exceptions being the $slice / $elemMatch projections that no backend implements.
The entry says what is still open rather than implying otherwise: MySQL's
confirming run is pending (its container lost a port race, since fixed) and SAP
HANA is untested, because its image needs a licence acceptance and a machine with
the memory for it.

And one string was actually translated: Finnish `databaseReportTitle` ->
"Tietokantaongelmat". The other 40 Finnish placeholders are numbers, symbols,
product names (Gantt, LDAP, OAuth2, S3/MinIO, GridFS) and a font name, which ARE
the same in Finnish - the headline "12,254 untranslated strings" is mostly that,
which is worth knowing before anyone sets out to fill them. The search-operator
abbreviations are deliberately left in English: `b:` / `l:` / `s:` are how a
search is TYPED, and translating them changes the syntax for that language, which
is the maintainer's call rather than a wording fix.

Thanks to xet7 !
2026-07-28 17:59:17 +03:00
Lauri Ojansivu 032722fcc4 Security: trust a certificate instead of disabling verification, and escape every metacharacter.
CodeQL alert #430, js/disabling-certificate-validation, High:
`reqOptions.rejectUnauthorized = false` in the outgoing-webhook path. It is
right, and the switch is gone.

`rejectUnauthorized: false` accepts ANY certificate - including the one a man in
the middle presents - which is the attack the handshake exists to stop. What
#6553 and #6551 actually need is not "verify nothing", it is "this certificate is
legitimate", so that is what WeKan takes now:

  WEBHOOK_TLS_CA_CERT   the certificate (or CA) to TRUST for outgoing webhooks
  MAIL_TLS_CA_CERT      the same for the mail server
  MAIL_TLS_SERVERNAME   the name to verify the mail certificate AGAINST, for the
                        wildcard-one-level-short case the report describes

Each is the PEM itself or a path to a file holding it. A self-signed certificate
is its own issuer, so naming it here is exactly what makes it valid - and
verification stays ON, the chain is still checked, the hostname is still checked.
A path that cannot be read is not fatal: it logs which setting failed and keeps
the system trust store, rather than stopping mail or webhooks.

The SSRF protections are untouched: the address is still resolved once and
pinned, private ranges are still refused, redirects are still blocked.

CodeQL alert #429, js/incomplete-sanitization, High:
`helper.replace(/\./g, '\\.')` in a test escaped dots only, so a backslash in the
value could change the meaning of the pattern it was spliced into. It uses the
same escape-everything helper tests/testsAreRegistered.test.cjs already has, with
the backslash escaped FIRST.

tests/mailTransportTls.test.cjs now fails if `rejectUnauthorized: false`, that
same assignment, or NODE_TLS_REJECT_UNAUTHORIZED appears in either file - so the
pattern cannot come back quietly - and covers the certificate-from-PEM,
certificate-from-path and unreadable-path cases.

Thanks to GitHub CodeQL code scanning and xet7 !
2026-07-28 17:59:17 +03:00
github-actions[bot] f95f6ff439 Bump versions for v10.46 2026-07-28 13:57:12 +00:00
Lauri Ojansivu 131098c2c6 Prepare v10.46 release 2026-07-28 16:56:04 +03:00
Lauri Ojansivu eaf8b557cf Changelog: an Upcoming section again, with the two changes made after v10.45.
v10.45 was tagged at 12:11, and the two changes after it went into the file with
no Upcoming section to go into: the #6556 fix landed INSIDE the released v10.45
section, where it says something that is not true - that release does not contain
it - and the test-harness fixes were not recorded at all.

There is an `# Upcoming WeKan ® release` section above v10.45 now, holding both:
the SWC helper fix that 10.45 shipped and the bundler deleted, and the WebKit
navigation retry / conformance container cleanup, with the four guards that
drifted alongside them. The entry that used to open with "…and" reads on its own
now, since it no longer follows the entry it was a continuation of.

Checked against the rules in CLAUDE.md: the file still opens with Platforms and
TODO Later, Upcoming sits between them and the newest release, nothing else is a
heading, every summary is one line under 120 characters and links the commit it
describes, the first subsection says "This release …" and the next "and …", and
the backlog still thanks nobody.

Thanks to xet7 !
2026-07-28 16:54:30 +03:00
Lauri Ojansivu 64971491a6 Fix the four guards and the two harness faults the 15:15 run found.
Node suites: 267 run, 4 failed - each a LATER assertion in a suite fixed earlier
today, which is how this runner works.

  * boardsVisibilitySettings: the template lookup tried only `name='x'`, and this
    file writes both quote styles, so a template that is right there "must
    exist".
  * changelogFormat: my own entry's summary was 121 characters against a 120
    limit, and two summaries linked no wekan/wekan commit. One is shortened; the
    other two link the FerretDB commit they describe, and the guard accepts a
    wekan/FerretDB link now - the fork is where that code lives, and a WeKan hash
    for work that is not in this repository points at the wrong change.
  * leftMenu: the old /translation URL redirects with
    `redirect(FlowRouter.path('setting'))` inside triggersEnter, which is
    FlowRouter's own way; the guard wanted a `FlowRouter.go`, which in a trigger
    re-enters the router instead of replacing the navigation.
  * narrowWindowLayout: "every rule mentioning the bell or the avatar" is three,
    not two - the avatar has a second rule that only zeroes its padding. The two
    PLACEMENT rules are picked by what they declare.

WebKit: 242 passed, 1 failed - "WebKit encountered an internal error" from
page.goto, which is the browser, not WeKan. openBoard retries five times for
exactly this reason, but a THROW from the navigation escaped the loop, so one
flaky goto failed a test with four attempts left. The navigation is inside the
try now, and the final message says which of the two happened: a board that never
rendered, or a navigation that never succeeded.

Database conformance: postgresql and mysql were reported as "container did not
start" when the truth was `failed to bind host port 127.0.0.1:35432: address
already in use` - a container left behind by an interrupted earlier run. The
script now removes containers named `wekan-conformance-db-*` (its own, never a
`docker compose` stack) before it starts, and a `docker run` that hits a taken
port moves to the next free one and retries instead of declaring the image
broken.

And the news from that same run: MariaDB now answers IDENTICALLY to SQLite on 98
of 100 cases, the two exceptions being the $slice / $elemMatch projections that
no backend implements. It could not create a collection this morning.

Thanks to xet7 !
2026-07-28 16:44:30 +03:00
Lauri Ojansivu ea59cf5a61 Changelog: why the SWC helper fix did nothing, and what fixes it.
Thanks to zubzhaaaw and xet7 !
2026-07-28 16:37:28 +03:00
Lauri Ojansivu c1bb2a6a50 Fix #6556: the SWC helper fix was deleted by the bundler, because the package says it may be.
10.45 still failed in Yandex Browser with the same
"Cannot find module '@swc/helpers/_/_possible_constructor_return'". The previous
fix was in that build and did nothing.

`@swc/helpers` declares `"sideEffects": false`. That is a promise to the bundler
that importing one of its modules changes nothing observable, so an import whose
bindings are never READ may be removed entirely - and

    import '@swc/helpers/_/_possible_constructor_return';

is exactly that. Every line of the file was dropped, the package subdirectory was
never pulled into the bundle, and the legacy module tree came out as before. The
fix compiled to nothing and looked like it had been applied.

Every helper is now imported BY NAME and read: they are collected into an array
whose length is written to `window.__wekanSwcHelpers`. That is an observable
effect, so no optimizer may remove the imports that feed it - and the global is
useful on its own, because in a browser that fails this way again it says in one
word whether the helpers reached the bundle at all.

The `_/` path is what is imported, not `esm/`, because `_/` is what the
transform's own imports resolve at runtime; Node's resolver would refuse it (the
package's `exports` map has no `./_/*`), but Meteor's module system resolves the
directory through its package.json `main`, which is what the legacy bundle does.

tests/swcLegacyHelpers.test.cjs now fails if a bare side-effect import comes
back, if any binding is imported without being read, if the array does not end
somewhere observable, or if @swc/helpers stops declaring sideEffects: false (in
which case this reasoning should be revisited rather than silently kept).

Thanks to zubzhaaaw and xet7 !

Fixes #6556,
2026-07-28 16:37:13 +03:00
dependabot[bot] 7727e51296 Bump @babel/parser from 7.29.7 to 8.0.4
Bumps [@babel/parser](https://github.com/babel/babel/tree/HEAD/packages/babel-parser) from 7.29.7 to 8.0.4.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v8.0.4/packages/babel-parser)

---
updated-dependencies:
- dependency-name: "@babel/parser"
  dependency-version: 8.0.4
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-19 01:11:02 +00:00
783 changed files with 60900 additions and 8074 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Project-scoped Codex configuration.
#
# Claude has no non-default tool permissions or MCP servers configured for this
# repository, so there are no settings to translate here. Project instructions
# live in AGENTS.md and the nested AGENTS.md files.
+417
View File
@@ -0,0 +1,417 @@
name: AppImage
# Build WeKan as an AppImage for every CPU an AppImage runtime exists for, and
# attach them to the newest WeKan release.
#
# WHAT IS INSIDE IS WHAT WEKAN ALREADY SHIPS. release-all.yml already produces a
# bundle that runs offline with nothing installed: the Meteor server (main.js),
# the bundled Node.js the release was built against, the wekan/FerretDB v1 fork
# with its embedded pure-Go SQLite backend, the wekan/mongo-tools-patches binaries, the
# cpu-exec/qemu-user shim from #6458, and start-wekan.sh to bring them up. That
# bundle is already relocatable and already self-contained - it is the offline
# zip people download today - so this workflow does NOT rebuild WeKan. It takes
# the wekan-<version>-<arch>.zip off the release and wraps it, which means the
# AppImage is byte-for-byte the same server, Node and FerretDB as the zip beside
# it rather than a second build that might differ.
#
# So there is no MongoDB to install, no Node to install, and no database server
# to run: FerretDB v1 keeps everything in SQLite files under the AppImage's data
# directory. One download, one file, double-click.
#
# WHICH CPUs. An AppImage needs a runtime binary for its architecture, and those
# exist for x86_64, aarch64, armhf and i686. WeKan publishes Linux bundles for
# amd64 and arm64, so those are the two that can be wrapped: x86_64 from amd64,
# aarch64 from arm64. Each is built on a runner of its own CPU, because
# appimagetool has to run on the architecture it is packing for. If a bundle for
# another architecture appears on a release, add it to the matrix and it will be
# wrapped the same way.
#
# WHERE THE DATA GOES. A .deb or a container has somewhere obvious to put its
# data; an AppImage is one file that may live on a USB stick, and the one place
# it can always write is the user's home. WRITABLE_PATH defaults to
# ~/.local/share/wekan here, so running the AppImage twice keeps the same boards,
# and setting WRITABLE_PATH yourself still overrides it - the launcher's own
# variables all still work.
on:
# Callable, so release-all-missing.yml can ask for just the architectures
# a release is short of. The build itself stays here - a second copy of the
# appimages build would drift from this one.
workflow_call:
inputs:
tag:
required: false
type: string
publish:
required: false
default: true
type: boolean
only:
description: 'Only build these architectures, as a JSON array (e.g. ["aarch64"]); empty = all'
required: false
default: ''
type: string
workflow_dispatch:
inputs:
tag:
description: 'Release tag to wrap and attach to (empty = newest release)'
required: false
type: string
publish:
description: 'Attach the AppImages to the release'
required: false
default: true
type: boolean
only:
description: 'Only build these architectures, as a JSON array (e.g. ["aarch64"]); empty = all'
required: false
default: ''
type: string
permissions:
contents: write
concurrency:
group: wekan-appimage-${{ inputs.tag || github.ref }}
cancel-in-progress: false
jobs:
release:
name: find the release
runs-on: ubuntu-24.04
outputs:
tag: ${{ steps.find.outputs.tag }}
version: ${{ steps.find.outputs.version }}
steps:
- uses: actions/checkout@v7
- id: find
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
# --repo on every gh call, whether or not the job happens to have
# checked out: a call that relies on an inferred remote breaks the day
# somebody removes the checkout, and the error it gives - "fatal: not
# a git repository" - names neither gh nor the workflow.
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
--limit 1 --json tagName --jq '.[0].tagName')"
if [ -z "$tag" ]; then
echo "::error::No release to wrap, and no tag was given."
exit 1
fi
# The bundles are named wekan-10.57-amd64.zip against tag v10.57.
version="${tag#v}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Wrapping the bundles from $tag (version $version)"
build:
name: AppImage ${{ matrix.appimage_arch }}
needs: release
runs-on: ${{ matrix.runs_on }}
# A 32-bit job that cannot run on the runner it was given must not fail the
# release for the architectures that built fine.
continue-on-error: ${{ matrix.tolerate_failure || false }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- appimage_arch: x86_64
bundle_arch: amd64
runs_on: ubuntu-24.04
- appimage_arch: aarch64
bundle_arch: arm64
runs_on: ubuntu-24.04-arm
# The other two architectures an AppImage runtime EXISTS for. There
# are four in total - x86_64, aarch64, i686, armhf - and that is the
# whole limit: the runtime is the ELF the kernel executes, so an
# architecture with no runtime cannot have an AppImage at all. That is
# why ppc64le, s390x, riscv64 and loong64 are not here and never will
# be; they are served by the bundle zip and the .deb.
#
# i686 builds on the x86_64 runner, which runs 32-bit x86 natively -
# no emulation, no multiarch, and appimagetool-i686 runs as-is.
- appimage_arch: i686
bundle_arch: i386
runs_on: ubuntu-24.04
# armhf builds on the aarch64 runner, which runs 32-bit ARM only if
# the kernel was built with CONFIG_COMPAT and the 32-bit loader is
# installed. GitHub does not promise either, so this one is allowed to
# fail without taking the release with it - the same treatment TSC
# gives its emulated armhf job. If it turns out to work reliably,
# drop the flag; if it never does, drop the entry.
- appimage_arch: armhf
bundle_arch: armhf
runs_on: ubuntu-24.04-arm
tolerate_failure: true
# WHICH entries this run wants, decided here because it cannot be decided
# where it belongs. A job-level `if:` cannot see `matrix` - GitHub exposes
# that context to runs-on, env, name, steps and a few others, but not to a
# job's own condition - and a workflow that tries answers
# Unrecognized named-value: 'matrix'
# and refuses to load at all, taking every workflow that CALLS it with it.
# A job-level env CAN see matrix, so the answer is computed once here and
# every step below asks for it.
env:
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.appimage_arch) }}
steps:
- uses: actions/checkout@v7
if: ${{ env.BUILD_THIS == 'true' }}
# An AppImage only WRAPS an existing release bundle
# (wekan-<version>-<arch>.zip); it cannot build one. When this workflow is
# asked to fill a gap for a release whose base bundle is not published yet
# (release in progress, or it failed), `gh release download` finds "no
# assets to download" and the job failed hard. Turn that into a graceful
# skip: if the bundle this arch needs is not on the release, flip BUILD_THIS
# off so every step below (all gated on it) is skipped with a notice. The
# AppImage is built normally once the base bundle exists.
- name: Skip this arch if its release bundle is not published yet
if: ${{ env.BUILD_THIS == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
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 }}" \
--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
echo "::notice::$zip is not on ${{ needs.release.outputs.tag }} yet - skipping this AppImage (it can only wrap a bundle that exists)."
echo "BUILD_THIS=false" >> "$GITHUB_ENV"
fi
- name: Fetch the release bundle for this architecture
if: ${{ env.BUILD_THIS == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
gh release download '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --pattern "$zip" --clobber
unzip -q "$zip"
# A zip that exists is not a bundle that runs; these are the two files
# the AppImage is built around.
for f in bundle/main.js bundle/node bundle/ferretdb bundle/start-wekan.sh; do
if [ ! -e "$f" ]; then
echo "::error::$zip has no $f - it is not a runnable WeKan bundle."
exit 1
fi
done
chmod +x bundle/node bundle/ferretdb bundle/start-wekan.sh
du -sh bundle
- name: Build the AppDir
if: ${{ env.BUILD_THIS == 'true' }}
run: |
set -euo pipefail
version='${{ needs.release.outputs.version }}'
rm -rf AppDir
mkdir -p AppDir/usr/lib/wekan AppDir/usr/share/applications \
AppDir/usr/share/icons/hicolor/512x512/apps
cp -a bundle AppDir/usr/lib/wekan/bundle
# WeKan's own icon, from public/. 512x512 is what appimagetool wants
# for the thumbnail a desktop shows.
cp public/android-chrome-512x512.png \
AppDir/usr/share/icons/hicolor/512x512/apps/wekan.png
cp AppDir/usr/share/icons/hicolor/512x512/apps/wekan.png AppDir/wekan.png
cat > AppDir/usr/share/applications/wekan.desktop <<'DESKTOP'
[Desktop Entry]
Type=Application
Name=WeKan
GenericName=Kanban board
Comment=Open Source kanban board with FerretDB and Node.js included
Exec=wekan
Icon=wekan
Terminal=true
Categories=Office;ProjectManagement;
Keywords=kanban;board;tasks;project;
DESKTOP
cp AppDir/usr/share/applications/wekan.desktop AppDir/wekan.desktop
# AppRun is what the AppImage runs. It does three things the launcher
# cannot do for itself: pick a writable place for the data (an AppImage
# is read-only and may sit on a USB stick), say where WeKan is once it
# has started, and then hand over to start-wekan.sh, which is the same
# launcher the offline zip uses - so every variable it documents
# (WRITABLE_PATH, PORT, ROOT_URL, MONGO_URL, WEKAN_DB, ...) still works.
cat > AppDir/AppRun <<'APPRUN'
#!/bin/sh
set -eu
HERE="$(dirname "$(readlink -f "$0")")"
BUNDLE="$HERE/usr/lib/wekan/bundle"
# An AppImage is read-only, so the data cannot live beside it.
: "${WRITABLE_PATH:=${XDG_DATA_HOME:-$HOME/.local/share}/wekan}"
export WRITABLE_PATH
: "${PORT:=8080}"
export PORT
: "${ROOT_URL:=http://localhost:$PORT}"
export ROOT_URL
mkdir -p "$WRITABLE_PATH"
echo "WeKan is starting."
echo " Open: $ROOT_URL"
echo " Data: $WRITABLE_PATH"
echo " Stop: Ctrl-C"
echo
echo "Set WRITABLE_PATH, PORT, ROOT_URL or MONGO_URL to change any of"
echo "this; WEKAN_DB=mongodb uses your own database instead of the"
echo "bundled FerretDB."
echo
exec "$BUNDLE/start-wekan.sh" "$@"
APPRUN
chmod +x AppDir/AppRun
- name: Pack the AppImage
if: ${{ env.BUILD_THIS == 'true' }}
run: |
set -euo pipefail
version='${{ needs.release.outputs.version }}'
arch='${{ matrix.appimage_arch }}'
name="WeKan-${version}-${arch}"
curl -fSL --retry 5 --retry-delay 10 -o appimagetool \
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
chmod +x appimagetool
mkdir -p dist
# A runner has no FUSE, so the tool cannot mount itself; this unpacks
# it to a temporary directory and runs it from there instead.
APPIMAGE_EXTRACT_AND_RUN=1 ARCH="$arch" \
./appimagetool AppDir "dist/${name}.AppImage"
chmod +x "dist/${name}.AppImage"
# Checksum keeps the .AppImage extension in its name, matching the zip
# convention (wekan-<v>-<arch>.zip.sha256sum) and what
# releases/expected-assets.sh looks for. Naming it ${name}.sha256sum
# (extension dropped) meant "what is missing" never found the checksum,
# so every run rebuilt the AppImage even when it was already published.
( cd dist
md5sum "${name}.AppImage" > "${name}.AppImage.md5sum"
sha256sum "${name}.AppImage" > "${name}.AppImage.sha256sum" )
ls -lh dist
- name: Check that it starts
if: ${{ env.BUILD_THIS == 'true' }}
run: |
set -euo pipefail
version='${{ needs.release.outputs.version }}'
app="dist/WeKan-${version}-${{ matrix.appimage_arch }}.AppImage"
# An AppImage that packs is not an AppImage that runs. Start it, wait
# for WeKan to answer on its port, then stop it - so a broken bundle
# is caught here rather than by whoever downloads it.
export WRITABLE_PATH="$PWD/smoke-data" PORT=8080
# A CHECK THAT CANNOT RUN IS NOT A FAILED CHECK. The 32-bit builds are
# packed on a 64-bit runner, which executes them only if the kernel and
# loader for that word size are present - GitHub promises neither. If
# the binary cannot be executed AT ALL, the AppImage is as good as
# anything here knows and the release must not be withheld for it; say
# so and move on. Anything else - it starts and does not answer - is
# the bug this step exists for and still fails.
if ! APPIMAGE_EXTRACT_AND_RUN=1 "$app" --appimage-help >/dev/null 2>&1 \
&& ! APPIMAGE_EXTRACT_AND_RUN=1 "$app" --help >/dev/null 2>&1; then
if ! file "$app" | grep -q "$(uname -m)"; then
echo "::warning::this runner cannot execute a ${{ matrix.appimage_arch }} binary, so the AppImage was packed but not started. It is uploaded unchecked."
exit 0
fi
fi
APPIMAGE_EXTRACT_AND_RUN=1 "$app" > smoke.log 2>&1 &
pid=$!
ok=false
for _ in $(seq 1 90); do
if curl -fsS -o /dev/null "http://localhost:8080/sign-in"; then ok=true; break; fi
kill -0 "$pid" 2>/dev/null || break
sleep 2
done
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
if [ "$ok" != true ]; then
echo "::error::the AppImage did not answer on port 8080; its log follows"
tail -n 100 smoke.log
exit 1
fi
echo "WeKan answered on port 8080."
- uses: actions/upload-artifact@v7
if: ${{ env.BUILD_THIS == 'true' }}
with:
name: appimage-${{ matrix.appimage_arch }}
path: dist/
if-no-files-found: error
publish:
name: attach to the release
needs: [release, build]
if: ${{ always() && inputs.publish != false }}
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v8
with:
pattern: appimage-*
path: assets
merge-multiple: true
- name: Say what was built, and what was not
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
ls -lh assets 2>/dev/null || true
if ls assets/*.AppImage >/dev/null 2>&1; then
# Every architecture the matrix builds. A missing one WARNS rather
# than fails: the 32-bit pair is allowed not to build, and an
# architecture whose base bundle was never published has nothing to
# wrap. What must not happen silently is one going missing unnoticed.
for want in x86_64 aarch64 i686 armhf; do
ls assets/*"-$want.AppImage" >/dev/null 2>&1 || \
echo "::warning::no AppImage for $want - see that build job's log"
done
else
# 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)"
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
done
if [ "$base_present" = false ]; then
echo "::notice::No base bundle (wekan-${{ needs.release.outputs.version }}-{amd64,arm64}.zip) is on ${{ needs.release.outputs.tag }} yet, so there is nothing to wrap - skipping. release-all.yml builds the AppImages once the base bundles are published."
exit 0
fi
echo "::error::A base bundle is published but no architecture produced an AppImage - every build job failed. Their logs say why; nothing is uploaded."
exit 1
fi
- name: Upload them
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if ! ls assets/*.AppImage >/dev/null 2>&1; then
echo "Nothing to upload (no base bundle was published to wrap)."
exit 0
fi
# --repo, because this job does NOT check the repository out - it only
# downloads artifacts - so `gh` has no git remote to infer the repo
# from and fails with "fatal: not a git repository". Every other gh
# 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/* \
--repo "${{ github.repository }}" --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --json url --jq .url)"
+430
View File
@@ -0,0 +1,430 @@
name: Flatpak
# Build WeKan as a Flatpak for every CPU the runtime exists for, attach the
# single-file bundles to the newest WeKan release, and publish a Flatpak
# REPOSITORY that can be added with `flatpak remote-add`.
#
# Like AppImage.yml, this does NOT rebuild WeKan. release-all.yml already
# produces a bundle that runs offline with nothing installed - the Meteor server
# (main.js), the bundled Node.js the release was built against, the
# wekan/FerretDB v1 fork with its embedded pure-Go SQLite backend, the
# wekan/mongo-tools-patches binaries and start-wekan.sh - so this takes the
# wekan-<version>-<arch>.zip off the release and packages it. The Flatpak is
# therefore the same server, Node and FerretDB as the zip and the AppImage
# beside it, not a third build that might differ.
#
# A BUNDLE AND A REPOSITORY are two different things and both come out of one
# build here. A .flatpak bundle is one file you install by hand and update by
# downloading the next one; a repository is what makes `flatpak update` work,
# and one repository serves every architecture at once. The bundles go on the
# GitHub release; the repository is uploaded as an artifact of static files, so
# it can be copied to a website with no server to run.
#
# WHICH CPUs. org.freedesktop.Platform//24.08 is built for x86_64 and aarch64,
# and WeKan publishes Linux bundles for amd64 and arm64, so those are the two.
# Each builds on a runner of its own CPU rather than under emulation.
#
# AND THAT IS THE WHOLE LIST - there is no third to add. A flatpak runs against
# a RUNTIME, not against the host's libraries, so an architecture exists here
# only if freedesktop.org publishes org.freedesktop.Platform for it. It does so
# for x86_64 and aarch64; the i386 and arm runtimes were discontinued. That is
# the difference from AppImage.yml, which was able to grow from two
# architectures to four: an AppImage carries its own runtime binary, and
# runtimes exist for i686 and armhf as well. WeKan's ppc64le, s390x, riscv64,
# loong64 and 32-bit bundles are served by the zip and the .deb.
#
# THE SANDBOX. WeKan is a server you reach in a browser, so it needs the network
# and a place to keep its data, and nothing else - no X11, no audio, no access
# to your files. --share=network and --persist=.wekan are the whole permission
# list, and FerretDB keeps everything in SQLite inside that one directory.
#
# THE REPOSITORY IS UNSIGNED. Signing needs a GPG key, which is a secret rather
# than something a workflow can invent. The README written into the repository
# says exactly how to sign it when a key exists.
on:
# Callable, so release-all-missing.yml can ask for just the architectures
# a release is short of. The build itself stays here - a second copy of the
# flatpaks build would drift from this one.
workflow_call:
inputs:
tag:
required: false
type: string
publish:
required: false
default: true
type: boolean
only:
description: 'Only build these architectures, as a JSON array (e.g. ["x86_64"]); empty = all'
required: false
default: ''
type: string
workflow_dispatch:
inputs:
tag:
description: 'Release tag to wrap and attach to (empty = newest release)'
required: false
type: string
publish:
description: 'Attach the bundles to the release'
required: false
default: true
type: boolean
publish_repo:
description: 'Build the Flatpak repository for the website'
required: false
default: true
type: boolean
only:
description: 'Only build these architectures, as a JSON array (e.g. ["x86_64"]); empty = all'
required: false
default: ''
type: string
permissions:
contents: write
concurrency:
group: wekan-flatpak-${{ inputs.tag || github.ref }}
cancel-in-progress: false
jobs:
release:
name: find the release
runs-on: ubuntu-24.04
outputs:
tag: ${{ steps.find.outputs.tag }}
version: ${{ steps.find.outputs.version }}
steps:
- uses: actions/checkout@v7
- id: find
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
# --repo on every gh call, whether or not the job happens to have
# checked out: one that relies on an inferred remote breaks the day
# somebody removes the checkout.
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
--limit 1 --json tagName --jq '.[0].tagName')"
if [ -z "$tag" ]; then
echo "::error::No release to wrap, and no tag was given."
exit 1
fi
version="${tag#v}"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Wrapping the bundles from $tag (version $version)"
build:
name: flatpak ${{ matrix.flatpak_arch }}
needs: release
runs-on: ${{ matrix.runs_on }}
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- flatpak_arch: x86_64
bundle_arch: amd64
runs_on: ubuntu-24.04
- flatpak_arch: aarch64
bundle_arch: arm64
runs_on: ubuntu-24.04-arm
# WHICH entries this run wants, decided here because it cannot be decided
# where it belongs. A job-level `if:` cannot see `matrix` - GitHub exposes
# that context to runs-on, env, name, steps and a few others, but not to a
# job's own condition - and a workflow that tries answers
# Unrecognized named-value: 'matrix'
# and refuses to load at all, taking every workflow that CALLS it with it.
# A job-level env CAN see matrix, so the answer is computed once here and
# every step below asks for it.
env:
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.flatpak_arch) }}
steps:
- uses: actions/checkout@v7
if: ${{ env.BUILD_THIS == 'true' }}
- name: Install flatpak
if: ${{ env.BUILD_THIS == 'true' }}
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --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 \
org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
- name: Fetch the release bundle for this architecture
if: ${{ env.BUILD_THIS == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
gh release download '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --pattern "$zip" --clobber
unzip -q "$zip"
for f in bundle/main.js bundle/node bundle/ferretdb bundle/start-wekan.sh; do
if [ ! -e "$f" ]; then
echo "::error::$zip has no $f - it is not a runnable WeKan bundle."
exit 1
fi
done
chmod +x bundle/node bundle/ferretdb bundle/start-wekan.sh
du -sh bundle
# flatpak-builder is for building from source. The bundle IS the build, so
# this uses `flatpak build` directly: make a build directory from the
# runtime, copy the bundle into it, finish it with its permissions, and
# export it into a repository.
- name: Build the Flatpak
if: ${{ env.BUILD_THIS == 'true' }}
run: |
set -euo pipefail
version='${{ needs.release.outputs.version }}'
arch='${{ matrix.flatpak_arch }}'
rm -rf build-dir repo
flatpak build-init --arch="$arch" build-dir \
io.github.wekan.WeKan org.freedesktop.Sdk org.freedesktop.Platform 24.08
mkdir -p build-dir/files/lib/wekan build-dir/files/bin \
build-dir/files/share/applications \
build-dir/files/share/icons/hicolor/512x512/apps \
build-dir/files/share/metainfo
cp -a bundle build-dir/files/lib/wekan/bundle
cp public/android-chrome-512x512.png \
build-dir/files/share/icons/hicolor/512x512/apps/io.github.wekan.WeKan.png
# The command the Flatpak runs. --persist=.wekan gives the sandbox one
# writable directory that survives restarts, and that is where
# WRITABLE_PATH points, so FerretDB's SQLite files and the attachments
# live there. Everything start-wekan.sh documents still overrides.
cat > build-dir/files/bin/wekan <<'SH'
#!/bin/sh
set -eu
BUNDLE=/app/lib/wekan/bundle
: "${WRITABLE_PATH:=$HOME/.wekan}"
export WRITABLE_PATH
: "${PORT:=8080}"
export PORT
: "${ROOT_URL:=http://localhost:$PORT}"
export ROOT_URL
mkdir -p "$WRITABLE_PATH"
echo "WeKan is starting."
echo " Open: $ROOT_URL"
echo " Data: $WRITABLE_PATH (inside the Flatpak sandbox)"
echo " Stop: Ctrl-C"
echo
exec "$BUNDLE/start-wekan.sh" "$@"
SH
chmod +x build-dir/files/bin/wekan
cat > build-dir/files/share/applications/io.github.wekan.WeKan.desktop <<'DESKTOP'
[Desktop Entry]
Type=Application
Name=WeKan
GenericName=Kanban board
Comment=Open Source kanban board with FerretDB and Node.js included
Exec=wekan
Icon=io.github.wekan.WeKan
Terminal=true
Categories=Office;ProjectManagement;
Keywords=kanban;board;tasks;project;
DESKTOP
cat > build-dir/files/share/metainfo/io.github.wekan.WeKan.metainfo.xml <<XML
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.github.wekan.WeKan</id>
<name>WeKan</name>
<summary>Open Source kanban board</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<description>
<p>
WeKan is an Open Source kanban board. This build carries everything
it needs to run: the WeKan server, Node.js, and FerretDB v1 with an
embedded SQLite backend, so no separate database is required.
</p>
</description>
<launchable type="desktop-id">io.github.wekan.WeKan.desktop</launchable>
<url type="homepage">https://wekan.fi</url>
<releases><release version="${version}"/></releases>
</component>
XML
# A server reached in a browser needs the network and its own data
# directory, and nothing else.
flatpak build-finish build-dir \
--share=network \
--persist=.wekan \
--command=wekan
flatpak build-export --arch="$arch" repo build-dir stable
- name: Make the single-file bundle
if: ${{ env.BUILD_THIS == 'true' }}
run: |
set -euo pipefail
version='${{ needs.release.outputs.version }}'
name="WeKan-${version}-${{ matrix.flatpak_arch }}"
mkdir -p dist
flatpak build-bundle repo "dist/${name}.flatpak" \
--arch='${{ matrix.flatpak_arch }}' io.github.wekan.WeKan stable
# Checksum keeps the .flatpak extension in its name, matching the zip
# convention and what releases/expected-assets.sh looks for; dropping it
# meant "what is missing" never found the checksum and rebuilt the
# Flatpak every run even when already published.
( cd dist
md5sum "${name}.flatpak" > "${name}.flatpak.md5sum"
sha256sum "${name}.flatpak" > "${name}.flatpak.sha256sum" )
ls -lh dist
- uses: actions/upload-artifact@v7
if: ${{ env.BUILD_THIS == 'true' }}
with:
name: flatpak-${{ matrix.flatpak_arch }}
path: dist/
if-no-files-found: error
- uses: actions/upload-artifact@v7
if: ${{ env.BUILD_THIS == 'true' }}
with:
name: flatpak-repo-${{ matrix.flatpak_arch }}
path: repo/
if-no-files-found: warn
publish:
name: attach to the release, and build the repository
needs: [release, build]
if: ${{ always() }}
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v8
with:
pattern: flatpak-[0-9a-z]*
path: assets
merge-multiple: true
- name: Say what was built, and what was not
run: |
set -euo pipefail
ls -lh assets 2>/dev/null || true
if ! ls assets/*.flatpak >/dev/null 2>&1; then
echo "::error::No architecture produced a Flatpak bundle; nothing is uploaded."
exit 1
fi
for want in x86_64 aarch64; do
ls assets/*"-$want.flatpak" >/dev/null 2>&1 || \
echo "::warning::no Flatpak for $want - see that build job's log"
done
- name: Upload the bundles
if: ${{ inputs.publish != false }}
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# --repo, because this job does NOT check the repository out - it
# only downloads artifacts - so `gh` has no git remote to infer the
# repo from and fails with "fatal: not a git repository". The same
# line was wrong in AppImage.yml for the same reason.
#
# And the bundles BY NAME, not assets/*: the artifacts also carry the
# ostree repository the flatpak was exported through - config,
# 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 }}' \
assets/*.flatpak assets/*.flatpak.md5sum assets/*.flatpak.sha256sum \
--repo "${{ github.repository }}" --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --json url --jq .url)"
# ---- the repository -------------------------------------------------
- uses: actions/download-artifact@v8
if: ${{ inputs.publish_repo != false }}
with:
pattern: flatpak-repo-*
path: repos
- name: Build the multi-architecture repository
if: ${{ inputs.publish_repo != false }}
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
shopt -s nullglob
parts=(repos/flatpak-repo-*)
if [ ${#parts[@]} -eq 0 ]; then
echo "::warning::no repository halves were produced; skipping"
exit 0
fi
ostree init --repo=flatpak-repo --mode=archive-z2
for part in "${parts[@]}"; do
name="$(basename "$part")"
echo "Pulling $name"
ostree --repo=flatpak-repo remote add --no-gpg-verify --if-not-exists \
"$name" "file://$PWD/$part"
ostree --repo=flatpak-repo pull --mirror "$name" || true
done
flatpak build-update-repo --prune flatpak-repo
cat > flatpak-repo/wekan.flatpakrepo <<'REPO'
[Flatpak Repo]
Title=WeKan
Url=https://wekan.fi/flatpak/
Homepage=https://wekan.fi
Comment=WeKan releases, built by the Flatpak workflow in wekan/wekan
Description=Flatpak repository for WeKan, the Open Source kanban board
REPO
cat > flatpak-repo/README.txt <<'TXT'
Flatpak repository for WeKan.
Copy this whole directory to the website under /flatpak/, then:
flatpak remote-add --if-not-exists wekan \
https://wekan.fi/flatpak/wekan.flatpakrepo
flatpak install wekan io.github.wekan.WeKan
flatpak run io.github.wekan.WeKan
Then open http://localhost:8080 . Everything is inside: the WeKan
server, Node.js, and FerretDB v1 with an embedded SQLite backend, so
there is no database to install. The data lives in the sandbox's
persistent .wekan directory.
THIS REPOSITORY IS NOT SIGNED. Signing needs a GPG key, which is a
secret rather than something a workflow can invent. To sign it:
1. Make a key, once, on a machine you trust:
gpg --quick-generate-key "WeKan <security@wekan.fi>" default default never
gpg --export KEYID > wekan-flatpak.gpg # the public half
gpg --export-secret-keys --armor KEYID # the private half
2. Put the private half in the repository's Actions secrets as
GPG_PRIVATE_KEY.
3. In Flatpak.yml, import it before the export and add the two flags:
flatpak build-export --gpg-sign=KEYID ...
flatpak build-update-repo --gpg-sign=KEYID ...
4. Put wekan-flatpak.gpg beside this file on the website and add
GPGKey=<base64 of wekan-flatpak.gpg>
to wekan.flatpakrepo.
Until then flatpak will say the remote is unsigned, and installing
from it trusts whoever serves the files.
TXT
du -sh flatpak-repo
- uses: actions/upload-artifact@v7
if: ${{ inputs.publish_repo != false }}
with:
name: flatpak-repository
path: flatpak-repo/
if-no-files-found: warn
+31 -1
View File
@@ -34,11 +34,36 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v7
# The Dockerfile DOWNLOADS the prebuilt release zip that release-all.yml
# publishes (and `exit 8`s if it is missing), so this image can only be
# built once the release for the version pinned in the Dockerfile is up. On
# a tag push (the tag is created early in the release, ~30 min before the
# zips are attached), during the release window, or if the release failed,
# the asset 404s and this job failed hard. Check first and SKIP the build
# (a notice, green run) when the release is not published yet; the release
# image itself is built + pushed by release-all.yml's docker job.
- name: Is the release this Dockerfile needs published yet?
id: rel
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
VERSION="$(sed -n 's/^ARG VERSION=//p' Dockerfile | head -1)"
asset="wekan-${VERSION}-amd64.zip"
if gh release view "v${VERSION}" --repo "${{ github.repository }}" \
--json assets --jq '.assets[].name' 2>/dev/null | grep -qx "$asset"; then
echo "build=yes" >> "$GITHUB_OUTPUT"
echo "OK: $asset is on the v${VERSION} release; building the image."
else
echo "build=no" >> "$GITHUB_OUTPUT"
echo "::notice::$asset is not on the v${VERSION} release yet - skipping. release-all.yml builds and pushes the image once the release is published."
fi
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -55,9 +80,14 @@ jobs:
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
if: steps.rel.outputs.build == 'yes'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Full build provenance (SLSA), not the minimal default that BuildKit
# attaches on its own. See release-all.yml for what it is and why the
# manifest list gains an `unknown/unknown` entry per platform.
provenance: mode=max
+154
View File
@@ -0,0 +1,154 @@
# ─────────────────────────────────────────────────────────────────────────────
# Publish a variant Docker image — MANUALLY, never as part of a release.
#
# wekanteam/wekan-gantt-gpl (and wekanteam/wekan-ondra) are the same WeKan as
# wekanteam/wekan: the variant repositories are byte-identical to wekan/wekan
# apart from the snap name in snapcraft.yaml, so the image content is the same
# image. This workflow therefore does not rebuild anything - it points a variant
# tag at the multi-arch manifest the release already built and verified, with
# `docker buildx imagetools create`. Same digests, all five architectures, no
# 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.
#
# If a variant repository ever stops being identical to wekan/wekan, this
# retag becomes wrong: build that repository's own Dockerfile instead.
#
# Local equivalent, same behaviour: releases/docker-publish-variant.sh
# ─────────────────────────────────────────────────────────────────────────────
name: Publish variant Docker image (manual)
on:
workflow_dispatch:
inputs:
image:
description: 'Docker Hub repository under wekanteam/ to publish'
required: true
default: 'wekan-gantt-gpl'
type: choice
options:
- wekan-gantt-gpl
- wekan-ondra
version:
description: 'WeKan version to publish, without the v (e.g. 10.49). Empty = newest GitHub release.'
required: false
default: ''
type: string
also_latest:
description: 'Also move the :latest tag of the variant image'
required: false
default: true
type: boolean
permissions:
contents: read
jobs:
retag:
runs-on: ubuntu-latest
steps:
# First step on purpose: a missing or wrong credential is then ONE named
# line at the top of the log, not a failure deep inside a push.
- name: Check DOCKERHUB_AUTH is configured and works
env:
DOCKERHUB_AUTH: ${{ secrets.DOCKERHUB_AUTH }}
run: |
set -uo pipefail
if [ -z "${DOCKERHUB_AUTH:-}" ]; then
echo "::error::docker-variant: DOCKERHUB_AUTH is not set - the image cannot be pushed. It is base64 of 'username:token' for docker.io."
exit 1
fi
if ! creds="$(printf '%s' "$DOCKERHUB_AUTH" | base64 -d 2>/dev/null)"; then
echo "::error::docker-variant: DOCKERHUB_AUTH is not valid base64. Set it with: printf '%s' 'user:token' | base64 -w0"
exit 1
fi
case "$creds" in
*:*) ;;
*) echo "::error::docker-variant: DOCKERHUB_AUTH decodes to something without a ':' - it must be base64 of 'username:token'."; exit 1 ;;
esac
user="${creds%%:*}"
token="${creds#*:}"
if ! printf '%s' "$token" | docker login docker.io -u "$user" --password-stdin; then
echo "::error::docker-variant: DOCKERHUB_AUTH was REJECTED by docker.io (login failed as user '$user'). The token is wrong, expired, or lacks push rights for wekanteam/${{ inputs.image }}."
exit 1
fi
echo "docker-variant: DOCKERHUB_AUTH works for docker.io (user '$user')."
- name: Work out which version to publish
id: v
run: |
set -euo pipefail
version="${{ inputs.version }}"
if [ -z "$version" ]; then
# The newest release of wekan/wekan, asked of GitHub rather than guessed.
version="$(curl -sSL -H 'Accept: application/vnd.github+json' \
https://api.github.com/repos/wekan/wekan/releases/latest \
| sed -n 's/.*"tag_name": *"v\{0,1\}\([^"]*\)".*/\1/p' | head -1)"
fi
version="${version#v}"
if [ -z "$version" ]; then
echo "::error::docker-variant: no version given and the newest release could not be read from the GitHub API."
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Publishing wekanteam/${{ inputs.image }} from wekanteam/wekan:v$version"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Retag wekanteam/wekan as wekanteam/${{ inputs.image }}
run: |
set -euo pipefail
src="wekanteam/wekan:v${{ steps.v.outputs.version }}"
dst="wekanteam/${{ inputs.image }}:v${{ steps.v.outputs.version }}"
# The source must exist and be the full multi-arch manifest, or the
# variant tag would quietly cover fewer architectures than wekan does.
if ! platforms="$(docker buildx imagetools inspect "$src" \
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}} {{end}}' 2>&1)"; then
echo "::error::docker-variant: $src cannot be inspected - is that version released and pushed? ($platforms)"
exit 1
fi
echo "$src covers: $platforms"
for want in linux/amd64 linux/arm64 linux/ppc64le linux/s390x linux/riscv64; do
case " $platforms " in
*" $want "*) ;;
*) echo "::error::docker-variant: $src has no $want, so the variant image would be missing it too."; exit 1 ;;
esac
done
tags=(-t "$dst")
if [ "${{ inputs.also_latest }}" = "true" ]; then
tags+=(-t "wekanteam/${{ inputs.image }}:latest")
fi
# imagetools create copies the MANIFEST, so the variant tag points at the
# very digests the release built and verified. Nothing is rebuilt.
docker buildx imagetools create "${tags[@]}" "$src"
# Pushed is not the same as pushed correctly: ask Docker Hub what it has.
got="$(docker buildx imagetools inspect "$dst" \
--format '{{range .Manifest.Manifests}}{{.Platform.OS}}/{{.Platform.Architecture}} {{end}}')"
echo "$dst covers: $got"
for want in linux/amd64 linux/arm64 linux/ppc64le linux/s390x linux/riscv64; do
case " $got " in
*" $want "*) ;;
*) echo "::error::docker-variant: $dst is missing $want after the push."; exit 1 ;;
esac
done
echo "OK: $dst published (same digests as $src)."
- 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 docker-variant wekanteam/${{ inputs.image }}"
echo "$mark docker-variant wekanteam/${{ inputs.image }}" >> "$GITHUB_STEP_SUMMARY"
+28
View File
@@ -16,5 +16,33 @@ jobs:
steps:
- uses: actions/checkout@v7
# The Dockerfile does not build WeKan from source — it DOWNLOADS the prebuilt
# release zip (wekan-<VERSION>-amd64.zip) that release-all.yml publishes, and
# `exit 8`s if it is missing. So this image can only be built once the release
# for the version pinned in the Dockerfile is up. On a push during the release
# window (the version was just bumped but the release job has not attached the
# zip yet), or if the release failed, the asset 404s and this job failed hard
# even though nothing here is wrong. Check first and SKIP (a notice, green run)
# when the release is not published yet; the real image is built by
# release-all.yml's docker job once the release is up.
- name: Is the release this Dockerfile needs published yet?
id: rel
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
VERSION="$(sed -n 's/^ARG VERSION=//p' Dockerfile | head -1)"
asset="wekan-${VERSION}-amd64.zip"
if gh release view "v${VERSION}" --repo "${{ github.repository }}" \
--json assets --jq '.assets[].name' 2>/dev/null | grep -qx "$asset"; then
echo "build=yes" >> "$GITHUB_OUTPUT"
echo "OK: $asset is on the v${VERSION} release; building the image."
else
echo "build=no" >> "$GITHUB_OUTPUT"
echo "::notice::$asset is not on the v${VERSION} release yet - skipping. release-all.yml builds the image once the release is published."
fi
- name: Build the Docker image
if: steps.rel.outputs.build == 'yes'
run: docker build . --file Dockerfile --tag wekan:$(date +%s)
+318
View File
@@ -0,0 +1,318 @@
name: Release all missing
# Build only the bundles a release does not already carry.
#
# WHY: release-all.yml is one run that bumps versions, tags, builds every
# platform, publishes Docker images, snaps, the website and the charts. It is
# the right thing for MAKING a release and the wrong thing for FINISHING one.
# When the v10.57 run ended with the six extra architectures missing - four of
# them on a shell-quoting bug and two on a base image - there was no way to get
# 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.
#
# 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
# downloads the PUBLISHED wekan-<v>-amd64.zip and repacks it, so it can rebuild:
#
# the six extra architectures s390x ppc64le riscv64 i386 armhf loong64
# the AppImages and Flatpaks by calling their own workflows
#
# It cannot rebuild amd64 itself (that is the Meteor build), nor arm64, win64
# and mac-arm64 (each needs its own kind of runner and its own steps), nor the
# Sandstorm .spk. When one of those is missing it says so and names
# release-all.yml, rather than pretending.
#
# The repack is releases/repack-bundle-for-arch.sh - the SAME script
# release-all.yml's build-extra-arches runs, so a bundle added to a release
# months later is built exactly like the ones already on it.
#
# What a complete release looks like is releases/expected-assets.sh, in one
# place. An asset counts as present only when its .sha256sum is there too; the
# Sandstorm .spk is signed rather than checksummed and is marked nosums.
#
# Usage:
# gh workflow run release-all-missing.yml -f version=10.58
on:
workflow_dispatch:
inputs:
version:
description: 'Release to complete, without the v (e.g. 10.58). Empty = the newest release.'
required: false
type: string
permissions:
contents: write
env:
# Must match release-all.yml: the Node.js major the bundles carry.
NODE_VERSION: '24'
# Never at the same time as a full release run, which would be uploading to the
# same assets.
concurrency:
group: wekan-release-${{ inputs.version || github.ref }}
cancel-in-progress: false
jobs:
plan:
name: what the release is missing
runs-on: ubuntu-latest
outputs:
version: ${{ steps.plan.outputs.version }}
extra: ${{ steps.plan.outputs.extra }}
appimage: ${{ steps.plan.outputs.appimage }}
flatpak: ${{ steps.plan.outputs.flatpak }}
manual: ${{ steps.plan.outputs.manual }}
total: ${{ steps.plan.outputs.total }}
steps:
- uses: actions/checkout@v7
- id: plan
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
V='${{ inputs.version }}'
V="${V#v}"
if [ -z "$V" ]; then
V="$(gh release view --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
else
echo "::error::There is no release v$V to complete. This workflow FINISHES a release; release-all.yml makes one."
exit 1
fi
echo "v$V carries $(wc -l < existing.txt | tr -d ' ') asset(s)."
bash releases/expected-assets.sh "$V" > expected.txt
: > missing.txt
present=0
while read -r kind selector asset sums; do
need="$asset"
[ "$sums" = "sums" ] && need="$need ${asset}.sha256sum"
have=yes
for n in $need; do grep -qxF "$n" existing.txt || have=no; done
if [ "$have" = yes ]; then present=$((present + 1)); else echo "$kind $selector" >> missing.txt; fi
done < expected.txt
total="$(wc -l < missing.txt | tr -d ' ')"
echo "total=$total" >> "$GITHUB_OUTPUT"
for kind in extra appimage flatpak; do
sels="$(awk -v k="$kind" '$1 == k { print $2 }' missing.txt)"
json="$(printf '%s\n' $sels | sed '/^$/d' | jq -R . | jq -sc .)"
[ -n "$json" ] || json='[]'
echo "$kind=$json" >> "$GITHUB_OUTPUT"
echo " $kind: $json"
done
# The ones this workflow cannot make. Named, not silently skipped.
manual="$(awk '$1 == "meteor" || $1 == "repack" || $1 == "sandstorm" { printf "%s/%s ", $1, $2 }' missing.txt)"
echo "manual=${manual% }" >> "$GITHUB_OUTPUT"
{
echo "### v$V"
echo ""
echo "$present of $((present + total)) assets already published."
echo ""
if [ "$total" -eq 0 ]; then
echo "Nothing is missing - the release is complete."
else
echo "Missing:"
echo ""
echo '```'
cat missing.txt
echo '```'
if [ -n "$manual" ]; then
echo ""
echo "Not buildable here (run release-all.yml): \`$manual\`"
fi
fi
} >> "$GITHUB_STEP_SUMMARY"
echo "Missing $total. Needs release-all.yml: ${manual:-none}"
- name: Say plainly what this run will not do
if: ${{ steps.plan.outputs.manual != '' }}
run: |
echo "::warning::v${{ steps.plan.outputs.version }} is missing ${{ steps.plan.outputs.manual }}, which this workflow cannot build: amd64 is the Meteor build, arm64/win64/mac-arm64 each need their own runner, and the Sandstorm .spk is signed. Run release-all.yml for those."
# The six architectures nobody else builds for, repacked from the PUBLISHED
# amd64 bundle - which is what makes this possible without a Meteor build.
extra-arches:
name: ${{ matrix.arch }}
needs: plan
if: ${{ needs.plan.outputs.extra != '[]' }}
runs-on: ubuntu-24.04
timeout-minutes: 300
strategy:
fail-fast: false
matrix:
include:
# The same table as release-all.yml's build-extra-arches, and it has
# to stay the same: `node_arch` is what the Node.js download is
# called (Node says x86 where we say i386, armv7l where we say
# armhf), `ferretdb_arch` is the asset name in the wekan/FerretDB
# release, and `image` must be a base image that PUBLISHES this
# platform - ubuntu:26.04 does not build 386, debian:trixie does.
- { arch: s390x, platform: linux/s390x, image: debian:trixie, node_arch: s390x, ferretdb_arch: s390x }
- { arch: ppc64le, platform: linux/ppc64le, image: debian:trixie, node_arch: ppc64le, ferretdb_arch: ppc64le }
- { arch: riscv64, platform: linux/riscv64, image: debian:trixie, node_arch: riscv64, ferretdb_arch: riscv64 }
- { arch: i386, platform: linux/386, image: debian:trixie, node_arch: x86, ferretdb_arch: i386 }
- { arch: armhf, platform: linux/arm/v7, image: debian:trixie, node_arch: armv7l, ferretdb_arch: armhf }
# loong64 has NO base image on Docker Hub at any tag, so there is no
# loong64 userland to rebuild the native modules in. Its Node.js and
# its FerretDB both exist; the container does not. The preflight
# inside the script stops it saying exactly that, and it stays in the
# matrix so it is visible rather than quietly absent.
- { arch: loong64, platform: linux/loong64, image: debian:trixie, node_arch: loong64, ferretdb_arch: loong64 }
env:
VERSION: ${{ needs.plan.outputs.version }}
steps:
- name: Skip the architectures this release already has
id: want
run: |
if printf '%s' '${{ needs.plan.outputs.extra }}' | jq -e --arg a '${{ matrix.arch }}' 'index($a)' >/dev/null; then
echo "build=yes" >> "$GITHUB_OUTPUT"
else
echo "build=no" >> "$GITHUB_OUTPUT"
echo "v${VERSION} already has ${{ matrix.arch }}; nothing to do."
fi
- name: Install dependencies
if: ${{ steps.want.outputs.build == 'yes' }}
run: |
sudo apt-get update
sudo apt-get install -y 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' }}
# THE DIFFERENCE from release-all.yml, and the whole point of this
# workflow: the amd64 bundle comes from the RELEASE, not from an artifact
# of a build that is not running. Its checksum is verified when the
# release has one - a bundle every other architecture is cut from is worth
# checking before it is cut.
- name: Download the published amd64 bundle
if: ${{ steps.want.outputs.build == 'yes' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
zip="wekan-${VERSION}-amd64.zip"
gh release download "v${VERSION}" --pattern "$zip" --clobber
if gh release download "v${VERSION}" --pattern "${zip}.sha256sum" --clobber 2>/dev/null; then
sha256sum -c "${zip}.sha256sum"
echo "amd64 bundle checksum verified."
else
echo "::warning::v${VERSION} publishes no ${zip}.sha256sum, so the bundle every other architecture is cut from could not be verified."
fi
unzip -q "$zip"
[ -d bundle ] || { echo "::error::$zip did not contain a bundle/ directory."; exit 1; }
rm -f "$zip" "${zip}.sha256sum"
- name: Repack it for ${{ matrix.arch }}
if: ${{ steps.want.outputs.build == 'yes' }}
env:
ARCH: ${{ matrix.arch }}
NODE_ARCH: ${{ matrix.node_arch }}
FERRETDB_ARCH: ${{ matrix.ferretdb_arch }}
IMAGE: ${{ matrix.image }}
PLATFORM: ${{ matrix.platform }}
run: bash releases/repack-bundle-for-arch.sh
- name: Attach ${{ matrix.arch }} to the release
if: ${{ steps.want.outputs.build == 'yes' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
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"
# 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
grep -qxF "$asset" release-assets.txt || {
echo "::error::$asset is not on release v${VERSION} although the upload reported success."
exit 1
}
echo "OK: $asset attached ($(($(wc -c < "$asset")/1024/1024)) MiB)."
appimage:
name: AppImages
needs: plan
if: ${{ needs.plan.outputs.appimage != '[]' }}
uses: ./.github/workflows/AppImage.yml
permissions:
contents: write
with:
tag: v${{ needs.plan.outputs.version }}
publish: true
only: ${{ needs.plan.outputs.appimage }}
flatpak:
name: Flatpaks
needs: plan
if: ${{ needs.plan.outputs.flatpak != '[]' }}
uses: ./.github/workflows/Flatpak.yml
permissions:
contents: write
with:
tag: v${{ needs.plan.outputs.version }}
publish: true
only: ${{ needs.plan.outputs.flatpak }}
done:
name: what is still missing
needs: [plan, extra-arches, appimage, flatpak]
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
V: ${{ needs.plan.outputs.version }}
BEFORE: ${{ needs.plan.outputs.total }}
run: |
set -euo pipefail
if [ "$BEFORE" = "0" ]; then
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
bash releases/expected-assets.sh "$V" > expected.txt
still=""
while read -r kind selector asset sums; do
need="$asset"
[ "$sums" = "sums" ] && need="$need ${asset}.sha256sum"
for n in $need; do
grep -qxF "$n" now.txt || { still="$still $kind/$selector"; break; }
done
done < expected.txt
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ -z "$still" ]; then
echo "v$V is complete."
echo "**v$V is complete.**" >> "$GITHUB_STEP_SUMMARY"
else
# A warning, not a failure: what is left may be the parts this
# workflow never claimed to build.
echo "::warning::v$V is still missing:${still}"
echo "Still missing:\`${still# }\`" >> "$GITHUB_STEP_SUMMARY"
fi
File diff suppressed because it is too large. Load diff
+40 -14
View File
@@ -1,6 +1,10 @@
*~
/*.log
# vim swap files (.swp, .swo, .swn) - but NOT .swcrc, which is the SWC transpiler
# configuration and has to be committed. Without this exception `git add .swcrc`
# is silently a no-op and the fix for #6557 never leaves the working tree.
*.sw*
!.swcrc
.meteor-spk
*.sublime-workspace
tmp/
@@ -63,7 +67,14 @@ test-results/
# Claude AI
.claude/
# Tools like mongosh etc so that they work inside Flatpak sandbox
# Companion repositories and tools, cloned INSIDE this checkout so they work in a
# Flatpak sandbox that shares only this directory. One ignored directory for all
# of them - it replaced an entry per clone (/FerretDB/, /node/, /mongo-tools/,
# /mongosh/, /sandstorm/, /projects/, and the WeKan variants), which had to be
# extended by hand for every new one. build.sh's ensure_tool_repo clones what a
# test run needs into here: .tools/FerretDB, .tools/node-patches,
# .tools/mongo-tools-patches. Nothing in here is WeKan's source, and Meteor is
# told the same by /.tools/ in .meteorignore.
.tools/
# Meteor custom local directory (METEOR_LOCAL_DIR)
@@ -71,21 +82,36 @@ _build-local-test
*/build-assets-local-test
*/build-chunks-local-test
# Local-only working copies: other wekan repos cloned under this repo for editing,
# and build inputs. These are separate git repos / large machine-specific dirs;
# never commit them.
# - projects/ : unarchived meteor-spk fork (Node/Mongo/FerretDB binaries)
# - sandstorm/ : clone of sandstorm-io/sandstorm (platform source, for reference)
# - FerretDB/ : clone of wekan/FerretDB
# - mongo-tools/ : clone of wekan/mongo-tools
# - mongosh/ : clone of wekan/mongosh
/projects/
/sandstorm/
/FerretDB/
/mongo-tools/
/mongosh/
# Test and build logs, when they land INSIDE the repo. build.sh writes to
# ../log/<datetime>/ by preference - one level up, so a test run does not show
# up in git status - but the parent of the repo is not writable in a Flatpak
# sandbox, which shares only the repository. There it falls back to ./log/,
# which must not be committed.
/log/
# V8 heap snapshots from WEKAN_BUILD_HEAP_SNAPSHOT=1. Named
# Heap.<date>.<pid>.<n>.heapsnapshot by Node, written into the repo root
# because that is the build's working directory, and as large as the heap that
# produced them - gigabytes.
Heap.*.heapsnapshot
# Linux core dumps (core.<pid>), e.g. from a crashing Playwright/WebKit browser
# process. Written into the crashing process CWD (tests/playwright/) by
# kernel.core_pattern; never source files.
core.[0-9]*
# The two variant repositories (wekan/wekan-ondra, wekan/wekan-gantt-gpl) when
# they are cloned INSIDE this working copy to be synced by hand. They are whole
# git repositories of their own; git would not commit them into this one, but
# they should not show up as untracked noise either.
/wekan-ondra/
/wekan-gantt-gpl/
# node-patches: a SEPARATE repo (github.com/wekan/node-patches) checked out here.
node-patches/
# mongo-tools-patches: a SEPARATE repo (github.com/wekan/mongo-tools-patches)
# checked out here. Same story as node-patches: the PATCHES to the upstream
# MongoDB Database Tools that WeKan's bundled bsondump/mongodump/... are built
# from, replacing the wekan/mongo-tools source fork.
mongo-tools-patches/
+7 -4
View File
@@ -6,9 +6,12 @@
meteor-base@1.5.2
# Build system
ecmascript@0.18.1
ecmascript@0.19.0
standard-minifier-js@3.2.0
rspack@1.1.0
rspack@1.2.0
# Declared explicitly because server/modernBrowsers.js imports it, to say that
# Yandex Browser is modern and must not be served the legacy bundle (#6557).
modern-browsers@0.2.3
blaze@3.0.2
@@ -16,10 +19,10 @@ blaze@3.0.2
aldeed:collection2
aldeed:schema-index@4.0.0-beta.5
reywood:publish-composite
mongo@2.4.0
mongo@2.5.0
# Account system
accounts-password@3.3.0
accounts-password@3.3.1
accounts-express@1.0.0
communitypackages:core
communitypackages:unstyled
+1 -1
View File
@@ -1 +1 @@
METEOR@3.5
METEOR@3.5.1
+15 -15
View File
@@ -1,14 +1,14 @@
accounts-base@3.3.0
accounts-express@1.0.0
accounts-oauth@1.4.7
accounts-password@3.3.0
aldeed:collection2@4.2.0
accounts-password@3.3.1
aldeed:collection2@4.2.1
aldeed:schema-index@4.0.0
aldeed:simple-schema@1.13.1
allow-deny@2.1.0
audit-argument-checks@1.0.8
autoupdate@2.0.1
babel-compiler@7.14.1
babel-compiler@7.15.0
babel-runtime@1.5.2
base64@1.0.14
binary-heap@1.0.13
@@ -24,13 +24,13 @@ communitypackages:flow-routing@2.1.0
communitypackages:unstyled@2.0.1
core-runtime@1.0.0
ddp@1.4.2
ddp-client@3.3.0
ddp-client@3.4.0
ddp-common@1.4.5
ddp-rate-limiter@1.3.0
ddp-server@3.3.0
ddp-server@3.4.0
diff-sequence@1.1.3
dynamic-import@0.7.4
ecmascript@0.18.1
ecmascript@0.19.0
ecmascript-runtime@0.8.3
ecmascript-runtime-client@0.13.0
ecmascript-runtime-server@0.11.1
@@ -44,7 +44,7 @@ hot-code-push@1.0.5
html-tools@2.0.1
htmljs@2.0.2
id-map@1.2.0
inter-process-messaging@0.1.2
inter-process-messaging@0.1.3
jquery@3.0.2
lai:collection-extensions@1.0.0
localstorage@1.2.1
@@ -56,16 +56,16 @@ meteortesting:browser-tests@1.8.0
meteortesting:mocha@3.3.0
meteortesting:mocha-core@8.2.0
minifier-css@2.0.2
minifier-js@3.2.1
minifier-js@3.3.0
minimongo@2.2.0
modern-browsers@0.2.3
modules@0.20.3
modules-runtime@0.13.2
mongo@2.4.0
mongo@2.5.0
mongo-decimal@0.2.0
mongo-dev-server@1.1.1
mongo-id@1.0.9
npm-mongo@6.16.1
npm-mongo@6.16.2
oauth@3.0.4
oauth2@1.3.3
observe-sequence@2.0.1
@@ -87,12 +87,12 @@ reload@1.3.2
retry@1.1.1
reywood:publish-composite@1.9.0
routepolicy@1.1.2
rspack@1.1.0
rspack@1.2.0
service-configuration@1.3.5
session@1.2.3
sha@1.0.10
shell-server@0.7.0
socket-stream-client@0.7.0
socket-stream-client@0.7.1
spacebars@2.0.1
spacebars-compiler@2.0.1
standard-minifier-js@3.2.0
@@ -100,11 +100,11 @@ templating@1.4.5
templating-compiler@2.0.1
templating-runtime@2.0.2
templating-tools@2.0.1
tools-core@1.1.0
tools-core@1.2.0
tracker@1.3.4
typescript@5.10.1
typescript@5.11.0
url@1.3.6
webapp@2.2.0
webapp@2.3.0
webapp-hashing@1.1.2
wekan-accounts-cas@0.2.0
wekan-accounts-lockout@1.1.0
+21 -6
View File
@@ -19,9 +19,24 @@ npm-packages/
# ENOSPC from inotify_add_watch means "watch limit reached", NOT a full disk.
/.tools/
# The FerretDB v1 fork checkout: a complete Go project + its docs, 9,148 more
# directories. Nothing in WeKan imports from it - the FerretDB binary is
# downloaded from its GitHub releases at build time - so Meteor has no reason to
# watch or scan it. Both are already in .gitignore, but the watcher only honours
# .meteorignore.
/FerretDB/
# DO NOT ADD /_build/ OR /_build-local-test/ HERE. They look like build output
# that should be ignored - they are gitignored, and they are the first two
# entries of the 296 KB pattern list above - and excluding them breaks the
# build outright:
#
# error: Could not find mainModule for 'os' architecture:
# _build/main-prod/server-meteor.js
# Check the "meteor" section of your package.json file?
#
# They are not leftovers, they are the HANDOFF. rspack compiles the app into
# _build/main-prod/ (and _build/test/ for a test run), and Meteor then reads
# _build/main-prod/server-meteor.js and client-meteor.js as the application's
# main modules - package.json names client/main.js and server/main.js, and the
# rspack integration repoints mainModule at its own output. Ignoring them hides
# the files Meteor is about to be handed. They cost 3 directories each, so there
# was nothing to win here anyway.
# Local build and test logs. WEKAN_LOG_ROOT prefers ../log/<datetime>/, but
# falls back to ./log when the parent is not writable - which is exactly the
# case inside the Flatpak sandbox, so this directory does appear here.
/log/
+5
View File
@@ -0,0 +1,5 @@
{
"jsc": {
"externalHelpers": false
}
}
+453
View File
@@ -0,0 +1,453 @@
# AGENTS.md — instructions Codex reads first
Codex reads this file at the repo root before doing work here. Follow it.
## First: are you the maintainer or a contributor?
Check the current git identity before committing or releasing:
```
git config user.name && git config user.email
```
- **Maintainer mode** — ONLY when the identity is exactly
`Lauri Ojansivu <x@xet7.org>` (name `Lauri Ojansivu`, email `x@xet7.org`). Then, and
only then: commit **directly to the current branch** as `Lauri Ojansivu <x@xet7.org>`
with no AI trailer and no pull request, and the **publishing / release steps** below
are available.
- **Contributor mode** — any other git identity. Then: do **not** commit directly to
the branch and do **not** run any release/publishing step. Make changes on a branch
and open a **pull request** for the maintainer to review. The "commit as Lauri
Ojansivu", "commit directly", and all release instructions below are **maintainer-only
and do not apply to you**.
Everything below marked as maintainer-specific (committing directly, the exact commit
author, and the entire "Making a release" / publishing flow) applies only in maintainer
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
good practice for everyone.
## Process: "Fix open issues"
When asked to fix an open issue (one issue at a time):
1. **Read the whole issue** — the description AND all comments (e.g.
`gh issue view <n> --comments`, or the GitHub API `.../issues/<n>/comments`).
2. **If it is a bug, first check whether it is already fixed** in the current WeKan
AND FerretDB source code (read the actual code, not just changelogs). It may have
been fixed already.
3. **If it is NOT fixed yet, fix it**, using the newest documentation and issues of
the dependencies involved, checking where the newest MAINTAINED dependency lives and
reading its source and issues:
- Meteor 3 docs — https://docs.meteor.com/
- Meteor 3 source + issues — https://github.com/meteor/meteor/issues
- other Meteor software docs/code (e.g. Rocket.Chat) for how they solved it
- Meteor forums — https://forums.meteor.com
- AtmosphereJS — https://atmospherejs.com/ ; Packosphere — https://packosphere.com/
- Meteor Community Packages — https://github.com/meteor-community-packages
- Meteor-Files — https://github.com/veliovgroup/Meteor-Files
- npm — https://www.npmjs.com
4. **After fixing, add tests** — unit tests, negative tests, and UI tests where
appropriate — and **run the new tests to verify they pass** (see Tests / the sandbox
build+test instructions in `docs/Security/Sandboxes/vscode/README.md`).
5. **Commit** (maintainer only — when the current git user is `Lauri Ojansivu
<x@xet7.org>`), with no "Co-Authored" / AI trailer, a message body ending:
```
Thanks to (GitHub nickname of the issue creator) and xet7 !
Fixes #1234,
```
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
whose message ends `Fixes #1234,`), noting where/when it was fixed.
Fix from source and test — do not guess. If the environment cannot run the relevant
test (e.g. Docker is unavailable in this sandbox), reproduce as closely as possible
from source and say clearly what was and was not verified.
## Translations (Transifex ↔ direct LLM fill, no external service)
WeKan translations live in `imports/i18n/data/<lang>.i18n.json` (flat
`key -> string`, 2-space indent, key order matches `en.i18n.json`). Transifex holds
the human translations. **The policy is: never overwrite a human translation with a
filled (or English) one, but always take the newest translations from Transifex.**
- Pull with `releases/translations/pull-translations.sh`. It runs `tx pull -a -f`
(which fills every string that is UNtranslated on Transifex with the English source)
and then a **per-key merge** (`releases/translations/merge-translations.mjs`) that,
for every language file and every key:
- Transifex has a real translation (pulled value differs from English) → **keep it**
(the newest human translation always wins);
- the pull reverted it to English but a human translation is committed in git →
**restore the committed translation** (a pull never reverts a human translation,
even in files that also received real new Transifex translations);
- **no translation anywhere** (untranslated on Transifex AND never committed) → leave
the English source as a placeholder. **This is the only case a non-human value is
used.** A separate fill step may fill *only* these English placeholders, so a filled
string can never overwrite a human translation.
- Restored languages are pushed back to Transifex so they stop reverting.
### Filling the remaining untranslated strings — directly, no translation service
The strings still equal to the English source after the merge are the ones untranslated
**everywhere** (Transifex + git). Translate these **directly** — the maintainer or the
assistant (an LLM) writes the translation itself, using that language's **existing
translations** and general **kanban terminology** for the language as the reference.
**Do NOT wire up any external translation service, API, endpoint, key or password** — an
earlier `machine-translate.mjs` that called LibreTranslate/DeepL is removed on purpose
(it did not work and needed a password). There is **no `WEKAN_MT*` env var** anymore.
- `node releases/translations/fill-translations.mjs --missing` — per-language count of
strings still needing translation (English + `en-*` variants are skipped: English by
design). Also printed at the end of `pull-translations.sh`.
- `node releases/translations/fill-translations.mjs --list <lang> [--limit N]` — dump the
untranslated keys of a language as `{ key: englishSource }` for the translator to fill.
- `node releases/translations/fill-translations.mjs --apply <lang> <translated.json>` —
merge the translations back. It writes **only** into placeholder keys, so it can
**never** overwrite a human translation, and a value still equal to English/empty is
ignored. Key order and 2-space indent are preserved.
**Both directions are safe, and it is verified:**
`node releases/translations/verify-human-preference.mjs` proves (pure-logic, no network)
that the pull-merge keeps/restores human translations and that a fill only touches
placeholders. Filled strings stay **local** — they are **NOT** pushed to Transifex (only
the merge-restored human languages are pushed), so a filled string can never masquerade
as human there. So: **only missing strings are ever filled, only when missing
everywhere, human strings are always preferred and merged**, and nothing you fill is
pushed to Transifex as if it were human.
## General practices (from ../log/v10/Claude.txt)
- **[maintainer only]** Commit as `Lauri Ojansivu <x@xet7.org>`, with **no**
"Co-Authored-By" or any other AI trailer, directly to the `main` branch of WeKan and
the `main-v1` branch of the FerretDB fork. **Do not make pull requests.** (Contributors
do the opposite: work on a branch and open a pull request — see the top section.)
- Lauri Ojansivu (xet7) maintains WeKan (https://wekan.fi) and the FerretDB v1 fork.
- Directory structure:
- `wekan` — this repo (https://github.com/wekan/wekan); see
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG.md` at root.
- `../w/wekan.fi` — the WeKan website.
- `FerretDB` subdirectory (when present) — https://github.com/wekan/FerretDB, its own
`CHANGELOG.md`. FerretDB `.go` files must contain **no** application-specific names
(say "the client" / "a Meteor 3 driver" / a bare `#NNNN`); its `CHANGELOG.md` may
use `wekan/wekan#NNNN`.
- `sandstorm` subdirectory (when present) — https://github.com/sandstorm-io/sandstorm.
### CHANGELOG
- During development, add entries under a new `# Upcoming WeKan ® release` section above
the newest release (FerretDB uses `## Upcoming FerretDB release`). Do **not** hand-edit
`package.json` or any other version reference — the release workflow bumps those.
- **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 /
Upgrade / Docs / Mac ChangeLog bullets, then a `<details>` whose `<summary>` is
`Version` holding "which WeKan version uses what". There is no `# Version`
heading of its own.
2. `# TODO Later` — a `<details>` whose `<summary>` is `Carried to a future
release.` explaining the list, then one `<details>` per category (below).
3. The releases, newest first, each `# v<MAJOR>.<MINOR> YYYY-MM-DD WeKan ® release`.
Nothing else is an `#` heading. A `##`/`###` inside a release would break the
version list, and a wrapped line that BEGINS with `#` (e.g. an issue number such
as `#6514`) becomes a heading too — escape it `\#6514` or keep it off the line
start.
- **A release's order of subsections** — CRITICAL SECURITY ISSUES first, then new
features, then the Admin Panel / UI reorganisation if there is one, then dependency
updates (`Thanks to dependabot.`), then bug fixes, then developer-facing changes,
documentation and translations. The section ends with the line `Thanks to above
GitHub users for their contributions and translators for their translations.`
- **Every entry is a `<details>` block, and they are what a release is made of.**
The `<summary>` is the SHORT description of what was done and IS the link to the
commit — the hash lives in the `href` and is never on the page — followed by the
`Thanks to …` sentence. Clicking it reveals the long description:
```
<details>
<summary><a href="https://github.com/wekan/wekan/commit/<hash>">Short description of
what was done</a>. Thanks to (issue creator's GitHub nickname) and xet7.</summary>
The long description: what was wrong, why, what it does now, what the test pins.
Word-wrapped at 80 characters, ordinary markdown - links, `code`, emphasis.
</details>
```
Exactly as above: `<details>` and `</details>` each on their own line, the whole
`<summary>…</summary>` on ONE line, a blank line under it, the body, a blank line,
the close — and a blank line between two blocks.
- **The summary is one line at a glance** — aim for ≤ 110 characters — and PLAIN text:
no `[text](url)`, no `**bold**`, no backticks inside `<summary>`, because a link
cannot nest inside the `<a>` and the rest renders literally. It ends with a full
stop, then `Thanks to …`. Every summary in a release links its own commit.
- **A change with nothing more to say stays a plain bullet** —
`- [Short description](https://github.com/wekan/wekan/commit/<hash>). Thanks to xet7.`
— and a dependency batch keeps its `- **package 1.2.3 → 1.2.4** — one line on what
it is` bullets, closing with `Thanks to dependabot.` A `<details>` whose body only
repeats its summary is noise; use one when there IS a longer story to reveal, which
is most fixes.
- **`# TODO Later` blocks are the same shape with two differences:** the `<summary>` is
the short category text (no `<a>`, because nothing was committed), and there is **no
`Thanks to`** — nothing is done yet, so there is nobody to thank. The body lists the
issues as `[#NNNN](https://github.com/wekan/wekan/issues/NNNN) (one-line reason)`.
- **The hash is never the link text.** `[f1c89548e](…)` shows a hash to a reader who
cannot do anything with it; the link text says what changed. Same for `[merge
commit](…)` and for a bare URL — see the next rule.
- **Never show a long URL as visible text.** A link is always
`[short text](url)` — an issue is `[#6524](…/issues/6524)`, an advisory is
`[GHSA-xxxx](…)`, a security page is `[ZipBleed](https://wekan.fi/hall-of-fame/zipbleed/)`.
- **Subsection headers read as ONE flowing sentence.** The FIRST subsection of a release
starts with `This release ` (e.g. `This release fixes the following bugs:`); every LATER
subsection in the SAME release starts with a lowercase `and ` instead of repeating
`This release ` — e.g.
`This release fixes the following bugs:` … `and updates the following dependencies:` …
`and has the following developer-tooling fix:` … `and improves the translation workflow:`
… then the closing `Thanks to above GitHub users …` line. A release with only one
subsection just keeps its single `This release …:` header. Use `and adds the following
new features:`, `and fixes the following bugs:`, `and updates the following
dependencies:`, etc., matching the verb to the subsection.
- **The Upcoming section opens with an `**In short:**` paragraph.** One paragraph,
right under the `# Upcoming WeKan ® release` line and above the first
`This release …:` header, saying what the whole release amounts to — the areas
that changed and what changed about them, with the notable names in `**bold**`
so it can be skimmed. It is a SUMMARY, not a list: it does not link commits, it
does not repeat an entry's wording, and it ends by accounting for the rest in a
clause (`Below that: dependency updates, nine bug fixes … and the usual
documentation and translation work.`). Keep it current as entries are added —
it is the first thing a reader sees, so a stale one misdescribes the release.
A finished release keeps the paragraph it was written with.
- **Under the summary comes the BINARIES TABLE: what each platform ships.** So the
top of a release section is, in order, (1) the `**In short:**` paragraph and
(2) this table, and only then the `This release …:` subsections. A WeKan bundle
is not only WeKan — it carries a Node.js, a FerretDB and the MongoDB Database
Tools that other projects publish, and WHICH source has a given CPU changes
from release to release: nodejs.org builds some architectures,
unofficial-builds others, and [wekan/node-patches](https://github.com/wekan/node-patches)
the ones neither of them does. "Which Node.js is in the arm64 bundle of 10.69,
and was it checked" must be answerable from the CHANGELOG, not from a build log
that expires.
```
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `a1b2…` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.24.0/ferretdb-amd64) | v1.24.0 | `c3d4…` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `e5f6…` |
```
**GROUPED BY PLATFORM**: rows are sorted by platform and then by binary, so one
platform's binaries stay together and the table is read a platform at a time.
The URL is the LINK ON THE "From" CELL — never a bare URL as visible text, the
same rule as everywhere else — and it is the exact file that was downloaded, not
the project's front page. The SHA256 is the checksum the source published and the
build verified, in backticks; a source that publishes none says *no checksum
published*, which is not a failed check but a source worth fixing. Table rows
carry links, so the 80-character wrap does not apply to them.
It is the same table `releases/provenance-table.sh` puts at the top of the GitHub
release notes, from the `provenance.tsv` rows each build job records — so the two
are filled from the same data and cannot disagree. **A platform that was NOT built
has no rows**, which is how the table also answers "why is there no i386 bundle
this time": no source published a Node.js for it (see
`releases/resolve-node-source.sh`).
- **Inside a subsection, entries are GROUPED BY AREA.** A release touches a handful
of areas and repeating the area's name in every summary is the noise this
removes — twelve entries that each begin "All Boards:" say "All Boards" twelve
times and the part that differs starts halfway through the line. Instead the area
is named ONCE, as a bold line with a short description of what the group covers,
and every entry under it drops the prefix:
```
and fixes the following bugs:
**The first header bar** - how it lays itself out, and what sits under it.
<details>
<summary><a href="…">It fills each row before starting the next one</a>. Thanks to xet7.</summary>
…
</details>
<details>
<summary><a href="…">It wraps to a second row instead of hiding the buttons that do not fit</a>. Thanks to xet7.</summary>
…
</details>
**All Boards** - the overview and its search.
<details>
…
```
The group line is `**Area** - short description.` on ONE line, wrapped at 80 like
everything else, with a blank line under it. It is NOT a heading: a `##` inside a
release breaks the version list (see above). Group labels are the areas of the
app — `All Boards`, `The first header bar`, `The left menus`, `The Admin Panel`,
`Board views`, `Public Boards`, `Member Settings`, `Board roles` — and the same
label is reused across subsections when an area has both a feature and a fix.
EVERY entry of a grouped subsection belongs to a group, including a group with
one entry: a section that is half grouped and half loose reads as a mistake. A
subsection with only one entry, and the dependency bullets, stay flat.
- **CRITICAL security header — match the previous releases' wording.** A security release
leads with `This release fixes the following CRITICAL SECURITY ISSUE of
[Name](https://wekan.fi/hall-of-fame/namebleed/):` for a single named *Bleed, or
`This release fixes the following CRITICAL SECURITY ISSUES:` for several — NOT
`CRITICAL VULNERABILITIES`. Each security bullet still links its own
`https://wekan.fi/hall-of-fame/...bleed/`. CodeQL-only releases use the established
`This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:`.
Because CRITICAL comes first, it keeps the `This release ` prefix; a following non-security
subsection becomes `and …` per the rule above.
- **`# TODO Later` section** — a triage backlog near the TOP of `CHANGELOG.md` (above the
version sections), for open issues that were **investigated but not fixed here**, each
recorded with a concrete REASON so whoever picks it up next knows why. Use it when working
through open issues (the "Fix open issues" process): for each issue, either **fix it** (commit
ending `Fixes #NNNN,`), **close it** if already fixed in current code (commit `Close #NNNN` /
`Fixes #NNNN,`), or — when it can't be fixed/verified in this environment — **add it to `TODO
Later` under the matching category with the reason**. Group issues by category bullet, e.g.:
*Need specific infrastructure / a running server stack* (LDAP/WebHooks/Sandstorm/proxy —
environment owners), *Need the running app to reproduce/verify* (runtime UI / publication /
mergebox / router state, not unit-testable), *Already correct in the current code* (verified by
reading; could not reproduce), *Feature requests / behaviour-by-design rather than bugs*, and
*Needs a maintainer decision on the intended contract*. Each category is a `<details>`
whose `<summary>` is the SHORT category text and whose body lists the issues with their
reasons — and with **no `Thanks to` line**, because nothing is done yet, so there is
nobody to thank. Each entry is a normal issue link
`[#NNNN](https://github.com/wekan/wekan/issues/NNNN) (one-line reason)` — issue links here, NOT
commit links (nothing was committed). **Keep it current:** when an issue in `TODO Later` gets
fixed, REMOVE it from the list (its fix commit's `Fixes #NNNN` closes it); do not leave fixed
issues in the backlog. This is the only CHANGELOG place that uses issue links and lives above
the releases; everything else uses the per-release commit-link bullets above.
- FerretDB Upcoming structure — `### New Features 🎉`, `### Fixed 🐛`, `### Other Changes
🤖`; entries end `... by @xet7. Thanks to xet7.`
- Word-wrap both CHANGELOGs at 80 chars, but never break a long link across lines (a
`<summary>` line, or any other line carrying a link, may be longer). Continuation
lines of a plain bullet are indented by two spaces.
### Commit message structure
```
Do something.
Thanks to (original creator of issue) and xet7 !
Fixes #1234,
Fixes #1235.
```
### Making a release — no version number needed **[maintainer only]**
All publishing / release steps below are maintainer-only. Contributors never run them.
- WeKan: run `./releases/release-all.sh` (no arguments). It renames
`# Upcoming WeKan ® release` to the next version (same increment as the last release;
9.99 → 10.00) dated today, commits + pushes, and triggers
`.github/workflows/release-all.yml` (its `bump` job bumps `package.json` and every
version reference, then the release jobs tag `v<new>` and publish the GitHub Release).
An explicit `oldversion newversion` pair still overrides. Adding entries under Upcoming
is the only hand step.
- FerretDB: run `./build.sh release-ferretdb` from the `FerretDB` subdirectory (no
version). It renames `## Upcoming FerretDB release` to the next version with the
correct git-tag link, commits + tags + pushes, then triggers `release-all.yml` (which
in turn triggers `docker.yml` for the multi-arch image). It refuses to re-release an
already-tagged version.
### Security issues
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
entries, and update `../w/wekan.fi/hall-of-fame/index.html` and the vuln-name
subdirectory `index.html` like previous security issues.
- **A Hall of Fame row has EIGHT cells**, in this order: CVE, Icon, Vulnerability
name, Date, Responsible Security Disclosure by, Stars, Process, Vulnerabilities.
One thing per cell — the name without its icons, the Font Awesome icons alone in
Icon (still wrapped in `<h2>`), the reporter alone in "by", the `GoldStar.png`
images alone in Stars, and the sentence about how the report arrived (coordinated
disclosure, GitHub advisory, code scanning, found while reviewing …) in Process.
In "by", the reporter's NICKNAME is the link —
`<b><a href="https://github.com/nick">nick</a></b>`, never the name followed by a
separate "(GitHub)" link. **Check the account exists before linking it** (a 404 or,
worse, somebody else's profile is the failure here); a real name, a company or a
tool such as GitHub CodeQL is not a GitHub user and stays plain text. Do not write
a role note after a name — no `(fix)`, `(found)` or `(found and fix)`: this column
is the credit, and the Process cell beside it already says how the report arrived.
Stars is a column so it can be compared down the page; do not put a star, an icon
or the process sentence back into another cell. A page-local `<style>` in
`index.html` keeps a row's stars on ONE line from 900px up (they are a count) and
sizes them at 24px, and keeps the icon and its red drop on one line at every width
(they are one icon) — the table itself stays percentage-sized with no fixed or
minimum width, so it fills the page at every browser width.
- **On the Hall of Fame index page, the Process and Vulnerabilities cells are
collapsed.** Those two columns carry the prose — how the report arrived, and the
whole story of the vulnerability — so the page would be one wall of text and the
columns that identify a row (CVE, name, date, reporter, stars) would be far apart.
In `hall-of-fame/index.html`, and ONLY in those two `<td>`s, the cell's content
sits inside a `<details>` whose summary is the column's own name — `Process` for
the Process cell, `Details` for the Vulnerabilities cell:
```
<td valign="top">
<details>
<summary>Details</summary>
<ul>
<li>… the same list as before …</li>
</ul>
</details>
</td>
```
The summary is that plain word, there is a blank line under it, and the cell's
contents are otherwise unchanged. The other six cells of the row stay as they are
— they are what a reader scans — and so does the header row.
- **The vuln-name subdirectory page is NOT collapsed.** `hall-of-fame/<name>bleed/index.html`
is the page a reader opened on purpose, for one vulnerability: its table cell stays
open and its `<h2 class="hof">Details</h2>` prose section below the table stays as
it is. Only the index, which lists them all, hides them behind the summary.
### Tests
- `build.sh` / `build.bat` have a menu to install dependencies, build
WeKan and run tests.
- Add tests, negative tests and UI tests for all new features and fixes that do not yet
have tests. When adding a test, run or validate it and fix it until it works.
- **"Check newest test logs":** test logs go into a `log/<datetime>/` directory,
one per run (e.g. `2026-07-21_20-58-09/`). That is **`../log/`**, one level up
from the repo, so a run does not show up in `git status` - **unless the parent
is not writable**, as in a Flatpak sandbox that shares only the repository;
there it is **`./log/`** inside the repo (gitignored). `build.sh` prints which
one when a run starts. The
newest datetime directory is the latest run. Each holds the Playwright per-browser
logs (`wekan-alltests-chromium.log`, `-firefox.log`, `-webkit.log`), the mocha/unit
log (`wekan-alltests-mocha.log`), the e2e/import logs, and `wekan-test-server.log`
(the WeKan test server + database output). A whole-run directory also holds the
database-conformance logs (`db-conformance-*.log`, `-report.md`, `-summary.txt`)
and FerretDB's own (`ferretdb-unit.log`, `-vet.log`, `-integration.log`). "Check
the newest test logs" means: open the most recent `log/<datetime>/` - look in
`../log/` first, then `./log/` - and read those.
- **Check and fix WHILE the tests are still running.** A full run takes a long time
(three browsers, then every database with an image for this CPU, then FerretDB's
own suites), and its stages finish one at a time. Do not wait for the end: read
the logs that are already written, fix what they show, and then look again for the
stages that have finished since — repeat until every stage has run and everything
found is fixed. Two things make this work:
- A stage's log file is complete when its `===== ... finished` line is there; a
file whose mtime is still moving is a stage in progress, and its failures so far
are already real and worth fixing.
- The node suites are run by `tests/run-node-suites.cjs`, which runs ALL of them
and lists every failure at the end (`===== node suites: N run, M failed`). They
are the fastest signal - about 15 seconds for 260 suites - so they are usually
what to fix first while the browsers are still going.
- **A failing guard is not automatically a broken app.** Most of these suites read
the source and pin a behaviour. When one fails, decide which side is wrong: fix
the CODE when the guard still describes what WeKan should do, and fix the GUARD
when the behaviour deliberately changed - and then say in the test WHY, so the
next reader knows it was a decision and not a slip. Every fixed test keeps the
assertion that made it valuable; do not delete a test to make a run green.
## Environment
- The editor (VSCode) runs inside a **Flatpak sandbox**, launched by
`docs/Security/Sandboxes/vscode/vscode-sandbox.sh`. What the sandbox allows/blocks and
how it is set up is documented in `docs/Security/Sandboxes/vscode/README.md` in that
same directory — read it when something behaves differently than a normal host (file
access, network, running services).
### Always validate from the actual code
- When doing anything, check how it actually works in the code first.
+6765 -173
View File
File diff suppressed because it is too large. Load diff
+98 -10
View File
@@ -14,8 +14,7 @@ git config user.name && git config user.email
`Lauri Ojansivu <x@xet7.org>` (name `Lauri Ojansivu`, email `x@xet7.org`). Then, and
only then: commit **directly to the current branch** as `Lauri Ojansivu <x@xet7.org>`
with no AI trailer and no pull request, and the **publishing / release steps** below
are available. (Per the standing rule you still **commit only; do not push** unless
explicitly asked.)
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
@@ -122,17 +121,21 @@ 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.)
- **[maintainer only]** **Commit only. Do not push** (unless explicitly asked).
- Lauri Ojansivu (xet7) maintains WeKan (https://wekan.fi) and the FerretDB v1 fork.
- Directory structure:
- `wekan` — this repo (https://github.com/wekan/wekan); see
`docs/DeveloperDocs/Directory-Structure.md`; `CHANGELOG.md` at root.
- `../w/wekan.fi` — the WeKan website.
- `FerretDB` subdirectory (when present) — https://github.com/wekan/FerretDB, its own
- `.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`.
- `sandstorm` subdirectory (when present) — https://github.com/sandstorm-io/sandstorm.
- `.tools/sandstorm` (when present) — https://github.com/sandstorm-io/sandstorm.
### CHANGELOG
@@ -206,6 +209,87 @@ pushed to Transifex as if it were human.
subsection just keeps its single `This release …:` header. Use `and adds the following
new features:`, `and fixes the following bugs:`, `and updates the following
dependencies:`, etc., matching the verb to the subsection.
- **The Upcoming section opens with an `**In short:**` paragraph.** One paragraph,
right under the `# Upcoming WeKan ® release` line and above the first
`This release …:` header, saying what the whole release amounts to — the areas
that changed and what changed about them, with the notable names in `**bold**`
so it can be skimmed. It is a SUMMARY, not a list: it does not link commits, it
does not repeat an entry's wording, and it ends by accounting for the rest in a
clause (`Below that: dependency updates, nine bug fixes … and the usual
documentation and translation work.`). Keep it current as entries are added —
it is the first thing a reader sees, so a stale one misdescribes the release.
A finished release keeps the paragraph it was written with.
- **Under the summary comes the BINARIES TABLE: what each platform ships.** So the
top of a release section is, in order, (1) the `**In short:**` paragraph and
(2) this table, and only then the `This release …:` subsections. A WeKan bundle
is not only WeKan — it carries a Node.js, a FerretDB and the MongoDB Database
Tools that other projects publish, and WHICH source has a given CPU changes
from release to release: nodejs.org builds some architectures,
unofficial-builds others, and [wekan/node-patches](https://github.com/wekan/node-patches)
the ones neither of them does. "Which Node.js is in the arm64 bundle of 10.69,
and was it checked" must be answerable from the CHANGELOG, not from a build log
that expires.
```
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `a1b2…` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.24.0/ferretdb-amd64) | v1.24.0 | `c3d4…` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `e5f6…` |
```
**GROUPED BY PLATFORM**: rows are sorted by platform and then by binary, so one
platform's binaries stay together and the table is read a platform at a time.
The URL is the LINK ON THE "From" CELL — never a bare URL as visible text, the
same rule as everywhere else — and it is the exact file that was downloaded, not
the project's front page. The SHA256 is the checksum the source published and the
build verified, in backticks; a source that publishes none says *no checksum
published*, which is not a failed check but a source worth fixing. Table rows
carry links, so the 80-character wrap does not apply to them.
It is the same table `releases/provenance-table.sh` puts at the top of the GitHub
release notes, from the `provenance.tsv` rows each build job records — so the two
are filled from the same data and cannot disagree. **A platform that was NOT built
has no rows**, which is how the table also answers "why is there no i386 bundle
this time": no source published a Node.js for it (see
`releases/resolve-node-source.sh`).
- **Inside a subsection, entries are GROUPED BY AREA.** A release touches a handful
of areas and repeating the area's name in every summary is the noise this
removes — twelve entries that each begin "All Boards:" say "All Boards" twelve
times and the part that differs starts halfway through the line. Instead the area
is named ONCE, as a bold line with a short description of what the group covers,
and every entry under it drops the prefix:
```
and fixes the following bugs:
**The first header bar** - how it lays itself out, and what sits under it.
<details>
<summary><a href="…">It fills each row before starting the next one</a>. Thanks to xet7.</summary>
…
</details>
<details>
<summary><a href="…">It wraps to a second row instead of hiding the buttons that do not fit</a>. Thanks to xet7.</summary>
…
</details>
**All Boards** - the overview and its search.
<details>
…
```
The group line is `**Area** - short description.` on ONE line, wrapped at 80 like
everything else, with a blank line under it. It is NOT a heading: a `##` inside a
release breaks the version list (see above). Group labels are the areas of the
app — `All Boards`, `The first header bar`, `The left menus`, `The Admin Panel`,
`Board views`, `Public Boards`, `Member Settings`, `Board roles` — and the same
label is reused across subsections when an area has both a feature and a fix.
EVERY entry of a grouped subsection belongs to a group, including a group with
one entry: a section that is half grouped and half loose reads as a mistake. A
subsection with only one entry, and the dependency bullets, stay flat.
- **CRITICAL security header — match the previous releases' wording.** A security release
leads with `This release fixes the following CRITICAL SECURITY ISSUE of
[Name](https://wekan.fi/hall-of-fame/namebleed/):` for a single named *Bleed, or
@@ -263,7 +347,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
@@ -327,16 +411,20 @@ All publishing / release steps below are maintainer-only. Contributors never run
WeKan and run tests.
- Add tests, negative tests and UI tests for all new features and fixes that do not yet
have tests. When adding a test, run or validate it and fix it until it works.
- **"Check newest test logs":** test logs are written outside this repo, in
`../log/<datetime>/` (one directory per run, e.g. `../log/2026-07-21_20-58-09/`). The
- **"Check newest test logs":** test logs go into a `log/<datetime>/` directory,
one per run (e.g. `2026-07-21_20-58-09/`). That is **`../log/`**, one level up
from the repo, so a run does not show up in `git status` - **unless the parent
is not writable**, as in a Flatpak sandbox that shares only the repository;
there it is **`./log/`** inside the repo (gitignored). `build.sh` prints which
one when a run starts. The
newest datetime directory is the latest run. Each holds the Playwright per-browser
logs (`wekan-alltests-chromium.log`, `-firefox.log`, `-webkit.log`), the mocha/unit
log (`wekan-alltests-mocha.log`), the e2e/import logs, and `wekan-test-server.log`
(the WeKan test server + database output). A whole-run directory also holds the
database-conformance logs (`db-conformance-*.log`, `-report.md`, `-summary.txt`)
and FerretDB's own (`ferretdb-unit.log`, `-vet.log`, `-integration.log`). "Check
the newest test logs" means: open the most recent `../log/<datetime>/` and read
those.
the newest test logs" means: open the most recent `log/<datetime>/` - look in
`../log/` first, then `./log/` - and read those.
- **Check and fix WHILE the tests are still running.** A full run takes a long time
(three browsers, then every database with an image for this CPU, then FerretDB's
own suites), and its stages finish one at a time. Do not wait for the end: read
+80 -30
View File
@@ -1,20 +1,25 @@
FROM ubuntu:26.04
# debian:trixie (Debian 13), not ubuntu:26.04: Ubuntu publishes no linux/386
# image, which stopped the image from ever building for i386. Debian ships every
# 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).
FROM debian:trixie
LABEL maintainer="wekan"
LABEL org.opencontainers.image.ref.name="ubuntu"
LABEL org.opencontainers.image.version="26.04"
LABEL org.opencontainers.image.ref.name="debian"
LABEL org.opencontainers.image.version="trixie"
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.45
ARG VERSION=10.78
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 \
NODE_VERSION=v24.18.0 \
NODE_VERSION=v24.19.0 \
METEOR_RELEASE=METEOR@3.5-rc.2 \
USE_EDGE=false \
NPM_VERSION=11.12.1 \
@@ -169,6 +174,13 @@ ENV \
MONGO_PASSWORD_FILE="" \
S3_SECRET_FILE=""
# Where this image's Node.js comes from is decided by the SAME script the .zip
# bundles and the snap use - official nodejs.org, then unofficial-builds, then
# wekan/node-patches - so the image and the bundle of the same architecture can
# 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
RUN <<EOR
set -o xtrace
# Fail hard on any error so a missing release zip / failed download can never
@@ -185,35 +197,73 @@ apt-get update --assume-yes
apt-get upgrade --assume-yes
apt-get install --assume-yes --no-install-recommends ${BUILD_DEPS}
# Multi-arch mapping: Docker TARGETARCH -> Node.js arch name + WeKan bundle name.
# amd64/arm64 have MongoDB Community; ppc64le/s390x/riscv64 have no MongoDB server
# and ship FerretDB v1 instead (the ferretdb binary is baked into their .zip and
# started by wekan-entrypoint.sh). riscv64's Node.js 24 comes from
# unofficial-builds.nodejs.org (nodejs.org ships no riscv64). armv7l is excluded:
# there is no Node.js 24 build for it anywhere.
NODE_BASE="official"
# Multi-arch mapping: Docker TARGETARCH -> WeKan's own platform name, which is
# both the bundle .zip's name and what resolve-node-source.sh is asked about.
# amd64/arm64 have MongoDB Community; ppc64le/s390x/riscv64 have no MongoDB
# server and ship FerretDB v1 instead (the ferretdb binary is baked into their
# .zip and started by wekan-entrypoint.sh). Debian's 32-bit ARM port is armhf
# (ARMv7, VFPv3-D16), which is what linux/arm/v7 runs.
case "${TARGETARCH}" in
"amd64") NODE_ARCH="x64" WEKAN_ARCH="amd64" ;;
"arm64") NODE_ARCH="arm64" WEKAN_ARCH="arm64" ;;
"ppc64le") NODE_ARCH="ppc64le" WEKAN_ARCH="ppc64le" ;;
"s390x") NODE_ARCH="s390x" WEKAN_ARCH="s390x" ;;
"riscv64") NODE_ARCH="riscv64" WEKAN_ARCH="riscv64" NODE_BASE="unofficial" ;;
*) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;;
"amd64") WEKAN_ARCH="amd64" ;;
"arm64") WEKAN_ARCH="arm64" ;;
"ppc64le") WEKAN_ARCH="ppc64le" ;;
"s390x") WEKAN_ARCH="s390x" ;;
"riscv64") WEKAN_ARCH="riscv64" ;;
"386") WEKAN_ARCH="i386" ;;
"arm") WEKAN_ARCH="armhf" ;;
*) echo "Unsupported architecture: ${TARGETARCH}${TARGETVARIANT:+/${TARGETVARIANT}}"; exit 1 ;;
esac
# Node.js installation. Official nodejs.org builds for amd64/arm64/ppc64le/s390x;
# unofficial-builds.nodejs.org for riscv64 (nodejs.org ships no riscv64 binary).
# Node.js installation - official nodejs.org, then unofficial-builds.nodejs.org,
# then wekan/node-patches, in that order, for EVERY architecture.
#
# The choice is made by releases/resolve-node-source.sh, copied in above and used
# by the .zip bundles and the snap as well, so the image and the bundle of one
# architecture always carry the same Node.js from the same place. It is asked
# about the MAJOR, so a CPU whose newest build lags a release still gets its
# newest: it answers with the exact file, what shape that file is, and the
# SHA256 the source published for it.
#
# npm is arch-independent JavaScript, so it is grafted from the official amd64
# tarball of the version that was resolved - a build-time tool, not the shipped
# node. (node-patches publishes a bare node binary and no npm at all, which is
# why npm is fetched separately rather than taken from the archive above.)
cd /tmp
if [ "${NODE_BASE}" = "unofficial" ]; then
NODE_DIST="https://unofficial-builds.nodejs.org/download/release/${NODE_VERSION}"
else
NODE_DIST="https://nodejs.org/dist/${NODE_VERSION}"
NODE_MAJOR="${NODE_VERSION#v}"; NODE_MAJOR="${NODE_MAJOR%%.*}"
if ! NODE_META="$(bash /tmp/resolve-node-source.sh "${WEKAN_ARCH}" "${NODE_MAJOR}")"; then
echo "No Node.js ${NODE_MAJOR}.x for ${WEKAN_ARCH} at nodejs.org, unofficial-builds.nodejs.org or wekan/node-patches, so this image cannot be built for it. Build node-${WEKAN_ARCH} in wekan/node-patches, or drop ${TARGETARCH} from the image's platform list." >&2
exit 1
fi
wget "${NODE_DIST}/node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz"
wget "${NODE_DIST}/SHASUMS256.txt"
grep " node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz\$" SHASUMS256.txt | shasum -a 256 -c -
tar xzf "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" -C /usr/local --strip-components=1 --no-same-owner
rm -f "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" SHASUMS256.txt
eval "${NODE_META}"
echo "Node.js ${node_full} for ${WEKAN_ARCH}: ${node_from} (${node_url})"
wget --tries=20 --waitretry=20 --retry-on-http-error=403,500,502,503 -O node-download "${node_url}"
echo "${node_sha256} node-download" | sha256sum -c -
case "${node_kind}" in
binary)
cp node-download /usr/local/bin/node
;;
tar.xz|tar.gz|tar)
# bsdtar (libarchive-tools) is installed below for the bundle; plain tar
# here, letting it detect the compression rather than naming it.
tar -xf node-download --no-same-owner "${node_member}"
cp "${node_member}" /usr/local/bin/node
;;
*)
echo "resolve-node-source.sh returned an unknown kind '${node_kind}'." >&2
exit 1
;;
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"
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" \
"node-${node_full}-linux-x64/bin/npm" \
"node-${node_full}-linux-x64/bin/npx"
rm -rf node-download "node-${node_full}-linux-x64.tar.gz" "node-${node_full}-linux-x64" SHASUMS256.txt
ln -s "/usr/local/bin/node" "/usr/local/bin/nodejs"
# NPM configuration
@@ -244,7 +294,7 @@ mv /home/wekan/app/bundle /build
# offline downloads; the Docker image installs its own Node and uses
# wekan-entrypoint.sh, so drop the redundant bundled node + launchers. Keeps
# /build/ferretdb (used by the entrypoint) and the per-arch MongoDB Database Tools
# (bsondump, mongodump, mongorestore, … from wekan/mongo-tools, embedded in the
# (bsondump, mongodump, mongorestore, … from wekan/mongo-tools-patches, embedded in the
# bundle) for backup/restore inside the container. Saves ~80 MB per arch.
rm -f /build/node /build/start-wekan.sh /build/start-wekan.bat
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v10.45.0"
appVersion: "v10.78.0"
files:
userUploads:
- README.md
+718 -88
View File
@@ -17,6 +17,30 @@ REM - If Meteor does not run well natively on your Windows, WSL2 + Ubuntu
REM with build.sh is the recommended alternative.
REM ============================================================================
REM --- Command line: build.bat <name> [args] / --help / --list ---------------
REM Same names as build.sh, so a release, a bundle build or a translation pull
REM can be scripted on Windows too. With no arguments this falls through to the
REM menu, exactly as before.
if "%~1"=="" goto no_cli_args
if /I "%~1"=="-h" goto cli_help_exit
if /I "%~1"=="--help" goto cli_help_exit
if /I "%~1"=="help" goto cli_help_exit
if /I "%~1"=="-l" goto cli_list_exit
if /I "%~1"=="--list" goto cli_list_exit
if /I "%~1"=="list" goto cli_list_exit
call :cli_run %*
exit /b %errorlevel%
:cli_help_exit
call :cli_help
exit /b 0
:cli_list_exit
call :cli_help
echo.
echo Commands:
call :cli_list
exit /b 0
:no_cli_args
REM --- Repo root = folder of this script (strip trailing backslash) ---
set "REPO=%~dp0"
if "%REPO:~-1%"=="\" set "REPO=%REPO:~0,-1%"
@@ -52,7 +76,9 @@ echo 1^) Setup ^(install dependencies, build^)
echo 2^) Dev server ^(meteor run variants^)
echo 3^) Tests ^(mocha, playwright, e2e, ...^)
echo 4^) Docker ^(start / follow logs / stop^)
echo 5^) Tools ^(save deps, forge tools, mirror^)
echo 5^) Releases ^(release, snap, bundles, translations, ...^)
echo 6^) CLI commands ^(run any of them without the menu^)
echo 7^) Tools ^(save deps, forge tools, mirror^)
echo 0^) Quit
echo ==========================================================
set "choice="
@@ -61,7 +87,9 @@ if "%choice%"=="1" goto menu_setup
if "%choice%"=="2" goto menu_dev
if "%choice%"=="3" goto menu_tests
if "%choice%"=="4" goto menu_docker
if "%choice%"=="5" goto menu_tools
if "%choice%"=="5" goto menu_releases
if "%choice%"=="6" goto menu_cli
if "%choice%"=="7" goto menu_tools
if "%choice%"=="0" goto end
echo invalid option
goto menu
@@ -72,12 +100,14 @@ echo.
echo -- Setup -- ^(0 = Back^)
echo 1^) Install dependencies
echo 2^) Build WeKan
echo 3^) Update git ^(fetch + rebase onto origin, fix CHANGELOG hashes, status^)
echo 3^) git pull ^(fetch, fast-forward or rebase, repoint moved CHANGELOG links^)
echo 4^) git push ^(check the CHANGELOG links, push, pull-and-retry once if rejected^)
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" goto install
if "%choice%"=="2" goto build
if "%choice%"=="3" goto updategit
if "%choice%"=="3" goto gitpull
if "%choice%"=="4" goto gitpush
if "%choice%"=="0" goto menu
goto menu_setup
@@ -110,8 +140,10 @@ REM ===========================================================================
:menu_tests
echo.
echo -- Tests -- ^(0 = Back^)
echo 1^) EVERYTHING ^(sequential^): WeKan's own tests, then every database with an
echo image for this CPU, then all FerretDB tests. Logs in ..\log\^<datetime^>\
echo 1^) EVERYTHING ^(sequential^): the floating-promises guard, then WeKan's own
echo tests ^(mocha, node suites, import, node E2E, three browsers^), then every
echo database with an image for this CPU, then all FerretDB tests ^(unit, vet,
echo integration^). Logs in ..\log\^<datetime^>\
echo 2^) WeKan's own tests only, parallel: Mocha, node unit suites, import, node
echo E2E and all three browsers, concurrently. No databases, no FerretDB
echo 3^) WeKan's own tests only, sequential: the same suite, one job at a time
@@ -163,6 +195,531 @@ if "%choice%"=="3" goto mirror_forge
if "%choice%"=="0" goto menu
goto menu_tools
REM ===========================================================================
REM Releases: every maintainer script in releases/, grouped, same list and
REM same order as build.sh's RELEASE_SCRIPTS. They are Git Bash scripts, so
REM this needs Git for Windows (bash on PATH); .mjs entries are run by node.
REM The entries build.sh marks as linux-only (systemd, ufw, snap, multipass)
REM are not here: Windows cannot do them at all.
REM tests/buildScriptParity.test.cjs fails when a script is added to
REM releases/ and not to BOTH menus.
:menu_releases
echo.
echo -- Releases -- ^(0 = Back^)
echo 1^) Release ^(22 entries^)
echo 2^) Snap ^(11 entries^)
echo 3^) Bundles ^(10 entries^)
echo 4^) Docker images ^(5 entries^)
echo 5^) Sandstorm ^(5 entries^)
echo 6^) Translations ^(9 entries^)
echo 7^) Git and repo ^(8 entries^)
echo 8^) Server and VM ^(4 entries^)
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" goto rel_release
if "%choice%"=="2" goto rel_snap
if "%choice%"=="3" goto rel_bundles
if "%choice%"=="4" goto rel_dockerimages
if "%choice%"=="5" goto rel_sandstorm
if "%choice%"=="6" goto rel_translations
if "%choice%"=="7" goto rel_gitandrepo
if "%choice%"=="8" goto rel_serverandvm
if "%choice%"=="0" goto menu
goto menu_releases
REM ---------------------------------------------------------------------------
:rel_release
echo.
echo -- Releases / Release -- ^(0 = Back^)
echo 1^) Release ALL platforms: push CHANGELOG, trigger release-all.yml
echo 2^) Release ^(older local flow^), for one version
echo 3^) Show the version numbers this checkout would release
echo 4^) Show the CHANGELOG of the release being prepared
echo 5^) Rebuild the API docs ^(wekan.yml + wekan.html^)
echo 6^) Rebuild a release that already exists
echo 7^) Prepare the release directory for one version
echo 8^) Collect the built bundles for one version
echo 9^) Link the newest bundle as wekan-latest
echo 10^) Move an old release out of the download directory
echo 11^) Clean up after a release
echo 12^) Publish the Helm chart in wekan/charts
echo 13^) Update wekan.fi with the new version and API docs
echo 14^) Publish the npm packages xet7 maintains
echo 15^) Check every download URL snapcraft.yaml uses
echo 16^) Clone all release-related repositories
echo 17^) Create the GitHub Actions secrets a release needs
echo 18^) Add a git tag for a release
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
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" call :rel_run "releases/release-all.sh" ""
if "%choice%"=="2" call :rel_run "releases/release.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="3" call :rel_run "releases/version.sh" ""
if "%choice%"=="4" call :rel_run "releases/changelog.sh" ""
if "%choice%"=="5" call :rel_run "releases/rebuild-docs.sh" ""
if "%choice%"=="6" call :rel_run "releases/rebuild-release.sh" ""
if "%choice%"=="7" call :rel_run "releases/rel.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="8" call :rel_run "releases/release-bundle.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="9" call :rel_run "releases/release-ln.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="10" call :rel_run "releases/release-x2.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="11" call :rel_run "releases/release-cleanup.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="12" call :rel_run "releases/release-charts.sh" ""
if "%choice%"=="13" call :rel_run "releases/release-website.sh" ""
if "%choice%"=="14" call :rel_run "releases/npm-publish.sh" ""
if "%choice%"=="15" call :rel_run "releases/test-download-urls.sh" ""
if "%choice%"=="16" call :rel_run "releases/clone-release-repos.sh" ""
if "%choice%"=="17" call :rel_run "releases/create-github-secrets.sh" ""
if "%choice%"=="18" call :rel_run "releases/add-tag.sh" "Version tag, e.g. v10.50"
if "%choice%"=="19" call :rel_run "releases/delete-tag.sh" "Version tag, e.g. v10.50"
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%"=="0" goto menu_releases
goto rel_release
REM ---------------------------------------------------------------------------
:rel_snap
echo.
echo -- Releases / Snap -- ^(0 = Back^)
echo 1^) Build the snap from snapcraft.yaml
echo 2^) Install the locally built .snap
echo 3^) Push one .snap to the Snap Store
echo 4^) Release the snap for one version
echo 5^) List the newest Snap Store revisions
echo 6^) Release one store revision to edge, beta and candidate
echo 7^) Switch the installed snap to the edge channel
echo 8^) Switch the installed snap to the stable channel
echo 9^) snapcraft help topics
echo 10^) wekan.help of the installed snap
echo 11^) Switch between KVM, snapcraft, Waydroid and VirtualBox
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" call :rel_run "releases/snap-build.sh" ""
if "%choice%"=="2" call :rel_run "releases/snap-install.sh" ""
if "%choice%"=="3" call :rel_run "releases/snap-push-to-store.sh" "Path to the .snap file"
if "%choice%"=="4" call :rel_run "releases/release-snap.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="5" call :rel_run "releases/snap-store-revisions.sh" ""
if "%choice%"=="6" call :rel_run "releases/snap-store-release-revision-to-channels.sh" "Snap Store revision number"
if "%choice%"=="7" call :rel_run "releases/snap-release-all-channels.sh" "Version, or empty for the newest"
if "%choice%"=="7" call :rel_run "releases/snap-edge.sh" ""
if "%choice%"=="8" call :rel_run "releases/snap-stable.sh" ""
if "%choice%"=="9" call :rel_run "releases/snapcraft-help.sh" ""
if "%choice%"=="10" call :rel_run "releases/wekan-snap-help.sh" ""
if "%choice%"=="11" call :rel_run "releases/switch-kvm-snapcraft-waydroid-virtualbox.sh" ""
if "%choice%"=="0" goto menu_releases
goto rel_snap
REM ---------------------------------------------------------------------------
:rel_bundles
echo.
echo -- Releases / Bundles -- ^(0 = Back^)
echo ^(the Windows bundle is built by running releases\build-bundle-win64.bat directly^)
echo 1^) Build the arm64 bundle
echo 2^) Build the armhf ^(arm/v7^) bundle
echo 3^) Build the ppc64el bundle
echo 4^) Build the ppc64le bundle
echo 5^) Build the s390x bundle
echo 6^) Fetch the built bundle from the amd64 build host
echo 7^) Fetch the built bundle from the arm64 build host
echo 8^) Fetch the built bundle from the ppc64le build host
echo 9^) Fetch the built bundle from the s390x build host
echo 10^) Upload the Windows bundle to the download server
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" call :rel_run "releases/build-bundle-arm64.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="2" call :rel_run "releases/build-bundle-armhf.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="3" call :rel_run "releases/build-bundle-ppc64el.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="4" call :rel_run "releases/build-bundle-ppc64le.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="5" call :rel_run "releases/build-bundle-s390x.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="6" call :rel_run "releases/up.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="7" call :rel_run "releases/up-a.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="8" call :rel_run "releases/up-o.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="9" call :rel_run "releases/up-s.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="10" call :rel_run "releases/up-w.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="0" goto menu_releases
goto rel_bundles
REM ---------------------------------------------------------------------------
:rel_dockerimages
echo.
echo -- Releases / Docker images -- ^(0 = Back^)
echo 1^) Build the WeKan Docker image
echo 2^) Create the multi-platform buildx builder
echo 3^) Publish a variant image ^(wekan-gantt-gpl / wekan-ondra^)
echo 4^) Push the locally built images to Docker Hub and Quay
echo 5^) Mirror the images between registries with skopeo
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" call :rel_run "releases/docker-build.sh" ""
if "%choice%"=="2" call :rel_run "releases/docker-build-deps.sh" ""
if "%choice%"=="3" call :rel_run "releases/docker-publish-variant.sh" "Image and version, e.g. wekan-gantt-gpl 10.50"
if "%choice%"=="4" call :rel_run "releases/docker-push-gantt.sh" "Docker build tag and WeKan version"
if "%choice%"=="5" call :rel_run "releases/docker-registry-sync.sh" ""
if "%choice%"=="0" goto menu_releases
goto rel_dockerimages
REM ---------------------------------------------------------------------------
:rel_sandstorm
echo.
echo -- Releases / Sandstorm -- ^(0 = Back^)
echo 1^) Install the Sandstorm-related files
echo 2^) Disable the Sandstorm files again
echo 3^) Make the .spk package
echo 4^) Run the Sandstorm dev server
echo 5^) Release the Sandstorm version
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" call :rel_run "releases/install-sandstorm.sh" ""
if "%choice%"=="2" call :rel_run "releases/disable-sandstorm.sh" ""
if "%choice%"=="3" call :rel_run "releases/sandstorm-make-spk.sh" ""
if "%choice%"=="4" call :rel_run "releases/sandstorm-test-dev.sh" ""
if "%choice%"=="5" call :rel_run "releases/release-sandstorm.sh" ""
if "%choice%"=="0" goto menu_releases
goto rel_sandstorm
REM ---------------------------------------------------------------------------
:rel_translations
echo.
echo -- Releases / Translations -- ^(0 = Back^)
echo 1^) Pull the newest translations from Transifex and merge them
echo 2^) How many strings each language still needs
echo 3^) Push one language to Transifex
echo 4^) Push every language to Transifex
echo 5^) Push the English source to Transifex
echo 6^) Copy the English source into en-GB on Transifex
echo 7^) Report English strings that regressed
echo 8^) Prove a pull keeps human translations ^(no network^)
echo 9^) Merge a finished pull by hand
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" call :rel_run "releases/translations/pull-translations.sh" ""
if "%choice%"=="2" call :rel_run "releases/translations/fill-translations.mjs --missing" ""
if "%choice%"=="3" call :rel_run "releases/translations/push-translation.sh" "Language code, e.g. ja"
if "%choice%"=="4" call :rel_run "releases/translations/push-all-translations.sh" ""
if "%choice%"=="5" call :rel_run "releases/translations/push-english-base-translation.sh" ""
if "%choice%"=="6" call :rel_run "releases/translations/push-copy-en-gb-translation.sh" ""
if "%choice%"=="7" call :rel_run "releases/translations/report-english-regressions.mjs" ""
if "%choice%"=="8" call :rel_run "releases/translations/verify-human-preference.mjs" ""
if "%choice%"=="9" call :rel_run "releases/translations/merge-translations.mjs" ""
if "%choice%"=="0" goto menu_releases
goto rel_translations
REM ---------------------------------------------------------------------------
:rel_gitandrepo
echo.
echo -- Releases / Git and repo -- ^(0 = Back^)
echo 1^) Commit with the editor open for a multi-line message
echo 2^) Add everything, then revert it again
echo 3^) Delete a branch, locally and on the remote
echo 4^) Count lines of code per committer
echo 5^) Convert the remaining Stylus to CSS
echo 6^) Update Node.js everywhere in the sources
echo 7^) Update the local Node.js version
echo 8^) Migrate a MongoDB database to FerretDB ^(--help first^)
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" call :rel_run "releases/commit.sh" ""
if "%choice%"=="2" call :rel_cmd "git restore --staged" "Path to unstage"
if "%choice%"=="3" call :rel_run "releases/delete-branch-local-and-remote.sh" "Branch name"
if "%choice%"=="4" call :rel_run "releases/count-lines-of-code-per-committer.sh" ""
if "%choice%"=="5" call :rel_run "releases/stylus-to-css.sh" ""
if "%choice%"=="6" call :rel_run "releases/node-update.sh" ""
if "%choice%"=="7" call :rel_run "releases/node-update-local.sh" ""
if "%choice%"=="8" call :rel_run "releases/migrate-mongodb-to-ferretdb.mjs" "Arguments, e.g. --help"
if "%choice%"=="0" goto menu_releases
goto rel_gitandrepo
REM ---------------------------------------------------------------------------
:rel_serverandvm
echo.
echo -- Releases / Server and VM -- ^(0 = Back^)
echo 1^) Show the VirtualBox VM's IP address
echo 2^) Let Node.js bind port 80 in the VM
echo 3^) Start WeKan in the VirtualBox VM
echo 4^) Stop WeKan in the VirtualBox VM
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" call :rel_run "releases/virtualbox/ipaddress.sh" ""
if "%choice%"=="2" call :rel_run "releases/virtualbox/node-allow-port-80.sh" ""
if "%choice%"=="3" call :rel_run "releases/virtualbox/start-wekan.sh" ""
if "%choice%"=="4" call :rel_run "releases/virtualbox/stop-wekan.sh" ""
if "%choice%"=="0" goto menu_releases
goto rel_serverandvm
REM ---------------------------------------------------------------------------
REM Run one releases/ script: %1 = path (may carry arguments), %2 = the
REM prompt for its argument, empty when it takes none.
:rel_run
set "RS=%~1"
set "RP=%~2"
set "RA="
if "%RP%"=="" goto rel_run_go
set /p "RA=%RP%: "
:rel_run_go
call :need_bash
if errorlevel 1 goto :eof
echo.
echo --- %RS% %RA% ---
echo %RS%| findstr /I /C:".mjs" >nul
if errorlevel 1 goto rel_run_bash
call node %RS% %RA%
goto rel_run_done
:rel_run_bash
call bash %RS% %RA%
:rel_run_done
pause
goto :eof
REM ---------------------------------------------------------------------------
REM Run one COMMAND (the entries that used to be one-line wrapper scripts):
REM %1 = the command, %2 = the prompt for its argument. Through bash, so the
REM ^&^& in a command means what it means everywhere else.
:rel_cmd
set "RC=%~1"
set "RP=%~2"
set "RA="
if "%RP%"=="" goto rel_cmd_go
set /p "RA=%RP%: "
:rel_cmd_go
call :need_bash
if errorlevel 1 goto :eof
echo.
echo --- !RC! !RA! ---
call bash -c "!RC! !RA!"
pause
goto :eof
REM ---------------------------------------------------------------------------
:need_bash
where bash >nul 2>&1
if errorlevel 1 (
echo bash not found - the releases scripts need Git Bash, bundled with Git for Windows.
echo Install Git for Windows, or run this under WSL2 with build.sh.
pause
exit /b 1
)
exit /b 0
REM ===========================================================================
REM CLI: run any of the above without the menu.
REM build.bat ^<name^> [arguments] run one entry
REM build.bat --list every name, with what it does
REM build.bat --help usage and examples
:cli_help
echo WeKan build.bat - menu, or a command line.
echo.
echo build.bat the menu
echo build.bat --help this text
echo build.bat --list every command name, with what it does
echo build.bat ^<name^> [arguments] run one, without the menu
echo.
echo Examples:
echo.
echo build.bat release-all push the CHANGELOG, trigger the release
echo build.bat version show the version numbers this would release
echo build.bat rebuild-docs rebuild wekan.yml + wekan.html
echo build.bat release-snap 10.50 release the snap of that version
echo build.bat pull-translations pull and merge the newest translations
echo build.bat push-translation ja push one language to Transifex
echo build.bat fill-translations how many strings each language still needs
echo build.bat add-tag v10.50 tag the release
echo.
echo Arguments after the name are passed to the script unchanged. Names come
echo from --list. The scripts run under Git Bash; systemd/ufw/snap entries are
echo Linux only and are in build.sh instead.
goto :eof
:cli_list
echo release-all Release ALL platforms: push CHANGELOG, trigger release-all.yml
echo release Release ^(older local flow^), for one version ^<WeKan version, e.g. 10.50^>
echo version Show the version numbers this checkout would release
echo changelog Show the CHANGELOG of the release being prepared
echo rebuild-docs Rebuild the API docs ^(wekan.yml + wekan.html^)
echo rebuild-release Rebuild a release that already exists
echo rel Prepare the release directory for one version ^<WeKan version, e.g. 10.50^>
echo release-bundle Collect the built bundles for one version ^<WeKan version, e.g. 10.50^>
echo release-ln Link the newest bundle as wekan-latest ^<WeKan version, e.g. 10.50^>
echo release-x2 Move an old release out of the download directory ^<WeKan version, e.g. 10.50^>
echo release-cleanup Clean up after a release ^<WeKan version, e.g. 10.50^>
echo release-charts Publish the Helm chart in wekan/charts
echo release-website Update wekan.fi with the new version and API docs
echo npm-publish Publish the npm packages xet7 maintains
echo test-download-urls Check every download URL snapcraft.yaml uses
echo clone-release-repos Clone all release-related repositories
echo create-github-secrets Create the GitHub Actions secrets a release needs
echo add-tag Add a git tag for a release ^<Version tag, e.g. v10.50^>
echo delete-tag Delete a git tag, locally and on the remote ^<Version tag, e.g. v10.50^>
echo stable-tag Move the 'stable' tag to HEAD
echo release-ondra-1 Release the wekan-ondra / wekan-gantt-gpl variants, part 1
echo release-ondra-2 Release the wekan-ondra / wekan-gantt-gpl variants, part 2
echo snap-build Build the snap from snapcraft.yaml
echo snap-install Install the locally built .snap
echo snap-push-to-store Push one .snap to the Snap Store ^<Path to the .snap file^>
echo release-snap Release the snap for one version ^<WeKan version, e.g. 10.50^>
echo snap-store-revisions List the newest Snap Store revisions
echo snap-store-release-revision-to-channels Release one store revision to edge, beta and candidate ^<Snap Store revision number^>
echo snap-release-all-channels Release every snap, every architecture, to all four channels ^<Version, or empty for the newest^>
echo snap-edge Switch the installed snap to the edge channel
echo snap-stable Switch the installed snap to the stable channel
echo snapcraft-help snapcraft help topics
echo wekan-snap-help wekan.help of the installed snap
echo switch-kvm-snapcraft-waydroid-virtualbox Switch between KVM, snapcraft, Waydroid and VirtualBox
echo build-bundle-arm64 Build the arm64 bundle ^<WeKan version, e.g. 10.50^>
echo build-bundle-armhf Build the armhf ^(arm/v7^) bundle ^<WeKan version, e.g. 10.50^>
echo build-bundle-ppc64el Build the ppc64el bundle ^<WeKan version, e.g. 10.50^>
echo build-bundle-ppc64le Build the ppc64le bundle ^<WeKan version, e.g. 10.50^>
echo build-bundle-s390x Build the s390x bundle ^<WeKan version, e.g. 10.50^>
echo up Fetch the built bundle from the amd64 build host ^<WeKan version, e.g. 10.50^>
echo up-a Fetch the built bundle from the arm64 build host ^<WeKan version, e.g. 10.50^>
echo up-o Fetch the built bundle from the ppc64le build host ^<WeKan version, e.g. 10.50^>
echo up-s Fetch the built bundle from the s390x build host ^<WeKan version, e.g. 10.50^>
echo up-w Upload the Windows bundle to the download server ^<WeKan version, e.g. 10.50^>
echo docker-build Build the WeKan Docker image
echo docker-build-deps Create the multi-platform buildx builder
echo docker-publish-variant Publish a variant image ^(wekan-gantt-gpl / wekan-ondra^) ^<Image and version, e.g. wekan-gantt-gpl 10.50^>
echo docker-push-gantt Push the locally built images to Docker Hub and Quay ^<Docker build tag and WeKan version^>
echo docker-registry-sync Mirror the images between registries with skopeo
echo install-sandstorm Install the Sandstorm-related files
echo disable-sandstorm Disable the Sandstorm files again
echo sandstorm-make-spk Make the .spk package
echo sandstorm-test-dev Run the Sandstorm dev server
echo release-sandstorm Release the Sandstorm version
echo pull-translations Pull the newest translations from Transifex and merge them
echo fill-translations How many strings each language still needs
echo push-translation Push one language to Transifex ^<Language code, e.g. ja^>
echo push-all-translations Push every language to Transifex
echo push-english-base-translation Push the English source to Transifex
echo push-copy-en-gb-translation Copy the English source into en-GB on Transifex
echo report-english-regressions Report English strings that regressed
echo verify-human-preference Prove a pull keeps human translations ^(no network^)
echo merge-translations Merge a finished pull by hand
echo commit Commit with the editor open for a multi-line message
echo git-add-revert Add everything, then revert it again ^<Path to unstage^>
echo delete-branch-local-and-remote Delete a branch, locally and on the remote ^<Branch name^>
echo count-lines-of-code-per-committer Count lines of code per committer
echo stylus-to-css Convert the remaining Stylus to CSS
echo node-update Update Node.js everywhere in the sources
echo node-update-local Update the local Node.js version
echo migrate-mongodb-to-ferretdb Migrate a MongoDB database to FerretDB ^(--help first^) ^<Arguments, e.g. --help^>
echo ipaddress Show the VirtualBox VM's IP address
echo node-allow-port-80 Let Node.js bind port 80 in the VM
echo start-wekan Start WeKan in the VirtualBox VM
echo stop-wekan Stop WeKan in the VirtualBox VM
goto :eof
:cli_run
set "K=%~1"
set "ARGS=%*"
call set "ARGS=%%ARGS:*%K%=%%"
set "CMD="
set "SHCMD="
if /I "%K%"=="release-all" (set "CMD=bash releases/release-all.sh" ^& goto cli_go)
if /I "%K%"=="release" (set "CMD=bash releases/release.sh" ^& goto cli_go)
if /I "%K%"=="version" (set "CMD=bash releases/version.sh" ^& goto cli_go)
if /I "%K%"=="changelog" (set "CMD=bash releases/changelog.sh" ^& goto cli_go)
if /I "%K%"=="rebuild-docs" (set "CMD=bash releases/rebuild-docs.sh" ^& goto cli_go)
if /I "%K%"=="rebuild-release" (set "CMD=bash releases/rebuild-release.sh" ^& goto cli_go)
if /I "%K%"=="rel" (set "CMD=bash releases/rel.sh" ^& goto cli_go)
if /I "%K%"=="release-bundle" (set "CMD=bash releases/release-bundle.sh" ^& goto cli_go)
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%"=="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)
if /I "%K%"=="clone-release-repos" (set "CMD=bash releases/clone-release-repos.sh" ^& goto cli_go)
if /I "%K%"=="create-github-secrets" (set "CMD=bash releases/create-github-secrets.sh" ^& goto cli_go)
if /I "%K%"=="add-tag" (set "CMD=bash releases/add-tag.sh" ^& goto cli_go)
if /I "%K%"=="delete-tag" (set "CMD=bash releases/delete-tag.sh" ^& goto cli_go)
if /I "%K%"=="stable-tag" (set "SHCMD=git tag --force stable HEAD && git push --tags --force && git push --follow-tags" ^& goto cli_go)
if /I "%K%"=="release-ondra-1" (set "CMD=bash releases/release-ondra-1.sh" ^& goto cli_go)
if /I "%K%"=="release-ondra-2" (set "CMD=bash releases/release-ondra-2.sh" ^& goto cli_go)
if /I "%K%"=="snap-build" (set "CMD=bash releases/snap-build.sh" ^& goto cli_go)
if /I "%K%"=="snap-install" (set "CMD=bash releases/snap-install.sh" ^& goto cli_go)
if /I "%K%"=="snap-push-to-store" (set "CMD=bash releases/snap-push-to-store.sh" ^& goto cli_go)
if /I "%K%"=="release-snap" (set "CMD=bash releases/release-snap.sh" ^& goto cli_go)
if /I "%K%"=="snap-store-revisions" (set "CMD=bash releases/snap-store-revisions.sh" ^& goto cli_go)
if /I "%K%"=="snap-store-release-revision-to-channels" (set "CMD=bash releases/snap-store-release-revision-to-channels.sh" ^& goto cli_go)
if /I "%K%"=="snap-release-all-channels" (set "CMD=bash releases/snap-release-all-channels.sh" ^& goto cli_go)
if /I "%K%"=="snap-edge" (set "CMD=bash releases/snap-edge.sh" ^& goto cli_go)
if /I "%K%"=="snap-stable" (set "CMD=bash releases/snap-stable.sh" ^& goto cli_go)
if /I "%K%"=="snapcraft-help" (set "CMD=bash releases/snapcraft-help.sh" ^& goto cli_go)
if /I "%K%"=="wekan-snap-help" (set "CMD=bash releases/wekan-snap-help.sh" ^& goto cli_go)
if /I "%K%"=="switch-kvm-snapcraft-waydroid-virtualbox" (set "CMD=bash releases/switch-kvm-snapcraft-waydroid-virtualbox.sh" ^& goto cli_go)
if /I "%K%"=="build-bundle-arm64" (set "CMD=bash releases/build-bundle-arm64.sh" ^& goto cli_go)
if /I "%K%"=="build-bundle-armhf" (set "CMD=bash releases/build-bundle-armhf.sh" ^& goto cli_go)
if /I "%K%"=="build-bundle-ppc64el" (set "CMD=bash releases/build-bundle-ppc64el.sh" ^& goto cli_go)
if /I "%K%"=="build-bundle-ppc64le" (set "CMD=bash releases/build-bundle-ppc64le.sh" ^& goto cli_go)
if /I "%K%"=="build-bundle-s390x" (set "CMD=bash releases/build-bundle-s390x.sh" ^& goto cli_go)
if /I "%K%"=="up" (set "CMD=bash releases/up.sh" ^& goto cli_go)
if /I "%K%"=="up-a" (set "CMD=bash releases/up-a.sh" ^& goto cli_go)
if /I "%K%"=="up-o" (set "CMD=bash releases/up-o.sh" ^& goto cli_go)
if /I "%K%"=="up-s" (set "CMD=bash releases/up-s.sh" ^& goto cli_go)
if /I "%K%"=="up-w" (set "CMD=bash releases/up-w.sh" ^& goto cli_go)
if /I "%K%"=="docker-build" (set "CMD=bash releases/docker-build.sh" ^& goto cli_go)
if /I "%K%"=="docker-build-deps" (set "CMD=bash releases/docker-build-deps.sh" ^& goto cli_go)
if /I "%K%"=="docker-publish-variant" (set "CMD=bash releases/docker-publish-variant.sh" ^& goto cli_go)
if /I "%K%"=="docker-push-gantt" (set "CMD=bash releases/docker-push-gantt.sh" ^& goto cli_go)
if /I "%K%"=="docker-registry-sync" (set "CMD=bash releases/docker-registry-sync.sh" ^& goto cli_go)
if /I "%K%"=="install-sandstorm" (set "CMD=bash releases/install-sandstorm.sh" ^& goto cli_go)
if /I "%K%"=="disable-sandstorm" (set "CMD=bash releases/disable-sandstorm.sh" ^& goto cli_go)
if /I "%K%"=="sandstorm-make-spk" (set "CMD=bash releases/sandstorm-make-spk.sh" ^& goto cli_go)
if /I "%K%"=="sandstorm-test-dev" (set "CMD=bash releases/sandstorm-test-dev.sh" ^& goto cli_go)
if /I "%K%"=="release-sandstorm" (set "CMD=bash releases/release-sandstorm.sh" ^& goto cli_go)
if /I "%K%"=="pull-translations" (set "CMD=bash releases/translations/pull-translations.sh" ^& goto cli_go)
if /I "%K%"=="fill-translations" (set "CMD=node releases/translations/fill-translations.mjs --missing" ^& goto cli_go)
if /I "%K%"=="push-translation" (set "CMD=bash releases/translations/push-translation.sh" ^& goto cli_go)
if /I "%K%"=="push-all-translations" (set "CMD=bash releases/translations/push-all-translations.sh" ^& goto cli_go)
if /I "%K%"=="push-english-base-translation" (set "CMD=bash releases/translations/push-english-base-translation.sh" ^& goto cli_go)
if /I "%K%"=="push-copy-en-gb-translation" (set "CMD=bash releases/translations/push-copy-en-gb-translation.sh" ^& goto cli_go)
if /I "%K%"=="report-english-regressions" (set "CMD=node releases/translations/report-english-regressions.mjs" ^& goto cli_go)
if /I "%K%"=="verify-human-preference" (set "CMD=node releases/translations/verify-human-preference.mjs" ^& goto cli_go)
if /I "%K%"=="merge-translations" (set "CMD=node releases/translations/merge-translations.mjs" ^& goto cli_go)
REM `git pull` and `git push` are shell functions in build.sh, not scripts in
REM releases/, so this answers to the same two names with its own labels - the
REM ones the interactive menu already uses.
if /I "%K%"=="git-pull" (goto gitpull)
if /I "%K%"=="git-push" (goto gitpush)
if /I "%K%"=="commit" (set "CMD=bash releases/commit.sh" ^& goto cli_go)
if /I "%K%"=="git-add-revert" (set "SHCMD=git restore --staged" ^& goto cli_go)
if /I "%K%"=="delete-branch-local-and-remote" (set "CMD=bash releases/delete-branch-local-and-remote.sh" ^& goto cli_go)
if /I "%K%"=="count-lines-of-code-per-committer" (set "CMD=bash releases/count-lines-of-code-per-committer.sh" ^& goto cli_go)
if /I "%K%"=="stylus-to-css" (set "CMD=bash releases/stylus-to-css.sh" ^& goto cli_go)
if /I "%K%"=="node-update" (set "CMD=bash releases/node-update.sh" ^& goto cli_go)
if /I "%K%"=="node-update-local" (set "CMD=bash releases/node-update-local.sh" ^& goto cli_go)
if /I "%K%"=="migrate-mongodb-to-ferretdb" (set "CMD=node releases/migrate-mongodb-to-ferretdb.mjs" ^& goto cli_go)
if /I "%K%"=="ipaddress" (set "CMD=bash releases/virtualbox/ipaddress.sh" ^& goto cli_go)
if /I "%K%"=="node-allow-port-80" (set "CMD=bash releases/virtualbox/node-allow-port-80.sh" ^& goto cli_go)
if /I "%K%"=="start-wekan" (set "CMD=bash releases/virtualbox/start-wekan.sh" ^& goto cli_go)
if /I "%K%"=="stop-wekan" (set "CMD=bash releases/virtualbox/stop-wekan.sh" ^& goto cli_go)
echo build.bat: no command called "%K%".
echo Try: build.bat --list
exit /b 2
:cli_go
call :need_bash
if errorlevel 1 exit /b 1
if not "!SHCMD!"=="" goto cli_go_sh
call !CMD! !ARGS!
exit /b %errorlevel%
:cli_go_sh
call bash -c "!SHCMD! !ARGS!"
exit /b %errorlevel%
REM ===========================================================================
:menu_cli
echo.
call :cli_help
echo.
echo Commands:
call :cli_list
pause
goto menu
REM ===========================================================================
:menu_docker
echo.
@@ -254,50 +811,32 @@ echo Done.
goto end
REM ===========================================================================
REM Make the working copy current in one step: fetch + rebase the current branch
REM onto its upstream, repoint any CHANGELOG commit links the rebase made stale
REM (the same shared releases\fix-changelog-hashes.sh release-all.sh uses), and
REM show the status. Parity with build.sh's Setup -> "Update git ...".
:updategit
echo Updating git: fetch + rebase onto origin, fix CHANGELOG hashes, show status.
git rev-parse --git-dir >nul 2>&1
if errorlevel 1 ( echo Not a git repository. & goto end )
set "BRANCH="
for /f "delims=" %%b in ('git rev-parse --abbrev-ref HEAD 2^>nul') do set "BRANCH=%%b"
echo Branch: %BRANCH%
echo (A dirty tree is auto-stashed for the rebase and re-applied after.)
echo --- git fetch --all --prune ---
git fetch --all --prune
git rev-parse --verify --quiet "origin/%BRANCH%" >nul 2>&1
if errorlevel 1 (
echo No upstream origin/%BRANCH% - skipping rebase.
) else (
echo --- git pull --rebase --autostash origin %BRANCH% ---
git pull --rebase --autostash origin "%BRANCH%"
if errorlevel 1 (
echo.
echo Rebase stopped ^(conflicts^). Resolve them, then:
echo git rebase --continue ^(or: git rebase --abort^)
echo and run this option again to finish the hash fix + status.
goto end
)
)
echo --- Fixing CHANGELOG commit links ^(releases\fix-changelog-hashes.sh^) ---
:gitpull
REM Both git actions are build.sh's git_pull / git_push, run through bash rather
REM than written a second time here. What they do - fast-forward or rebase,
REM repoint the CHANGELOG links a rebase moved, abort a conflicting rebase so the
REM repo is left exactly as it was, pull-and-retry once when a push is rejected -
REM is shell logic, and the batch copy of the old "Update git" that used to live
REM here is why this file drifted: it reimplemented the same steps, so a fix to
REM one never reached the other.
where bash >nul 2>&1
if errorlevel 1 (
echo bash not found - the CHANGELOG hash fix needs Git Bash, bundled with Git for Windows.
echo Skipping it. Run it yourself in Git Bash: bash releases/fix-changelog-hashes.sh
) else (
bash releases/fix-changelog-hashes.sh
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
goto end
)
echo --- git status ---
git status
echo.
echo NOTE: if CHANGELOG.md was changed above, review 'git diff CHANGELOG.md' and
echo commit it - this script never commits for you.
bash ./build.sh git-pull
goto end
REM ===========================================================================
:gitpush
where bash >nul 2>&1
if errorlevel 1 (
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
goto end
)
bash ./build.sh git-push
goto end
:dev_local
call :ensure_dirs
call :set_dev_env
@@ -403,8 +942,10 @@ call :rebuild_for_tests
if errorlevel 1 goto end
set "FAILED=0"
set "S_mocha=RUN" & set "S_unit=RUN" & set "S_import=RUN" & set "S_e2e=RUN" & set "S_browsers=RUN"
set "C_mocha=0" & set "C_unit=0" & set "C_import=0" & set "C_e2e=0" & set "C_browsers=0"
set "S_mocha=RUN" & set "S_unit=RUN" & set "S_import=RUN" & set "S_e2e=RUN"
set "S_chromium=RUN" & set "S_firefox=RUN" & set "S_webkit=RUN"
set "C_mocha=0" & set "C_unit=0" & set "C_import=0" & set "C_e2e=0"
set "C_chromium=0" & set "C_firefox=0" & set "C_webkit=0"
REM Each run gets its own ..\log\<timestamp>\ dir (stamped once at run start), so
REM logs are never overwritten and previous runs are kept. PowerShell gives a
REM locale-independent yyyy-MM-dd_HH-mm-ss; %RUN_LOGDIR% is absolute so it works
@@ -414,7 +955,7 @@ set "RUN_LOGDIR=%REPO%\..\log\%RUN_TS%"
if not exist "%RUN_LOGDIR%" md "%RUN_LOGDIR%"
echo Logs for this run: %RUN_LOGDIR%\ - previous runs are kept
REM Clear completion flags from any previous run.
del /q ".done-mocha" ".done-unit" ".done-import" ".done-e2e" ".done-browsers" 2>nul
del /q ".done-mocha" ".done-unit" ".done-import" ".done-e2e" ".done-chromium" ".done-firefox" ".done-webkit" 2>nul
REM Start the :3000 server FIRST and let it build alone. Mocha runs its own
REM Meteor build (.meteor\local-test); launching it here would make two full
@@ -436,11 +977,19 @@ start "Wekan import" /MIN /D "%REPO%" cmd /c "(echo ===== Import regression [no
if "!SERVER_READY!"=="0" (
echo FAIL: server did not become ready on http://localhost:3000 ^(see %RUN_LOGDIR%\wekan-test-server.log^)
set "S_e2e=SKIP" & set "S_browsers=SKIP" & set "FAILED=1"
set "S_e2e=SKIP" & set "S_chromium=SKIP" & set "S_firefox=SKIP" & set "S_webkit=SKIP" & set "FAILED=1"
) else (
echo ==^> Server is up: starting Node E2E and Playwright ^(Chromium + Firefox + WebKit, --workers=3^) in parallel.
echo ==^> Server is up: starting Node E2E and Playwright ^(Chromium, Firefox and WebKit as three separate jobs^) in parallel.
start "Wekan e2e" /MIN /D "%REPO%" cmd /c "(echo ===== Node E2E [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-e2e.log 2>&1 & call meteor npm run test:e2e 1>>%RUN_LOGDIR%\wekan-alltests-e2e.log 2>&1 & if errorlevel 1 (echo FAIL>.done-e2e) else (echo PASS>.done-e2e)"
start "Wekan browsers" /MIN /D "%REPO%\tests\playwright" cmd /c "set WEKAN_PLAYWRIGHT_ALL=1&& (echo ===== Playwright browsers [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-browsers.log 2>&1 & call meteor npm exec playwright test -- --project=chromium --project=firefox --project=webkit --workers=3 --reporter=list 1>>%RUN_LOGDIR%\wekan-alltests-browsers.log 2>&1 & if errorlevel 1 (echo FAIL>..\..\.done-browsers) else (echo PASS>..\..\.done-browsers)"
REM One job, one log, one summary row PER BROWSER - the same shape build.sh
REM produces. A single combined "browsers" job wrote one wekan-alltests-browsers.log
REM for all three, so "which browser failed, and what did WebKit print" could not
REM be answered from the logs, and CLAUDE.md's "check the newest test logs" names
REM the per-browser files. --output keeps each browser's artifacts in its own
REM directory, or the three would clear each other's at startup.
call :start_browser_job chromium
call :start_browser_job firefox
call :start_browser_job webkit
)
REM Live progress: re-print a status line every ~3s until all expected jobs
@@ -455,18 +1004,25 @@ call :jcount C_unit unit "%RUN_LOGDIR%\wekan-alltests-unit.log"
call :jcount C_import check "%RUN_LOGDIR%\wekan-alltests-import.log"
if "!SERVER_READY!"=="1" (
call :jstate e2e
call :jstate browsers
call :jstate chromium
call :jstate firefox
call :jstate webkit
call :jcount C_e2e e2e "%RUN_LOGDIR%\wekan-alltests-e2e.log"
call :jcount C_browsers check "%RUN_LOGDIR%\wekan-alltests-browsers.log"
call :jcount C_chromium check "%RUN_LOGDIR%\wekan-alltests-chromium.log"
call :jcount C_firefox check "%RUN_LOGDIR%\wekan-alltests-firefox.log"
call :jcount C_webkit check "%RUN_LOGDIR%\wekan-alltests-webkit.log"
)
echo mocha [M2 :3100/db:3101] !S_mocha! tests:!C_mocha! ^| unit [no server] !S_unit! tests:!C_unit! ^| import [no server] !S_import! tests:!C_import! ^| e2e [M1 :3000/db:3001] !S_e2e! tests:!C_e2e! ^| browsers [M1 :3000/db:3001] !S_browsers! tests:!C_browsers!
echo mocha [M2 :3100/db:3101] !S_mocha! tests:!C_mocha! ^| unit [no server] !S_unit! tests:!C_unit! ^| import [no server] !S_import! tests:!C_import! ^| e2e [M1 :3000/db:3001] !S_e2e! tests:!C_e2e!
echo chromium !S_chromium! tests:!C_chromium! ^| firefox !S_firefox! tests:!C_firefox! ^| webkit !S_webkit! tests:!C_webkit! [all three on M1 :3000/db:3001]
set "ALLDONE=1"
if not exist ".done-mocha" set "ALLDONE=0"
if not exist ".done-unit" set "ALLDONE=0"
if not exist ".done-import" set "ALLDONE=0"
if "!SERVER_READY!"=="1" (
if not exist ".done-e2e" set "ALLDONE=0"
if not exist ".done-browsers" set "ALLDONE=0"
if not exist ".done-chromium" set "ALLDONE=0"
if not exist ".done-firefox" set "ALLDONE=0"
if not exist ".done-webkit" set "ALLDONE=0"
)
if "!ALLDONE!"=="0" (
ping -n 4 127.0.0.1 >nul
@@ -483,7 +1039,9 @@ if "!S_mocha!"=="FAIL" set "FAILED=1"
if "!S_unit!"=="FAIL" set "FAILED=1"
if "!S_import!"=="FAIL" set "FAILED=1"
if "!S_e2e!"=="FAIL" set "FAILED=1"
if "!S_browsers!"=="FAIL" set "FAILED=1"
if "!S_chromium!"=="FAIL" set "FAILED=1"
if "!S_firefox!"=="FAIL" set "FAILED=1"
if "!S_webkit!"=="FAIL" set "FAILED=1"
echo.
echo ==================== TEST SUMMARY ====================
@@ -492,9 +1050,11 @@ call :report "!S_unit!" "Unit tests (node)" "[no server]
call :report "!S_import!" "Import regression" "[no server] tests:!C_import!"
if "!SERVER_READY!"=="1" ( call :report "PASS" "Server startup" "[M1 :3000/db:3001]" ) else ( call :report "FAIL" "Server startup" "[M1 :3000/db:3001]" )
call :report "!S_e2e!" "Node E2E regressions" "[M1 :3000/db:3001] tests:!C_e2e!"
call :report "!S_browsers!" "Playwright (Chromium+Firefox+WebKit)" "[M1 :3000/db:3001] tests:!C_browsers!"
call :report "!S_chromium!" "Playwright Chromium" "[M1 :3000/db:3001] tests:!C_chromium!"
call :report "!S_firefox!" "Playwright Firefox" "[M1 :3000/db:3001] tests:!C_firefox!"
call :report "!S_webkit!" "Playwright WebKit" "[M1 :3000/db:3001] tests:!C_webkit!"
echo =====================================================
echo (per-job logs in: %RUN_LOGDIR%\ as wekan-alltests-^<mocha^|unit^|import^|e2e^|browsers^>.log and wekan-test-server.log)
echo (per-job logs in: %RUN_LOGDIR%\ as wekan-alltests-^<mocha^|unit^|import^|e2e^|chromium^|firefox^|webkit^>.log and wekan-test-server.log)
if "!FAILED!"=="0" ( echo RESULT: All tests passed. ) else ( echo RESULT: Some tests FAILED ^(see details above^). )
goto end
@@ -520,8 +1080,10 @@ call :rebuild_for_tests
if errorlevel 1 goto end
set "FAILED=0"
set "S_mocha=RUN" & set "S_unit=RUN" & set "S_import=RUN" & set "S_e2e=RUN" & set "S_browsers=RUN"
set "C_mocha=0" & set "C_unit=0" & set "C_import=0" & set "C_e2e=0" & set "C_browsers=0"
set "S_mocha=RUN" & set "S_unit=RUN" & set "S_import=RUN" & set "S_e2e=RUN"
set "S_chromium=RUN" & set "S_firefox=RUN" & set "S_webkit=RUN"
set "C_mocha=0" & set "C_unit=0" & set "C_import=0" & set "C_e2e=0"
set "C_chromium=0" & set "C_firefox=0" & set "C_webkit=0"
REM Each run gets its own ..\log\<timestamp>\ dir (stamped once at run start), so
REM logs are never overwritten and previous runs are kept. PowerShell gives a
REM locale-independent yyyy-MM-dd_HH-mm-ss; %RUN_LOGDIR% is absolute so it works
@@ -531,7 +1093,7 @@ set "RUN_LOGDIR=%REPO%\..\log\%RUN_TS%"
if not exist "%RUN_LOGDIR%" md "%RUN_LOGDIR%"
echo Logs for this run: %RUN_LOGDIR%\ - previous runs are kept
REM Clear completion flags from any previous run.
del /q ".done-mocha" ".done-unit" ".done-import" ".done-e2e" ".done-browsers" 2>nul
del /q ".done-mocha" ".done-unit" ".done-import" ".done-e2e" ".done-chromium" ".done-firefox" ".done-webkit" 2>nul
REM Start the :3000 server FIRST and let it build alone. Mocha runs its own
REM Meteor build (.meteor\local-test); launching it here would make two full
@@ -570,15 +1132,23 @@ echo ==^> Running Node E2E regressions on Meteor #1 [Node.js :3000, MongoDB :300
start "Wekan e2e" /MIN /D "%REPO%" cmd /c "(echo ===== Node E2E [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-e2e.log 2>&1 & call meteor npm run test:e2e 1>>%RUN_LOGDIR%\wekan-alltests-e2e.log 2>&1 & if errorlevel 1 (echo FAIL>.done-e2e) else (echo PASS>.done-e2e)"
call :seq_run_wait e2e e2e C_e2e "%RUN_LOGDIR%\wekan-alltests-e2e.log"
echo ==^> Running Playwright Chromium, Firefox and WebKit one at a time ^(--workers=1^) on Meteor #1 [Node.js :3000, MongoDB :3001]. Full log: %RUN_LOGDIR%\wekan-alltests-browsers.log
start "Wekan browsers" /MIN /D "%REPO%\tests\playwright" cmd /c "set WEKAN_PLAYWRIGHT_ALL=1&& (echo ===== Playwright browsers [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-browsers.log 2>&1 & call :onelog playwright-all
call meteor npm exec playwright test -- --project=chromium --project=firefox --project=webkit --workers=1 --reporter=list 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%ONELOG%'" 1>>%RUN_LOGDIR%\wekan-alltests-browsers.log 2>&1 & if errorlevel 1 (echo FAIL>..\..\.done-browsers) else (echo PASS>..\..\.done-browsers)"
call :seq_run_wait browsers check C_browsers "%RUN_LOGDIR%\wekan-alltests-browsers.log"
echo ==^> Running Playwright Chromium, Firefox and WebKit one browser at a time on Meteor #1 [Node.js :3000, MongoDB :3001].
REM One job, one log and one summary row per browser, as build.sh does. The
REM combined run wrote a single wekan-alltests-browsers.log for all three, so
REM neither "which browser failed" nor "what did WebKit print" was answerable
REM from the logs afterwards - and CLAUDE.md's "check the newest test logs"
REM names wekan-alltests-chromium.log, -firefox.log and -webkit.log.
call :start_browser_job chromium
call :seq_run_wait chromium check C_chromium "%RUN_LOGDIR%\wekan-alltests-chromium.log"
call :start_browser_job firefox
call :seq_run_wait firefox check C_firefox "%RUN_LOGDIR%\wekan-alltests-firefox.log"
call :start_browser_job webkit
call :seq_run_wait webkit check C_webkit "%RUN_LOGDIR%\wekan-alltests-webkit.log"
goto server_jobs_done
:skip_server_jobs
echo FAIL: server did not become ready on http://localhost:3000 ^(see %RUN_LOGDIR%\wekan-test-server.log^)
set "S_e2e=SKIP" & set "S_browsers=SKIP" & set "FAILED=1"
set "S_e2e=SKIP" & set "S_chromium=SKIP" & set "S_firefox=SKIP" & set "S_webkit=SKIP" & set "FAILED=1"
:server_jobs_done
@@ -592,14 +1162,18 @@ if "!S_mocha!"=="FAIL" set "FAILED=1"
if "!S_unit!"=="FAIL" set "FAILED=1"
if "!S_import!"=="FAIL" set "FAILED=1"
if "!S_e2e!"=="FAIL" set "FAILED=1"
if "!S_browsers!"=="FAIL" set "FAILED=1"
if "!S_chromium!"=="FAIL" set "FAILED=1"
if "!S_firefox!"=="FAIL" set "FAILED=1"
if "!S_webkit!"=="FAIL" set "FAILED=1"
REM Count passing tests per job from each log (advances shown in the summary).
call :jcount C_mocha check "%RUN_LOGDIR%\wekan-alltests-mocha.log"
call :jcount C_unit unit "%RUN_LOGDIR%\wekan-alltests-unit.log"
call :jcount C_import check "%RUN_LOGDIR%\wekan-alltests-import.log"
call :jcount C_e2e e2e "%RUN_LOGDIR%\wekan-alltests-e2e.log"
call :jcount C_browsers check "%RUN_LOGDIR%\wekan-alltests-browsers.log"
call :jcount C_chromium check "%RUN_LOGDIR%\wekan-alltests-chromium.log"
call :jcount C_firefox check "%RUN_LOGDIR%\wekan-alltests-firefox.log"
call :jcount C_webkit check "%RUN_LOGDIR%\wekan-alltests-webkit.log"
echo.
echo ==================== TEST SUMMARY ====================
@@ -608,9 +1182,11 @@ call :report "!S_unit!" "Unit tests (node)" "[no server]
call :report "!S_import!" "Import regression" "[no server] tests:!C_import!"
if "!SERVER_READY!"=="1" ( call :report "PASS" "Server startup" "[M1 :3000/db:3001]" ) else ( call :report "FAIL" "Server startup" "[M1 :3000/db:3001]" )
call :report "!S_e2e!" "Node E2E regressions" "[M1 :3000/db:3001] tests:!C_e2e!"
call :report "!S_browsers!" "Playwright (Chromium+Firefox+WebKit)" "[M1 :3000/db:3001] tests:!C_browsers!"
call :report "!S_chromium!" "Playwright Chromium" "[M1 :3000/db:3001] tests:!C_chromium!"
call :report "!S_firefox!" "Playwright Firefox" "[M1 :3000/db:3001] tests:!C_firefox!"
call :report "!S_webkit!" "Playwright WebKit" "[M1 :3000/db:3001] tests:!C_webkit!"
echo =====================================================
echo (per-job logs in: %RUN_LOGDIR%\ as wekan-alltests-^<mocha^|unit^|import^|e2e^|browsers^>.log and wekan-test-server.log)
echo (per-job logs in: %RUN_LOGDIR%\ as wekan-alltests-^<mocha^|unit^|import^|e2e^|chromium^|firefox^|webkit^>.log and wekan-test-server.log)
if "!FAILED!"=="0" ( echo RESULT: All tests passed. ) else ( echo RESULT: Some tests FAILED ^(see details above^). )
goto end
@@ -706,17 +1282,34 @@ if /i "%INSTALL_DEPS%"=="y" (
popd
)
REM All three browsers run natively on Windows. Use --workers=1 so a single
REM Playwright run executes Chromium, Firefox and WebKit tests one at a time
REM (sequentially) rather than concurrently: running all three at once uses too
REM much RAM/swap and can crash the machine. WEKAN_PLAYWRIGHT_ALL=1 enables all
REM projects.
cd /d "%REPO%\tests\playwright"
set "WEKAN_PLAYWRIGHT_ALL=1"
call meteor npm exec playwright test -- --project=chromium --project=firefox --project=webkit --workers=1 --reporter=list
if errorlevel 1 ( echo RESULT: Some Playwright browsers FAILED ^(see details above^). ) else ( echo RESULT: All Playwright browsers passed. )
REM One browser at a time, each with its own log - the same as build.sh's
REM run_playwright_parallel, and the same per-browser logs the whole-suite runs
REM write. Running all three concurrently against one dev server uses too much
REM RAM/swap on smaller machines and can crash it, so this stays sequential.
REM
REM It used to be a single `playwright test --project=... --project=...` call
REM with no log at all: everything went to the terminal and there was nothing
REM left to read afterwards, which is what tests/dbConformanceWiring.test.cjs
REM means by "every Tests option writes its log to ../log/<datetime>/".
set "PW_ALL_FAILED=0"
for %%B in (chromium firefox webkit) do call :pw_one_browser %%B
if "%PW_ALL_FAILED%"=="1" ( echo RESULT: Some Playwright browsers FAILED ^(see the logs above^). ) else ( echo RESULT: All Playwright browsers passed. )
goto end
:pw_one_browser
REM One browser of the "ALL browsers" option: %1 = chromium^|firefox^|webkit.
REM Streams live AND writes wekan-playwright-^<browser^>.log, via the same
REM :onelog helper every other Tests option uses.
set "PW_PROJECT=%~1"
call :onelog playwright-%PW_PROJECT%
echo ==^> Playwright %PW_PROJECT% ^(one browser at a time^). Log: %ONELOG%
pushd "%REPO%\tests\playwright"
set "WEKAN_PLAYWRIGHT_ALL=1"
call meteor npm exec playwright test -- --project=%PW_PROJECT% --output=test-results\%PW_PROJECT% --reporter=list 2>&1 | powershell -NoProfile -Command "$input | Tee-Object -FilePath '%ONELOG%'"
if errorlevel 1 set "PW_ALL_FAILED=1"
popd
exit /b 0
REM ===========================================================================
:check_floating
echo Ensuring missing ESLint dependencies for the no-floating-promises rule.
@@ -1178,6 +1771,25 @@ REM 6) Wait for :3000 to answer (bundle boots in seconds; curl-timeout poll).
call :wait_server_ready
exit /b 0
:start_browser_job
REM Start ONE Playwright browser as its own job: %1 = chromium^|firefox^|webkit.
REM
REM One job, one log, one .done flag and one summary row per browser - the same
REM shape build.sh's run_pw_all_browser produces, and what CLAUDE.md means by the
REM per-browser logs in log\^<datetime^>\. The combined three-project run this
REM replaced wrote everything into one wekan-alltests-browsers.log, where a
REM failure could not be attributed to a browser without reading the whole file.
REM
REM --output is per browser as well: Playwright CLEARS its output directory at
REM startup, so three jobs sharing test-results\ would delete each other's traces
REM and screenshots - exactly the artifacts wanted after a failure. The job runs
REM in tests\playwright, so the .done flag is written two levels up, in the repo
REM root, where the wait loops look for it.
if "%~1"=="" exit /b 1
echo ==^> Playwright %~1 on Meteor #1 [Node.js :3000, MongoDB :3001]. Log: %RUN_LOGDIR%\wekan-alltests-%~1.log
start "Wekan %~1" /MIN /D "%REPO%\tests\playwright" cmd /c "set WEKAN_PLAYWRIGHT_ALL=1&& (echo ===== Playwright %~1 [M1 node:3000 db:3001] test run: %DATE% %TIME% =====) 1>%RUN_LOGDIR%\wekan-alltests-%~1.log 2>&1 & call meteor npm exec playwright test -- --project=%~1 --output=test-results\%~1 --reporter=list 1>>%RUN_LOGDIR%\wekan-alltests-%~1.log 2>&1 & if errorlevel 1 (echo FAIL>..\..\.done-%~1) else (echo PASS>..\..\.done-%~1)"
exit /b 0
:seq_run_wait
REM Live progress for a sequential job that runs in its own minimized window and
REM writes .done-<key> when finished. %1=key %2=count kind (check^|e2e) %3=count var
@@ -1215,8 +1827,8 @@ exit /b 0
REM ===========================================================================
:test_all_databases
REM The same thing build.sh's "All databases (sequential)" runs: build the newest
REM FerretDB v1 from the FerretDB subdirectory (cloning wekan/FerretDB if it is
REM not there, and installing Go and the module dependencies if they are
REM FerretDB v1 from .tools\FerretDB (cloning wekan/FerretDB there if it is not
REM there, and installing Go and the module dependencies if they are
REM missing), then run the whole FerretDB v1 query catalogue against every
REM database that has a Docker image for THIS CPU - one at a time, because they
REM all use the same FerretDB port - and compare that they all answered the same.
@@ -1236,9 +1848,13 @@ goto end
REM ===========================================================================
:test_ferretdb
REM All of FerretDB's own tests, one at a time: unit, vet, integration. FerretDB
REM is expected to be a subdirectory of this repo - the "All databases" option
REM clones it if it is not there. Its build.sh installs Go and the Go modules
REM All of FerretDB's own tests, one at a time: unit, vet, integration.
REM
REM FerretDB lives in .tools\FerretDB - companion repos are kept in one directory
REM that .gitignore and .meteorignore already exclude, instead of one ignored
REM subdirectory each at the repo root. It is cloned here when it is not there,
REM the same as build.sh's ensure_tool_repo does, so neither script depends on
REM the other having been run first. Its build.sh installs Go and the Go modules
REM when they are missing, and writes its logs to ..\log\<datetime>\ with every
REM other test run's.
where bash >nul 2>&1
@@ -1246,13 +1862,27 @@ if errorlevel 1 (
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
goto end
)
if not exist "FerretDB\build.sh" (
echo FerretDB\build.sh is missing. Clone it first:
echo git clone git@github.com:wekan/FerretDB
echo ^(or run Tests - All databases, which clones it before building.^)
if not exist "%REPO%\.tools\FerretDB\build.sh" (
where git >nul 2>&1
if errorlevel 1 (
echo ERROR: git was not found, so .tools\FerretDB cannot be cloned.
echo Install Git for Windows, or clone it by hand:
echo git clone git@github.com:wekan/FerretDB .tools/FerretDB
goto end
)
if not exist "%REPO%\.tools" md "%REPO%\.tools"
echo ==^> FerretDB is not in .tools\ yet; cloning wekan/FerretDB
git clone git@github.com:wekan/FerretDB "%REPO%\.tools\FerretDB"
if errorlevel 1 (
echo ==^> SSH clone failed ^(no key for github.com?^); trying HTTPS.
git clone https://github.com/wekan/FerretDB "%REPO%\.tools\FerretDB"
)
)
if not exist "%REPO%\.tools\FerretDB\build.sh" (
echo ERROR: .tools\FerretDB\build.sh is still missing after cloning.
goto end
)
bash -c "cd FerretDB && ./build.sh test-all"
bash -c "cd .tools/FerretDB && ./build.sh test-all"
goto end
REM ===========================================================================
+842 -162
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,105 @@
//- The All Boards right sidebar.
//-
//- The board sidebar (client/components/sidebar/) is built around a board - its
//- members, labels, activities and settings - and All Boards has no board, so
//- this is its own. What it borrows is the SHELL: the same
//- `.board-sidebar.sidebar` classes, the same `.sidebar-actions` row with the ✕,
//- the same `.sidebar-content` with a title and a back arrow above the view. So
//- it looks and behaves like the one on a board without pretending to be it.
//-
//- docs/Design/Page/Search.md, docs/Design/Page/Multi-Selection.md
template(name="allBoardsSidebar")
//- The theme class goes on an ANCESTOR of `.sidebar`, never on it: every
themed sidebar rule is a DESCENDANT selector -
`.board-color-belize .sidebar .sidebar-content .sidebar-btn` - so a class on
the sidebar element itself matches nothing at all, which is how the first
attempt at this left the buttons white on light grey anyway. On a board the
class sits on a container around the sidebar; this is that container.
It wraps ONLY the sidebar, so the theme cannot bleed into the board icons
behind it. It is out of flow (the sidebar inside is fixed), so it costs no
layout.
.all-boards-sidebar-theme(class="{{themeClass}}")
.board-sidebar.sidebar.all-boards-sidebar(class="{{#if isSidebarOpen}}is-open{{/if}}")
.sidebar-actions
a.sidebar-xmark.js-close-all-boards-sidebar(aria-label="{{_ 'close'}}") &#10005;
.sidebar-content.js-all-boards-sidebar-content
//- A title and a back arrow on every view EXCEPT home, which is where
back would go - exactly as the board sidebar does it.
if sidebarViewTitle
h2
a.fa.fa-arrow-left.js-all-boards-sidebar-home(aria-label="{{_ 'go-back'}}")
= sidebarViewTitle
if isSidebarOpen
+Template.dynamic(template=sidebarViewTemplate)
//- What the hamburger opens: the page's own menu, the counterpart of
//- `homeSidebar` on a board.
template(name="allBoardsHomeSidebar")
//- Sort, which was a button in this page's second header bar. The VIEW menu
was too, and it is in the first top header bar instead - a view menu says
what you are looking at, which belongs beside the page's name rather than
behind a panel you have to open. Search and Multi-Selection were in that bar
as well and are NOT repeated here: this view has offered both as rows since
it was written. docs/Design/Page/All-Boards.md
a.sidebar-btn.js-open-boards-sort(class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
i.fa.fa-sort
span {{_ 'sort-boards'}}
hr
ul.sidebar-list
li
a.js-all-boards-sidebar-search
i.fa.fa-search
span.sidebar-list-item-description {{_ 'search-boards'}}
if canModifyBoards
li
a.js-all-boards-sidebar-multiselection
i.fa.fa-check-square-o
span.sidebar-list-item-description {{_ 'multi-selection'}}
hr
//- Archived boards had a handler in the header bar and no button to reach it.
a.sidebar-btn.js-open-archived-board
i.fa.fa-archive
span {{_ 'archived-boards'}}
template(name="allBoardsSearchSidebar")
//- The SAME term the page filters by, so the board list behind the sidebar
narrows as you type - which is what the search field in the header bar did
before this became a sidebar view.
input.js-board-search-input(
type="text"
aria-label="{{_ 'search-boards'}}"
value="{{boardSearch}}"
autofocus
autocomplete="off"
dir="auto")
if boardSearch
a.sidebar-btn.js-board-search-clear
i.fa.fa-times
span {{_ 'filter-clear'}}
//- The actions on a selection, as sidebar rows - the same place a board puts
//- them. They were four icons crowded into the header bar; here each one has
//- its name beside it.
template(name="allBoardsMultiSelectionSidebar")
if hasBoardsSelected
a.sidebar-btn.js-star-selected
i.fa.fa-star
span {{selectedStarTitle}}
a.sidebar-btn.js-home-selected
i.fa.fa-home
span {{_ 'set-selected-home'}}
hr
a.sidebar-btn.js-archive-selected-boards
i.fa.fa-archive
span {{_ 'archive-board'}}
a.sidebar-btn.js-duplicate-selected-boards
i.fa.fa-clipboard
span {{_ 'duplicate-board'}}
hr
a.sidebar-btn.js-multiselection-reset
i.fa.fa-times-thin
span {{_ 'multi-selection-off'}}
else
p.sidebar-multiselection-hint {{_ 'multi-selection-active'}}
@@ -0,0 +1,211 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
const { allBoardsPath, SECTION_ARCHIVE } = require('/models/lib/allBoardsUrls');
import { TAPi18n } from '/imports/i18n';
import { BoardMultiSelection } from '/client/lib/boardMultiSelection';
import { allBoardsSearchVar } from '/client/lib/allBoardsView';
import {
allBoardsSidebarView,
isAllBoardsSidebarOpen,
openAllBoardsSidebar,
closeAllBoardsSidebar,
} from '/client/lib/allBoardsSidebar';
import {
SIDEBAR_HOME,
SIDEBAR_SEARCH,
SIDEBAR_MULTISELECTION,
sidebarViewTitleKey,
sidebarViewTemplate,
} from '/models/lib/allBoardsSidebar';
import { selectedStarTitleKey, selectedStarAction } from '/models/lib/selectedStars';
// The All Boards right sidebar. Its shell mirrors the board sidebar's; what it
// shows is this page's own. docs/Design/Page/Search.md,
// docs/Design/Page/Multi-Selection.md
Template.allBoardsSidebar.helpers({
isSidebarOpen() {
return isAllBoardsSidebarOpen();
},
// The theme the panel is painted with.
//
// `.sidebar .sidebar-content .sidebar-btn` is a light grey box whose text is
// WHITE; what makes that readable on a board is a `.board-color-*` ancestor
// replacing the grey with a themed colour. This page has no board, so without
// a class here every button was white on light grey - unreadable, which is
// exactly what it looked like.
//
// `board-color-belize` is the established default for a themed thing outside
// a board: client/components/main/globalSearch.js falls back to the same one.
themeClass() {
return 'board-color-belize';
},
sidebarViewTemplate() {
return sidebarViewTemplate(allBoardsSidebarView());
},
// Home has no title and no back arrow: there is nothing behind it.
sidebarViewTitle() {
const key = sidebarViewTitleKey(allBoardsSidebarView());
return key ? TAPi18n.__(key) : null;
},
});
Template.allBoardsSidebar.events({
'click .js-close-all-boards-sidebar'(evt) {
evt.preventDefault();
closeAllBoardsSidebar();
},
'click .js-all-boards-sidebar-home'(evt) {
evt.preventDefault();
openAllBoardsSidebar(SIDEBAR_HOME);
},
});
Template.allBoardsHomeSidebar.helpers({
canModifyBoards() {
const currentUser = ReactiveCache.getCurrentUser();
return currentUser && !currentUser.isCommentOnly();
},
});
Template.allBoardsHomeSidebar.events({
'click .js-all-boards-sidebar-search'(evt) {
evt.preventDefault();
openAllBoardsSidebar(SIDEBAR_SEARCH);
},
'click .js-all-boards-sidebar-multiselection'(evt) {
evt.preventDefault();
BoardMultiSelection.activate();
openAllBoardsSidebar(SIDEBAR_MULTISELECTION);
},
// Boards in Archive is a page. This row had no handler at all: the one it
// relied on lived in the header bar's events map and went when that map was
// rewritten, so the button did nothing.
'click .js-open-archived-board'(evt) {
evt.preventDefault();
closeAllBoardsSidebar();
// The SECTION of this page, not the full-width page it replaced.
// docs/Design/Page/Archive.md
FlowRouter.go(allBoardsPath(SECTION_ARCHIVE, []));
},
});
Template.allBoardsSearchSidebar.helpers({
boardSearch() {
return allBoardsSearchVar.get();
},
});
// The search term is the page's, not this template's: the board list behind the
// sidebar filters as you type, which is what the header bar's search field did
// before it became a sidebar view.
Template.allBoardsSearchSidebar.events({
'input .js-board-search-input'(evt) {
allBoardsSearchVar.set(evt.currentTarget.value);
},
'keydown .js-board-search-input'(evt) {
if (evt.key === 'Escape') {
// Clear first, close second: Escape on a search box that has text in it
// means "undo the search", and only an already-empty box falls through to
// the sidebar's own Escape.
if (allBoardsSearchVar.get()) {
evt.preventDefault();
evt.stopPropagation();
allBoardsSearchVar.set('');
evt.currentTarget.value = '';
}
}
},
'click .js-board-search-clear'(evt, tpl) {
evt.preventDefault();
allBoardsSearchVar.set('');
const input = tpl.find('.js-board-search-input');
if (input) {
input.value = '';
input.focus();
}
},
});
// What the "Selected: ★" row would do if clicked right now, and to which
// boards. The same function the header bar used - see models/lib/selectedStars.
function currentSelectedStarAction() {
const user = ReactiveCache.getCurrentUser();
return selectedStarAction(BoardMultiSelection.getSelectedBoardIds(), id =>
Boolean(user && user.hasStarred(id)),
);
}
Template.allBoardsMultiSelectionSidebar.helpers({
hasBoardsSelected() {
return BoardMultiSelection.count() > 0;
},
selectedStarTitle() {
return TAPi18n.__(selectedStarTitleKey(currentSelectedStarAction().action));
},
});
Template.allBoardsMultiSelectionSidebar.events({
// Only the boards that must CHANGE are toggled: toggleBoardStar flips one
// board, so calling it for an already-starred one in the mixed case would
// un-star it.
'click .js-star-selected'(evt) {
evt.preventDefault();
currentSelectedStarAction().boardIds.forEach((id) => {
Meteor.call('toggleBoardStar', id);
});
},
'click .js-home-selected'(evt) {
evt.preventDefault();
const ids = BoardMultiSelection.getSelectedBoardIds();
if (ids.length) {
Meteor.call('toggleDefaultBoard', ids[0]);
}
},
'click .js-archive-selected-boards'(evt) {
evt.preventDefault();
const selectedBoards = BoardMultiSelection.getSelectedBoardIds();
if (
selectedBoards.length > 0 &&
confirm(TAPi18n.__('archive-board-confirm'))
) {
selectedBoards.forEach((boardId) => {
Meteor.call('archiveBoard', boardId, (err) => {
if (err) alert(err?.reason || err?.message || 'Failed to archive board');
});
});
BoardMultiSelection.reset();
}
},
'click .js-duplicate-selected-boards'(evt) {
evt.preventDefault();
const selectedBoards = BoardMultiSelection.getSelectedBoardIds();
if (
selectedBoards.length > 0 &&
confirm(TAPi18n.__('duplicate-board-confirm'))
) {
selectedBoards.forEach((boardId) => {
const board = ReactiveCache.getBoard(boardId);
if (board) {
Meteor.call(
'copyBoard',
boardId,
{
sort: ReactiveCache.getBoards({ archived: false }).length,
type: 'board',
title: board.title,
},
(err) => {
if (err) console.error(err);
},
);
}
});
BoardMultiSelection.reset();
}
},
'click .js-multiselection-reset'(evt) {
evt.preventDefault();
BoardMultiSelection.disable();
},
});
@@ -1,9 +1,4 @@
template(name="archivedBoards")
h2
span(title="{{_ 'archived-boards'}}")
i.fa.fa-archive
| {{_ 'archived-boards'}}
.table-page-controls
input.js-archived-boards-search(type="search" placeholder="{{_ 'search'}}")
.table-page-pagination
+25 -5
View File
@@ -1,10 +1,16 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { TAPi18n } from '/imports/i18n';
import dragscroll from '@wekanteam/dragscroll';
import {
suspendBoardDragscroll,
resumeBoardDragscroll,
resetBoardDragscroll,
} from '/client/lib/boardDragscroll';
import '/client/lib/dragscrollTouch';
import '/client/lib/dragDropTouch';
import { boardConverter } from '/client/lib/boardConverter';
// Bring the swimlane or list a link named into view, once it has rendered.
import { watchBoardItemReveals } from '/client/lib/revealBoardItem';
import { formatDateByUserPreference } from '/imports/lib/dateUtils';
import { toFullCalendarFirstDay } from '/client/lib/calendarFirstDay';
import { weekNumberByFirstDay } from '/models/lib/weekStart';
@@ -285,6 +291,11 @@ Template.boardBody.onRendered(function () {
// Initialize user settings (zoom and mobile mode)
Utils.initializeUserSettings();
// A link to a swimlane or a list has to end with that thing on screen. The
// route can only NAME it - it runs before this has rendered - so the reveal
// waits here for the element to exist. client/lib/revealBoardItem.js
this.boardItemReveals = watchBoardItemReveals();
// Detect iPhone devices and add class for better CSS targeting
const isIPhone = /iPhone|iPod/.test(navigator.userAgent);
if (isIPhone) {
@@ -443,8 +454,9 @@ Template.boardBody.onRendered(function () {
.js-add-card[tabindex] {
outline: none;
}
/* Sidebar hamburger menu button in header */
.js-toggle-sidebar .fa-bars {
/* Sidebar hamburger menu button in header. Renamed with the button when
it moved to the first top header bar: docs/Design/Page/Header.md */
.js-toggle-page-sidebar .fa-bars {
color: #fff !important;
}
/* Grey icons in card detail header */
@@ -515,6 +527,7 @@ Template.boardBody.onRendered(function () {
EscapeActions.executeUpTo('popup-close');
listDom.addClass('moving-swimlane');
tpl.setIsDragging(true);
suspendBoardDragscroll(); // #6558
ui.placeholder.insertAfter(ui.placeholder.next());
tpl.origPlaceholderIndex = ui.placeholder.index();
@@ -555,6 +568,7 @@ Template.boardBody.onRendered(function () {
const nextSwimlaneDom = ui.item.nextAll('.js-swimlane').get(0);
const sortIndex = calculateIndex(prevSwimlaneDom, nextSwimlaneDom, 1);
resumeBoardDragscroll(); // #6558
$swimlanesDom.sortable('cancel');
const swimlaneDomElement = ui.item.get(0);
const swimlane = Blaze.getData(swimlaneDomElement);
@@ -624,7 +638,7 @@ Template.boardBody.onRendered(function () {
dragscrollBoard.swimlanes();
dragscrollBoard.draggableLists();
}
dragscroll.reset();
resetBoardDragscroll();
if ($swimlanesDom.data('uiSortable') || $swimlanesDom.data('sortable')) {
if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
@@ -653,7 +667,7 @@ Template.boardBody.onRendered(function () {
}
});
dragscroll.reset();
resetBoardDragscroll();
// #4978: apply the board background inside a reactive autorun instead of a
// one-shot call. Switching directly between boards (e.g. the favorites bar)
// reuses this template instance and never re-fires onRendered, so the
@@ -666,6 +680,12 @@ Template.boardBody.onRendered(function () {
});
Template.boardBody.onDestroyed(function () {
// The reveal watcher owns a Tracker computation and a retry interval, and
// neither stops on its own when this template goes.
if (this.boardItemReveals) {
this.boardItemReveals.stop();
this.boardItemReveals = null;
}
if (BoardBody === this) {
BoardBody = null;
}
File diff suppressed because it is too large. Load diff
-110
View File
@@ -30,75 +30,6 @@
flex: 1;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 7px;
background: rgba(255, 255, 255, 0.9);
padding: 5px 14px;
border-radius: 7px;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}
.zoom-controls .board-header-btn {
padding: 5px 11px !important;
border-radius: 4px !important;
background: #fff !important;
border: 1px solid #000 !important;
transition: all 0.2s ease !important;
color: #000 !important;
height: auto !important;
line-height: normal !important;
margin: 0 !important;
float: none !important;
overflow: visible !important;
}
.zoom-controls .board-header-btn i {
color: #000 !important;
float: none !important;
display: inline !important;
line-height: normal !important;
margin: 0 !important;
}
.zoom-controls .board-header-btn:hover {
background: #000 !important;
border-color: #000 !important;
color: #fff !important;
}
.zoom-controls .board-header-btn:hover i {
color: #fff !important;
}
.zoom-controls .board-header-btn.is-active {
background: #0079bf;
color: white;
border-color: #005a8a;
}
.zoom-controls .board-header-btn.is-active i {
color: white;
}
.zoom-level {
font-weight: bold;
color: #333;
min-width: 42px;
text-align: center;
font-size: 12px;
cursor: pointer;
padding: 3px 7px;
border-radius: 4px;
transition: all 0.2s ease;
}
.zoom-level:hover {
background: #f0f0f0;
color: #000;
}
/* Mobile Mode Styles */
.mobile-mode .board-wrapper {
width: 100%;
@@ -124,47 +55,6 @@
font-size: 14px;
}
.mobile-mode .zoom-controls {
padding: 9px 21px;
gap: 14px;
}
.mobile-mode .zoom-controls .board-header-btn {
padding: 9px 21px !important;
font-size: 14px !important;
background: #fff !important;
border: 1px solid #000 !important;
color: #000 !important;
height: auto !important;
line-height: normal !important;
margin: 0 !important;
float: none !important;
overflow: visible !important;
}
.mobile-mode .zoom-controls .board-header-btn i {
color: #000 !important;
float: none !important;
display: inline !important;
line-height: normal !important;
margin: 0 !important;
}
.mobile-mode .zoom-controls .board-header-btn:hover {
background: #000 !important;
border-color: #000 !important;
color: #fff !important;
}
.mobile-mode .zoom-controls .board-header-btn:hover i {
color: #fff !important;
}
.mobile-mode .zoom-level {
font-size: 14px;
min-width: 56px;
}
/* Comprehensive Mobile Mode Styles - Works on all screen sizes */
.mobile-mode .board-wrapper {
width: 100% !important;
+129 -178
View File
@@ -1,189 +1,93 @@
template(name="boardHeaderBar")
unless notDisplayThisBoard
h1.header-board-menu
with currentBoard
if $eq title 'Templates'
| {{_ 'templates'}}
else
+viewer
= title
//- 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
//- The sidebar hamburger is its own flex item, right after the title in the
//- markup, with `order` putting it last on a wide screen. That is what lets it sit
//- in the TOP RIGHT of the bar in mobile mode - on the first row beside the title,
//- with its divider to its left - while every other button wraps to the second
//- row. Inside the right-hand group it could only ever wrap along with them.
.board-header-btns.board-header-sidebar-toggle
.separator
//- Board Settings cog removed: Board Settings is available in the right
//- sidebar (the hamburger), so the header no longer duplicates it.
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
i.fa.fa-bars
//- Everything else the board's second header bar held, as icons in the FIRST
//- bar, to the left of the notification bell.
//-
//- ICON ONLY, each named by a tooltip. The bar is a single row shared with the
//- logo, the starred boards, the view menu, the bell and the user menu, and
//- these are ten more controls - labelled, they would not fit at any width.
//-
//- There is no desktop/mobile split any more either. The second bar wrote the
//- whole set out TWICE, once for each, because a labelled row does not fit on a
//- phone; icons do, so one set serves both. docs/Design/Page/Header.md
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}}
//- Every button group lives inside ONE flex item so that, when the board title
//- is long enough that title + buttons no longer fit on a line, ALL the buttons
//- move down to the second row together, under the title. As separate flex items
//- the left group stayed beside a long title and only the right group wrapped,
//- which split the controls across both rows.
.board-header-btns-group
.board-header-btns.left
unless isMiniScreen
if currentBoard
if currentUser
with currentBoard
if currentUser.isBoardAdmin
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
i.fa.fa-pencil-square-o
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 }}
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}}")
span {{_ currentBoard.permission}}
//- 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
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 {{_ watchLevel}}
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
if showStarCounter
span.board-star-counter {{currentBoard.stars}}
a.board-header-btn(title="{{_ 'sort-cards'}}" class="js-sort-cards {{#if isSortActive }}emphasis{{/if}}")
i.fa.fa-sort
span {{#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
else
a.board-header-btn.js-log-in(
title="{{_ 'log-in'}}")
i.fa.fa-sign-in
span {{_ 'log-in'}}
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
.board-header-btns.center
if currentUser
+boardButtons
a.board-header-btn.js-open-search-view(title="{{_ 'search'}}")
i.fa.fa-search
span.board-header-btn-label {{_ 'search'}}
.board-header-btns.right
if currentBoard
if isMiniScreen
if currentUser
with currentBoard
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
i.fa.fa-pencil-square-o
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}}
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}}")
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
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
a.board-header-btn(title="{{_ 'sort-cards'}}" class="js-sort-cards {{#if isSortActive }}emphasis{{/if}}")
i.fa.fa-sort
span {{#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
else
a.board-header-btn.js-log-in(
title="{{_ 'log-in'}}")
i.fa.fa-sign-in
if isSandstorm
if currentUser
a.board-header-btn.js-open-archived-board
i.fa.fa-archive
//if showSort
//
a.board-header-btn.js-open-sort-view(title="{{_ 'sort-desc'}}")
//
i.fa(class="{{directionClass}}")
//
span {{_ 'sort'}}{{_ listSortShortDesc}}
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 {{#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 {{_ 'search'}}
unless currentBoard.isTemplatesBoard
a.board-header-btn.js-toggle-board-view
i.fa.fa-caret-down
if $eq boardView 'board-view-swimlanes'
i.fa.fa-th-large
if $eq boardView 'board-view-lists'
i.fa.fa-trello
if $eq boardView 'board-view-cal'
i.fa.fa-calendar
if $eq boardView 'board-view-gantt'
i.fa.fa-bar-chart
if $eq boardView 'board-view-table'
i.fa.fa-table
if $eq boardView 'board-view-stats'
i.fa.fa-pie-chart
span
if $eq boardView 'board-view-swimlanes'
| {{_ 'swimlanes'}}
if $eq boardView 'board-view-lists'
| {{_ 'lists'}}
if $eq boardView 'board-view-cal'
| {{_ 'calendar'}}
if $eq boardView 'board-view-gantt'
| {{_ 'gantt'}}
if $eq boardView 'board-view-table'
| {{_ 'board-view-table'}}
if $eq boardView 'board-view-stats'
| {{_ 'board-view-stats'}}
a.board-header-btn.js-toggle-dependencies(
title="{{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}"
class="{{#if currentBoard.showDependencies}}emphasis{{/if}}")
i.fa.fa-link
span {{#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
span {{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
if MultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
//- The separator and the sidebar hamburger used to end this group. They are
//- now their own flex item (.board-header-sidebar-toggle, above) so that in
//- mobile mode the hamburger can stay in the top right corner of the bar,
//- beside the title, while the rest of the buttons wrap to the second row.
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
//- `described-list`: every row is icon + name + description, and the three are laid
//- out as three left-aligned columns (popup.css), so the descriptions of all the
@@ -454,3 +358,50 @@ template(name="cardsSortPopup")
i.fa.fa-arrow-up
| {{_ 'created-at-oldest-first'}}
//- The board's view menu, in the FIRST top header bar.
//-
//- It was a button in the board's second header bar. A view menu says what you
//- are looking AT, so it belongs beside the page's name in the one bar that is
//- always on screen - the same place the All Boards Lists/Table menu is.
//-
//- `unless currentBoard.isTemplatesBoard`: there is nothing to look at six
//- ways on a templates board. docs/Design/Page/Header.md
//- Whether THIS board is starred, and the button that changes it. Its own
//- template because the first header bar places it beside the starred-boards
//- dropdown rather than among the board's other controls: the two are one pair -
//- how many boards you have starred, and whether this is one of them.
//-
//- The outline star means not starred and the solid one means starred, so the
//- button shows its state rather than the action - the tooltip says the action.
//- docs/Design/Page/Header.md
template(name="boardStarButton")
if currentBoard
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
if showStarCounter
span.board-star-counter {{currentBoard.stars}}
template(name="boardViewMenu")
unless currentBoard.isTemplatesBoard
//- The name is VISIBLE beside the icon, not only in the tooltip. Six view
icons are six glyphs to learn, and a tooltip is the one place a name
cannot be read without hovering - so the button says which view is on.
The bar wraps to a second row when it runs out of width, which is what
makes a word beside the icon affordable. docs/Design/Page/Header.md
a.board-header-btn.js-toggle-board-view(title="{{boardViewName}}")
i.fa.fa-caret-down
if $eq boardView 'board-view-swimlanes'
i.fa.fa-th-large
if $eq boardView 'board-view-lists'
i.fa.fa-trello
if $eq boardView 'board-view-cal'
i.fa.fa-calendar
if $eq boardView 'board-view-gantt'
i.fa.fa-bar-chart
if $eq boardView 'board-view-table'
i.fa.fa-table
if $eq boardView 'board-view-stats'
i.fa.fa-pie-chart
span.board-header-btn-label= boardViewName
+113 -58
View File
@@ -1,12 +1,19 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
const { allBoardsPath, SECTION_ARCHIVE } = require('/models/lib/allBoardsUrls');
import dragscroll from '@wekanteam/dragscroll';
import getSlug from 'limax';
import Boards from '/models/boards';
import Swimlanes from '/models/swimlanes';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { Filter } from '/client/lib/filter';
// Which way a button that opens a sidebar view goes on a click - one answer,
// in one place, for both Filter and Search.
import {
SIDEBAR_VIEW_CLOSE,
sidebarViewButtonAction,
} from '/models/lib/sidebarViewButton';
import { MultiSelection } from '/client/lib/multiSelection';
import { getSidebarInstance } from '/client/features/sidebar/service';
import { Utils } from '/client/lib/utils';
@@ -44,7 +51,16 @@ Template.boardChangeTitlePopup.events({
.val()
.trim();
if (newTitle) {
const board = Utils.getCurrentBoard();
// The board this popup was opened FOR, when it was opened for one: the
// All Boards Table view opens this same popup from a row whose data
// context is that row's board (docs/Design/Page/All-Boards.md), and on
// that page there is no "current board" at all. The board header opens it
// with the current board as its context, so that side is unchanged.
const context = Template.currentData();
const board =
context && context._id && typeof context.rename === 'function'
? context
: Utils.getCurrentBoard();
if (board) {
await board.rename(newTitle);
await board.setDescription(newDesc);
@@ -54,7 +70,7 @@ Template.boardChangeTitlePopup.events({
},
});
Template.boardHeaderBar.onCreated(function () {
Template.boardHeaderButtons.onCreated(function () {
Meteor.subscribe('tableVisibilityModeSettings');
// Restore the persisted card sort after a reload (Session is in-memory), so
// both the sorting and the sort icon are remembered. See #5886.
@@ -68,7 +84,7 @@ Template.boardHeaderBar.onCreated(function () {
}
});
Template.boardHeaderBar.helpers({
Template.boardHeaderButtons.helpers({
notDisplayThisBoard() {
const allowPrivateVisibilityOnly = TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly');
const currentBoard = Utils.getCurrentBoard();
@@ -85,18 +101,6 @@ Template.boardHeaderBar.helpers({
return currentBoard && currentBoard.getWatchLevel(Meteor.userId());
},
isStarred() {
const boardId = Session.get('currentBoard');
const user = ReactiveCache.getCurrentUser();
return user && user.hasStarred(boardId);
},
// Only show the star counter if the number of star is greater than 2
showStarCounter() {
const currentBoard = Utils.getCurrentBoard();
return currentBoard && currentBoard.stars >= 2;
},
boardView() {
return Utils.boardView();
},
@@ -120,54 +124,75 @@ Template.boardHeaderBar.helpers({
},
});
Template.boardHeaderBar.events({
'click .js-edit-board-title': Popup.open('boardChangeTitle'),
// The board star, in its own template because the first header bar places it
// beside the starred-boards dropdown. A Blaze event map only sees events inside
// its own template, so its helpers and its click come with its markup.
Template.boardStarButton.helpers({
isStarred() {
const boardId = Session.get('currentBoard');
const user = ReactiveCache.getCurrentUser();
return user && user.hasStarred(boardId);
},
// Only show the star counter if the number of stars is greater than 2.
showStarCounter() {
const currentBoard = Utils.getCurrentBoard();
return currentBoard && currentBoard.stars >= 2;
},
currentBoard() {
return Utils.getCurrentBoard();
},
});
Template.boardStarButton.events({
'click .js-star-board'() {
const boardId = Session.get('currentBoard');
if (boardId) {
Meteor.call('toggleBoardStar', boardId);
}
},
});
// Open the sidebar on `view`, or shut it when it is already showing that view.
// `mustStayOpen` is Filter's exception: see models/lib/sidebarViewButton.js.
function toggleSidebarView(view, mustStayOpen) {
const sidebar = getSidebarInstance();
if (!sidebar) {
console.warn('Sidebar not available for setView');
return;
}
// Open AND on this view: a sidebar open on Activities is showing neither, and
// clicking either button there has to switch to it rather than close.
const isShowingView = sidebar.isOpen() && sidebar.getView() === view;
if (sidebarViewButtonAction(isShowingView, mustStayOpen) === SIDEBAR_VIEW_CLOSE) {
sidebar.hide();
return;
}
sidebar.setView(view);
}
Template.boardHeaderButtons.events({
'click .js-edit-board-title': Popup.open('boardChangeTitle'),
'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
// row in its left menu. `/archive` is the full-width page that section
// replaced. docs/Design/Page/Archive.md
'click .js-open-archived-board'() {
Modal.open('archivedBoards');
},
'click .js-toggle-board-view': Popup.open('boardChangeView'),
'click .js-toggle-sidebar'() {
if (process.env.DEBUG === 'true') {
console.log('Hamburger menu clicked');
}
// Use the same approach as keyboard shortcuts
const sidebar = getSidebarInstance();
if (sidebar && typeof sidebar.toggle === 'function') {
if (process.env.DEBUG === 'true') {
console.log('Using Sidebar.toggle()');
}
sidebar.toggle();
} else {
if (process.env.DEBUG === 'true') {
console.warn('Sidebar not available, trying alternative approach');
}
// Try to trigger the sidebar through the global Blaze helper
if (typeof Blaze !== 'undefined' && Blaze._globalHelpers && Blaze._globalHelpers.Sidebar) {
const blazeSidebar = Blaze._globalHelpers.Sidebar();
if (blazeSidebar && typeof blazeSidebar.toggle === 'function') {
if (process.env.DEBUG === 'true') {
console.log('Using Blaze helper Sidebar.toggle()');
}
blazeSidebar.toggle();
}
}
}
FlowRouter.go(allBoardsPath(SECTION_ARCHIVE, []));
},
// The button that opens the filter sidebar also shuts it. It only ever
// opened, so a second click did nothing visible and the only way back was the
// sidebar's own X - somewhere else on screen from the thing you just clicked.
//
// Not while a filter is ON, though: the sidebar is then the one place that
// says what is being hidden from the board, and closing it would leave a
// board showing a subset of its cards with nothing on screen to say so. The X
// beside this button is what clears the filter.
// models/lib/sidebarViewButton.js
'click .js-open-filter-view'() {
const sidebar = getSidebarInstance();
if (sidebar) {
sidebar.setView('filter');
} else {
console.warn('Sidebar not available for setView');
}
toggleSidebarView('filter', Filter.isActive());
},
'click .js-sort-cards': Popup.open('cardsSort'),
/*
@@ -195,13 +220,11 @@ Template.boardHeaderBar.events({
'click .js-sort-reset'() {
setCardsSortBy('');
},
// Search shuts what it opened too, with no exception: its results are inside
// the panel, so closing it hides nothing from the board the way closing an
// active filter's panel would. models/lib/sidebarViewButton.js
'click .js-open-search-view'() {
const sidebar = getSidebarInstance();
if (sidebar) {
sidebar.setView('search');
} else {
console.warn('Sidebar not available for setView');
}
toggleSidebarView('search', false);
},
'click .js-toggle-dependencies'() {
const currentBoard = Utils.getCurrentBoard();
@@ -584,3 +607,35 @@ Template.cardsSortPopup.events({
Popup.back();
},
});
// The board's view menu is its own template in the FIRST header bar now, so its
// handler is here rather than on the second bar that used to draw it.
// docs/Design/Page/Header.md
Template.boardViewMenu.events({
'click .js-toggle-board-view': Popup.open('boardChangeView'),
});
Template.boardViewMenu.helpers({
boardView() {
return Utils.boardView();
},
// The tooltip: the name of the view that is ON. The button is icon only, so
// this is the only place the name appears. docs/Design/Page/Header.md
boardViewName() {
const names = {
'board-view-swimlanes': 'swimlanes',
'board-view-lists': 'lists',
'board-view-cal': 'calendar',
'board-view-gantt': 'gantt',
'board-view-table': 'board-view-table',
'board-view-stats': 'board-view-stats',
};
return TAPi18n.__(names[Utils.boardView()] || 'board-view');
},
});
// 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'),
});
File diff suppressed because it is too large. Load diff
+420 -228
View File
@@ -1,39 +1,62 @@
template(name="boardList")
.wrapper
.boards-layout
//- `sidebar-open` moves the boards LEFT while the right sidebar is open, so
every board icon stays visible between the left menu and the panel instead
of hiding under it. Desktop only - on a phone the sidebar is full width and
there is nothing to move aside. boardsList.css
.wrapper.all-boards-wrapper(class="{{#if isSidebarOpen}}sidebar-open{{/if}}")
+allBoardsSidebar
//- The folded state is marked on the GRID, not on the menu inside it: a
hidden grid ITEM still leaves its track behind, so the boards would keep
a 260px-wide nothing beside them. The grid drops to one column and hides
the menu in the same rule. The Admin Panel marks its menu instead, because
a flex row with a hidden item simply closes up.
docs/Design/Page/Left-Menu.md
.boards-layout(class="{{#if isLeftMenuCollapsed}}collapsed{{/if}}")
// Left menu
.boards-left-menu
//- The caret that folds it away - the Admin Panel's own, so the two
menus cannot end up with two different carets, and the same control a
list has on a board. Folded, the menu is not drawn at all and the
caret that brings it back rides on the pane title below.
docs/Design/Page/Left-Menu.md
+leftMenuCollapse
ul.menu
li(class="menu-item {{#if isSelectedMenu 'starred'}}active{{/if}}")
a.js-select-menu(data-type="starred")
span.menu-label
span.emoji-icon
i.fa.fa-star
| {{_ 'allboards.starred'}}
span.menu-count {{menuItemCount 'starred'}}
li(class="menu-item {{#if isSelectedMenu 'templates'}}active{{/if}}")
a.js-select-menu(data-type="templates")
span.menu-label
span.emoji-icon
i.fa.fa-clipboard
| {{_ 'allboards.templates'}}
span.menu-count {{menuItemCount 'templates'}}
li(class="menu-item {{#if isSelectedMenu 'remaining'}}active{{/if}}")
a.js-select-menu(data-type="remaining")
span.menu-label
span.emoji-icon
i.fa.fa-folder
| {{_ 'allboards.remaining'}}
span.menu-count {{menuItemCount 'remaining'}}
if isSelectedMenu 'templates'
if shareTargets.length
li.menu-section-title {{_ 'share-template-with'}}
each shareTargets
li.menu-item.js-share-target(data-share-type="{{type}}", data-share-id="{{id}}", data-share-name="{{name}}", title="{{_ 'drag-template-here-to-share'}}")
span.menu-label
span.emoji-icon
i.fa.fa-share-alt
| {{name}}
//- ONE row, rendered four times, rather than four copies of the same
eight lines. Their ORDER depends on the user - Starred first if
anything is starred, Remaining first if not - and four copies could
not be reordered without moving markup about.
menuSections() asks models/lib/allBoardsUrls.js for the order.
"Archive", not "Boards in Archive": one word, like its neighbours,
and the menu already says what it lists. `archives` is an existing
key, translated in the same 135 languages, and the one already used
for the archive as a PLACE.
each menuSections
li(class="menu-item {{#if isSelectedMenu type}}active{{/if}}")
a.js-select-menu(class="{{extraClass}}" data-type="{{type}}")
span.menu-label
span.emoji-icon
i.fa(class="{{icon}}")
| {{_ labelKey}}
span.menu-count {{sectionCount type}}
//- The share targets belong to the TEMPLATES row, so they are drawn
inside the loop directly under it - not after the whole list,
where they would follow the Archive and read as its sub-list.
if $eq type 'templates'
if isSelectedMenu 'templates'
if shareTargets.length
li.menu-section-title {{_ 'share-template-with'}}
each shareTargets
li.menu-item.js-share-target(data-share-type="{{type}}", data-share-id="{{id}}", data-share-name="{{name}}", title="{{_ 'drag-template-here-to-share'}}")
span.menu-label
span.emoji-icon
i.fa.fa-share-alt
| {{name}}
//- Rules above and below the Workspaces section. The menu is three
kinds of thing in one column - the three board lists, the workspaces
tree, and the archive - and without them the tree ran into its
neighbours as if it were more of the same list.
hr.boards-menu-divider
.workspaces-header
span
span.emoji-icon
@@ -42,6 +65,7 @@ template(name="boardList")
a.js-add-workspace(title="{{_ 'allboards.add-workspace'}}") +
// Workspaces tree
+workspaceTree(nodes=workspacesTree selectedWorkspaceId=selectedWorkspaceId)
hr.boards-menu-divider
// Existing filter by orgs/teams (kept)
ul.AllBoardTeamsOrgs
@@ -71,202 +95,236 @@ template(name="boardList")
i.fa.fa-times-thin
span {{_ 'filter-clear'}}
//- The grip that changes the menu's width, the Admin Panel's own. It is a
child of the GRID and not of the menu: it is positioned absolutely, so it
is no grid item and adds no track, and it does not scroll away with a
long list of workspaces the way a handle inside the menu would.
docs/Design/Page/Left-Menu.md
+leftMenuResize
// Right boards grid
//- There is NO bar above the boards, and no second header bar either.
Every control of this page - Sort, Search, Multi-Selection, the view
menu and the actions on a selection - is in the right sidebar, and the
page is named by the FIRST header bar. docs/Design/Page/All-Boards.md
.boards-right-grid
.boards-path-header
.path-left
span.path-title
//- #6524: the current section's Font Awesome icon (same glyph and
colour as the matching left-menu item), not a Unicode emoji.
span.path-icon
i.fa(class="{{currentMenuPath.faIcon}}")
span.path-text {{currentMenuPath.text}}
.path-right
//- Order (left -> right): Multi-Selection, Sort, Search boards.
if canModifyBoards
if hasBoardsSelected
button.js-archive-selected-boards.board-header-btn
span.emoji-icon
i.fa.fa-archive
span {{_ 'archive-board'}}
button.js-duplicate-selected-boards.board-header-btn
span.emoji-icon
i.fa.fa-clipboard
span {{_ 'duplicate-board'}}
//- Multi-Selection button, with the "click checkboxes" hint stacked
directly BELOW it (own column) instead of beside the path title.
.multiselection-group
a.board-header-btn.js-multiselection-activate(
title="{{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
class="{{#if BoardMultiSelection.isActive}}emphasis{{/if}}")
i.fa.fa-check-square-o
span {{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
if BoardMultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
if BoardMultiSelection.isActive
span.multiselection-hint
//- The heading of the pane: which list of boards this is. It sits above
the view branch, so it is drawn ONCE whichever view is on - the board
icons and the Table are two ways of showing the same section, not two
sections.
The Admin Panel's own `paneTitle`, so the two pages have one heading
at one size and colour. docs/Design/Page/All-Boards.md
+paneTitle(allBoardsPaneTitle)
//- The Archive is a SECTION of this page, drawn here beside the left
menu, not a page of its own that replaces it - and drawn with the same
board ICONS every other section uses. It had a table of its own, which
meant the one place you go to bring a board back was the one place
multi-selection, dragging and the tile layout did not work.
docs/Design/Page/Archive.md
if isAllBoardsView 'table'
+tablePage(tablePageData)
else
if boardsPaginationActive
.boards-pagination
button.js-boards-prev-page(class="{{#if hasBoardsPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.boards-page-info {{boardsCurrentPage}} / {{boardsTotalPages}}
button.js-boards-next-page(class="{{#if hasBoardsNextPage}}{{else}}disabled{{/if}}")
| &gt;
ul.board-list.clearfix.js-boards.dragscroll(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if BoardMultiSelection.isActive}}is-multiselection-active{{/if}}")
//- The Remove bar, drawn only while a board from Home is actually
being dragged - the launcher's, which appears at the top of the
screen when an icon is picked up and is not there the rest of the
time. Dropping a board here takes it off Home; the board itself
stays where it lives, the way an app stays in the drawer.
docs/Features/Board/Home.md
if showsHomeRemoveTarget
li.home-remove-target.js-home-remove
i.fa.fa-trash
span {{_ 'home-board-remove'}}
//- Home is a mark on ONE board rather than a place boards are
kept, so an empty Home says what to drag instead of showing a
blank pane.
if isSelectedMenu 'home'
unless boards.length
li.board-list-item-empty {{_ 'home-board-empty'}}
//- No "Add Board" in the Archive - a board cannot be created
already archived - nor in Home, where a new board would not be
the board that opens after login: the tile would promise
something it cannot do. ONE helper rather than two nested
conditions, because nesting one moved the tile's own children out
from under it.
if showsAddBoardTile
li.js-add-board
if isSelectedMenu 'templates'
a.board-list-item.label.is-template-container(title="{{_ 'add-template-container'}}")
span.emoji-icon
i.fa.fa-thumb-tack
| {{_ 'multi-selection-active'}}
//- "Selected:" actions on the multi-selected boards: star them,
or set one as the Home board (opened after login, #2220).
.selected-actions
span.selected-label {{_ 'selected-label'}}
a.selected-action.js-star-selected(title="{{_ 'set-selected-starred'}}")
i.fa.fa-star
a.selected-action.js-home-selected(title="{{_ 'set-selected-home'}}")
i.fa.fa-home
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
//- Icon as a direct `i.fa` child (like the Multi-Selection button
//- beside it), not wrapped in span.emoji-icon, and the plain
//- fa-sort glyph so the blue Sort button always shows a sort icon.
i.fa.fa-sort
span {{_ 'sort'}}
//- Search boards is placed to the RIGHT of the Multi-Selection button.
.board-search
span.emoji-icon
i.fa.fa-search
//- No placeholder text: the magnifier icon beside the field already
says what it is, and the label took the whole width of the box on a
phone. `aria-label` keeps it named for screen readers, which a
placeholder is not a substitute for anyway.
input.js-board-search-input(type="text" placeholder="" aria-label="{{_ 'search-boards'}}" value="{{boardSearch}}" autocomplete="off")
if boardSearch
a.board-search-clear.js-board-search-clear(title="{{_ 'filter-clear'}}")
span.emoji-icon
i.fa.fa-times
if boardsPaginationActive
.boards-pagination
button.js-boards-prev-page(class="{{#if hasBoardsPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.boards-page-info {{boardsCurrentPage}} / {{boardsTotalPages}}
button.js-boards-next-page(class="{{#if hasBoardsNextPage}}{{else}}disabled{{/if}}")
| &gt;
ul.board-list.clearfix.js-boards.dragscroll(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if BoardMultiSelection.isActive}}is-multiselection-active{{/if}}")
li.js-add-board
if isSelectedMenu 'templates'
a.board-list-item.label.is-template-container(title="{{_ 'add-template-container'}}")
span.emoji-icon
i.fa.fa-plus
| &nbsp;{{_ 'add-template-container'}}
else
a.board-list-item.label(title="{{_ 'add-board'}}")
span.emoji-icon
i.fa.fa-plus
| &nbsp;{{_ 'add-board'}}
each boards
li.js-board.nodragscroll(class="{{_id}} {{#if isStarred}}starred{{/if}} {{colorClass}} {{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}", draggable="true")
if isInvited
.board-list-item
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
span.details
span.board-list-item-name= title
span.js-star-board(
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
title="{{_ 'star-board-title'}}")
span.emoji-icon
i.fa(class="{{#if isStarred}}fa-star{{else}}fa-star-o{{/if}}")
p.board-list-item-desc {{_ 'just-invited'}}
button.js-accept-invite.primary {{_ 'accept'}}
button.js-decline-invite {{_ 'decline'}}
else
if $eq type "template-container"
.template-container.board-list-item
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
//- The drag handle obeys the same "Show desktop drag handles"
setting as swimlanes, lists and cards. With handles ON it is
the only place a board drag may start, so the rest of the
tile is free to dragscroll; with handles OFF there is no
handle and the whole tile is the drag source.
if isTouchScreenOrShowDesktopDragHandles
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
span.emoji-icon
i.fa.fa-arrows
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}" draggable="false")
span.details
span.board-list-item-name(title="{{_ 'template-container'}}")
+viewer
= title
p.board-list-item-desc
+viewer
= description
if hasSpentTimeCards
span.js-has-spenttime-cards(
class="{{#if hasOvertimeCards}}has-overtime-card-active{{else}}no-overtime-card-active{{/if}}"
title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
span.emoji-icon
i.fa.fa-clock-o
span.js-star-board(
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
title="{{_ 'star-board-title'}}")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
i.fa.fa-plus
| &nbsp;{{_ 'add-template-container'}}
else
//- #5157: reuse the board's existing backgroundImageURL as the
//- tile background on the board list page.
.board-list-item(class="{{#if backgroundImageURL}}has-background-image{{/if}}" style="{{#if backgroundImageURL}}background-image:url('{{backgroundImageURL}}');{{/if}}")
a.board-list-item.label(title="{{_ 'add-board'}}")
span.emoji-icon
i.fa.fa-plus
| &nbsp;{{_ 'add-board'}}
//- The BOOKMARKS, in the Starred section beside the starred
boards - because Starred is the list of places you keep, and a
bookmark is one of them. A tile rather than a row, so it sits in
the same grid as the boards; a white border, so it is legible as
a different KIND of thing without being a different shape; and
the theme's own colour behind it, so it belongs to this WeKan.
It carries the title AND the URL: the title says where it goes
and the URL says what it is, which is what a bookmark bar shows.
Dragging one past another reorders them, and that order is the
order of the header dropdown - the two are views of one array.
docs/Features/Board/Starred.md
if isSelectedMenu 'starred'
each starredPages
li.js-bookmark.nodragscroll.board-list-item-bookmark(data-url="{{url}}", draggable="true")
a.board-list-item(href="{{url}}", title="{{title}}")
span.bookmark-title
i.fa.fa-star
| {{title}}
span.bookmark-url {{url}}
//- A SIBLING of the link, not a child of it: an `a` inside an
`a` is invalid HTML, and browsers repair it by closing the
outer one early - which would leave the URL line outside the
link that is supposed to carry it.
a.js-unstar-bookmark.bookmark-unstar(href="#", title="{{_ 'click-to-unstar-page'}}")
i.fa.fa-times
each boards
li.js-board.nodragscroll(class="{{_id}} {{#if isStarred}}starred{{/if}} {{colorClass}} {{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}", draggable="true")
if isInvited
.board-list-item
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
//- #2220: the Home board (opened after login) is badged with a
home icon at the top centre of its tile.
if isDefaultBoard
span.board-home-badge(title="{{_ 'home-board-badge'}}")
i.fa.fa-home
//- The drag handle obeys the same "Show desktop drag handles"
setting as swimlanes, lists and cards. With handles ON it is
the only place a board drag may start, so the rest of the
tile is free to dragscroll; with handles OFF there is no
handle and the whole tile is the drag source.
if isTouchScreenOrShowDesktopDragHandles
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
span.details
span.board-list-item-name= title
span.js-star-board(
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
title="{{_ 'star-board-title'}}")
span.emoji-icon
i.fa.fa-arrows
i.fa(class="{{#if isStarred}}fa-star{{else}}fa-star-o{{/if}}")
p.board-list-item-desc {{_ 'just-invited'}}
button.js-accept-invite.primary {{_ 'accept'}}
button.js-decline-invite {{_ 'decline'}}
else
if $eq type "template-container"
.template-container.board-list-item
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
//- The drag handle obeys the same "Show desktop drag handles"
setting as swimlanes, lists and cards. With handles ON it is
the only place a board drag may start, so the rest of the
tile is free to dragscroll; with handles OFF there is no
handle and the whole tile is the drag source.
if isTouchScreenOrShowDesktopDragHandles
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
span.emoji-icon
i.fa.fa-arrows
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}" draggable="false")
span.details
span.board-list-item-name(title="{{boardOpenAndMoveHint}}")
+viewer
= title
//- #5174 / #4825: the per-board allows* flags are
//- resolved server-side into the one-shot tile data
//- (the raw fields are not even published to this
//- page), so the show* helpers below gate the whole
//- block consistently for every board.
unless currentSetting.hideBoardMemberList
if showBoardMemberList _id
.minicard-members
each member in boardMembers _id
a.name
+userAvatar(userId=member noRemove=true)
unless currentSetting.hideCardCounterList
if showCardCounterList _id
.minicard-lists.flex.flex-wrap
each list in boardLists _id
.item
| {{ list }}
p.board-list-item-desc
+viewer
= description
if hasSpentTimeCards
span.js-has-spenttime-cards(
class="{{#if hasOvertimeCards}}has-overtime-card-active{{else}}no-overtime-card-active{{/if}}"
title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
span.emoji-icon
i.fa.fa-clock-o
a.js-star-board(
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
title="{{_ 'star-board-title'}}")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
//- #2220 Home-board toggle is disabled: the icon and the
//- set-default-board action are intentionally not rendered.
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}" draggable="false")
span.details
span.board-list-item-name(title="{{_ 'template-container'}}")
+viewer
= title
p.board-list-item-desc
+viewer
= description
//- When you are looking at the Archive, when it was
archived - that is what you sort an archive by, and
what tells one old board from another.
client/components/boards/boardsList.js
if isSelectedMenu 'archive'
p.board-list-item-archived-at
span.emoji-icon
i.fa.fa-archive
| {{_ 'archived-at'}}
= ' '
= archivedAtText
if hasSpentTimeCards
span.js-has-spenttime-cards(
class="{{#if hasOvertimeCards}}has-overtime-card-active{{else}}no-overtime-card-active{{/if}}"
title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
span.emoji-icon
i.fa.fa-clock-o
span.js-star-board(
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
title="{{_ 'star-board-title'}}")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
else
//- #5157: reuse the board's existing backgroundImageURL as the
//- tile background on the board list page.
.board-list-item(class="{{#if backgroundImageURL}}has-background-image{{/if}}" style="{{#if backgroundImageURL}}background-image:url('{{backgroundImageURL}}');{{/if}}")
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
//- #2220: the Home board (opened after login) is badged with a
home icon at the top centre of its tile.
if isDefaultBoard
span.board-home-badge(title="{{_ 'home-board-badge'}}")
i.fa.fa-home
//- The drag handle obeys the same "Show desktop drag handles"
setting as swimlanes, lists and cards. With handles ON it is
the only place a board drag may start, so the rest of the
tile is free to dragscroll; with handles OFF there is no
handle and the whole tile is the drag source.
if isTouchScreenOrShowDesktopDragHandles
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
span.emoji-icon
i.fa.fa-arrows
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}" draggable="false")
span.details
span.board-list-item-name(title="{{boardOpenAndMoveHint}}")
+viewer
= title
//- #5174 / #4825: the per-board allows* flags are
//- resolved server-side into the one-shot tile data
//- (the raw fields are not even published to this
//- page), so the show* helpers below gate the whole
//- block consistently for every board.
unless currentSetting.hideBoardMemberList
if showBoardMemberList _id
.minicard-members
each member in boardMembers _id
a.name
+userAvatar(userId=member noRemove=true)
unless currentSetting.hideCardCounterList
if showCardCounterList _id
.minicard-lists.flex.flex-wrap
each list in boardLists _id
.item
| {{ list }}
p.board-list-item-desc
+viewer
= description
//- When you are looking at the Archive, when it was
archived - that is what you sort an archive by, and
what tells one old board from another.
client/components/boards/boardsList.js
if isSelectedMenu 'archive'
p.board-list-item-archived-at
span.emoji-icon
i.fa.fa-archive
| {{_ 'archived-at'}}
= ' '
= archivedAtText
if hasSpentTimeCards
span.js-has-spenttime-cards(
class="{{#if hasOvertimeCards}}has-overtime-card-active{{else}}no-overtime-card-active{{/if}}"
title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
span.emoji-icon
i.fa.fa-clock-o
a.js-star-board(
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
title="{{_ 'star-board-title'}}")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
//- #2220 Home-board toggle is disabled: the icon and the
//- set-default-board action are intentionally not rendered.
template(name="boardsSortPopup")
ul.pop-over-list
@@ -286,8 +344,37 @@ template(name="boardsSortPopup")
i.fa.fa-sort-alpha-desc
| &nbsp;{{_ 'sort-boards-title-desc'}}
template(name="boardListHeaderBar")
h1 {{_ title }}
//- One row of the All Boards Table view. A rowTemplate, because the Edit cell is
//- a control and the row carries its board's colours - neither of which a
//- text-cell column spec can express. Cells must match the header, in order:
//- Edit, Board title, Board description.
template(name="allBoardsRow")
tr.all-boards-row(class="{{colorClass}}" data-board-id="{{_id}}")
td.all-boards-edit
a.js-edit-board-title-row(title="{{_ 'edit'}}")
i.fa.fa-pencil
td.all-boards-title
a.js-open-board-row(href="{{pathFor 'board' id=_id slug=slug}}")
+viewer
= title
td.all-boards-desc
+viewer
= description
template(name="allBoardsViewPopup")
ul.pop-over-list
li
a.js-all-boards-view-lists
i.fa.fa-trello
| {{_ 'lists'}}
if isAllBoardsView 'lists'
i.fa.fa-check
li
a.js-all-boards-view-table
i.fa.fa-table
| {{_ 'board-view-table'}}
if isAllBoardsView 'table'
i.fa.fa-check
//.board-header-btns.right
//
a.board-header-btn.js-open-archived-board
@@ -306,14 +393,63 @@ template(name="boardListHeaderBar")
template(name="workspaceTree")
if nodes
ul.workspace-tree.js-workspace-tree
//- `../selectedWorkspaceId`, not the bare name. Inside `each` the data
context is the NODE, and this is a template argument rather than a
helper - so a bare lookup found nothing on the node, `$eq` compared an
id with undefined, and no workspace ever got the `active` class. The
theme highlight was styled for a class that was never applied.
each nodes
li.workspace-node(class="{{#if $eq id selectedWorkspaceId}}active{{/if}}" data-workspace-id="{{id}}" draggable="true")
//- Which part of the row starts a reorder follows the "Show desktop drag
handles" toggle in the first header bar - the same setting swimlanes,
lists, cards and the board tiles obey:
handles ON - the ✥ handle, and only the handle;
handles OFF - the workspace's icon AND its name, which is the whole
`a.js-select-space`.
The anchor rather than the icon alone: a 16px glyph is a target that
has to be aimed at, and the name beside it is the part of the row a
reader is actually pointing at. Clicking it still opens the workspace -
a click and a drag are different gestures on the same element, exactly
as they are on a board tile with handles off.
NOT the whole row either: the row also holds the ⋯ menu and the board
count, and a drag started on those is a drag of something else.
`dragstart` bubbles, so the handler stays on the row in both cases.
docs/Design/Page/All-Boards.md
li.workspace-node(class="{{#if $eq id ../selectedWorkspaceId}}active{{/if}}" data-workspace-id="{{id}}")
.workspace-node-content
span.workspace-drag-handle
span.emoji-icon
i.fa.fa-arrows
//- The caret that folds this workspace's own workspaces away, at
the START of the row - before the drag handle, so the carets of a
tree line up in one column whatever the handles toggle says. It is
the control a list has on a board and the one the left menu itself
has, down to the two words in its tooltip.
A workspace with no workspaces under it has nothing to fold, so it
gets a spacer of the same width instead: a row that shifted
sideways when it gained a child would be a moving target.
docs/Design/Page/Workspaces.md
if workspaceHasChildren
a.workspace-collapse-indicator.js-collapse-workspace(
title="{{workspaceCollapseLabel}}"
aria-label="{{workspaceCollapseLabel}}"
aria-expanded="{{#if isWorkspaceCollapsed}}false{{else}}true{{/if}}"
role="button"
tabindex="0")
if isWorkspaceCollapsed
i.fa.fa-caret-right
else
i.fa.fa-caret-down
else
span.workspace-collapse-spacer
if isTouchScreenOrShowDesktopDragHandles
span.workspace-drag-handle.nodragscroll(draggable="true")
span.emoji-icon
i.fa.fa-arrows
a.js-select-workspace.js-select-space(data-id="{{id}}")
//- `nodragscroll` for the same reason the handle carries it: the
page-level dragscroll owns a mousedown it is not opted out of, and
a drag that never begins is what "reordering does not work" looks
like.
a.js-select-workspace.js-select-space.nodragscroll(
data-id="{{id}}"
draggable="{{#if isTouchScreenOrShowDesktopDragHandles}}false{{else}}true{{/if}}")
span.workspace-icon
if icon
span.workspace-icon-value(style="{{#if iconColor}}color: {{iconColor}};{{/if}}")
@@ -327,8 +463,13 @@ template(name="workspaceTree")
span.emoji-icon
i.fa.fa-bars
span.workspace-count {{workspaceCount id}}
if children
+workspaceTree(nodes=children selectedWorkspaceId=selectedWorkspaceId)
//- ONE condition, not `if children` wrapped in an `unless collapsed`:
`..` counts block levels, and a second block between the `each` and
this inclusion is another level for `../selectedWorkspaceId` to
climb - which is how the highlight was lost once already.
docs/Design/Page/Workspaces.md
if workspaceShowsChildren
+workspaceTree(nodes=children selectedWorkspaceId=../selectedWorkspaceId)
//- #6524: creating a workspace used window.prompt(), which a browser may refuse
to show at all - iOS Safari lets the user suppress further prompts for a page,
@@ -360,3 +501,54 @@ template(name="workspaceActionsPopup")
.workspace-delete-block
span.js-workspace-delete-error(style="color: #c00; display: block; margin: 4px 0;")
button.js-delete-workspace.negate.wide(type="button" data-id="{{id}}") {{_ 'delete'}}
//- The All Boards view menu, in the FIRST top header bar, beside the board
//- view menu's place - a view menu says what you are looking AT, so both of
//- them sit next to the page's name rather than in a bar or a panel.
//-
//- It names the CURRENT view, not itself: the board header says "Swimlanes" or
//- "Lists" rather than "Board View", and this matches it.
//- docs/Design/Page/Header.md
//- The page's controls, as icons, in the first top header bar - left of the
//- bell, exactly where a board's own controls are. They were rows inside the
//- right sidebar's home view, which meant two clicks and a panel covering the
//- boards to reach the thing you came for; and the sidebar's home view was the
//- ONLY reason All Boards offered a hamburger, so it does not any more. Search
//- and Multi-Selection still open the sidebar, straight into their own view.
//-
//- Each names itself beside its icon where the bar has room, and the one rule
//- in header.css drops every label below 1100px, leaving the tooltips.
//- docs/Design/Page/All-Boards.md
template(name="allBoardsHeaderButtons")
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
i.fa.fa-sort
span.board-header-btn-label {{_ 'sort-boards'}}
a.board-header-btn.js-all-boards-sidebar-search(title="{{_ 'search-boards'}}")
i.fa.fa-search
span.board-header-btn-label {{_ 'search-boards'}}
if canModifyBoards
//- Says whether it is ON, and offers the way OFF - the same pair the
board's own Multi-Selection has. It looked identical whether or not a
selection was running, so the one control that changes what a click on a
board does gave no sign it had changed it, and the only way out was to
find the row inside the sidebar. docs/Design/Page/Multi-Selection.md
a.board-header-btn.js-all-boards-sidebar-multiselection(
title="{{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
class="{{#if BoardMultiSelection.isActive}}emphasis{{/if}}")
i.fa.fa-check-square-o
span.board-header-btn-label {{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
if BoardMultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'multi-selection-off'}}")
i.fa.fa-times-thin
template(name="allBoardsViewMenu")
//- The name is visible beside the icon, the same as the board's view menu:
a tooltip is the one place a name cannot be read without hovering.
a.board-header-btn.js-open-all-boards-view(title="{{#if isAllBoardsView 'table'}}{{_ 'board-view-table'}}{{else}}{{_ 'lists'}}{{/if}}")
i.fa.fa-caret-down
if isAllBoardsView 'table'
i.fa.fa-table
span.board-header-btn-label {{_ 'board-view-table'}}
else
i.fa.fa-trello
span.board-header-btn-label {{_ 'lists'}}
File diff suppressed because it is too large. Load diff
+60
View File
@@ -0,0 +1,60 @@
/* /public — the Public Boards table.
* Design: docs/Design/Page/Public.md, which is docs/Design/Page/Table.md.
*
* ONLY the row's board colouring lives here. The table itself — its width, its
* columns, its cells, its controls row and its stacking below 800px — is
* tablePage.css, shared with every other table page, and this file must not
* restate any of it.
*/
.public-boards {
padding: 12px;
}
/* A board is recognised by its colour before its title is read, and it is the
* same board here as on All Boards, so a row carries its colour.
*
* The colour itself comes from boardColors.css, where `.public-board-row` was
* added to each board colour's existing selector list — the same declaration,
* one more selector — so these rows follow the board colours and the theme
* without a single hex value being repeated here.
*
* THE DEFAULT MATTERS MOST. A board with no colour matches none of those rules,
* so the row keeps the table's white background: with the light text below that
* is white on white, and only the row's emoji was visible. The All Boards tile
* has the same problem and solves it the same way — `.board-list-item` falls
* back to a grey (#999) so its white text always has something to sit on. */
.public-board-row {
background-color: #999;
background-size: cover;
background-position: center;
cursor: pointer;
}
/* Light text, like the tile's own (.board-list-item is #f6f6f6), which is
* readable because the rule above guarantees a background to sit on. */
.public-board-row td {
color: #f6f6f6;
}
.public-board-row .public-board-title a {
color: inherit;
font-weight: 700;
text-decoration: none;
}
.public-board-row .public-board-title a:hover {
text-decoration: underline;
}
/* A description is secondary to the title it explains, and it can be long: it may
* wrap, and it is dimmed a little against the board's own background — the same
* treatment .board-list-item-sub-name gets on the tile. */
.public-board-row .public-board-desc {
color: rgba(255, 255, 255, 0.85);
white-space: normal;
word-break: break-word;
}
/* The whole row opens the board, so it has to look like it does. */
.public-board-row:hover {
filter: brightness(1.08);
}
@@ -0,0 +1,32 @@
//- /public — the boards anybody may open.
//- Design: docs/Design/Page/Public.md, which is the Table page design
//- (docs/Design/Page/Table.md).
//-
//- The whole page is the shared table. It used to render `boardList`, the All
//- Boards page, with its query swapped for `{ permission: 'public' }` — which
//- brought the Starred/Templates/Remaining menu, the workspaces tree, the org and
//- team filters, Multi-Selection with its archive and duplicate actions, board
//- dragging and an "Add board" tile along with it, none of which means anything
//- for a list of somebody else's public boards.
template(name="publicBoards")
.wrapper.public-boards
+tablePage(tablePageData)
//- One row. It supplies a rowTemplate — rather than a text-cell column spec —
//- because a row is a LINK and carries its board's colours, neither of which a
//- text cell can express. It is still read-only: one anchor, no control, and
//- nothing on it changes anything.
//-
//- The cells must match the header, in order: Board, Description.
template(name="publicBoardRow")
tr.public-board-row.js-open-public-board(
class="{{colorClass}}"
data-board-id="{{_id}}"
style="{{backgroundStyle}}")
td.public-board-title
a.js-open-public-board-link(href="{{pathFor 'board' id=_id slug=slug}}")
+viewer
= title
td.public-board-desc
+viewer
= description
+137
View File
@@ -0,0 +1,137 @@
import { ReactiveVar } from 'meteor/reactive-var';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Boards from '/models/boards';
import { ReportPages } from '/client/lib/reportPages';
import {
buildHeader,
docsByIds,
pageInfo,
TABLE_PAGE_ROWS_PER_PAGE,
} from '/models/lib/tablePage';
// /public — the boards anybody may open, as a table page.
// Design: docs/Design/Page/Public.md (which is docs/Design/Page/Table.md).
//
// The page is ONLY the table. It used to render `boardList`, the All Boards page,
// with its query swapped for `{ permission: 'public' }` — which brought the
// Starred/Templates/Remaining menu, the workspaces tree, the org and team
// filters, Multi-Selection with its archive and duplicate actions, board dragging
// and an "Add board" tile with it. None of that means anything for a list of
// somebody else's public boards, and some of it offered actions the visitor has
// no rights to.
// The server names its page under this id (publishReportPage in the publication).
const PAGE_ID = 'public-boards';
// The columns, in the order a board tile shows them. Header labels only: the rows
// are a template, because a row is a LINK and carries its board's colours, and a
// text cell can express neither.
// Two columns, deliberately. The All Boards tile also shows member avatars, each
// list with its card count and a spent-time clock; what a visitor needs of a board
// they do not belong to is what it is called and what it is for. Each of the rest
// also costs a query this page would otherwise not make - see the design doc.
const COLUMNS = [
{ labelKey: 'board' },
{ labelKey: 'description' },
];
Template.publicBoards.onCreated(function () {
this.page = new ReactiveVar(1);
this.searchTerm = new ReactiveVar('');
this.total = new ReactiveVar(0);
this.autorun(() => {
const page = this.page.get();
const term = this.searchTerm.get();
this.subscribe('publicBoards', term, TABLE_PAGE_ROWS_PER_PAGE,
(page - 1) * TABLE_PAGE_ROWS_PER_PAGE);
// The total is a method, not a published count: the page needs one number and
// a live count cursor over every public board on the instance is not worth it.
Meteor.call('getPublicBoardsCount', term, (err, res) => {
if (!err && typeof res === 'number') this.total.set(res);
});
});
});
Template.publicBoards.helpers({
tablePageData() {
const tpl = Template.instance();
const info = pageInfo(tpl.total.get(), tpl.page.get());
// The boards of the page the SERVER named, in the order it sorted them. Not
// a plain find(): every board this user has opened is also in minimongo, and
// reading the collection would draw those too — the pager would say "1 / 3"
// beside rows from all three pages.
const index = ReportPages.findOne(PAGE_ID);
const ids = (index && index.ids) || [];
const boards = ids.length
? docsByIds(ids, Boards.find({ _id: { $in: ids } }).fetch())
: [];
const docs = boards.map(board => {
return {
_id: board._id,
slug: board.slug,
title: board.title,
description: board.description,
colorClass: board.colorClass ? board.colorClass() : '',
// #5157: the board's own background image, the same one the All Boards
// tile uses, so a board is recognised here the way it is there.
backgroundStyle: board.backgroundImageURL
? `background-image:url('${board.backgroundImageURL}');`
: '',
};
});
return {
// NO titleKey. The page already has a heading: the route renders
// `boardListHeaderBar`, whose `h1` says "Public". The shared table page
// prints a title only when one is supplied, precisely so a page that
// already has a heading does not show two - the same reason the Admin
// Panel panes supply none (docs/Design/Page/Table.md).
header: buildHeader(COLUMNS),
// A row template, not `rows`: see COLUMNS above.
rowTemplate: 'publicBoardRow',
docs,
rowCount: docs.length,
total: tpl.total.get(),
searchTerm: tpl.searchTerm.get(),
page: info.page,
totalPages: info.totalPages,
hasPrev: info.hasPrev,
hasNext: info.hasNext,
emptyKey: 'no-results',
};
},
});
Template.publicBoards.events({
// The shared controls row: search on Enter, and the pager. There is nothing
// else — no filter, no action button — because nothing on this page changes
// anything.
'keydown .js-table-page-search'(event, tpl) {
if (event.key !== 'Enter') return;
event.preventDefault();
tpl.searchTerm.set(event.currentTarget.value || '');
tpl.page.set(1);
},
'click .js-table-page-prev'(event, tpl) {
event.preventDefault();
tpl.page.set(Math.max(1, tpl.page.get() - 1));
},
'click .js-table-page-next'(event, tpl) {
event.preventDefault();
tpl.page.set(tpl.page.get() + 1);
},
// The whole row opens its board. The title is already an anchor, so a click on
// it is left alone — following it twice would push the same route twice.
'click .js-open-public-board'(event) {
if ($(event.target).closest('a').length > 0) return;
const boardId = event.currentTarget.getAttribute('data-board-id');
if (!boardId) return;
const board = Boards.findOne(boardId);
if (!board) return;
FlowRouter.go('board', { id: boardId, slug: board.slug });
},
});
+19
View File
@@ -153,6 +153,25 @@ body.desktop-mode .card-details:not(.card-details-popup):not([style*="left"]):no
bottom: 8px;
}
/* #6465: the Y half of the rule above, kept when the window has been placed
horizontally.
The default-position rule is `:not([style*="left"]):not([style*="top"])`, so it
switches itself off the moment anything writes an inline `left` — and it
carried `top` as well as the width. cardDetails.js now writes an inline `left`
to dock the window beside its minicard (X only), which would leave a SIXTH open
window — past the five staggered rules below — with no `top` at all, i.e.
`top: auto`, which for a fixed element is wherever it happened to land.
Same specificity as the stagger rules and written BEFORE them, so cards 1-5
still take their staggered `top` from those and nothing about the vertical
position changes. `bottom` is unchanged too: it comes from the
`:not(.card-details-collapsed)` rule further down, which never depended on the
inline styles. */
body.desktop-mode .card-details:not(.card-details-popup):not([style*="top"]) {
top: 8px;
}
/* Stagger positions for multiple cards using nth-of-type (from the end edge) */
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(1) {
top: 8px;
+12 -16
View File
@@ -24,17 +24,9 @@ template(name="cardDetails")
a.fa.fa-window-maximize.maximize-card-details.js-maximize-card-details(title="{{_ 'maximize-card'}}")
a.card-details-menu.js-open-card-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
i.fa.fa-bars
a.card-copy-button.js-copy-link(
id="cardURL_copy"
title="{{_ 'copy-card-link-to-clipboard'}}"
href="{{ originRelativeUrl }}"
)
span.emoji-icon
i.fa.fa-link
if canModifyCard
span.card-drag-handle.js-card-drag-handle(title="Drag card")
i.fa.fa-arrows
span.copied-tooltip {{_ 'copied'}}
else
a.close-card-details.js-close-card-details(title="{{_ 'close-card'}}")
i.fa.fa-times-thin
@@ -53,14 +45,6 @@ template(name="cardDetails")
a.fa.fa-window-maximize.maximize-card-details.js-maximize-card-details(title="{{_ 'maximize-card'}}")
a.card-details-menu-mobile-web.js-open-card-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
i.fa.fa-bars
a.card-copy-mobile-button.js-copy-link(
id="cardURL_copy"
title="{{_ 'copy-card-link-to-clipboard'}}"
href="{{ originRelativeUrl }}"
)
span.emoji-icon
i.fa.fa-link
span.copied-tooltip {{_ 'copied'}}
if canModifyCard
if currentBoard.allowsDueComplete
a.card-details-complete-toggle.js-toggle-due-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
@@ -786,6 +770,18 @@ template(name="editCardSortOrderForm")
template(name="cardDetailsActionsPopup")
if currentUser
//- Copy link, first, and named - the card's header carried this as an icon
with only a tooltip, which is the one place a name cannot be read without
hovering. It is here now, beside the swimlane's and the list's, so all
three are copied the same way from the same kind of menu.
docs/Design/Page/Board-Item-Links.md
ul.pop-over-list
li
a.js-copy-card-link
i.fa.fa-link
| {{_ 'copy-link-to-clipboard'}}
span.copied-tooltip {{_ 'copied'}}
hr
ul.pop-over-list
li
a.js-toggle-watch-card
+143 -9
View File
@@ -10,6 +10,7 @@ import {
} from '/models/metadata/dependencies';
import { canArchiveCard } from '/client/lib/archivePermission';
import { isTextSelectionInsideCard } from '/client/lib/cardCloseGuard';
import { placeCardDetailsX, MARGIN } from '/client/lib/cardDetailsPlacement';
// Which dependency the icon picker should apply its choice to. The icon popup's
// own data context is the dependency row (not the source card), so we capture
@@ -328,6 +329,121 @@ function getCardDetailsElement(cardId) {
return null;
}
// ────────────────────────────────────────────────────────────────────────────
// #6465: dock the desktop card-details window BESIDE the minicard it was opened
// from, on whichever side has more room, and always fully inside the viewport.
//
// The window used to cover the middle of the board; docking it to the end edge
// stopped that but left it nowhere near its card — open a card in the first list
// of a wide board and its details are a screen away. The decision is
// client/lib/cardDetailsPlacement.js; this is the DOM half.
//
// X ONLY. The Y geometry — the staggered `top` and the `bottom: 8px` that makes
// the window full height — is already right, so `top`, `bottom` and `height` are
// never written here.
//
// The one Y side effect that has to be paid for: the stylesheet's default-
// position rule is `:not([style*="left"]):not([style*="top"])`, so writing an
// inline `left` switches it off — and it carried the `top` for any window past
// the five staggered `nth-of-type` rules. cardDetails.css restores exactly that
// `top` for anchored windows, ahead of the stagger so cards 1-5 keep theirs.
// ────────────────────────────────────────────────────────────────────────────
// Set on the element once the user drags the window: from then on it stays where
// they put it, and only the viewport clamp still applies.
const USER_MOVED_ATTR = 'data-wekan-user-moved';
function markCardDetailsUserMoved($card) {
const el = $card && $card.get(0);
if (el) el.setAttribute(USER_MOVED_ATTR, '1');
}
function anchorCardDetailsX(el) {
if (!el || !el.isConnected) return;
// Only the desktop floating window is placed here. The popup form (Board Table
// view, search results, mini-screen lists) and the mini-screen full-screen card
// have their own geometry, and the maximized window's insets are !important, so
// writing inline styles for it would be a silent no-op at best.
if (!document.body.classList.contains('desktop-mode')) return;
if (el.classList.contains('card-details-popup')) return;
if (el.classList.contains('card-details-maximized')) return;
const rect = el.getBoundingClientRect();
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
// A window the user dragged stays where they put it: only the "must be in the
// visible area" half still applies, so a viewport that shrank cannot leave it
// hanging off the edge. Its width is left alone — the drag did not change it.
if (el.getAttribute(USER_MOVED_ATTR) === '1') {
const maxLeft = viewportWidth - MARGIN - rect.width;
const left = Math.min(Math.max(rect.left, MARGIN), Math.max(MARGIN, maxLeft));
if (Math.round(left) !== Math.round(rect.left)) {
el.style.left = `${Math.round(left)}px`;
el.style.right = 'auto';
}
return;
}
const cardId = Blaze.getData(el)?._id;
const minicard = cardId
? document.querySelector(`.js-minicard[data-card-id="${cardId}"]`)
: null;
// Nothing to anchor to — the card was opened from a URL or from search, or its
// list is scrolled out of view. Leave the stylesheet's dock alone.
if (!minicard) return;
// Hand the geometry back to the stylesheet before measuring, so what is
// measured is the width the stylesheet WANTS rather than whatever this function
// wrote last time. Without it, a run on a narrow viewport would clamp the width
// and every later run would re-measure that clamp, so widening the browser
// again would never give the window its full width back. Nothing is painted
// between the reset and the write below, so there is nothing to see.
el.style.left = '';
el.style.right = '';
el.style.width = '';
const placement = placeCardDetailsX({
anchor: minicard.getBoundingClientRect(),
panelWidth: el.getBoundingClientRect().width,
viewportWidth,
rtl: document.documentElement.dir === 'rtl',
});
if (!placement) return;
// `right: auto` because the stylesheet docks the window with an
// inset-inline-end, and an explicit width because the rule that carried the
// width is the one this inline `left` switches off.
el.style.left = `${placement.left}px`;
el.style.right = 'auto';
el.style.width = `${placement.width}px`;
}
// Every open window, re-placed. Used on resize: the request is that the card is
// in the visible area, and a viewport that shrank is the other way to lose it.
function anchorAllCardDetailsX() {
document
.querySelectorAll('.card-details:not(.card-details-popup)')
.forEach(anchorCardDetailsX);
}
let cardDetailsResizeBound = false;
function bindCardDetailsResize() {
if (cardDetailsResizeBound || typeof window === 'undefined') return;
cardDetailsResizeBound = true;
let scheduled = false;
window.addEventListener('resize', () => {
// Coalesced into one frame: a drag-resize of the browser window fires this
// continuously, and each run measures layout.
if (scheduled) return;
scheduled = true;
window.requestAnimationFrame(() => {
scheduled = false;
anchorAllCardDetailsX();
});
});
}
Template.cardDetails.onCreated(function () {
this.currentBoard = Utils.getCurrentBoard();
this.isLoaded = new ReactiveVar(false);
@@ -396,6 +512,15 @@ Template.cardDetails.onRendered(function () {
// within the same flush. If our DOM range is already gone, calling this.$()
// below throws "Can't select in removed DomRange", so bail out early.
if (this.view && this.view.isDestroyed) return;
// #6465: place the window beside its minicard (X only) once it has been laid
// out, so the width measured here is the one the stylesheet gave it.
const $cardDetails = this.$('.card-details').first();
if ($cardDetails.length) {
Tracker.afterFlush(() => anchorCardDetailsX($cardDetails.get(0)));
}
bindCardDetailsResize();
this.calculateNextPeak();
if (Meteor.settings.public.CARD_OPENED_WEBHOOK_ENABLED) {
// Send Webhook but not create Activities records ---
@@ -727,6 +852,9 @@ Template.cardDetails.events({
'mousedown .js-card-drag-handle'(event) {
event.preventDefault();
const $card = $(event.target).closest('.card-details');
// #6465: where the user put it wins over where the minicard is, so a later
// resize re-places every other window but only clamps this one into view.
markCardDetailsUserMoved($card);
const startX = event.clientX;
const startY = event.clientY;
const startLeft = $card.offset().left;
@@ -758,6 +886,8 @@ Template.cardDetails.events({
event.preventDefault();
const $card = $(event.target).closest('.card-details');
// #6465: same as the drag handle above — a dragged window keeps its place.
markCardDetailsUserMoved($card);
const startX = event.clientX;
const startY = event.clientY;
const startLeft = $card.offset().left;
@@ -831,15 +961,6 @@ Template.cardDetails.events({
Utils.goBoardId(boardId);
}
},
'click .js-copy-link'(event, tpl) {
event.preventDefault();
const card = Template.currentData();
const url = card.absoluteUrl();
const promise = Utils.copyTextToClipboard(url);
const $tooltip = tpl.$('.card-details-header .copied-tooltip');
Utils.showCopied(promise, $tooltip);
},
'change .js-date-format-selector'(event) {
const dateFormat = event.target.value;
if (Meteor.userId()) {
@@ -1465,6 +1586,19 @@ Template.cardDetailsActionsPopup.helpers({
});
Template.cardDetailsActionsPopup.events({
// Copy the card's address. This was an icon in the card's header with only a
// tooltip to name it - the one place a name cannot be read without hovering -
// and it is here now beside the swimlane's and the list's, so all three are
// copied the same way from the same kind of menu.
// docs/Design/Page/Board-Item-Links.md
'click .js-copy-card-link'(event, tpl) {
event.preventDefault();
const card = Template.currentData();
if (!card) return;
const url = card.absoluteUrl();
if (!url) return;
Utils.showCopied(Utils.copyTextToClipboard(url), tpl.$('.copied-tooltip'));
},
'click .js-export-card': Popup.open('exportCard'),
'click .js-members': Popup.open('cardMembers'),
'click .js-assignees': Popup.open('cardAssignees'),
+5
View File
@@ -249,6 +249,11 @@
text-align: center;
}
.minicard .minicard-title {
/* #6465: one step below the list heading above it (16px), and above the
"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;
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
+3 -1
View File
@@ -5,6 +5,8 @@ template(name="minicard")
//- card body is free, so a finger dragging across it PANS the board
//- (dragscroll) instead of doing nothing. It used to be unconditional, so with
//- handles on, a drag starting on a card neither panned nor moved the card.
//- The HANDLE itself is always `nodragscroll` (#6558): it is the drag source,
//- so a press on it must move the card, never pan the board underneath it.
.minicard(
class="{{#unless isTouchScreenOrShowDesktopDragHandles}}nodragscroll{{/unless}}"
class="{{#if showMinicardHandle}}minicard-with-handle{{/if}}"
@@ -15,7 +17,7 @@ template(name="minicard")
style="{{colorStyle}}")
if canMoveCard
if isTouchScreenOrShowDesktopDragHandles
.handle
.handle.nodragscroll
i.fa.fa-arrows
//- #459: keyboard/screen-reader reordering (visually hidden, focusable).
.minicard-move-buttons.sr-only
+15 -3
View File
@@ -170,20 +170,32 @@ button:active:active {
background: #e6e6e6;
background: linear-gradient(#e6e6e6, #e6e6e6);
}
/* The submit button of a useraccounts form inside a popup is a `.primary` too.
Member Settings / Change Password renders `+atForm(state='changePwd')`, whose
button is `button.at-btn#at-btn` with no class of ours on it - so it fell back
to the plain grey `button` above, while the Save button of Edit Profile beside
it in the same menu was the themed one. Two buttons, one menu, two looks.
It is named in the SAME rules rather than given a copy of them, so the theme
accent and the hover and active states have one home.
docs/Design/Page/Theme.md */
input[type="submit"].primary,
button.primary {
button.primary,
.pop-over .at-form button.at-btn {
background: var(--theme-accent, #005377);
box-shadow: 0 1px 0 #4d4d4d;
color: #fff;
}
input[type="submit"].primary:hover,
button.primary:hover,
.pop-over .at-form button.at-btn:hover,
input[type="submit"].primary:focus,
button.primary:focus {
button.primary:focus,
.pop-over .at-form button.at-btn:focus {
background: var(--theme-accent, #004766);
}
input[type="submit"].primary:active,
button.primary:active {
button.primary:active,
.pop-over .at-form button.at-btn:active {
background: var(--theme-accent, #01628c);
}
input[type="submit"].negate,
-6
View File
@@ -1,9 +1,3 @@
template(name="importHeaderBar")
h1
a.back-btn(href="{{pathFor 'home'}}")
i.fa-arrow-left
| {{title}}
template(name="import")
.wrapper
if error.get
-23
View File
@@ -13,29 +13,6 @@ import TrelloImportJobs from '/models/trelloImportJobs';
const Papa = require('papaparse');
Template.importHeaderBar.helpers({
title() {
const sourceNameByKey = {
trello: 'Trello',
wekan: 'JSON',
csv: 'CSV-TSV',
excel: 'Excel',
jira: 'Jira',
kanboard: 'Kanboard',
deck: 'NextCloud Deck',
openproject: 'OpenProject',
github: 'GitHub',
gitlab: 'GitLab',
gitea: 'Gitea',
forgejo: 'Forgejo',
asana: 'Asana',
zenkit: 'Zenkit',
};
const sourceName = sourceNameByKey[Session.get('importSource')] || 'JSON';
return `${TAPi18n.__('import')} / ${sourceName}`;
},
});
// Helper to find the closest ancestor template instance by name
function findParentTemplateInstance(childTemplateInstance, parentTemplateName) {
let view = childTemplateInstance.view;
+51 -3
View File
@@ -119,6 +119,7 @@ body.list-resizing-active * {
}
/* More aggressive override for any container that might interfere */
.js-lists .list.list-resizing,
.js-swimlane .list.list-resizing,
.dragscroll .list.list-resizing,
[id^="swimlane-"] .list.list-resizing {
@@ -140,6 +141,18 @@ body.list-resizing-active * {
property, and without :not() this !important rule overrode the 30px collapsed
width, so collapsing a list that had a custom width left it full width.
See https://github.com/wekan/wekan/issues/5892 */
/* #6573: `.js-lists` is the selector that has to be here, and `.dragscroll` is
the one that cannot be relied on. In LISTS view the container is
`.swimlane.list-group.js-lists.dragscroll` - no `js-swimlane` class and no
`swimlane-<id>` id - so `.dragscroll` was the only one of the three that
matched, and the width of every list hung on it. Starting a card drag calls
suspendBoardDragscroll(), which REMOVES the dragscroll class from the board so
the same pointer cannot pan and drag at once (#6558); this rule stopped
matching with it, every list fell back to the flex rules below, and the whole
board re-flowed to fill the window the instant a card was picked up - then
snapped back on drop, when the class returned. Both views' containers carry
`js-lists`, and nothing removes it. */
.js-lists .list[style*="--list-width"]:not(.list-collapsed),
.js-swimlane .list[style*="--list-width"]:not(.list-collapsed),
.dragscroll .list[style*="--list-width"]:not(.list-collapsed),
[id^="swimlane-"] .list[style*="--list-width"]:not(.list-collapsed) {
@@ -163,6 +176,7 @@ body.list-resizing-active * {
in mobile mode on a desktop browser the inline width was there and the lists stayed
at their desktop width while everything else was in mobile layout. Same three
selectors, prefixed with body.mobile-mode so this one is the more specific. */
body.mobile-mode .js-lists .list[style*="--list-width"]:not(.list-collapsed),
body.mobile-mode .js-swimlane .list[style*="--list-width"]:not(.list-collapsed),
body.mobile-mode .dragscroll .list[style*="--list-width"]:not(.list-collapsed),
body.mobile-mode [id^="swimlane-"] .list[style*="--list-width"]:not(.list-collapsed) {
@@ -454,11 +468,20 @@ form.inlined-form .list-composer {
}
.list-header .list-header-name {
display: block;
font-size: 14px;
/* #6465: the type scale of a list, from the outside in. A list heading is to
its cards what an h2 is to its paragraphs, and it had ended up SMALLER than
them: the heading was 14px while a minicard title and the "Add card" link
both inherited the 16px document size, so the column looked like it was
labelled in fine print and the affordance to add a card shouted louder than
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;
line-height: 1.2;
margin: 0;
font-weight: bold;
min-height: 14px;
min-height: 16px;
min-width: 56px;
overflow-wrap: break-word;
word-wrap: break-word;
@@ -732,6 +755,23 @@ form.inlined-form .list-composer {
.list-header .highlight {
color: #ce1414;
}
/* #6465: the WIP counter belongs on the title line, and the header of a list
that has one must be as tall as its neighbours - the report showed "(2/5)"
dropped onto a second line under the title, making that one column's header
taller than the lists either side of it.
The counter is already inside the h2, right after the title (listHeader.jade),
but the title is a `+viewer`, and `.viewer` is display: block with a 22px
min-height: a block in the middle of a heading pushes everything after it onto
the next line and reserves a band under it. Inline, the counter stays where the
markup already puts it. Same treatment the quick-access header and the table
view give their viewers. */
.list-header .list-header-name .viewer,
.list-header .list-header-name .viewer p {
display: inline;
min-height: 0;
margin: 0;
}
.list-header .cardCount {
color: #8c8c8c;
font-size: 12px;
@@ -784,12 +824,20 @@ form.inlined-form .list-composer {
margin-bottom: 9px;
}
.list-body .minicards .add-controls button {
min-height: 50px;
/* Was min-height: 50px - taller than a minicard is for its title, so the
"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;
}
.list-body .open-minicard-composer {
border-radius: 2px;
color: #8c8c8c;
display: block;
/* Smaller than a card title, because it is not a card: it is the affordance
for making one. At the inherited 16px it was the largest text in the list
body, level with the board's real content. */
font-size: 13px;
padding: 7px 10px;
position: relative;
text-decoration: none;
+11
View File
@@ -3,6 +3,10 @@ import { TAPi18n } from '/imports/i18n';
import { EscapeActions } from '/client/lib/escapeActions';
import { MultiSelection } from '/client/lib/multiSelection';
import { Utils } from '/client/lib/utils';
import {
suspendBoardDragscroll,
resumeBoardDragscroll,
} from '/client/lib/boardDragscroll';
import Cards from '/models/cards';
import {
isDegenerateSortGap,
@@ -285,8 +289,15 @@ Template.list.onRendered(function () {
ui.placeholder.height(ui.helper.height());
EscapeActions.executeUpTo('popup-close');
if (boardComponent) boardComponent.setIsDragging(true);
// #6558: the same pointer must not ALSO pan the lane and the canvas -
// that is what made a card drag scroll the list, scroll the board, and
// move the card all at once. The `sort` handler below does the one
// auto-scroll a drag needs, at the edges.
suspendBoardDragscroll();
},
stop(evt, ui) {
// #6558: panning is available again the moment the drag is over.
resumeBoardDragscroll();
// To attribute the new index number, we need to get the DOM element
// of the previous and the following card -- if any.
const prevCardDom = ui.item.prev('.js-minicard').get(0);
+39 -8
View File
@@ -16,6 +16,10 @@ import { sortCardsByTitle } from '/models/lib/sortCardsByTitle';
import { labelMatchesTerm } from '/models/lib/labelAutocomplete';
import { memberMatchesTerm } from '/models/lib/memberAutocomplete';
import { isLazyCards, BoardListCardCounts, windowCountId } from '/client/lib/lazyCards';
import {
shouldShowLoadMoreSpinner,
renderableCardsSelector,
} from '/client/lib/listCardWindow';
import {
mutationsChangeDragGeometry,
findActiveCardDrag,
@@ -398,12 +402,15 @@ Template.listBody.helpers({
tpl.subscribe('boardCardsWindow', list.boardId, mongoSelector, sortBy, limit);
tpl.subscribe(
'boardListCardCount',
windowCountId(list._id, swimlaneId),
// The selector is part of the count document's id: without it a filter
// change re-subscribed under the SAME id and could keep the previous
// filter's count (see models/lib/cardsLoading.js).
windowCountId(list._id, swimlaneId, mongoSelector),
list.boardId,
mongoSelector,
);
}
const ret = ReactiveCache.getCards(mongoSelector, {
const ret = ReactiveCache.getCards(renderableCardsSelector(mongoSelector), {
// sort: ['sort'],
sort: sortBy,
limit,
@@ -414,14 +421,38 @@ Template.listBody.helpers({
showSpinner(swimlaneId) {
const tpl = Template.instance();
const list = Template.currentData();
if (isLazyCards()) {
const limit = tpl.cardlimit.get();
// The same selector and sort cardsWithLimit renders with, so "how many cards
// are on screen" is measured against what is actually on screen. The two used
// to be built in different places from different sources, which is how the
// spinner came to disagree with an empty list.
const selector = listCardsSelector(
list._id,
swimlaneId,
list.orphanedCardsSwimlaneIds
? list.orphanedCardsSwimlaneIds(swimlaneId)
: undefined,
);
const mongoSelector = Filter.mongoSelector(selector);
const renderSelector = renderableCardsSelector(mongoSelector);
const loaded = ReactiveCache.getCards(renderSelector, { limit }).length;
let total;
if (isLazyCards(list.boardId)) {
// In lazy mode minimongo only holds the loaded window, so the total comes
// from the server count published into BoardListCardCounts.
const countDoc = BoardListCardCounts.findOne(windowCountId(list._id, swimlaneId));
const total = countDoc ? countDoc.count : 0;
return total > tpl.cardlimit.get();
// from the server count published into BoardListCardCounts. (This used to
// ask isLazyCards() with no board, which could disagree with the branch
// cardsWithLimit took for the same list.)
const countDoc = BoardListCardCounts.findOne(
windowCountId(list._id, swimlaneId, mongoSelector),
);
total = countDoc ? countDoc.count : 0;
} else {
total = ReactiveCache.getCards(renderSelector).length;
}
return list.cards(swimlaneId).length > tpl.cardlimit.get();
return shouldShowLoadMoreSpinner({ total, loaded, limit });
},
canSeeAddCard() {
+17 -5
View File
@@ -87,7 +87,7 @@ template(name="listHeader")
i.fa.fa-plus-square-o
unless currentUser.isWorker
if isTouchScreenOrShowDesktopDragHandles
a.list-header-handle.handle.js-list-handle
a.list-header-handle.handle.nodragscroll.js-list-handle
i.fa.fa-arrows
else if currentUser.isBoardMember
if isWatching
@@ -96,7 +96,7 @@ template(name="listHeader")
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
if isTouchScreenOrShowDesktopDragHandles
a.list-header-handle-desktop.handle.js-list-handle(title="{{_ 'drag-list'}}")
a.list-header-handle-desktop.handle.nodragscroll.js-list-handle(title="{{_ 'drag-list'}}")
i.fa.fa-arrows
unless collapsed
div.list-header-menu
@@ -107,7 +107,7 @@ template(name="listHeader")
//
a.fa.js-list-star.list-header-plus-top(class="fa-star{{#unless starred}}-o{{/unless}}")
if isTouchScreenOrShowDesktopDragHandles
a.list-header-handle-desktop.handle.js-list-handle(title="{{_ 'drag-list'}}")
a.list-header-handle-desktop.handle.nodragscroll.js-list-handle(title="{{_ 'drag-list'}}")
i.fa.fa-arrows
if canSeeAddCard
a.js-add-card.list-header-plus-top(title="{{_ 'add-card-to-top-of-list'}}")
@@ -128,6 +128,16 @@ template(name="editListTitleForm")
i.fa.fa-times-thin
template(name="listActionPopup")
//- Copy link, FIRST and above every permission check - see the swimlane
popup: copying an address is reading, not editing.
models/lib/boardItemUrl.js, docs/Design/Page/Board-Item-Links.md
ul.pop-over-list
li: a.js-copy-list-link
i.fa.fa-link
span
| {{_ 'copy-link-to-clipboard'}}
span.copied-tooltip {{_ 'copied'}}
hr
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
ul.pop-over-list
@@ -226,8 +236,10 @@ template(name="listMorePopup")
= ' '
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
//- #6459: was {{ rootUrl }}, a helper that does not exist anywhere, so the
//- link input was always empty. absoluteUrl resolves a URL via a card on
//- this list.
//- link input was always empty. absoluteUrl was then resolved through a
//- CARD of this list, so the box showed a card's address - and nothing at
//- all for an empty list. A list has its own address now.
//- models/lib/boardItemUrl.js
input.inline-input(type="text" readonly value="{{ absoluteUrl }}")
| {{_ 'added'}}
span.date(title=list.createdAt) {{ displayDate createdAt 'LLL' }}
+13
View File
@@ -278,6 +278,19 @@ Template.listActionPopup.helpers({
});
Template.listActionPopup.events({
// Copy the list's own address. `absoluteUrl` so what lands in the
// clipboard can be pasted anywhere - a relative path is only a link inside
// this page. The popup stays open long enough to show "Copied"; it is the
// confirmation, and a menu that vanishes the instant you click leaves you
// wondering whether anything happened. models/lib/boardItemUrl.js
'click .js-copy-list-link'(event, tpl) {
event.preventDefault();
const list = Template.currentData();
if (!list) return;
const url = list.absoluteUrl();
if (!url) return;
Utils.showCopied(Utils.copyTextToClipboard(url), tpl.$('.copied-tooltip'));
},
'click .js-list-subscribe'() {},
'click .js-add-card.list-header-plus-top'(event) {
const listDom = $(`#js-list-${this._id}`)[0];
@@ -1,11 +1,3 @@
template(name="accessibilityHeaderBar")
if currentUser
h1
if isAccessibilityEnabled
= accessibilityTitle
else
| {{_ 'accessibility'}}
template(name="accessibility")
if currentUser
.accessibility-page
+6 -6
View File
@@ -3,10 +3,11 @@ import { TAPi18n } from '/imports/i18n';
import AccessibilitySettings from '/models/accessibilitySettings';
// Shared helpers for both accessibility templates
import { accessibilityPageTitle } from '/client/lib/pageTitleSources';
const accessibilityHelpers = {
accessibilityTitle() {
const setting = AccessibilitySettings.findOne({});
return setting && setting.title ? setting.title : TAPi18n.__('accessibility-title');
return accessibilityPageTitle() || TAPi18n.__('accessibility-title');
},
accessibilityContent() {
const setting = AccessibilitySettings.findOne({});
@@ -29,9 +30,8 @@ Template.accessibility.onCreated(function () {
Template.accessibility.helpers(accessibilityHelpers);
// Header bar component
Template.accessibilityHeaderBar.onCreated(function () {
// The subscription the header bar used to make; the top header bar names this
// page from the same setting.
Template.accessibility.onCreated(function () {
Meteor.subscribe('accessibilitySettings');
});
Template.accessibilityHeaderBar.helpers(accessibilityHelpers);
-4
View File
@@ -1,7 +1,3 @@
template(name="brokenCardsHeaderBar")
h1
| {{_ 'broken-cards'}}
template(name="brokenCards")
if currentUser
if searching.get
+27
View File
@@ -47,3 +47,30 @@
margin: 0 0 4px;
padding: 0;
}
/* As many colour swatches visible at once as the width allows.
*
* The swatch list is shared with the board-background picker, where it is a
* float-based TWO-column grid (boardsList.css: `float: inline-start; width: 50%`).
* Two columns is right for background thumbnails; for the Select Color picker it
* meant Flat, Clear, Dark and Special each ran down the popup in a narrow pair,
* and most of them were below the fold however wide the browser was.
*
* Auto-filling columns instead - the same answer as the Change Language popup
* (popup.css), for the same reason. `auto-fill` + `minmax` takes as many columns
* as fit and collapses to one on a narrow window, so no media query is needed and
* a phone is unaffected. Scoped to `.theme-color-picker`, so the board-background
* picker keeps its pairs.
*
* The float rules have to be undone explicitly: a floated grid item is still
* floated, and `width: 50%` would leave every other column half empty. */
.theme-color-picker .board-backgrounds-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
}
.theme-color-picker .board-backgrounds-list .board-background-select {
float: none;
width: auto;
padding: 0;
}
+12 -15
View File
@@ -1,18 +1,15 @@
template(name="dueCardsHeaderBar")
if currentUser
h1
i.fa.fa-calendar
| {{_ 'dueCards-title'}}
.board-header-btns.left
a.board-header-btn.js-due-cards-view-change(title="{{_ 'dueCardsViewChange-title'}}")
i.fa.fa-caret-down
if $eq dueCardsView 'me'
i.fa.fa-user
| {{_ 'dueCardsViewChange-choice-me'}}
if $eq dueCardsView 'all'
i.fa.fa-users
| {{_ 'dueCardsViewChange-choice-all'}}
template(name="dueCardsControls")
//- The controls of this page. They were a group in its second header bar,
beside a title the first bar now carries; they are a view of the shared
page sidebar. models/lib/pageSidebar.js, docs/Design/Page/Header.md
a.board-header-btn.js-due-cards-view-change(title="{{_ 'dueCardsViewChange-title'}}")
i.fa.fa-caret-down
if $eq dueCardsView 'me'
i.fa.fa-user
| {{_ 'dueCardsViewChange-choice-me'}}
if $eq dueCardsView 'all'
i.fa.fa-users
| {{_ 'dueCardsViewChange-choice-all'}}
template(name="dueCardsModalTitle")
if currentUser
+2 -2
View File
@@ -5,7 +5,7 @@ import { filterAndSortDueCards } from '/client/components/main/dueCardsLogic';
// const subManager = new SubsManager();
Template.dueCardsHeaderBar.helpers({
Template.dueCardsControls.helpers({
dueCardsView() {
// eslint-disable-next-line no-console
// console.log('sort:', Utils.dueCardsView());
@@ -13,7 +13,7 @@ Template.dueCardsHeaderBar.helpers({
},
});
Template.dueCardsHeaderBar.events({
Template.dueCardsControls.events({
'click .js-due-cards-view-change': Popup.open('dueCardsViewChange'),
});
+12 -15
View File
@@ -1,18 +1,15 @@
template(name="globalSearchHeaderBar")
if currentUser
h1
i.fa.fa-search
| {{_ 'globalSearch-title'}}
.board-header-btns.left
a.board-header-btn.js-global-search-view-change(title="{{_ 'globalSearchViewChange-title'}}")
i.fa.fa-caret-down
if $eq globalSearchView 'me'
i.fa.fa-user
| {{_ 'globalSearchViewChange-choice-me'}}
if $eq globalSearchView 'all'
i.fa.fa-users
| {{_ 'globalSearchViewChange-choice-all'}}
template(name="globalSearchControls")
//- The controls of this page. They were a group in its second header bar,
beside a title the first bar now carries; they are a view of the shared
page sidebar. models/lib/pageSidebar.js, docs/Design/Page/Header.md
a.board-header-btn.js-global-search-view-change(title="{{_ 'globalSearchViewChange-title'}}")
i.fa.fa-caret-down
if $eq globalSearchView 'me'
i.fa.fa-user
| {{_ 'globalSearchViewChange-choice-me'}}
if $eq globalSearchView 'all'
i.fa.fa-users
| {{_ 'globalSearchViewChange-choice-all'}}
template(name="globalSearchViewChangePopup")
if currentUser
+2 -2
View File
@@ -11,13 +11,13 @@ import { Utils } from '/client/lib/utils';
// #3392-adjacent: "my cards / all cards" toggle for global search (mirrors the
// due-cards view toggle). When "me", the current user is injected as a `user:`
// predicate (member OR assignee), reusing the existing search logic.
Template.globalSearchHeaderBar.helpers({
Template.globalSearchControls.helpers({
globalSearchView() {
return Utils.globalSearchView();
},
});
Template.globalSearchHeaderBar.events({
Template.globalSearchControls.events({
'click .js-global-search-view-change': Popup.open('globalSearchViewChange'),
});
+11 -1
View File
@@ -3,7 +3,7 @@ import { Tracker } from 'meteor/tracker';
import { Session } from 'meteor/session';
import { ReactiveCache } from '/imports/reactiveCache';
import { Template } from 'meteor/templating';
import { activeAccent } from '/models/lib/themeAccents';
import { activeAccent, activeFill } from '/models/lib/themeAccents';
// #5778 + docs/Theme/Theme.md: apply the theme color to the whole UI (All Boards,
// Search, Admin Panel, My Cards, etc.) via a `board-color-<name>` class on <body>,
@@ -83,6 +83,16 @@ Meteor.startup(() => {
} else {
root.style.removeProperty('--theme-accent');
}
// ...and what to PAINT a themed control with, which is not the same
// thing: --theme-accent is one colour, and a colour-slide theme's fill is
// a gradient. A control that reads only the accent came out flat while
// the header above it slid. models/lib/themeAccents.js
const fill = activeFill(color, custom);
if (fill) {
root.style.setProperty('--theme-accent-fill', fill);
} else {
root.style.removeProperty('--theme-accent-fill');
}
if (c1) {
document.body.classList.add('has-custom-theme-color');
} else {
File diff suppressed because it is too large. Load diff
+222 -63
View File
@@ -9,22 +9,46 @@ template(name="header")
//- which forwards it to whatever actually scrolls. Without it the page-level
//- dragscroll on the body element would scroll the page at the same time.
#header-quick-access.nodragscroll(class="{{themeColorClass}}" role="navigation" aria-label="{{_ 'all-boards'}}")
// Home icon - always at left side of logo
//- Home icon - always at left side of logo. The house is the link to All
Boards; the TEXT beside it is the page you are on, which on a board is
the board's title. It used to say "All Boards" on every page, so the one
bar that is always on screen named a place you were not.
models/lib/pageTitles.js, docs/Design/Page/Header.md
span.home-icon.allBoards
a(href="{{pathFor 'home'}}")
//- `header-home-link`: the rules that collapse this link's LABEL on a
narrow screen were written as `.home-icon a`, which also matched the
rename pencil beside it - an anchor in the same box - and collapsed
its icon to nothing. They name the link itself now.
a.header-home-link(href="{{pathFor 'home'}}" title="{{_ 'all-boards'}}")
i.fa.fa-home
| {{_ 'all-boards'}}
//- The ROOT only - "All Boards", "Admin Panel" - with the whole path
in the tooltip. The path is what the page is, but it grows: a
workspace nests as deep as its tree does, and this bar is the one
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}}")
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
if currentSetting.customTopLeftCornerLogoImageUrl
if currentSetting.customTopLeftCornerLogoLinkUrl
a(href="{{currentSetting.customTopLeftCornerLogoLinkUrl}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
a.header-logo(href="{{currentSetting.customTopLeftCornerLogoLinkUrl}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
img(src="{{currentSetting.customTopLeftCornerLogoImageUrl}}" height="{{#if currentSetting.customTopLeftCornerLogoHeight}}#{currentSetting.customTopLeftCornerLogoHeight}{{else}}27{{/if}}" width="auto" margin="0" padding="0")
unless currentSetting.customTopLeftCornerLogoLinkUrl
img(src="{{currentSetting.customTopLeftCornerLogoImageUrl}}" height="{{#if currentSetting.customTopLeftCornerLogoHeight}}#{currentSetting.customTopLeftCornerLogoHeight}{{else}}27{{/if}}" width="auto" margin="0" padding="0" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
img.header-logo(src="{{currentSetting.customTopLeftCornerLogoImageUrl}}" height="{{#if currentSetting.customTopLeftCornerLogoHeight}}#{currentSetting.customTopLeftCornerLogoHeight}{{else}}27{{/if}}" width="auto" margin="0" padding="0" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
unless currentSetting.customTopLeftCornerLogoImageUrl
div#headerIsSettingDatabaseCallDone
//- `header-logo` on all three branches - a custom logo that links, one
that does not, and the stock one - so the bar can space the logo the
same way whichever is drawn. docs/Design/Page/Header.md
div#headerIsSettingDatabaseCallDone.header-logo
img(src="{{pathFor '/logo-header.png'}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
// Mobile/desktop mode toggle (swapped with zoom)
@@ -43,70 +67,167 @@ template(name="header")
unless isShowDesktopDragHandles
i.fa.fa-ban
if isMiniScreen
ul.header-quick-access-list
//- Everything from here on hugs the END of the bar - the right edge in a
left-to-right language, the LEFT edge in a right-to-left one. It is one
`margin-inline-start: auto` on this wrapper, which is a LOGICAL
property, so the mirroring is what the browser already does for the
writing direction rather than a second rule to keep in step.
The items before it - the home link and page title, the logo, the
phone/desktop toggle and the drag-handles toggle - stay at the start.
.header-quick-access-end
//- Starred boards, as a DROPDOWN rather than a row of links.
The bar listed every starred board inline, which is the widest thing in
it and grows with the number of boards you star. It is one button now,
laid out like the view menu beside it - a caret, then the COUNT of
them - and the names are in the popup it opens.
No star of its own: the button beside it is a star, and two stars in a
row are read as one control that has somehow been drawn twice. The
count is what this button is about, and the caret says it opens.
On a phone the popup is full width, which body.mobile-mode does for
every pop-over; nothing here has to ask for it.
//- One GROUP, drawn with a rounded outline of its own, the way the
phone/desktop toggle beside it is: the two buttons are about the same
thing - how many boards you have starred, and whether this one is
among them - and without the outline they read as two unrelated icons
that happen to sit next to each other.
White, where the toggle's is black: the toggle is a white box on the
bar, so its border is dark against it; these buttons sit on the bar's
own colour, so theirs has to be light against that.
.header-star-group
a.board-header-btn.js-open-starred-boards(title="{{_ 'allboards.starred'}}")
i.fa.fa-caret-down
span.board-star-counter {{starredBoardsCount}}
//- ...and whether THIS one is one of them - the third control of the
group, so it reads caret, count, star, the way a browser's
bookmarks menu does.
On a BOARD that is the board's own star, which also carries the
count of members who starred it.
client/components/boards/boardHeader.jade
On every other page it stars THE PAGE. All Boards / Remaining, a
workspace, Admin Panel / Settings / Version - each of those has had
its own address since the All Boards and Admin Panel URLs landed,
so each of them is a place that can be kept.
TWO conditions rather than if/else: a comment between an `if` and
its `else` is a jade parse error, and `isPageStarrable` already
answers false on a board. docs/Features/Board/Starred.md
if isBoardPage
+boardStarButton
if isPageStarrable
a.board-header-btn.js-star-page(class="{{#if isCurrentPageStarred}}is-active{{/if}}"
title="{{#if isCurrentPageStarred}}{{_ 'click-to-unstar-page'}}{{else}}{{_ 'click-to-star-page'}}{{/if}}")
i.fa(class="fa-star{{#unless isCurrentPageStarred}}-o{{/unless}}")
//- On a phone, INSIDE a list, the bar still shows that board's lists:
that is a different thing from the starred boards, and it is what tells
you where you are while you swipe between lists.
if isMiniScreen
if currentList
each currentBoard.lists
li(class="{{#if $.Session.equals 'currentList' _id}}current{{/if}}")
a.js-select-list
+viewer
= title
else
each currentUser.starredBoards
li(class="{{#if $.Session.equals 'currentBoard' _id}}current{{/if}}")
a(href="{{pathFor 'board' id=_id slug=slug}}")
+viewer
= title
#header-new-board-icon
else
ul.header-quick-access-list
//li
//
a(href="{{pathFor 'public'}}")
//
span.fa.fa-globe
//
| {{_ 'public'}}
each currentUser.starredBoards
li(class="{{#if $.Session.equals 'currentBoard' _id}}current{{/if}}")
a(href="{{pathFor 'board' id=_id slug=slug}}")
+viewer
= title
else
li.current(title="{{_ 'quick-access-description'}}")
| {{_ 'quick-access-description'}}
//
Hide duplicate create board button,
//
because it did not show board templates correctly.
//a#header-new-board-icon.js-create-board
//
i.fa.fa-plus(title="Create a new board")
ul.header-quick-access-list
each currentBoard.lists
li(class="{{#if $.Session.equals 'currentList' _id}}current{{/if}}")
a.js-select-list
+viewer
= title
#header-new-board-icon
//
Hide duplicate create board button,
//
because it did not show board templates correctly.
//a#header-new-board-icon.js-create-board
//
i.fa.fa-plus(title="Create a new board")
// Zoom controls (swapped with the mobile/desktop toggle)
.zoom-controls
span.zoom-level.js-zoom-level-click(title="{{_ 'click-to-change-zoom'}}")
span.zoom-display {{zoomLevel}}%
input.zoom-input.js-zoom-input(type="number" value=zoomLevel min="50" max="300" step="10" style="display: none;")
//- The "100%" zoom control that used to sit here is REMOVED. It scaled the
board with a CSS transform, which never worked properly - and WeKan
already has a font-size setting (Member Settings / Change Font), which is
what people actually wanted from it. Removed rather than left in place as
a control that does nothing.
// Notifications
+notifications
//- Everything the board's second header bar held, as icons, left of the
bell. client/components/boards/boardHeader.jade
if isBoardPage
+boardHeaderButtons
if currentSetting.customHelpLinkUrl
#header-help
a(href="{{currentSetting.customHelpLinkUrl}}", title="{{_ 'help'}}", target="_blank", rel="noopener noreferrer")
i.fa.fa-question-circle
//- All Boards' four controls - Sort, Search, Multi-Selection, Boards in
Archive - as icons, left of the bell, exactly where a board's own
controls are. client/components/boards/boardsList.jade
if isAllBoardsPage
+allBoardsHeaderButtons
+headerUserBar
//- The view menu of whatever page has one - Swimlanes / Lists / Calendar
/ Gantt / Table / Stats on a board, Lists / Table on All Boards. A view
menu says what you are looking AT, so it belongs beside the page's name
in the bar that is always on screen, not in a second bar or behind a
panel. docs/Design/Page/Header.md
if isBoardPage
+boardViewMenu
else if isAllBoardsPage
+allBoardsViewMenu
//- The Admin Panel's four tabs, to the LEFT of the bell. Drawn only in
the Admin Panel: they are navigation between its four pages.
client/components/settings/settingHeader.jade
if isAdminPanel
+adminPanelTabs
// Notifications
+notifications
//- Everything that belongs to YOU rather than to the page - the divider
that separates the two, help, your account, and the sidebar toggle
behind its own divider. This group is aligned to the END of the bar:
the right edge in a left-to-right language, the LEFT edge in a
right-to-left one, which one `margin-inline-start: auto` does by
itself because it is a LOGICAL property.
It is a box rather than `display: contents`, unlike the page's own
button groups, and deliberately: these five belong together, and an
avatar left on the first row with its hamburger on the second is worse
than the whole cluster moving down at once. docs/Design/Page/Header.md
.header-account-group
//- The divider is the seam. Everything before it belongs to the PAGE -
its controls, its view menu, the panel's tabs. The bar runs them
together otherwise, and the run of icons reads as one list of
unrelated things.
.separator
if currentSetting.customHelpLinkUrl
#header-help
a(href="{{currentSetting.customHelpLinkUrl}}", title="{{_ 'help'}}", target="_blank", rel="noopener noreferrer")
i.fa.fa-question-circle
+headerUserBar
//- The divider and the sidebar hamburger, at the end of the username.
They were the last item of the second header bar; they are here
because this bar is the one that is always on screen, and because
what the hamburger opens - the right sidebar - is now where a page
keeps its controls.
if hasSidebar
.board-header-btns.header-sidebar-toggle
.separator
a.board-header-btn.js-toggle-page-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
i.fa.fa-bars
//- The second bar renders ONLY where a page still has one. Most pages have
none now - their title is in the bar above and their controls are in a
sidebar - and `#header` is a coloured block with a height of its own, so
without this it painted a tall empty strip under the first bar on every one
of them. docs/Design/Page/Header.md
//- See the note above: dragging this bar is owned by headerDragscroll.js.
#header.nodragscroll(class="{{themeColorClass}}")
//-
The main bar is a colorful bar that provide all the meta-data for the
current page. This bar is contextual based.
If the user is not connected we display "sign in" and "log in" buttons.
#header-main-bar(class="{{#if wrappedHeader}}wrapper{{/if}}")
+Template.dynamic(template=headerBar)
if headerBar
#header.nodragscroll(class="{{themeColorClass}}")
//-
The main bar is a colorful bar that provide all the meta-data for the
current page. This bar is contextual based.
If the user is not connected we display "sign in" and "log in" buttons.
#header-main-bar(class="{{#if wrappedHeader}}wrapper{{/if}}")
+Template.dynamic(template=headerBar)
if appIsOffline
+offlineWarning
@@ -132,3 +253,41 @@ template(name="offlineWarning")
if canReconnectNow
br
a.app-try-reconnect {{_ 'app-try-reconnect'}}
//- The starred boards, by name. This is what the first header bar used to list
//- inline; it is a popup now, opened by the caret+star button, so the bar stays
//- one row however many boards you star.
//-
//- Shaped like boardChangeViewPopup - a `ul.pop-over-list` of links, with a
//- check on the one you are looking at - because it is the same kind of menu
//- and opens from the same kind of button. On a phone it is full width, which
//- body.mobile-mode does for every pop-over. docs/Design/Page/Header.md
template(name="starredBoardsPopup")
ul.pop-over-list
each currentUser.starredBoards
li
a(href="{{pathFor 'board' id=_id slug=slug}}")
i.fa.fa-star
+viewer
= title
if $.Session.equals 'currentBoard' _id
i.fa.fa-check
//- ...then the starred PAGES, under their own heading, because they are a
different kind of destination and a flat list of both would leave the reader
working out which rows are boards.
The link's text is the page's TITLE - the words its browser tab shows - so
a row says where it goes rather than making a path be read.
Nothing is drawn when there are none: an empty heading is a promise of rows
that are not there.
if currentUser.starredPages.length
hr
div.pop-over-list-title {{_ 'starred-pages'}}
ul.pop-over-list
each currentUser.starredPages
li
a(href="{{url}}")
i.fa.fa-star
span {{title}}
unless hasAnyStarred
ul.pop-over-list
li.no-items-message {{_ 'quick-access-description'}}
+274 -46
View File
@@ -1,12 +1,58 @@
import { Meteor } from 'meteor/meteor';
import { Session } from 'meteor/session';
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Announcements, {
announcementVersion,
shouldShowAnnouncement,
} from '/models/announcements';
import { Utils } from '/client/lib/utils';
// What this bar calls the page you are on. models/lib/pageTitles.js
import { headerTitle } from '/models/lib/pageTitles';
// The bookmark rules. docs/Features/Board/Starred.md
import { isStarrablePageUrl } from '/models/lib/starredPages';
import { headerPathVar } from '/client/lib/headerPathVar';
// The right sidebar the hamburger opens. On a board that is the board's own; on
// every other page it is the shared page sidebar.
import { getSidebarInstance } from '/client/features/sidebar/service';
import { toggleAllBoardsSidebar } from '/client/lib/allBoardsSidebar';
import { togglePageSidebar } from '/client/lib/pageSidebar';
import { hasOwnSidebar, hasPageSidebar, hasHamburger } from '/models/lib/pageSidebar';
import {
ADMIN_PAGE_KEYS,
ADMIN_PAGES,
ADMIN_PANEL_ROUTES,
adminPaneTitle,
} from '/models/lib/adminUrls';
// All Boards is four lists of boards under one name, and a workspace is a tree
// under one of them. models/lib/allBoardsUrls.js
import {
SECTION_WORKSPACES,
resolveSection,
sectionTitleKey,
splitWorkspacePath,
workspaceNamePath,
} from '/models/lib/allBoardsUrls';
// The same slugifier a board URL and a workspace URL are built with.
import getSlug from 'limax';
// The All Boards sections - Public has no Lists/Table choice, so it is not one.
const ALL_BOARDS_VIEW_ROUTES = ['home', 'allboards', 'allboards-templates', 'allboards-remaining'];
// Three pages carry a name of their own rather than a fixed one: an admin can
// rename Support and Accessibility, and Import names the source it is importing
// from. Asked for here rather than computed a second time.
import {
supportPageTitle,
accessibilityPageTitle,
importPageTitle,
} from '/client/lib/pageTitleSources';
function customPageTitle(routeName) {
if (routeName === 'support') return supportPageTitle();
if (routeName === 'accessibility') return accessibilityPageTitle();
if (routeName === 'import') return importPageTitle();
return '';
}
// Drag-to-scroll on the two top header bars (they are not scroll containers
// themselves, so the drag is forwarded to the board canvas / page scroller).
import '/client/lib/headerDragscroll';
@@ -20,6 +66,13 @@ Template.header.onCreated(function () {
templateInstance.currentSetting = new ReactiveVar();
templateInstance.isLoading = new ReactiveVar(false);
// Publish the page's path so the browser tab can carry it too. Here rather
// than in `Utils`, because this is the one place that already works it out
// and the header is on every page. client/lib/headerPathVar.js
templateInstance.autorun(() => {
headerPathVar.set(headerFullPath());
});
Meteor.subscribe('setting', {
onReady() {
templateInstance.currentSetting.set(ReactiveCache.getCurrentSetting());
@@ -44,6 +97,176 @@ Template.header.onCreated(function () {
});
});
Template.header.helpers({
// The page's title, beside the house icon. A board's own title wherever there
// is a board; otherwise the page's, by route name. Two helpers rather than
// one string, because a translated title has to go through {{_ }} and a board
// title must NOT (it is user text, and a board called "settings" is not the
// Admin Panel). docs/Design/Page/Header.md
// How many boards are starred, shown on the button the way a board's own star
// shows its count. Nothing is shown when none are.
// The button carries no star icon any more, so this number IS its label -
// shown even at zero, or the button would be a bare caret with nothing to
// say what it opens.
starredBoardsCount() {
const user = ReactiveCache.getCurrentUser();
if (!user) return 0;
// Boards AND pages: the group is one list of the places you keep, like a
// browser's bookmarks, so a count that left the pages out would say 2 above
// a dropdown showing five rows. docs/Features/Board/Starred.md
const boards = user.starredBoards ? user.starredBoards() : [];
const pages = user.starredPages ? user.starredPages() : [];
return boards.length + pages.length;
},
// The star at the end of the group, on a page that is not a board: it stars
// THIS PAGE. On a board the board's own star is drawn there instead - the
// board is what you are looking at, and its star is the one that already
// counts stars from every member.
isPageStarrable() {
if (Utils.getCurrentBoardId()) return false;
return isStarrablePageUrl(currentPagePath());
},
isCurrentPageStarred() {
const user = ReactiveCache.getCurrentUser();
return Boolean(user && user.hasStarredPage && user.hasStarredPage(currentPagePath()));
},
// Which page's view menu to draw, if any.
isBoardPage() {
return Boolean(Utils.getCurrentBoardId());
},
isAllBoardsPage() {
return ALL_BOARDS_VIEW_ROUTES.includes(FlowRouter.getRouteName());
},
// The Admin Panel is four routes; its tabs show on all of them.
isAdminPanel() {
return ADMIN_PANEL_ROUTES.includes(FlowRouter.getRouteName());
},
// Whether to draw the hamburger at all. A page with no sidebar and nothing to
// put in one must not offer a control that opens an empty panel.
hasSidebar() {
const route = FlowRouter.getRouteName();
// All Boards has a sidebar but no hamburger: its controls are in this bar,
// and Search and Multi-Selection open the sidebar into their own view. A
// hamburger there would open a menu listing what is already one click away.
if (!hasHamburger(route)) return false;
return Boolean(Utils.getCurrentBoardId()) || hasOwnSidebar(route) || hasPageSidebar(route);
},
headerTitleKey() {
const route = FlowRouter.getRouteName();
const board = Utils.getCurrentBoard();
return headerTitle(route, board && board.title, customPageTitle(route)).key || '';
},
headerTitleText() {
const route = FlowRouter.getRouteName();
const board = Utils.getCurrentBoard();
return headerTitle(route, board && board.title, customPageTitle(route)).title || '';
},
// The whole path as ONE string, for the title's tooltip: "All Boards /
// Starred", "Admin Panel / Settings / Version".
//
// The bar shows only the ROOT now. The path is what the page is, but it grows
// - a workspace nests as deep as its tree does - and this bar is the one
// strip always on screen and already short of width. The root names the
// place; the tooltip carries the rest.
//
// Resolved here rather than in the template because a `title` attribute is
// plain text: it cannot hold the `{{_ }}` calls the visible version used. A
// workspace's own name still does NOT go through the translator.
headerTitleFullPath() {
return headerFullPath();
},
});
// The whole path as ONE string: "All Boards / Starred", "Admin Panel /
// Settings / Version". A plain function, because both the tooltip helper and
// the browser tab need it and a Blaze helper cannot be called from outside its
// template.
function headerFullPath() {
const route = FlowRouter.getRouteName();
const board = Utils.getCurrentBoard();
const title = headerTitle(route, board && board.title, customPageTitle(route));
const root = title.key ? TAPi18n.__(title.key) : title.title;
const parts = [root].concat(
headerTitleTrailOf().map(part => (part.key ? TAPi18n.__(part.key) : part.title)),
);
return parts.filter(Boolean).join(' / ');
}
// The page's own address, relative - what a bookmark stores.
//
// From the ROUTER rather than from `window.location`, so it is reactive: the
// star has to turn hollow the moment you navigate away from a page you starred,
// and `window.location.pathname` is not something Blaze can watch.
//
// The query string comes with it. `/allboards/workspaces/engineering` and the
// same page with a filter on are two different things to bookmark, and dropping
// the query would silently star the wrong one.
function currentPagePath() {
const current = FlowRouter.current();
if (!current) return '';
const path = current.path || '';
return typeof path === 'string' ? path : '';
}
// The path after the page's own name: "Settings / Version" under Admin Panel,
// "Workspaces / Engineering / Backend" under All Boards.
//
// ONE list rather than a helper per segment, because the two pages that have a
// path do not have the same NUMBER of segments - the Admin Panel always has
// two, and a workspace has as many as its tree is deep. A fixed set of helpers
// can only serve whichever page was written first.
//
// Each entry is one of the two forms a left-menu entry has: `key` for something
// translated, `title` for text that must NOT go through the translator - a
// workspace's name is what the person typed, and a workspace called "starred"
// is not the Starred section.
//
// A plain function, not a helper: the tooltip needs it too, and a Blaze helper
// cannot call a sibling helper (`this` there is the data context).
//
// Read from the URL and from the user document, never from the pages
// themselves: the header is a separate Blaze instance, and importing a page
// module from here once ran it before its own template was registered, which
// threw and aborted every module after it.
function headerTitleTrailOf() {
const route = FlowRouter.getRouteName();
// A board's title is the whole name of that page, so there is no path.
if (Utils.getCurrentBoardId()) return [];
const page = ADMIN_PAGE_KEYS.find(k => ADMIN_PAGES[k].routeName === route);
if (page) {
const subKey = headerTitle(route).subKey;
const params = FlowRouter.current().params || {};
const pane = adminPaneTitle(page, params.pane || ADMIN_PAGES[page].defaultSlug);
const trail = subKey ? [{ key: subKey }] : [];
if (pane.titleKey) trail.push({ key: pane.titleKey });
else if (pane.title) trail.push({ title: pane.title });
return trail;
}
if (!ALL_BOARDS_VIEW_ROUTES.includes(route)) return [];
const params = FlowRouter.current().params || {};
const section = resolveSection(params.section);
const trail = [{ key: sectionTitleKey(section) }];
if (section !== SECTION_WORKSPACES) return trail;
// The workspaces the URL walks through, by NAME. The tree is on the user
// document, which is where the All Boards page reads it from too.
const user = ReactiveCache.getCurrentUser();
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
for (const name of workspaceNamePath(tree, splitWorkspacePath(params.path), getSlug)) {
trail.push({ title: name });
}
return trail;
}
Template.header.helpers({
wrappedHeader() {
return !Session.get('currentBoard');
},
@@ -98,14 +321,6 @@ Template.header.helpers({
return announcement && announcement.body;
},
zoomLevel() {
const sessionZoom = Session.get('wekan-zoom-level');
if (sessionZoom !== undefined) {
return Math.round(sessionZoom * 100);
}
return Math.round(Utils.getZoomLevel() * 100);
},
mobileMode() {
const sessionMode = Session.get('wekan-mobile-mode');
if (sessionMode !== undefined) {
@@ -116,48 +331,47 @@ Template.header.helpers({
});
Template.header.events({
'click .js-create-board': Popup.open('headerBarCreateBoard'),
'click .js-zoom-level-click'(evt) {
const $zoomDisplay = $(evt.currentTarget).find('.zoom-display');
const $zoomInput = $(evt.currentTarget).find('.zoom-input');
// The starred boards, by name. They were listed inline in this bar; the bar
// is one row now and the names are in the popup. docs/Design/Page/Header.md
// Titled "Starred Boards", from the key the app already has for that phrase,
// rather than a `starredBoardsPopup-title` of its own: the convention key
// would be a second copy of one phrase in all 147 language files, English in
// every one of them at first, so most languages would show English for
// something they have already translated. A title also gives the popup its
// header, and with it the close button - without one it renders as a
// `no-title` pop-over with nothing to shut it but clicking away.
'click .js-open-starred-boards': Popup.open('starredBoards', { titleKey: 'allboards.starred' }),
// Hide display, show input
$zoomDisplay.hide();
$zoomInput.show().focus().select();
// 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`
// would make the reader parse a path to find out where it goes.
'click .js-star-page'(evt) {
evt.preventDefault();
const url = currentPagePath();
if (!isStarrablePageUrl(url)) return;
Meteor.call('toggleStarredPage', url, document.title || url, (err) => {
if (err) console.error(err);
});
},
'keypress .js-zoom-input'(evt) {
if (evt.which === 13) {
// Enter key
const newZoomPercent = parseInt(evt.target.value);
if (
!isNaN(newZoomPercent) &&
newZoomPercent >= 50 &&
newZoomPercent <= 300
) {
const newZoom = newZoomPercent / 100;
Utils.setZoomLevel(newZoom);
// Hide input, show display
const $zoomDisplay = $(evt.target).siblings('.zoom-display');
const $zoomInput = $(evt.target);
$zoomInput.hide();
$zoomDisplay.show();
} else {
alert('Please enter a zoom level between 50% and 300%');
evt.target.focus().select();
}
// The one hamburger, in the bar that is always on screen. Which sidebar it
// toggles depends on where you are: a board has its own, and every other page
// shares one. docs/Design/Page/Header.md
'click .js-toggle-page-sidebar'(evt) {
evt.preventDefault();
const boardSidebar = Utils.getCurrentBoardId() ? getSidebarInstance() : null;
if (boardSidebar && typeof boardSidebar.toggle === 'function') {
boardSidebar.toggle();
return;
}
// All Boards and Public keep their own; every other page shares one.
if (hasOwnSidebar(FlowRouter.getRouteName())) {
toggleAllBoardsSidebar();
return;
}
togglePageSidebar();
},
'blur .js-zoom-input'(evt) {
// When input loses focus, hide it and show display
const $zoomDisplay = $(evt.target).siblings('.zoom-display');
const $zoomInput = $(evt.target);
$zoomInput.hide();
$zoomDisplay.show();
},
'click .js-create-board': Popup.open('headerBarCreateBoard'),
'click .js-mobile-mode-toggle'() {
const currentMode = Utils.getMobileMode();
Utils.setMobileMode(!currentMode);
@@ -210,3 +424,17 @@ Template.offlineWarning.events({
Meteor.reconnect();
},
});
// The dropdown lists two kinds of thing, so "is it empty" is a question about
// both. Without this the "Star a board to add a shortcut" line was drawn under
// a list of starred pages, because the `each` it hung off only knew about
// boards. docs/Features/Board/Starred.md
Template.starredBoardsPopup.helpers({
hasAnyStarred() {
const user = ReactiveCache.getCurrentUser();
if (!user) return false;
const boards = user.starredBoards ? user.starredBoards() : [];
const pages = user.starredPages ? user.starredPages() : [];
return boards.length + pages.length > 0;
},
});
@@ -1,9 +1,3 @@
template(name="shortcutsHeaderBar")
h1
a.back-btn(href="{{pathFor 'home'}}")
i.fa.fa-chevron-left
| {{_ 'keyboard-shortcuts'}}
template(name="shortcutsModalTitle")
h2
i.fa.fa-keyboard-o
+36 -27
View File
@@ -757,7 +757,13 @@ a:not(.disabled).is-active i.fa {
/* Header mobile layout */
#header {
padding: 8px;
/* No horizontal padding, at any width: the INSET of the first header bar is
the bar's own gutter (--wekan-header-gutter, header.css), and padding here
is added on top of it. It used to be 8px here, 16px on a tablet and 8px on
a large display, so the All Boards house started at a different X on every
screen size - and on none of them on the line the left menu's rows start
on. docs/Design/Page/Header.md */
padding: 8px 0;
/* Keep top bar on a single row on small screens */
flex-wrap: nowrap;
align-items: center;
@@ -814,28 +820,25 @@ a:not(.disabled).is-active i.fa {
font-size: 16px;
margin-inline-end: 0;
}
/* `.allBoards` is the same element as `.home-icon`; its 15px side padding is
desktop spacing. */
/* `.allBoards` is the same element as `.home-icon`, and it carries no inline
padding at any width now (header.css). This rule cut a 15px desktop padding
down to 6px on a phone, which is half of why the All Boards house started at
a different X on every screen size. docs/Design/Page/Header.md */
#header-quick-access .allBoards {
padding: 4px 6px;
}
/* Exactly ONE auto margin in the row. Two of them (zoom AND user bar) each
absorbed the remaining space on their line, which is what forced the wrap:
the first auto margin filled line 1, so everything after it started line 2.
The single one here right-aligns the user menu when there is space left and
collapses to 0 when there is not. */
#header-quick-access .zoom-controls {
margin-inline-start: 0;
margin-inline-end: 0;
padding-block: 4px;
padding-inline: 0;
}
.mobile-mode-toggle {
margin-inline-end: 0;
}
/* No auto margin: the bar packs from the START and wraps forward at every
width, so nothing is pushed to the far end. This one survived the sweep
that removed the others because it lives in layouts.css rather than
header.css. docs/Design/Page/Header.md */
#header-user-bar {
margin-inline-start: auto;
margin-inline-start: 0;
}
/* Ensure header elements don't wrap on very small screens */
@@ -860,12 +863,6 @@ a:not(.disabled).is-active i.fa {
max-width: 84px;
}
/* Ensure zoom controls are compact on mobile */
.zoom-controls .zoom-level {
padding: 4px 8px;
font-size: 12px;
}
/* Modal mobile optimization */
#modal .modal-content,
#modal .modal-content-wide {
@@ -939,8 +936,12 @@ a:not(.disabled).is-active i.fa {
padding: 0 12px 12px 0;
}
/* Vertical only. The 16px it used to carry sideways pushed the whole first bar
- the All Boards house first of all - 16px further in than at any other
width. The inset is the bar's own gutter, once, at every width.
docs/Design/Page/Header.md */
#header {
padding: 12px 16px;
padding: 12px 0;
}
#modal .modal-content {
@@ -957,8 +958,12 @@ a:not(.disabled).is-active i.fa {
gap: 20px;
}
.setting-content .content-body .side-menu {
width: 250px;
/* The DEFAULT width of the left menu on a tablet. The variable, not the
`width` of the menu: a width dragged by the reader arrives as an inline
property on <html>, and a rule naming `.side-menu` here would override it
at exactly this one screen size. docs/Design/Page/Left-Menu.md */
:root {
--wekan-left-menu-width: 250px;
}
/* Responsive handling for quick-access description on tablets */
@@ -995,8 +1000,10 @@ a:not(.disabled).is-active i.fa {
padding: 0;
}
/* ...and on a large display the same: no side padding of its own.
docs/Design/Page/Header.md */
#header {
padding: 0 8px;
padding: 0;
}
#content > .wrapper {
@@ -1015,8 +1022,10 @@ a:not(.disabled).is-active i.fa {
gap: 32px;
}
.setting-content .content-body .side-menu {
width: 320px;
/* ...and on a large display, for the same reason as the tablet default above.
docs/Design/Page/Left-Menu.md */
:root {
--wekan-left-menu-width: 320px;
}
}
.inline-input {
+4
View File
@@ -97,6 +97,10 @@ template(name="defaultLayout")
| {{{afterBodyStart}}}
+Template.dynamic(template=content)
| {{{beforeBodyEnd}}}
//- The right sidebar of a page that has no sidebar of its own. It draws
nothing on a board or on All Boards, which have theirs, and nothing on a
page with no controls to put in one. docs/Design/Page/Header.md
+pageSidebar
+boardConversionProgress
if (Modal.isOpen)
#modal(role="dialog" aria-modal="true" aria-label="{{_ 'modal-title'}}")
+11 -17
View File
@@ -1,21 +1,15 @@
template(name="myCardsHeaderBar")
if currentUser
h1
//a.back-btn(href="{{pathFor 'home'}}")
//
i.fa.fa-chevron-left
template(name="myCardsControls")
//- The controls of this page. They were a group in its second header bar,
beside a title the first bar now carries; they are a view of the shared
page sidebar. models/lib/pageSidebar.js, docs/Design/Page/Header.md
a.board-header-btn.js-my-cards-view-change(title="{{_ 'myCardsViewChange-title'}}")
i.fa.fa-caret-down
if $eq myCardsView 'boards'
i.fa.fa-list
| {{_ 'my-cards'}}
.board-header-btns.left
a.board-header-btn.js-my-cards-view-change(title="{{_ 'myCardsViewChange-title'}}")
i.fa.fa-caret-down
if $eq myCardsView 'boards'
i.fa.fa-list
| {{_ 'myCardsViewChange-choice-boards'}}
if $eq myCardsView 'table'
i.fa.fa-bar-chart
| {{_ 'myCardsViewChange-choice-table'}}
| {{_ 'myCardsViewChange-choice-boards'}}
if $eq myCardsView 'table'
i.fa.fa-bar-chart
| {{_ 'myCardsViewChange-choice-table'}}
template(name="myCardsModalTitle")
if currentUser
+2 -2
View File
@@ -1,7 +1,7 @@
import { CardSearchPaged } from '../../lib/cardSearch';
import { Utils } from '/client/lib/utils';
Template.myCardsHeaderBar.helpers({
Template.myCardsControls.helpers({
myCardsSort() {
// eslint-disable-next-line no-console
// console.log('sort:', Utils.myCardsSort());
@@ -15,7 +15,7 @@ Template.myCardsHeaderBar.helpers({
},
});
Template.myCardsHeaderBar.events({
Template.myCardsControls.events({
'click .js-toggle-my-cards-choose-sort': Popup.open(
'myCardsSortChange',
),
+20
View File
@@ -0,0 +1,20 @@
//- The right sidebar of a page that has no sidebar of its own.
//-
//- A board has one, and All Boards has one; this is for every other page whose
//- second header bar had controls in it. The shell is the board sidebar's - the
//- same `.board-sidebar.sidebar` classes, the same close button, the same
//- content area - so all three look and behave alike.
//-
//- docs/Design/Page/Header.md
template(name="pageSidebar")
if hasPageSidebar
//- The theme goes on an ANCESTOR of `.sidebar`, never on it: every themed
sidebar rule is a descendant selector, so a class on the sidebar element
itself matches nothing. Same wrapper, same reason, as the All Boards one.
.page-sidebar-theme(class="{{themeClass}}")
.board-sidebar.sidebar.page-sidebar(class="{{#if isSidebarOpen}}is-open{{/if}}")
.sidebar-actions
a.sidebar-xmark.js-close-page-sidebar(aria-label="{{_ 'close'}}") &#10005;
.sidebar-content.js-page-sidebar-content
if isSidebarOpen
+Template.dynamic(template=controlsTemplate)
+34
View File
@@ -0,0 +1,34 @@
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import {
isPageSidebarOpen,
closePageSidebar,
} from '/client/lib/pageSidebar';
import { pageSidebarTemplate, hasPageSidebar } from '/models/lib/pageSidebar';
// The shared page sidebar. docs/Design/Page/Header.md
Template.pageSidebar.helpers({
// Drawn at all only where there is something to put in it: a panel that opens
// empty is worse than no panel.
hasPageSidebar() {
return hasPageSidebar(FlowRouter.getRouteName());
},
isSidebarOpen() {
return isPageSidebarOpen();
},
controlsTemplate() {
return pageSidebarTemplate(FlowRouter.getRouteName());
},
// A themed thing outside a board, like the All Boards sidebar: without a
// `.board-color-*` ancestor a `.sidebar-btn` is white text on light grey.
themeClass() {
return 'board-color-belize';
},
});
Template.pageSidebar.events({
'click .js-close-page-sidebar'(evt) {
evt.preventDefault();
closePageSidebar();
},
});
+45 -18
View File
@@ -22,6 +22,17 @@
(Member Settings -> Change color sets --theme-accent on :root) with the WeKan
blue as the fallback. */
/* FILLED with the theme, and the arrows white on it. These were an outline on
transparent, which put the theme colour only in a 1px border and the glyph -
so on a light accent there was almost nothing to see, and on any accent the
pager read as a pair of ghosts beside solidly themed chrome.
`--theme-accent-fill` rather than `--theme-accent`: the accent is ONE colour
and a colour-slide theme's fill is a gradient, so a button reading the accent
came out flat while the header above it slid
(models/lib/themeAccents.js publishes both). The double fallback keeps a
theme that publishes no fill on its accent, and no theme at all on the WeKan
blue. */
.table-page-pagination button,
.boards-pagination button,
.table-view-pagination button,
@@ -29,11 +40,15 @@
min-width: 32px;
height: 30px;
padding: 0 10px;
border: 1px solid var(--theme-accent, #01628c);
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
background-image: none;
color: var(--theme-accent, #01628c);
background: var(--theme-accent-fill, var(--theme-accent, #01628c));
/* No `background-image: none` after that shorthand. It was there to defeat
the global button gradient, but a `background` shorthand ALREADY resets
background-image - and once the fill became a colour-SLIDE the reset wiped
the gradient the line above had just set, so clearorange's pager had no
background at all while its header slid. */
color: #fff;
font-weight: 700;
line-height: 1.3;
cursor: pointer;
@@ -56,9 +71,12 @@
.cron-settings .pagination button:hover,
.cron-settings .pagination button:focus,
.cron-settings .pagination button:active {
background: var(--theme-accent, #01628c);
background-image: none;
border-color: var(--theme-accent, #01628c);
background: var(--theme-accent-fill, var(--theme-accent, #01628c));
border-color: rgba(0, 0, 0, 0.25);
/* The fill IS the resting state now, so pressing cannot be "fill it in".
Darkening the same fill keeps the theme - and keeps a slide sliding, which
swapping in a flat colour would not. */
filter: brightness(0.88);
color: #fff;
box-shadow: none;
}
@@ -74,7 +92,6 @@
.cron-settings .pagination button:active:hover,
.cron-settings .pagination button:active:focus {
background: var(--theme-accent, #01628c);
background-image: none;
color: #fff;
}
@@ -82,21 +99,33 @@
.table-page-pagination button.active,
.boards-pagination button.active,
.table-view-pagination button.active {
background: var(--theme-accent, #01628c);
background-image: none;
border-color: var(--theme-accent, #01628c);
background: var(--theme-accent-fill, var(--theme-accent, #01628c));
border-color: rgba(0, 0, 0, 0.25);
/* The fill IS the resting state now, so pressing cannot be "fill it in".
Darkening the same fill keeps the theme - and keeps a slide sliding, which
swapping in a flat colour would not. */
filter: brightness(0.88);
color: #fff;
}
/* Disabled = the same themed outline, faded. Not a grey block. */
/* A disabled pager still has to look like the THEME. It is filled with the
theme colour, and dimming it is what takes the theme back out: at 0.4 the
belize blue #2980b9 arrived on the page's grey as rgb(122,170,202), a pale
wash with no family resemblance to the header above it - and on a one-page
list, where BOTH arrows are disabled, that is the whole pager.
0.85 keeps the colour recognisable, rgb(68,142,191), while still being
visibly flatter than an enabled button beside it, which is the only thing
this rule is for. The `pointer-events: none` below is what actually makes it
unusable; the dimming only has to SAY so. docs/Design/Page/Theme.md */
.table-page-pagination button.disabled,
.boards-pagination button.disabled,
.table-view-pagination button.disabled {
background: transparent;
background-image: none;
border-color: var(--theme-accent, #01628c);
color: var(--theme-accent, #01628c);
opacity: 0.4;
background: var(--theme-accent-fill, var(--theme-accent, #01628c));
border-color: transparent;
color: #fff;
opacity: 0.85;
pointer-events: none;
}
@@ -111,7 +140,6 @@
border: 1px solid var(--theme-accent, #01628c);
border-radius: 6px;
background: var(--theme-accent, #01628c);
background-image: none;
color: #fff;
font-weight: 700;
cursor: pointer;
@@ -124,7 +152,6 @@
.table-page-controls button.js-table-page-action:active:hover,
.table-page-controls button.js-table-page-action:active:focus {
background: var(--theme-accent, #005377);
background-image: none;
border-color: var(--theme-accent, #005377);
color: #fff;
box-shadow: none;
+32
View File
@@ -259,6 +259,25 @@
visibility: hidden !important;
}
/* Select Color (Member Settings and Board Settings) is wide enough for several
* columns of swatches, the same idea as the language popup below: width buys
* columns, and the picker's grid (customTheme.css) takes as many as fit.
*
* Desktop only. Below 800px popup.css lays every popup out as a full-screen
* sheet, and an !important width here would fight that rule and win.
*
* 720px is mirrored in client/lib/popupOffset.js: the JS clamps a popup into the
* viewport using its width, and computed for the default 380px it would place a
* wide popup opened near the right edge with a third of itself off the screen.
* Change one and change the other. */
@media screen and (min-width: 801px) {
.pop-over[data-popup='changeColorPopup'],
.pop-over[data-popup='boardChangeColorPopup'] {
width: min(90vw, 720px) !important;
max-width: 90vw !important;
}
}
/* Make language popup extend to bottom of browser window */
.pop-over[data-popup='changeLanguagePopup'] {
position: fixed !important;
@@ -942,3 +961,16 @@ body.mobile-mode .pop-over .content-wrapper {
max-height: calc(100vh - 48px) !important;
}
}
/* The heading over the starred PAGES in the star dropdown. The dropdown lists
two kinds of destination - boards and pages - and a flat list of both leaves
the reader working out which rows are which. Quiet: it is a label on a group,
not a row you can click. docs/Features/Board/Starred.md */
.pop-over .pop-over-list-title {
padding: 4px 12px;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.6;
}
-7
View File
@@ -1,10 +1,3 @@
template(name="supportHeaderBar")
h1
if isSupportEnabled
= supportTitle
else
| {{_ 'support'}}
template(name="support")
.support-page
if isSupportPublic
+6 -6
View File
@@ -2,10 +2,11 @@ import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
// Shared helpers for both support templates
import { supportPageTitle } from '/client/lib/pageTitleSources';
const supportHelpers = {
supportTitle() {
const setting = ReactiveCache.getCurrentSetting();
return setting && setting.supportTitle ? setting.supportTitle : TAPi18n.__('support');
return supportPageTitle() || TAPi18n.__('support');
},
supportContent() {
const setting = ReactiveCache.getCurrentSetting();
@@ -31,9 +32,8 @@ Template.support.onCreated(function () {
Template.support.helpers(supportHelpers);
// Header bar component
Template.supportHeaderBar.onCreated(function () {
// The subscription the header bar used to make. The top header bar names this
// page from the same setting, so it still has to be here.
Template.support.onCreated(function () {
Meteor.subscribe('setting');
});
Template.supportHeaderBar.helpers(supportHelpers);
@@ -2,5 +2,11 @@ template(name='notifications')
#notifications.board-header-btns.right
a.notifications-drawer-toggle(class="{{#if $gt unreadNotifications 0}}alert{{/if}}" title="{{_ 'notifications'}}")
i.fa.fa-bell
//- Named beside the bell, from the key its own tooltip uses. It carries
the same `board-header-btn-label` class every other named button in this
bar does, so it appears and disappears with them: shown on All Boards
and the Admin Panel at every width, and dropped on a board when the bar
would wrap. docs/Design/Page/Header.md
span.board-header-btn-label {{_ 'notifications'}}
if $.Session.get 'showNotificationsDrawer'
+notificationsDrawer(unreadNotifications=unreadNotifications)
@@ -1,13 +1,23 @@
/* It starts below the header, and the header is NOT 48px tall.
`top: 48px` was a guess at the height of one bar, and the first header bar
wraps to a second and a third row - so on any window where it did, the bar's
own contents came down over this panel: the user avatar, the tallest thing in
it, sat on top of this header beside the ✕ that closes it.
`--wekan-header-height` is what the header MEASURES itself to be, kept current
by a ResizeObserver (client/lib/utils.js), and it is what everything else laid
out against the viewport already uses. docs/Design/Page/Header.md */
section#notifications-drawer {
position: fixed;
top: 48px;
top: var(--wekan-header-height, 48px);
inset-inline-end: 0;
width: 100vw;
max-width: 100vw;
background-color: #fafafa;
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
border-radius: 2px;
max-height: calc(100vh - 28px - 36px);
/* ...and what is left of the window below it, for the same reason: the old
`100vh - 28px - 36px` was that guess again, in two pieces. */
max-height: calc(100vh - var(--wekan-header-height, 48px));
color: #000;
padding-top: 36px;
}
@@ -16,7 +26,9 @@ section#notifications-drawer a:hover {
}
section#notifications-drawer .header {
position: fixed;
top: 48px;
/* The panel's own header is fixed too, so it follows the same measurement -
it is the row the ✕ is in, and it is what the avatar used to overlap. */
top: var(--wekan-header-height, 48px);
inset-inline-end: 0;
width: 100vw;
box-sizing: border-box;
+16 -17
View File
@@ -1,20 +1,19 @@
template(name="rulesHeaderBar")
h1
i.fa.fa-magic
| {{_ 'r-board-rules'}}
.board-header-btns.left
a.board-header-btn.js-rules-back-to-board(title="{{_ 'back'}}")
i.fa.fa-arrow-left
| {{_ 'board'}}
a.board-header-btn.js-rules-toggle-view(title="{{_ 'r-workflow-view'}}")
i.fa.fa-sitemap
if isWorkflowView
| {{_ 'r-list-view'}}
else
| {{_ 'r-workflow-view'}}
a.board-header-btn.js-rules-import-export(title="{{_ 'r-import-export'}}")
i.fa.fa-exchange
| {{_ 'r-import-export'}}
template(name="rulesControls")
//- The controls of this page. They were a group in its second header bar,
beside a title the first bar now carries; they are a view of the shared
page sidebar. models/lib/pageSidebar.js, docs/Design/Page/Header.md
a.board-header-btn.js-rules-back-to-board(title="{{_ 'back'}}")
i.fa.fa-arrow-left
| {{_ 'board'}}
a.board-header-btn.js-rules-toggle-view(title="{{_ 'r-workflow-view'}}")
i.fa.fa-sitemap
if isWorkflowView
| {{_ 'r-list-view'}}
else
| {{_ 'r-workflow-view'}}
a.board-header-btn.js-rules-import-export(title="{{_ 'r-import-export'}}")
i.fa.fa-exchange
| {{_ 'r-import-export'}}
template(name="rulesMain")
.wrapper
+2 -2
View File
@@ -41,7 +41,7 @@ Template.rulesMain.helpers({
},
});
Template.rulesHeaderBar.helpers({
Template.rulesControls.helpers({
currentBoard() {
return Utils.getCurrentBoard();
},
@@ -50,7 +50,7 @@ Template.rulesHeaderBar.helpers({
},
});
Template.rulesHeaderBar.events({
Template.rulesControls.events({
'click .js-rules-back-to-board'(event) {
event.preventDefault();
const currentBoard = Utils.getCurrentBoard();
@@ -30,6 +30,8 @@ template(name="adminReports")
+eventStreamReport(stream="cpu")
else if showDatabase.get
+eventStreamReport(stream="database")
else if showIntegrity.get
+eventStreamReport(stream="integrity")
else if tablePageData
+tablePage(tablePageData)
+140 -11
View File
@@ -1,4 +1,9 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Session } from 'meteor/session';
import { Tracker } from 'meteor/tracker';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
// The per-pane URLs of the Admin Panel. docs/Design/Page/Admin-Panel-URLs.md
import { adminPath } from '/models/lib/adminUrls';
import { TAPi18n } from '/imports/i18n';
import Attachments, { AttachmentStorage } from '/models/attachments';
import Boards from '/models/boards';
@@ -8,7 +13,7 @@ import ImpersonatedUsers from '/models/impersonatedUsers';
import RecoveryEvents from '/models/recoveryEvents';
import { Mongo } from 'meteor/mongo';
import { buildHeader, buildRows, docsByIds, pageInfo, TABLE_PAGE_ROWS_PER_PAGE } from '/models/lib/tablePage';
import { REPORT_PAGE_COLLECTION } from '/models/lib/reportPageIndex';
import { ReportPages } from '/client/lib/reportPages';
import { leftMenuData, paneTitle } from '/models/lib/leftMenu';
import Settings from '/models/settings';
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
@@ -47,10 +52,10 @@ function collectionResults(collection, sort) {
return collection.find({}, sort ? { sort } : {});
}
// The page index the report publications send (models/lib/reportPageIndex.js).
// Client-only: there is no `report_pages` collection on the server, the publication
// just addresses documents to that name over DDP.
const ReportPages = new Mongo.Collection(REPORT_PAGE_COLLECTION);
// The page index the report publications send. Declared once in
// client/lib/reportPages.js, because `new Mongo.Collection(name)` throws if the
// name is taken - it used to be declared here, so the second page to need it
// (/public) could not have one.
// The documents of the page the SERVER named, in the order it sorted them.
//
@@ -105,6 +110,7 @@ Template.adminReports.onCreated(function () {
this.showTests = new ReactiveVar(false);
this.showCpu = new ReactiveVar(false);
this.showDatabase = new ReactiveVar(false);
this.showIntegrity = new ReactiveVar(false);
this.error = new ReactiveVar('');
this.loading = new ReactiveVar(false);
@@ -200,6 +206,78 @@ Template.adminReports.onCreated(function () {
cfg.count.set(total);
});
};
// Which user the open report was subscribed as. See the autorun below.
this.subscribedAs = null;
// The pane the URL asks for. The route resolved it, so it is always a real
// pane id; a bare /admin-reports opens Summary. Reactive, so following a link
// to another report while this page is open switches to it - the route action
// runs again without re-creating the template.
// docs/Design/Page/Admin-Panel-URLs.md
//
// It depends on Meteor.userId() as well, and that is not decoration. Opening
// a report BY ITS ADDRESS - /admin/problems/files typed, bookmarked, or just
// refreshed - is a full page load, and Meteor resumes the login from
// localStorage ASYNCHRONOUSLY. The route sets problemsOpenPane before that
// lands, so the subscription was made with no user; the publication's admin
// check then answered `this.ready()` with no rows, and nothing ever
// re-subscribed because this autorun did not depend on the user. The pane drew
// its column headers, "No results" and a "1 / 1" pager over data that was
// plainly there - while the count METHOD, called later from the same page,
// happily reported five. Reached from the menu it worked, because by then the
// login had landed; only the address did not.
//
// openReportPane() returns early when the pane is already open, so re-running
// it after the login would do nothing at all - hence the second branch, which
// re-subscribes the report that is already open now that there is a user to
// subscribe as.
//
// The ONLY two things that may re-run this autorun are the pane id and the
// user - read reactively, above the nonreactive body. Everything the body
// touches (activeReport, and the count/page/search that loadReport reads
// through pageInfo) is read INSIDE Tracker.nonreactive, on purpose:
//
// 1. loadReport() calls its count method and then cfg.count.set(...). If the
// autorun depended on cfg.count, that set would re-run it - and a
// Meteor.subscribe made inside an autorun is AUTO-CANCELLED when the
// autorun re-runs. The re-run then took the `else if` (same user, same
// pane), did NOT re-subscribe, and left the report with no subscription
// at all: "attachments in minimongo: 0", the empty table over data that
// was plainly there. From the menu it worked only because switchMenu
// calls openReportPane from an EVENT, not a computation, so that
// subscribe was never auto-managed. By URL it was, which is why only the
// address failed.
// 2. The subscription's lifetime is ours to manage (loadReport stops the
// previous one; onDestroyed below stops the last), not the autorun's, so
// it must not be created inside this computation's reactive scope.
this.autorun(() => {
const paneId = Session.get('problemsOpenPane');
const userId = Meteor.userId();
Tracker.nonreactive(() => {
if (!paneId) {
return;
}
if (paneId !== this.activeReport.get()) {
openReportPane(this, paneId);
this.subscribedAs = userId;
} else if (userId !== this.subscribedAs) {
this.subscribedAs = userId;
if (reportConfig(this)[paneId]) {
this.loadReport(paneId, { recount: true });
}
}
});
});
});
// The report subscription is created OUTSIDE a reactive computation now (see the
// autorun above), so the autorun no longer tears it down - this does, when the
// panel is left.
Template.adminReports.onDestroyed(function () {
if (this.subscription) {
this.subscription.stop();
}
});
// The Problems side menu, as data (docs/Design/Page/Left-Menu.md). Every entry
@@ -238,6 +316,10 @@ const PROBLEMS_MENU = [
// What the database itself said, classified: which database type, what it
// means and what to do (server/lib/databaseProblems.js).
{ id: 'report-database', icon: 'fa-database', labelKey: 'databaseReportTitle' },
// What the FILESYSTEM said: a stored file that is no longer the file WeKan
// stored, and whether this server stopped cleanly
// (docs/Security/Remediation/WeKan.md §13).
{ id: 'report-integrity', icon: 'fa-fingerprint', labelKey: 'integrityReportTitle' },
];
Template.adminReports.helpers({
@@ -333,7 +415,13 @@ Template.adminReports.events({
// same class and puts the pane id in data-id, so the twelve identical
// 'click a.js-report-<name>' handlers collapsed to this.
'click .js-left-menu-item'(event) {
switchMenu(event, Template.instance());
const tpl = Template.instance();
switchMenu(event, tpl);
// ...and into the address bar, so the report can be linked and bookmarked.
const targetID = $(event.currentTarget || event.target)
.closest('.js-left-menu-item').data('id');
const path = adminPath('problems', targetID);
if (path && FlowRouter.current().path !== path) FlowRouter.go(path);
},
// --- Controls: ONE handler each, for every table page ---
@@ -394,8 +482,14 @@ function runSearch(tmpl, reportId, inputSelector) {
function switchMenu(event, tmpl) {
// data-id is on the anchor; event.target may be the icon inside it.
const target = $(event.currentTarget || event.target).closest('.js-left-menu-item');
const targetID = target.data('id');
// Re-clicking the open pane must do nothing. The active row is rendered from
openReportPane(tmpl, target.data('id'));
}
// Open a pane BY ID. Split out of switchMenu so the URL can open one too - every
// left-menu entry has an address now (/admin/problems/cpu, /admin/problems/rules).
// docs/Design/Page/Admin-Panel-URLs.md
function openReportPane(tmpl, targetID) {
// Re-opening the open pane must do nothing. The active row is rendered from
// activeReport now, so compare ids instead of reading a DOM class.
if (targetID && targetID !== tmpl.activeReport.get()) {
tmpl.loading.set(true);
@@ -408,6 +502,7 @@ function switchMenu(event, tmpl) {
tmpl.showTests.set(false);
tmpl.showCpu.set(false);
tmpl.showDatabase.set(false);
tmpl.showIntegrity.set(false);
if (tmpl.subscription) {
tmpl.subscription.stop();
}
@@ -444,6 +539,9 @@ function switchMenu(event, tmpl) {
} else if ('report-database' === targetID) {
tmpl.showDatabase.set(true);
tmpl.loading.set(false);
} else if ('report-integrity' === targetID) {
tmpl.showIntegrity.set(true);
tmpl.loading.set(false);
} else if ('report-broken' === targetID) {
// A report like the others now: same controls, same paging, same loader.
tmpl.brokenPage.set(1);
@@ -535,6 +633,12 @@ const REPORT_TABLES = {
try {
return reportPageResults(coll, 'report-files');
} catch (e) {
// Say what went wrong. This used to return [] in silence, and an empty
// catch on the one helper that fills the table is indistinguishable
// from "there is nothing to show": the pane draws its headers, says
// "No results", and nothing anywhere records that it threw. A report
// that cannot render its rows is worth one line in the console.
console.error('[report-files] could not read the page of rows:', e);
return [];
}
},
@@ -740,9 +844,34 @@ const EVENT_STREAM_COLUMNS = [
{ labelKey: 'event-severity', value: r => r.severity, data: r => r.severity },
{ labelKey: 'event-action', value: r => r.action || r.type },
{ labelKey: 'event-source', value: r => r.source },
// The user who triggered the event (e.g. who uploaded a sanitized file).
{ labelKey: 'username', value: r => userName(r.userId), userId: r => r.userId },
{ labelKey: 'event-detail', value: r => r.detail },
// WHO tried it. The stored `username` wins over looking the account up: it is
// what the account was CALLED when the event happened, so a later rename does
// not rewrite history and a deleted account does not erase it. The lookup
// stays as the fallback for the older events that predate the stored field.
{
labelKey: 'username',
value: r => r.username || userName(r.userId),
userId: r => r.userId,
},
// FROM WHERE. Resolved with the same spoofing-safe rule as the login throttle -
// X-Forwarded-For only as far as HTTP_FORWARDED_COUNT says to trust it - so this
// column cannot be written by sending a header.
{ labelKey: 'event-ip', nowrap: true, value: r => r.ip || '' },
// HOW MANY attempts this row stands for. A canary counts repeats inside its
// window rather than writing one row each, so "1" is an ordinary event and a
// larger number is a burst that was deliberately not written out in full
// (docs/Security/Remediation/WeKan.md §12).
{
labelKey: 'event-attempts',
nowrap: true,
value: r => (r.count && r.count > 1 ? String(r.count) : ''),
},
// The `database` stream's detail is WeKan's reading of the error - what it means
// and what to do - and it is worth little without the sentence the database
// itself produced, which is the one thing an admin can search for or paste into
// an issue. So this cell carries both, advice first, message after. The other
// streams have no `message`, and their cell is unchanged.
{ labelKey: 'event-detail', value: r => [r.detail, r.message].filter(Boolean).join(' — ') },
];
function formatEventAt(at) {
@@ -238,3 +238,21 @@
line-height: 1.4;
margin-top: 2px;
}
/* #6465: "Please move the button down. This is too close" - Save sat directly
under the 1..28 day buttons of the monthly schedule, close enough to be hit
while aiming for a date. The gap is on the form-group that FOLLOWS the day
grid, so it applies wherever that grid is used and nowhere else. */
.attachments-content .schedule-day-buttons + .form-group,
.schedule-day-buttons + .form-group {
margin-top: 18px;
}
/* #6465: "The alignment is out of place" - the move button and the labelled
selects beside it are one row (align-items: flex-end), so a margin of the
button's own would offset it from the controls it acts on. Zero it, and let
the row do the aligning. */
.attachments-content .move-storage-form > button {
margin: 0;
align-self: flex-end;
}
+14
View File
@@ -1,4 +1,8 @@
import { Meteor } from 'meteor/meteor';
import { Session } from 'meteor/session';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
// The per-pane URLs of the Admin Panel. docs/Design/Page/Admin-Panel-URLs.md
import { adminPath } from '/models/lib/adminUrls';
import { leftMenuData, paneTitle } from '/models/lib/leftMenu';
import AttachmentBulkMoveStatus from '/models/attachmentBulkMoveStatus';
import { TAPi18n } from '/imports/i18n';
@@ -249,6 +253,12 @@ Template.attachments.onCreated(function () {
// comes to this page for most often, and the one action here that has to be
// reachable in a hurry.
this.activeSection = new ReactiveVar('backup');
// The pane the URL asks for. The route resolved it, so it is always a real
// pane id; a bare /attachments opens Backup.
this.autorun(() => {
const paneId = Session.get('attachmentsOpenPane');
if (paneId) this.activeSection.set(paneId);
});
this.storageSettingsSubscription = Meteor.subscribe('attachmentStorageSettings');
this.attachmentStorageSettings = new ReactiveVar(null);
// #6473: the real storage paths only exist on the SERVER (WRITABLE_PATH is a
@@ -920,6 +930,10 @@ Template.attachments.events({
}
tpl.activeSection.set(targetID);
// ...and into the address bar, so the pane can be linked and bookmarked.
// docs/Design/Page/Admin-Panel-URLs.md
const path = adminPath('attachments', targetID);
if (path && FlowRouter.current().path !== path) FlowRouter.go(path);
},
// Deleting the raw MongoDB 3 files of a grain - commented out with the pane
// (see attachmentsMenu). Compacting the database frees the space instead, and
+78 -2
View File
@@ -15,7 +15,14 @@
//-
//- Data context: { items } from buildMenuItems() in models/lib/leftMenu.js.
template(name="leftMenu")
.side-menu
//- TWO roots, and the handle is the second on purpose: it is positioned
against the ROW (.content-body), not against the menu, because the menu is
its own scroll area and a handle inside it would scroll away with the
entries. Here rather than in each page's template, so a new Admin Panel page
cannot draw the menu and forget the handle.
docs/Design/Page/Left-Menu.md
.side-menu(class="{{#if isLeftMenuCollapsed}}collapsed{{/if}}")
+leftMenuCollapse
ul
each items
if separator
@@ -43,6 +50,28 @@ template(name="leftMenu")
| {{_ labelKey}}
else
| {{label}}
+leftMenuResize
//- The grip that changes the menu's width, on its INNER edge - the right one
//- while reading left to right, the left one under a right-to-left language,
//- which the CSS mirrors by itself. It is the same gesture the right board
//- sidebar has on its own inner edge (sidebar.jade), and it is dragged the same
//- way: press, move, and the width is saved when let go.
//-
//- `nodragscroll` for the same reason the sidebar's has it: the board pages
//- drag-scroll on mousedown, and without it a drag on the grip would pan the
//- page instead of resizing the menu.
//-
//- It is positioned against the ROW around the menu rather than against the
//- menu, so it does not scroll with a long list of entries, and it follows the
//- menu because both read the same `--wekan-left-menu-width`.
//- client/components/settings/leftMenu.js, docs/Design/Page/Left-Menu.md
template(name="leftMenuResize")
.left-menu-resize-handle.js-left-menu-resize.nodragscroll(
title="{{_ 'drag-to-resize-left-menu'}}"
aria-label="{{_ 'drag-to-resize-left-menu'}}"
role="separator"
aria-orientation="vertical")
//- The title of the open pane, at the top of the content beside the menu. Its text
//- is the ACTIVE menu entry's own label (paneTitle() in models/lib/leftMenu.js), so
@@ -51,9 +80,56 @@ template(name="leftMenu")
//- table page puts that class on its own title, so a table pane and a form pane
//- look the same.
//-
//- FOLDED, this heading is also the way back: the menu is gone entirely - no
//- column, no strip - so the caret moves here, to the inline start of the title,
//- and the caret AND the title are one target that brings the menu back. The
//- whole heading is inside the anchor for exactly that reason.
//-
//- It is drawn even when there is no title. paneTitle() returns {} when no menu
//- entry is active, and an empty heading is better than a folded menu with
//- nothing left to unfold it.
//-
//- Data context: { titleKey } or { label } from paneTitle().
template(name="paneTitle")
if titleKey
if isLeftMenuCollapsed
h1.admin-pane-title.admin-pane-title-folded
+leftMenuCollapse(paneTitleKey=titleKey paneLabel=label)
else if titleKey
h1.admin-pane-title {{_ titleKey}}
else if label
h1.admin-pane-title {{label}}
//- The caret that folds the menu away.
//-
//- The same control a LIST has on a board (listHeader.jade): a caret pointing
//- DOWN while the thing is open and RIGHT once it is folded, clicked to switch.
//- It is the collapse gesture WeKan already has, so there is nothing new to
//- learn, and it uses the same two words - Collapse / Uncollapse - in its
//- tooltip.
//-
//- It is drawn TWICE, and only ever one of them is on screen, because the menu
//- itself is not drawn while it is folded:
//- * at the top of the menu, where it points down and folds the menu away;
//- * at the inline start of the pane title, where it points right and brings
//- the menu back - see `paneTitle` above, which passes the pane's title in
//- so that caret and title are ONE target rather than two.
//- One template for both places and both pages, so the carets cannot drift.
//- client/components/settings/leftMenu.js
template(name="leftMenuCollapse")
a.left-menu-collapse-indicator.js-collapse-left-menu(
title="{{collapseLabel}}"
aria-label="{{collapseLabel}}"
role="button"
tabindex="0")
if isLeftMenuCollapsed
i.fa.fa-caret-right
else
i.fa.fa-caret-down
//- The pane's own title, when this is the caret beside it. `paneTitleKey` /
//- `paneLabel` rather than the `titleKey` / `label` paneTitle() itself uses,
//- so the caret in the MENU - which inherits whatever data context the menu
//- sits in - can never pick a title up by accident.
if paneTitleKey
span.left-menu-collapse-title {{_ paneTitleKey}}
else if paneLabel
span.left-menu-collapse-title {{paneLabel}}
+216
View File
@@ -0,0 +1,216 @@
import { Meteor } from 'meteor/meteor';
import { Template } from 'meteor/templating';
import { Tracker } from 'meteor/tracker';
import { TAPi18n } from '/imports/i18n';
import { Utils } from '/client/lib/utils';
// Collapsing the left menu, for both pages that have one.
//
// The same gesture a LIST has on a board (`client/components/lists/listHeader.js`):
// a caret pointing down when the thing is open and right when it is folded away,
// clicked to switch. It is the collapse gesture WeKan already has, so it needs no
// learning - and it is stored the same way, a Session value for the fold itself
// and the user document behind it so it survives a reload.
//
// Folded, the menu is not drawn at all - no column, no strip - so the caret that
// brings it back rides at the inline start of the PANE TITLE, and the caret and
// the title are one target. Both carets are the same template, and this one
// handler serves both because they carry the same `js-collapse-left-menu`.
//
// ONE state for both pages. All Boards and the Admin Panel draw one menu, and a
// reader who folds it away on one of them has said what they want on the other.
//
// The helper is GLOBAL because the element that takes the `collapsed` class is a
// different one in a different template on each page - `.side-menu` here,
// `.boards-left-menu` in boardsList.jade - and a helper registered on one of
// them cannot be read by the other.
// docs/Design/Page/Left-Menu.md
Template.registerHelper('isLeftMenuCollapsed', () => Utils.getLeftMenuCollapseState());
Template.leftMenuCollapse.helpers({
// What the control DOES, for the tooltip and for a screen reader - the same
// two words a list's caret uses.
//
// Beside a pane title the anchor holds the heading's own text, and an
// `aria-label` REPLACES what is inside the element: labelled with the bare
// word, the pane's name would be the one thing not announced. So the name is
// part of the label there, and the label is the bare word only where the
// caret stands alone, at the top of the open menu.
collapseLabel() {
const action = TAPi18n.__(Utils.getLeftMenuCollapseState() ? 'uncollapse' : 'collapse');
const data = Template.currentData() || {};
const pane = data.paneTitleKey ? TAPi18n.__(data.paneTitleKey) : data.paneLabel;
return pane ? `${action}: ${pane}` : action;
},
});
function toggleLeftMenu(evt) {
evt.preventDefault();
Utils.setLeftMenuCollapseState(!Utils.getLeftMenuCollapseState());
}
Template.leftMenuCollapse.events({
'click .js-collapse-left-menu': toggleLeftMenu,
// The caret is an anchor with no href - it goes nowhere, it folds something -
// so the browser gives it neither keyboard focus nor the Enter that a link or
// a button answers by itself. `tabindex` in the template gives it the focus;
// this gives it the two keys, which is what `role="button"` beside them
// promises. Without it the menu could be folded away with a mouse and only
// with a mouse.
'keydown .js-collapse-left-menu'(evt) {
if (evt.key === 'Enter' || evt.key === ' ' || evt.key === 'Spacebar') {
toggleLeftMenu(evt);
}
},
});
// ── Dragging the menu's inner edge to change its width ───────────────────────
//
// The same gesture the right board sidebar has (client/components/sidebar/
// sidebar.js): press the grip on the panel's inner edge, move, and the width is
// saved when let go. Stored the same three ways the fold is - Session, then the
// user's profile, then a cookie for a reader who is not signed in.
//
// The width reaches the CSS as a custom property on <html> rather than as an
// inline style on the menu, because the menu is a DIFFERENT element in a
// different template on each page, and on All Boards the grid track has to
// follow it too. One property, read by every rule that needs the number.
//
// Not on a phone: there the menu is full width above the content (the ≤800px
// rules), so there is no inner edge to drag and nothing a width would mean.
// docs/Design/Page/Left-Menu.md
const LEFT_MENU_WIDTH_PROPERTY = '--wekan-left-menu-width';
// A menu narrower than this cannot show its longest label, and one wider than
// the max is a menu that has eaten the page it is a menu for.
const MIN_LEFT_MENU_WIDTH = 160;
const isPhoneLayout = () => window.innerWidth <= 800;
const maxLeftMenuWidth = () =>
Math.max(MIN_LEFT_MENU_WIDTH, Math.min(600, window.innerWidth - 240));
function applyLeftMenuWidth(width) {
const root = document.documentElement;
if (typeof width !== 'number' || !Number.isFinite(width)) {
// Nobody has dragged it: the property goes away and the stylesheet's own
// default applies, which is the one place that number lives.
root.style.removeProperty(LEFT_MENU_WIDTH_PROPERTY);
return;
}
const clamped = Math.max(MIN_LEFT_MENU_WIDTH, Math.min(maxLeftMenuWidth(), width));
root.style.setProperty(LEFT_MENU_WIDTH_PROPERTY, `${Math.round(clamped)}px`);
}
Meteor.startup(() => {
// Re-runs when the width changes AND when the user document arrives, which is
// what carries a saved width into a page that has already rendered.
Tracker.autorun(() => {
applyLeftMenuWidth(Utils.getLeftMenuWidth());
});
// A saved width that no longer fits is clamped against the window it is being
// shown in, not the one it was dragged in.
window.addEventListener('resize', () => {
applyLeftMenuWidth(Utils.getLeftMenuWidth());
});
});
function pageXOf(evt) {
const oe = evt.originalEvent || evt;
if (oe.touches && oe.touches.length) return oe.touches[0].pageX;
if (oe.changedTouches && oe.changedTouches.length) return oe.changedTouches[0].pageX;
return evt.pageX;
}
// The menu sits at the logical inline START, so its grip is on the inline END:
// dragging AWAY from that start widens it. Reading left to right that is a drag
// to the right; under a right-to-left language the whole row is mirrored, the
// menu is on the right, and the same drag goes the other way.
const isRtlLayout = () =>
(document.documentElement.getAttribute('dir') || document.dir) === 'rtl';
// The width the menu is at right now: what was saved, or - the first time it is
// dragged - what the stylesheet made it, measured from the menu itself rather
// than guessed at, so the drag starts where the edge actually is.
function currentLeftMenuWidth() {
const saved = Utils.getLeftMenuWidth();
if (typeof saved === 'number') return saved;
const menu = document.querySelector('.side-menu, .boards-left-menu');
return menu ? menu.getBoundingClientRect().width : MIN_LEFT_MENU_WIDTH;
}
Template.leftMenuResize.onRendered(function() {
let isResizing = false;
let startX = 0;
let startWidth = 0;
let lastWidth = 0;
// Only what a drag put in place is ever taken down or saved. Without the flag
// the cleanup below would "finish" a drag that never started and save the
// width it was initialised with, which is no width at all.
const releaseDocument = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onEnd);
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onEnd);
$('body').removeClass('left-menu-resizing-active');
};
const onMove = evt => {
if (!isResizing) return;
const delta = (pageXOf(evt) - startX) * (isRtlLayout() ? -1 : 1);
lastWidth = Math.max(MIN_LEFT_MENU_WIDTH,
Math.min(maxLeftMenuWidth(), startWidth + delta));
// Straight onto the property while dragging: the edge follows the pointer
// without a write to the database - and to a Session value - per pixel.
document.documentElement.style.setProperty(
LEFT_MENU_WIDTH_PROPERTY, `${Math.round(lastWidth)}px`);
evt.preventDefault();
};
const onEnd = evt => {
if (!isResizing) return;
isResizing = false;
releaseDocument();
// Saved once, at the end. Session first, so the width does not jump back
// while the server is answering.
Utils.setLeftMenuWidth(lastWidth);
if (evt && evt.preventDefault) evt.preventDefault();
};
const onStart = evt => {
if (isPhoneLayout()) return;
isResizing = true;
startX = pageXOf(evt);
startWidth = currentLeftMenuWidth();
lastWidth = startWidth;
// The whole page shows the resize cursor and stops selecting text, the same
// way the right sidebar's drag does - without it the drag selects every
// label it passes over.
$('body').addClass('left-menu-resizing-active');
document.addEventListener('mousemove', onMove, { passive: false });
document.addEventListener('mouseup', onEnd, { passive: false });
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onEnd, { passive: false });
evt.preventDefault();
evt.stopPropagation();
};
const handle = this.find('.js-left-menu-resize');
if (!handle) return;
handle.addEventListener('mousedown', onStart, { passive: false });
// Native, not a Blaze event map, so `{ passive: false }` applies and the
// touch drag can stop the page from scrolling under it.
handle.addEventListener('touchstart', onStart, { passive: false });
this._leftMenuResizeCleanup = () => {
isResizing = false;
releaseDocument();
};
});
Template.leftMenuResize.onDestroyed(function() {
// A drag that was still going when the page changed leaves its document
// listeners behind otherwise, and they would keep resizing a menu that is no
// longer there. The width is NOT saved from here: what a half-finished drag
// was passing through is not what the reader chose.
if (this._leftMenuResizeCleanup) this._leftMenuResizeCleanup();
});
+16
View File
@@ -264,3 +264,19 @@ table:not(.table-page-table) td:first-child {
(docs/Design/Page/Table.md), so its controls row, pagination and table are
styled there - and its pager is themed by paginationControls.css like every
other pager. Nothing pane-specific is needed here any more. */
/* #6465: the "all users" select-all in a table heading. One row, box after the
word, with a gap that cannot collapse - the two used to be loose inline nodes
in a centred heading cell, and at the column's width the box sat on top of the
text. inline-flex keeps the heading's own centring intact. */
.select-all-user {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
white-space: nowrap;
}
.select-all-user input.allUserChkBox {
margin: 0;
flex: none;
}
+15 -2
View File
@@ -101,8 +101,14 @@ template(name="peopleGeneral")
+tablePage(this)
template(name="selectAllUser")
| {{_ 'dueCardsViewChange-choice-all'}}
input.allUserChkBox(type="checkbox", id="chkSelectAll")
//- #6465: label AND box in one <label>, so the box cannot land on top of the
//- word. They were two loose inline nodes in a table heading: the heading
//- centres its content, the checkbox is replaced-content with its own
//- baseline, and at the width the column happens to have, the box overlapped
//- the text. A label also makes the word itself click the box.
label.select-all-user(for="chkSelectAll")
span {{_ 'dueCardsViewChange-choice-all'}}
input.allUserChkBox(type="checkbox", id="chkSelectAll")
template(name="domainGeneral")
+tablePage(tablePageData)
@@ -754,6 +760,13 @@ template(name="rolesGeneral")
button.js-roles-save.primary {{_ 'save'}}
//- Roles Status: what each board role may do, READ-ONLY, below the Save
//- button. It is the shared table page (docs/Design/Page/Table.md), so it
//- looks and behaves like every other Admin Panel table, and it follows the
//- checkboxes ABOVE the Save button - the working copy, not the saved
//- document - so an admin can see the effect of a change before saving it.
+tablePage(rolesStatusTable)
template(name="templatesGeneral")
.templates-settings
p.templates-description {{_ 'shared-templates-info'}}
+129 -5
View File
@@ -1,4 +1,5 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Session } from 'meteor/session';
import { leftMenuData, paneTitle } from '/models/lib/leftMenu';
// buildFilters and buildActions are imported like the rest of them. The People
// pane declares its filter dropdown and its two action buttons to the shared
@@ -12,6 +13,8 @@ import { avatarUpdateCounter } from '/client/components/users/avatarUpdateCounte
import { InfiniteScrolling } from '/client/lib/infiniteScrolling';
import LockoutSettings from '/models/lockoutSettings';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
// The per-pane URLs of the Admin Panel. docs/Design/Page/Admin-Panel-URLs.md
import { adminPath } from '/models/lib/adminUrls';
import Org from '/models/org';
import Settings from '/models/settings';
import Team from '/models/team';
@@ -23,6 +26,13 @@ import InviteToBoardRolesSettings, {
INVITE_TO_BOARD_ROLES,
INVITE_TO_BOARD_ROLES_ID,
} from '/models/inviteToBoardRolesSettings';
// The one capability table the server allow rules and the client's canModify*
// helpers also read, so the Roles Status pane cannot show a permission that is
// not enforced.
const {
BOARD_ROLES,
ROLE_CAPABILITIES,
} = require('/models/lib/boardRoleCapabilities');
// Multitenancy option D (D.2/D.9): the org fields that make an Organization a
// tenant - its hostnames plus the branding that overrides the instance branding on
@@ -299,11 +309,11 @@ Template.people.onCreated(function () {
this.activeMenuId.set(openPaneId);
});
this.switchMenu = (event) => {
// data-id is on the anchor; event.target may be the icon inside it.
const target = $(event.currentTarget || event.target).closest('.js-left-menu-item');
const targetID = target.data('id');
// Re-clicking the open pane must do nothing. The active row is rendered from
// Open a pane BY ID. Split out of switchMenu so the URL can open one too -
// every left-menu entry has an address now (/people/roles, /people/domains).
// docs/Design/Page/Admin-Panel-URLs.md
this.openPane = (targetID) => {
// Re-opening the open pane must do nothing. The active row is rendered from
// activeMenuId now, so compare ids instead of reading a DOM class.
if (targetID && targetID !== this.activeMenuId.get()) {
this.activeMenuId.set(targetID);
@@ -332,6 +342,19 @@ Template.people.onCreated(function () {
}
};
this.switchMenu = (event) => {
// data-id is on the anchor; event.target may be the icon inside it.
const target = $(event.currentTarget || event.target).closest('.js-left-menu-item');
this.openPane(target.data('id'));
};
// The pane the URL asks for. The route resolved it, so it is always a real
// pane id; a bare /people opens the page's default.
this.autorun(() => {
const paneId = Session.get('peopleOpenPane');
if (paneId) this.openPane(paneId);
});
this.autorun(() => {
const limitOrgs = orgsPerPage;
const skipOrgs = (this.orgPage.get() - 1) * orgsPerPage;
@@ -899,6 +922,9 @@ Template.people.events({
'click .js-left-menu-item'(event, tpl) {
const targetID = $(event.currentTarget).data('id');
tpl.switchMenu(event);
// ...and into the address bar, so the pane can be linked and bookmarked.
const path = adminPath('people', targetID);
if (path && FlowRouter.current().path !== path) FlowRouter.go(path);
if (targetID === 'org-setting') {
tpl.orgPage.set(1);
tpl.refreshOrgsCount();
@@ -915,6 +941,11 @@ Template.people.events({
Template.rolesGeneral.onCreated(function () {
// Working copy of the allowed-roles set; null until the published doc loads.
this.workingRoles = new ReactiveVar(null);
// Roles Status: the shared table page's search term and page. There are nine
// roles and no server paging - the set is a constant of the code, not data -
// but the shared controls row is part of the design, so they work.
this.statusSearch = new ReactiveVar('');
this.statusPage = new ReactiveVar(1);
this.autorun(() => {
if (this.workingRoles.get() === null) {
const doc = InviteToBoardRolesSettings.findOne(INVITE_TO_BOARD_ROLES_ID);
@@ -925,7 +956,81 @@ Template.rolesGeneral.onCreated(function () {
});
});
// ── Roles Status ────────────────────────────────────────────────────────────
//
// The read-only table under the Save button: what each board role may do. Its
// rows come from models/lib/boardRoleCapabilities.js — the same table the server
// allow rules and the client's canModify* helpers decide with — so this pane
// cannot show a permission the code does not enforce.
//
// Every string is a translation key, including the Yes/No of each cell: the
// values are booleans, and a hard-coded "Yes" would be English on every one of
// WeKan's languages.
//
// The "Invite to board" column is the one thing here that is a SETTING rather
// than a capability, and it reads the pane's WORKING copy — the checkboxes above
// the Save button — not the saved document. That is deliberate: the table is
// there to show what the checkboxes mean, so it has to follow them as they are
// ticked, before saving.
const ROLES_STATUS_COLUMNS = [
{ labelKey: 'roles-status-role', value: doc => TAPi18n.__(doc.roleKey) },
{ labelKey: 'roles-status-invite', value: doc => yesNo(doc.invite) },
{
labelKey: 'roles-status-sees',
value: doc =>
TAPi18n.__(doc.seesAllCards ? 'roles-status-sees-all' : 'roles-status-sees-assigned'),
},
{ labelKey: 'roles-status-comment', value: doc => yesNo(doc.comment) },
{ labelKey: 'roles-status-write', value: doc => yesNo(doc.write) },
{ labelKey: 'roles-status-manage', value: doc => yesNo(doc.manageBoard) },
];
function yesNo(value) {
return TAPi18n.__(value ? 'yes' : 'no');
}
Template.rolesGeneral.helpers({
// The shared table page's data context (docs/Design/Page/Table.md).
rolesStatusTable() {
const tpl = Template.instance();
const working = tpl.workingRoles.get() || [];
const term = (tpl.statusSearch.get() || '').trim().toLowerCase();
const all = BOARD_ROLES.map(roleKey => ({
_id: roleKey,
roleKey,
// Only the roles offered for invitation carry the setting; the rest are
// not part of that list at all, so they read as not invitable rather than
// as "unticked".
invite: INVITE_TO_BOARD_ROLES.includes(roleKey) && working.includes(roleKey),
...ROLE_CAPABILITIES[roleKey],
}));
// The shared controls row always renders a search box, so it searches -
// on the role's TRANSLATED name, which is what the reader sees.
const docs = term
? all.filter(doc => TAPi18n.__(doc.roleKey).toLowerCase().includes(term))
: all;
const info = pageInfo(docs.length, tpl.statusPage.get());
const page = docs.slice(info.skip, info.skip + TABLE_PAGE_ROWS_PER_PAGE);
return {
titleKey: 'roles-status',
descKey: 'roles-status-desc',
header: buildHeader(ROLES_STATUS_COLUMNS),
rows: buildRows(page, ROLES_STATUS_COLUMNS),
rowCount: page.length,
total: docs.length,
searchTerm: tpl.statusSearch.get(),
page: info.page,
totalPages: info.totalPages,
hasPrev: info.hasPrev,
hasNext: info.hasNext,
emptyKey: 'roles-status-empty',
};
},
roleOptions() {
const working = Template.instance().workingRoles.get() || [];
// The role key doubles as the i18n key. 'board-admin' renders as
@@ -967,6 +1072,25 @@ Template.rolesGeneral.events({
$set: { allowedRoles: tpl.workingRoles.get() || [] },
});
},
// Roles Status controls. The table is read-only, so these are the only
// interactions it has: search on Enter (like every other table page) and the
// pager. No row is clickable and no cell is editable — a role's capabilities
// are a property of the code, not a setting.
'keydown .js-table-page-search'(event, tpl) {
if (event.key !== 'Enter') return;
event.preventDefault();
tpl.statusSearch.set(event.currentTarget.value || '');
tpl.statusPage.set(1);
},
'click .js-table-page-prev'(event, tpl) {
event.preventDefault();
tpl.statusPage.set(Math.max(1, tpl.statusPage.get() - 1));
},
'click .js-table-page-next'(event, tpl) {
event.preventDefault();
tpl.statusPage.set(tpl.statusPage.get() + 1);
},
});
// Feature #3313 "Shared templates": admin view of users' shareable template
+146 -3
View File
@@ -22,15 +22,48 @@
the rest, so both stay visible without switching to a stacked layout. */
flex-direction: row;
flex-wrap: nowrap;
padding-top: 18px;
/* No padding above the ROW: it put grey between the header and the top of the
side menu, and the menu is meant to run from directly under the header to
the bottom of the window. The main body keeps its breathing room below -
see `.main-body` - because that IS content and content is not flush to a
bar. docs/Design/Page/Left-Menu.md */
padding-top: 0;
height: 100%;
gap: 18px;
/* The width grip is positioned against this ROW rather than against the menu:
the menu is its own scroll area, and a handle inside it would scroll away
with the entries. docs/Design/Page/Left-Menu.md */
position: relative;
}
/* ── How wide the left menu is ───────────────────────────────────────────────
ONE number, read by everything that needs it: the Admin Panel's menu, the All
Boards one (boardsList.css), and the grip that changes it, which has to sit
exactly on the menu's inner edge.
A reader who drags the grip gets it as an inline property on <html>, which
beats every rule here - so the declarations below are DEFAULTS, and the
breakpoints that want a different default (layouts.css) override the variable
instead of the width, or a dragged width would be overridden right back.
docs/Design/Page/Left-Menu.md */
:root {
--wekan-left-menu-width: 260px;
}
.setting-content .content-body .side-menu {
background-color: #f7f7f7;
/* Flush on three sides - it starts at the window's left edge, directly under
the header, and runs to the bottom - so it has a border and a corner only
on the side that faces the page. A rounded corner on a flush edge shows the
grey behind it, which is exactly the gap this removes. */
border: 1px solid #f0f0f0;
border-radius: 7px;
width: min(250px, 32vw);
border-inline-start: 0;
border-top: 0;
border-bottom: 0;
border-radius: 0;
/* The shared width above, so this menu and the All Boards one are one menu at
one width - and so dragging either changes both. It replaces a
`min(250px, 32vw)` that only ever resolved to 250px above the 800px
breakpoint, below which the menu is full width anyway. */
width: var(--wekan-left-menu-width);
/* Never let the menu be squeezed away on narrow windows; keep it visible. */
flex: 0 0 auto;
/* No X offset: an inset shadow offset sideways is a PHYSICAL direction and does
@@ -129,6 +162,10 @@
margin-inline-end: 20px;
}
.setting-content .content-body .main-body {
/* The row lost its top padding so the side menu can start under the header;
the content keeps it, because content is not meant to be flush against a
bar. */
padding-block-start: 18px;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
@@ -225,6 +262,112 @@
padding-bottom: 50px;
}
/* Folded: the menu is GONE, not narrowed - no column and no strip - and the pane
beside it takes the whole width. See boardsList.css for the All Boards twin:
the two menus fold the same way because they are one menu. The way back is the
caret on the pane title below. */
.setting-content .content-body .side-menu.collapsed {
display: none;
}
/* ── The caret that folds the left menu ──────────────────────────────────────
The same control a LIST has on a board: pointing DOWN while the menu is open
and RIGHT once it is folded. It is the collapse gesture WeKan already has, so
there is nothing new to learn.
It is styled here rather than beside one page's menu because it appears on
both pages and, folded, in a third place: the pane title.
docs/Design/Page/Left-Menu.md */
.left-menu-collapse-indicator {
display: flex;
align-items: center;
/* The rows' own inline start, so the caret sits on the line the row icons
start on rather than hanging to their left. */
padding: 6px 10px 6px 18px;
color: inherit;
text-decoration: none;
cursor: pointer;
opacity: 0.7;
}
.left-menu-collapse-indicator:hover {
opacity: 1;
}
/* Folded, the caret rides at the inline start of the PANE TITLE and the two are
one target - the heading itself is what brings the menu back, so it is as easy
to hit as the menu was wide. It keeps the heading's own size and weight: it IS
the heading, with a caret in front of it, not a control that happens to sit
near one. The padding the caret carries inside the menu would push the title
off the line every other pane's title starts on, so it goes. */
.admin-pane-title-folded > .left-menu-collapse-indicator {
display: inline-flex;
gap: 8px;
padding: 0;
font: inherit;
color: inherit;
opacity: 1;
}
.admin-pane-title-folded > .left-menu-collapse-indicator:hover .left-menu-collapse-title {
text-decoration: underline;
}
/* Keyboard focus has to be visible: the caret is an anchor with no href, which
the browser would otherwise leave with no ring of its own. */
.left-menu-collapse-indicator:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* ── The grip that changes the menu's width ──────────────────────────────────
The same control the right board sidebar has on its own inner edge
(sidebar.css): a 6px strip straddling the edge, invisible until pointed at,
dragged to resize.
It sits on the menu's INNER edge - `inset-inline-start`, so under a
right-to-left language, where the whole row is mirrored and the menu is on
the right, the grip mirrors with it and needs no second rule.
It is positioned against the row/grid around the menu rather than inside the
menu, because the menu scrolls its own entries and a handle within it would
scroll away with them. Being absolutely positioned, it is neither a flex item
nor a grid item: it adds no column and no gap.
docs/Design/Page/Left-Menu.md */
.left-menu-resize-handle {
position: absolute;
top: 0;
bottom: 0;
inset-inline-start: calc(var(--wekan-left-menu-width) - 3px);
width: 6px;
cursor: col-resize;
z-index: 10;
background: transparent;
transition: background-color 0.2s ease;
}
.left-menu-resize-handle:hover {
background: rgba(0, 123, 255, 0.4);
box-shadow: 0 0 4px rgba(0, 123, 255, 0.3);
}
.left-menu-resize-handle:active {
background: rgba(0, 123, 255, 0.6);
box-shadow: 0 0 6px rgba(0, 123, 255, 0.4);
}
/* Folded there is no edge to drag, and on a phone the menu is full width above
the content, so a width would mean nothing there either. `.collapsed` is on
the menu in the Admin Panel and on the grid on All Boards - see the folding
rules above - so both are named. */
.side-menu.collapsed ~ .left-menu-resize-handle,
.boards-layout.collapsed > .left-menu-resize-handle {
display: none;
}
@media screen and (max-width: 800px) {
.left-menu-resize-handle {
display: none;
}
}
/* While dragging, the whole page shows the resize cursor and stops selecting
text - otherwise the drag selects every label it passes over. The same pair
`body.sidebar-resizing-active` uses for the right sidebar. */
body.left-menu-resizing-active {
user-select: none;
cursor: col-resize;
}
/* The pane title: ONE size and weight for the heading at the top of every Admin
Panel pane, whether that pane is a shared table page (Domains, People, the
Problems reports - their h1 carries this class too, see tablePage.jade) or a form
+65 -51
View File
@@ -1,4 +1,5 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Session } from 'meteor/session';
import { leftMenuData, paneTitle } from '/models/lib/leftMenu';
import { TAPi18n } from '/imports/i18n';
import { ALLOWED_WAIT_SPINNERS } from '/config/const';
@@ -11,6 +12,9 @@ import Settings from '/models/settings';
// (docs/Design/Multitenancy/Multitenancy.md).
import * as tenantAdmin from '/models/lib/tenantAdmin';
import { resolveDefaultAuthenticationMethod } from '/models/lib/authenticationMethod';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
// The per-pane URLs of the Admin Panel. docs/Design/Page/Admin-Panel-URLs.md
import { adminPath } from '/models/lib/adminUrls';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { format } from '/imports/lib/dateUtils';
@@ -261,21 +265,6 @@ Template.setting.onCreated(function () {
this.accessibilitySetting = new ReactiveVar(false);
this.layoutSetting = new ReactiveVar(false);
this.webhookSetting = new ReactiveVar(false);
// An old page URL that is now a pane here - /information, /translation - asks for
// its pane by name when it redirects, so the bookmark still lands where it used
// to. It is consumed once: a later visit to /setting opens on the default.
const requestedPane = Session.get('settingsOpenPane');
if (requestedPane) {
Session.set('settingsOpenPane', null);
if (requestedPane === 'translation-setting') {
this.versionSetting.set(false);
this.translationSetting.set(true);
this.openPaneDecided = true;
} else if (requestedPane === 'version-setting') {
this.versionSetting.set(true);
this.openPaneDecided = true;
}
}
this.attachmentSettings = new ReactiveVar(false);
this.attachmentStorageSettings = new ReactiveVar(null);
// #6473: real storage paths come from the server (WRITABLE_PATH does not
@@ -298,6 +287,24 @@ Template.setting.onCreated(function () {
apiUploadMaxBytes: false,
apiDownloadMaxBytes: false,
});
// The pane the URL asks for. `/settings/global-webhooks` opens Global
// Webhooks; the bare `/settings` opens the default, which the route resolved
// for us, so this is always a real pane id. Reactive, so following a link to
// another pane while this page is already open switches to it - the route
// action runs again without re-creating the template.
//
// It replaces the one-shot `settingsOpenPane` the old page URLs used to hand
// over: those - /information, /translation - redirect to a slug now, and the
// pane is in the address rather than in a Session value consumed once.
this.autorun(() => {
const paneId = Session.get('settingsOpenPane');
if (!paneId) return;
if (openSettingsPane(this, paneId)) {
// A URL is an explicit choice, so it wins over the "which pane does this
// user open on" default that the site-admin autorun above applies.
this.openPaneDecided = true;
}
});
Meteor.subscribe('setting');
Meteor.subscribe('mailServer');
Meteor.subscribe('accountSettings');
@@ -363,6 +370,35 @@ Template.setting.onRendered(function () {
});
// Open exactly one pane. The URL and the menu click both come through here, so
// "which ReactiveVar is this pane" is answered once instead of in two places
// that can disagree. docs/Design/Page/Admin-Panel-URLs.md
const SETTINGS_PANE_VARS = {
'version-setting': 'versionSetting',
'tableVisibilityMode-setting': 'tableVisibilityModeSetting',
'announcement-setting': 'announcementSetting',
'accessibility-setting': 'accessibilitySetting',
'translation-setting': 'translationSetting',
'layout-setting': 'layoutSetting',
'webhook-setting': 'webhookSetting',
// Reachable from Admin Panel / Attachments rather than from this menu, but it
// is one of this template's panes and has to be cleared with the rest.
'attachment-settings': 'attachmentSettings',
// NOT registration-setting / email-setting: the Login and E-mail panes moved
// to Admin Panel / People, and naming them here would put them back in the
// one place that decides what this page can open.
};
function openSettingsPane(tpl, paneId) {
for (const varName of Object.values(SETTINGS_PANE_VARS)) {
if (tpl[varName]) tpl[varName].set(false);
}
const varName = SETTINGS_PANE_VARS[paneId];
if (varName && tpl[varName]) tpl[varName].set(true);
return !!varName;
}
// The Settings side menu, as data (docs/Design/Page/Left-Menu.md). Each entry
// used to be six lines of markup; the pane it opens is its `id`.
// `emoji: true` reproduces the empty span.emoji-icon this page always rendered
@@ -561,45 +597,23 @@ Template.setting.events({
},
'click a.js-setting-menu'(event, tpl) {
const target = $(event.target);
if (!target.hasClass('active')) {
$('.side-menu li.active').removeClass('active');
target.parent().addClass('active');
const targetID = target.data('id');
// Reset all settings to false
tpl.forgotPasswordSetting.set(false);
tpl.versionSetting.set(false);
tpl.tableVisibilityModeSetting.set(false);
tpl.translationSetting.set(false);
tpl.announcementSetting.set(false);
tpl.accessibilitySetting.set(false);
tpl.layoutSetting.set(false);
tpl.webhookSetting.set(false);
tpl.attachmentSettings.set(false);
// Set the selected setting to true
if (targetID === 'version-setting') {
tpl.versionSetting.set(true);
} else if (targetID === 'tableVisibilityMode-setting') {
tpl.tableVisibilityModeSetting.set(true);
} else if (targetID === 'translation-setting') {
tpl.translationSetting.set(true);
} else if (targetID === 'announcement-setting') {
tpl.announcementSetting.set(true);
} else if (targetID === 'accessibility-setting') {
tpl.accessibilitySetting.set(true);
} else if (targetID === 'layout-setting') {
tpl.layoutSetting.set(true);
} else if (targetID === 'webhook-setting') {
tpl.webhookSetting.set(true);
} else if (targetID === 'attachment-settings') {
tpl.attachmentSettings.set(true);
refreshAttachmentStorageSettings(tpl, true);
// Set default sub-menu state for attachment settings
console.log('Initializing attachment sub-menu');
}
if (target.hasClass('active')) return;
$('.side-menu li.active').removeClass('active');
target.parent().addClass('active');
const targetID = target.data('id');
openSettingsPane(tpl, targetID);
if (targetID === 'attachment-settings') {
refreshAttachmentStorageSettings(tpl, true);
}
// ...and put it in the address bar, so the pane can be linked, bookmarked
// and reached with the back button. Replacing rather than pushing would
// make Back leave the Admin Panel instead of returning to the previous
// pane. docs/Design/Page/Admin-Panel-URLs.md
const path = adminPath('settings', targetID);
if (path && FlowRouter.current().path !== path) FlowRouter.go(path);
},
'click a.js-toggle-support'(event, tpl) {
tpl.loading.set(true);
const supportPageEnabled = !$(
+14 -37
View File
@@ -1,44 +1,21 @@
template(name="settingHeaderBar")
h1.header-setting-menu
span {{_ 'admin-panel'}}
.setting-header-btns.left
if currentUser
template(name="adminPanelTabs")
if currentUser
.board-header-btns.admin-panel-tabs
//- Multitenancy option D (docs/Design/Multitenancy/Multitenancy.md, D.7):
//- Settings and Problems are about the INSTANCE, so a per-tenant Global
//- Admin does not get those tabs - they get People (their own Organizations
//- and their members) and Attachments (Backup, scoped to their tenant). The
//- site admin's bar is unchanged.
Settings and Problems are about the INSTANCE, so a per-tenant Global
Admin does not get those tabs - they get People (their own Organizations
and their members) and Attachments (Backup, scoped to their tenant).
if currentUser.isAdmin
a.setting-header-btn.settings(class=isSettingsActive href="{{pathFor 'setting'}}")
span.emoji-icon
a.board-header-btn.settings(class=isSettingsActive href="{{pathFor 'setting'}}" title="{{_ 'settings'}}")
i.fa.fa-cog
span {{_ 'settings'}}
a.setting-header-btn.people(class=isPeopleActive href="{{pathFor 'people'}}")
span.emoji-icon
span.board-header-btn-label {{_ 'settings'}}
a.board-header-btn.people(class=isPeopleActive href="{{pathFor 'people'}}" title="{{_ 'people'}}")
i.fa.fa-users
span {{_ 'people'}}
a.setting-header-btn.informations(class=isAttachmentsActive href="{{pathFor 'attachments'}}")
span.emoji-icon
span.board-header-btn-label {{_ 'people'}}
a.board-header-btn.informations(class=isAttachmentsActive href="{{pathFor 'attachments'}}" title="{{_ 'attachments'}}")
i.fa.fa-paperclip
span {{_ 'attachments'}}
span.board-header-btn-label {{_ 'attachments'}}
if currentUser.isAdmin
a.setting-header-btn.problems(class="{{isProblemsActive}} {{problemsClass}}" href="{{pathFor 'admin-reports'}}")
span.emoji-icon
a.board-header-btn.problems(class="{{isProblemsActive}} {{problemsClass}}" href="{{pathFor 'admin-reports'}}" title="{{_ 'problems'}}")
i.fa.fa-exclamation-triangle
span {{_ 'problems'}}
//- No Version tab: Version is the FIRST pane of Admin Panel / Settings now,
//- and the one that opens with the page, so the tab would have led to a
//- second copy of what Settings already shows. Same move Translation made.
else
a.setting-header-btn.js-log-in(
title="{{_ 'log-in'}}")
span.emoji-icon
i.fa.fa-sign-in
span {{_ 'log-in'}}
span.board-header-btn-label {{_ 'problems'}}
+3 -3
View File
@@ -11,15 +11,15 @@ function refreshProblems() {
});
}
Template.settingHeaderBar.onCreated(function () {
Template.adminPanelTabs.onCreated(function () {
refreshProblems();
problemsTimer = Meteor.setInterval(refreshProblems, 30000);
});
Template.settingHeaderBar.onDestroyed(function () {
Template.adminPanelTabs.onDestroyed(function () {
if (problemsTimer) Meteor.clearInterval(problemsTimer);
});
Template.settingHeaderBar.helpers({
Template.adminPanelTabs.helpers({
isProblemsActive() {
return FlowRouter.getRouteName() === 'admin-reports' ? 'active' : '';
},
+5 -4
View File
@@ -4,6 +4,7 @@ import { ReactiveVar } from 'meteor/reactive-var';
import { ReactiveCache } from '/imports/reactiveCache';
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 AccessibilitySettings from '/models/accessibilitySettings';
import Boards from '/models/boards';
@@ -310,9 +311,6 @@ Template.sidebar.events({
'click .js-hide-sidebar'(event, tpl) {
tpl.hide();
},
'click .js-toggle-sidebar'(event, tpl) {
tpl.toggle();
},
'click .js-back-home'(event, tpl) {
tpl.setView();
},
@@ -827,8 +825,11 @@ Template.membersWidget.events({
'click .js-manage-board-addTeam': Popup.open('addBoardTeam'),
'click .js-manage-board-addDomain': Popup.open('addBoardDomain'),
'click .js-import-board': Popup.open('chooseBoardSource'),
// Boards in Archive is a SECTION of All Boards, not a page of its own: the
// row in its left menu. `/archive` is the full-width page that section
// replaced. docs/Design/Page/Archive.md
'click .js-open-archived-board'() {
Modal.open('archivedBoards');
FlowRouter.go(allBoardsPath(SECTION_ARCHIVE, []));
},
'click .sandstorm-powerbox-request-identity'() {
window.sandstormRequestIdentity();
@@ -4,6 +4,7 @@ import { Filter } from '/client/lib/filter';
import { EscapeActions } from '/client/lib/escapeActions';
import { MultiSelection } from '/client/lib/multiSelection';
import { Utils } from '/client/lib/utils';
import { getSidebarInstance } from '/client/features/sidebar/service';
import { DEPENDENCY_TYPES } from '/models/metadata/dependencies';
Template.filterSidebar.helpers({
@@ -16,6 +17,57 @@ Template.filterSidebar.helpers({
},
});
// ────────────────────────────────────────────────────────────────────────────
// Clicking outside the filter panel closes it.
//
// Reported as "the modal that appears when I use a filter sometimes doesn't
// disappear — it should close the moment I click anything outside it". It is the
// board sidebar showing its filter view, and nothing dismissed it but the
// sidebar's own toggle or Escape: the document click handler in
// client/lib/escapeActions.js runs `clickExecute(target, 'multiselection')`, and
// `sidebarView` sits BELOW `multiselection` in the hierarchy, so a click never
// reaches it by design.
//
// Done here rather than by raising that limit, because raising it would make
// every sidebar view close on any outside click — Archive, Settings, Card
// Settings are panels people work beside on purpose, and only the filter reads as
// a thing you open, use and are done with. Escape is untouched: it still returns
// the sidebar to its default view through the existing action.
//
// Not closed by a click on: the panel itself, a pop-over it opened (the label /
// member / due-date pickers render outside the sidebar), or the header button
// that opens the filter — which would otherwise toggle it shut in the same
// gesture that opened it.
const OUTSIDE_CLICK_KEEPS_OPEN = [
'.board-sidebar',
'.pop-over',
'.js-open-filter-view',
].join(',');
Template.filterSidebar.onRendered(function () {
const instance = this;
instance._closeOnOutsideClick = evt => {
if (evt.button !== 0) return;
const sidebar = getSidebarInstance();
if (!sidebar || !sidebar.isOpen || !sidebar.isOpen()) return;
if ($(evt.target).closest(OUTSIDE_CLICK_KEEPS_OPEN).length > 0) return;
sidebar.hide();
};
// Bound after the current event has finished propagating, so the very click
// that opened the filter view cannot reach the handler it just created and
// close it again.
instance._bindOutsideClick = setTimeout(() => {
$(document).on('click.wekanFilterSidebar', instance._closeOnOutsideClick);
}, 0);
});
Template.filterSidebar.onDestroyed(function () {
clearTimeout(this._bindOutsideClick);
$(document).off('click.wekanFilterSidebar', this._closeOnOutsideClick);
});
// SubsManager removed for Meteor 3 migration
function getFilterIdFromEvent(evt, fallbackId) {
@@ -39,10 +39,10 @@ template(name="swimlaneFixedHeader")
i.fa.fa-plus
if isTouchScreenOrShowDesktopDragHandles
unless isTouchScreen
a.swimlane-header-handle.handle.js-swimlane-header-handle
a.swimlane-header-handle.handle.nodragscroll.js-swimlane-header-handle
i.fa.fa-arrows
if isTouchScreen
a.swimlane-header-miniscreen-handle.handle.js-swimlane-header-handle
a.swimlane-header-miniscreen-handle.handle.nodragscroll.js-swimlane-header-handle
i.fa.fa-arrows
template(name="editSwimlaneTitleForm")
@@ -55,6 +55,18 @@ template(name="editSwimlaneTitleForm")
template(name="swimlaneActionPopup")
if currentUser
//- Copy link, FIRST and above every permission check: a swimlane has an
address now, and copying it is reading, not editing - somebody who may
only read the board may still tell a colleague which swimlane they mean.
The rows below are all guarded because they change the board.
models/lib/boardItemUrl.js, docs/Design/Page/Board-Item-Links.md
ul.pop-over-list
li: a.js-copy-swimlane-link
i.fa.fa-link
span
| {{_ 'copy-link-to-clipboard'}}
span.copied-tooltip {{_ 'copied'}}
hr
unless currentUser.isCommentOnly
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
@@ -98,6 +98,19 @@ Template.editSwimlaneTitleForm.helpers({
});
Template.swimlaneActionPopup.events({
// Copy the swimlane's own address. `absoluteUrl` so what lands in the
// clipboard can be pasted anywhere - a relative path is only a link inside
// this page. The popup stays open long enough to show "Copied"; it is the
// confirmation, and a menu that vanishes the instant you click leaves you
// wondering whether anything happened. models/lib/boardItemUrl.js
'click .js-copy-swimlane-link'(event, tpl) {
event.preventDefault();
const swimlane = Template.currentData();
if (!swimlane) return;
const url = swimlane.absoluteUrl();
if (!url) return;
Utils.showCopied(Utils.copyTextToClipboard(url), tpl.$('.copied-tooltip'));
},
'click .js-add-swimlane': Popup.open('swimlaneAdd'),
'click .js-add-list-from-swimlane': Popup.open('addList'),
'click .js-set-swimlane-color': Popup.open('setSwimlaneColor'),
+28
View File
@@ -399,3 +399,31 @@ body.swimlane-resizing-active {
body.swimlane-resizing-active * {
cursor: row-resize !important;
}
/* The swimlane or list a link named, for a moment after it is scrolled to.
A scroll that lands mid-board gives no sign of WHICH of the things now on
screen the link was about; this says so and then gets out of the way. The
class is removed after the animation by client/lib/revealBoardItem.js, so
nothing is left highlighted for the rest of the session.
`outline`, not `border` or `box-shadow`: it is drawn outside the box and so
moves neither the swimlane's rows nor the list's cards by a pixel. */
.is-revealed {
outline: 3px solid #2980b9;
outline-offset: -3px;
animation: wekan-reveal-fade 2s ease-out forwards;
}
@keyframes wekan-reveal-fade {
0% { outline-color: #2980b9; }
70% { outline-color: #2980b9; }
100% { outline-color: transparent; }
}
/* Somebody who asked not to be animated gets the outline without the fade -
the point is to say which one, and that does not need movement. */
@media (prefers-reduced-motion: reduce) {
.is-revealed {
animation: none;
}
}
+36 -25
View File
@@ -1,6 +1,10 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { once } from '/imports/lib/collectionHelpers';
import dragscroll from '@wekanteam/dragscroll';
import {
suspendBoardDragscroll,
resumeBoardDragscroll,
isBoardDragscrollSuspended,
} from '/client/lib/boardDragscroll';
import Lists from '/models/lists';
import { CSSEvents } from '/client/lib/cssEvents';
import { Filter } from '/client/lib/filter';
@@ -106,9 +110,7 @@ function saveSorting(ui) {
!window.confirm(TAPi18n.__('confirm-move-list-to-swimlane'))
) {
Meteor.subscribe('board', list.boardId, false);
$('.js-swimlane').each(function () {
$(this).addClass('dragscroll');
});
resumeBoardDragscroll(); // #6558: the drag is over, panning comes back
return;
}
updateData.swimlaneId = targetSwimlaneId;
@@ -177,17 +179,9 @@ function saveSorting(ui) {
// Silent fail
}
// Re-enable dragscroll after list dragging is complete
try {
dragscroll.reset();
} catch (e) {
// Silent fail
}
// Re-enable dragscroll on all swimlanes
$('.js-swimlane').each(function () {
$(this).addClass('dragscroll');
});
// Re-enable dragscroll after list dragging is complete - the swimlanes AND
// the canvas, exactly as they were tagged before the drag (#6558).
resumeBoardDragscroll();
}
function currentListIsInThisSwimlane(swimlaneId) {
@@ -386,16 +380,11 @@ function initSortable(boardComponent, $listsDom) {
// Add visual feedback for list being dragged
ui.item.addClass('ui-sortable-helper');
// Disable dragscroll during list dragging to prevent interference
try {
dragscroll.reset();
} catch (e) {
}
// Also disable dragscroll on all swimlanes during list dragging
$('.js-swimlane').each(function() {
$(this).removeClass('dragscroll');
});
// #6558: no panning while a list is dragged. This used to call
// dragscroll.reset() FIRST and remove the class afterwards, which does
// nothing - reset() binds to whatever carries the class at that moment,
// so the listeners stayed - and it left `.board-canvas` tagged anyway.
suspendBoardDragscroll();
},
beforeStop(evt, ui) {
// Clean up visual feedback
@@ -483,8 +472,10 @@ Template.swimlane.onRendered(function () {
ui.placeholder.width(ui.helper.width());
EscapeActions.executeUpTo('popup-close');
if (boardComponent) boardComponent.setIsDragging(true);
suspendBoardDragscroll(); // #6558
},
stop(evt, ui) {
resumeBoardDragscroll(); // #6558
if (boardComponent) boardComponent.setIsDragging(false);
saveSorting(ui);
},
@@ -817,6 +808,18 @@ Template.swimlane.events({
).concat([
'.js-list-resize-handle',
'.js-swimlane-resize-handle',
// #6558: everything that is a DRAG SOURCE rather than a pan surface.
// This lane-panning handler is a second implementation of drag-scroll,
// and it used to ignore the `nodragscroll` marker the dragscroll library
// honours - so pressing a card (which carries `nodragscroll` when the
// card itself is the sortable's handle) started a horizontal lane pan
// that ran ALONGSIDE the card drag: the card followed the pointer while
// the list slid sideways under it, and the drop landed somewhere else.
// `.handle` covers the card/list/swimlane drag handles when handles are
// on, `.nodragscroll` covers the minicards, resize handles, checklists
// and anything else already marked as not-for-panning.
'.nodragscroll',
'.handle',
]);
const isResizeHandle = $(evt.target).closest('.js-list-resize-handle, .js-swimlane-resize-handle').length > 0;
@@ -840,6 +843,12 @@ Template.swimlane.events({
}
},
mousemove(evt, tpl) {
// #6558: a card / list / swimlane drag has taken over the pointer - stop
// panning, whatever started it.
if (tpl._isDragging && isBoardDragscrollSuspended()) {
tpl._isDragging = false;
return;
}
if (tpl._isDragging) {
// Update the canvas position
tpl.listsDom.scrollLeft -= evt.clientX - tpl._lastDragPositionX;
@@ -1038,8 +1047,10 @@ Template.listsGroup.onRendered(function () {
ui.placeholder.width(ui.helper.width());
EscapeActions.executeUpTo('popup-close');
if (boardComponent) boardComponent.setIsDragging(true);
suspendBoardDragscroll(); // #6558
},
stop(evt, ui) {
resumeBoardDragscroll(); // #6558
if (boardComponent) boardComponent.setIsDragging(false);
saveSorting(ui);
},
+15 -3
View File
@@ -266,12 +266,24 @@ template(name="changeSettingsPopup")
option(value="#{day.value}") #{day.name}
label.bold.clear
| {{_ 'card-settings'}}
//- #6465: the checkbox and its label are ONE row, built the way every
//- other checkbox row in this file is (see the board list above):
//- a .flex anchor, the .materialCheckBox, then the text. It used to
//- carry a `b &nbsp;` spacer and `.left` (float: inline-start) as
//- well - a float inside a flex container is ignored, so the spacer
//- became a stray flex item and the box drifted away from the text it
//- belongs to, up beside the "Card settings" heading above.
ul#cards.card-description-rescued
a.flex.js-rescue-card-description(title="{{_ 'rescue-card-description'}}")
b &nbsp;
#rescue-card-description.materialCheckBox.left(class="{{#if rescueCardDescription}}is-checked{{/if}}", value=rescueCardDescription)
#rescue-card-description.materialCheckBox(class="{{#if rescueCardDescription}}is-checked{{/if}}", value=rescueCardDescription)
span {{_ 'rescue-card-description'}}
input.js-apply-user-settings.left(type="submit" value="{{_ 'apply'}}")
//- "Save", and themed. It said "Apply" while every other settings form in
//- WeKan says Save, and it carried no `.primary` class - so it fell to the
//- BASE submit rule in forms.css, whose fallback is `#000`: a pure black
//- button with no theme in it. `.primary` is the themed one
//- (`var(--theme-accent, #005377)`), which is what the Save button of the
//- Change Language form above it uses.
input.js-apply-user-settings.primary.left(type="submit" value="{{_ 'save'}}")
template(name="userDeletePopup")
unless currentUser.isWorker
+8 -1
View File
@@ -7,6 +7,7 @@ import { Utils } from '/client/lib/utils';
import { ReactiveVar } from 'meteor/reactive-var';
import { detectAvailableFonts } from '/client/lib/fontDetector';
import { fontFamilyValue, fontSizeValue, UI_FONT_SIZES, isHexColor6, colorValue } from '/models/lib/uiFonts';
const { allBoardsPath, SECTION_ARCHIVE } = require('/models/lib/allBoardsUrls');
Template.headerUserBar.events({
'click .js-open-header-member-menu': Popup.open('memberMenu'),
@@ -69,8 +70,14 @@ Template.memberMenuPopup.events({
'click .js-due-cards'() {
Popup.back();
},
// Boards in Archive is a SECTION of All Boards - the row in its left menu -
// not a page of its own. This sent the reader to `/archive`, the full-width
// page that section replaced: the same list, but with no menu beside it and
// no way to step across to Starred or Remaining without going back first.
// docs/Design/Page/Archive.md
'click .js-open-archived-board'() {
Modal.open('archivedBoards');
FlowRouter.go(allBoardsPath(SECTION_ARCHIVE, []));
Popup.back();
},
'click .js-invite-people': Popup.open('invitePeople'),
'click .js-edit-profile': Popup.open('editProfile'),
+7
View File
@@ -3,6 +3,8 @@ import '/client/components/boards/boardBody.jade';
import '/client/components/boards/dependencyOverlay.jade';
import '/client/components/boards/boardHeader.jade';
import '/client/components/boards/boardsList.jade';
import '/client/components/boards/allBoardsSidebar.jade';
import '/client/components/boards/publicBoards.jade';
import '/client/components/boards/miniboard.jade';
import '/client/components/boards/originalPositionsView.jade';
import '/client/components/boards/tableView.jade';
@@ -12,7 +14,11 @@ import '/client/components/boards/boardArchive.js';
import '/client/components/boards/boardBody.js';
import '/client/components/boards/dependencyOverlay.js';
import '/client/components/boards/boardHeader.js';
import '/client/lib/allBoardsView';
import '/client/lib/allBoardsSidebar';
import '/client/components/boards/boardsList.js';
import '/client/components/boards/allBoardsSidebar.js';
import '/client/components/boards/publicBoards.js';
import '/client/components/boards/originalPositionsView.js';
import '/client/components/boards/tableView.js';
import '/client/components/boards/statsView.js';
@@ -22,6 +28,7 @@ import '/client/components/boards/dependencyOverlay.css';
import '/client/components/boards/boardColors.css';
import '/client/components/boards/boardHeader.css';
import '/client/components/boards/boardsList.css';
import '/client/components/boards/publicBoards.css';
import '/client/components/boards/calendarView.css';
import '/client/components/boards/originalPositionsView.css';
import '/client/components/boards/tableView.css';
+5
View File
@@ -9,6 +9,9 @@ import '/client/components/main/recoveryMaintenance.jade';
import '/client/components/main/themeColorPicker.jade';
import '/client/components/main/keyboardShortcuts.jade';
import '/client/components/main/layouts.jade';
// The shared page sidebar, rendered by the layout above. A .jade file is not
// picked up by being on disk - see tests/templateRegistration.test.cjs.
import '/client/components/main/pageSidebar.jade';
import '/client/components/main/myCards.jade';
import '/client/components/main/popup.tpl.jade';
import '/client/components/main/spinner.jade';
@@ -24,6 +27,8 @@ import '/client/components/main/support.jade';
import '/client/lib/keyboard';
import '/client/lib/popup';
import '/client/lib/pageSidebar';
import '/client/components/main/pageSidebar.js';
import '/client/components/main/accessibility.js';
import '/client/components/main/bookmarks.js';
import '/client/components/main/brokenCards.js';
+7
View File
@@ -17,6 +17,13 @@ import '/client/components/settings/attachments.js';
import '/client/components/settings/connectionMethod.js';
import '/client/components/settings/informationBody.js';
import '/client/components/settings/invitationCode.js';
// The left menu's caret. Its .jade above only DRAWS the caret: the click that
// folds the menu and the `isLeftMenuCollapsed` helper that says whether it is
// folded live here, and package.json sets meteor.mainModule, so a file nobody
// imports is simply not in the bundle. Without this line the caret rendered and
// did nothing - an unregistered helper is undefined, so the panel never took
// the `collapsed` class either. docs/Design/Page/Left-Menu.md
import '/client/components/settings/leftMenu.js';
import '/client/components/settings/lockedUsersBody.js';
import '/client/components/settings/migrationProgress.js';
import '/client/components/settings/peopleBody.js';
+71
View File
@@ -0,0 +1,71 @@
import { ReactiveVar } from 'meteor/reactive-var';
import { EscapeActions } from '/client/lib/escapeActions';
const {
DEFAULT_SIDEBAR_VIEW,
resolveSidebarView,
} = require('/models/lib/allBoardsSidebar');
// Whether the All Boards right sidebar is open, and on which view.
//
// Two variables rather than "view or null", because closing must not forget
// where you were: open the sidebar with the hamburger after searching and you
// get the search you had, not an empty home. The board sidebar keeps its view
// across open/close for the same reason.
//
// Module scope, not a template instance: the header bar that opens the sidebar
// and the sidebar itself are separate Blaze instances - the bar is rendered
// into the layout's headerBar region - so an instance ReactiveVar on either is
// invisible to the other. Same reason as client/lib/allBoardsView.js.
//
// docs/Design/Page/Search.md, docs/Design/Page/Multi-Selection.md
const openVar = new ReactiveVar(false);
const viewVar = new ReactiveVar(DEFAULT_SIDEBAR_VIEW);
export function allBoardsSidebarView() {
return resolveSidebarView(viewVar.get());
}
export function isAllBoardsSidebarOpen() {
return openVar.get();
}
export function isAllBoardsSidebarView(view) {
return openVar.get() && allBoardsSidebarView() === view;
}
// Open on a view. Asking for the view that is already showing while the sidebar
// is open closes it again, so the header-bar button that opened it also shuts
// it - which is what a pressed-looking button should do.
export function openAllBoardsSidebar(view) {
const next = resolveSidebarView(view);
if (openVar.get() && allBoardsSidebarView() === next) {
closeAllBoardsSidebar();
return;
}
viewVar.set(next);
openVar.set(true);
}
export function closeAllBoardsSidebar() {
openVar.set(false);
}
export function toggleAllBoardsSidebar() {
if (openVar.get()) closeAllBoardsSidebar();
else openVar.set(true);
}
// Escape closes it, at the same level as the board sidebar's own view - so one
// Escape leaves the sidebar and the next one is free for whatever is behind it.
//
// The KEY only: `enabledOnClick` defaults to true, which would also run this
// from the global click handler, and then every click closed the sidebar -
// including clicks on its own contents, and including the header-bar button
// that had just opened it.
EscapeActions.register(
'sidebarView',
() => closeAllBoardsSidebar(),
() => isAllBoardsSidebarOpen(),
{ enabledOnClick: false },
);
Loaded 100 of 783 files, more files were not shown because too many files have changed in this diff. Show more