mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 15:45:01 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce35869a7 | ||
|
|
2017d07e37 | ||
|
|
9ea5c81932 | ||
|
|
45ccdd9c3c | ||
|
|
5a21b074d4 | ||
|
|
e9c5888485 | ||
|
|
09c9d72398 | ||
|
|
4f3d45f3de | ||
|
|
3aaa46fdae | ||
|
|
59f1b705e0 | ||
|
|
22e5df55d7 | ||
|
|
016b4bf3bd | ||
|
|
4d44349ab6 | ||
|
|
d3edc56170 | ||
|
|
5134e3d2a8 | ||
|
|
f686b76023 | ||
|
|
cd2adcad02 | ||
|
|
0766a65fef | ||
|
|
58ad81e301 | ||
|
|
2912f4b080 | ||
|
|
97d1eca09d | ||
|
|
95b75177b8 | ||
|
|
880ec2d707 | ||
|
|
dbc7cccea3 | ||
|
|
d8b3579117 | ||
|
|
c7e53e9f38 | ||
|
|
6012521ccc | ||
|
|
0195a5b3d4 | ||
|
|
b12e761afb | ||
|
|
0c0d666ffa | ||
|
|
fc2ad0a48b | ||
|
|
dad03959d9 | ||
|
|
ac960e7d81 | ||
|
|
3213d7fa1d | ||
|
|
4757676abf | ||
|
|
efaf94242e | ||
|
|
d87baa0244 | ||
|
|
006d24502c | ||
|
|
8f4efbca57 | ||
|
|
dde09dead5 | ||
|
|
3621413d6f | ||
|
|
3c3fc07b3c | ||
|
|
c1e8976b28 | ||
|
|
dcce935310 | ||
|
|
1b05673200 | ||
|
|
dafb63299f | ||
|
|
84b0480aa8 | ||
|
|
f6fc97e77a | ||
|
|
d7105e531d | ||
|
|
f25957dcf3 | ||
|
|
984ea93aa8 | ||
|
|
2441563008 | ||
|
|
f3950b9adc | ||
|
|
5e07b8db6c | ||
|
|
eeeb0dbec9 | ||
|
|
96d56ecc6c | ||
|
|
aadfd9b6b3 | ||
|
|
1c44876beb | ||
|
|
bd86b518ea | ||
|
|
f576a0cca9 | ||
|
|
46c0ad43e8 | ||
|
|
499772e4c2 | ||
|
|
90fe133363 | ||
|
|
b812d36871 | ||
|
|
d05a42a5af | ||
|
|
42507de1d0 | ||
|
|
131b0cc509 | ||
|
|
93ae5ac6ee | ||
|
|
34bb91d1f5 | ||
|
|
d231df56fc | ||
|
|
e422ab691e | ||
|
|
3f712a0445 | ||
|
|
d99dadadee | ||
|
|
a58b7bee2c | ||
|
|
62da564a1e | ||
|
|
2ada2e2d61 | ||
|
|
5de1acf496 | ||
|
|
3008bb5606 | ||
|
|
4f7521ae3c | ||
|
|
1c63049913 | ||
|
|
6b27764452 | ||
|
|
ae49937b44 | ||
|
|
7f27ea1f26 | ||
|
|
144777ae6b | ||
|
|
f4b5025a7b | ||
|
|
cdcf8ec39d | ||
|
|
739f9ed7a0 | ||
|
|
f06d082e34 | ||
|
|
4036b16774 | ||
|
|
0110e84ce5 | ||
|
|
00a50ae139 | ||
|
|
c67a67bc9a | ||
|
|
0b8a7face3 | ||
|
|
8b2114cd0f | ||
|
|
0426c7741a | ||
|
|
c2a28a9233 | ||
|
|
a6b10e3592 | ||
|
|
1ac4d1b1f9 | ||
|
|
222492f313 | ||
|
|
4f95b10f0d | ||
|
|
86a18665f0 | ||
|
|
1ded34dfdd | ||
|
|
db791e1d40 | ||
|
|
e8e22dc25e | ||
|
|
08d20930de | ||
|
|
10f244de77 | ||
|
|
4e28465964 |
No files matched your search
@@ -26,7 +26,7 @@ Work out which you are from the git identity (the next section says how), then:
|
||||
| Who | Where the work goes |
|
||||
| --- | --- |
|
||||
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then one pull request per feature or fix** for the maintainer to review. Never a commit on `main`, and no changelog changes. |
|
||||
|
||||
Nothing else is a third option. If a tool, a task runner or an agent harness offers
|
||||
some other arrangement — a topic branch for the maintainer, a direct push for a
|
||||
@@ -41,7 +41,9 @@ released section links a commit that release does not contain.
|
||||
|
||||
The contributor's half is review. Nobody but the maintainer commits to `main` in
|
||||
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
|
||||
the only place it can be discussed before it lands.
|
||||
the only place it can be discussed before it lands. That is why each pull request
|
||||
holds one feature or one fix, and leaves the changelogs to the maintainer: see
|
||||
"Contributor mode" below.
|
||||
|
||||
Check which branch you are on before committing:
|
||||
|
||||
@@ -143,12 +145,29 @@ git config user.name && git config user.email
|
||||
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
|
||||
steps** below are available to a human maintainer only: an AI stops after
|
||||
committing locally and never runs a step that pushes or publishes.
|
||||
|
||||
**Maintainer mode is the only mode that commits directly and the only mode that
|
||||
updates changelogs**, in WeKan and in the maintainer's repositories under
|
||||
`.tools/` alike. It applies to the changes the WeKan maintainer makes, and to the
|
||||
changes of an agent working as the maintainer according to this file.
|
||||
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
|
||||
own — a person, or an AI that files pull requests on its own account. Then: do
|
||||
**not** commit to `main` and do **not** run any release/publishing step. Make the
|
||||
changes on a branch and open a **pull request** for the maintainer to review. The
|
||||
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
|
||||
instructions below are **maintainer-only and do not apply to you**.
|
||||
**not** commit directly to the repository or to `main`, and do **not** run any
|
||||
release/publishing step. Make the changes on a branch and open a **pull request**
|
||||
for the maintainer to review. The "commit as Lauri Ojansivu", "commit directly to
|
||||
`main`", changelog and release instructions below are **maintainer-only and do
|
||||
not apply to you**.
|
||||
|
||||
Every pull request, to wekan/wekan or to a maintainer's repository under `.tools/`,
|
||||
follows two more rules:
|
||||
- **One feature or one fix per pull request.** Changes share a pull request only
|
||||
when they are related to that one feature or fix. Anything unrelated, however
|
||||
small, goes in a pull request of its own, so each can be reviewed, merged or
|
||||
declined without holding up the others.
|
||||
- **No changelog changes.** Do not edit `CHANGELOG-big.md`, `CHANGELOG.md`, or the
|
||||
`CHANGELOG.md` or `CHANGELOG` of a repository under `.tools/` — not an Upcoming
|
||||
entry, not TODO Later. Describe the change in the pull request instead. The
|
||||
maintainer writes the entry after merging, when there is a commit for it to link.
|
||||
|
||||
Who that pull request credits follows the attribution rule above, and the two
|
||||
cases differ:
|
||||
@@ -171,8 +190,8 @@ The one repository under `.tools/` that is somebody else's is `sandstorm-io/sand
|
||||
cloned for reference only.
|
||||
|
||||
Everything below marked as maintainer-specific (committing directly, the exact commit
|
||||
author, and the entire "Making a release" / publishing flow) applies only in maintainer
|
||||
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
|
||||
author, updating changelogs, and the entire "Making a release" / publishing flow) applies
|
||||
only in maintainer mode. The rest (translation policy, tests, validating from code) is
|
||||
good practice for everyone.
|
||||
|
||||
## Process: "Fix open issues"
|
||||
@@ -206,6 +225,9 @@ When asked to fix an open issue (one issue at a time):
|
||||
|
||||
Fixes #1234,
|
||||
```
|
||||
|
||||
A contributor instead commits on a branch for one pull request that fixes this
|
||||
one issue, with no changelog changes (see "Contributor mode" above).
|
||||
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
|
||||
whose message ends `Fixes #1234,`), noting where/when it was fixed.
|
||||
|
||||
@@ -407,6 +429,10 @@ directly after the merge.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
|
||||
section is for the maintainer and for an agent working as the maintainer, in WeKan
|
||||
and in the repositories under `.tools/`. A pull request contributor changes no
|
||||
changelog in any of them (see "Contributor mode" above).
|
||||
- **Every repository writes its changelog in the format that repository's own file
|
||||
already uses.** Open its changelog, read the entries above the place you are
|
||||
adding one, and match them — never import another project's shape into it. The
|
||||
@@ -817,7 +843,7 @@ have cost a released section its accuracy:
|
||||
### Security issues
|
||||
|
||||
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
|
||||
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
entries (maintainer mode only, as with every changelog change), and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
subdirectory `index.html` like previous security issues.
|
||||
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
|
||||
fault cannot exist ANYWHERE in the codebase — not just at the place it was
|
||||
|
||||
@@ -124,6 +124,900 @@ its reason.
|
||||
|
||||
</details>
|
||||
|
||||
# v12.96 2026-11-05 WeKan ® release
|
||||
|
||||
**In short:** The release **bundle starts again**: v12.95's build stopped on
|
||||
boot because the **nodemailer** 10 raised inside Meteor's email package no
|
||||
longer had the files that package was built against.
|
||||
|
||||
This release fixes the following bug:
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/5a21b074d4">The bundle starts again, with nodemailer 10 inside Meteor's email package</a>. Thanks to xet7.</summary>
|
||||
|
||||
v12.95 raised the nodemailer in `meteor/email` from 8.0.3 to 10.3.1, and
|
||||
nodemailer 10 moved `lib/` to `dist/cjs/`. `packages/email.js`, compiled
|
||||
against 8.0.3, links `lib/nodemailer.js` and `lib/well-known/index.js` and
|
||||
`Npm.require`s `lib/mail-composer` by path, so build-amd64 failed its smoke
|
||||
boot. `releases/bundle-npm-security-bumps.json` now shims nodemailer as it
|
||||
already shims uuid 11: `lib/nodemailer.js` and every `lib/<name>` directory the
|
||||
exports map lists are written back, forwarding to `dist/cjs`, with a
|
||||
`lib/package.json` marking them CommonJS because nodemailer is `"type":
|
||||
"module"`. A fresh local bundle taken through build-amd64's install, bump, trim
|
||||
and prune steps passes `releases/bundle-smoke-boot.sh`.
|
||||
`tests/bundleNpmSecurityBumps.test.cjs` requires the recorded paths through the
|
||||
shim, checks every exported `lib/<name>`, and fails without the CommonJS marker.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.95 2026-11-05 WeKan ® release
|
||||
|
||||
**In short:** **Continuous backup** now runs on one server at a time, with a
|
||||
lease, when WeKan has several servers. Missing-board **exports** and REST
|
||||
updates of missing **integrations**, **users**, **organizations** and **teams**
|
||||
answer 404 instead of 200, the **minicard** shows a picked Requested by and
|
||||
Assigned by, a card stops offering the attachment "+" when uploads are blocked,
|
||||
and the Docker image's **nodemailer**, **body-parser**, **qs**, **uuid** and
|
||||
**postcss-selector-parser** are raised past the advisories a Grype scan found.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**Exports** - what the export routes answer when something is wrong.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/0c0d666ffa">Missing-board exports return 404, and an over-long export token returns 400</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
The board Excel export answered a missing board with HTTP 200 and only the text
|
||||
`Board not found` (PR #6869). The two PDF export routes had the same fault, and
|
||||
the Excel and PDF routes answered an over-long auth token with 200 as well,
|
||||
while `export.js` already used 404 and 400. All of them now choose their status
|
||||
the same way.
|
||||
|
||||
`tests/exportErrorStatuses.test.cjs` scans every `models/export*.js` route for a
|
||||
text answer without a status, so the fault cannot return anywhere there; the
|
||||
PR's route and browser tests stay.
|
||||
|
||||
</details>
|
||||
|
||||
**Continuous backup** - which server streams it.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/880ec2d707">Only one server streams it at a time, holding a lease</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
With several WeKan servers on one database, every server applied the continuous
|
||||
backup setting, so
|
||||
each of them tailed the oplog and wrote its own archives into the same target
|
||||
directory
|
||||
(\#6861). The leader now holds a lease, one `continuousBackupLease` document
|
||||
with its id and an
|
||||
expiry. Taking it is one atomic operation, the leader renews it every 15 seconds
|
||||
and it lapses after
|
||||
45, so another server takes over within a minute when the leader stops, and a
|
||||
server that loses the
|
||||
lease stops its stream. Startup, saving the settings and a change saved on
|
||||
another server converge
|
||||
through that loop; a restore or a fetch from the cloud takes the lease and waits
|
||||
one renewal so it
|
||||
is the only writer. One server is always its own leader.
|
||||
|
||||
Checked against the real driver on FerretDB (SQLite): 24 clients at once give
|
||||
exactly one leader, and
|
||||
renewal, expiry, the old leader losing the lease and release behave.
|
||||
`tests/continuousBackupLease.test.cjs`
|
||||
pins the same and the wiring.
|
||||
|
||||
</details>
|
||||
|
||||
**Attachments** - what a card offers when the administrator blocked uploads.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/d8b3579117">A card no longer offers the attachment "+" when uploads are blocked for the whole site</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
Admin Panel / Attachments / Transfer limits already has "Attachments upload" and
|
||||
"API upload" set to
|
||||
Blocked, and the server refuses on every path: the upload pipeline,
|
||||
`api.attachment.upload` and
|
||||
`.copy`, and the `/api/attachment` upload, upload-background and copy routes.
|
||||
The card still showed
|
||||
the "+" button and accepted a drop or paste for an upload that would then be
|
||||
refused. It now asks
|
||||
the server one boolean, at most once a minute, and hides both. No second setting
|
||||
was added
|
||||
(\#6870 proposed a `disableAttachments`), so nothing can disagree with what the
|
||||
server enforces.
|
||||
|
||||
`tests/attachmentUploadPolicy.test.cjs` pins the client, the method, the server
|
||||
enforcement it relies
|
||||
on, and that no `disableAttachments` setting exists.
|
||||
|
||||
The method was not imported at first, so the call failed and the "+" stayed;
|
||||
[a follow-up](https://github.com/wekan/wekan/commit/3aaa46fdae) loads it, and
|
||||
`tests/serverModulesLoaded.test.cjs` now passes.
|
||||
|
||||
</details>
|
||||
|
||||
**REST API** - what the integration, user, organization and team update routes
|
||||
answer for something that is not there.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/6012521ccc">An integration update for a missing or wrong-board integration returns 404</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
`PUT /api/boards/:boardId/integrations/:intId` answered 200 with the requested
|
||||
id when no
|
||||
integration matched that id and board (PR #6871). The two activities routes
|
||||
(`POST` and `DELETE`
|
||||
`.../activities`) updated nothing and answered 200 with `data: null`. All three
|
||||
now look the
|
||||
integration up on that board after board-admin authorization and answer the JSON
|
||||
404 the `GET`
|
||||
route already uses, before validating or writing anything.
|
||||
|
||||
`tests/restIntegrationWriteRoutes404.test.cjs` pins, for the whole file, that
|
||||
every handler that
|
||||
updates an integration looks it up first and only after authorization; the PR's
|
||||
route and browser
|
||||
tests stay.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/d3edc56170">An update of a missing user returns 404</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
`PUT /api/users/:userId` answered 200 with no data when no user had that id,
|
||||
for every action (PR #6873). After the admin check it now answers a JSON 404
|
||||
`User not found` before taking ownership, disabling or enabling a login.
|
||||
`tests/restUserUpdate.test.cjs` runs the handler for every action against a
|
||||
missing user, and checks that authorization still comes first, that
|
||||
self-disable stays a no-op and that a database failure stays a safe 500;
|
||||
`tests/playwright/specs/rest-user-update.e2e.js` drives it.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/4d44349ab6">A feature update of a missing organization or team returns 404</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
`PUT /api/admin/orgs/:orgId/features` and `PUT
|
||||
/api/admin/teams/:teamId/features`
|
||||
answered 200 with `data: undefined` when there was no such organization or team
|
||||
(PR #6874). They now answer a JSON 404 `Organization not found` / `Team not
|
||||
found`. `tests/restPeopleFeatures.test.cjs` runs both handlers for missing and
|
||||
existing records, empty bodies, denied authorization and database failures;
|
||||
`tests/playwright/specs/rest-people-features.e2e.js` checks the Admin Panel /
|
||||
People feature checkboxes.
|
||||
|
||||
</details>
|
||||
|
||||
**The minicard** - who requested and who assigned a card.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/22e5df55d7">Requested by and Assigned by show when a member was picked, with one icon per field everywhere</a>. Thanks to mueschel and xet7.</summary>
|
||||
|
||||
The opened card's + button picks board members into `requesters` /
|
||||
`assigners`, and the free-text name is optional, but the minicard drew only the
|
||||
free text: a requester picked the usual way never showed although Show on
|
||||
Minicard was on (#6872). The minicard now shows the picked members as avatars
|
||||
beside any typed name. Card Settings also had the icons swapped - a head with a
|
||||
plus for Requested by and a cart for Assigned by - and now uses the cart and
|
||||
user-plus that the opened card and the minicard use.
|
||||
`tests/minicardRequestedAssignedBy6872.test.cjs` pins the avatars, the
|
||||
either-or condition and one icon per field, and fails on a minicard block gated
|
||||
on the free text alone;
|
||||
`tests/playwright/specs/minicard-requested-by-6872.e2e.js`
|
||||
drives it.
|
||||
|
||||
</details>
|
||||
|
||||
and updates the following dependencies:
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/dad03959d9">nodemailer 10.3.1, body-parser 1.20.9 and 2.4.0, qs 6.16.1, uuid 11.1.1 and postcss-selector-parser 6.1.5 in the bundle</a>. Thanks to the Grype scan reporter and xet7.</summary>
|
||||
|
||||
A Grype scan of the image found High and Medium advisories in packages that
|
||||
Meteor's own packages carry inside `programs/server/npm/node_modules/meteor/`,
|
||||
where `package.json` and its `overrides` cannot reach.
|
||||
`releases/bump-bundle-npm-deps.mjs` raises them after `meteor build`.
|
||||
|
||||
- **nodemailer 8.0.11 to 10.3.1** (GHSA-2x7j-588g-ccc2, GHSA-v53p-9fqp-m79j,
|
||||
GHSA-p6gq-j5cr-w38f and the rest fixed in 10.1.4 or later). It was listed as
|
||||
not fixable because the fix is a new major. `meteor/email` only calls
|
||||
`createTransport`, the `stream` plugin hook, `nodemailer/lib/well-known` and
|
||||
`nodemailer/lib/mail-composer`, and all four were run against 10.3.1 through
|
||||
the bundle's own package specifiers.
|
||||
- **body-parser 1.20.6 and 1.20.8 to 1.20.9, and 2.3.0 to 2.4.0**
|
||||
(GHSA-27r3-jxmh-p828). A minimum may now be written `body-parser@2`, which
|
||||
applies to copies of that major only, so each major is raised inside itself.
|
||||
- **qs to 6.16.1** (GHSA-6pq8-jhm2-2vcw) and **postcss-selector-parser 6.1.4 to
|
||||
6.1.5** (GHSA-rj75-hqrm-r3gf).
|
||||
- **uuid 8.3.2 and 9.0.1 to 11.1.1** (GHSA-w5hq-g745-h8pq). uuid 11 moved its
|
||||
entry point from `dist/index.js` to `dist/cjs/index.js`, which killed the
|
||||
server on boot the one time it was tried. The bumper now writes the old
|
||||
`dist/index.js` back as `export * from './esm/index.js'`: it is an ES module
|
||||
because uuid is `"type": "module"`, and Node 26 `require()`s it.
|
||||
|
||||
glob 14 and minimatch 11 were already in `package.json`; the scanned image
|
||||
predates them. The runtime image installs no compiler (`gcc-14-base` is Debian's
|
||||
one-file package), so the multi-stage build the report asks for is already how
|
||||
it is built. Run against a copy of the published image's bundle, the bumper
|
||||
raised all of the above and every raised copy loaded.
|
||||
|
||||
**This release's bundle did not start.** "Every raised copy loaded" was checked
|
||||
by loading each package, not by booting the bundle: nodemailer 10 moved `lib/`
|
||||
to `dist/cjs/`, Meteor's `email` package still links the `lib/` paths of 8.0.3,
|
||||
and build-amd64 failed its smoke boot with "Cannot find module
|
||||
.../meteor/email/node_modules/nodemailer/lib/nodemailer.js". The fix is in the
|
||||
next release.
|
||||
`tests/bundleNpmSecurityBumps.test.cjs` pins the minimums, the per-major
|
||||
rule and the shim, and runs the bumper with `--dry-run` on a bundle of
|
||||
body-parser, uuid and nodemailer copies at, below and above their minimums.
|
||||
|
||||
</details>
|
||||
|
||||
and has the following documentation change:
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/016b4bf3bd">Only maintainer mode commits directly and updates changelogs, and a pull request has one feature or fix</a>. Thanks to xet7.</summary>
|
||||
|
||||
CLAUDE.md and AGENTS.md now say that a pull request contributor, to wekan/wekan
|
||||
or to the maintainer's repositories under `.tools/`, sends each feature or fix
|
||||
as its own pull request with only related changes in it, and changes no
|
||||
changelog. The maintainer, or an agent working as the maintainer, writes the
|
||||
entry after merging, when there is a commit to link.
|
||||
`tests/changelogFormat.test.cjs` checks both files.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.94 2026-11-04 WeKan ® release
|
||||
|
||||
**In short:** On an iPad the **opened card covers the header bar** instead of
|
||||
sitting
|
||||
behind it, and the speed test measures where the time goes when a WeKan board is
|
||||
opened, on FerretDB and on MongoDB.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**Opened cards** - where a card opens.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/efaf94242e">On a phone in desktop mode the opened card starts below the header bar</a>. Thanks to xet7.</summary>
|
||||
|
||||
The header wraps to several rows on a phone, far taller than the 8px the
|
||||
floating card docks at, so the card's top row was among the header's rows and
|
||||
was only seen by dragging it down. Below 801px a card without an inline top,
|
||||
popup cards included, now starts at `--wekan-header-height` + 8px. A dragged
|
||||
place is an inline top, so it is kept when the finger is lifted.
|
||||
`tests/cardBelowHeaderOnPhone.test.cjs` pins the rule and that it never
|
||||
matches a dragged card.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/006d24502c">The opened card covers the header bar on an iPad instead of sitting behind it</a>. Thanks to xet7.</summary>
|
||||
|
||||
On iPad Chrome (and Safari) the opened card's top row - the collapse caret, the
|
||||
card
|
||||
number, the menu, maximize and X - was behind the header bar, although the
|
||||
card's
|
||||
`z-index` is 2001 and the header's 1000. iOS WebKit makes every scrolling
|
||||
element
|
||||
(`#content`, the board wrapper) a stacking context of its own, so the card's
|
||||
`z-index`
|
||||
is compared only inside it, and the header bar, itself a stacking context, is
|
||||
painted
|
||||
over all of it. While a card is open the header bar is now at the level of the
|
||||
content,
|
||||
which comes after it in the page and paints over it; with no card open it keeps
|
||||
its
|
||||
own level. A browser spec makes the same trap with `isolation: isolate` on
|
||||
`#content`
|
||||
and the board wrapper and shows the card over the header, open and collapsed,
|
||||
with its
|
||||
close button reachable, in Chromium and WebKit; without the rule it fails as the
|
||||
screenshot shows. `tests/cardOverHeaderStacking.test.cjs` pins the rule and its
|
||||
selector. An iPad is the final check.
|
||||
|
||||
</details>
|
||||
|
||||
and has the following documentation change:
|
||||
|
||||
**Database speed test** - how fast the databases answer WeKan's queries.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/3621413d6f">The speed test measures where the time goes when a WeKan board is opened, on FerretDB and on MongoDB</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
`docs/Databases/FerretDB/1/SpeedTest/app` opens a board of 200, 2,000 and 5,000
|
||||
cards in
|
||||
Chromium on WeKan with FerretDB (SQLite) and with MongoDB 8, and profiles the
|
||||
Meteor
|
||||
server (Node inspector) and FerretDB (pprof). Two runs each. The Meteor server
|
||||
uses
|
||||
0.1-0.5 s of CPU, the same on both, and its DDP server is 1% of that; the
|
||||
browser gets
|
||||
the same ~210 KB at every size. A 5,000-card board takes FerretDB 400-600 ms
|
||||
longer to
|
||||
open (first card +42-48%), a small board only 70-100 ms, and FerretDB spends
|
||||
about 4 s
|
||||
of CPU on it, 39% in the SQLite engine and 34% filtering in Go what it read. So
|
||||
a
|
||||
backend rewritten in another language would save part of a fraction of a second,
|
||||
while
|
||||
the part that scales with the board is the database layer. The README says what
|
||||
was not
|
||||
measured: a production build, the browser's own time, and which query sends the
|
||||
`aggregate`. `tests/boardOpenProfile.test.cjs` pins the analysis of the profile
|
||||
and what
|
||||
the README claims.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.93 2026-11-04 WeKan ® release
|
||||
|
||||
**In short:** The database **speed test** has a new small-scale result, ordered
|
||||
from fastest to slowest with how many times slower each database is, and two
|
||||
more databases: **pgrust** (PostgreSQL in Rust) and **TiDB**. Noria, YugabyteDB
|
||||
and CockroachDB were looked at and cannot be used, for reasons the README gives.
|
||||
|
||||
This release adds the following new features:
|
||||
|
||||
**Database speed test** - how fast the databases answer WeKan's queries.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/46c0ad43e8">Results.md is a quick small-scale run, fastest first, with how many times slower each is</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
The speed test was only ever run at twice a production WeKan, which takes hours.
|
||||
`WEKAN_SPEEDTEST_SCALE=0.05` is 1/20 of one and the whole run takes under half
|
||||
an
|
||||
hour. Results.md opens with an **At a glance** table: rank, the total of all
|
||||
queries, and **how many times slower** than the fastest database, for the
|
||||
average
|
||||
user and for the slowest one (the biggest board or the busiest user). Every
|
||||
table
|
||||
is ordered by its own numbers, so the slowest-user table can rank differently,
|
||||
the
|
||||
loading table is ordered by the whole load time, and the header says the real
|
||||
data
|
||||
size and the real operating system (it said "twice a production WeKan" and
|
||||
"Linux" whatever was measured). `tests/dbSpeedTest.test.cjs` pins the ranks, the
|
||||
ratios, the per-table order and the header, with a negative test for each.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/46c0ad43e8">The speed test includes pgrust, PostgreSQL rewritten in Rust</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
[pgrust](https://github.com/malisper/pgrust) v0.4-beta is wire compatible with
|
||||
PostgreSQL 18, so FerretDB's PostgreSQL handler uses it unchanged, and it gives
|
||||
the
|
||||
same answers as MongoDB. It is **not faster** than PostgreSQL for WeKan's
|
||||
queries
|
||||
(174 ms against 145 ms in total), and creating WeKan's 47 indexes takes 38 s
|
||||
against 1 s. In the conformance run it is opt-in (`WEKAN_CONFORMANCE_PGRUST=1`).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/f576a0cca9">The speed test includes TiDB, and the README says which other databases cannot run FerretDB and why</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
TiDB 7.5 (Apache-2.0, MySQL protocol, one process on its own storage) runs
|
||||
FerretDB's MySQL handler and gives the same answers as MongoDB, after one change
|
||||
in FerretDB: its metadata table's generated columns are `VIRTUAL`, because TiDB
|
||||
refuses `ALTER TABLE ... ADD COLUMN ... STORED`. Opt-in in the conformance run
|
||||
(`WEKAN_CONFORMANCE_TIDB=1`). Noria has no JSON type, triggers or generated
|
||||
columns, which the MySQL handler needs; YugabyteDB refuses a `CREATE INDEX` on a
|
||||
`jsonb` expression, which every FerretDB v1 PostgreSQL index is; CockroachDB is
|
||||
under a proprietary licence. The README's **Other databases tried** has the
|
||||
reasons.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/1c44876beb">The speed test includes Dolt, and VersionHistory tests what version history would give undo and redo</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
[Dolt](https://github.com/dolthub/dolt) (git for data, MySQL protocol) runs under
|
||||
FerretDB's existing MySQL handler and gives the same answers, but is by far the
|
||||
slowest: 20,470 ms in total, 1,230x MongoDB, because every query over cards is a
|
||||
2.8 s whole-table scan. `docs/Databases/FerretDB/1/VersionHistory` drives
|
||||
WeKan-shaped changes through FerretDB onto Dolt and through the REST API of
|
||||
TerminusDB. Dolt makes a commit per FerretDB write for +0.4 ms, undoes and
|
||||
redoes
|
||||
with `DOLT_REVERT` and reads any earlier state with `AS OF`, but names the SQL
|
||||
user and not the WeKan user, and cannot restore one field of a document that
|
||||
changed again (a conflict). TerminusDB records the author and message of every
|
||||
commit and restores one field while later changes stay, but speaks neither the
|
||||
MongoDB protocol nor SQL. WeKan's own History remains the undo; Dolt would be a
|
||||
backup and audit layer under it. ClickHouse and StarRocks were tried with the
|
||||
MySQL handler and need a handler of their own, which has not been written.
|
||||
`tests/versionHistoryExperiment.test.cjs` and `tests/dbSpeedTest.test.cjs` pin
|
||||
the
|
||||
scripts and the findings.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/96d56ecc6c">Go databases that could be linked into FerretDB were compared with its SQLite: none is faster where it counts</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
`docs/Databases/FerretDB/1/SpeedTest/embedded` is a Go module of its own (pure
|
||||
Go,
|
||||
no cgo) that loads the speed test's cards into SQLite set up as FerretDB sets it
|
||||
and into Pebble, Badger, bbolt, BuntDB, Nutsdb and Clover, and asks each the
|
||||
questions WeKan asks of cards, at 15,377 and 153,768 cards. Every engine must
|
||||
give
|
||||
the right counts first. A board's cards take the same time on every engine that
|
||||
is
|
||||
not slower, because decoding the JSON dominates; the key-value stores win single
|
||||
reads and writes by microseconds that a FerretDB call hides, and would make the
|
||||
handler do in Go what SQL does in SQLite. Pebble is 12x slower on fetching a
|
||||
board, Clover 90-190x slower in total, and bbolt with its default fsync 100-160x
|
||||
slower on writes. The README accounts for every entry of the awesome-go
|
||||
databases section. `tests/embeddedEnginesBench.test.cjs` pins the export, the
|
||||
engines, the SQLite setup and the conclusion.
|
||||
|
||||
</details>
|
||||
|
||||
**The header bar** - what sits beside the logo.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/f25957dcf3">A full screen button to the right of the logo in the header bar</a>. Thanks to xet7.</summary>
|
||||
|
||||
A button puts the whole page in full screen and takes it out again with the
|
||||
Fullscreen API, in iPad Safari, desktop browsers and Android browsers. Its label
|
||||
is the existing Maximize, so no language file changes. It is drawn only where
|
||||
the
|
||||
browser offers the API and the page may use it: iPhone Safari does not have it
|
||||
for pages (use Add to Home Screen, the manifest says `display: standalone`), and
|
||||
neither does a page in an iframe without `allowfullscreen`. The icon follows the
|
||||
browser's own events, so leaving with Esc is understood.
|
||||
`tests/fullscreenButton.test.cjs`
|
||||
drives the pure part with fake documents, including a refusal, and
|
||||
`tests/playwright/specs/header-fullscreen.e2e.js` checks the position, the
|
||||
label,
|
||||
entering, leaving, Esc and the negative case in Chromium and WebKit.
|
||||
|
||||
</details>
|
||||
|
||||
and updates the following dependencies:
|
||||
|
||||
- **minimatch 10.2.6 → 11.0.4** in the `npm-packages/exceljs` and vendored
|
||||
`jade` lockfiles ([GHSA-g3hv-75wg-m249](https://github.com/advisories/GHSA-g3hv-75wg-m249),
|
||||
Dependabot alerts \#180 and \#182), through overrides on `readdir-glob`,
|
||||
`glob` 13 and
|
||||
`test-exclude`, as the root lockfile has; a test reads every
|
||||
`package-lock.json`.
|
||||
- **klauspost/compress 1.18.0 → 1.18.7**, **golang.org/x/net 0.23.0 → 0.62.0**,
|
||||
**golang.org/x/text 0.14.0 → 0.43.0**, **golang/glog 1.0.0 → 1.2.4** and
|
||||
**OpenTelemetry 1.41.0 → 1.42.0** in the Go module of the embedded-engine
|
||||
benchmark (Dependabot alerts \#183-\#187; `govulncheck` reports none). A test
|
||||
holds
|
||||
every `go.mod` at or above these floors.
|
||||
|
||||
Thanks to dependabot.
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
**Opened cards** - where a card opens.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/84b0480aa8">A card opened as a popup covers the header bars instead of opening below them</a>. Thanks to xet7.</summary>
|
||||
|
||||
On an iPad, a card opened from the Roadmap view (and from search results and the
|
||||
table view) opened below the top header, and collapsed it was a bar floating
|
||||
over
|
||||
the swimlane. A card opened as a popup got the generic wide-layout rule
|
||||
`top: 97px` and `z-index: 30`, because the desktop-mode rules that dock an
|
||||
opened
|
||||
card at the top skip a popup, while the popup wrapper rule in the same file says
|
||||
it must cover the header bars. It now docks at `top: 8px` with `z-index: 2001`
|
||||
in
|
||||
the wide layout; mobile mode keeps its full-window rule. This was reproduced in
|
||||
Chromium and in WebKit with an iPad profile; whether it is the whole of what an
|
||||
iPad shows is for the iPad to say.
|
||||
`tests/cardPopupOverHeader.test.cjs` pins the rule and
|
||||
`tests/playwright/specs/card-popup-over-header.e2e.js` shows the card over the
|
||||
header, open and collapsed, and fails without the fix (top 97 against a header
|
||||
88
|
||||
high).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/1b05673200">The opened card and every popup move under a finger, on an iPad</a>. Thanks to xet7.</summary>
|
||||
|
||||
On an iPad the opened card did not move when dragged from the collapse caret,
|
||||
the
|
||||
space beside it, the card number, or the drag handle, while the page behind it
|
||||
scrolled. The card window was moved by mouse events only, which a finger does
|
||||
not
|
||||
send while it moves, and its drag areas did not say `touch-action: none`, so the
|
||||
browser took the gesture for a scroll. It is moved by Pointer Events now: the
|
||||
header row or the handle starts the drag; a link or button in the row (the
|
||||
caret,
|
||||
close, the menu) still does its job when tapped and moves the window when
|
||||
dragged,
|
||||
without also collapsing or closing; the title still edits when tapped; and with
|
||||
drag handles on only the handle moves it. The card number is above the title's
|
||||
edit
|
||||
zone, so it drags too. The window is kept within reach, and `touch-action: none`
|
||||
is
|
||||
on the card header, its handle and every popup's title bar and handle, so popups
|
||||
move by finger as well.
|
||||
`tests/windowDragTouch.test.cjs` pins the decisions and the wiring, and
|
||||
`tests/playwright/specs/card-window-touch-drag.e2e.js` drags with a real touch
|
||||
from
|
||||
each place with handles on and off, taps the caret, drags with a mouse and moves
|
||||
a
|
||||
popup; the finger tests fail without the fix. Tested in Chromium; an iPad is the
|
||||
final check.
|
||||
|
||||
</details>
|
||||
|
||||
and has the following documentation change:
|
||||
|
||||
**Database speed test** - what was improved, and what was tried and was not.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/d05a42a5af">The speed test README lists the improvements since the last run, and what did not help</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
SQLite sorts in the database and stores JSONB, PostgreSQL answers membership
|
||||
from a GIN index and pushes down `$or`, `$elemMatch` and `$and`, MySQL and
|
||||
MariaDB read through their indexes with multikey side tables, and WeKan no
|
||||
longer scans whole collections every hour or at every start of every server. The
|
||||
small run found one more: **PostgreSQL planned from no statistics** until
|
||||
autovacuum analyzed a freshly indexed table, so a Due Cards query took 407 ms
|
||||
and
|
||||
84 ms after; FerretDB now analyzes the table when its indexes are created (the
|
||||
PostgreSQL row went from 454 to 145 ms, and from last place to eighth). A
|
||||
profile of Due Cards on SQLite found another: a membership test made SQLite turn
|
||||
the whole `members` array into JSON text and parse it again for every row, and
|
||||
reading it from the column instead cut the visible-boards query from 26 to 16 ms
|
||||
(All Boards 6.9 → 4.9 and 5.9 → 3.9 ms, Due Cards 54.5 → 43.9 ms, the SQLite
|
||||
total 82.7 → 68.3 ms) with identical answers. Tried and
|
||||
not kept, with the numbers: sorting in the database on PostgreSQL (52 ms against
|
||||
38-49 ms decoded in Go) and `ANALYZE TABLE` on MySQL and MariaDB (no change).
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.92 2026-11-03 WeKan ® release
|
||||
|
||||
**In short:** A missing **REST checklist** answers a JSON 404 instead of an
|
||||
empty 500.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**REST API** - answers for things that are not there.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/d231df56fc">A missing checklist, or one under another card, answers a JSON 404 instead of an empty 500</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
[\#6867](https://github.com/wekan/wekan/pull/6867). `GET
|
||||
/api/boards/:boardId/cards/:cardId/checklists/:checklistId` answered `500` with
|
||||
no body when the checklist did not exist or belonged to another card. It now
|
||||
answers `404` with `Checklist not found or does not belong to the specified
|
||||
card`, the text the update and delete routes already use; the board-access check
|
||||
and the parent-card check are unchanged. `tests/restChecklistGet.test.cjs`
|
||||
drives
|
||||
the registered handler (found, missing, another card, another board, access
|
||||
denied, and a failing lookup that must not become a false 404) and
|
||||
`tests/playwright/specs/rest-checklist-not-found.e2e.js` reads the checklist
|
||||
over REST and after a page reload.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.91 2026-11-03 WeKan ® release
|
||||
|
||||
**In short:** A **GitHub CodeQL** finding in a test is fixed, and the guard
|
||||
against
|
||||
replacing text with itself now also sees patterns with a `^` or `$` anchor.
|
||||
|
||||
This release has the following developer-tooling fix:
|
||||
|
||||
**Code scanning** - the tests that guard the tree.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/62da564a1e">A test no longer replaces getLabel with itself, and the identity-replacement guard knows anchored patterns</a>. Thanks to GitHub CodeQL and xet7.</summary>
|
||||
|
||||
Code scanning alert #600 (`js/identity-replacement`, CWE-116):
|
||||
`tests/boardLabelLookup.test.cjs` cut the `getLabel` method out of
|
||||
`models/boards.js` and then ran `.replace(/^getLabel/, 'getLabel')` on it, which
|
||||
does nothing because the slice already starts at `getLabel`. The line is gone.
|
||||
`tests/noIdentityReplacement.test.cjs` skipped every regular expression with an
|
||||
anchor, so it could not see the shape; a leading `^` or a trailing `$` over
|
||||
plain characters is now compared like any other, with samples of both for the
|
||||
guard to detect and the existing samples that must not be reported.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.90 2026-11-03 WeKan ® release
|
||||
|
||||
**In short:** The login page can have its own **background color**, Member
|
||||
Settings
|
||||
has a per-member switch, **Show focus outline**, to hide the blue focus ring,
|
||||
and
|
||||
**Escape closes the add-list composer**.
|
||||
|
||||
This release adds the following new features:
|
||||
|
||||
**Member Settings** - what each member can set for themselves.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/144777ae6b">Change Settings has Show focus outline, to hide the blue focus ring for yourself</a>. Thanks to mimZD and xet7.</summary>
|
||||
|
||||
[\#6755](https://github.com/wekan/wekan/issues/6755). The blue ring WeKan draws
|
||||
round the focused control is an accessibility feature (WCAG 2.4.7) and stays on
|
||||
by
|
||||
default, but it also shows after a click on a minicard, and a member asked
|
||||
whether
|
||||
it can be turned off. Change Settings now has **Show focus outline**, ticked
|
||||
while
|
||||
the ring shows. Unticking it stores `profile.hideFocusOutline` for the signed-in
|
||||
member (a method that takes no user id from the client) and puts a class on the
|
||||
page that hides the outline and its halo for that member only; field borders and
|
||||
the colour a button takes on focus stay. The label is translated into every
|
||||
language file; the translations of these were written without a native speaker
|
||||
and
|
||||
are the least certain: Akan, Aymara, Bambara, Tibetan, Breton, Cherokee,
|
||||
Dzongkha,
|
||||
Ewe, Fulah, Fijian, Guarani, Manx, Hausa, Hawaiian, Igbo, Inuktitut,
|
||||
Kalaallisut,
|
||||
Kashmiri, Cornish, Luganda, Ladin, Nahuatl, Neapolitan, Northern Ndebele,
|
||||
Northern
|
||||
Sotho, Chichewa, Oromo, Quechua, Kirundi, Aromanian, Kinyarwanda, Northern Sami,
|
||||
Samoan, Shona, Swati, Southern Sotho, Tigrinya, Tigre, Klingon, Tswana, Tongan,
|
||||
Tsonga, Venda, Volapük, Walamo, Wolof, Xhosa and Tamazight.
|
||||
`tests/hideFocusOutline6755.test.cjs` and
|
||||
`tests/playwright/specs/hide-focus-outline-6755.e2e.js` pin it, including that
|
||||
one
|
||||
member hiding the ring does not hide it for another.
|
||||
|
||||
</details>
|
||||
|
||||
**The login page** - how the sign-in page and the support page look.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/6b27764452">Admin Panel / Lay-out has a Login page background color, and the login text and the support page have a readable layout</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
[\#6866](https://github.com/wekan/wekan/issues/6866). The login page stayed
|
||||
grey whatever the Admin Panel said: the custom HTML fields only render after
|
||||
sign-in and the text below the login logo is sanitised. The new setting
|
||||
`loginBackgroundColor` takes a hex colour only (`#rgb` or `#rrggbb`); anything
|
||||
else means "not set", so the value in the CSS custom property can never carry
|
||||
CSS of its own. The text under the logo is white on a dark colour and black on a
|
||||
light one. It is put on the page only while the sign-in layout is shown and
|
||||
removed when it closes. The support page was text flush against the window edge
|
||||
with no headings: it is now a centred white column of at most 860px, and the
|
||||
text under the login logo gets the same column without a card of its own, both
|
||||
with sized bold headings and space between paragraphs. The label is translated
|
||||
into every language file. `tests/loginBackground6866.test.cjs` pins the colour
|
||||
decision, that nothing but the validated value reaches the page, and the layout.
|
||||
|
||||
</details>
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
**Lists** - the add-list composer.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/f4b5025a7b">Escape closes the inline add-list composer</a>. Thanks to mimZD and xet7.</summary>
|
||||
|
||||
[\#6865](https://github.com/wekan/wekan/issues/6865). The composer that opens after
|
||||
a list (or in an empty swimlane) stays open once a list is created, so several
|
||||
can
|
||||
be added, and only its X closed it: Escape did nothing. Escape now closes it,
|
||||
with
|
||||
the same reset as the X, at the level of an inlined form, and a click elsewhere
|
||||
still does not. `tests/addListEscape6865.test.cjs` and
|
||||
`tests/playwright/specs/add-list-escape-6865.e2e.js` pin it before and after a
|
||||
list
|
||||
is created.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.89 2026-11-03 WeKan ® release
|
||||
|
||||
**In short:** Keyboard **shortcuts run once on a non-Latin keyboard**: `d` no
|
||||
longer opens the Due date popup and closes it again on a Farsi layout.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**Keyboard shortcuts** - what a key does on each layout.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/0110e84ce5">A shortcut runs once on a Farsi, Cyrillic or other non-Latin keyboard</a>. Thanks to mimZD and xet7.</summary>
|
||||
|
||||
[\#6840](https://github.com/wekan/wekan/issues/6840). `d` opened the Due date
|
||||
popup and closed it at a glance, "sometimes", for a reporter whose keyboard was
|
||||
Farsi. `client/lib/keyboard.js` re-dispatched every key typed on another script
|
||||
as its Latin key, a leftover from Mousetrap, which read the typed character.
|
||||
hotkeys-js, which replaced it, resolves a key from `event.code` and `keyCode`
|
||||
and so already ran the shortcut for the physical d key on any layout; the copy
|
||||
ran
|
||||
every shortcut a second time, the due date editor opened and the repeat clicked
|
||||
the
|
||||
same control again, closing it. The same doubling was behind Shift+D on a Latin
|
||||
keyboard (#6750). The re-dispatch is gone. `tests/keyboardNonLatinOnce.test.cjs`
|
||||
pins that no handler turns a typed character back into a key, and reads
|
||||
hotkeys-js's own rule; `tests/playwright/specs/keyboard-nonlatin-once.e2e.js`
|
||||
sends
|
||||
real key events for Latin, Farsi and Cyrillic and counts one run each.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.88 2026-11-03 WeKan ® release
|
||||
|
||||
**In short:** **Every board theme** and **every color a member chooses** is
|
||||
readable: the dark themes' **Card Settings**, popups and sidebars, the header of
|
||||
eight themes, and a custom accent, text or label color made legible. A padded
|
||||
**REST label** name no longer creates a second label.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**Dark themes** - cleandark, moderndark, dark and exodark.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/a6b10e3592">Card Settings, popups, sidebars and the quiet text are readable in the dark themes</a>. Thanks to Samunosuke and xet7.</summary>
|
||||
|
||||
[\#6863](https://github.com/wekan/wekan/issues/6863). Card Settings drew white
|
||||
text on light grey blocks (1.07:1 in cleandark); popup items, the board
|
||||
sidebar's Keyboard shortcuts row, "Add custom field" and its sister buttons and
|
||||
the sidebar's labels were black on dark (1.18 to 2.19:1); and a list header's
|
||||
`!important` light grey beat each theme's own and left its light text on it
|
||||
(1.09:1 in moderndark, 1.27:1 in exodark). Part of it was new: the accessibility
|
||||
audit made the quiet text greys dark for every theme, so the dark themes showed
|
||||
dark grey on dark; the rest was older, rules that never followed the dark
|
||||
themes. The quiet text is now a variable, `#595959` on the light themes and a
|
||||
light grey where the surface is dark (cleandark, moderndark, and dark and
|
||||
exodark on the board, the card and the sidebar); Card Settings' sections, its
|
||||
muted text, the popup items, the quiet buttons, the shortcuts row, the sidebar
|
||||
labels, dark's swimlane header and moderndark's selected minicard and primary
|
||||
buttons follow the theme; and the list header's `!important` is gone.
|
||||
`tests/playwright/specs/46-axe-theme-contrast.e2e.js` runs axe-core's colour
|
||||
contrast rule on the four themes (the board, the sidebar, Card Settings, an
|
||||
opened card and the custom fields popup) and finds nothing;
|
||||
`tests/darkThemeContrast6863.test.cjs` pins each rule. The header colours of
|
||||
relax, nephritis and modern, and a few lines of clearblue and cleanlight, are
|
||||
still below 4.5 and are listed in the accessibility audit.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/8b2114cd0f">Every board theme, and every color a member chooses, is readable</a>. Thanks to Samunosuke and xet7.</summary>
|
||||
|
||||
[\#6863](https://github.com/wekan/wekan/issues/6863) asked to check the other
|
||||
themes too, and the same scan was run on all 25. White text on the accent was
|
||||
2.1 to 4.3:1 in nephritis, pumpkin, moderatepink, strongcyan, limegreen, relax,
|
||||
corteza and modern, and their dark, hover and emphasis shades were as low: the
|
||||
accents are darkened, with the same hue, until white text and the header's
|
||||
`#f2f2f2` labels read at 4.5:1, and the hover and selected shades follow
|
||||
(limegreen
|
||||
is a forest green now, strongcyan a teal: white at 4.5:1 cannot be had on a
|
||||
bright
|
||||
accent). The colour-slide themes that end in the same colours read too. The
|
||||
filter sidebar's headings (`#808080`, in every theme), the clear themes' quiet
|
||||
text and cleanlight's add-dependency link are darker. No colour contrast failure
|
||||
is left on the board, the sidebars, Card Settings, an opened card and its popups
|
||||
in any theme. axe-core cannot measure text over a gradient, so the six
|
||||
colour-slide headers are not covered by it.
|
||||
|
||||
A color a member chooses is one half of a pair, and the other half is worked out
|
||||
(`models/lib/contrastColor.js`). A custom theme accent, or the two ends of a
|
||||
custom
|
||||
slide, is darkened under the header's white text until it reads, and the
|
||||
picker's
|
||||
preview shows the color that will be drawn. The text color from Member Settings
|
||||
is
|
||||
moved, with its hue, until it reads at 4.5:1 on the surfaces of the theme in
|
||||
use,
|
||||
says so when it changed, and no longer reaches the header bars, labels and
|
||||
primary
|
||||
buttons, which sit on a color of their own (`#626262` was 1.18:1 on the blue
|
||||
header). A dependency line is drawn at 3:1 against the surface. Kanri's tag text
|
||||
is the better of dark and light, which the brightness test it replaces was not.
|
||||
A
|
||||
custom label, card, list or swimlane color already derived its text, black or
|
||||
white, and that is now pinned at 4.5:1 for every color.
|
||||
`tests/playwright/specs/46-axe-theme-contrast.e2e.js` scans every theme and four
|
||||
colors that do not read; `tests/customColorContrast.test.cjs` pins the rules.
|
||||
|
||||
</details>
|
||||
|
||||
**REST API** - board labels.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/0426c7741a">A padded label name finds the stored label instead of creating another</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
[\#6864](https://github.com/wekan/wekan/pull/6864). A label is looked up by name
|
||||
and color before one is added, and the lookup compared the name as it was sent
|
||||
while the schema stores it trimmed, so a padded " Bug " missed the stored "Bug"
|
||||
and every repeat of the request created another label with the same name and
|
||||
color. The pull request trimmed the name in the REST builder; the lookup is
|
||||
shared
|
||||
by the card's label form, the importers and the list sync, so it trims too, and
|
||||
a
|
||||
legacy label stored with padding is still found. A different color, letter case
|
||||
or inner space stays a different label. `tests/boardLabelLookup.test.cjs` pins
|
||||
it.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.87 2026-11-03 WeKan ® release
|
||||
|
||||
**In short:** A **REST API** "get one" that finds nothing answers **404**
|
||||
instead of 200 with an empty body.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**REST API** - what a request for one item answers when there is none.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/1ded34dfdd">A single-item GET that finds nothing answers 404, not an empty 200</a>. Thanks to AhmedLukman and xet7.</summary>
|
||||
|
||||
[\#6862](https://github.com/wekan/wekan/pull/6862). The scoped card GET
|
||||
(`/api/boards/:boardId/lists/:listId/cards/:cardId`) sent HTTP 200 with no body
|
||||
for a missing, wrong-list, wrong-board or archived card, which a client cannot
|
||||
tell from a card with nothing in it. The pull request fixed that one route; the
|
||||
same shape was in the list, swimlane, custom field, comment and integration
|
||||
GETs, and the user GET threw a TypeError for an unknown name, which reached the
|
||||
caller as a server error. Each now answers 404 ("Card not found", "List not
|
||||
found" and so on). The board check stays first in each, so someone with no
|
||||
access gets the same answer for an existing and a missing id. The global card
|
||||
GET, which finds a card by id alone, already answered 404.
|
||||
`tests/restSingleItem404.test.cjs` runs every one of these routes from its own
|
||||
source with a cache that finds nothing, something, and a stranger, and fails if
|
||||
any single-item GET route under server/ has no not-found branch.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.86 2026-11-02 WeKan ® release
|
||||
|
||||
**In short:** The daily **notification cleanup** and the **file integrity
|
||||
audit**
|
||||
run once for the whole installation, not once per server.
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
**Database load** - work that every server of a cluster repeated.
|
||||
|
||||
<details>
|
||||
<summary><a href="https://github.com/wekan/wekan/commit/4e28465964">The notification cleanup and the file integrity audit run once, not on every server</a>. Thanks to Raymond-Voogt and xet7.</summary>
|
||||
|
||||
[\#6861](https://github.com/wekan/wekan/issues/6861). The notification cleanup
|
||||
ran at every start and daily on every server, reading and rewriting the same
|
||||
18,000 users 24 times at a rollout, and a notification another server added
|
||||
between a read and its write was lost. A run now claims a document per job and
|
||||
day in the dailyJobRuns collection (a duplicate key means another server has
|
||||
it), and the timer looks hourly for an unclaimed day, so the cleanup still
|
||||
happens daily when no server restarts. The file integrity audit read its last
|
||||
run, checked it, and then wrote it, so two servers whose hourly check fell at
|
||||
the same moment both scanned every file; one atomic update, matching only while
|
||||
the last run is older than the interval, now claims the run. Continuous backup
|
||||
has no leader and every server writes its own stream into the same directory;
|
||||
that needs a lease, which is not built, so the documentation now says it is for
|
||||
one server, and item 3 of the issue stays open. `tests/dailyClaim.test.cjs`
|
||||
pins the claims with 24 servers at once, that other database errors are not
|
||||
swallowed, and that neither job runs unclaimed.
|
||||
|
||||
</details>
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v12.85 2026-11-02 WeKan ® release
|
||||
|
||||
**In short:** The findings of the **accessibility audit** are fixed in the
|
||||
|
||||
+30
-26
@@ -5,43 +5,47 @@ each one changed and the project status are in
|
||||
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
|
||||
make changes there.
|
||||
|
||||
# v12.85 2026-11-02 WeKan ® release
|
||||
# v12.96 2026-11-05 WeKan ® release
|
||||
|
||||
The findings of the **accessibility audit** are fixed in the
|
||||
default theme: header and label **colour contrast**, names that contain the
|
||||
visible text, named form fields, card section headings that are real buttons,
|
||||
and landmarks and headings on every page. axe-core now finds no violation on
|
||||
22 pages. A cleared database connection pool is reported as a lost connection.
|
||||
The release **bundle starts again**: v12.95's build stopped on
|
||||
boot because the **nodemailer** 10 raised inside Meteor's email package no
|
||||
longer had the files that package was built against.
|
||||
|
||||
[All changes in v12.85](CHANGELOG-big.md#v1285-2026-11-02-wekan--release)
|
||||
[All changes in v12.96](CHANGELOG-big.md#v1296-2026-11-05-wekan--release)
|
||||
|
||||
# v12.84 2026-11-02 WeKan ® release
|
||||
# v12.95 2026-11-05 WeKan ® release
|
||||
|
||||
GitHub code scanning's **code-quality** findings are fixed: a
|
||||
promise formatted as text, undeclared globals, an upload that hung on an
|
||||
unreadable file, and the lint around them. `npm run lint:promises`
|
||||
now finds promises that are not awaited or handled.
|
||||
**Continuous backup** now runs on one server at a time, with a
|
||||
lease, when WeKan has several servers. Missing-board **exports** and REST
|
||||
updates of missing **integrations**, **users**, **organizations** and **teams**
|
||||
answer 404 instead of 200, the **minicard** shows a picked Requested by and
|
||||
Assigned by, a card stops offering the attachment "+" when uploads are blocked,
|
||||
and the Docker image's **nodemailer**, **body-parser**, **qs**, **uuid** and
|
||||
**postcss-selector-parser** are raised past the advisories a Grype scan found.
|
||||
|
||||
[All changes in v12.84](CHANGELOG-big.md#v1284-2026-11-02-wekan--release)
|
||||
[All changes in v12.95](CHANGELOG-big.md#v1295-2026-11-05-wekan--release)
|
||||
|
||||
# v12.83 2026-11-02 WeKan ® release
|
||||
# v12.94 2026-11-04 WeKan ® release
|
||||
|
||||
An **LDAP** login with a reused login name no longer opens the
|
||||
previous owner's account, and existing accounts record their directory
|
||||
identifier.
|
||||
On an iPad the **opened card covers the header bar** instead of
|
||||
sitting
|
||||
behind it, and the speed test measures where the time goes when a WeKan board is
|
||||
opened, on FerretDB and on MongoDB.
|
||||
|
||||
[All changes in v12.83](CHANGELOG-big.md#v1283-2026-11-02-wekan--release)
|
||||
[All changes in v12.94](CHANGELOG-big.md#v1294-2026-11-04-wekan--release)
|
||||
|
||||
# v12.82 2026-11-02 WeKan ® release
|
||||
# v12.93 2026-11-04 WeKan ® release
|
||||
|
||||
A card moved to another board, restored from the archive or given
|
||||
a number no longer takes a **card number** another card on that board has.
|
||||
The database **speed test** has a new small-scale result, ordered
|
||||
from fastest to slowest with how many times slower each database is, and two
|
||||
more databases: **pgrust** (PostgreSQL in Rust) and **TiDB**. Noria, YugabyteDB
|
||||
and CockroachDB were looked at and cannot be used, for reasons the README gives.
|
||||
|
||||
[All changes in v12.82](CHANGELOG-big.md#v1282-2026-11-02-wekan--release)
|
||||
[All changes in v12.93](CHANGELOG-big.md#v1293-2026-11-04-wekan--release)
|
||||
|
||||
# v12.81 2026-11-02 WeKan ® release
|
||||
# v12.92 2026-11-03 WeKan ® release
|
||||
|
||||
The **database health** probe reports a finding once, not once
|
||||
per server.
|
||||
A missing **REST checklist** answers a JSON 404 instead of an
|
||||
empty 500.
|
||||
|
||||
[All changes in v12.81](CHANGELOG-big.md#v1281-2026-11-02-wekan--release)
|
||||
[All changes in v12.92](CHANGELOG-big.md#v1292-2026-11-03-wekan--release)
|
||||
@@ -26,7 +26,7 @@ Work out which you are from the git identity (the next section says how), then:
|
||||
| Who | Where the work goes |
|
||||
| --- | --- |
|
||||
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then one pull request per feature or fix** for the maintainer to review. Never a commit on `main`, and no changelog changes. |
|
||||
|
||||
Nothing else is a third option. If a tool, a task runner or an agent harness offers
|
||||
some other arrangement — a topic branch for the maintainer, a direct push for a
|
||||
@@ -41,7 +41,9 @@ released section links a commit that release does not contain.
|
||||
|
||||
The contributor's half is review. Nobody but the maintainer commits to `main` in
|
||||
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
|
||||
the only place it can be discussed before it lands.
|
||||
the only place it can be discussed before it lands. That is why each pull request
|
||||
holds one feature or one fix, and leaves the changelogs to the maintainer: see
|
||||
"Contributor mode" below.
|
||||
|
||||
Check which branch you are on before committing:
|
||||
|
||||
@@ -143,12 +145,29 @@ git config user.name && git config user.email
|
||||
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
|
||||
steps** below are available to a human maintainer only: an AI stops after
|
||||
committing locally and never runs a step that pushes or publishes.
|
||||
|
||||
**Maintainer mode is the only mode that commits directly and the only mode that
|
||||
updates changelogs**, in WeKan and in the maintainer's repositories under
|
||||
`.tools/` alike. It applies to the changes the WeKan maintainer makes, and to the
|
||||
changes of an agent working as the maintainer according to this file.
|
||||
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
|
||||
own — a person, or an AI that files pull requests on its own account. Then: do
|
||||
**not** commit to `main` and do **not** run any release/publishing step. Make the
|
||||
changes on a branch and open a **pull request** for the maintainer to review. The
|
||||
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
|
||||
instructions below are **maintainer-only and do not apply to you**.
|
||||
**not** commit directly to the repository or to `main`, and do **not** run any
|
||||
release/publishing step. Make the changes on a branch and open a **pull request**
|
||||
for the maintainer to review. The "commit as Lauri Ojansivu", "commit directly to
|
||||
`main`", changelog and release instructions below are **maintainer-only and do
|
||||
not apply to you**.
|
||||
|
||||
Every pull request, to wekan/wekan or to a maintainer's repository under `.tools/`,
|
||||
follows two more rules:
|
||||
- **One feature or one fix per pull request.** Changes share a pull request only
|
||||
when they are related to that one feature or fix. Anything unrelated, however
|
||||
small, goes in a pull request of its own, so each can be reviewed, merged or
|
||||
declined without holding up the others.
|
||||
- **No changelog changes.** Do not edit `CHANGELOG-big.md`, `CHANGELOG.md`, or the
|
||||
`CHANGELOG.md` or `CHANGELOG` of a repository under `.tools/` — not an Upcoming
|
||||
entry, not TODO Later. Describe the change in the pull request instead. The
|
||||
maintainer writes the entry after merging, when there is a commit for it to link.
|
||||
|
||||
Who that pull request credits follows the attribution rule above, and the two
|
||||
cases differ:
|
||||
@@ -171,8 +190,8 @@ The one repository under `.tools/` that is somebody else's is `sandstorm-io/sand
|
||||
cloned for reference only.
|
||||
|
||||
Everything below marked as maintainer-specific (committing directly, the exact commit
|
||||
author, and the entire "Making a release" / publishing flow) applies only in maintainer
|
||||
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
|
||||
author, updating changelogs, and the entire "Making a release" / publishing flow) applies
|
||||
only in maintainer mode. The rest (translation policy, tests, validating from code) is
|
||||
good practice for everyone.
|
||||
|
||||
## Process: "Fix open issues"
|
||||
@@ -206,6 +225,9 @@ When asked to fix an open issue (one issue at a time):
|
||||
|
||||
Fixes #1234,
|
||||
```
|
||||
|
||||
A contributor instead commits on a branch for one pull request that fixes this
|
||||
one issue, with no changelog changes (see "Contributor mode" above).
|
||||
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
|
||||
whose message ends `Fixes #1234,`), noting where/when it was fixed.
|
||||
|
||||
@@ -407,6 +429,10 @@ directly after the merge.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
|
||||
section is for the maintainer and for an agent working as the maintainer, in WeKan
|
||||
and in the repositories under `.tools/`. A pull request contributor changes no
|
||||
changelog in any of them (see "Contributor mode" above).
|
||||
- **Every repository writes its changelog in the format that repository's own file
|
||||
already uses.** Open its changelog, read the entries above the place you are
|
||||
adding one, and match them — never import another project's shape into it. The
|
||||
@@ -817,7 +843,7 @@ have cost a released section its accuracy:
|
||||
### Security issues
|
||||
|
||||
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
|
||||
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
entries (maintainer mode only, as with every changelog change), and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
subdirectory `index.html` like previous security issues.
|
||||
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
|
||||
fault cannot exist ANYWHERE in the codebase — not just at the place it was
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=12.85
|
||||
ARG VERSION=12.96
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
|
||||
@@ -151,7 +151,7 @@ ENV \
|
||||
NODE_VERSION=v26.13.0 \
|
||||
METEOR_RELEASE=METEOR@3.6-rc.1 \
|
||||
USE_EDGE=false \
|
||||
NPM_VERSION=12.2.1 \
|
||||
NPM_VERSION=12.2.2 \
|
||||
SRC_PATH=./ \
|
||||
WITH_API=true \
|
||||
MONGO_OPLOG_URL="" \
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v12.85.0"
|
||||
appVersion: "v12.96.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
background-color: #fff;
|
||||
border: 0;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
height: 36px;
|
||||
margin-block: 4px 6px;
|
||||
margin-inline: 0 4px;
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
/* Ordinary solid-colour themes. */
|
||||
|
||||
.board-color-nephritis {
|
||||
--board-theme-accent: #27ae60;
|
||||
--board-theme-dark: #1f8b4d;
|
||||
--board-theme-hover: #2cc66d;
|
||||
--board-theme-accent: #217d49;
|
||||
--board-theme-dark: #1c6c3f;
|
||||
--board-theme-hover: #1f7645;
|
||||
--board-theme-emphasis: #ae2775;
|
||||
--board-theme-emphasis-hover: #9d2369;
|
||||
--board-theme-emphasis-close: #8b1f5e;
|
||||
@@ -84,12 +84,12 @@
|
||||
}
|
||||
|
||||
.board-color-pumpkin {
|
||||
--board-theme-accent: #e67e22;
|
||||
--board-theme-dark: #be6415;
|
||||
--board-theme-hover: #e98b38;
|
||||
--board-theme-emphasis: #228ae6;
|
||||
--board-theme-emphasis-hover: #187dd5;
|
||||
--board-theme-emphasis-close: #156fbe;
|
||||
--board-theme-accent: #a75919;
|
||||
--board-theme-dark: #904d15;
|
||||
--board-theme-hover: #9d5417;
|
||||
--board-theme-emphasis: #196ec2;
|
||||
--board-theme-emphasis-hover: #1765b2;
|
||||
--board-theme-emphasis-close: #155ca3;
|
||||
--board-theme-checklist-bg: #f9e5d1;
|
||||
--board-theme-selected-bg: #fdf2e9;
|
||||
--board-theme-selected-hover-bg: #fefbf8;
|
||||
@@ -99,12 +99,12 @@
|
||||
}
|
||||
|
||||
.board-color-moderatepink {
|
||||
--board-theme-accent: #cd5a91;
|
||||
--board-theme-dark: #b53773;
|
||||
--board-theme-hover: #d26b9c;
|
||||
--board-theme-emphasis: #5acd96;
|
||||
--board-theme-emphasis-hover: #43c688;
|
||||
--board-theme-emphasis-close: #37b579;
|
||||
--board-theme-accent: #bd3a79;
|
||||
--board-theme-dark: #a33268;
|
||||
--board-theme-hover: #b23772;
|
||||
--board-theme-emphasis: #217b54;
|
||||
--board-theme-emphasis-hover: #1e714d;
|
||||
--board-theme-emphasis-close: #1c6747;
|
||||
--board-theme-checklist-bg: #f4dde8;
|
||||
--board-theme-selected-bg: #faeef4;
|
||||
--board-theme-selected-hover-bg: #fefafc;
|
||||
@@ -114,9 +114,9 @@
|
||||
}
|
||||
|
||||
.board-color-strongcyan {
|
||||
--board-theme-accent: #00aecc;
|
||||
--board-theme-dark: #008ba3;
|
||||
--board-theme-hover: #00c8eb;
|
||||
--board-theme-accent: #00798d;
|
||||
--board-theme-dark: #006879;
|
||||
--board-theme-hover: #007285;
|
||||
--board-theme-emphasis: #cc1e00;
|
||||
--board-theme-emphasis-hover: #b81b00;
|
||||
--board-theme-emphasis-close: #a31800;
|
||||
@@ -129,12 +129,12 @@
|
||||
}
|
||||
|
||||
.board-color-limegreen {
|
||||
--board-theme-accent: #4bbf6b;
|
||||
--board-theme-dark: #389d54;
|
||||
--board-theme-hover: #5dc57a;
|
||||
--board-theme-emphasis: #bf4b9f;
|
||||
--board-theme-emphasis-hover: #b13f91;
|
||||
--board-theme-emphasis-close: #9d3881;
|
||||
--board-theme-accent: #277c48;
|
||||
--board-theme-dark: #226b3e;
|
||||
--board-theme-hover: #257544;
|
||||
--board-theme-emphasis: #b34093;
|
||||
--board-theme-emphasis-hover: #a53b87;
|
||||
--board-theme-emphasis-close: #96367b;
|
||||
--board-theme-checklist-bg: #daf4de;
|
||||
--board-theme-selected-bg: #edf9f0;
|
||||
--board-theme-selected-hover-bg: #fafdfb;
|
||||
@@ -144,12 +144,12 @@
|
||||
}
|
||||
|
||||
.board-color-corteza {
|
||||
--board-theme-accent: #568ba2;
|
||||
--board-theme-dark: #456f82;
|
||||
--board-theme-hover: #6597ad;
|
||||
--board-theme-emphasis: #a26d56;
|
||||
--board-theme-emphasis-hover: #92624d;
|
||||
--board-theme-emphasis-close: #825745;
|
||||
--board-theme-accent: #4a738a;
|
||||
--board-theme-dark: #406377;
|
||||
--board-theme-hover: #466c82;
|
||||
--board-theme-emphasis: #94614f;
|
||||
--board-theme-emphasis-hover: #885949;
|
||||
--board-theme-emphasis-close: #7c5142;
|
||||
--board-theme-checklist-bg: #dce6ec;
|
||||
--board-theme-selected-bg: #eef3f6;
|
||||
--board-theme-selected-hover-bg: #fafcfc;
|
||||
@@ -904,10 +904,10 @@ THEME - Relax
|
||||
.board-list li.board-color-relax,
|
||||
.board-color-relax .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-relax .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #27ae61;
|
||||
background-color: #217d4a;
|
||||
}
|
||||
.board-color-relax .is-selected .minicard {
|
||||
border-inline-start: 3px solid #27ae61;
|
||||
border-inline-start: 3px solid #217d4a;
|
||||
}
|
||||
.board-color-relax .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -918,17 +918,17 @@ THEME - Relax
|
||||
.board-color-relax input[type=submit].primary,
|
||||
.board-color-relax .sidebar .sidebar-content .sidebar-btn,
|
||||
.board-color-relax .selected-calendar-picker button.primary {
|
||||
background-color: #1f8b4e;
|
||||
background-color: #1c6c40;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.board-color-relax.pop-over .pop-over-list li a:not(.disabled):hover,
|
||||
.board-color-relax .sidebar .sidebar-content .sidebar-btn:hover,
|
||||
.board-color-relax .sidebar-list li a:hover {
|
||||
background-color: #2cc66f;
|
||||
background-color: #1f7646;
|
||||
}
|
||||
.board-color-relax#header ul li.current,
|
||||
.board-color-relax#header-quick-access ul li.current {
|
||||
border-bottom: 2px solid #2cc66f;
|
||||
border-bottom: 2px solid #1f7646;
|
||||
}
|
||||
.board-color-relax#header-quick-access {
|
||||
color: #fff;
|
||||
@@ -944,14 +944,14 @@ THEME - Relax
|
||||
background: #8b1f5d;
|
||||
}
|
||||
.board-color-relax .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #27ae61;
|
||||
border-right: 2px solid #27ae61;
|
||||
border-bottom: 2px solid #217d4a;
|
||||
border-right: 2px solid #217d4a;
|
||||
}
|
||||
.board-color-relax .checklist-progress-bar {
|
||||
background-color: #d4f1dd !important;
|
||||
}
|
||||
.board-color-relax .checklist-progress-bar .checklist-progress {
|
||||
background-color: #27ae61 !important;
|
||||
background-color: #217d4a !important;
|
||||
}
|
||||
.board-color-relax .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
|
||||
background: #e7faef;
|
||||
@@ -960,17 +960,17 @@ THEME - Relax
|
||||
background: #f8fdfa;
|
||||
}
|
||||
.board-color-relax .toggle-label:after {
|
||||
background-color: #1f8b4e;
|
||||
background-color: #1c6c40;
|
||||
}
|
||||
.board-color-relax .toggle-switch:checked ~ .toggle-label {
|
||||
background-color: #3dd37e;
|
||||
}
|
||||
.board-color-relax .toggle-switch:checked ~ .toggle-label:after {
|
||||
background-color: #1f8b4e;
|
||||
background-color: #1c6c40;
|
||||
}
|
||||
@media screen and (max-width: 800px) {
|
||||
.board-color-relax.pop-over .header {
|
||||
background: #27ae61;
|
||||
background: #217d4a;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
@@ -1557,7 +1557,7 @@ THEME - Clear Blue
|
||||
.board-backgrounds-list .board-color-clearblue.background-box,
|
||||
.public-board-row.board-color-clearblue,
|
||||
.board-list li.board-color-clearblue {
|
||||
background: linear-gradient(180deg, #499bea 0%, #00aecc 100%);
|
||||
background: linear-gradient(180deg, #499bea 0%, #00798d 100%);
|
||||
}
|
||||
.board-color-clearblue .is-selected .minicard {
|
||||
border-inline-start: 3px solid #499bea;
|
||||
@@ -1569,7 +1569,7 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue#header ul li.current,
|
||||
.board-color-clearblue#header-quick-access ul li.current {
|
||||
border-bottom: 4px solid #00c8eb;
|
||||
border-bottom: 4px solid #007285;
|
||||
}
|
||||
.board-color-clearblue#header-quick-access {
|
||||
color: #fff;
|
||||
@@ -1588,14 +1588,14 @@ THEME - Clear Blue
|
||||
.board-color-clearblue#header-quick-access,
|
||||
.board-color-clearblue .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-clearblue .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #00aecc;
|
||||
border-bottom: 1px solid #008ba3;
|
||||
background-color: #00798d;
|
||||
border-bottom: 1px solid #006879;
|
||||
border-top: 1px solid #00687a;
|
||||
}
|
||||
/* clearblue's bar is a colour SLIDE, and the slide has to be on the bar that is
|
||||
actually on screen. It was only ever on `#header #header-main-bar` - the
|
||||
inner element of the SECOND bar - because that is where the theme used to
|
||||
live. The first bar got the flat `background-color: #00aecc` from the rule
|
||||
live. The first bar got the flat `background-color: #00798d` from the rule
|
||||
above instead, which is exactly strongcyan's colour: picking clearblue
|
||||
painted the header strongcyan.
|
||||
`#header-quick-access` has no inner bar of its own, so the slide goes
|
||||
@@ -1604,7 +1604,7 @@ THEME - Clear Blue
|
||||
The All Boards left menu's selected row is named here too. Every other theme
|
||||
paints that row through `--theme-accent`, which is one colour and is exactly
|
||||
right for them; a slide is not a colour, so the variable cannot carry it and
|
||||
clearblue's row came out flat #00aecc while the Admin Panel's row beside it
|
||||
clearblue's row came out flat #00798d while the Admin Panel's row beside it
|
||||
slid. The Admin Panel's row has always been named in this rule; this is the
|
||||
All Boards row joining it. docs/Features/General/Theme/Change-Color.md */
|
||||
.board-color-clearblue#header #header-main-bar,
|
||||
@@ -1613,13 +1613,13 @@ THEME - Clear Blue
|
||||
.board-color-clearblue .setting-content .content-body .side-menu ul li.active:hover,
|
||||
.board-color-clearblue .boards-left-menu .menu-item.active a,
|
||||
.board-color-clearblue .boards-left-menu .menu-item.active a:hover {
|
||||
background: linear-gradient(180deg, #499bea 0%, #00aecc 100%);
|
||||
background: linear-gradient(180deg, #499bea 0%, #00798d 100%);
|
||||
}
|
||||
.board-color-clearblue#header #header-main-bar p {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.board-color-clearblue#header #header-main-bar .board-header-btn.emphasis {
|
||||
background: #00c8eb;
|
||||
background: #007285;
|
||||
}
|
||||
.board-color-clearblue#header #header-main-bar .board-header-btn.emphasis:hover,
|
||||
.board-color-clearblue#header #header-main-bar .board-header-btn.emphasis .board-header-btn-close {
|
||||
@@ -1651,7 +1651,7 @@ THEME - Clear Blue
|
||||
background-color: #197ddc;
|
||||
}
|
||||
.board-color-clearblue.board-wrapper {
|
||||
background: linear-gradient(135deg, #499bea 0%, #00aecc 100%);
|
||||
background: linear-gradient(135deg, #499bea 0%, #00798d 100%);
|
||||
}
|
||||
.board-color-clearblue .swimlane {
|
||||
background: none;
|
||||
@@ -1743,7 +1743,7 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue .quiet,
|
||||
.board-color-clearblue .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearblue .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -1770,7 +1770,7 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-inline-start: 9px solid #00aecc;
|
||||
border-inline-start: 9px solid #00798d;
|
||||
}
|
||||
.board-color-clearblue .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -1836,7 +1836,7 @@ THEME - Clear Blue
|
||||
|
||||
/* Apply scrollbar to sidebar content*/
|
||||
.board-color-clearblue .sidebar .sidebar-content {
|
||||
scrollbar-color: #00aecc #ffffff00;
|
||||
scrollbar-color: #00798d #ffffff00;
|
||||
}
|
||||
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
@@ -1987,10 +1987,10 @@ THEME - Modern
|
||||
.board-list li.board-color-modern,
|
||||
.board-color-modern .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-modern .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #2a80b8;
|
||||
background-color: #2372a4;
|
||||
}
|
||||
.board-color-modern .is-selected .minicard {
|
||||
border-inline-start: 3px solid #2a80b8;
|
||||
border-inline-start: 3px solid #2372a4;
|
||||
}
|
||||
.board-color-modern .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -2001,40 +2001,40 @@ THEME - Modern
|
||||
.board-color-modern input[type=submit].primary,
|
||||
.board-color-modern .sidebar .sidebar-content .sidebar-btn,
|
||||
.board-color-modern .selected-calendar-picker button.primary {
|
||||
background-color: #226693;
|
||||
background-color: #1e628d;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.board-color-modern.pop-over .pop-over-list li a:not(.disabled):hover,
|
||||
.board-color-modern .sidebar .sidebar-content .sidebar-btn:hover,
|
||||
.board-color-modern .sidebar-list li a:hover {
|
||||
background-color: #2f90cf;
|
||||
background-color: #216b9a;
|
||||
}
|
||||
.board-color-modern#header ul li.current,
|
||||
.board-color-modern#header-quick-access ul li.current {
|
||||
border-bottom: 2px solid #2f90cf;
|
||||
border-bottom: 2px solid #216b9a;
|
||||
}
|
||||
.board-color-modern#header-quick-access {
|
||||
color: #fff;
|
||||
}
|
||||
.board-color-modern#header #header-main-bar .board-header-btn.emphasis {
|
||||
background: #b8622a;
|
||||
background: #a45b23;
|
||||
}
|
||||
.board-color-modern#header #header-main-bar .board-header-btn.emphasis:hover,
|
||||
.board-color-modern#header #header-main-bar .board-header-btn.emphasis .board-header-btn-close {
|
||||
background: #a65826;
|
||||
background: #975420;
|
||||
}
|
||||
.board-color-modern#header #header-main-bar .board-header-btn.emphasis:hover .board-header-btn-close {
|
||||
background: #934e22;
|
||||
background: #8a4c1d;
|
||||
}
|
||||
.board-color-modern .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #2a80b8;
|
||||
border-right: 2px solid #2a80b8;
|
||||
border-bottom: 2px solid #2372a4;
|
||||
border-right: 2px solid #2372a4;
|
||||
}
|
||||
.board-color-modern .checklist-progress-bar {
|
||||
background-color: #d1e7f5 !important;
|
||||
}
|
||||
.board-color-modern .checklist-progress-bar .checklist-progress {
|
||||
background-color: #2a80b8 !important;
|
||||
background-color: #2372a4 !important;
|
||||
}
|
||||
.board-color-modern .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
|
||||
background: #e8f3fa;
|
||||
@@ -2043,17 +2043,17 @@ THEME - Modern
|
||||
background: #f8fbfd;
|
||||
}
|
||||
.board-color-modern .toggle-label:after {
|
||||
background-color: #226693;
|
||||
background-color: #1e628d;
|
||||
}
|
||||
.board-color-modern .toggle-switch:checked ~ .toggle-label {
|
||||
background-color: #469cd5;
|
||||
}
|
||||
.board-color-modern .toggle-switch:checked ~ .toggle-label:after {
|
||||
background-color: #226693;
|
||||
background-color: #1e628d;
|
||||
}
|
||||
@media screen and (max-width: 800px) {
|
||||
.board-color-modern.pop-over .header {
|
||||
background: #2a80b8;
|
||||
background: #2372a4;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
@@ -2382,7 +2382,7 @@ THEME - Modern Dark
|
||||
.board-color-moderndark button[type=submit].primary,
|
||||
.board-color-moderndark .board-color-modern input[type=submit].primary,
|
||||
.board-color-moderndark .selected-calendar-picker button.primary {
|
||||
background-color: #777;
|
||||
background-color: #6e6e6e;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.board-color-moderndark .toggle-switch:checked~.toggle-label {
|
||||
@@ -3015,7 +3015,15 @@ THEME - Exodark
|
||||
.pop-over.board-color-exodark {
|
||||
background: #2b2b2b;
|
||||
color: #fff;
|
||||
--wekan-quiet-text: #b4b4b4;
|
||||
/* Card Settings opens in this popup: dark sections for its white text. */
|
||||
--card-settings-section-bg: rgba(255, 255, 255, 0.08);
|
||||
--card-settings-helper-bg: #3a3a46;
|
||||
--card-settings-notch-bg: #2c3340;
|
||||
}
|
||||
/* The popup's own links keep the light popup's black otherwise: black on
|
||||
#2b2b2b was 1.48:1 (#6863). */
|
||||
|
||||
.pop-over.board-color-exodark .header {
|
||||
background: #2b2b2b;
|
||||
color: #fff;
|
||||
@@ -3794,7 +3802,7 @@ THEME - Clean Light
|
||||
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: 400;
|
||||
line-height: calc(24px * var(--wekan-ui-font-scale, 1));
|
||||
color: rgba(10, 10, 20, 0.5);
|
||||
color: rgba(10, 10, 20, 0.7);
|
||||
}
|
||||
|
||||
.board-color-cleanlight .card-details-header,
|
||||
@@ -4617,7 +4625,7 @@ THEME - Clean Light
|
||||
/* ===============
|
||||
THEME - cleargreen
|
||||
|
||||
A colour SLIDE, like clearblue: #8ad59f down to #4bbf6b.
|
||||
A colour SLIDE, like clearblue: #8ad59f down to #277c48.
|
||||
The base is limegreen's own colour, so the flat theme and the slide theme are
|
||||
the same colour family and a reader picking either gets what they expected.
|
||||
|
||||
@@ -4634,7 +4642,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-backgrounds-list .board-color-cleargreen.background-box,
|
||||
.public-board-row.board-color-cleargreen,
|
||||
.board-list li.board-color-cleargreen {
|
||||
background: linear-gradient(180deg, #8ad59f 0%, #4bbf6b 100%);
|
||||
background: linear-gradient(180deg, #8ad59f 0%, #277c48 100%);
|
||||
}
|
||||
.board-color-cleargreen .is-selected .minicard {
|
||||
border-inline-start: 3px solid #8ad59f;
|
||||
@@ -4661,7 +4669,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-cleargreen#header-quick-access,
|
||||
.board-color-cleargreen .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-cleargreen .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #4bbf6b;
|
||||
background-color: #277c48;
|
||||
border-bottom: 1px solid #3c9956;
|
||||
border-top: 1px solid #2d7340;
|
||||
}
|
||||
@@ -4672,7 +4680,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-cleargreen .setting-content .content-body .side-menu ul li.active:hover,
|
||||
.board-color-cleargreen .boards-left-menu .menu-item.active a,
|
||||
.board-color-cleargreen .boards-left-menu .menu-item.active a:hover {
|
||||
background: linear-gradient(180deg, #8ad59f 0%, #4bbf6b 100%);
|
||||
background: linear-gradient(180deg, #8ad59f 0%, #277c48 100%);
|
||||
}
|
||||
.board-color-cleargreen#header #header-main-bar p {
|
||||
margin-bottom: 6px;
|
||||
@@ -4710,7 +4718,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
background-color: #42a85e;
|
||||
}
|
||||
.board-color-cleargreen.board-wrapper {
|
||||
background: linear-gradient(135deg, #8ad59f 0%, #4bbf6b 100%);
|
||||
background: linear-gradient(135deg, #8ad59f 0%, #277c48 100%);
|
||||
}
|
||||
.board-color-cleargreen .swimlane {
|
||||
background: none;
|
||||
@@ -4802,7 +4810,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-cleargreen .quiet,
|
||||
.board-color-cleargreen .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-cleargreen .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -4829,7 +4837,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-cleargreen .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-inline-start: 9px solid #4bbf6b;
|
||||
border-inline-start: 9px solid #277c48;
|
||||
}
|
||||
.board-color-cleargreen .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -4894,7 +4902,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
|
||||
/* Apply scrollbar to sidebar content*/
|
||||
.board-color-cleargreen .sidebar .sidebar-content {
|
||||
scrollbar-color: #4bbf6b #ffffff00;
|
||||
scrollbar-color: #277c48 #ffffff00;
|
||||
}
|
||||
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
@@ -4913,7 +4921,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
/* ===============
|
||||
THEME - clearorange
|
||||
|
||||
A colour SLIDE, like clearblue: #efab6f down to #e67e22.
|
||||
A colour SLIDE, like clearblue: #efab6f down to #a75919.
|
||||
The base is pumpkin's own colour, so the flat theme and the slide theme are
|
||||
the same colour family and a reader picking either gets what they expected.
|
||||
|
||||
@@ -4930,7 +4938,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-backgrounds-list .board-color-clearorange.background-box,
|
||||
.public-board-row.board-color-clearorange,
|
||||
.board-list li.board-color-clearorange {
|
||||
background: linear-gradient(180deg, #efab6f 0%, #e67e22 100%);
|
||||
background: linear-gradient(180deg, #efab6f 0%, #a75919 100%);
|
||||
}
|
||||
.board-color-clearorange .is-selected .minicard {
|
||||
border-inline-start: 3px solid #efab6f;
|
||||
@@ -4957,7 +4965,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-clearorange#header-quick-access,
|
||||
.board-color-clearorange .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-clearorange .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #e67e22;
|
||||
background-color: #a75919;
|
||||
border-bottom: 1px solid #b8651b;
|
||||
border-top: 1px solid #8a4c14;
|
||||
}
|
||||
@@ -4968,7 +4976,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-clearorange .setting-content .content-body .side-menu ul li.active:hover,
|
||||
.board-color-clearorange .boards-left-menu .menu-item.active a,
|
||||
.board-color-clearorange .boards-left-menu .menu-item.active a:hover {
|
||||
background: linear-gradient(180deg, #efab6f 0%, #e67e22 100%);
|
||||
background: linear-gradient(180deg, #efab6f 0%, #a75919 100%);
|
||||
}
|
||||
.board-color-clearorange#header #header-main-bar p {
|
||||
margin-bottom: 6px;
|
||||
@@ -5006,7 +5014,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
background-color: #ca6f1e;
|
||||
}
|
||||
.board-color-clearorange.board-wrapper {
|
||||
background: linear-gradient(135deg, #efab6f 0%, #e67e22 100%);
|
||||
background: linear-gradient(135deg, #efab6f 0%, #a75919 100%);
|
||||
}
|
||||
.board-color-clearorange .swimlane {
|
||||
background: none;
|
||||
@@ -5098,7 +5106,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearorange .quiet,
|
||||
.board-color-clearorange .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearorange .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -5125,7 +5133,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearorange .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-inline-start: 9px solid #e67e22;
|
||||
border-inline-start: 9px solid #a75919;
|
||||
}
|
||||
.board-color-clearorange .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -5190,7 +5198,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
|
||||
/* Apply scrollbar to sidebar content*/
|
||||
.board-color-clearorange .sidebar .sidebar-content {
|
||||
scrollbar-color: #e67e22 #ffffff00;
|
||||
scrollbar-color: #a75919 #ffffff00;
|
||||
}
|
||||
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
@@ -5209,7 +5217,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
/* ===============
|
||||
THEME - clearpink
|
||||
|
||||
A colour SLIDE, like clearblue: #df94b8 down to #cd5a91.
|
||||
A colour SLIDE, like clearblue: #df94b8 down to #bd3a79.
|
||||
The base is moderatepink's own colour, so the flat theme and the slide theme are
|
||||
the same colour family and a reader picking either gets what they expected.
|
||||
|
||||
@@ -5226,7 +5234,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-backgrounds-list .board-color-clearpink.background-box,
|
||||
.public-board-row.board-color-clearpink,
|
||||
.board-list li.board-color-clearpink {
|
||||
background: linear-gradient(180deg, #df94b8 0%, #cd5a91 100%);
|
||||
background: linear-gradient(180deg, #df94b8 0%, #bd3a79 100%);
|
||||
}
|
||||
.board-color-clearpink .is-selected .minicard {
|
||||
border-inline-start: 3px solid #df94b8;
|
||||
@@ -5253,7 +5261,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-clearpink#header-quick-access,
|
||||
.board-color-clearpink .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-clearpink .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #cd5a91;
|
||||
background-color: #bd3a79;
|
||||
border-bottom: 1px solid #a44874;
|
||||
border-top: 1px solid #7b3657;
|
||||
}
|
||||
@@ -5264,7 +5272,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-clearpink .setting-content .content-body .side-menu ul li.active:hover,
|
||||
.board-color-clearpink .boards-left-menu .menu-item.active a,
|
||||
.board-color-clearpink .boards-left-menu .menu-item.active a:hover {
|
||||
background: linear-gradient(180deg, #df94b8 0%, #cd5a91 100%);
|
||||
background: linear-gradient(180deg, #df94b8 0%, #bd3a79 100%);
|
||||
}
|
||||
.board-color-clearpink#header #header-main-bar p {
|
||||
margin-bottom: 6px;
|
||||
@@ -5302,7 +5310,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
background-color: #b44f80;
|
||||
}
|
||||
.board-color-clearpink.board-wrapper {
|
||||
background: linear-gradient(135deg, #df94b8 0%, #cd5a91 100%);
|
||||
background: linear-gradient(135deg, #df94b8 0%, #bd3a79 100%);
|
||||
}
|
||||
.board-color-clearpink .swimlane {
|
||||
background: none;
|
||||
@@ -5394,7 +5402,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearpink .quiet,
|
||||
.board-color-clearpink .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearpink .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -5421,7 +5429,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearpink .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-inline-start: 9px solid #cd5a91;
|
||||
border-inline-start: 9px solid #bd3a79;
|
||||
}
|
||||
.board-color-clearpink .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -5486,7 +5494,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
|
||||
/* Apply scrollbar to sidebar content*/
|
||||
.board-color-clearpink .sidebar .sidebar-content {
|
||||
scrollbar-color: #cd5a91 #ffffff00;
|
||||
scrollbar-color: #bd3a79 #ffffff00;
|
||||
}
|
||||
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
@@ -5690,7 +5698,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearpurple .quiet,
|
||||
.board-color-clearpurple .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearpurple .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -5986,7 +5994,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearred .quiet,
|
||||
.board-color-clearred .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearred .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -6093,3 +6101,98 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
|
||||
/* === END clearred THEME === */
|
||||
|
||||
/* The quiet text colour (#595959 on the light themes' greys, WCAG 1.4.3) is a
|
||||
variable, so the themes whose popups and sidebars are DARK can have a light
|
||||
one: a dark grey on a dark surface was nearly invisible (#6863). cleandark and
|
||||
moderndark are those; dark and exodark keep light popups and sidebars, where
|
||||
the dark grey is right. Popups carry the theme class themselves, so the
|
||||
variable reaches them. */
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark
|
||||
) {
|
||||
--wekan-quiet-text: #b4b4b4;
|
||||
/* Card Settings: its sections are light grey blocks (cardAddFields.css), and
|
||||
these themes' text on them is white (1.07:1 in cleandark). */
|
||||
--card-settings-section-bg: rgba(255, 255, 255, 0.08);
|
||||
--card-settings-helper-bg: #3a3a46;
|
||||
--card-settings-notch-bg: #2c3340;
|
||||
}
|
||||
/* moderndark's surfaces are lighter greys (#545454): the quiet text needs more. */
|
||||
.board-color-moderndark {
|
||||
--wekan-quiet-text: #dcdcdc;
|
||||
}
|
||||
/* The board sidebar's "Keyboard shortcuts" row is outside .sidebar-content, so
|
||||
the white of its buttons does not reach it, and sidebar.css makes it black:
|
||||
black on these sidebars was 1.34:1. */
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark
|
||||
) .sidebar .sidebar-shortcuts {
|
||||
color: #fff;
|
||||
}
|
||||
/* moderndark: grey (#808080) on its near-black sidebar was 4.02. */
|
||||
.board-color-moderndark :is(.js-open-board-menu, h3[data-fold], .activity-title) {
|
||||
color: var(--wekan-quiet-text);
|
||||
}
|
||||
/* dark: the swimlane header's text was black on its #2c3e50 bar (1.91:1). */
|
||||
.board-color-dark .swimlane-header {
|
||||
color: #fff;
|
||||
}
|
||||
/* dark and exodark: the board, the opened card and the sidebar are dark surfaces
|
||||
(#222, #2b2b2b, #2c3e50). Their popups differ: exodark's is dark too (above),
|
||||
dark's is light, so the dark theme's popups keep the dark quiet text. */
|
||||
:is(
|
||||
.board-color-dark,
|
||||
.board-color-exodark
|
||||
) :is(.board-canvas, .card-details, .sidebar) {
|
||||
--wekan-quiet-text: #b4b4b4;
|
||||
}
|
||||
/* A "quiet button" (Add custom field, Link fields to another card, ...) is #222 in
|
||||
forms.css, for a light surface; on a dark popup or sidebar it was 1.18:1 (#6863). */
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark
|
||||
) .quiet-button,
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark,
|
||||
.board-color-exodark
|
||||
).pop-over .quiet-button {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
/* The board sidebar of the dark themes: what is not a button or a heading
|
||||
(a form label, the list of swimlanes' lists and cards) kept the light
|
||||
sidebar's black: 1.34:1 on cleandark's #23232b (#6863). */
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark
|
||||
) .sidebar .sidebar-content :is(label, .minilists, .minicards) {
|
||||
color: #fff;
|
||||
}
|
||||
/* Popup items and sidebar lists: popup.css forces `.pop-over-list li > a` to black
|
||||
with !important, which is right on a light surface. moderndark's popups and
|
||||
sidebar and exodark's popups are dark: black on #454545 was 2.19:1, on #2b2b2b
|
||||
1.48:1 (#6863). */
|
||||
:is(
|
||||
.board-color-moderndark,
|
||||
.board-color-exodark
|
||||
).pop-over .pop-over-list li > a,
|
||||
:is(
|
||||
.board-color-moderndark,
|
||||
.board-color-exodark
|
||||
).pop-over .pop-over-list li > a *,
|
||||
.board-color-moderndark .sidebar .pop-over-list li > a,
|
||||
.board-color-moderndark .sidebar .pop-over-list li > a * {
|
||||
color: #fff !important;
|
||||
}
|
||||
/* moderndark: the sidebar's headings were #808080 on #222 (4.02:1). */
|
||||
.board-color-moderndark .sidebar .sidebar-content h3 {
|
||||
color: var(--wekan-quiet-text);
|
||||
}
|
||||
/* moderndark: the selected minicard's title was #ccc on #666 (3.57:1). */
|
||||
.board-color-moderndark .is-selected > .minicard .minicard-title,
|
||||
.board-color-moderndark .is-selected > .minicard .minicard-title * {
|
||||
color: #fff;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { ensureContrast } from '/models/lib/contrastColor';
|
||||
import { surfacesFor } from '/models/lib/uiFonts';
|
||||
import { activeThemeName } from '/client/lib/activeTheme';
|
||||
import {
|
||||
DEPENDENCY_ICON_CHOICES,
|
||||
DEPENDENCY_TYPES,
|
||||
@@ -138,13 +141,17 @@ Template.dependencyOverlay.onCreated(function () {
|
||||
const y2 = dependent.y + dependent.h / 2;
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
const path = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
const markerId = markerIdForColor(dep.color);
|
||||
// The colour is the member's free choice (a colour wheel), drawn over the
|
||||
// board: made visible against the theme's surface - a line is a graphic
|
||||
// that says something, 3:1 (WCAG 1.4.11). The stored colour is untouched.
|
||||
const drawColor = ensureContrast(dep.color, surfacesFor(activeThemeName()), 3);
|
||||
const markerId = markerIdForColor(drawColor);
|
||||
if (meta.directed) {
|
||||
markerColors.add(dep.color);
|
||||
markerColors.add(drawColor);
|
||||
}
|
||||
lines.push({
|
||||
path,
|
||||
color: dep.color,
|
||||
color: drawColor,
|
||||
type: dep.type,
|
||||
icon: dep.icon,
|
||||
directed: meta.directed,
|
||||
|
||||
@@ -87,7 +87,7 @@ template(name="attachmentGallery")
|
||||
.attachment-gallery
|
||||
|
||||
//- #6796: a small "+" like every other section's, not a full-width box.
|
||||
if canModifyCard
|
||||
if canAddAttachment
|
||||
a.card-label.add-label.add-attachment.js-add-attachment(title="{{_ 'add-attachment'}}" aria-label="{{_ 'add-attachment'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManag
|
||||
import Attachments from '/models/attachments';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { buildAttachmentUploadConfig } from '/client/lib/attachmentUploadConfig';
|
||||
import { attachmentUploadsBlocked } from '/client/lib/attachmentUploadPolicy';
|
||||
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
|
||||
const { attachmentTileCompact } = require('/models/lib/attachmentTile.cjs');
|
||||
import { formatDateTime } from '/imports/lib/dateUtils';
|
||||
@@ -467,6 +468,10 @@ Template.attachmentGallery.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.attachmentGallery.helpers({
|
||||
// #6870: the "+" button - a card the user may edit, unless uploads are blocked site-wide
|
||||
canAddAttachment() {
|
||||
return Utils.canModifyCard() && !attachmentUploadsBlocked();
|
||||
},
|
||||
// #6796: the small file button instead of the preview tile
|
||||
// (models/lib/attachmentTile.cjs).
|
||||
attachmentTileCompact(file) {
|
||||
@@ -672,6 +677,9 @@ export async function handleFileUpload(card, files) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// #6870: the administrator blocked uploads for the whole site; the server refuses them too
|
||||
if (attachmentUploadsBlocked()) return [];
|
||||
|
||||
// Check if board allows attachments
|
||||
const board = card.board();
|
||||
if (!board || !board.allowsAttachments) {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
}
|
||||
.card-settings-pane-collapse {
|
||||
padding: 2px 4px;
|
||||
color: #808080;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -113,7 +113,9 @@ body.card-settings-splitting-active {
|
||||
|
||||
/* A section: the checklist's grey block, its title line and its items. */
|
||||
.card-settings-inline-section {
|
||||
background: #f7f7f7;
|
||||
/* The checklist's grey block; a dark theme has its own (#6863: white text on
|
||||
this light grey was 1.07:1 in cleandark). */
|
||||
background: var(--card-settings-section-bg, #f7f7f7);
|
||||
border-radius: 3px;
|
||||
margin-block-end: 8px;
|
||||
padding: 4px 8px 6px;
|
||||
@@ -139,12 +141,12 @@ body.card-settings-splitting-active {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-settings-inline-section-title.js-card-settings-section-handle { cursor: grab; }
|
||||
.card-settings-inline-section-count { color: #808080; white-space: nowrap; }
|
||||
.card-settings-inline-section-count { color: var(--wekan-quiet-text, #595959); white-space: nowrap; }
|
||||
.card-settings-inline-collapse,
|
||||
.card-settings-inline-section-move,
|
||||
.card-settings-inline-menu {
|
||||
padding: 2px 4px;
|
||||
color: #808080;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -171,7 +173,7 @@ body.card-settings-splitting-active {
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 3px;
|
||||
color: #808080;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: none;
|
||||
}
|
||||
.card-settings-add-custom-field-button:hover,
|
||||
@@ -189,7 +191,7 @@ body.card-settings-splitting-active {
|
||||
border-bottom: 0;
|
||||
padding-inline-start: 22px;
|
||||
}
|
||||
.card-settings-inline .card-settings-line.ui-sortable-helper { background: #fff; }
|
||||
.card-settings-inline .card-settings-line.ui-sortable-helper { background: var(--card-settings-helper-bg, #fff); }
|
||||
.card-settings-inline .materialCheckBox { transition: none; }
|
||||
.card-settings-inline .card-field-order-toggle.is-animating .materialCheckBox { transition: 0.2s; }
|
||||
/* The hamburgers' checkboxes, likewise: only the one just clicked animates. */
|
||||
@@ -257,7 +259,7 @@ body.card-settings-splitting-active {
|
||||
border-inline-start: 1px solid rgba(41, 128, 185, 0.45);
|
||||
border-block-start: 1px solid rgba(41, 128, 185, 0.45);
|
||||
transform: rotate(45deg);
|
||||
background-color: #eef4f9;
|
||||
background-color: var(--card-settings-notch-bg, #eef4f9);
|
||||
}
|
||||
/* The + is at the start of its line: the notch is under it there. */
|
||||
.card-settings-inline-panel-create::before {
|
||||
@@ -270,7 +272,7 @@ body.card-settings-splitting-active {
|
||||
position: absolute;
|
||||
inset-block-start: 4px;
|
||||
inset-inline-end: 6px;
|
||||
color: #808080;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.card-settings-inline-panel-close:hover,
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
background-color: #fff;
|
||||
border: 0;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
height: 36px;
|
||||
margin: 4px 4px 6px 0;
|
||||
width: 92%;
|
||||
|
||||
@@ -185,6 +185,23 @@ body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-colla
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
/* A phone in desktop mode: the header bar wraps to several rows (up to five with
|
||||
the board controls open), far taller than the 8px the floating window docks
|
||||
at, so the card's own top row (caret, number, menu, X) sat among the header's
|
||||
rows and was only seen by dragging the card down. Below 801px the card starts
|
||||
BELOW the header, at the height Utils publishes as --wekan-header-height.
|
||||
Only a card WITHOUT an inline top matches (`:not([style*="top"])`), so the
|
||||
place a finger dragged it to is an inline top and is kept when the finger is
|
||||
lifted - this rule never competes with it. */
|
||||
@media screen and (max-width: 800px) {
|
||||
body.desktop-mode .card-details:not(.card-details-popup):not([style*="top"]),
|
||||
/* a card opened as a popup (search result, table, Roadmap bar) docks the same way */
|
||||
body.desktop-mode .card-details.card-details-popup:not([style*="top"]),
|
||||
body.desktop-mode .card-details.card-details-maximized:not(.card-details-popup):not(.card-details-collapsed) {
|
||||
top: calc(var(--wekan-header-height, 0px) + 8px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* #4822: the MAXIMIZED card must be anchored to the VIEWPORT (position: fixed
|
||||
with viewport insets), never laid out inside — or measured from — the
|
||||
scrolled swimlane/board container. Historically the maximized pane was an
|
||||
@@ -250,6 +267,24 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
color: #b3b3b3;
|
||||
display: inline-block;
|
||||
margin-inline-end: 6px;
|
||||
/* Above the title's edit zone (z-index 1, over the whole title): a press on the
|
||||
card number moves the window like the rest of the header row; only the title
|
||||
itself edits. */
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* The areas that move the window by a finger. Without touch-action: none the browser
|
||||
takes a finger drag for a scroll and cancels the pointer, so the window never moves
|
||||
(the page behind it scrolls instead). Only in desktop mode, where the card is a
|
||||
window; the full-screen card of mobile mode is not moved. */
|
||||
body.desktop-mode .card-details .card-details-header,
|
||||
body.desktop-mode .card-details .card-details-header .card-drag-handle {
|
||||
touch-action: none;
|
||||
}
|
||||
body.card-window-dragging {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Collapse toggle triangle */
|
||||
@@ -536,6 +571,19 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
resize: both;
|
||||
}
|
||||
|
||||
/* A card opened as a POPUP (Session popupCardId: from a search result, a table
|
||||
or a Roadmap/Gantt bar) docks at the very top of the window like every other
|
||||
opened card, and covers the header bars, as the .pop-over[data-popup=
|
||||
"cardDetailsPopup"] rule near the end of this file says it must. The 97px
|
||||
above is the room the header takes for a card that is NOT docked by the
|
||||
desktop-mode rules (those skip popups), and it opened the popup BELOW the
|
||||
header: collapsed, it was a bar floating over the swimlane. Mobile mode keeps
|
||||
its own full-window rule (top: 0 !important). */
|
||||
.card-details.card-details-popup {
|
||||
top: 8px;
|
||||
z-index: 2001;
|
||||
}
|
||||
|
||||
/* Override for mobile mode even on larger screens */
|
||||
body.mobile-mode .card-details {
|
||||
width: 100vw !important;
|
||||
@@ -1096,7 +1144,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
}
|
||||
.card-details-item-location .card-location-remove {
|
||||
margin-inline-start: 8px;
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-remove:hover {
|
||||
|
||||
@@ -81,6 +81,7 @@ const editingLocationId = new ReactiveVar(null);
|
||||
// Shared with Admin Panel, which links the places accounts log in from the same
|
||||
// way - models/lib/mapLink.js.
|
||||
const { mapLinkFor, GCJ } = require('/models/lib/mapLink');
|
||||
const { cardHeaderPress, dragThreshold, passedThreshold, clampWindow } = require('/models/lib/windowDrag');
|
||||
|
||||
|
||||
|
||||
@@ -1192,6 +1193,66 @@ Template.cardSectionHeader.events({
|
||||
},
|
||||
});
|
||||
|
||||
// Moves the card window under a finger or a mouse. `press` is cardHeaderPress's answer.
|
||||
// A press on a control waits until the pointer has really moved, so a tap still
|
||||
// collapses, closes or opens the menu; once it moved, the click that follows the drag
|
||||
// is swallowed.
|
||||
function beginCardWindowDrag(jqEvent, press) {
|
||||
// Blaze hands over a jQuery event, which does not copy `isPrimary`; the browser's
|
||||
// own event has it.
|
||||
const event = jqEvent.originalEvent || jqEvent;
|
||||
if (!press.start || !event.isPrimary) return;
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
|
||||
const $card = $(event.target).closest('.card-details');
|
||||
if (!$card.length) return;
|
||||
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const offset = $card.offset();
|
||||
const startLeft = offset.left - window.scrollX;
|
||||
const startTop = offset.top - window.scrollY;
|
||||
const threshold = dragThreshold(event.pointerType, press.deferred);
|
||||
const pointerId = event.pointerId;
|
||||
let moving = false;
|
||||
|
||||
const onMove = (e) => {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
if (!moving) {
|
||||
if (!passedThreshold(dx, dy, threshold)) return;
|
||||
moving = true;
|
||||
// #6465: where the user put it wins over where the minicard is, so a later
|
||||
// resize re-places every other window but only clamps this one into view.
|
||||
markCardDetailsUserMoved($card);
|
||||
document.body.classList.add('card-window-dragging');
|
||||
}
|
||||
e.preventDefault();
|
||||
const placed = clampWindow(startLeft + dx, startTop + dy, $card.outerWidth(),
|
||||
{ width: window.innerWidth, height: window.innerHeight });
|
||||
$card.css({ left: `${placed.left}px`, top: `${placed.top}px` });
|
||||
};
|
||||
|
||||
const onEnd = (e) => {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onEnd);
|
||||
document.removeEventListener('pointercancel', onEnd);
|
||||
document.body.classList.remove('card-window-dragging');
|
||||
if (moving && press.deferred) {
|
||||
// The drag started on a link or a button: its click is not a tap.
|
||||
const swallow = (click) => { click.preventDefault(); click.stopPropagation(); };
|
||||
document.addEventListener('click', swallow, { capture: true, once: true });
|
||||
setTimeout(() => document.removeEventListener('click', swallow, { capture: true }), 400);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', onMove, { passive: false });
|
||||
document.addEventListener('pointerup', onEnd);
|
||||
document.addEventListener('pointercancel', onEnd);
|
||||
}
|
||||
|
||||
Template.cardDetails.events({
|
||||
// #1591 folded each FIELD by its own title, storing a class per card in the
|
||||
// profile. The section carets replaced it: one handle per section, on the
|
||||
@@ -1221,86 +1282,31 @@ Template.cardDetails.events({
|
||||
Users.setPublicCardCollapsed(!currentState);
|
||||
}
|
||||
},
|
||||
'mousedown .js-card-drag-handle'(event) {
|
||||
event.preventDefault();
|
||||
const $card = $(event.target).closest('.card-details');
|
||||
// #6465: where the user put it wins over where the minicard is, so a later
|
||||
// resize re-places every other window but only clamps this one into view.
|
||||
markCardDetailsUserMoved($card);
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startLeft = $card.offset().left;
|
||||
const startTop = $card.offset().top;
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
$card.css({
|
||||
left: startLeft + deltaX + 'px',
|
||||
top: startTop + deltaY + 'px'
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
$(document).off('mousemove', onMouseMove);
|
||||
$(document).off('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
$(document).on('mousemove', onMouseMove);
|
||||
$(document).on('mouseup', onMouseUp);
|
||||
// The opened card's window is moved by a finger as well as a mouse, so these are
|
||||
// Pointer Events (a finger sends no mouse events while it moves, which left the card
|
||||
// fixed on an iPad while the page behind it scrolled) and the areas that start a
|
||||
// drag are `touch-action: none` (cardDetails.css). With drag handles ON the handle
|
||||
// is the only thing that moves the window; with them off the header row moves it,
|
||||
// except the title (it edits). A link or button in the row - the collapse caret, the
|
||||
// menu, the card number's neighbours - still does its job when tapped, and moves the
|
||||
// window when dragged. models/lib/windowDrag.js
|
||||
'pointerdown .js-card-drag-handle'(event) {
|
||||
beginCardWindowDrag(event, { start: true, deferred: false });
|
||||
},
|
||||
'mousedown .js-card-title-drag-handle'(event) {
|
||||
// The title bar drags the window. Don't interfere with text selection.
|
||||
if (
|
||||
$(event.target).closest(
|
||||
'a, input, textarea, button, select, option, .js-card-details-title',
|
||||
).length > 0
|
||||
) {
|
||||
// #6641: while the editor replaces the header, a textarea press still
|
||||
// bubbles to this drag bar. Leave it to the browser so mouse dragging
|
||||
// selects text instead of moving the card window.
|
||||
return;
|
||||
}
|
||||
// #6839: ...and never from the title, which edits - all of it
|
||||
// (cardDetails.css). The window is moved from the empty space of the
|
||||
// header row above it, beside the collapse caret and the menu.
|
||||
if ($(event.target).closest('.card-details-title-edit-zone').length > 0) {
|
||||
return;
|
||||
}
|
||||
// The drag handle has its own handler; without this both would run and the
|
||||
// window would be moved twice by one press. And when there IS a handle -
|
||||
// drag handles on - it is the only thing that moves the window, which is
|
||||
// what turning handles on means.
|
||||
if ($(event.target).closest('.js-card-drag-handle').length > 0
|
||||
|| $(event.target).closest('.card-details').find('.js-card-drag-handle').length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const $card = $(event.target).closest('.card-details');
|
||||
// #6465: same as the drag handle above — a dragged window keeps its place.
|
||||
markCardDetailsUserMoved($card);
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startLeft = $card.offset().left;
|
||||
const startTop = $card.offset().top;
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
$card.css({
|
||||
left: startLeft + deltaX + 'px',
|
||||
top: startTop + deltaY + 'px'
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
$(document).off('mousemove', onMouseMove);
|
||||
$(document).off('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
$(document).on('mousemove', onMouseMove);
|
||||
$(document).on('mouseup', onMouseUp);
|
||||
'pointerdown .js-card-title-drag-handle'(event) {
|
||||
const target = event.target;
|
||||
const closest = selector => (target.closest ? target.closest(selector) : null);
|
||||
const press = cardHeaderPress({
|
||||
onEditable: !!closest('input, textarea, select, option, .js-card-details-title'),
|
||||
onTitleEditZone: !!closest('.card-details-title-edit-zone'),
|
||||
// The handle has its own handler above; this one steps aside for it.
|
||||
onHandle: false,
|
||||
cardHasHandle: !!closest('.js-card-drag-handle')
|
||||
|| !!(closest('.card-details') && closest('.card-details').querySelector('.js-card-drag-handle')),
|
||||
onControl: !!closest('a, button'),
|
||||
});
|
||||
if (closest('.js-card-drag-handle')) return;
|
||||
beginCardWindowDrag(event, press);
|
||||
},
|
||||
'click .js-close-card-details'(event) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.js-add-checklist {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
textarea.js-add-checklist-item,
|
||||
textarea.js-edit-checklist-item {
|
||||
@@ -11,7 +11,7 @@ textarea.js-edit-checklist-item {
|
||||
.delete-text,
|
||||
.js-delete-checklist-item,
|
||||
.js-convert-checklist-item-to-card {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: underline;
|
||||
word-wrap: break-word;
|
||||
float: inline-end;
|
||||
@@ -139,7 +139,7 @@ textarea.js-edit-checklist-item {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.checklist-item .item-title.is-checked {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-style: italic;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -167,7 +167,7 @@ textarea.js-edit-checklist-item {
|
||||
.checklist-item .checklist-item-due-date-add {
|
||||
align-self: center;
|
||||
margin-inline-start: 6px;
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: none;
|
||||
}
|
||||
.checklist-item .checklist-item-due-date-add:hover {
|
||||
|
||||
@@ -1011,7 +1011,7 @@
|
||||
.minicard-list-name {
|
||||
clear: inline-start;
|
||||
font-size: 0.75em;
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1023,7 +1023,7 @@
|
||||
.minicard-swimlane-name {
|
||||
clear: inline-start;
|
||||
font-size: 0.75em;
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -419,18 +419,27 @@ template(name="minicard")
|
||||
.minicard-location
|
||||
i.fa.fa-map-marker
|
||||
| {{#if name}}{{name}}{{else}}{{address}}{{/if}}
|
||||
//- #6872: the + button on the opened card picks board members into
|
||||
requesters / assigners; the free-text name is optional. Show both,
|
||||
as the opened card does, or a picked member never reached here.
|
||||
if $eq field "requestedBy"
|
||||
unless $eq currentBoard.allowsRequestedByOnMinicard false
|
||||
if getRequestedBy
|
||||
if hasRequestedByOnMinicard
|
||||
.minicard-requested-by(title="{{_ 'requested-by'}}")
|
||||
i.fa.fa-shopping-cart
|
||||
| {{ getRequestedBy }}
|
||||
each userId in getRequesters
|
||||
+userAvatar(userId=userId noRemove=true)
|
||||
if getRequestedBy
|
||||
span {{ getRequestedBy }}
|
||||
if $eq field "assignedBy"
|
||||
unless $eq currentBoard.allowsAssignedByOnMinicard false
|
||||
if getAssignedBy
|
||||
if hasAssignedByOnMinicard
|
||||
.minicard-assigned-by(title="{{_ 'assigned-by'}}")
|
||||
i.fa.fa-user-plus
|
||||
| {{ getAssignedBy }}
|
||||
each userId in getAssigners
|
||||
+userAvatar(userId=userId noRemove=true)
|
||||
if getAssignedBy
|
||||
span {{ getAssignedBy }}
|
||||
if $eq field "flowtime"
|
||||
if currentBoard.allowsFlowtimeOnMinicard
|
||||
if isFlowActive
|
||||
|
||||
@@ -261,6 +261,13 @@ Template.minicard.helpers({
|
||||
isWatching() {
|
||||
return this.getRealCard().findWatcher(Meteor.userId());
|
||||
},
|
||||
// #6872: a picked member (requesters / assigners) or a free-text name.
|
||||
hasRequestedByOnMinicard() {
|
||||
return !!this.getRequestedBy() || (this.getRequesters() || []).length > 0;
|
||||
},
|
||||
hasAssignedByOnMinicard() {
|
||||
return !!this.getAssignedBy() || (this.getAssigners() || []).length > 0;
|
||||
},
|
||||
|
||||
showMembers() {
|
||||
const board = minicardBoardOf(this);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.js-add-subtask {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
textarea.js-add-subtask-item,
|
||||
textarea.js-edit-subtask-item {
|
||||
@@ -12,7 +12,7 @@ textarea.js-edit-subtask-item {
|
||||
.subtask-title .js-delete-subtask,
|
||||
.subtask-title .js-view-subtask,
|
||||
.js-delete-subtask-item {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: underline;
|
||||
word-wrap: break-word;
|
||||
flex-shrink: 0;
|
||||
@@ -163,7 +163,7 @@ textarea.js-edit-subtask-item {
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
.subtasks-item .item-title.is-checked {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-style: italic;
|
||||
}
|
||||
.subtasks-item .item-title .viewer p {
|
||||
@@ -186,7 +186,7 @@ textarea.js-edit-subtask-item {
|
||||
instead of disappearing — the same dimmed/italic treatment a checked
|
||||
checklist item gets (.subtasks-item .item-title.is-checked above). */
|
||||
.subtask.is-completed .subtask-title .title {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-style: italic;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -199,7 +199,7 @@ textarea.js-edit-subtask-item {
|
||||
.subtasks-hide-completed-toggle {
|
||||
display: inline-block;
|
||||
margin: 0.2em 0 0.5em 1.33em;
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
cursor: pointer;
|
||||
}
|
||||
.subtasks-hide-completed-toggle:hover {
|
||||
@@ -224,7 +224,7 @@ textarea.js-edit-subtask-item {
|
||||
flex-shrink: 0;
|
||||
margin: 0 0.5em;
|
||||
padding-top: 6px;
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -251,5 +251,5 @@ textarea.js-edit-subtask-item {
|
||||
}
|
||||
.subtask-title.is-done .title {
|
||||
text-decoration: line-through;
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
@@ -192,9 +192,11 @@ body.mobile-mode [id^="swimlane-"] .list[style*="--list-width"]:not(.list-collap
|
||||
min-height: 30px !important;
|
||||
height: auto !important;
|
||||
padding: 28px 21px 6px !important;
|
||||
/* Make sure the background covers the full height */
|
||||
background-color: #e4e4e4 !important;
|
||||
border-bottom: 8px solid #e4e4e4 !important;
|
||||
/* Make sure the background covers the full height. Not !important: it beat
|
||||
every theme's own list header (moderndark's #6a6a6a, exodark's #222), and
|
||||
left their light text on this grey at 1.09:1 and 1.27:1 (#6863). */
|
||||
background-color: #e4e4e4;
|
||||
border-bottom: 8px solid #e4e4e4;
|
||||
/* Use original display for consistent button positioning */
|
||||
display: block !important;
|
||||
position: relative !important;
|
||||
@@ -470,7 +472,7 @@ form.inlined-form .list-composer {
|
||||
|
||||
.list.list-composer .open-list-composer,
|
||||
.list .list-composer .open-list-composer {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
.list.list-composer .list-name-input,
|
||||
.list .list-composer .list-name-input {
|
||||
@@ -534,7 +536,7 @@ form.inlined-form .list-composer {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-weight: bold;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
vertical-align: middle;
|
||||
@@ -722,7 +724,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
|
||||
.list-header .cardCount {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -781,7 +783,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-body .open-minicard-composer {
|
||||
border-radius: 2px;
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
display: block;
|
||||
/* Smaller than a card title, because it is not a card: it is the affordance
|
||||
for making one. At the inherited 16px it was the largest text in the list
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Session } from 'meteor/session';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Template } from 'meteor/templating';
|
||||
import { activeAccent, activeFill } from '/models/lib/themeAccents';
|
||||
import { readableAccent } from '/models/lib/contrastColor';
|
||||
|
||||
// #5778 + docs/Theme/Theme.md: apply the theme color to the whole UI (All Boards,
|
||||
// Search, Admin Panel, My Cards, etc.) via a `board-color-<name>` class on <body>,
|
||||
@@ -99,7 +100,8 @@ Meteor.startup(() => {
|
||||
document.body.classList.remove('has-custom-theme-color');
|
||||
}
|
||||
if (c2) {
|
||||
root.style.setProperty('--theme-accent-2', c2);
|
||||
// Made readable like the first end: white text is drawn across the slide.
|
||||
root.style.setProperty('--theme-accent-2', readableAccent(c2));
|
||||
document.body.classList.add('has-custom-theme-slide');
|
||||
} else {
|
||||
root.style.removeProperty('--theme-accent-2');
|
||||
|
||||
@@ -20,6 +20,18 @@
|
||||
background: #2573a7;
|
||||
z-index: 17;
|
||||
}
|
||||
/* An opened card covers the header bars, in every browser. iOS WebKit - iPad Safari and
|
||||
iPad Chrome, which is WebKit too - makes every SCROLLING element (#content, the board
|
||||
wrapper) a stacking context of its own. The card is inside them, so its z-index (2001)
|
||||
is only compared there, while the header bar (#header-quick-access, z-index 1000, a
|
||||
flex item of body and so a stacking context itself) is painted over all of it: the
|
||||
card's top row - the collapse caret, the card number, the menu, maximize and X - sat
|
||||
BEHIND the header bar. While a card is open the header drops to the level of the
|
||||
content, which comes after it in the page and so paints over it; a browser without that
|
||||
trap shows no difference. */
|
||||
body:has(.card-details) #header-quick-access {
|
||||
z-index: 0;
|
||||
}
|
||||
#header #header-main-bar {
|
||||
/* Flex row (was float-based) so the right button group — which ends with the
|
||||
sidebar hamburger — is always pinned to the right edge at every window width,
|
||||
@@ -247,7 +259,7 @@
|
||||
float: inline-start;
|
||||
display: block;
|
||||
line-height: calc(28px * var(--wekan-ui-font-scale, 1));
|
||||
color: #27ae60;
|
||||
color: #217d49;
|
||||
margin: 0 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,19 @@ template(name="header")
|
||||
div.header-logo
|
||||
img(src="{{pathFor '/logo-header.png'}}" alt="{{headerLogoAlt}}" title="{{headerLogoAlt}}")
|
||||
|
||||
//- Full screen, to the right of the logo (iPad Safari, desktop and Android
|
||||
browsers). Only drawn where the browser has the Fullscreen API and the page
|
||||
may use it: not on iPhone, not in an iframe without allowfullscreen. The
|
||||
label is the existing "Maximize" one. It stays before the first
|
||||
.js-header-collapsible-icon, so "hide the icons" does not hide it.
|
||||
models/lib/fullscreen.js
|
||||
if fullscreenSupported
|
||||
a.board-header-btn.js-toggle-fullscreen(href="#" role="button" title="{{#if isFullscreen}}{{_ 'minimize-window'}}{{else}}{{_ 'maximize-window'}}{{/if}}" aria-label="{{#if isFullscreen}}{{_ 'minimize-window'}}{{else}}{{_ 'maximize-window'}}{{/if}}" aria-pressed="{{#if isFullscreen}}true{{else}}false{{/if}}")
|
||||
if isFullscreen
|
||||
i.fa.fa-compress
|
||||
else
|
||||
i.fa.fa-expand
|
||||
|
||||
// Mobile/desktop mode toggle (swapped with zoom)
|
||||
// #6680 follow-up: .js-header-collapsible-icon marks every icon the new
|
||||
// .js-toggle-header-icons-collapsed button (beside Home) hides as a
|
||||
|
||||
@@ -59,6 +59,7 @@ function customPageTitle(routeName) {
|
||||
// Drag-to-scroll on the two top header bars (they are not scroll containers
|
||||
// themselves, so the drag is forwarded to the board canvas / page scroller).
|
||||
import '/client/lib/headerDragscroll';
|
||||
const fullscreen = require('/models/lib/fullscreen');
|
||||
|
||||
Meteor.subscribe('user-admin');
|
||||
Meteor.subscribe('boards');
|
||||
@@ -70,6 +71,10 @@ Meteor.subscribe('setting');
|
||||
Meteor.subscribe('announcements');
|
||||
Template.header.onCreated(function () {
|
||||
const templateInstance = this;
|
||||
// Full screen: whether the browser offers it here, and whether the page is in it.
|
||||
// Both are set when the header is drawn (onRendered), where there is a document.
|
||||
templateInstance.fullscreenSupported = new ReactiveVar(false);
|
||||
templateInstance.isFullscreen = new ReactiveVar(false);
|
||||
templateInstance.currentSetting = new ReactiveVar();
|
||||
templateInstance.isLoading = new ReactiveVar(false);
|
||||
templateInstance.subscribe('setting');
|
||||
@@ -83,6 +88,14 @@ Template.header.onCreated(function () {
|
||||
|
||||
});
|
||||
Template.header.onRendered(function () {
|
||||
// The Fullscreen API, where it exists and the page may use it (models/lib/fullscreen.js).
|
||||
// The state follows the browser's own events, so leaving with Esc, or with the
|
||||
// browser's own control, changes the icon too.
|
||||
this.fullscreenSupported.set(fullscreen.isSupported(document));
|
||||
this.isFullscreen.set(fullscreen.isFullscreen(document));
|
||||
this.onFullscreenChange = () => this.isFullscreen.set(fullscreen.isFullscreen(document));
|
||||
fullscreen.CHANGE_EVENTS.forEach(name => document.addEventListener(name, this.onFullscreenChange));
|
||||
|
||||
this.autorun(() => {
|
||||
Meteor.userId();
|
||||
FlowRouter.watchPathChange();
|
||||
@@ -92,7 +105,19 @@ Template.header.onRendered(function () {
|
||||
});
|
||||
});
|
||||
|
||||
Template.header.onDestroyed(function () {
|
||||
if (this.onFullscreenChange) {
|
||||
fullscreen.CHANGE_EVENTS.forEach(name => document.removeEventListener(name, this.onFullscreenChange));
|
||||
}
|
||||
});
|
||||
|
||||
Template.header.helpers({
|
||||
fullscreenSupported() {
|
||||
return Template.instance().fullscreenSupported.get();
|
||||
},
|
||||
isFullscreen() {
|
||||
return Template.instance().isFullscreen.get();
|
||||
},
|
||||
logoSettingsReady() {
|
||||
return Template.instance().subscriptionsReady();
|
||||
},
|
||||
@@ -365,6 +390,13 @@ Template.header.helpers({
|
||||
});
|
||||
|
||||
Template.header.events({
|
||||
// Full screen on and off. The request is made inside this click, which is the
|
||||
// user gesture browsers require; a refusal is not an error (models/lib/fullscreen.js).
|
||||
'click .js-toggle-fullscreen'(event) {
|
||||
event.preventDefault();
|
||||
fullscreen.toggle(document);
|
||||
},
|
||||
|
||||
// The starred boards, by name. They were listed inline in this bar; the bar
|
||||
// is one row now and the names are in the popup. docs/Features/Header-Bar/Header.md
|
||||
// Titled "Starred Boards", from the key the app already has for that phrase,
|
||||
|
||||
@@ -49,6 +49,20 @@ summary:focus-visible,
|
||||
box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.35);
|
||||
border-radius: 2px;
|
||||
}
|
||||
/* #6755: a member who turned off "Show focus outline" (Member Settings / Change
|
||||
* Settings) sees no ring: the outline and its halo, for keyboard focus and for the
|
||||
* fallback below. Field borders and the colour a button takes on focus are not the
|
||||
* ring and stay. The ring is an accessibility feature, so it is on unless hidden. */
|
||||
body.has-hide-focus-outline :focus-visible,
|
||||
body.has-hide-focus-outline a:focus,
|
||||
body.has-hide-focus-outline button:focus,
|
||||
body.has-hide-focus-outline [tabindex]:focus {
|
||||
outline: none !important;
|
||||
outline-offset: 0 !important;
|
||||
}
|
||||
body.has-hide-focus-outline :is(a, button, input, select, textarea, summary, [tabindex], [role="button"], [contenteditable="true"]):focus-visible {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
/* Browsers that do not support :focus-visible fall back to :focus so keyboard
|
||||
* users are never left without an indicator. */
|
||||
.no-focus-visible-support a:focus,
|
||||
@@ -378,14 +392,14 @@ h6 {
|
||||
}
|
||||
.quiet,
|
||||
.quiet a {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
.error,
|
||||
.error a {
|
||||
color: #eb3800;
|
||||
}
|
||||
.no-items-message {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
margin: 36px 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1133,6 +1147,77 @@ a:not(.disabled).is-active i.fa {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* The administrator's login page background colour (layouts.js sets the two
|
||||
custom properties and the class while the sign-in layout is shown). The
|
||||
foreground is white on a dark colour and black on a light one. */
|
||||
body.has-login-background {
|
||||
background: var(--login-background);
|
||||
}
|
||||
body.has-login-background .textBelowCustomLoginLogo,
|
||||
body.has-login-background .textBelowCustomLoginLogo a {
|
||||
color: var(--login-foreground);
|
||||
}
|
||||
body.has-login-background hr {
|
||||
border-color: var(--login-foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* The text under the login logo: one column, real headings, space between
|
||||
paragraphs. It has no card or background of its own, so the page colour shows
|
||||
through. */
|
||||
.textBelowCustomLoginLogo > * {
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.textBelowCustomLoginLogo h1,
|
||||
.textBelowCustomLoginLogo h2,
|
||||
.textBelowCustomLoginLogo h3,
|
||||
.support-page-content h1,
|
||||
.support-page-content h2,
|
||||
.support-page-content h3 {
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
margin: 1em 0 0.5em;
|
||||
}
|
||||
.textBelowCustomLoginLogo h1,
|
||||
.support-page-content h1 { font-size: 1.8em; }
|
||||
.textBelowCustomLoginLogo h2,
|
||||
.support-page-content h2 { font-size: 1.45em; }
|
||||
.textBelowCustomLoginLogo h3,
|
||||
.support-page-content h3 { font-size: 1.2em; }
|
||||
.textBelowCustomLoginLogo p,
|
||||
.textBelowCustomLoginLogo ul,
|
||||
.textBelowCustomLoginLogo ol,
|
||||
.support-page-content p,
|
||||
.support-page-content ul,
|
||||
.support-page-content ol {
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
/* The support page: a centred column instead of text flush against the window's
|
||||
left edge. */
|
||||
.support-page {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.support-page-content {
|
||||
box-sizing: border-box;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 28px;
|
||||
background: #fff;
|
||||
color: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.support-page {
|
||||
padding: 8px;
|
||||
}
|
||||
.support-page-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
.auth-layout .auth-dialog {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
const { OAUTH_PROVIDERS, providerByKey } = require('/models/lib/oauthProviders');
|
||||
const { loginOriginMismatch, offersProviderLogin } = require('/models/lib/loginOriginMismatch');
|
||||
const { loginBackgroundStyle } = require('/models/lib/loginBackground');
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import Users from '/models/users';
|
||||
@@ -672,6 +673,34 @@ Template.userFormsLayout.onDestroyed(function () {
|
||||
this.removeAuthenticationSubmit?.();
|
||||
});
|
||||
|
||||
// The administrator's login page background colour (Admin Panel -> Lay-out).
|
||||
// It is put on <body> only while the sign-in layout is shown, as two custom
|
||||
// properties and a class, and taken off again when it closes so the pages after
|
||||
// sign-in keep their own background. loginBackgroundStyle accepts a hex colour
|
||||
// only, so the value can never carry CSS of its own.
|
||||
const LOGIN_BACKGROUND_PROPERTIES = ['--login-background', '--login-foreground'];
|
||||
|
||||
function clearLoginBackground() {
|
||||
document.body.classList.remove('has-login-background');
|
||||
LOGIN_BACKGROUND_PROPERTIES.forEach(name => document.body.style.removeProperty(name));
|
||||
}
|
||||
|
||||
Template.userFormsLayout.onRendered(function () {
|
||||
this.autorun(() => {
|
||||
const setting = this.currentSetting.get();
|
||||
const style = loginBackgroundStyle(setting && setting.loginBackgroundColor);
|
||||
if (!style) {
|
||||
clearLoginBackground();
|
||||
return;
|
||||
}
|
||||
document.body.style.setProperty('--login-background', style.background);
|
||||
document.body.style.setProperty('--login-foreground', style.foreground);
|
||||
document.body.classList.add('has-login-background');
|
||||
});
|
||||
});
|
||||
|
||||
Template.userFormsLayout.onDestroyed(clearLoginBackground);
|
||||
|
||||
// The form's title ("Sign In", "Create an Account") is an h3 of the useraccounts
|
||||
// package and the first heading of the page: a level-one heading, whatever the
|
||||
// package draws it as (WCAG 1.3.1; axe page-has-heading-one and heading-order).
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
first row of a popup, and it must match the space beside it: `.content` has 18px of
|
||||
side padding, so 6 + 12 = 18 makes the top gap and the left gutter the same. */
|
||||
.pop-over .header {
|
||||
/* A popup is moved by its title bar (popup.js, Pointer Events). Without this a
|
||||
finger drag on it is taken for a scroll and the pointer is cancelled, so only a
|
||||
mouse could move it. */
|
||||
touch-action: none;
|
||||
height: calc(41px * var(--wekan-ui-font-scale, 1));
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
@@ -822,7 +826,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
background: #fff;
|
||||
}
|
||||
.pop-over-list li > a .sub-name {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
display: block;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: 400;
|
||||
@@ -867,7 +871,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
background-color: #2e85b8;
|
||||
}
|
||||
.pop-over-list li > a.disabled {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
cursor: default;
|
||||
}
|
||||
.pop-over-list li > a.disabled .vis-icon {
|
||||
@@ -878,7 +882,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
}
|
||||
.pop-over-list li > a.disabled:hover .sub-name,
|
||||
.pop-over-list li > a.disabled:hover .quiet {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
.pop-over-list li > a.disabled:active {
|
||||
background: none;
|
||||
@@ -1441,6 +1445,7 @@ body.mobile-mode .pop-over .content-wrapper {
|
||||
button (popup.js MOVABLE_POPUPS / MAXIMIZABLE_POPUPS), placed like the opened
|
||||
card's: the handle before the title, the button beside the close button. */
|
||||
.pop-over .header .popup-drag-handle {
|
||||
touch-action: none;
|
||||
float: inline-start;
|
||||
padding: 12px 0 12px 12px;
|
||||
cursor: move;
|
||||
|
||||
@@ -43,3 +43,6 @@ template(name="themeColorPicker")
|
||||
input.js-theme-wheel(type="color" data-index="{{index}}" value="{{value}}" title="{{_ 'custom-color'}}")
|
||||
.theme-preview
|
||||
span.background-box(class="{{previewClass}}" style="{{previewStyle}}")
|
||||
//- WCAG 1.4.3: a colour that white text cannot be read on is darkened; this says so.
|
||||
if customAdjusted
|
||||
p.theme-custom-adjusted.js-theme-custom-adjusted {{_ 'custom-color'}}: {{customAdjusted}} (4.5:1)
|
||||
@@ -2,7 +2,7 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { isHexColor, toHex } from '/models/lib/contrastColor';
|
||||
import { isHexColor, toHex, readableAccent } from '/models/lib/contrastColor';
|
||||
import { BOARD_COLORS } from '/models/metadata/colors';
|
||||
import {
|
||||
THEME_CATEGORY_ORDER,
|
||||
@@ -138,7 +138,10 @@ Template.themeColorPicker.helpers({
|
||||
const tpl = Template.instance();
|
||||
const cat = customCategory(tpl);
|
||||
if (!allowsCustomColor(cat)) return '';
|
||||
const cc = tpl.customColors.get();
|
||||
// The colours as the theme applies them: made readable under white text
|
||||
// (models/lib/themeAccents.js activeAccent), so the preview is what the header
|
||||
// will be, not what the wheel showed.
|
||||
const cc = tpl.customColors.get().map(c => (c ? readableAccent(c) : c));
|
||||
if (!cc.some(Boolean)) return '';
|
||||
if (customColorCount(cat) === 2 && cc[0] && cc[1]) {
|
||||
return `background: linear-gradient(135deg, ${cc[0]}, ${cc[1]}) !important;`;
|
||||
@@ -146,6 +149,13 @@ Template.themeColorPicker.helpers({
|
||||
if (cc[0]) return `background: ${cc[0]} !important;`;
|
||||
return '';
|
||||
},
|
||||
// The colours that had to change to be readable, for the note under the preview.
|
||||
customAdjusted() {
|
||||
const tpl = Template.instance();
|
||||
const chosen = tpl.customColors.get().filter(Boolean).map(c => toHex(c)).filter(Boolean);
|
||||
const changed = chosen.map(c => readableAccent(c)).filter((c, i) => c !== chosen[i]);
|
||||
return changed.join(' ');
|
||||
},
|
||||
});
|
||||
|
||||
// Collect the custom colors to save: [] unless the user actually engaged a wheel, in
|
||||
|
||||
@@ -61,9 +61,20 @@ html.has-ui-font-size {
|
||||
* follow this anyway; the ones with a colour of their own - a red alert, a
|
||||
* green tick - keep saying what they mean. */
|
||||
body.has-ui-text-color,
|
||||
body.has-ui-text-color *:not(.fa) {
|
||||
body.has-ui-text-color *:not(.fa):not(
|
||||
#header, #header *,
|
||||
#header-quick-access, #header-quick-access *,
|
||||
.card-label, .card-label *,
|
||||
button.primary, button.primary *
|
||||
) {
|
||||
color: var(--wekan-ui-text-color) !important;
|
||||
}
|
||||
/* The elements left out of the rule above sit on a COLOUR of their own - the
|
||||
* theme's header bars, a label's colour, a primary button's accent - with a text
|
||||
* colour that is chosen to read on it (4.5:1, WCAG 1.4.3). The member's colour is
|
||||
* made legible on the page's surfaces (models/lib/uiFonts.js readableUiTextColor),
|
||||
* which is not the same as legible on those: #626262 on the blue header was
|
||||
* 1.18:1. */
|
||||
|
||||
/* There was a "text background color" here (#4759), a colour painted behind the
|
||||
* text. It is REMOVED. A background needs elements to sit on, and neither choice
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import { fontFamilyValue, fontSizeValue, fontScaleValue, colorValue } from '/models/lib/uiFonts';
|
||||
import { fontFamilyValue, fontSizeValue, fontScaleValue, readableUiTextColor } from '/models/lib/uiFonts';
|
||||
import { activeThemeName } from '/client/lib/activeTheme';
|
||||
|
||||
// #4759: apply the user's chosen UI font AND font-size preset to the whole UI by
|
||||
// setting CSS variables on :root and toggling marker classes on <body> (uiFont.css
|
||||
@@ -37,6 +38,17 @@ Meteor.startup(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// #6755: a member who hid the focus outline (Member Settings / Change Settings):
|
||||
// a class on <body>, which layouts.css turns into no ring.
|
||||
Tracker.autorun(() => {
|
||||
const user = Meteor.user();
|
||||
try {
|
||||
document.body.classList.toggle('has-hide-focus-outline', !!(user && user.profile && user.profile.hideFocusOutline === true));
|
||||
} catch (_) {
|
||||
// document.body may not exist yet in exotic embeddings; ignore.
|
||||
}
|
||||
});
|
||||
|
||||
Tracker.autorun(() => {
|
||||
const user = Meteor.user();
|
||||
const profile = (user && user.profile) || {};
|
||||
@@ -50,7 +62,9 @@ Meteor.startup(() => {
|
||||
// of it that happened to be written in rem. No class goes with it - a
|
||||
// variable that is not set falls back to 1 in the calc itself.
|
||||
toggle('--wekan-ui-font-scale', null, fontScaleValue(profile.uiFontSize));
|
||||
toggle('--wekan-ui-text-color', 'has-ui-text-color', colorValue(profile.uiTextColor));
|
||||
// The chosen colour, made legible on the surface of the theme in use (4.5:1,
|
||||
// WCAG 1.4.3): a colour that already reads is applied as chosen.
|
||||
toggle('--wekan-ui-text-color', 'has-ui-text-color', readableUiTextColor(profile.uiTextColor, activeThemeName()));
|
||||
// There was a `--wekan-ui-bg-color` here, for "text background color". It is
|
||||
// removed: a colour behind the text needs elements to sit on, and there is no
|
||||
// set of them that looks right - on the boxes it painted the page, on the text
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
}
|
||||
/* Drag-and-drop workflow editor */
|
||||
.rules-workflow .workflow-help {
|
||||
color: #5a5c61;
|
||||
color: var(--wekan-quiet-text, #5a5c61);
|
||||
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
@@ -284,7 +284,7 @@
|
||||
display: flex;
|
||||
}
|
||||
.triggers-content {
|
||||
color: #5a5c61;
|
||||
color: var(--wekan-quiet-text, #5a5c61);
|
||||
background: #dedede;
|
||||
}
|
||||
.triggers-content .triggers-body {
|
||||
@@ -437,7 +437,7 @@
|
||||
}
|
||||
.rules-import-export .rules-ie-hint {
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
color: #5a5c61;
|
||||
color: var(--wekan-quiet-text, #5a5c61);
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
.rules-import-export .rules-ie-textarea {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
height: 100%;
|
||||
}
|
||||
.setting-content {
|
||||
color: #5a5c61;
|
||||
color: var(--wekan-quiet-text, #5a5c61);
|
||||
background: #dedede;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -630,6 +630,10 @@ template(name='tableVisibilityModeSettings')
|
||||
.title {{_ 'text-below-custom-login-logo'}}
|
||||
.form-group
|
||||
textarea#text-below-custom-login-logo.wekan-form-control(data-editor-submit=".js-visibility-logo-save")= currentSetting.textBelowCustomLoginLogo
|
||||
li.tableVisibilityMode-form
|
||||
.title {{_ 'login-background-color'}}
|
||||
.form-group
|
||||
input.wekan-form-control#login-background-color(type="text", placeholder="#1b365d" value="{{currentSetting.loginBackgroundColor}}")
|
||||
li.tableVisibilityMode-form
|
||||
.title {{_ 'custom-top-left-corner-logo-image-url'}}
|
||||
.form-group
|
||||
|
||||
@@ -811,6 +811,7 @@ Template.tableVisibilityModeSettings.events({
|
||||
['#custom-login-logo-image-url', 'customLoginLogoImageUrl'],
|
||||
['#custom-login-logo-link-url', 'customLoginLogoLinkUrl'],
|
||||
['#text-below-custom-login-logo', 'textBelowCustomLoginLogo'],
|
||||
['#login-background-color', 'loginBackgroundColor'],
|
||||
['#custom-top-left-corner-logo-image-url', 'customTopLeftCornerLogoImageUrl'],
|
||||
['#custom-top-left-corner-logo-link-url', 'customTopLeftCornerLogoLinkUrl'],
|
||||
['#custom-top-left-corner-logo-height', 'customTopLeftCornerLogoHeight'],
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
display: none;
|
||||
}
|
||||
.sidebar .sidebar-content h3 {
|
||||
color: #808080;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-size: 1em;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -1039,12 +1039,30 @@ Template.addListInline.events({
|
||||
// #2809
|
||||
'click .js-link-list': Popup.open('linkList'),
|
||||
'click .js-close-add-list-inline'() {
|
||||
Session.set('wekan-add-list-after', null);
|
||||
Session.set('wekan-add-list-swimlane', null);
|
||||
Session.set('wekan-add-list-empty', null);
|
||||
closeAddListComposer();
|
||||
},
|
||||
});
|
||||
|
||||
// Close the inline add-list composer (the one opened after a list, or in an empty
|
||||
// swimlane). The X in the composer and Escape do the same thing.
|
||||
function closeAddListComposer() {
|
||||
Session.set('wekan-add-list-after', null);
|
||||
Session.set('wekan-add-list-swimlane', null);
|
||||
Session.set('wekan-add-list-empty', null);
|
||||
}
|
||||
|
||||
// #6865: Escape closes the add-list composer. Only the X did: the composer stays
|
||||
// open after a list is created, so one can add several, and then Escape did
|
||||
// nothing. It is closed at the level of an inlined form (below popups and modals,
|
||||
// above the card's details and the sidebar), and not by a click elsewhere, as an
|
||||
// inlined form is not.
|
||||
EscapeActions.register(
|
||||
'inlinedForm',
|
||||
closeAddListComposer,
|
||||
() => !!(Session.get('wekan-add-list-after') || Session.get('wekan-add-list-empty')),
|
||||
{ enabledOnClick: false },
|
||||
);
|
||||
|
||||
Template.listsGroup.helpers({
|
||||
// #6745: board-role checks only (see listHeader.js) - not the whole user.
|
||||
currentUser() {
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
color: #595959 !important;
|
||||
color: var(--wekan-quiet-text, #595959) !important;
|
||||
}
|
||||
/* Outweighs .pop-over-list li > a:not(.disabled):hover, which would otherwise
|
||||
paint the delete button with the generic blue row-hover colour. */
|
||||
@@ -149,7 +149,7 @@
|
||||
background-color: #b04632;
|
||||
}
|
||||
.avatar-upload-hints {
|
||||
color: #595959;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
line-height: calc(16px * var(--wekan-ui-font-scale, 1));
|
||||
margin: 6px 0;
|
||||
|
||||
@@ -410,6 +410,9 @@ template(name="changeFontPopup")
|
||||
.color-wheel-wrap
|
||||
input.js-ui-text-color(type="color" value=textColorHex title="{{_ 'text-color'}}")
|
||||
a.js-reset-text-color(class="{{#unless hasTextColor}}disabled{{/unless}}") {{_ 'remove-btn'}}
|
||||
//- WCAG 1.4.3: the colour is made legible on the theme's surface; this says so.
|
||||
if textColorAdjusted
|
||||
p.ui-text-color-adjusted.js-ui-text-color-adjusted {{_ 'text-color'}}: {{readableTextColor}} (4.5:1)
|
||||
|
||||
template(name="changeSettingsPopup")
|
||||
//- #6835: its own class - a popup keeps the screens before it (the member
|
||||
@@ -436,6 +439,13 @@ template(name="changeSettingsPopup")
|
||||
| {{_ 'keep-title-tags'}}
|
||||
if isKeepTitleTags
|
||||
i.fa.fa-check
|
||||
//- #6755: the blue focus outline (an accessibility feature, so on by default).
|
||||
li
|
||||
a.js-toggle-hide-focus-outline
|
||||
i.fa.fa-square-o
|
||||
| {{_ 'show-focus-outline'}}
|
||||
if isShowFocusOutline
|
||||
i.fa.fa-check
|
||||
//- #6827: the Add card form stays open after a card is added.
|
||||
li
|
||||
a.js-toggle-keep-add-card-form-open(title="{{_ 'keep-add-card-form-open-description'}}")
|
||||
|
||||
@@ -9,7 +9,8 @@ import Users from '/models/users';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { detectAvailableFonts } from '/client/lib/fontDetector';
|
||||
import { fontFamilyValue, fontSizeValue, UI_FONT_SIZES, isHexColor6, colorValue } from '/models/lib/uiFonts';
|
||||
import { fontFamilyValue, fontSizeValue, UI_FONT_SIZES, isHexColor6, colorValue, readableUiTextColor } from '/models/lib/uiFonts';
|
||||
import { activeThemeName } from '/client/lib/activeTheme';
|
||||
const { allBoardsPath, SECTION_ARCHIVE } = require('/models/lib/allBoardsUrls');
|
||||
|
||||
// WCAG 2.5.3 Label in Name: the link shows the avatar's initials (when the avatar
|
||||
@@ -502,6 +503,11 @@ Template.changeSettingsPopup.helpers({
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser ? currentUser.hasKeepTitleTags() : false;
|
||||
},
|
||||
// #6755: the outline is shown unless this member hid it.
|
||||
isShowFocusOutline() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser ? !currentUser.hasHideFocusOutline() : true;
|
||||
},
|
||||
isKeepAddCardFormOpen() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser ? currentUser.hasKeepAddCardFormOpen() : false;
|
||||
@@ -568,6 +574,9 @@ Template.changeSettingsPopup.events({
|
||||
'click .js-toggle-unread-changes'() {
|
||||
if (ReactiveCache.getCurrentUser()) Meteor.call('toggleUnreadChanges');
|
||||
},
|
||||
'click .js-toggle-hide-focus-outline'() {
|
||||
if (ReactiveCache.getCurrentUser()) Meteor.call('toggleHideFocusOutline');
|
||||
},
|
||||
'click .js-toggle-keep-add-card-form-open'() {
|
||||
if (ReactiveCache.getCurrentUser()) Meteor.call('toggleKeepAddCardFormOpen');
|
||||
},
|
||||
@@ -666,12 +675,22 @@ Template.changeFontPopup.helpers({
|
||||
hasTextColor() {
|
||||
return !!Template.instance().textColor.get();
|
||||
},
|
||||
// The chosen text color as it is applied: made legible on the theme's surface
|
||||
// (4.5:1), and whether that changed it, so the member is told why the page does
|
||||
// not show exactly what the wheel did.
|
||||
readableTextColor() {
|
||||
return readableUiTextColor(Template.instance().textColor.get(), activeThemeName());
|
||||
},
|
||||
textColorAdjusted() {
|
||||
const chosen = colorValue(Template.instance().textColor.get());
|
||||
return !!chosen && readableUiTextColor(chosen, activeThemeName()) !== chosen.toLowerCase();
|
||||
},
|
||||
// Preview reflects the chosen font, size and text color.
|
||||
previewStyle() {
|
||||
const tpl = Template.instance();
|
||||
const family = fontFamilyValue(tpl.selected.get());
|
||||
const size = fontSizeValue(tpl.selectedSize.get());
|
||||
const color = colorValue(tpl.textColor.get());
|
||||
const color = readableUiTextColor(tpl.textColor.get(), activeThemeName());
|
||||
return [
|
||||
family && `font-family: ${family};`,
|
||||
size && `font-size: ${size};`,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Session } from 'meteor/session';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
|
||||
// The name of the theme the page is drawn in, in the order of themes of
|
||||
// client/components/main/globalThemeColor.js: the member's own override, then the
|
||||
// board's own colour on a board page, then the site theme. '' for WeKan's default.
|
||||
// Reactive, so a Tracker.autorun that reads it follows a change of theme.
|
||||
export function activeThemeName() {
|
||||
const user = Meteor.user();
|
||||
const own = user && user.profile && user.profile.globalThemeColor;
|
||||
if (own) return own;
|
||||
const boardId = Session.get('currentBoard');
|
||||
const board = boardId ? ReactiveCache.getBoard(boardId) : null;
|
||||
if (board && board.color) return board.color;
|
||||
const setting = ReactiveCache.getCurrentSetting();
|
||||
return (!board && setting && setting.themeColor) || '';
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
|
||||
// #6870: whether the administrator blocked attachment uploads for the whole site
|
||||
// (models/lib/attachmentUploadPolicy.js). Asked of the server once a minute at most; until it
|
||||
// answers - or if it cannot - uploads are shown as before, because the server refuses them either
|
||||
// way and a wrong guess here only costs a button.
|
||||
const blocked = new ReactiveVar(false);
|
||||
let askedAt = 0;
|
||||
|
||||
export function refreshUploadPolicy() {
|
||||
if (Date.now() - askedAt < 60 * 1000) return;
|
||||
askedAt = Date.now();
|
||||
Meteor.callAsync('attachmentUploadPolicy')
|
||||
.then(policy => blocked.set(!!(policy && policy.uploadsBlocked === true)))
|
||||
.catch(() => { askedAt = 0; });
|
||||
}
|
||||
|
||||
// reactive
|
||||
export function attachmentUploadsBlocked() {
|
||||
refreshUploadPolicy();
|
||||
return blocked.get();
|
||||
}
|
||||
+10
-29
@@ -74,35 +74,16 @@ hotkeys.filter = (event) => {
|
||||
};
|
||||
|
||||
// Handle non-Latin keyboards
|
||||
window.addEventListener('keydown', (e) => {
|
||||
// Only handle event if coming from body
|
||||
if (e.target !== document.body) return;
|
||||
|
||||
// Only a character typed on a non-Latin layout: one non-ASCII character.
|
||||
// Comparing it with the key code's lowercase letter also matched every
|
||||
// SHIFTED Latin key ('D', '!', and 'Shift' itself), and re-dispatched it, so
|
||||
// each Shift shortcut ran twice: Shift+D opened the due date editor and the
|
||||
// copy clicked the same control again, closing it (#6750).
|
||||
if (typeof e.key !== 'string' || e.key.length !== 1 || e.key.charCodeAt(0) < 128) return;
|
||||
|
||||
// Trigger the corresponding action by dispatching a new event with the ASCII key
|
||||
const key = String.fromCharCode(e.which).toLowerCase();
|
||||
if (!/^[a-z0-9]$/.test(key)) return;
|
||||
// Create a synthetic event for hotkeys to handle, with the same modifiers,
|
||||
// so Shift shortcuts keep working on non-Latin layouts.
|
||||
const syntheticEvent = new KeyboardEvent('keydown', {
|
||||
key: key,
|
||||
keyCode: e.which,
|
||||
which: e.which,
|
||||
shiftKey: e.shiftKey,
|
||||
ctrlKey: e.ctrlKey,
|
||||
altKey: e.altKey,
|
||||
metaKey: e.metaKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
document.dispatchEvent(syntheticEvent);
|
||||
});
|
||||
//
|
||||
// Nothing to do here, on purpose (#6840). This used to re-dispatch a key typed on
|
||||
// another script (a Farsi or Cyrillic letter) as its Latin key, for the old
|
||||
// Mousetrap, which read the typed character. hotkeys-js does not: it resolves a
|
||||
// key from `event.code` ('KeyD') and `keyCode`, whatever character the layout
|
||||
// types, so it already runs the shortcut for the physical d key on any layout.
|
||||
// The copy then ran every shortcut a SECOND time - the due date editor opened
|
||||
// and the repeat clicked the same control, closing it again, so `d` "opens the
|
||||
// popup and closes it at a glance" on a Farsi keyboard (and Shift+D did the same
|
||||
// on a Latin one, #6750). tests/keyboardNonLatinOnce.test.cjs.
|
||||
|
||||
function getHoveredCardId() {
|
||||
const card = $('.js-minicard:hover').get(0);
|
||||
|
||||
@@ -20,6 +20,30 @@ only what changes: documents, files and log lines. It writes them seconds after
|
||||
they happen, to a directory that can be another disk, an NFS share or an
|
||||
[rclone mount](Rclone/Rclone.md).
|
||||
|
||||
## Several servers: one of them streams
|
||||
|
||||
When WeKan runs as several servers on one database (for example the replicas of a Kubernetes
|
||||
Deployment), **one of them runs the stream** and the others stand by
|
||||
([#6861](https://github.com/wekan/wekan/issues/6861)). They coordinate through a *lease*: one
|
||||
document, `continuousBackupLease`, holding the leader's id and an expiry.
|
||||
|
||||
- Every server with the feature switched on tries to take the lease. Taking it is one atomic
|
||||
database operation, so two servers can never both lead.
|
||||
- The leader renews it every 15 seconds. It lapses 45 seconds after the last renewal, so when the
|
||||
leader stops (a pod restart, a rollout, a crash) another server takes over within about a minute
|
||||
and continues from the position the stream saved in the target.
|
||||
- A server that finds it has lost the lease stops its stream, so two never write one target.
|
||||
- Saving the settings, switching the feature off and on, and a change saved on another server all
|
||||
converge through the same loop: the leader follows the change on its next tick.
|
||||
- A restore, and *fetch from the cloud*, must be the only writer: they take the lease from whoever
|
||||
holds it and wait one renewal, so the former leader has stopped before anything is written. The
|
||||
stream resumes through the loop afterwards.
|
||||
|
||||
An installation with one server is always its own leader, so nothing changes for it. The target
|
||||
directory is still one place: with several servers it must be on storage they all reach (a shared
|
||||
volume, NFS, an [rclone mount](Rclone/Rclone.md)), or the server that takes over starts a new
|
||||
generation instead of continuing.
|
||||
|
||||
## Licences: only copyfree code by default
|
||||
|
||||
The built-in engines are part of WeKan and use the MIT licence. They depend
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
The full-scale tables below were measured on 2026-10-23 with FerretDB v1.99.0.
|
||||
Every change in this section came after it (FerretDB v1.100.0 to v1.105.0,
|
||||
2026-10-24 to 2026-10-31, WeKan up to 2026-11-02), so **those tables do not show
|
||||
them**; [Results.md](Results.md) is a new, small-scale run that does. The
|
||||
figures quoted in this section are the ones in FerretDB's own
|
||||
[CHANGELOG](https://github.com/wekan/FerretDB/blob/main-v1/CHANGELOG.md), each
|
||||
measured on a smaller database (61,507 cards, 92,000 boards or the 615,070 cards
|
||||
of this test, as stated), so they show the size of a change, not a place in the
|
||||
table.
|
||||
|
||||
# WeKan database speed test
|
||||
|
||||
How fast does WeKan's database answer the queries WeKan actually makes - on
|
||||
@@ -24,6 +34,9 @@ exist, which images they use or how they are started:
|
||||
| FerretDB v1 + PostgreSQL | `postgres:18`, the image of [docker-compose-ferretdb-v1-postgresql.yml](../../../../../docker-compose-ferretdb-v1-postgresql.yml) |
|
||||
| FerretDB v1 + MySQL | `mysql:9`, from [docker-compose-ferretdb-v1-mysql.yml](../../../../../docker-compose-ferretdb-v1-mysql.yml) |
|
||||
| FerretDB v1 + MariaDB | `mariadb:12`, from [docker-compose-ferretdb-v1-mariadb.yml](../../../../../docker-compose-ferretdb-v1-mariadb.yml) |
|
||||
| FerretDB v1 + pgrust | `malisper/pgrust:v0.4-beta`: [PostgreSQL rewritten in Rust](https://github.com/malisper/pgrust), wire compatible, so FerretDB's PostgreSQL handler talks to it unchanged. In the conformance run only with `WEKAN_CONFORMANCE_PGRUST=1` |
|
||||
| FerretDB v1 + TiDB | `pingcap/tidb:latest` (Apache-2.0, MySQL protocol) as one process on its own storage, no PD or TiKV. In the conformance run only with `WEKAN_CONFORMANCE_TIDB=1` |
|
||||
| FerretDB v1 + Dolt | `dolthub/dolt-sql-server:latest`: [git for data](https://github.com/dolthub/dolt), MySQL protocol, run as it comes (no commit per write). In the conformance run only with `WEKAN_CONFORMANCE_DOLT=1`. What its version history gives WeKan's undo is in [VersionHistory](../VersionHistory/) |
|
||||
| MongoDB 6, 7, 8, 9 | `mongo:6.0`, `mongo:7` ([docker-compose-mongodb-v7.yml](../../../../../docker-compose-mongodb-v7.yml)), `mongo:8.0`, `mongo:9.0` - queried directly, no FerretDB |
|
||||
| FerretDB v1 + SAP HANA | only with `WEKAN_CONFORMANCE_HANA=1` (amd64, ~16 GB RAM, SAP's licence) |
|
||||
| FerretDB 2 + PostgreSQL with DocumentDB | `ghcr.io/ferretdb/ferretdb:2` and `ghcr.io/ferretdb/postgres-documentdb:17`, the images of [docker-compose-ferretdb-v2-postgresql.yml](../../../../../docker-compose-ferretdb-v2-postgresql.yml), on a network of their own; in the conformance run only with `WEKAN_CONFORMANCE_FERRETDB2=1` |
|
||||
@@ -59,7 +72,11 @@ the busy users.
|
||||
It is **deterministic**: a fixed seed and a seeded generator, never
|
||||
`Math.random()`, so every database is loaded with byte-for-byte identical
|
||||
documents - which is what makes it possible to ask whether they answer the same.
|
||||
`WEKAN_SPEEDTEST_SCALE` changes the size (1 = production, 0.05 for a quick try).
|
||||
`WEKAN_SPEEDTEST_SCALE` changes the size (1 = production, 2 = twice that, the
|
||||
default). **`WEKAN_SPEEDTEST_SCALE=0.05` is 1/20 of a production WeKan - 15,377
|
||||
cards - and the whole run, every database, takes under half an hour instead of
|
||||
hours** (with the images already pulled) (the SQLite indexes alone take 19 minutes at scale 2 and 7 seconds at
|
||||
0.05). Results.md says which scale it was measured at.
|
||||
|
||||
After loading, the test creates **the indexes WeKan creates**. They are not
|
||||
listed here: [indexes.cjs](indexes.cjs) reads every `ensureIndex(...)` call in
|
||||
@@ -135,6 +152,12 @@ Things to know when reading the numbers:
|
||||
That costs FerretDB some load and write time MongoDB does not pay here.
|
||||
- Each database runs with its image's default settings - what an administrator
|
||||
gets - not tuned.
|
||||
- Results.md was measured at scale 0.05 (see "The data"), with FerretDB v1.105.0
|
||||
plus the changes of "Improvements since the last run". A row's FerretDB commit
|
||||
is in its name; the rows differ because a database is re-run alone after a
|
||||
change to FerretDB, which does not touch the others. The full-scale tables
|
||||
further down are from 2026-10-23 (v1.98 and v1.99) and are kept as the record
|
||||
of what each change was worth.
|
||||
- A row's FerretDB commit is in its name: the databases were measured as their
|
||||
FerretDB fixes went in (the PostgreSQL row after its index fix, MySQL and
|
||||
MariaDB after their `_id` fix).
|
||||
@@ -208,28 +231,207 @@ and its board scope is a plain `boardId` list when no board is assigned-only. It
|
||||
docker-compose for FerretDB 2 could not start with the current DocumentDB image
|
||||
(`POSTGRES_DB` must be `postgres`).
|
||||
|
||||
## Improvements since the last run
|
||||
|
||||
[Results.md](Results.md) was measured on 2026-10-23 with FerretDB v1.99.0. Every
|
||||
change below came after it (FerretDB v1.100.0 to v1.105.0, 2026-10-24 to
|
||||
2026-10-31, and WeKan up to 2026-11-02), so **the tables in Results.md do not
|
||||
show them yet** - run the speed test again for full-scale numbers. The figures
|
||||
quoted here are the ones in FerretDB's own
|
||||
[CHANGELOG](https://github.com/wekan/FerretDB/blob/main-v1/CHANGELOG.md), each
|
||||
measured on a smaller database (61,507 cards, 92,000 boards or the 615,070 cards
|
||||
of this test, as stated), so they show the size of a change, not a place in the
|
||||
table.
|
||||
|
||||
**FerretDB on SQLite**
|
||||
|
||||
- **SQLite sorts in the database** (v1.100.0). A sorted query used to read,
|
||||
decode and keep every matching document in Go to return a few - People's ten
|
||||
newest users, the first page of a list. SQLite now orders the row ids by a key
|
||||
that reproduces MongoDB's order for simple values, and documents are fetched a
|
||||
batch at a time in that order: the ten newest of 30,000 users allocate 2.6 MB
|
||||
instead of 264 MB. A document whose sort field holds an array, a document, NaN
|
||||
or an infinity comes first, and then everything is sorted in Go as before, so
|
||||
the answer is the same. The key is read once per row, in one JSON path.
|
||||
- **Documents are stored as SQLite JSONB** (v1.101.0). Every filter, sort key,
|
||||
index and multikey trigger used to parse the JSON text of a document again.
|
||||
New collections are binary, and an existing database is converted once when
|
||||
it is opened, table by table in a transaction (204,212 documents took 81 s; a
|
||||
table that fails stays text and keeps working). On 61,507 cards, due cards went
|
||||
from 1,003 to 519 ms, All Boards from 95 to 42 ms and creating the 44 indexes
|
||||
from 107 to 70 s. After the conversion an older FerretDB cannot read the
|
||||
database.
|
||||
- **`distinct` works when documents lack the field** (v1.105.0), which a database
|
||||
migrated from MongoDB 3 needs on its first start.
|
||||
|
||||
- **A membership test reads the array from the column, not from the text of `->`**
|
||||
(found by profiling Due Cards, fixed after v1.105.0). `{members: {$elemMatch:
|
||||
{userId: X}}}` was `json_each(col->"members")`: `->` renders the whole array as
|
||||
JSON text and `json_each` parses that text again, for every row. It is now
|
||||
`json_each(col, '$."members"')`, which SQLite walks in place. The visible-boards
|
||||
query went from 26.2 to 15.7 ms on 2,308 boards, and in the speed test All Boards
|
||||
count from 6.9 to 4.9 ms, All Boards page from 5.9 to 3.9 ms and Due Cards from
|
||||
54.5 to 43.9 ms (the SQLite total 82.7 → 68.3 ms); the 169 answers of 12 queries
|
||||
are identical to before.
|
||||
|
||||
**FerretDB on PostgreSQL**
|
||||
|
||||
- **Membership tests use a GIN index** (v1.100.0): an index whose leading key is
|
||||
`ARRAY.FIELD` (`members.userId`) also gets a `jsonb_path_ops` GIN index of the
|
||||
array, and `{members: {$elemMatch: {userId: X}}}` uses it.
|
||||
- **`$or` and `$elemMatch` are conditions in the database** (v1.101.0), so Due
|
||||
Cards' query went from 2,031 to 815 ms on 61,507 cards.
|
||||
- **A top-level `$and` is pushed down** (v1.100.0). WeKan's Due Cards wraps its
|
||||
conditions in one, and every document was read.
|
||||
|
||||
- **A table is analyzed once its indexes are created** (found by the small run,
|
||||
fixed after v1.105.0). PostgreSQL has statistics for an expression index
|
||||
(`_jsonb->'field'`) only after `ANALYZE`, which autovacuum runs a minute or
|
||||
more after a bulk load. Before that the planner guessed **one row** for a Due
|
||||
Cards query that returns 2,000, and chose a plan with no parallelism: 407 ms
|
||||
on 15,377 cards, 84 ms after. The whole PostgreSQL row went from 454 ms to
|
||||
145 ms and from last place to eighth. The same `ANALYZE TABLE` was tried on
|
||||
MySQL and MariaDB and made no difference (100 and 279 ms before, 106 and 283
|
||||
after), so it was not kept.
|
||||
|
||||
**FerretDB on MySQL and MariaDB**
|
||||
|
||||
- **Reads use their indexes** (v1.100.0): an equality or `$in` narrows through
|
||||
the index's generated column. A card by `_id` among 615,070 went from 3.4 s
|
||||
(MySQL) and 18.7 s (MariaDB) to 1.8 ms. Generated columns are `VIRTUAL`, so
|
||||
adding the 44 indexes of a 600,000-document database takes 157 s, not 20
|
||||
minutes; an index of four or more fields indexes prefixes, so it fits the
|
||||
3,072-byte key limit that three of WeKan's compound indexes used to exceed;
|
||||
and a path with a dash or a digit (`services.accounts-lockout.unlockTime`, `profile.emailBuffer.0`) no longer
|
||||
fails with "Invalid JSON path expression".
|
||||
- **Multikey side tables** (v1.100.0), as on SQLite, so a membership test reads
|
||||
only the matching documents. Without the right to create triggers (MySQL with
|
||||
binary logging and no SUPER) nothing is created and queries read as before.
|
||||
- **An `$or` of `_id` sets is one `UNION ALL`** (v1.100.0): All Boards for a user
|
||||
with a starred board went from 900 ms (MySQL) and 5.7 s (MariaDB) among 92,000
|
||||
boards to 6-7 ms.
|
||||
- **A long `$in` is one `IN`** (v1.101.0), not 2,596 `JSON_CONTAINS` in an OR
|
||||
(52 million comparisons over 20,000 cards).
|
||||
- **Updates and deletes by `_id` use the `_id` index** (v1.99.0, listed above).
|
||||
|
||||
**WeKan** - less work for the database, which matters more the bigger it is:
|
||||
|
||||
- **Collection scans are gone from the hourly and startup paths.** The recurring
|
||||
cards job has a partial index on `recurrenceInterval` (it read 1,108,017 cards
|
||||
in 1,155 ms every hour); search sessions have the indexes their upsert and
|
||||
lookup use, and the stale sessions of every user are removed (one instance had
|
||||
4,751 taking 10 GB); the board key backfill is one `$in` on the key index; the
|
||||
list sync scan and the due-date lookup have partial and sparse indexes.
|
||||
- **One server does it, not all of them.** The startup schema re-check is claimed
|
||||
in `_wekan_migration`, the daily due-date reminder scan and the database
|
||||
health probe are claimed per period, so 24 replicas no longer scan, send or
|
||||
report 24 times.
|
||||
- **Admin Panel / Problems / Database** reports what makes an instance slow: a
|
||||
query that read a whole collection of at least 10,000 documents (which
|
||||
collection, the command, the operators per field and the `queryHash`), and a
|
||||
high read rate. The speed test's Due Cards and All Boards numbers say what a
|
||||
query costs on one database; this says which query an instance is actually
|
||||
asking.
|
||||
|
||||
## What could still be improved
|
||||
|
||||
- **Sorting and `limit` in the database.** FerretDB v1 sorts in Go: People's ten
|
||||
newest users, All Boards' first page and a card window read and sort every
|
||||
matching document. Pushing an ORDER BY down needs MongoDB's ordering across
|
||||
types, missing fields and arrays to be exact in SQL.
|
||||
- **Due Cards on SQLite, 44 ms of the 68 ms total.** A CPU profile of FerretDB
|
||||
while it ran Due Cards (taken before the change above): half the CPU was inside
|
||||
SQLite, and about a fifth of all of it converting JSON between binary and text
|
||||
(`_jsonTranslateBlobToText` 10%, `_jsonTranslateTextToBlob` 10%, besides
|
||||
`_jsonLookupStep`); another quarter was Go runtime (thread signalling,
|
||||
`madvise`, the network poller). Of the cards query's 24 ms, 18 ms is the index
|
||||
lookups (the 700-board `IN` list) and 6 ms the five per-user conditions
|
||||
(`members`, `assignees`, `requesters`, `assigners`, `userId`), each of which
|
||||
evaluates `->` three times per row. Reading each once, as
|
||||
`json_type(col,'$."f"') = 'array' OR col->>'$."f"' = ?`, took that step from
|
||||
24.4 to 20.5 ms with the same rows - **not done**, because it may only be applied
|
||||
to a field no index covers (an index is matched by the expression `col->"f"`),
|
||||
and FerretDB's WHERE builder does not know which fields are indexed.
|
||||
- **Tried on SQLite and not worth it, or not conclusive**: `ANALYZE` made no
|
||||
difference (FerretDB chooses its indexes itself); a Unix socket instead of TCP
|
||||
saves about 0.05 ms (20%) on a point read (0.24 → 0.19 ms), which is free when
|
||||
WeKan and FerretDB share a machine (`--listen-unix`); `GOGC=400` gave 10-15% on
|
||||
some queries in one test (board cards 5.3 → 4.4 ms) and nothing in another that
|
||||
also set a memory limit, so it is a lead, not a result.
|
||||
|
||||
- **The busiest users.** What remains of All Boards for a member of thousands of
|
||||
boards, and of Due Cards, is decoding those boards and sorting them in Go.
|
||||
- **SQLite stores JSON as text.** Every `->` parses the document again - which is
|
||||
also why creating an index takes 19 minutes for SQLite and 115 s for PostgreSQL,
|
||||
whose `jsonb` is binary. Storing SQLite's JSONB would remove that, but changes
|
||||
the storage format and needs a migration.
|
||||
- **Multikey indexes for PostgreSQL, MySQL and MariaDB.** Only SQLite has the side
|
||||
tables; All Boards takes 8-9 s on the others.
|
||||
- **MySQL and MariaDB reads do not use their indexes**: equality is
|
||||
`JSON_CONTAINS(JSON_EXTRACT(doc, ?))`, so a card by `_id` takes 3.4 s and 18.7 s
|
||||
(and an edit as long - it reads the card first). Their indexes are generated
|
||||
`VARCHAR(255)` columns added with a table rebuild each (20 and 40 minutes for
|
||||
44 indexes; VIRTUAL columns would avoid the rebuild), and three of WeKan's
|
||||
compound indexes exceed the 3,072-byte key limit and are not created.
|
||||
boards, and of Due Cards, is decoding those boards and sorting them in Go on
|
||||
the databases that do not sort in SQL.
|
||||
- **Sorting in the database on PostgreSQL, MySQL and MariaDB.** Only SQLite does
|
||||
it. PostgreSQL was tried (2026-11-03): an `ORDER BY` of type class, number and
|
||||
text (`COLLATE "C"`, MongoDB's byte order) with the documents that cannot be
|
||||
ordered first, as on SQLite, and the tests matched the Go sort over mixed
|
||||
types. It was **not faster**: the ten newest of 30,116 users took 52 ms, against
|
||||
38-49 ms for fetching, decoding and sorting them in Go, because the Go path is
|
||||
already fast after the decoding changes and the sort expressions cost about as
|
||||
much. It would only save memory and transfer, so it was not kept. A sort that
|
||||
wins needs an expression index on the sort field, which has to know the field
|
||||
in advance; MySQL and MariaDB have the same cost structure and no measurement
|
||||
that says otherwise.
|
||||
- **MariaDB stores JSON as text** that every JSON function parses again, so it
|
||||
keeps only what an index answers (`$or` and `$elemMatch` are not pushed down).
|
||||
- **Existing MySQL/MariaDB tables** keep their case-insensitive index columns
|
||||
until rebuilt.
|
||||
- **Measuring it again at full scale.** Results.md is scale 0.05. A full-scale
|
||||
run (`WEKAN_SPEEDTEST_SCALE=2`, hours) would show the busiest users, where the
|
||||
differences are largest.
|
||||
|
||||
## Other databases tried
|
||||
|
||||
Asked for: any other fast database that FerretDB can use and that is open source.
|
||||
FerretDB v1 has handlers for PostgreSQL, MySQL, SQLite and SAP HANA, so a
|
||||
candidate has to speak one of those protocols **and** run the SQL the handler
|
||||
writes: `jsonb` expression indexes, `@>` and GIN on PostgreSQL; `JSON`,
|
||||
`JSON_EXTRACT`, generated columns and triggers on MySQL.
|
||||
|
||||
| Database | Result |
|
||||
| --- | --- |
|
||||
| [pgrust](https://github.com/malisper/pgrust) v0.4-beta (PostgreSQL in Rust, AGPL-3.0) | **Works**, same answers as MongoDB, and is in Results.md. It is not faster than PostgreSQL for WeKan's queries: 174 ms against 145 ms in total, and creating the 47 indexes takes 38 s against 1 s. |
|
||||
| [TiDB](https://github.com/pingcap/tidb) 7.5 (Apache-2.0, MySQL protocol) | **Works**, same answers, and is in Results.md (240 ms, between pgrust and MariaDB). It needed one change in FerretDB: its metadata table's generated columns are `VIRTUAL`, because TiDB refuses `ALTER TABLE ... ADD COLUMN ... STORED`. This is one process with its own storage; a real cluster (PD and TiKV) is a different test. |
|
||||
| [Dolt](https://github.com/dolthub/dolt) 2.6.2 (git for data, Apache-2.0, MySQL protocol) | **Works with the existing MySQL handler, no new handler**, same answers as MongoDB, and is in Results.md - and is **by far the slowest: 20,470 ms in total, 1,230x MongoDB**. Every query over cards takes about 2.8 s on 15,377 cards (a whole-table scan, about 187 µs a document: the generated-column indexes the handler relies on do not narrow a read there), All Boards 850 ms, and creating the indexes 106 s. Its point is version history, not speed; see [VersionHistory](../VersionHistory/). |
|
||||
| [Noria](https://github.com/mit-pdos/noria) (MIT research prototype, Apache-2.0) | **Cannot run FerretDB.** It is a streaming dataflow system that precomputes the results of a fixed set of prepared queries. Its SQL has no `JSON` type (the parser's type list is `INT`, `VARCHAR`, `TEXT`, `BLOB`, `DATE`, ...), no triggers and no generated columns, and the MySQL handler needs all three: it uses `JSON` 79 times, `JSON_EXTRACT` 74 times and 7 generated columns. It also needs unpinned nightly Rust (2021) and ZooKeeper, and has not changed since October 2021. Not built or run. |
|
||||
| [YugabyteDB](https://github.com/yugabyte/yugabyte-db) 2026.1.2 (core Apache-2.0, PostgreSQL 15 compatible) | **Cannot run FerretDB.** Started, and FerretDB's first `CREATE INDEX` fails: `INDEX on column of type 'JSONB' not yet supported`. Every FerretDB v1 index on PostgreSQL is an expression of type `jsonb`, and so is every query that uses one; YugabyteDB can index a `jsonb` column only with GIN. Supporting it means writing the indexes and conditions as `->>` text, which is a different handler. |
|
||||
| [CockroachDB](https://github.com/cockroachdb/cockroach) | **Not tried: not open source.** Its LICENSE is the proprietary CockroachDB Software License, not an open-source one. |
|
||||
|
||||
Tried with the MySQL handler, to see how far it gets (both speak the MySQL protocol):
|
||||
|
||||
| Database | Result |
|
||||
| --- | --- |
|
||||
| [ClickHouse](https://github.com/ClickHouse/ClickHouse) 26.10 (Apache-2.0) | **Needs a handler of its own.** The first statement FerretDB sends through its MySQL interface fails: `sql: expected 0 arguments, got 2`. Behind that it has no `JSON_EXTRACT`, `JSON_CONTAINS`, triggers or generated columns as the handler uses them (it has `JSONExtract*`), and a row update is an asynchronous mutation, which is not what WeKan's edits and moves are. |
|
||||
| [StarRocks](https://github.com/StarRocks/starrocks) 4.1 (Apache-2.0) | **Needs a handler of its own.** The first `CREATE TABLE` fails: `Data type of first column cannot be JSON`; its tables need a key column and a distribution. An analytical engine again, with no triggers. |
|
||||
|
||||
A handler for either is a new backend of the size of the SAP HANA one (about
|
||||
2,000 lines of Go: query, insert, update, delete, indexes, statistics) plus its own
|
||||
SQL for every filter FerretDB can push down; without the pushdown a query reads
|
||||
the whole collection, which the speed test would measure as slow. That has not
|
||||
been written. Not tried at all: DuckDB (no server; the Go driver needs cgo, which
|
||||
FerretDB's builds do not use) and SingleStore (not open source, and its image
|
||||
needs a licence key). A MongoDB-protocol database needs no FerretDB and joins the
|
||||
MongoDB rows; FerretDB 2 with DocumentDB is already there.
|
||||
|
||||
## Opening a board in WeKan
|
||||
|
||||
The same databases, one level up: [app/](app/) opens a WeKan board of 200, 2,000 and
|
||||
5,000 cards on FerretDB (SQLite) and on MongoDB 8 and profiles both the Meteor server
|
||||
and FerretDB. The Meteor server's CPU is the same on both (0.1-0.5 s); on a 5,000-card
|
||||
board FerretDB (SQLite) takes **400-600 ms longer** (to the first card +42-48%), a
|
||||
small board only 70-100 ms, and it uses about 4 s of CPU to open the 5,000-card board,
|
||||
a third of it filtering in Go what it read; the browser receives the same ~210 KB at
|
||||
every size.
|
||||
|
||||
## Go databases linked into FerretDB
|
||||
|
||||
Could a database implemented in Go be linked into the FerretDB binary beside its
|
||||
SQLite, and be faster? [embedded/](embedded/) asks that of the
|
||||
[awesome-go list](https://github.com/avelino/awesome-go#databases-implemented-in-go):
|
||||
Pebble, Badger, bbolt, BuntDB, Nutsdb and Clover against SQLite set up as FerretDB
|
||||
sets it, on the speed test's cards, in minutes. **None is faster where it counts**:
|
||||
a board's cards take the same time on every engine that is not slower, because
|
||||
decoding the JSON dominates; the key-value stores win single reads and writes by
|
||||
microseconds that the 0.2 ms floor of a FerretDB call hides, and would make the
|
||||
handler do in Go what SQL does in SQLite. bbolt with its default fsync is 100-160x
|
||||
slower on writes, Pebble 12x slower on fetching a board, Clover 90-190x slower.
|
||||
|
||||
## FerretDB 2
|
||||
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
# WeKan database speed test results
|
||||
|
||||
Run 2026-10-23 on Intel(R) Core(TM) i7-8750H CPU @ 2.20GHz, 12 threads, 30 GiB RAM, Linux 7.2.6-070206-generic, by
|
||||
Run 2026-11-03 on Apple M2 Max, 12 threads, 32 GiB RAM, Darwin 27.2.0, by
|
||||
[speedtest.sh](speedtest.sh). How it works, and what was improved because of it:
|
||||
[the README](.).
|
||||
|
||||
The data is twice a production WeKan: 30,116 users, 92,306 boards, 221,038 lists, 222,546 swimlanes, 615,070 cards (production: 307,535 cards). Every database got identical
|
||||
The data is 1/20 of a production WeKan: 753 users, 2,308 boards, 5,526 lists, 5,564 swimlanes, 15,377 cards (production: 307,535 cards). Every database got identical
|
||||
documents and WeKan's own indexes, and was asked identical questions.
|
||||
|
||||
## At a glance
|
||||
|
||||
One number per database: the time of one call of each of the common WeKan queries below,
|
||||
added together. **Times slower** is that sum divided by the fastest database's. Ordered
|
||||
from fastest to slowest. The second pair of columns is the same for the slowest parameter -
|
||||
the biggest board or the busiest user, who waits longest.
|
||||
|
||||
| # | Database | Total (ms) | Times slower | Slowest user (ms) | Times slower |
|
||||
| ---: | --- | ---: | ---: | ---: | ---: |
|
||||
| 1 | MongoDB 9.0.2 | 16.6 | 1.0x | 45.1 | 1.0x |
|
||||
| 2 | MongoDB 8.0.32 | 17.4 | 1.0x | 48.8 | 1.1x |
|
||||
| 3 | MongoDB 6.0.28 | 18.0 | 1.1x | 50.2 | 1.1x |
|
||||
| 4 | MongoDB 7.0.43 | 18.1 | 1.1x | 50.1 | 1.1x |
|
||||
| 5 | FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | 57.9 | 3.5x | 154 | 3.4x |
|
||||
| 6 | FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | 68.3 | 4.1x | 219 | 4.9x |
|
||||
| 7 | FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | 103 | 6.2x | 415 | 9.2x |
|
||||
| 8 | FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | 145 | 8.7x | 483 | 11x |
|
||||
| 9 | FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | 174 | 10x | 594 | 13x |
|
||||
| 10 | FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | 240 | 14x | 877 | 19x |
|
||||
| 11 | FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | 290 | 17x | 1,063 | 24x |
|
||||
| 12 | FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | 20,470 | 1,230x | 20,904 | 464x |
|
||||
|
||||
## Speed
|
||||
|
||||
Milliseconds for one call - smaller is faster. Each query is asked with 15 parameters (the
|
||||
@@ -16,66 +38,80 @@ fastest database at the top.
|
||||
|
||||
| Database | Total | Login | User by id | All Boards count | All Boards page | Board by id | Board lists | Board swimlanes | Board cards | Card window | Card count | Cards per list | Card by id | Card search | Due Cards | People page | Edit card | Move card | Add card |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| MongoDB 9.0.4 | **495** | 0.75 | 0.66 | 5.76 | 16.4 | 0.68 | 1.06 | 0.98 | 47.7 | 0.77 | 1.61 | 2.62 | 0.66 | 5.34 | 394 | 13.6 | 0.77 | 1.16 | 1.00 |
|
||||
| MongoDB 6.0.28 | **562** | 0.78 | 0.73 | 5.49 | 21.0 | 0.83 | 1.01 | 0.93 | 48.2 | 0.89 | 2.01 | 4.38 | 0.74 | 4.76 | 447 | 20.9 | 0.83 | 0.90 | 0.77 |
|
||||
| MongoDB 7.0.45 | **565** | 0.86 | 0.74 | 6.29 | 23.7 | 0.79 | 1.07 | 1.00 | 48.9 | 0.87 | 2.00 | 4.35 | 0.72 | 5.52 | 445 | 20.7 | 0.79 | 0.77 | 0.76 |
|
||||
| MongoDB 8.0.34 | **577** | 0.81 | 0.69 | 6.83 | 16.8 | 0.77 | 1.19 | 1.01 | 48.5 | 0.88 | 1.90 | 2.87 | 0.69 | 6.28 | 470 | 15.6 | 0.78 | 0.89 | 0.78 |
|
||||
| FerretDB v1.99.0 (b6c494e9) + SQLite (modernc.org/sqlite v1.61.0) | **18,848** | 0.99 | 0.93 | 1,405 | 1,655 | 1.08 | 4.15 | 2.94 | 223 | 67.2 | 86.0 | 119 | 0.89 | 81.7 | 12,604 | 2,582 | 4.69 | 6.36 | 2.32 |
|
||||
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | **88,884** | 1.35 | 1.03 | 183 | 241 | 1.15 | 1.72 | 1.68 | 113 | 53.5 | 43.5 | 46.0 | 0.93 | 46.1 | 88,013 | 118 | 7.89 | 6.09 | 6.20 |
|
||||
| FerretDB v1.99.0 (b6c494e9) + MariaDB 12.3.3 | **97,536** | 1.76 | 2.05 | 11,389 | 11,396 | 1.82 | 3.41 | 2.43 | 189 | 2.43 | 171 | 173 | 1.79 | 171 | 73,217 | 791 | 9.24 | 8.49 | 6.68 |
|
||||
| FerretDB v1.99.0 (b6c494e9) + PostgreSQL 18.6 | **98,435** | 1.24 | 1.21 | 159 | 149 | 1.36 | 2.14 | 1.62 | 217 | 1.46 | 121 | 132 | 1.14 | 137 | 96,035 | 1,444 | 9.51 | 11.9 | 9.48 |
|
||||
| FerretDB v1.99.0 (b6c494e9) + MySQL 9.7.4 | **106,082** | 1.86 | 3.09 | 1,073 | 1,082 | 1.86 | 2.90 | 2.37 | 249 | 4.88 | 133 | 140 | 1.74 | 153 | 101,505 | 1,687 | 14.4 | 15.7 | 11.6 |
|
||||
| MongoDB 9.0.2 | **16.6** | 0.48 | 0.41 | 0.89 | 0.87 | 0.43 | 0.42 | 0.43 | 2.08 | 0.42 | 0.45 | 0.53 | 0.35 | 0.60 | 6.23 | 0.68 | 0.39 | 0.52 | 0.48 |
|
||||
| MongoDB 8.0.32 | **17.4** | 0.46 | 0.41 | 0.86 | 0.85 | 0.39 | 0.48 | 0.45 | 2.14 | 0.42 | 0.49 | 0.52 | 0.35 | 0.63 | 7.09 | 0.78 | 0.38 | 0.40 | 0.34 |
|
||||
| MongoDB 6.0.28 | **18.0** | 0.44 | 0.46 | 0.71 | 0.96 | 0.43 | 0.44 | 0.41 | 2.13 | 0.39 | 0.43 | 0.50 | 0.36 | 0.60 | 7.42 | 0.82 | 0.49 | 0.49 | 0.53 |
|
||||
| MongoDB 7.0.43 | **18.1** | 0.45 | 0.41 | 0.71 | 0.91 | 0.41 | 0.46 | 0.41 | 2.05 | 0.40 | 0.42 | 0.51 | 0.36 | 0.55 | 7.81 | 0.76 | 0.46 | 0.52 | 0.48 |
|
||||
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | **57.9** | 0.67 | 0.55 | 2.10 | 2.82 | 0.69 | 0.71 | 0.56 | 3.21 | 1.12 | 1.06 | 1.29 | 0.52 | 1.42 | 33.8 | 1.91 | 1.87 | 1.77 | 1.85 |
|
||||
| FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | **68.3** | 0.29 | 0.23 | 4.35 | 3.40 | 0.28 | 0.42 | 0.37 | 4.91 | 0.72 | 1.69 | 2.36 | 0.22 | 1.01 | 40.7 | 3.90 | 1.36 | 1.39 | 0.67 |
|
||||
| FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | **103** | 0.92 | 0.87 | 6.13 | 6.40 | 0.91 | 0.97 | 0.88 | 7.79 | 1.04 | 5.05 | 5.13 | 0.85 | 5.75 | 35.9 | 16.9 | 2.65 | 2.87 | 2.08 |
|
||||
| FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | **145** | 0.76 | 0.67 | 5.84 | 6.14 | 0.59 | 0.57 | 0.55 | 7.70 | 0.70 | 4.54 | 4.57 | 0.50 | 5.20 | 83.8 | 16.1 | 2.34 | 2.27 | 1.66 |
|
||||
| FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | **174** | 0.75 | 0.62 | 6.00 | 6.39 | 0.70 | 0.68 | 0.61 | 7.49 | 0.73 | 4.75 | 4.70 | 0.57 | 5.29 | 112 | 15.3 | 2.59 | 2.58 | 1.95 |
|
||||
| FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | **240** | 2.00 | 1.48 | 32.7 | 33.0 | 1.44 | 1.95 | 1.68 | 10.4 | 2.96 | 7.61 | 7.59 | 1.50 | 8.35 | 97.2 | 18.4 | 4.32 | 4.81 | 2.71 |
|
||||
| FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | **290** | 1.06 | 0.90 | 11.6 | 11.6 | 1.20 | 1.13 | 0.96 | 7.68 | 1.28 | 6.63 | 6.77 | 0.91 | 6.90 | 216 | 9.12 | 2.52 | 2.48 | 1.43 |
|
||||
| FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | **20,470** | 42.6 | 1.87 | 852 | 847 | 2.09 | 188 | 84.7 | 2,876 | 2,846 | 2,857 | 2,836 | 2.06 | 2,834 | 4,126 | 56.6 | 6.94 | 6.92 | 4.75 |
|
||||
|
||||
### The slowest parameter
|
||||
|
||||
The same, for the slowest of the 15 parameters - nearly always the biggest board or the
|
||||
busiest user. This is how long the person who waits longest waits. Same row order.
|
||||
busiest user. This is how long the person who waits longest waits. Ordered by its own
|
||||
total, so the order can differ from the table above.
|
||||
|
||||
| Database | Total | Login | User by id | All Boards count | All Boards page | Board by id | Board lists | Board swimlanes | Board cards | Card window | Card count | Cards per list | Card by id | Card search | Due Cards | People page | Edit card | Move card | Add card |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| MongoDB 9.0.4 | **1,110** | 0.78 | 0.69 | 28.1 | 187 | 0.72 | 1.89 | 1.63 | 297 | 0.83 | 4.48 | 9.99 | 0.68 | 28.1 | 525 | 19.9 | 0.85 | 1.48 | 1.02 |
|
||||
| MongoDB 6.0.28 | **1,257** | 0.80 | 0.78 | 29.6 | 256 | 0.95 | 1.82 | 1.43 | 300 | 0.98 | 7.77 | 22.4 | 0.78 | 24.9 | 585 | 21.1 | 0.87 | 1.11 | 0.79 |
|
||||
| MongoDB 7.0.45 | **1,303** | 0.88 | 0.77 | 34.1 | 291 | 0.83 | 1.83 | 1.65 | 304 | 0.89 | 7.95 | 22.1 | 0.77 | 29.6 | 583 | 20.8 | 0.84 | 1.04 | 0.79 |
|
||||
| MongoDB 8.0.34 | **1,212** | 0.87 | 0.71 | 33.7 | 184 | 0.82 | 2.09 | 1.66 | 301 | 0.93 | 5.14 | 11.0 | 0.73 | 33.8 | 611 | 22.1 | 0.84 | 1.12 | 0.80 |
|
||||
| FerretDB v1.99.0 (b6c494e9) + SQLite (modernc.org/sqlite v1.61.0) | **29,845** | 1.04 | 0.97 | 4,780 | 4,812 | 1.27 | 18.1 | 8.09 | 1,419 | 389 | 538 | 755 | 0.96 | 504 | 13,798 | 2,804 | 5.69 | 7.52 | 2.83 |
|
||||
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | **136,362** | 1.39 | 1.07 | 190 | 252 | 1.22 | 2.90 | 2.50 | 451 | 113 | 90.2 | 90.2 | 0.98 | 90.0 | 134,900 | 147 | 13.8 | 7.81 | 6.78 |
|
||||
| FerretDB v1.99.0 (b6c494e9) + MariaDB 12.3.3 | **130,784** | 1.87 | 2.46 | 15,784 | 15,689 | 2.05 | 9.24 | 5.17 | 1,192 | 2.71 | 1,101 | 1,079 | 2.63 | 1,079 | 93,914 | 891 | 11.3 | 10.5 | 7.36 |
|
||||
| FerretDB v1.99.0 (b6c494e9) + PostgreSQL 18.6 | **127,323** | 1.27 | 1.31 | 887 | 812 | 1.56 | 6.39 | 3.80 | 1,365 | 1.63 | 762 | 874 | 1.20 | 848 | 120,122 | 1,601 | 12.2 | 13.1 | 10.4 |
|
||||
| FerretDB v1.99.0 (b6c494e9) + MySQL 9.7.4 | **131,734** | 1.98 | 3.56 | 1,821 | 1,815 | 2.41 | 7.84 | 4.79 | 1,561 | 6.74 | 791 | 873 | 1.84 | 936 | 122,154 | 1,709 | 15.7 | 16.6 | 12.5 |
|
||||
| MongoDB 9.0.2 | **45.1** | 0.59 | 0.47 | 2.54 | 4.03 | 0.48 | 0.50 | 0.50 | 13.9 | 0.45 | 0.64 | 0.90 | 0.37 | 1.87 | 15.6 | 0.71 | 0.42 | 0.59 | 0.54 |
|
||||
| MongoDB 8.0.32 | **48.8** | 0.52 | 0.44 | 2.63 | 4.09 | 0.42 | 0.59 | 0.55 | 14.6 | 0.44 | 0.66 | 0.91 | 0.40 | 2.08 | 18.4 | 0.83 | 0.40 | 0.45 | 0.37 |
|
||||
| MongoDB 7.0.43 | **50.1** | 0.51 | 0.46 | 2.41 | 5.25 | 0.44 | 0.54 | 0.48 | 14.3 | 0.42 | 0.64 | 1.26 | 0.40 | 1.83 | 18.7 | 0.80 | 0.52 | 0.60 | 0.54 |
|
||||
| MongoDB 6.0.28 | **50.2** | 0.48 | 0.52 | 2.49 | 5.59 | 0.49 | 0.52 | 0.50 | 14.6 | 0.42 | 0.66 | 1.33 | 0.39 | 2.05 | 17.6 | 0.87 | 0.54 | 0.55 | 0.59 |
|
||||
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | **154** | 0.74 | 0.59 | 2.40 | 3.22 | 0.88 | 0.91 | 0.65 | 20.4 | 1.65 | 1.43 | 2.17 | 0.57 | 2.74 | 107 | 2.01 | 2.28 | 2.17 | 2.06 |
|
||||
| FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | **219** | 0.34 | 0.30 | 21.6 | 11.2 | 0.35 | 0.82 | 0.67 | 44.0 | 3.71 | 13.9 | 20.6 | 0.24 | 5.43 | 85.6 | 6.88 | 1.53 | 1.49 | 0.77 |
|
||||
| FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | **415** | 1.00 | 0.93 | 36.7 | 38.9 | 1.05 | 1.43 | 1.11 | 61.6 | 1.26 | 36.6 | 37.1 | 0.93 | 41.5 | 128 | 18.3 | 3.26 | 3.21 | 2.63 |
|
||||
| FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | **483** | 0.87 | 0.73 | 38.3 | 39.1 | 0.68 | 0.97 | 0.79 | 66.7 | 1.22 | 36.7 | 37.0 | 0.57 | 41.4 | 193 | 16.9 | 2.65 | 2.86 | 1.91 |
|
||||
| FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | **594** | 0.88 | 0.67 | 36.5 | 38.8 | 0.80 | 1.13 | 0.92 | 62.5 | 1.19 | 36.4 | 36.1 | 0.60 | 40.3 | 314 | 15.8 | 2.78 | 2.77 | 2.12 |
|
||||
| FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | **877** | 2.12 | 1.52 | 77.1 | 77.7 | 1.58 | 2.64 | 2.11 | 76.4 | 4.03 | 51.1 | 49.9 | 1.62 | 55.6 | 442 | 18.9 | 4.53 | 5.05 | 2.86 |
|
||||
| FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | **1,063** | 1.19 | 0.98 | 104 | 104 | 1.39 | 1.66 | 1.34 | 54.4 | 1.39 | 49.9 | 50.7 | 0.98 | 51.3 | 622 | 9.26 | 2.84 | 2.63 | 1.61 |
|
||||
| FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | **20,904** | 43.8 | 1.99 | 875 | 887 | 2.36 | 192 | 88.5 | 2,912 | 2,890 | 2,895 | 2,908 | 2.57 | 2,860 | 4,262 | 61.4 | 8.81 | 8.48 | 4.91 |
|
||||
|
||||
## Loading the data
|
||||
|
||||
Inserting the documents in batches of 1,000, then creating every index WeKan creates.
|
||||
Ordered by the whole time, fastest first.
|
||||
|
||||
| Database | users | boards | lists | swimlanes | cards | Indexes | Index errors |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| MongoDB 9.0.4 | 0.8 s | 3.4 s | 4.5 s | 4.1 s | 17.0 s | 43.6 s | none |
|
||||
| MongoDB 6.0.28 | 0.8 s | 3.3 s | 4.6 s | 4.2 s | 17.7 s | 41.6 s | none |
|
||||
| MongoDB 7.0.45 | 0.8 s | 3.3 s | 4.7 s | 4.2 s | 17.3 s | 42.8 s | none |
|
||||
| MongoDB 8.0.34 | 0.8 s | 3.2 s | 4.9 s | 4.3 s | 17.9 s | 45.3 s | none |
|
||||
| FerretDB v1.99.0 (b6c494e9) + SQLite (modernc.org/sqlite v1.61.0) | 10.5 s | 65.5 s | 59.6 s | 41.4 s | 315.3 s | 1157.0 s | none |
|
||||
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | 1.5 s | 6.0 s | 7.7 s | 6.9 s | 32.3 s | 219.2 s | none |
|
||||
| FerretDB v1.99.0 (b6c494e9) + MariaDB 12.3.3 | 8.5 s | 35.9 s | 33.7 s | 28.3 s | 148.9 s | 900.9 s | none |
|
||||
| FerretDB v1.99.0 (b6c494e9) + PostgreSQL 18.6 | 15.2 s | 83.7 s | 74.6 s | 63.3 s | 376.9 s | 116.8 s | none |
|
||||
| FerretDB v1.99.0 (b6c494e9) + MySQL 9.7.4 | 11.2 s | 58.6 s | 58.0 s | 53.1 s | 254.2 s | 172.0 s | none |
|
||||
| MongoDB 9.0.2 | 0.0 s | 0.0 s | 0.1 s | 0.0 s | 0.2 s | 0.6 s | none |
|
||||
| MongoDB 7.0.43 | 0.0 s | 0.0 s | 0.1 s | 0.0 s | 0.2 s | 0.6 s | none |
|
||||
| MongoDB 6.0.28 | 0.0 s | 0.0 s | 0.1 s | 0.0 s | 0.2 s | 0.6 s | none |
|
||||
| MongoDB 8.0.32 | 0.0 s | 0.0 s | 0.1 s | 0.0 s | 0.2 s | 0.7 s | none |
|
||||
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | 0.0 s | 0.1 s | 0.1 s | 0.1 s | 0.3 s | 1.3 s | none |
|
||||
| FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | 0.2 s | 0.5 s | 0.4 s | 0.4 s | 2.0 s | 1.7 s | none |
|
||||
| FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | 0.1 s | 0.3 s | 0.3 s | 0.2 s | 1.3 s | 7.2 s | none |
|
||||
| FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | 0.1 s | 0.5 s | 0.3 s | 0.2 s | 1.9 s | 7.4 s | none |
|
||||
| FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | 0.3 s | 0.6 s | 0.5 s | 0.5 s | 2.2 s | 7.9 s | none |
|
||||
| FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | 0.1 s | 0.7 s | 0.5 s | 0.4 s | 3.0 s | 16.0 s | none |
|
||||
| FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | 0.1 s | 0.7 s | 0.5 s | 0.4 s | 3.2 s | 38.0 s | none |
|
||||
| FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | 0.2 s | 0.8 s | 0.8 s | 0.6 s | 4.2 s | 105.7 s | none |
|
||||
|
||||
## Same data
|
||||
|
||||
Every answer of every query, for every parameter, and the cards the writes left behind,
|
||||
compared with **MongoDB 8.0.34**. Answers are compared as typed values with
|
||||
compared with **MongoDB 8.0.32**. Answers are compared as typed values with
|
||||
sorted keys; a query whose order WeKan relies on must also return the same order.
|
||||
|
||||
| Database | Same answers | Different |
|
||||
| --- | --- | --- |
|
||||
| MongoDB 9.0.4 | 19 of 19 | none |
|
||||
| MongoDB 9.0.2 | 19 of 19 | none |
|
||||
| MongoDB 8.0.32 | 19 of 19 | none |
|
||||
| MongoDB 6.0.28 | 19 of 19 | none |
|
||||
| MongoDB 7.0.45 | 19 of 19 | none |
|
||||
| MongoDB 8.0.34 | 19 of 19 | none |
|
||||
| FerretDB v1.99.0 (b6c494e9) + SQLite (modernc.org/sqlite v1.61.0) | 19 of 19 | none |
|
||||
| MongoDB 7.0.43 | 19 of 19 | none |
|
||||
| FerretDB v2.7.0 + PostgreSQL 17.6 with DocumentDB | 19 of 19 | none |
|
||||
| FerretDB v1.99.0 (b6c494e9) + MariaDB 12.3.3 | 19 of 19 | none |
|
||||
| FerretDB v1.99.0 (b6c494e9) + PostgreSQL 18.6 | 19 of 19 | none |
|
||||
| FerretDB v1.99.0 (b6c494e9) + MySQL 9.7.4 | 19 of 19 | none |
|
||||
| FerretDB v1.105.0 (9030e55c) + SQLite (modernc.org/sqlite v1.61.0) | 19 of 19 | none |
|
||||
| FerretDB v1.105.0 (0db7b801) + MySQL 9.7.2 | 19 of 19 | none |
|
||||
| FerretDB v1.105.0 (0c784f2c) + PostgreSQL 18.6 | 19 of 19 | none |
|
||||
| FerretDB v1.105.0 (0c784f2c) + pgrust 0.4-beta (PostgreSQL 18.6 compatible) | 19 of 19 | none |
|
||||
| FerretDB v1.105.0 (0db7b801) + TiDB v7.5.1 | 19 of 19 | none |
|
||||
| FerretDB v1.105.0 (0db7b801) + MariaDB 12.3.3 | 19 of 19 | none |
|
||||
| FerretDB v1.105.0 (0db7b801) + Dolt 2.6.2 | 19 of 19 | none |
|
||||
|
||||
## The queries
|
||||
|
||||
@@ -103,7 +139,7 @@ Each is the query WeKan itself sends; see [queries.cjs](queries.cjs).
|
||||
| Add card | cards.insertOne({ ...a new card }) | the 3 biggest boards + 12 others |
|
||||
|
||||
Each query is first run once per parameter to warm up (those answers are the ones compared),
|
||||
then repeated for up to 5 passes or 10 seconds; 15-90 calls per query were timed.
|
||||
then repeated for up to 5 passes or the time budget (WEKAN_SPEEDTEST_BUDGET_MS); 15-90 calls per query were timed.
|
||||
A query too slow to be repeated in that time keeps its warm-up time.
|
||||
Raw results: `.tools/log/test-db-speedtest/2026-10-23_19-33-27/speedtest-<database>.json`.
|
||||
Raw results: `.tools/log/test-db-speedtest/2026-11-03_22-10-05/speedtest-<database>.json`.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# Opening a WeKan board: where the time goes
|
||||
|
||||
The [speed test](../) asks the database alone. This asks what a person waits for: the
|
||||
whole of opening a board in WeKan, with WeKan on FerretDB (SQLite) and on MongoDB 8,
|
||||
and how much of it is the database. [board-open.cjs](board-open.cjs) seeds a board of
|
||||
N cards, logs in, opens it in Chromium and records
|
||||
|
||||
- the wall clock to the first minicard and until the DDP WebSocket has been quiet for
|
||||
1.5 s (**settled**), and what crossed the WebSocket;
|
||||
- the Meteor server's CPU, by who spends it (a Node inspector profile of the server);
|
||||
- FerretDB's CPU (its pprof), against what it spends with nobody in the browser.
|
||||
|
||||
```sh
|
||||
# WeKan on FerretDB: MONGO_URL=mongodb://127.0.0.1:27040/wekan?directConnection=true,
|
||||
# DEFAULT_METEOR_REACTIVITY_ORDER=polling (as WeKan is deployed), meteor run --inspect=9229,
|
||||
# and FerretDB with --debug-addr=127.0.0.1:8098
|
||||
WEKAN_BASE_URL=http://localhost:3310 WEKAN_MONGO_URL=mongodb://127.0.0.1:27040/wekan \
|
||||
node docs/Databases/FerretDB/1/SpeedTest/app/board-open.cjs --sizes 200,2000,5000 \
|
||||
--inspect 9229 --ferret-pprof http://127.0.0.1:8098 --label ferretdb-sqlite
|
||||
```
|
||||
|
||||
Run on 2026-11-03/04, Apple M2 Max, **the development build of Meteor** (so the browser's
|
||||
own part is larger than in production), one user, `polling` reactivity, FerretDB v1.105.0
|
||||
with the SQLite membership-test change, MongoDB 8.0 in Docker. Each board is opened
|
||||
twice (the second is warm), and the whole measurement was run **twice** (run 1 / run 2
|
||||
below), because one run is not enough: the same board's "settled" time moved by up to
|
||||
300 ms between runs.
|
||||
|
||||
## Result: the database is not what you wait for on a small board, and is on a big one
|
||||
|
||||
Milliseconds, first open of a board with this many cards (run 1 / run 2). WeKan loads
|
||||
cards lazily, so the browser gets **the same ~210 KB and draws 70-120 minicards at every
|
||||
size**.
|
||||
|
||||
| Cards | FerretDB (SQLite): first card | settled | MongoDB 8: first card | settled |
|
||||
| ---: | ---: | ---: | ---: | ---: |
|
||||
| 200 | 750 / 764 | 966 / 880 | 752 / 698 | 865 / 813 |
|
||||
| 2,000 | 931 / 1,031 | 1,763 / 1,555 | 829 / 856 | 1,457 / 1,582 |
|
||||
| 5,000 | 1,331 / 1,334 | 2,166 / 2,065 | 938 / 899 | 1,562 / 1,622 |
|
||||
|
||||
What is the same in both runs:
|
||||
|
||||
- **5,000 cards: FerretDB is 400-600 ms slower**, to the first card by 42-48% and until
|
||||
settled by 27-39%.
|
||||
- **2,000 cards: within the noise** for "settled" (-30 to +300 ms), and 100-175 ms
|
||||
slower to the first card.
|
||||
- **200 cards: 70-100 ms**, which is 9-12%.
|
||||
|
||||
The warm second open of run 2: 5,000 cards 1,321 against 865 ms to the first card
|
||||
and 1,946 against 1,594 ms settled; 2,000 cards 904 against 824 and 1,628 against
|
||||
1,446; 200 cards 721 against 689 and 934 against 805.
|
||||
|
||||
The Meteor server's CPU is the same on both databases within the noise (first open, run
|
||||
1 / run 2: 102 / 119, 276 / 421 and 470 / 517 ms on FerretDB; 178 / 93, 245 / 342 and
|
||||
521 / 483 ms on MongoDB), so the difference is **time spent waiting for the database**,
|
||||
and it grows with the size of the board while the data the browser receives does not.
|
||||
|
||||
For a board of a few hundred cards, 0.7-0.75 s to the first card is nearly the same on
|
||||
both: that is the application (the development bundle, Blaze, the DDP round trips), not
|
||||
the database.
|
||||
|
||||
## Where the Meteor server spends its CPU (5,000 cards, first open, run 2)
|
||||
|
||||
| | FerretDB (517 ms) | MongoDB 8 (483 ms) |
|
||||
| --- | ---: | ---: |
|
||||
| Garbage collector | 31% | 24% |
|
||||
| MongoDB driver (wire, decode, encode) | 19% | 22% |
|
||||
| Meteor mongo (observers, polling, diff) | 12% | 13% |
|
||||
| Node internals | 11% | 12% |
|
||||
| WeKan code | 11% | 10% |
|
||||
| Other Meteor packages and dependencies | 8% | 9% |
|
||||
| Native, EJSON, DDP server (publish, mergebox) | 8% | 10% |
|
||||
|
||||
The DDP server itself, publish and mergebox, is 1%. The largest single functions are
|
||||
the garbage collector, `CollectionDocument` (WeKan's model class), `replaceTypes`
|
||||
(Meteor's BSON-to-EJSON step) and the BSON decoder.
|
||||
|
||||
## Where FerretDB spends its CPU
|
||||
|
||||
With nobody in the browser FerretDB uses 110-510 ms of CPU in the 14 s window the
|
||||
profile covers. Opening a board adds, net: about nothing at 200 cards, **about 1.6 s at
|
||||
2,000 and about 3.9-4.1 s at 5,000** - eight times the Meteor server's CPU for the same
|
||||
open. For the 5,000-card board, of 4.41 s (run 2):
|
||||
|
||||
- the SQLite engine, 1.72 s (39%), a good part of it moving through the pages of the
|
||||
cards table (`btreeInitPage` 18% by itself);
|
||||
- 34% is `filterIterator.Next`: documents read from SQLite and filtered again in Go;
|
||||
- by MongoDB command: `find` 0.79 s, `getMore` 0.74 s, `aggregate` 0.68 s (a `$group`
|
||||
done in Go over the documents it read);
|
||||
- the Go garbage collector, 0.57 s (13%).
|
||||
|
||||
Which WeKan query sends the `aggregate` during the open was **not identified**; the
|
||||
debug log of a 2,000-card open contains none, so the 5,000-card profile may have
|
||||
included the All Boards page's card counters (`getAllBoardsTileData`, a `$group` over
|
||||
the user's boards) that the login lands on first. Treat that line as a lead.
|
||||
|
||||
## What this does and does not say
|
||||
|
||||
- It says the database layer is the part of opening a board that **scales with the board
|
||||
size**, and that on FerretDB (SQLite) a 5,000-card board takes 400-600 ms longer than on
|
||||
MongoDB, with the same data sent to the browser.
|
||||
- It says the **Meteor server is not the bottleneck**: 0.1-0.5 s of CPU, the same on both
|
||||
databases, and the DDP server is about 1% of it. A backend rewritten in another language
|
||||
could save a part of that, and would not change the database or the browser.
|
||||
- It does not separate the browser's own time from the wait for the server: the first
|
||||
card at 0.7 s on a small board is mostly the development bundle and rendering.
|
||||
- Single machine, the development build, one user, two runs, two opens per board, `settled`
|
||||
meaning 1.5 s without a DDP frame. A production build and a loaded server would differ.
|
||||
@@ -0,0 +1,188 @@
|
||||
'use strict';
|
||||
|
||||
// Where the time goes when a WeKan board is opened, with WeKan running on a database
|
||||
// (FerretDB on SQLite, or MongoDB): the browser's wall clock, what crosses the DDP
|
||||
// WebSocket, the Meteor server's CPU by who spends it (Node inspector profile), and
|
||||
// FerretDB's CPU (its pprof). The speed test asks the database alone; this asks whether
|
||||
// the database is what a person waits for.
|
||||
//
|
||||
// # WeKan on FerretDB: meteor run --port 3310 --inspect=9229 with MONGO_URL set,
|
||||
// # FerretDB with --debug-addr=127.0.0.1:8098
|
||||
// WEKAN_BASE_URL=http://localhost:3310 WEKAN_MONGO_URL=mongodb://127.0.0.1:27040/wekan \
|
||||
// node docs/Databases/FerretDB/1/SpeedTest/app/board-open.cjs --sizes 200,2000 \
|
||||
// --inspect 9229 --ferret-pprof http://127.0.0.1:8098 --label ferretdb-sqlite
|
||||
//
|
||||
// Dev servers: the Meteor build is the development one, so the client's own time is
|
||||
// larger than in production; the comparison between databases is what it is for.
|
||||
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const ROOT = path.join(__dirname, '../../../../../..');
|
||||
const pw = require(path.join(ROOT, 'tests/playwright/node_modules/playwright-core'));
|
||||
const { MongoClient } = require(path.join(ROOT, 'tests/playwright/node_modules/mongodb'));
|
||||
const WebSocket = require(path.join(ROOT, 'node_modules/ws'));
|
||||
|
||||
function arg(name, fallback) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
||||
}
|
||||
const BASE = process.env.WEKAN_BASE_URL || 'http://localhost:3310';
|
||||
process.env.WEKAN_BASE_URL = BASE;
|
||||
const MONGO = process.env.WEKAN_MONGO_URL || 'mongodb://127.0.0.1:27040/wekan';
|
||||
process.env.WEKAN_MONGO_URL = MONGO;
|
||||
const db = require(path.join(ROOT, 'tests/playwright/helpers/db.js'));
|
||||
const { loginWithToken } = require(path.join(ROOT, 'tests/playwright/helpers/auth.js'));
|
||||
|
||||
const SIZES = arg('sizes', '200,2000').split(',').map(Number);
|
||||
const INSPECT = arg('inspect', '');
|
||||
const PPROF = arg('ferret-pprof', '');
|
||||
const LABEL = arg('label', 'run');
|
||||
const GO = arg('go', path.join(ROOT, '.tools/go/bin/go'));
|
||||
const FERRET_BIN = arg('ferret-bin', path.join(ROOT, '.tools/FerretDB/bin/ferretdb'));
|
||||
const QUIET_MS = 1500;
|
||||
|
||||
// ── seeding ─────────────────────────────────────────────────────────────────
|
||||
async function seedBoard(user, size) {
|
||||
const board = db.seedBoard({ ownerId: user.id, title: `Open ${size}`, listCount: 6, cardTitlesPerList: [['Template']] });
|
||||
const template = db.findOne('cards', { boardId: board.boardId, title: 'Template' });
|
||||
const client = await new MongoClient(MONGO).connect();
|
||||
try {
|
||||
const cards = client.db().collection('cards');
|
||||
const docs = [];
|
||||
for (let i = 0; i < size; i += 1) {
|
||||
docs.push({
|
||||
...template,
|
||||
_id: `card${board.boardId}x${i}`,
|
||||
title: `Card ${i} ${['plan', 'build', 'review', 'ship'][i % 4]} the thing`,
|
||||
listId: board.listIds[i % board.listIds.length],
|
||||
sort: Math.floor(i / board.listIds.length) * 100,
|
||||
cardNumber: i + 2,
|
||||
createdAt: new Date(), modifiedAt: new Date(), dateLastActivity: new Date(),
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < docs.length; i += 1000) await cards.insertMany(docs.slice(i, i + 1000));
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
return board;
|
||||
}
|
||||
|
||||
// ── the Meteor server's CPU, from the Node inspector ────────────────────────
|
||||
async function inspector() {
|
||||
const list = await (await fetch(`http://127.0.0.1:${INSPECT}/json`)).json();
|
||||
const ws = new WebSocket(list[0].webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
|
||||
let id = 0;
|
||||
const pending = new Map();
|
||||
ws.on('message', (m) => {
|
||||
const msg = JSON.parse(m);
|
||||
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
|
||||
});
|
||||
const send = (method, params = {}) => new Promise((resolve) => { id += 1; pending.set(id, resolve); ws.send(JSON.stringify({ id, method, params })); });
|
||||
await send('Profiler.enable');
|
||||
await send('Profiler.setSamplingInterval', { interval: 250 });
|
||||
return {
|
||||
start: () => send('Profiler.start'),
|
||||
stop: async () => (await send('Profiler.stop')).result.profile,
|
||||
close: () => ws.close(),
|
||||
};
|
||||
}
|
||||
|
||||
const { analyse } = require('./profileAnalysis.cjs');
|
||||
|
||||
// ── FerretDB's CPU, from its pprof ──────────────────────────────────────────
|
||||
async function ferretProfile(seconds) {
|
||||
if (!PPROF) return null;
|
||||
const res = await fetch(`${PPROF}/debug/pprof/profile?seconds=${seconds}`);
|
||||
const file = path.join(process.env.TMPDIR || '/tmp', `ferret-open-${Date.now()}.prof`);
|
||||
require('fs').writeFileSync(file, Buffer.from(await res.arrayBuffer()));
|
||||
const env = { ...process.env, HOME: path.join(ROOT, '.tools') };
|
||||
const top = execFileSync(GO, ['tool', 'pprof', '-top', '-nodecount=80', FERRET_BIN, file], { encoding: 'utf8', env });
|
||||
const total = /Total samples = ([\d.]+)(m?s)/.exec(top);
|
||||
const cpuMs = total ? Number(total[1]) * (total[2] === 's' ? 1000 : 1) : null;
|
||||
// A line reads: flat flat% sum% cum cum% name - SQLite's share is the cumulative time of its VM.
|
||||
const line = top.split('\n').find((l) => /_sqlite3VdbeExec/.test(l));
|
||||
const sqlite = line ? /([\d.]+)(m?s)\s+[\d.]+%\s+[\d.]+%\s+([\d.]+)(m?s)\s+[\d.]+%/.exec(line) : null;
|
||||
// The cumulative top, for the first open: who in FerretDB spends it.
|
||||
const cum = execFileSync(GO, ['tool', 'pprof', '-top', '-cum', '-nodecount=34', FERRET_BIN, file], { encoding: 'utf8', env })
|
||||
.split('\n').filter((l) => /^\s+[\d.]+m?s/.test(l)).map((l) => l.trim().replace(/\s+/g, ' ')).slice(0, 34);
|
||||
return { cum, cpuMs, sqliteMs: sqlite ? Number(sqlite[3]) * (sqlite[4] === 's' ? 1000 : 1) : null };
|
||||
}
|
||||
|
||||
// ── one board open ──────────────────────────────────────────────────────────
|
||||
async function openBoard(user, board, size) {
|
||||
const browser = await pw.chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1500, height: 1000 } });
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(() => { try { localStorage.setItem('wekan-mobile-mode', 'false'); } catch (e) { /* none */ } });
|
||||
let frames = 0; let bytes = 0; let last = Date.now();
|
||||
page.on('websocket', (ws) => ws.on('framereceived', (e) => { frames += 1; bytes += e.payload.length; last = Date.now(); }));
|
||||
await loginWithToken(page, user.id, user.token);
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const insp = INSPECT ? await inspector() : null;
|
||||
frames = 0; bytes = 0;
|
||||
const ferret = PPROF ? ferretProfile(14) : Promise.resolve(null);
|
||||
if (insp) await insp.start();
|
||||
const t0 = Date.now();
|
||||
await page.goto(`${BASE}/b/${board.boardId}/${board.slug}`, { waitUntil: 'commit' });
|
||||
let tFirst = null;
|
||||
await page.waitForSelector('.minicard', { timeout: 60000 }).then(() => { tFirst = Date.now() - t0; });
|
||||
// quiet: no DDP frame for QUIET_MS
|
||||
for (;;) {
|
||||
if (Date.now() - last > QUIET_MS && Date.now() - t0 > (tFirst || 0)) break;
|
||||
if (Date.now() - t0 > 60000) break;
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
const tQuiet = Date.now() - t0 - QUIET_MS;
|
||||
const profile = insp ? await insp.stop() : null;
|
||||
const minicards = await page.locator('.minicard').count();
|
||||
const ferretResult = await ferret;
|
||||
if (insp) insp.close();
|
||||
await browser.close();
|
||||
return { size, tFirst, tQuiet, frames, kb: Math.round(bytes / 1024), minicards, server: profile ? analyse(profile) : null, ferret: ferretResult };
|
||||
}
|
||||
|
||||
const f = (v) => (v === null || v === undefined ? '-' : Math.round(v).toLocaleString('en-US'));
|
||||
|
||||
async function main() {
|
||||
const user = db.seedUser();
|
||||
const results = [];
|
||||
// What FerretDB spends in the same 14 s with nobody in the browser: the baseline.
|
||||
const idle = PPROF ? await ferretProfile(14) : null;
|
||||
if (idle) console.error(`FerretDB idle for 14 s: ${f(idle.cpuMs)} ms of CPU`);
|
||||
for (const size of SIZES) {
|
||||
console.error(`seeding a board of ${size} cards...`);
|
||||
const board = await seedBoard(user, size);
|
||||
// The first open of a board is the one a person waits for; a second shows a warm server.
|
||||
for (const pass of ['first open', 'second open']) {
|
||||
console.error(`opening ${size} (${pass})...`);
|
||||
const r = await openBoard(user, board, size);
|
||||
r.pass = pass;
|
||||
results.push(r);
|
||||
}
|
||||
}
|
||||
console.log(`\n## ${LABEL}: opening a board\n`);
|
||||
if (idle) console.log(`FerretDB with nobody in the browser: ${f(idle.cpuMs)} ms of CPU in the same 14 s window (subtract it).\n`);
|
||||
console.log('| Cards | Open | First card (ms) | Settled (ms) | DDP frames | DDP KB | Minicards drawn | Meteor server busy (ms) | FerretDB CPU (ms) | of it SQLite (ms) |');
|
||||
console.log('| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |');
|
||||
for (const r of results) {
|
||||
console.log(`| ${r.size} | ${r.pass} | ${f(r.tFirst)} | ${f(r.tQuiet)} | ${f(r.frames)} | ${f(r.kb)} | ${f(r.minicards)} | ${f(r.server && r.server.busyMs)} | ${f(r.ferret && r.ferret.cpuMs)} | ${f(r.ferret && r.ferret.sqliteMs)} |`);
|
||||
}
|
||||
for (const r of results.filter((x) => x.pass === 'first open' && x.server)) {
|
||||
console.log(`\n### The Meteor server, ${r.size} cards, first open: ${f(r.server.busyMs)} ms of CPU\n`);
|
||||
for (const [c, ms] of r.server.categories) console.log(`- ${c}: ${f(ms)} ms (${Math.round((100 * ms) / r.server.busyMs)}%)`);
|
||||
if (r.ferret && r.ferret.cum) {
|
||||
console.log(`\n### FerretDB, ${r.size} cards, first open: cumulative CPU by function (flat flat% sum% cum cum% name)\n`);
|
||||
console.log('```');
|
||||
r.ferret.cum.forEach((l) => console.log(l));
|
||||
console.log('```');
|
||||
}
|
||||
console.log('\nTop functions:');
|
||||
for (const [fn, ms] of r.server.topFunctions) console.log(`- ${f(ms)} ms ${fn}`);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (require.main === module) main().catch((e) => { console.error(e); process.exit(1); });
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
|
||||
// A Node inspector CPU profile of the Meteor server, by who spends the time. Apart from
|
||||
// the browser and the database so a test can drive it with a made-up profile.
|
||||
|
||||
function categorise(node) {
|
||||
const { functionName, url } = node.callFrame;
|
||||
if (functionName === '(idle)') return 'idle';
|
||||
if (functionName === '(garbage collector)') return 'garbage collector';
|
||||
if (functionName === '(program)') return 'native (program)';
|
||||
if (/\/mongodb\/|npm-mongo|\/bson\//.test(url)) return 'MongoDB driver (wire, decode, encode)';
|
||||
if (/packages\/ddp-server|packages\/ddp-common|packages\/ddp\b/.test(url)) return 'Meteor DDP (publish, mergebox, sessions)';
|
||||
if (/packages\/mongo|minimongo|diff-sequence/.test(url)) return 'Meteor mongo (observe, polling, diff)';
|
||||
if (/packages\/ejson/.test(url)) return 'EJSON';
|
||||
// Other Meteor packages and dependencies are not WeKan's: their paths contain /server/ too.
|
||||
if (/programs\/server\/packages|node_modules/.test(url)) return 'other packages';
|
||||
if (/programs\/server\/app|\/_build\/|\/models\/|\/server\/|\/imports\/|wekan/.test(url)) return 'WeKan code';
|
||||
if (/^node:/.test(url) || url === '') return 'node internals';
|
||||
return 'other packages';
|
||||
}
|
||||
|
||||
function analyse(profile) {
|
||||
const byId = new Map(profile.nodes.map((n) => [n.id, n]));
|
||||
const cats = new Map();
|
||||
const fns = new Map();
|
||||
let total = 0;
|
||||
profile.samples.forEach((sid, i) => {
|
||||
const dt = (profile.timeDeltas[i + 1] ?? profile.timeDeltas[i] ?? 0) / 1000; // ms
|
||||
const node = byId.get(sid);
|
||||
const cat = categorise(node);
|
||||
cats.set(cat, (cats.get(cat) || 0) + dt);
|
||||
total += dt;
|
||||
if (cat !== 'idle') {
|
||||
const key = `${node.callFrame.functionName || '(anonymous)'} ${node.callFrame.url.split('/').slice(-2).join('/')}`;
|
||||
fns.set(key, (fns.get(key) || 0) + dt);
|
||||
}
|
||||
});
|
||||
const idle = cats.get('idle') || 0;
|
||||
return {
|
||||
totalMs: total, busyMs: total - idle,
|
||||
categories: [...cats.entries()].filter(([c]) => c !== 'idle').sort((a, b) => b[1] - a[1]),
|
||||
topFunctions: [...fns.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { categorise, analyse };
|
||||
@@ -0,0 +1,148 @@
|
||||
# Go databases that could be linked into FerretDB, against its SQLite
|
||||
|
||||
FerretDB is one Go binary, and its SQLite handler is the pure-Go driver
|
||||
[modernc.org/sqlite](https://gitlab.com/cznic/sqlite) inside it. Is any database
|
||||
implemented in Go (the [awesome-go list](https://github.com/avelino/awesome-go#databases-implemented-in-go))
|
||||
one that could be linked in beside it, has enough to take SQLite's place, and is
|
||||
faster for what WeKan asks?
|
||||
|
||||
**Short answer: no. Nothing is faster in a way that matters, and the ones that are
|
||||
faster at a single read or write are key-value stores that would make the handler
|
||||
do SQLite's job.**
|
||||
|
||||
This is the engine level of the question, so it can be answered in minutes and
|
||||
without writing a handler for each: [bench.go](main.go) loads the speed test's cards
|
||||
and asks each engine the questions WeKan asks of cards, the way a FerretDB handler
|
||||
written for it would ask them.
|
||||
|
||||
```sh
|
||||
node docs/Databases/FerretDB/1/SpeedTest/embedded/export.cjs 0.05 <dir> # the speed test's cards as JSON lines
|
||||
cd docs/Databases/FerretDB/1/SpeedTest/embedded && go run . -data <dir> # go 1.24+, pure Go, no cgo
|
||||
```
|
||||
|
||||
Run on 2026-11-03 on an Apple M2 Max, Go 1.27, one engine at a time, each in its own
|
||||
directory. `-only "Pebble (CockroachDB's LSM),Nutsdb"` runs some.
|
||||
|
||||
## What is asked
|
||||
|
||||
| Column | The question |
|
||||
| --- | --- |
|
||||
| By id | one card by `_id` |
|
||||
| Board cards | the unarchived cards of a board (an index on boardId and archived), **decoded** from JSON as FerretDB must |
|
||||
| (raw, not decoded) | the same fetch without the decoding: the part that differs between engines |
|
||||
| Count | how many unarchived cards a board has |
|
||||
| Edit, Move | read the card, change it in Go, write it back (FerretDB's `updateOne`), 200 different cards |
|
||||
| Add | insert a card |
|
||||
|
||||
Each read is asked of 15 boards - the 3 biggest and 12 ordinary ones, as in the
|
||||
speed test - and a cell is the average of the 15 medians. Times are milliseconds
|
||||
for one call, **Total** is the sum of the six and the table is ordered by it. Every
|
||||
engine first has to give the right number of cards and the right counts for every
|
||||
board, and the writes must be there afterwards: a fast wrong answer is no answer.
|
||||
|
||||
**SQLite is set up as FerretDB sets it**: WAL, `synchronous=NORMAL`, a 64 MiB cache
|
||||
and 256 MiB of mmap, the document as JSON and the indexes on `json_extract`
|
||||
expressions. A row with plain columns shows what SQLite could do and FerretDB does not.
|
||||
The key-value stores keep a document under `d/<id>` and an index of
|
||||
(boardId, archived) as keys, which is what a handler on them would have to build.
|
||||
No engine fsyncs per commit except where the row says so, to match SQLite's
|
||||
`NORMAL`.
|
||||
|
||||
## 15,377 cards (scale 0.05)
|
||||
|
||||
| # | Engine | Total | By id | Board cards | (raw, not decoded) | Count | Edit | Move | Add | Load (s) | Size (MB) |
|
||||
| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | Nutsdb | **1.394** | 0.0004 | 1.332 | 0.057 | 0.0066 | 0.019 | 0.032 | 0.0041 | 0.0 | 13 |
|
||||
| 2 | bbolt, no fsync | **1.481** | 0.0005 | 1.349 | 0.055 | 0.0047 | 0.037 | 0.054 | 0.035 | 0.2 | 27 |
|
||||
| 3 | SQLite, plain columns (not what FerretDB does) | **1.598** | 0.0067 | 1.385 | 0.116 | 0.025 | 0.057 | 0.067 | 0.058 | 0.1 | 15 |
|
||||
| 4 | Badger | **1.623** | 0.0015 | 1.536 | 0.152 | 0.012 | 0.027 | 0.040 | 0.0065 | 0.0 | n/a |
|
||||
| 5 | SQLite as FerretDB uses it (JSON, expression indexes) | **1.677** | 0.0078 | 1.421 | 0.113 | 0.037 | 0.073 | 0.082 | 0.057 | 0.2 | 14 |
|
||||
| 6 | SQLite as FerretDB uses it, synchronous=FULL | **1.747** | 0.0079 | 1.421 | 0.118 | 0.037 | 0.098 | 0.099 | 0.084 | 0.2 | 14 |
|
||||
| 7 | BuntDB (in memory with an append-only file) | **1.795** | 0.0003 | 1.470 | 0.186 | 0.166 | 0.066 | 0.072 | 0.021 | 0.3 | 12 |
|
||||
| 8 | Pebble (CockroachDB's LSM) | **1.841** | 0.0028 | 1.770 | 0.298 | 0.011 | 0.021 | 0.034 | 0.0010 | 0.1 | 16 |
|
||||
| 9 | bbolt, fsync on every commit (its default) | **25.5** | 0.0005 | 1.370 | 0.057 | 0.0048 | 8.062 | 8.025 | 8.015 | 0.2 | 27 |
|
||||
| 10 | Clover (document database over Badger) | **145** | 0.016 | 59.1 | 58.5 | 57.2 | 9.412 | 9.635 | 9.751 | 0.7 | 41 |
|
||||
|
||||
## 153,768 cards (scale 0.5)
|
||||
|
||||
| # | Engine | Total | By id | Board cards | (raw, not decoded) | Count | Edit | Move | Add | Load (s) | Size (MB) |
|
||||
| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | SQLite, plain columns (not what FerretDB does) | **5.950** | 0.0071 | 5.693 | 0.418 | 0.062 | 0.052 | 0.072 | 0.064 | 1.1 | 147 |
|
||||
| 2 | Nutsdb | **6.007** | 0.0005 | 5.923 | 0.326 | 0.024 | 0.022 | 0.033 | 0.0044 | 0.3 | 132 |
|
||||
| 3 | bbolt, no fsync | **6.065** | 0.0005 | 5.909 | 0.289 | 0.019 | 0.042 | 0.056 | 0.039 | 14.5 | 274 |
|
||||
| 4 | SQLite as FerretDB uses it, synchronous=FULL | **6.118** | 0.0089 | 5.726 | 0.413 | 0.103 | 0.098 | 0.097 | 0.085 | 1.8 | 141 |
|
||||
| 5 | SQLite as FerretDB uses it (JSON, expression indexes) | **6.153** | 0.0082 | 5.830 | 0.430 | 0.103 | 0.072 | 0.079 | 0.062 | 1.8 | 141 |
|
||||
| 6 | BuntDB (in memory with an append-only file) | **7.001** | 0.0003 | 6.130 | 0.753 | 0.682 | 0.080 | 0.083 | 0.026 | 4.3 | 124 |
|
||||
| 7 | Badger | **7.419** | 0.0025 | 7.273 | 1.483 | 0.060 | 0.032 | 0.042 | 0.0096 | 0.4 | n/a |
|
||||
| 8 | Pebble (CockroachDB's LSM) | **10.5** | 0.023 | 10.3 | 4.992 | 0.056 | 0.033 | 0.032 | 0.0012 | 2.0 | 67 |
|
||||
| 9 | bbolt, fsync on every commit (its default) | **42.0** | 0.0006 | 5.923 | 0.296 | 0.019 | 11.8 | 12.2 | 12.1 | 14.4 | 274 |
|
||||
| 10 | Clover (document database over Badger) | **1176** | 0.017 | 577 | 571 | 563 | 11.9 | 11.7 | 11.8 | 25.7 | 301 |
|
||||
|
||||
## What it says
|
||||
|
||||
- **Nothing is faster where it counts.** A board's cards cost 1.4 ms at 15,000 cards
|
||||
and 5.7-6.2 ms at 154,000 for every engine that is not slower, and nearly all of
|
||||
it is decoding the JSON, which is the same work whatever stores it. Totals are
|
||||
within 5% of SQLite's for Nutsdb and bbolt.
|
||||
- **The key-value stores win single reads and writes, by microseconds.** A card by
|
||||
id takes 0.0003-0.0006 ms on bbolt, Nutsdb and BuntDB against SQLite's 0.008; an insert
|
||||
0.001 ms on Pebble against 0.062; an edit 0.022 ms on Nutsdb against 0.072. But the
|
||||
cheapest call through FerretDB costs about 0.2 ms in the speed test (the protocol,
|
||||
the handler, the network) and most cost more, so an engine that took nothing at all would make one call
|
||||
at most a few percent faster.
|
||||
- **Some are slower, and by a lot.** Pebble's LSM is 12x SQLite on fetching a big
|
||||
board's cards (5.0 ms against 0.43), Badger 3.5x, Clover about 1,300x (it decodes every
|
||||
document to answer anything), 90-190x in total. **bbolt with its default - an fsync on every commit
|
||||
- takes 8-12 ms per write, 100-160x SQLite's**; without it, it is as fast as
|
||||
SQLite and not durable the same way.
|
||||
- **The cost is in what is not there.** A key-value store has keys. FerretDB's SQLite
|
||||
handler is about 8,500 lines because filters, sorting, `$or`, `$in`, regular
|
||||
expressions, aggregation, indexes and statistics are SQL there. On a key-value
|
||||
store the handler would do them in Go, and this benchmark asked only the six
|
||||
easiest questions. Of the stores here only BuntDB and Clover have indexes on JSON
|
||||
fields, and neither has a query language that could take SQLite's place.
|
||||
- **Loading** at 154,000 cards: Badger 0.4 s, Nutsdb 0.3 s, Pebble 2.0 s, SQLite 1.8
|
||||
s, BuntDB 4.3 s, bbolt 14.5 s (unsorted inserts), Clover 26 s.
|
||||
- Disk: SQLite 141 MB, Pebble 67, Nutsdb 132, BuntDB 124, bbolt 274, Clover 301.
|
||||
|
||||
## The list, entry by entry
|
||||
|
||||
Tested (Go library, open source, maintained, pure Go):
|
||||
|
||||
| Entry | Licence, last push | Result |
|
||||
| --- | --- | --- |
|
||||
| [go-sqlite](https://gitlab.com/cznic/sqlite) | BSD-3, FerretDB's own | the baseline, three ways |
|
||||
| [Pebble](https://github.com/cockroachdb/pebble) | BSD-3, 2026-11 | fastest insert and edit, 12x slower board fetch |
|
||||
| [Badger](https://github.com/dgraph-io/badger) | Apache-2.0, 2026-10 | fast writes, 3.5x slower board fetch |
|
||||
| [bbolt](https://github.com/etcd-io/bbolt) | MIT, 2026-10 | as fast as SQLite without fsync, 100x slower writes with it |
|
||||
| [BuntDB](https://github.com/tidwall/buntdb) | MIT, 2026-05 | fastest by id, slowest count (index scan), in memory |
|
||||
| [Nutsdb](https://github.com/nutsdb/nutsdb) | Apache-2.0, 2026-10 | on par with SQLite, faster point operations |
|
||||
| [Clover](https://github.com/ostafen/clover) | MIT, 2025-09 | has indexes and queries, 90-190x slower in total |
|
||||
|
||||
Not tested, and why:
|
||||
|
||||
| Entry | Why not |
|
||||
| --- | --- |
|
||||
| goleveldb (2024-05), tiedot (2021), pudge (2021), scribble (2023), ledisdb (2023), hare, diskv, libradb | not maintained since 2021-2024, or toys; the engines that matter of their kind (LSM, B+tree) are Pebble, Badger and bbolt, tested |
|
||||
| Bitcask, pogreb, moss, LotusDB, RoseDB, Coffer, tempdb | more key-value stores of the same kind; they would also need the handler to do what SQL does |
|
||||
| gedb, [minisql](https://github.com/RichardKnop/minisql) (54 stars) | not looked at beyond their descriptions: a MongoDB-like store and a young single-file SQL database |
|
||||
| [rqlite](https://github.com/rqlite/rqlite), [redka](https://github.com/nalgeon/redka) | they are SQLite underneath, so not faster by construction |
|
||||
| [Dolt](https://github.com/dolthub/dolt), [TiDB](https://github.com/pingcap/tidb) | tested as servers in the [speed test](../) (Dolt the slowest of all, TiDB 2.3x MySQL's total); embedding their engines (go-mysql-server) would bring the same engines and far more code |
|
||||
| CockroachDB | not open source (proprietary licence) |
|
||||
| immudb | GitHub does not recognise its licence as an open-source one (`NOASSERTION`); an immutable ledger |
|
||||
| DiceDB, godis, rotom, Vasto, ledisdb | Redis-style servers, not libraries |
|
||||
| Dgraph, EliasDB, NornicDB, MemHop | graph databases |
|
||||
| InfluxDB, Prometheus, VictoriaMetrics, LinDB, lynxdb, nanotdb, unitdb | time series and logs |
|
||||
| Milvus | vector database |
|
||||
| column | in-memory columnar store |
|
||||
| objectbox-go | needs cgo |
|
||||
| CovenantSQL, Databunker, NoKV, piladb | blockchain, a PII vault, a metadata service, a stack |
|
||||
|
||||
## What would be needed to know more
|
||||
|
||||
A real answer for one engine means a handler for it: query pushdown, updates,
|
||||
indexes, statistics, 2,000-8,000 lines each. This benchmark says where it could pay:
|
||||
nowhere on reads (decoding dominates), and only on small writes, which the protocol
|
||||
hides. It does not measure what SQLite does in the engine and a key-value store
|
||||
would do in Go - sorting, `$or`, regular expressions, aggregation - where SQLite can
|
||||
only be ahead.
|
||||
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
)
|
||||
|
||||
type badgerEng struct {
|
||||
db *badger.DB
|
||||
dir string
|
||||
}
|
||||
|
||||
func openBadger() maker {
|
||||
return maker{name: "Badger", open: func(dir string) (Engine, error) {
|
||||
db, err := badger.Open(badger.DefaultOptions(dir).WithLogger(nil).WithSyncWrites(false))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &badgerEng{db: db, dir: dir}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func (e *badgerEng) Name() string { return "Badger" }
|
||||
|
||||
func (e *badgerEng) Load(cards []Card) error {
|
||||
wb := e.db.NewWriteBatch()
|
||||
defer wb.Cancel()
|
||||
for _, c := range cards {
|
||||
if err := wb.Set(docKey(c.ID), c.Raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := wb.Set(indexKey(c), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return wb.Flush()
|
||||
}
|
||||
|
||||
func (e *badgerEng) ByID(id string) ([]byte, error) {
|
||||
var out []byte
|
||||
err := e.db.View(func(tx *badger.Txn) error {
|
||||
it, err := tx.Get(docKey(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err = it.ValueCopy(nil)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (e *badgerEng) BoardCards(board string) ([][]byte, error) {
|
||||
var out [][]byte
|
||||
err := e.db.View(func(tx *badger.Txn) error {
|
||||
p := boardPrefix(board)
|
||||
it := tx.NewIterator(badger.IteratorOptions{Prefix: p})
|
||||
defer it.Close()
|
||||
var ids []string
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
ids = append(ids, idFromIndexKey(it.Item().Key()))
|
||||
}
|
||||
for _, id := range ids {
|
||||
item, err := tx.Get(docKey(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := item.ValueCopy(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (e *badgerEng) CountBoard(board string) (int, error) {
|
||||
n := 0
|
||||
err := e.db.View(func(tx *badger.Txn) error {
|
||||
it := tx.NewIterator(badger.IteratorOptions{Prefix: boardPrefix(board)})
|
||||
defer it.Close()
|
||||
for it.Rewind(); it.Valid(); it.Next() {
|
||||
n++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (e *badgerEng) Update(id string, change func([]byte) ([]byte, error)) error {
|
||||
return e.db.Update(func(tx *badger.Txn) error {
|
||||
item, err := tx.Get(docKey(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := item.ValueCopy(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := change(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Set(docKey(id), next)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *badgerEng) Insert(c Card) error {
|
||||
return e.db.Update(func(tx *badger.Txn) error {
|
||||
if err := tx.Set(docKey(c.ID), c.Raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Set(indexKey(c), nil)
|
||||
})
|
||||
}
|
||||
|
||||
// SizeBytes is -1: Badger keeps a recent write in its memtable and preallocates its
|
||||
// log files, so the directory says nothing about what the data takes.
|
||||
func (e *badgerEng) SizeBytes() int64 { return -1 }
|
||||
func (e *badgerEng) Close() error { return e.db.Close() }
|
||||
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
type boltEng struct {
|
||||
db *bolt.DB
|
||||
dir string
|
||||
}
|
||||
|
||||
// bbolt fsyncs on every commit, which the others here do not; "NoSync" is the same
|
||||
// engine without it, for a like-for-like with SQLite's synchronous=NORMAL.
|
||||
func openBolt(name string, noSync bool) maker {
|
||||
return maker{name: name, open: func(dir string) (Engine, error) {
|
||||
db, err := bolt.Open(filepath.Join(dir, "cards.bolt"), 0o600, &bolt.Options{NoSync: noSync})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.NoSync = noSync
|
||||
return &boltEng{db: db, dir: dir}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func (e *boltEng) Name() string { return "bbolt" }
|
||||
|
||||
func (e *boltEng) Load(cards []Card) error {
|
||||
return e.db.Update(func(tx *bolt.Tx) error {
|
||||
d, err := tx.CreateBucketIfNotExists([]byte("d"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := tx.CreateBucketIfNotExists([]byte("b"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range cards {
|
||||
if err := d.Put([]byte(c.ID), c.Raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.Put(indexKey(c)[2:], nil); err != nil { // without the b/
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (e *boltEng) ByID(id string) ([]byte, error) {
|
||||
var out []byte
|
||||
err := e.db.View(func(tx *bolt.Tx) error {
|
||||
v := tx.Bucket([]byte("d")).Get([]byte(id))
|
||||
if v == nil {
|
||||
return fmt.Errorf("not found")
|
||||
}
|
||||
out = append([]byte(nil), v...)
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (e *boltEng) scan(tx *bolt.Tx, board string, f func(id string) error) error {
|
||||
p := boardPrefix(board)[2:]
|
||||
c := tx.Bucket([]byte("b")).Cursor()
|
||||
for k, _ := c.Seek(p); k != nil && bytes.HasPrefix(k, p); k, _ = c.Next() {
|
||||
if err := f(idFromIndexKey(k)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *boltEng) BoardCards(board string) ([][]byte, error) {
|
||||
var out [][]byte
|
||||
err := e.db.View(func(tx *bolt.Tx) error {
|
||||
d := tx.Bucket([]byte("d"))
|
||||
return e.scan(tx, board, func(id string) error {
|
||||
out = append(out, append([]byte(nil), d.Get([]byte(id))...))
|
||||
return nil
|
||||
})
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (e *boltEng) CountBoard(board string) (int, error) {
|
||||
n := 0
|
||||
err := e.db.View(func(tx *bolt.Tx) error { return e.scan(tx, board, func(string) error { n++; return nil }) })
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (e *boltEng) Update(id string, change func([]byte) ([]byte, error)) error {
|
||||
return e.db.Update(func(tx *bolt.Tx) error {
|
||||
d := tx.Bucket([]byte("d"))
|
||||
cur := d.Get([]byte(id))
|
||||
if cur == nil {
|
||||
return fmt.Errorf("not found")
|
||||
}
|
||||
next, err := change(append([]byte(nil), cur...))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return d.Put([]byte(id), next)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *boltEng) Insert(c Card) error {
|
||||
return e.db.Update(func(tx *bolt.Tx) error {
|
||||
if err := tx.Bucket([]byte("d")).Put([]byte(c.ID), c.Raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Bucket([]byte("b")).Put(indexKey(c)[2:], nil)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *boltEng) SizeBytes() int64 { return dirSize(e.dir) }
|
||||
func (e *boltEng) Close() error { return e.db.Close() }
|
||||
@@ -0,0 +1,101 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/tidwall/buntdb"
|
||||
)
|
||||
|
||||
// BuntDB keeps everything in memory and writes an append-only file. It has indexes
|
||||
// on JSON fields of its own, so no index keys are built by hand.
|
||||
type buntEng struct {
|
||||
db *buntdb.DB
|
||||
dir string
|
||||
}
|
||||
|
||||
func openBunt() maker {
|
||||
return maker{name: "BuntDB (in memory with an append-only file)", open: func(dir string) (Engine, error) {
|
||||
db, err := buntdb.Open(filepath.Join(dir, "cards.db"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &buntEng{db: db, dir: dir}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func (e *buntEng) Name() string { return "BuntDB" }
|
||||
|
||||
func (e *buntEng) Load(cards []Card) error {
|
||||
if err := e.db.Update(func(tx *buntdb.Tx) error {
|
||||
for _, c := range cards {
|
||||
if _, _, err := tx.Set("card:"+c.ID, string(c.Raw), nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.db.CreateIndex("board", "card:*", buntdb.IndexJSON("boardId"), buntdb.IndexJSON("archived"))
|
||||
}
|
||||
|
||||
func (e *buntEng) ByID(id string) ([]byte, error) {
|
||||
var out []byte
|
||||
err := e.db.View(func(tx *buntdb.Tx) error {
|
||||
v, err := tx.Get("card:" + id)
|
||||
out = []byte(v)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (e *buntEng) pivot(board string) string {
|
||||
b, _ := json.Marshal(board)
|
||||
return fmt.Sprintf(`{"boardId":%s,"archived":false}`, b)
|
||||
}
|
||||
|
||||
func (e *buntEng) BoardCards(board string) ([][]byte, error) {
|
||||
var out [][]byte
|
||||
err := e.db.View(func(tx *buntdb.Tx) error {
|
||||
return tx.AscendEqual("board", e.pivot(board), func(_, v string) bool {
|
||||
out = append(out, []byte(v))
|
||||
return true
|
||||
})
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (e *buntEng) CountBoard(board string) (int, error) {
|
||||
n := 0
|
||||
err := e.db.View(func(tx *buntdb.Tx) error {
|
||||
return tx.AscendEqual("board", e.pivot(board), func(_, _ string) bool { n++; return true })
|
||||
})
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (e *buntEng) Update(id string, change func([]byte) ([]byte, error)) error {
|
||||
return e.db.Update(func(tx *buntdb.Tx) error {
|
||||
v, err := tx.Get("card:" + id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := change([]byte(v))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err = tx.Set("card:"+id, string(next), nil)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (e *buntEng) Insert(c Card) error {
|
||||
return e.db.Update(func(tx *buntdb.Tx) error {
|
||||
_, _, err := tx.Set("card:"+c.ID, string(c.Raw), nil)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (e *buntEng) SizeBytes() int64 { return dirSize(e.dir) }
|
||||
func (e *buntEng) Close() error { return e.db.Close() }
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
c "github.com/ostafen/clover/v2"
|
||||
d "github.com/ostafen/clover/v2/document"
|
||||
q "github.com/ostafen/clover/v2/query"
|
||||
)
|
||||
|
||||
// Clover is a document database with indexes and queries of its own, over Badger.
|
||||
type cloverEng struct {
|
||||
db *c.DB
|
||||
dir string
|
||||
}
|
||||
|
||||
func openClover() maker {
|
||||
return maker{name: "Clover (document database over Badger)", open: func(dir string) (Engine, error) {
|
||||
db, err := c.Open(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.CreateCollection("cards"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cloverEng{db: db, dir: dir}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func (e *cloverEng) Name() string { return "Clover" }
|
||||
|
||||
func toDoc(raw []byte) (*d.Document, error) {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// _id is Clover's own (a UUID); the card's id is kept in cid.
|
||||
m["cid"] = m["_id"]
|
||||
delete(m, "_id")
|
||||
return d.NewDocumentOf(m), nil
|
||||
}
|
||||
|
||||
// fromDoc is the card as JSON again, with its own _id back.
|
||||
func fromDoc(doc *d.Document) ([]byte, error) {
|
||||
m := map[string]any{}
|
||||
for k, v := range doc.AsMap() {
|
||||
m[k] = v
|
||||
}
|
||||
m["_id"] = m["cid"]
|
||||
delete(m, "cid")
|
||||
delete(m, "_id_clover")
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
func (e *cloverEng) Load(cards []Card) error {
|
||||
docs := make([]*d.Document, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
doc, err := toDoc(card.Raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
docs = append(docs, doc)
|
||||
}
|
||||
if err := e.db.Insert("cards", docs...); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range []string{"cid", "boardId"} {
|
||||
if err := e.db.CreateIndex("cards", f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *cloverEng) one(id string) (*d.Document, error) {
|
||||
return e.db.FindFirst(q.NewQuery("cards").Where(q.Field("cid").Eq(id)))
|
||||
}
|
||||
|
||||
func (e *cloverEng) ByID(id string) ([]byte, error) {
|
||||
doc, err := e.one(id)
|
||||
if err != nil || doc == nil {
|
||||
return nil, err
|
||||
}
|
||||
return fromDoc(doc)
|
||||
}
|
||||
|
||||
func (e *cloverEng) query(board string) *q.Query {
|
||||
return q.NewQuery("cards").Where(q.Field("boardId").Eq(board).And(q.Field("archived").Eq(false)))
|
||||
}
|
||||
|
||||
func (e *cloverEng) BoardCards(board string) ([][]byte, error) {
|
||||
docs, err := e.db.FindAll(e.query(board))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Clover returns decoded documents. They are encoded again so the runner decodes
|
||||
// them like every other engine's - which charges Clover for a step it does not need.
|
||||
out := make([][]byte, 0, len(docs))
|
||||
for _, doc := range docs {
|
||||
b, err := fromDoc(doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (e *cloverEng) CountBoard(board string) (int, error) { return e.db.Count(e.query(board)) }
|
||||
|
||||
func (e *cloverEng) Update(id string, change func([]byte) ([]byte, error)) error {
|
||||
doc, err := e.one(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := fromDoc(doc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := change(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(next, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
m["cid"] = m["_id"]
|
||||
delete(m, "_id")
|
||||
return e.db.Update(q.NewQuery("cards").Where(q.Field("cid").Eq(id)), m)
|
||||
}
|
||||
|
||||
func (e *cloverEng) Insert(card Card) error {
|
||||
doc, err := toDoc(card.Raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = e.db.InsertOne("cards", doc)
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *cloverEng) SizeBytes() int64 { return dirSize(e.dir) }
|
||||
func (e *cloverEng) Close() error { return e.db.Close() }
|
||||
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
// engines lists what is compared, the baseline first.
|
||||
func engines() []maker {
|
||||
return []maker{
|
||||
openSQLite("SQLite as FerretDB uses it (JSON, expression indexes)", "normal", false),
|
||||
openSQLite("SQLite, plain columns (not what FerretDB does)", "normal", true),
|
||||
openSQLite("SQLite as FerretDB uses it, synchronous=FULL", "full", false),
|
||||
openPebble(),
|
||||
openBadger(),
|
||||
openBolt("bbolt, no fsync", true),
|
||||
openBolt("bbolt, fsync on every commit (its default)", false),
|
||||
openBunt(),
|
||||
openNuts(),
|
||||
openClover(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use strict';
|
||||
|
||||
// Writes the speed test's deterministic data as JSON lines for the Go benchmark of
|
||||
// embedded engines (bench.go): the cards, with the fields the queries read, and the
|
||||
// parameters (the 3 biggest boards and 12 ordinary ones) the same way run.cjs picks
|
||||
// them. node embedded/export.cjs <scale> <out-directory>
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const data = require('../data.cjs');
|
||||
|
||||
const scale = Number(process.argv[2] || '0.05');
|
||||
const out = path.resolve(process.argv[3] || '.');
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
|
||||
const plan = data.plan({ scale });
|
||||
const lines = [];
|
||||
const perBoard = new Map();
|
||||
let n = 0;
|
||||
for (const batch of data.documents(plan, 'cards', 5000)) {
|
||||
for (const doc of batch) {
|
||||
lines.push(JSON.stringify(doc, (k, v) => (v instanceof Date ? { $date: v.getTime() } : v)));
|
||||
perBoard.set(doc.boardId, (perBoard.get(doc.boardId) || 0) + 1);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(path.join(out, 'cards.jsonl'), `${lines.join('\n')}\n`);
|
||||
|
||||
// Parameters: the 3 biggest boards, then 12 ordinary ones spread over the rest.
|
||||
const boards = [...perBoard.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const biggest = boards.slice(0, 3).map(b => b[0]);
|
||||
const rest = boards.slice(3);
|
||||
const step = Math.max(1, Math.floor(rest.length / 12));
|
||||
const ordinary = Array.from({ length: 12 }, (_, i) => rest[Math.min(rest.length - 1, i * step)][0]);
|
||||
fs.writeFileSync(path.join(out, 'params.json'), JSON.stringify({ scale, cards: n, boards: [...biggest, ...ordinary], sizes: boards.slice(0, 3).map(b => b[1]) }));
|
||||
console.log(`${n} cards, ${perBoard.size} boards; biggest boards have ${boards.slice(0, 3).map(b => b[1]).join(', ')} cards`);
|
||||
@@ -0,0 +1,86 @@
|
||||
module embeddedbench
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/cockroachdb/pebble v1.1.5
|
||||
github.com/dgraph-io/badger/v4 v4.9.6
|
||||
github.com/nutsdb/nutsdb v1.1.0
|
||||
github.com/ostafen/clover/v2 v2.0.0-alpha.3
|
||||
github.com/tidwall/buntdb v1.3.2
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
modernc.org/sqlite v1.61.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/DataDog/zstd v1.4.5 // indirect
|
||||
github.com/antlabs/stl v0.0.2 // indirect
|
||||
github.com/antlabs/timer v0.1.4 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bwmarrin/snowflake v0.3.0 // indirect
|
||||
github.com/cespare/xxhash v1.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cockroachdb/errors v1.11.3 // indirect
|
||||
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce // indirect
|
||||
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect
|
||||
github.com/cockroachdb/redact v1.1.5 // indirect
|
||||
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgraph-io/badger/v3 v3.2103.2 // indirect
|
||||
github.com/dgraph-io/ristretto v0.1.0 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
github.com/edsrzf/mmap-go v1.2.0 // indirect
|
||||
github.com/getsentry/sentry-go v0.27.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gofrs/flock v0.8.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/glog v1.2.4 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/flatbuffers v25.2.10+incompatible // indirect
|
||||
github.com/google/orderedcode v0.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.7 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
|
||||
github.com/ncruces/go-strftime v1.1.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.15.0 // indirect
|
||||
github.com/prometheus/client_model v0.3.0 // indirect
|
||||
github.com/prometheus/common v0.42.0 // indirect
|
||||
github.com/prometheus/procfs v0.9.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/satori/go.uuid v1.2.0 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/tidwall/btree v1.6.0 // indirect
|
||||
github.com/tidwall/gjson v1.14.3 // indirect
|
||||
github.com/tidwall/grect v0.1.4 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tidwall/rtred v0.1.2 // indirect
|
||||
github.com/tidwall/tinyqueue v0.1.1 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/xujiajun/utils v0.0.0-20220904132955-5f7c5b914235 // indirect
|
||||
go.opencensus.io v0.23.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
|
||||
golang.org/x/net v0.62.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
google.golang.org/protobuf v1.36.7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.79.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
@@ -0,0 +1,379 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/antlabs/stl v0.0.2 h1:sna1AXR5yIkNE9lWhCcKbheFJSVfCa3vugnGyakI79s=
|
||||
github.com/antlabs/stl v0.0.2/go.mod h1:kKrO4xrn9cfS1mJVo+/BqePZjAYMXqD0amGF2Ouq7ac=
|
||||
github.com/antlabs/timer v0.1.4 h1:MHdE00MDnNfhJCmqSOdLXs35uGNwfkMwfbynxrGmQ1c=
|
||||
github.com/antlabs/timer v0.1.4/go.mod h1:mpw4zlD5KVjstEyUDp43DGLWsY076Mdo4bS78NTseRE=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/brianvoe/gofakeit/v6 v6.17.0 h1:obbQTJeHfktJtiZzq0Q1bEpsNUs+yHrYlPVWt7BtmJ4=
|
||||
github.com/brianvoe/gofakeit/v6 v6.17.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8=
|
||||
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4=
|
||||
github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU=
|
||||
github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I=
|
||||
github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8=
|
||||
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4=
|
||||
github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M=
|
||||
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
|
||||
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
|
||||
github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw=
|
||||
github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo=
|
||||
github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
|
||||
github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
|
||||
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo=
|
||||
github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.2 h1:dpyM5eCJAtQCBcMCZcT4UBZchuTJgCywerHHgmxfxM8=
|
||||
github.com/dgraph-io/badger/v3 v3.2103.2/go.mod h1:RHo4/GmYcKKh5Lxu63wLEMHJ70Pac2JqZRYGhlyAo2M=
|
||||
github.com/dgraph-io/badger/v4 v4.9.6 h1:IQqMPVGLNCQr1b4Mu8lHkYm/xyqFRsyKaFEtyLi9CCQ=
|
||||
github.com/dgraph-io/badger/v4 v4.9.6/go.mod h1:Xa9dAupjbwAacupWFCpa6YEn9E1PjBXkfZYr2I/8aWg=
|
||||
github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI=
|
||||
github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
||||
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||
github.com/edsrzf/mmap-go v1.2.0 h1:hXLYlkbaPzt1SaQk+anYwKSRNhufIDCchSPkUD6dD84=
|
||||
github.com/edsrzf/mmap-go v1.2.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps=
|
||||
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
|
||||
github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc=
|
||||
github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v2.0.6+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
||||
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/orderedcode v0.0.1 h1:UzfcAexk9Vhv8+9pNOgRu41f16lHq725vPwnSeiG/Us=
|
||||
github.com/google/orderedcode v0.0.1/go.mod h1:iVyU4/qPKHY5h/wSd6rZZCDcLJNxiWO6dvsYES2Sb20=
|
||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50 h1:PlNWJRxu5APSV0pWqfIsZ0qt9OOGHSWfZQ4zvx+WgWs=
|
||||
github.com/google/pprof v0.0.0-20261016150608-1facf8f91b50/go.mod h1:yUo1AMMpsPIDFGuwDK8nLcf8+vvAd5l2O6JDZ6hpwrM=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
|
||||
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
||||
github.com/nutsdb/nutsdb v1.1.0 h1:fNGFzBHGqF2mB5BF8Qk8W94c3/ZzwdCdKAH7azwx70Y=
|
||||
github.com/nutsdb/nutsdb v1.1.0/go.mod h1:aKCtgSprZf2Mp1dIQD00Iya3DttoTErSSOnRx5ZtpAs=
|
||||
github.com/ostafen/clover/v2 v2.0.0-alpha.3 h1:fXC7tVHQkUPFlxlj/kD98h0ngrTpIeJymaxVIqDzw3Q=
|
||||
github.com/ostafen/clover/v2 v2.0.0-alpha.3/go.mod h1:5YCDt+wJDUNN1uSXE5csxSQBuJrNjidkOkJTXWuNhDY=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM=
|
||||
github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
|
||||
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
|
||||
github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM=
|
||||
github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc=
|
||||
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI=
|
||||
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/assert v0.1.0 h1:aWcKyRBUAdLoVebxo95N7+YZVTFF/ASTr7BN4sLP6XI=
|
||||
github.com/tidwall/assert v0.1.0/go.mod h1:QLYtGyeqse53vuELQheYl9dngGCJQ+mTtlxcktb+Kj8=
|
||||
github.com/tidwall/btree v1.6.0 h1:LDZfKfQIBHGHWSwckhXI0RPSXzlo+KYdjK7FWSqOzzg=
|
||||
github.com/tidwall/btree v1.6.0/go.mod h1:twD9XRA5jj9VUQGELzDO4HPQTNJsoWWfYEL+EUQ2cKY=
|
||||
github.com/tidwall/buntdb v1.3.2 h1:qd+IpdEGs0pZci37G4jF51+fSKlkuUTMXuHhXL1AkKg=
|
||||
github.com/tidwall/buntdb v1.3.2/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU=
|
||||
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
|
||||
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg=
|
||||
github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q=
|
||||
github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8=
|
||||
github.com/tidwall/lotsa v1.0.2/go.mod h1:X6NiU+4yHA3fE3Puvpnn1XMDrFZrE9JO2/w+UMuqgR8=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/rtred v0.1.2 h1:exmoQtOLvDoO8ud++6LwVsAMTu0KPzLTUrMln8u1yu8=
|
||||
github.com/tidwall/rtred v0.1.2/go.mod h1:hd69WNXQ5RP9vHd7dqekAz+RIdtfBogmglkZSRxCHFQ=
|
||||
github.com/tidwall/tinyqueue v0.1.1 h1:SpNEvEggbpyN5DIReaJ2/1ndroY8iyEGxPYxoSaymYE=
|
||||
github.com/tidwall/tinyqueue v0.1.1/go.mod h1:O/QNHwrnjqr6IHItYrzoHAKYhBkLI67Q096fQP5zMYw=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/xujiajun/utils v0.0.0-20220904132955-5f7c5b914235 h1:w0si+uee0iAaCJO9q86T6yrhdadgcsoNuh47LrUykzg=
|
||||
github.com/xujiajun/utils v0.0.0-20220904132955-5f7c5b914235/go.mod h1:MR4+0R6A9NS5IABnIM3384FfOq8QFVnm7WDrBOhIaMU=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4=
|
||||
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||
go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
|
||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME=
|
||||
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20220728211354-c7608f3a8462/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
|
||||
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
modernc.org/cc/v4 v4.30.3 h1:aDq8gIX1X7+VFgO/JYYji6pLWL2H4tv3g8WatT3KCys=
|
||||
modernc.org/cc/v4 v4.30.3/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.37.0 h1:BAuJCUtIHIxFIYaFhqXIw5YaoMBlNCUlFovejHfMip4=
|
||||
modernc.org/ccgo/v4 v4.37.0/go.mod h1:/8627VhwWtAbqUXmPORxFQU5SgLrAx2jl9seLw9GBhY=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
|
||||
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.79.0 h1:9Dvvv++bAMSkSuBzuOFAatbTV5OIoq6Y6FohmD3e7q8=
|
||||
modernc.org/libc v1.79.0/go.mod h1:uT3kR61NsbeKJSLqq83GJwSnZMVLlSjyAz4DfTaBy1M=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.61.0 h1:sQi63oLQ9uquAUA4CzOisH35Q3pEszkPBuHM0tFL8CY=
|
||||
modernc.org/sqlite v1.61.0/go.mod h1:f4gr5tbpx2umklaT/QVtbi/50hNdM+4ng+2zMo8KXJ0=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// The key layout a handler on a key-value store would have to build: the document
|
||||
// under d/<id>, and the index of (boardId, archived) as keys b/<board>/<0|1>/<id>
|
||||
// with no value. A scan of the prefix b/<board>/0/ is "the unarchived cards of a
|
||||
// board" and counting its keys is the count.
|
||||
func docKey(id string) []byte { return []byte("d/" + id) }
|
||||
|
||||
func boardPrefix(board string) []byte { return []byte("b/" + board + "/0/") }
|
||||
|
||||
func indexKey(c Card) []byte {
|
||||
a := "0"
|
||||
if c.Archived {
|
||||
a = "1"
|
||||
}
|
||||
return []byte("b/" + c.Board + "/" + a + "/" + c.ID)
|
||||
}
|
||||
|
||||
func idFromIndexKey(k []byte) string {
|
||||
s := string(k)
|
||||
return s[strings.LastIndexByte(s, '/')+1:]
|
||||
}
|
||||
|
||||
// prefixEnd is the first key after every key with the prefix.
|
||||
func prefixEnd(p []byte) []byte {
|
||||
end := append([]byte(nil), p...)
|
||||
end[len(end)-1]++
|
||||
return end
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Embedded engines for FerretDB, compared with the SQLite it uses today.
|
||||
//
|
||||
// FerretDB is one Go binary, and its SQLite handler is the modernc.org/sqlite driver
|
||||
// inside it. Which of the databases implemented in Go (awesome-go) could be linked in
|
||||
// beside it, and is any of them faster for what WeKan asks? This is the engine
|
||||
// level of that question: the same cards, the same questions, asked of each engine
|
||||
// the way a handler written for it would ask. See README.md.
|
||||
//
|
||||
// go run . -data <dir with cards.jsonl and params.json> [-only sqlite,pebble] [-out results.json]
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Card is one WeKan card: what the questions read, and the whole document.
|
||||
type Card struct {
|
||||
ID string `json:"_id"`
|
||||
Board string `json:"boardId"`
|
||||
List string `json:"listId"`
|
||||
Archived bool `json:"archived"`
|
||||
Raw []byte `json:"-"`
|
||||
}
|
||||
|
||||
// Engine is what a FerretDB handler needs from a storage engine for WeKan.
|
||||
type Engine interface {
|
||||
Name() string
|
||||
// Load stores the cards and builds the indexes: by _id, and by (boardId, archived).
|
||||
Load(cards []Card) error
|
||||
ByID(id string) ([]byte, error)
|
||||
// BoardCards returns the unarchived cards of a board.
|
||||
BoardCards(board string) ([][]byte, error)
|
||||
CountBoard(board string) (int, error)
|
||||
// Update reads the card, changes it in Go and writes it back, which is what
|
||||
// FerretDB's updateOne does.
|
||||
Update(id string, change func([]byte) ([]byte, error)) error
|
||||
Insert(c Card) error
|
||||
SizeBytes() int64
|
||||
Close() error
|
||||
}
|
||||
|
||||
type params struct {
|
||||
Scale float64 `json:"scale"`
|
||||
Cards int `json:"cards"`
|
||||
Boards []string `json:"boards"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
dataDir := flag.String("data", "", "directory with cards.jsonl and params.json")
|
||||
only := flag.String("only", "", "comma separated engine names to run")
|
||||
outFile := flag.String("out", "", "write the results as JSON")
|
||||
workDir := flag.String("work", "", "where the engines keep their files (default: a temporary directory)")
|
||||
flag.Parse()
|
||||
|
||||
cards, p := readData(*dataDir)
|
||||
byBoard := map[string][]Card{}
|
||||
for _, c := range cards {
|
||||
byBoard[c.Board] = append(byBoard[c.Board], c)
|
||||
}
|
||||
|
||||
root := *workDir
|
||||
if root == "" {
|
||||
var err error
|
||||
root, err = os.MkdirTemp("", "embeddedbench")
|
||||
must(err)
|
||||
}
|
||||
defer os.RemoveAll(root)
|
||||
|
||||
var results []Result
|
||||
for _, mk := range engines() {
|
||||
name := mk.name
|
||||
if *only != "" && !contains(strings.Split(*only, ","), name) {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(root, strings.NewReplacer(" ", "_", "/", "_", "(", "", ")", "").Replace(name))
|
||||
must(os.MkdirAll(dir, 0o755))
|
||||
fmt.Fprintf(os.Stderr, "== %s\n", name)
|
||||
res, err := run(mk.open, name, dir, cards, byBoard, p)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " FAILED: %v\n", err)
|
||||
results = append(results, Result{Name: name, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
results = append(results, *res)
|
||||
}
|
||||
|
||||
report(results, p)
|
||||
if *outFile != "" {
|
||||
b, _ := json.MarshalIndent(results, "", " ")
|
||||
must(os.WriteFile(*outFile, b, 0o644))
|
||||
}
|
||||
}
|
||||
|
||||
func readData(dir string) ([]Card, params) {
|
||||
var p params
|
||||
b, err := os.ReadFile(filepath.Join(dir, "params.json"))
|
||||
must(err)
|
||||
must(json.Unmarshal(b, &p))
|
||||
|
||||
f, err := os.Open(filepath.Join(dir, "cards.jsonl"))
|
||||
must(err)
|
||||
defer f.Close()
|
||||
|
||||
var cards []Card
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<24)
|
||||
for sc.Scan() {
|
||||
line := bytes.TrimSpace(sc.Bytes())
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var c Card
|
||||
must(json.Unmarshal(line, &c))
|
||||
c.Raw = append([]byte(nil), line...)
|
||||
cards = append(cards, c)
|
||||
}
|
||||
must(sc.Err())
|
||||
return cards, p
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func median(d []time.Duration) time.Duration {
|
||||
sort.Slice(d, func(i, j int) bool { return d[i] < d[j] })
|
||||
return d[len(d)/2]
|
||||
}
|
||||
|
||||
// modify is the change an updateOne makes: decode, set, encode.
|
||||
func modify(raw []byte, key string, value any) ([]byte, error) {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m[key] = value
|
||||
return json.Marshal(m)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/nutsdb/nutsdb"
|
||||
)
|
||||
|
||||
type nutsEng struct {
|
||||
db *nutsdb.DB
|
||||
dir string
|
||||
}
|
||||
|
||||
func openNuts() maker {
|
||||
return maker{name: "Nutsdb", open: func(dir string) (Engine, error) {
|
||||
db, err := nutsdb.Open(nutsdb.DefaultOptions, nutsdb.WithDir(dir), nutsdb.WithSyncEnable(false))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, b := range []string{"d", "b"} {
|
||||
if err := db.Update(func(tx *nutsdb.Tx) error { return tx.NewBucket(nutsdb.DataStructureBTree, b) }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &nutsEng{db: db, dir: dir}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func (e *nutsEng) Name() string { return "Nutsdb" }
|
||||
|
||||
func (e *nutsEng) Load(cards []Card) error {
|
||||
for start := 0; start < len(cards); start += 2000 {
|
||||
end := min(start+2000, len(cards))
|
||||
if err := e.db.Update(func(tx *nutsdb.Tx) error {
|
||||
for _, c := range cards[start:end] {
|
||||
if err := tx.Put("d", []byte(c.ID), c.Raw, nutsdb.Persistent); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Put("b", indexKey(c)[2:], []byte{1}, nutsdb.Persistent); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *nutsEng) ByID(id string) ([]byte, error) {
|
||||
var out []byte
|
||||
err := e.db.View(func(tx *nutsdb.Tx) error {
|
||||
v, err := tx.Get("d", []byte(id))
|
||||
out = append([]byte(nil), v...)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (e *nutsEng) ids(tx *nutsdb.Tx, board string) ([]string, error) {
|
||||
p := boardPrefix(board)[2:]
|
||||
keys, _, err := tx.PrefixScanEntries("b", p, "", 0, 1<<30, true, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, nutsdb.ErrPrefixScan) || errors.Is(err, nutsdb.ErrNotFoundKey) {
|
||||
return nil, nil // a prefix with nothing under it is an error here, not an empty list
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var ids []string
|
||||
for _, k := range keys {
|
||||
if !bytes.HasPrefix(k, p) {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, idFromIndexKey(k))
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (e *nutsEng) BoardCards(board string) ([][]byte, error) {
|
||||
var out [][]byte
|
||||
err := e.db.View(func(tx *nutsdb.Tx) error {
|
||||
ids, err := e.ids(tx, board)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
v, err := tx.Get("d", []byte(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, append([]byte(nil), v...))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (e *nutsEng) CountBoard(board string) (int, error) {
|
||||
n := 0
|
||||
err := e.db.View(func(tx *nutsdb.Tx) error {
|
||||
ids, err := e.ids(tx, board)
|
||||
n = len(ids)
|
||||
return err
|
||||
})
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (e *nutsEng) Update(id string, change func([]byte) ([]byte, error)) error {
|
||||
return e.db.Update(func(tx *nutsdb.Tx) error {
|
||||
v, err := tx.Get("d", []byte(id))
|
||||
if err != nil {
|
||||
return fmt.Errorf("get %s: %w", id, err)
|
||||
}
|
||||
next, err := change(append([]byte(nil), v...))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Put("d", []byte(id), next, nutsdb.Persistent)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *nutsEng) Insert(c Card) error {
|
||||
return e.db.Update(func(tx *nutsdb.Tx) error {
|
||||
if err := tx.Put("d", []byte(c.ID), c.Raw, nutsdb.Persistent); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Put("b", indexKey(c)[2:], []byte{1}, nutsdb.Persistent)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *nutsEng) SizeBytes() int64 { return dirSize(e.dir) }
|
||||
func (e *nutsEng) Close() error { return e.db.Close() }
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/cockroachdb/pebble"
|
||||
)
|
||||
|
||||
type pebbleEng struct {
|
||||
db *pebble.DB
|
||||
dir string
|
||||
}
|
||||
|
||||
func openPebble() maker {
|
||||
return maker{name: "Pebble (CockroachDB's LSM)", open: func(dir string) (Engine, error) {
|
||||
db, err := pebble.Open(dir, &pebble.Options{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pebbleEng{db: db, dir: dir}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func (e *pebbleEng) Name() string { return "Pebble" }
|
||||
|
||||
func (e *pebbleEng) Load(cards []Card) error {
|
||||
b := e.db.NewBatch()
|
||||
for i, c := range cards {
|
||||
if err := b.Set(docKey(c.ID), c.Raw, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.Set(indexKey(c), nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
if i%5000 == 4999 {
|
||||
if err := b.Commit(pebble.NoSync); err != nil {
|
||||
return err
|
||||
}
|
||||
b = e.db.NewBatch()
|
||||
}
|
||||
}
|
||||
if err := b.Commit(pebble.NoSync); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.db.Flush()
|
||||
}
|
||||
|
||||
func (e *pebbleEng) ByID(id string) ([]byte, error) {
|
||||
v, closer, err := e.db.Get(docKey(id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closer.Close()
|
||||
return append([]byte(nil), v...), nil
|
||||
}
|
||||
|
||||
func (e *pebbleEng) ids(board string) ([]string, error) {
|
||||
p := boardPrefix(board)
|
||||
it, err := e.db.NewIter(&pebble.IterOptions{LowerBound: p, UpperBound: prefixEnd(p)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer it.Close()
|
||||
var ids []string
|
||||
for ok := it.First(); ok; ok = it.Next() {
|
||||
ids = append(ids, idFromIndexKey(it.Key()))
|
||||
}
|
||||
return ids, it.Error()
|
||||
}
|
||||
|
||||
func (e *pebbleEng) BoardCards(board string) ([][]byte, error) {
|
||||
ids, err := e.ids(board)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([][]byte, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
d, err := e.ByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (e *pebbleEng) CountBoard(board string) (int, error) {
|
||||
ids, err := e.ids(board)
|
||||
return len(ids), err
|
||||
}
|
||||
|
||||
func (e *pebbleEng) Update(id string, change func([]byte) ([]byte, error)) error {
|
||||
d, err := e.ByID(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := change(d)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.db.Set(docKey(id), next, pebble.NoSync)
|
||||
}
|
||||
|
||||
func (e *pebbleEng) Insert(c Card) error {
|
||||
b := e.db.NewBatch()
|
||||
_ = b.Set(docKey(c.ID), c.Raw, nil)
|
||||
_ = b.Set(indexKey(c), nil, nil)
|
||||
return b.Commit(pebble.NoSync)
|
||||
}
|
||||
|
||||
func (e *pebbleEng) SizeBytes() int64 { return dirSize(e.dir) }
|
||||
func (e *pebbleEng) Close() error { return e.db.Close() }
|
||||
@@ -0,0 +1,238 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type maker struct {
|
||||
name string
|
||||
open func(dir string) (Engine, error)
|
||||
}
|
||||
|
||||
// Result is one engine's numbers. Times are milliseconds for one call.
|
||||
type Result struct {
|
||||
Name string `json:"name"`
|
||||
Error string `json:"error,omitempty"`
|
||||
LoadMs float64 `json:"loadMs"`
|
||||
SizeMB float64 `json:"sizeMB"`
|
||||
Queries map[string]float64 `json:"queries"`
|
||||
Slowest map[string]float64 `json:"slowest"`
|
||||
TotalMs float64 `json:"totalMs"`
|
||||
TotalSlow float64 `json:"totalSlowestMs"`
|
||||
}
|
||||
|
||||
var queryOrder = []string{"byId", "boardCards", "countBoard", "update", "move", "insert"}
|
||||
|
||||
// sample times f: it is repeated until about 15 ms have passed (at most 400 times)
|
||||
// and the mean of one call is returned, so a microsecond call is not lost in the
|
||||
// clock. It is taken three times and the median returned.
|
||||
func sample(f func(i int) error) (time.Duration, error) {
|
||||
var out []time.Duration
|
||||
for pass := 0; pass < 3; pass++ {
|
||||
start := time.Now()
|
||||
n := 0
|
||||
for n < 400 && (n == 0 || time.Since(start) < 15*time.Millisecond) {
|
||||
if err := f(n); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n++
|
||||
}
|
||||
out = append(out, time.Since(start)/time.Duration(n))
|
||||
}
|
||||
return median(out), nil
|
||||
}
|
||||
|
||||
func run(open func(string) (Engine, error), name, dir string, cards []Card, byBoard map[string][]Card, p params) (*Result, error) {
|
||||
e, err := open(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer e.Close()
|
||||
|
||||
res := &Result{Name: name, Queries: map[string]float64{}, Slowest: map[string]float64{}}
|
||||
|
||||
t0 := time.Now()
|
||||
if err = e.Load(cards); err != nil {
|
||||
return nil, fmt.Errorf("load: %w", err)
|
||||
}
|
||||
res.LoadMs = float64(time.Since(t0).Microseconds()) / 1000
|
||||
res.SizeMB = float64(e.SizeBytes()) / (1 << 20)
|
||||
|
||||
// Correctness first: a fast wrong answer is no answer.
|
||||
for _, b := range p.Boards {
|
||||
want := 0
|
||||
for _, c := range byBoard[b] {
|
||||
if !c.Archived {
|
||||
want++
|
||||
}
|
||||
}
|
||||
docs, err := e.BoardCards(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := e.CountBoard(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(docs) != want || n != want {
|
||||
return nil, fmt.Errorf("board %s: %d cards and a count of %d, want %d", b, len(docs), n, want)
|
||||
}
|
||||
first := byBoard[b][0]
|
||||
raw, err := e.ByID(first.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var back Card
|
||||
if err := json.Unmarshal(raw, &back); err != nil || back.ID != first.ID || back.Board != first.Board {
|
||||
return nil, fmt.Errorf("byId %s returned %q", first.ID, string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
// Reads: each is asked with every parameter board, and the cell is the average of
|
||||
// the per-parameter medians, like the speed test's. "Slowest" is the worst board.
|
||||
reads := map[string]func(b string, i int) error{
|
||||
"byId": func(b string, i int) error {
|
||||
cs := byBoard[b]
|
||||
_, err := e.ByID(cs[i%len(cs)].ID)
|
||||
return err
|
||||
},
|
||||
"boardCards": func(b string, i int) error {
|
||||
docs, err := e.BoardCards(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range docs { // decoded, as FerretDB must
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(d, &m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
"countBoard": func(b string, i int) error { _, err := e.CountBoard(b); return err },
|
||||
// The same fetch without decoding: the part that differs between engines.
|
||||
"boardRaw": func(b string, i int) error { _, err := e.BoardCards(b); return err },
|
||||
}
|
||||
for _, q := range []string{"byId", "boardCards", "boardRaw", "countBoard"} {
|
||||
var sum, worst float64
|
||||
for _, b := range p.Boards {
|
||||
f := reads[q]
|
||||
d, err := sample(func(i int) error { return f(b, i) })
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", q, err)
|
||||
}
|
||||
ms := float64(d.Nanoseconds()) / 1e6
|
||||
sum += ms
|
||||
if ms > worst {
|
||||
worst = ms
|
||||
}
|
||||
}
|
||||
res.Queries[q] = sum / float64(len(p.Boards))
|
||||
res.Slowest[q] = worst
|
||||
}
|
||||
|
||||
// Writes: 200 different cards each, so nothing is a no-op; the mean of a call.
|
||||
ids := make([]string, 0, 200)
|
||||
for i := 0; i < len(cards) && len(ids) < 200; i += len(cards) / 200 {
|
||||
ids = append(ids, cards[i].ID)
|
||||
}
|
||||
writes := []struct {
|
||||
name string
|
||||
f func(i int) error
|
||||
}{
|
||||
{"update", func(i int) error {
|
||||
return e.Update(ids[i%len(ids)], func(raw []byte) ([]byte, error) { return modify(raw, "title", fmt.Sprintf("Edited %d", i)) })
|
||||
}},
|
||||
{"move", func(i int) error {
|
||||
return e.Update(ids[(i+1)%len(ids)], func(raw []byte) ([]byte, error) {
|
||||
m, err := modify(raw, "sort", 100000+i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return modify(m, "listId", "moved")
|
||||
})
|
||||
}},
|
||||
{"insert", func(i int) error {
|
||||
doc := fmt.Sprintf(`{"_id":"new%d","boardId":"%s","listId":"l","archived":false,"title":"New","sort":%d}`, i, p.Boards[0], i)
|
||||
return e.Insert(Card{ID: fmt.Sprintf("new%d", i), Board: p.Boards[0], Raw: []byte(doc)})
|
||||
}},
|
||||
}
|
||||
for _, w := range writes {
|
||||
start := time.Now()
|
||||
for i := 0; i < len(ids); i++ {
|
||||
if err := w.f(i); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", w.name, err)
|
||||
}
|
||||
}
|
||||
ms := float64(time.Since(start).Nanoseconds()) / 1e6 / float64(len(ids))
|
||||
res.Queries[w.name] = ms
|
||||
res.Slowest[w.name] = ms
|
||||
}
|
||||
|
||||
// The writes must be there.
|
||||
raw, err := e.ByID(ids[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.Contains(string(raw), `"moved"`) {
|
||||
return nil, fmt.Errorf("the move was not stored: %s", raw)
|
||||
}
|
||||
|
||||
for _, q := range queryOrder {
|
||||
res.TotalMs += res.Queries[q]
|
||||
res.TotalSlow += res.Slowest[q]
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func report(results []Result, p params) {
|
||||
ok := results[:0:0]
|
||||
for _, r := range results {
|
||||
if r.Error == "" {
|
||||
ok = append(ok, r)
|
||||
}
|
||||
}
|
||||
// Fastest first.
|
||||
for i := range ok {
|
||||
for j := i + 1; j < len(ok); j++ {
|
||||
if ok[j].TotalMs < ok[i].TotalMs {
|
||||
ok[i], ok[j] = ok[j], ok[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nScale %v: %d cards, %d parameter boards. Milliseconds for one call; total = sum of the six.\n\n", p.Scale, p.Cards, len(p.Boards))
|
||||
fmt.Println("| # | Engine | Total | By id | Board cards | (raw, not decoded) | Count | Edit | Move | Add | Load (s) | Size (MB) |")
|
||||
fmt.Println("| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |")
|
||||
for i, r := range ok {
|
||||
size := fmt.Sprintf("%.0f", r.SizeMB)
|
||||
if r.SizeMB < 0 {
|
||||
size = "n/a"
|
||||
}
|
||||
fmt.Printf("| %d | %s | **%s** | %s | %s | %s | %s | %s | %s | %s | %.1f | %s |\n", i+1, r.Name, ms(r.TotalMs),
|
||||
ms(r.Queries["byId"]), ms(r.Queries["boardCards"]), ms(r.Queries["boardRaw"]), ms(r.Queries["countBoard"]),
|
||||
ms(r.Queries["update"]), ms(r.Queries["move"]), ms(r.Queries["insert"]), r.LoadMs/1000, size)
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.Error != "" {
|
||||
fmt.Printf("\n%s: %s\n", r.Name, r.Error)
|
||||
}
|
||||
}
|
||||
_ = os.Stdout.Sync()
|
||||
}
|
||||
|
||||
func ms(v float64) string {
|
||||
switch {
|
||||
case v < 0.01:
|
||||
return fmt.Sprintf("%.4f", v)
|
||||
case v < 10:
|
||||
return fmt.Sprintf("%.3f", v)
|
||||
case v < 100:
|
||||
return fmt.Sprintf("%.1f", v)
|
||||
default:
|
||||
return fmt.Sprintf("%.0f", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// SQLite as FerretDB uses it: modernc.org/sqlite, WAL, synchronous NORMAL, a 64 MiB
|
||||
// cache and 256 MiB of mmap (FerretDB's defaults, metadata/pool/uri.go). The
|
||||
// document is JSON text and the indexes are on json_extract expressions, which is
|
||||
// how FerretDB indexes a field. "columns" keeps the same document but indexes real
|
||||
// columns, which FerretDB does not do; it shows what SQLite itself could do.
|
||||
type sqliteEng struct {
|
||||
name string
|
||||
db *sql.DB
|
||||
dir string
|
||||
columns bool
|
||||
}
|
||||
|
||||
func openSQLite(name, sync string, columns bool) maker {
|
||||
return maker{name: name, open: func(dir string) (Engine, error) {
|
||||
path := filepath.Join(dir, "cards.sqlite")
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(wal)&_pragma=synchronous(%s)&_pragma=cache_size(-65536)&_pragma=mmap_size(268435456)&_pragma=temp_store(memory)&_pragma=busy_timeout(30000)&_txlock=immediate", path, sync)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
return &sqliteEng{name: name, db: db, dir: dir, columns: columns}, nil
|
||||
}}
|
||||
}
|
||||
|
||||
func (e *sqliteEng) Name() string { return e.name }
|
||||
|
||||
func (e *sqliteEng) Load(cards []Card) error {
|
||||
create := `CREATE TABLE cards (doc TEXT NOT NULL)`
|
||||
insert := `INSERT INTO cards (doc) VALUES (?)`
|
||||
if e.columns {
|
||||
create = `CREATE TABLE cards (id TEXT PRIMARY KEY, board TEXT, archived INTEGER, doc TEXT NOT NULL)`
|
||||
insert = `INSERT INTO cards (id, board, archived, doc) VALUES (?, ?, ?, ?)`
|
||||
}
|
||||
if _, err := e.db.Exec(create); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := e.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := tx.Prepare(insert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range cards {
|
||||
if e.columns {
|
||||
_, err = st.Exec(c.ID, c.Board, boolInt(c.Archived), string(c.Raw))
|
||||
} else {
|
||||
_, err = st.Exec(string(c.Raw))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
idx := []string{
|
||||
`CREATE UNIQUE INDEX cards_id ON cards (json_extract(doc, '$._id'))`,
|
||||
`CREATE INDEX cards_board ON cards (json_extract(doc, '$.boardId'), json_extract(doc, '$.archived'))`,
|
||||
}
|
||||
if e.columns {
|
||||
idx = []string{`CREATE INDEX cards_board ON cards (board, archived)`}
|
||||
}
|
||||
for _, q := range idx {
|
||||
if _, err = e.db.Exec(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = e.db.Exec(`ANALYZE`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *sqliteEng) where() (id, board string) {
|
||||
if e.columns {
|
||||
return `id = ?`, `board = ? AND archived = 0`
|
||||
}
|
||||
return `json_extract(doc, '$._id') = ?`, `json_extract(doc, '$.boardId') = ? AND json_extract(doc, '$.archived') = 0`
|
||||
}
|
||||
|
||||
func (e *sqliteEng) ByID(id string) ([]byte, error) {
|
||||
w, _ := e.where()
|
||||
var doc string
|
||||
err := e.db.QueryRow(`SELECT doc FROM cards WHERE `+w, id).Scan(&doc)
|
||||
return []byte(doc), err
|
||||
}
|
||||
|
||||
func (e *sqliteEng) BoardCards(board string) ([][]byte, error) {
|
||||
_, w := e.where()
|
||||
rows, err := e.db.Query(`SELECT doc FROM cards WHERE `+w, board)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out [][]byte
|
||||
for rows.Next() {
|
||||
var doc string
|
||||
if err := rows.Scan(&doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, []byte(doc))
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (e *sqliteEng) CountBoard(board string) (int, error) {
|
||||
_, w := e.where()
|
||||
var n int
|
||||
err := e.db.QueryRow(`SELECT COUNT(*) FROM cards WHERE `+w, board).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (e *sqliteEng) Update(id string, change func([]byte) ([]byte, error)) error {
|
||||
w, _ := e.where()
|
||||
tx, err := e.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var doc string
|
||||
if err = tx.QueryRow(`SELECT doc FROM cards WHERE `+w, id).Scan(&doc); err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := change([]byte(doc))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(`UPDATE cards SET doc = ? WHERE `+w, string(next), id); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (e *sqliteEng) Insert(c Card) error {
|
||||
if e.columns {
|
||||
_, err := e.db.Exec(`INSERT INTO cards (id, board, archived, doc) VALUES (?, ?, ?, ?)`, c.ID, c.Board, boolInt(c.Archived), string(c.Raw))
|
||||
return err
|
||||
}
|
||||
_, err := e.db.Exec(`INSERT INTO cards (doc) VALUES (?)`, string(c.Raw))
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *sqliteEng) SizeBytes() int64 {
|
||||
_, _ = e.db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
|
||||
return dirSize(e.dir)
|
||||
}
|
||||
|
||||
func (e *sqliteEng) Close() error { return e.db.Close() }
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// dirSize is what the files really occupy: the blocks, not the apparent size, which
|
||||
// counts the sparse files Badger preallocates.
|
||||
func dirSize(dir string) int64 {
|
||||
var n int64
|
||||
_ = filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
|
||||
if err == nil && !info.IsDir() {
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
n += st.Blocks * 512
|
||||
} else {
|
||||
n += info.Size()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return n
|
||||
}
|
||||
@@ -99,11 +99,42 @@ function compare(runs, reference) {
|
||||
function machine() {
|
||||
const cpus = os.cpus();
|
||||
return `${cpus[0] ? cpus[0].model.trim() : os.arch()}, ${cpus.length} threads, ` +
|
||||
`${Math.round(os.totalmem() / 2 ** 30)} GiB RAM, Linux ${os.release()}`;
|
||||
`${Math.round(os.totalmem() / 2 ** 30)} GiB RAM, ${os.type()} ${os.release()}`;
|
||||
}
|
||||
|
||||
// What the scale means, in words: 2 is twice a production WeKan, 0.05 a twentieth.
|
||||
function sizeWords(scale) {
|
||||
if (scale === 1) return 'as big as a production WeKan';
|
||||
if (scale === 2) return 'twice a production WeKan';
|
||||
if (scale > 1) return `${scale} times a production WeKan`;
|
||||
const n = Math.round(1 / scale);
|
||||
return Math.abs(1 / scale - n) < 0.01 ? `1/${n} of a production WeKan` : `${Math.round(scale * 100)}% of a production WeKan`;
|
||||
}
|
||||
|
||||
// How many times slower than the fastest, as a person reads it: "1.0x", "4.9x",
|
||||
// "27x", "1,300x".
|
||||
function times(v, fastest) {
|
||||
if (!Number.isFinite(v) || !fastest) return '-';
|
||||
const r = v / fastest;
|
||||
if (r < 10) return `${r.toFixed(1)}x`;
|
||||
return `${Math.round(r).toLocaleString('en-US')}x`;
|
||||
}
|
||||
|
||||
function render(runs, sameness, reference, dir) {
|
||||
const sorted = [...runs].sort((a, b) => total(a) - total(b) || a.label.localeCompare(b.label));
|
||||
// Fastest first, by the sum of a row. Every table below is ordered by ITS OWN
|
||||
// numbers, so each reads from fastest to slowest.
|
||||
const byTotal = (totalOf) => [...runs].sort((a, b) => totalOf(a) - totalOf(b) || a.label.localeCompare(b.label));
|
||||
const slowestTotal = (run) => QUERIES.reduce((sum, q) => {
|
||||
const r = run.queries[q.id];
|
||||
return r && !r.error && r.slowest !== undefined ? sum + r.slowest : Infinity;
|
||||
}, 0);
|
||||
const loadTotal = (run) => {
|
||||
const l = run.load || {};
|
||||
const parts = ['users', 'boards', 'lists', 'swimlanes', 'cards'].map(c => l[c] && l[c].ms);
|
||||
return parts.some(v => v === undefined) || run.indexTotalMs === undefined
|
||||
? Infinity : parts.reduce((a, b) => a + b, 0) + run.indexTotalMs;
|
||||
};
|
||||
const sorted = byTotal(total);
|
||||
const first = runs.map(r => r.started).sort()[0] || '';
|
||||
const sizes = (runs[0] && runs[0].sizes) || {};
|
||||
const lines = [];
|
||||
@@ -111,16 +142,17 @@ function render(runs, sameness, reference, dir) {
|
||||
lines.push(`Run ${first.slice(0, 10)} on ${machine()}, by`);
|
||||
lines.push('[speedtest.sh](speedtest.sh). How it works, and what was improved because of it:');
|
||||
lines.push('[the README](.).', '');
|
||||
lines.push(`The data is twice a production WeKan: ${['users', 'boards', 'lists', 'swimlanes', 'cards']
|
||||
lines.push(`The data is ${sizeWords(runs[0] && runs[0].scale)}: ${['users', 'boards', 'lists', 'swimlanes', 'cards']
|
||||
.map(c => `${(sizes[c] || 0).toLocaleString('en-US')} ${c}`).join(', ')}` +
|
||||
` (production: ${PRODUCTION.cards.toLocaleString('en-US')} cards). Every database got identical`);
|
||||
lines.push('documents and WeKan\'s own indexes, and was asked identical questions.', '');
|
||||
|
||||
const table = (field, totalOf) => {
|
||||
const order = byTotal(totalOf);
|
||||
const head = ['Database', 'Total', ...QUERIES.map(q => q.title)];
|
||||
lines.push(`| ${head.join(' | ')} |`);
|
||||
lines.push(`| ${head.map((_, i) => (i === 0 ? '---' : '---:')).join(' | ')} |`);
|
||||
for (const run of sorted) {
|
||||
for (const run of order) {
|
||||
const t = totalOf(run);
|
||||
const cells = QUERIES.map((q) => {
|
||||
const r = run.queries[q.id];
|
||||
@@ -132,6 +164,22 @@ function render(runs, sameness, reference, dir) {
|
||||
}
|
||||
lines.push('');
|
||||
};
|
||||
lines.push('## At a glance', '');
|
||||
lines.push('One number per database: the time of one call of each of the common WeKan queries below,');
|
||||
lines.push('added together. **Times slower** is that sum divided by the fastest database\'s. Ordered');
|
||||
lines.push('from fastest to slowest. The second pair of columns is the same for the slowest parameter -');
|
||||
lines.push('the biggest board or the busiest user, who waits longest.', '');
|
||||
lines.push('| # | Database | Total (ms) | Times slower | Slowest user (ms) | Times slower |');
|
||||
lines.push('| ---: | --- | ---: | ---: | ---: | ---: |');
|
||||
const fastestAvg = Math.min(...runs.map(total));
|
||||
const fastestSlow = Math.min(...runs.map(slowestTotal));
|
||||
sorted.forEach((run, i) => {
|
||||
const t = total(run);
|
||||
const w = slowestTotal(run);
|
||||
lines.push(`| ${i + 1} | ${mdCell(run.engine)} | ${Number.isFinite(t) ? ms(t) : 'incomplete'} | ${times(t, fastestAvg)} | ` +
|
||||
`${Number.isFinite(w) ? ms(w) : 'incomplete'} | ${times(w, fastestSlow)} |`);
|
||||
});
|
||||
lines.push('');
|
||||
lines.push('## Speed', '');
|
||||
lines.push('Milliseconds for one call - smaller is faster. Each query is asked with 15 parameters (the');
|
||||
lines.push('3 biggest boards or busiest users and 12 ordinary ones, see below); a cell is the average of');
|
||||
@@ -140,17 +188,16 @@ function render(runs, sameness, reference, dir) {
|
||||
table('average', total);
|
||||
lines.push('### The slowest parameter', '');
|
||||
lines.push('The same, for the slowest of the 15 parameters - nearly always the biggest board or the');
|
||||
lines.push('busiest user. This is how long the person who waits longest waits. Same row order.', '');
|
||||
table('slowest', (run) => QUERIES.reduce((sum, q) => {
|
||||
const r = run.queries[q.id];
|
||||
return r && !r.error && r.slowest !== undefined ? sum + r.slowest : Infinity;
|
||||
}, 0));
|
||||
lines.push('busiest user. This is how long the person who waits longest waits. Ordered by its own');
|
||||
lines.push('total, so the order can differ from the table above.', '');
|
||||
table('slowest', slowestTotal);
|
||||
|
||||
lines.push('## Loading the data', '');
|
||||
lines.push('Inserting the documents in batches of 1,000, then creating every index WeKan creates.', '');
|
||||
lines.push('Inserting the documents in batches of 1,000, then creating every index WeKan creates.');
|
||||
lines.push('Ordered by the whole time, fastest first.', '');
|
||||
lines.push('| Database | users | boards | lists | swimlanes | cards | Indexes | Index errors |');
|
||||
lines.push('| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |');
|
||||
for (const run of sorted) {
|
||||
for (const run of byTotal(loadTotal)) {
|
||||
const l = run.load || {};
|
||||
const ixErrors = Object.entries(run.indexes || {}).filter(([, v]) => v.error).map(([k]) => mdCell(k));
|
||||
lines.push(`| ${mdCell(run.engine)} | ${['users', 'boards', 'lists', 'swimlanes', 'cards'].map(c => seconds(l[c] && l[c].ms)).join(' | ')} | ` +
|
||||
@@ -182,7 +229,7 @@ function render(runs, sameness, reference, dir) {
|
||||
lines.push('');
|
||||
const calls = runs.length ? Object.values(runs[0].queries).map(r => r.calls).filter(Boolean) : [];
|
||||
lines.push(`Each query is first run once per parameter to warm up (those answers are the ones compared),`);
|
||||
lines.push(`then repeated for up to 5 passes or 10 seconds; ${calls.length ? `${Math.min(...calls)}-${Math.max(...calls)}` : 'the'} calls per query were timed.`);
|
||||
lines.push(`then repeated for up to 5 passes or the time budget (WEKAN_SPEEDTEST_BUDGET_MS); ${calls.length ? `${Math.min(...calls)}-${Math.max(...calls)}` : 'the'} calls per query were timed.`);
|
||||
lines.push('A query too slow to be repeated in that time keeps its warm-up time.');
|
||||
lines.push(`Raw results: \`${path.relative(path.resolve(__dirname, '../../../../..'), dir) || dir}/speedtest-<database>.json\`.`, '');
|
||||
return `${lines.join('\n')}\n`;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"uuid":"fb14521d-8728-4fdb-befd-132ef195c35c","telemetry":false}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Version history in the database: Dolt and TerminusDB, and WeKan's undo and redo
|
||||
|
||||
WeKan's undo and redo (Ctrl+Z, Ctrl+Y, History → Restore, see
|
||||
[History](../../../../Features/General/History/History.md) and
|
||||
[Undo](../../../../Features/General/Undo/Undo.md)) is application code: one
|
||||
`changeHistory` row per change, with who, when and which field, and a restore that
|
||||
reapplies the old values oldest to newest. Some databases version their data
|
||||
themselves. This asks what that would give WeKan, by trying WeKan-shaped changes
|
||||
(edit a card's title, move it, archive it, delete another one) on two of them.
|
||||
|
||||
```sh
|
||||
# Dolt, under FerretDB's MySQL handler - WeKan's writes arrive over the MongoDB protocol
|
||||
docker run -d --name dolttry -p 3307:3306 -e DOLT_ROOT_PASSWORD=rootpw -e DOLT_ROOT_HOST=% \
|
||||
-e DOLT_DATABASE=ferretdb dolthub/dolt-sql-server:latest
|
||||
mysql ... -e 'SET GLOBAL dolt_transaction_commit = 1' # a Dolt commit per transaction
|
||||
ferretdb --handler=mysql --mysql-url='mysql://root:rootpw@127.0.0.1:3307/ferretdb' --listen-addr=127.0.0.1:27022
|
||||
node docs/Databases/FerretDB/1/VersionHistory/dolt-history.cjs
|
||||
|
||||
# TerminusDB - REST only
|
||||
docker run -d --name tdbtry -p 6363:6363 terminusdb/terminusdb-server:latest
|
||||
node docs/Databases/FerretDB/1/VersionHistory/terminusdb-history.cjs
|
||||
```
|
||||
|
||||
Run on 2026-11-03: Dolt (sql-server image, MySQL 8.0.31 protocol) with FerretDB
|
||||
v1.105.0, and TerminusDB 12.0.9. Both are open source (Apache-2.0).
|
||||
|
||||
## What was found
|
||||
|
||||
| WeKan needs | Dolt under FerretDB | TerminusDB |
|
||||
| --- | --- | --- |
|
||||
| A record of every change | **Yes, with no change to WeKan.** With `dolt_transaction_commit` on, every FerretDB write is one Dolt commit (four writes, four commits). | Yes, one commit per write. |
|
||||
| Who made it | **No.** The committer is the SQL user (`root`) for every WeKan user, and the message is always `Transaction commit`. FerretDB would have to set them. | **Yes.** Each commit has the author and message the caller gives: `alice \| edit title`. |
|
||||
| What changed | Whole documents: an edit is a removed row and an added row in `dolt_diff_<table>`, because FerretDB's tables have no primary key and a card is one JSON cell. Fields are found by comparing the two. | **Field by field**: `{"title": {"@before": "Write docs v2", "@after": "Write docs", "@op": "SwapValue"}}`. |
|
||||
| Undo the last change | **Yes.** `CALL DOLT_REVERT('HEAD')` brought a deleted card back, read through FerretDB over the MongoDB protocol; reverting that revert is the redo. Both are commits of their own, so history is never rewritten. | **Yes**: diff the last two commits and apply it backwards (`/api/diff`, `/api/patch`). |
|
||||
| Restore one older change, keep the later ones | **Another document: yes. The same document: no.** Reverting an old title edit of card c1 after c1 was moved and archived failed with `the table(s) ... are in conflict` (the whole document is one cell). The failed revert left nothing behind, and FerretDB writes kept working. | **Yes, by field.** The title was restored while the move and the archive stayed: `title: "Write docs", listId: "l2", archived: true`. A patch whose before-value no longer matches is the conflict. |
|
||||
| The board as it was | **Yes**, in SQL: `SELECT ... AS OF '<commit>'`. FerretDB does not offer it over the MongoDB protocol. | **Yes**: `GET /api/document/.../local/commit/<id>`. |
|
||||
| Undo "my last change" or "this board's last change" | **No.** `HEAD` is the last commit of anyone on anything. A scope means choosing commits by their diff. | The log is per database; a scope is also chosen by the diff. |
|
||||
| Cost per write | **+0.4 ms**: `updateOne` took 2.34 ms with commits off and 2.77 ms with them on (200 calls each), +18%. 223 commits for 200 updates. | About 25 ms per `PUT` of a card, an HTTP call and a commit each. |
|
||||
| Can WeKan run on it | **Yes**: it is a MySQL-protocol database that FerretDB's MySQL handler already runs (it is in the [speed test](../SpeedTest/)). | **No.** It has neither the MongoDB protocol nor SQL; WeKan would need a new data layer. |
|
||||
|
||||
## What it means for WeKan
|
||||
|
||||
- **WeKan's own History is still the right undo.** It knows the user, the field,
|
||||
and the card, list or swimlane a change belongs to, and it restores one field
|
||||
while later changes stay. Dolt cannot do that for a document edited again
|
||||
afterwards, and it does not know who made a change; TerminusDB can, but WeKan
|
||||
cannot run on it.
|
||||
- **Dolt would add something WeKan does not have: the whole database at any
|
||||
commit.** Restoring a board, or all of them, as they were an hour ago is
|
||||
`AS OF` or `DOLT_RESET`, with no code in WeKan. It is a backup and audit layer
|
||||
under the undo, not a replacement for it. Its costs are real: a commit per
|
||||
write of everything WeKan writes (sessions, activities and search sessions
|
||||
included, which are not the user's changes), so the history is mostly noise.
|
||||
And Dolt is slow for WeKan's reads: in the [speed test](../SpeedTest/Results.md)
|
||||
every query over cards takes about 2.8 s on 15,377 cards (MySQL: 1-8 ms), 20,470
|
||||
ms in total against MySQL's 103, because it scans the table instead of using the
|
||||
indexes. A production-size WeKan on it would not be usable as it is.
|
||||
- **What FerretDB would need for Dolt to answer "who"**: set the commit author and
|
||||
message from the MongoDB command's `comment`, or the connection's user. It does
|
||||
neither today.
|
||||
- **Not tried**: Dolt branches and merges for a draft board, and TerminusDB's
|
||||
history of a single document, which would answer "this card's history" directly.
|
||||
@@ -0,0 +1,134 @@
|
||||
'use strict';
|
||||
|
||||
// What a database with version history gives WeKan's undo and redo: Dolt (git for
|
||||
// data, MySQL protocol) under FerretDB's MySQL handler. WeKan writes through the
|
||||
// MongoDB protocol as always; every FerretDB write is a Dolt transaction, and with
|
||||
// dolt_transaction_commit on every transaction is a Dolt commit.
|
||||
//
|
||||
// node docs/Databases/FerretDB/1/VersionHistory/dolt-history.cjs \
|
||||
// --uri 'mongodb://127.0.0.1:27022/?directConnection=true' --sql-port 3307 --sql-password rootpw
|
||||
//
|
||||
// It prints what it finds; README.md next to it explains the findings. The SQL side
|
||||
// is asked through the mysql client of the mysql:9 image, so nothing is installed.
|
||||
|
||||
const { execFileSync } = require('child_process');
|
||||
const { MongoClient } = require('mongodb');
|
||||
|
||||
function arg(name, fallback) {
|
||||
const i = process.argv.indexOf(`--${name}`);
|
||||
return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
||||
}
|
||||
const URI = arg('uri', 'mongodb://127.0.0.1:27022/?directConnection=true');
|
||||
const PORT = arg('sql-port', '3307');
|
||||
const PASSWORD = arg('sql-password', 'rootpw');
|
||||
const DB = 'wekanhistory';
|
||||
const TIMING_ONLY = process.argv.includes('--timing');
|
||||
|
||||
function sql(query, { db = DB, allowError = false } = {}) {
|
||||
try {
|
||||
return execFileSync('docker', ['run', '--rm', '--network', 'host', 'mysql:9', 'mysql',
|
||||
'-h', '127.0.0.1', '-P', PORT, '-u', 'root', `-p${PASSWORD}`, '-B', '-N', '-D', db, '-e', query],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||
} catch (e) {
|
||||
if (allowError) return `ERROR: ${String(e.stderr || e.message).replace(/mysql: \[Warning\][^\n]*\n/, '').trim()}`;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
const commits = () => Number(sql('SELECT COUNT(*) FROM dolt_log'));
|
||||
const head = () => sql("SELECT commit_hash FROM dolt_log LIMIT 1");
|
||||
const say = (line = '') => console.log(line);
|
||||
const json = (v) => { try { return JSON.parse(v); } catch { return v; } };
|
||||
|
||||
async function main() {
|
||||
const client = await new MongoClient(URI).connect();
|
||||
const db = client.db(DB);
|
||||
await db.dropDatabase().catch(() => {});
|
||||
|
||||
// Setup the way WeKan has it: a board, two lists and some cards.
|
||||
const boards = db.collection('boards');
|
||||
const lists = db.collection('lists');
|
||||
const cards = db.collection('cards');
|
||||
await boards.insertOne({ _id: 'b1', title: 'Board', archived: false });
|
||||
await lists.insertMany([{ _id: 'l1', boardId: 'b1', title: 'To do' }, { _id: 'l2', boardId: 'b1', title: 'Done' }]);
|
||||
await cards.insertMany([
|
||||
{ _id: 'c1', boardId: 'b1', listId: 'l1', title: 'Write docs', sort: 1, archived: false },
|
||||
{ _id: 'c2', boardId: 'b1', listId: 'l1', title: 'Fix bug', sort: 2, archived: false },
|
||||
]);
|
||||
|
||||
const table = sql(`SELECT table_name FROM information_schema.tables WHERE table_schema='${DB}' AND table_name LIKE 'cards%' LIMIT 1`);
|
||||
|
||||
if (TIMING_ONLY) {
|
||||
const N = 200;
|
||||
const t0 = process.hrtime.bigint();
|
||||
for (let i = 0; i < N; i += 1) await cards.updateOne({ _id: 'c1' }, { $set: { title: `Edit ${i}` } });
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6 / N;
|
||||
say(`updateOne: ${ms.toFixed(2)} ms per call, ${commits()} commits, dolt_transaction_commit=${sql('SELECT @@dolt_transaction_commit', { db: 'information_schema' })}`);
|
||||
await client.close();
|
||||
return;
|
||||
}
|
||||
|
||||
say('# 1. A Dolt commit per FerretDB write');
|
||||
const ops = [
|
||||
['edit title', () => cards.updateOne({ _id: 'c1' }, { $set: { title: 'Write docs v2' } })],
|
||||
['move to Done', () => cards.updateOne({ _id: 'c1' }, { $set: { listId: 'l2', sort: 5 } })],
|
||||
['archive (soft delete)', () => cards.updateOne({ _id: 'c1' }, { $set: { archived: true } })],
|
||||
['hard delete c2', () => cards.deleteOne({ _id: 'c2' })],
|
||||
];
|
||||
const hashes = {};
|
||||
for (const [name, run] of ops) {
|
||||
const before = commits();
|
||||
await run();
|
||||
hashes[name] = head();
|
||||
say(`${name.padEnd(24)} commits +${commits() - before} ${hashes[name].slice(0, 10)}`);
|
||||
}
|
||||
say(sql("SELECT CONCAT(committer, ' | ', date, ' | ', message) FROM dolt_log LIMIT 4"));
|
||||
|
||||
say('\n# 2. Who changed what: dolt_diff of the cards table, as WeKan\'s History table would show it');
|
||||
const diff = sql(`SELECT diff_type, to_commit, COALESCE(JSON_UNQUOTE(JSON_EXTRACT(from__ferretdb_sjson, '$.title')), ''), COALESCE(JSON_UNQUOTE(JSON_EXTRACT(to__ferretdb_sjson, '$.title')), ''), ` +
|
||||
`COALESCE(JSON_UNQUOTE(JSON_EXTRACT(from__ferretdb_sjson, '$.listId')), ''), COALESCE(JSON_UNQUOTE(JSON_EXTRACT(to__ferretdb_sjson, '$.listId')), '') ` +
|
||||
`FROM dolt_diff_${table} ORDER BY to_commit_date, to_commit`, { allowError: true });
|
||||
say('diff_type | commit | title from -> to | list from -> to');
|
||||
say(diff.split('\n').map((l) => { const c = l.split('\t'); return `${c[0]} | ${String(c[1]).slice(0, 8)} | ${c[2]} -> ${c[3]} | ${c[4]} -> ${c[5]}`; }).join('\n'));
|
||||
|
||||
say('\n# 3. Undo the last change (WeKan: Ctrl+Z) = DOLT_REVERT of HEAD, then redo = revert the revert');
|
||||
const exists = async (id) => !!(await cards.findOne({ _id: id }));
|
||||
say(`c2 exists before undo: ${await exists('c2')}`);
|
||||
say(sql("CALL DOLT_REVERT('HEAD')", { allowError: true }));
|
||||
say(`c2 exists after undo: ${await exists('c2')} (read through FerretDB, the MongoDB protocol)`);
|
||||
say(sql("CALL DOLT_REVERT('HEAD')", { allowError: true }));
|
||||
say(`c2 exists after redo: ${await exists('c2')}`);
|
||||
say(`commits now: ${commits()} - an undo and a redo are commits of their own, the history is never rewritten`);
|
||||
|
||||
say('\n# 4. Restore ONE older change while later ones stay (WeKan: Restore of a selected History row)');
|
||||
await cards.insertOne({ _id: 'c3', boardId: 'b1', listId: 'l1', title: 'Other card', sort: 3, archived: false });
|
||||
await cards.updateOne({ _id: 'c3' }, { $set: { title: 'Other card v2' } });
|
||||
const c3edit = head();
|
||||
await cards.updateOne({ _id: 'c2x', boardId: 'b1' }, { $set: { title: 'x' } }, { upsert: true });
|
||||
say('revert an old edit of c3 after an unrelated later commit (different row):');
|
||||
say(sql(`CALL DOLT_REVERT('${c3edit}')`, { allowError: true }));
|
||||
say(`c3 title: ${(await cards.findOne({ _id: 'c3' })).title}`);
|
||||
say('revert the OLD title edit of c1 while c1 was changed again afterwards (same row, other field):');
|
||||
say(sql(`CALL DOLT_REVERT('${hashes['edit title']}')`, { allowError: true }));
|
||||
const c1 = await cards.findOne({ _id: 'c1' });
|
||||
say(`c1 now: title=${c1.title} listId=${c1.listId} archived=${c1.archived}`);
|
||||
say(`left behind by the failed revert: ${sql('SELECT COUNT(*) FROM dolt_conflicts', { allowError: true })} conflict table(s), status: ${sql("SELECT GROUP_CONCAT(CONCAT(table_name, ':', status)) FROM dolt_status", { allowError: true })}`);
|
||||
const stuck = await cards.updateOne({ _id: 'c1' }, { $set: { sort: 99 } }).then(() => 'a write still works', (e) => `a write FAILS: ${String(e.message).slice(0, 120)}`);
|
||||
say(`FerretDB write while the conflict is open: ${stuck}`);
|
||||
say(`clean up with DOLT_RESET('--hard'): ${sql("CALL DOLT_RESET('--hard')", { allowError: true })}`);
|
||||
const after = await cards.updateOne({ _id: 'c1' }, { $set: { sort: 100 } }).then(() => 'a write works again', (e) => `a write FAILS: ${String(e.message).slice(0, 120)}`);
|
||||
say(`FerretDB write after the reset: ${after}`);
|
||||
|
||||
say('\n# 5. Time travel: the board as it was (AS OF), read with SQL');
|
||||
const asOf = sql(`SELECT COUNT(*) FROM ${table} AS OF '${hashes['move to Done']}'`, { allowError: true });
|
||||
const nowCount = sql(`SELECT COUNT(*) FROM ${table}`);
|
||||
say(`cards at the "move to Done" commit: ${asOf}; now: ${nowCount}`);
|
||||
const oldTitle = sql(`SELECT JSON_UNQUOTE(JSON_EXTRACT(_ferretdb_sjson, '$.title')) FROM ${table} AS OF '${hashes['edit title']}' WHERE JSON_UNQUOTE(JSON_EXTRACT(_ferretdb_sjson, '$._id')) = 'c1'`, { allowError: true });
|
||||
say(`c1 title as of the "edit title" commit: ${oldTitle}`);
|
||||
|
||||
say('\n# 6. What it costs');
|
||||
say(`commits for ${ops.length + 6} FerretDB writes: ${commits()}`);
|
||||
say(`size of the data database: ${sql(`SELECT COUNT(*) FROM dolt_log`)} commits; chunks are shared, only changed rows are stored again`);
|
||||
await client.close();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
|
||||
// The same questions as dolt-history.cjs, asked of TerminusDB (a document and
|
||||
// graph database with version control). TerminusDB speaks neither the MongoDB
|
||||
// protocol nor SQL, so FerretDB cannot sit on it and WeKan cannot use it as it
|
||||
// is; this only shows what its version control would give an undo and redo.
|
||||
//
|
||||
// docker run -d --name tdbtry -p 6363:6363 terminusdb/terminusdb-server:latest
|
||||
// node docs/Databases/FerretDB/1/VersionHistory/terminusdb-history.cjs
|
||||
|
||||
const BASE = process.argv.includes('--url') ? process.argv[process.argv.indexOf('--url') + 1] : 'http://127.0.0.1:6363';
|
||||
const AUTH = `Basic ${Buffer.from('admin:root').toString('base64')}`;
|
||||
const DB = 'wekanhistory';
|
||||
const say = (line = '') => console.log(line);
|
||||
|
||||
async function api(method, path, { query = {}, body, raw = false } = {}) {
|
||||
const url = new URL(`${BASE}${path}`);
|
||||
for (const [k, v] of Object.entries(query)) url.searchParams.set(k, v);
|
||||
const res = await fetch(url, {
|
||||
method, headers: { Authorization: AUTH, 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = text;
|
||||
try { data = JSON.parse(text); } catch { try { data = text.trim().split('\n').map(JSON.parse); } catch { /* text */ } }
|
||||
return raw ? { status: res.status, data } : { status: res.status, data };
|
||||
}
|
||||
const w = (author, message) => ({ author, message });
|
||||
const short = (v) => JSON.stringify(v).slice(0, 220);
|
||||
|
||||
async function main() {
|
||||
await api('DELETE', `/api/db/admin/${DB}`);
|
||||
const created = await api('POST', `/api/db/admin/${DB}`, { body: { label: DB, comment: 'WeKan history test' } });
|
||||
say(`create database: ${created.status}`);
|
||||
|
||||
const schema = [{
|
||||
'@type': 'Class', '@id': 'Card', '@key': { '@type': 'Lexical', '@fields': ['cardId'] },
|
||||
cardId: 'xsd:string', title: 'xsd:string', listId: 'xsd:string', sort: 'xsd:integer', archived: 'xsd:boolean',
|
||||
}];
|
||||
const s = await api('POST', `/api/document/admin/${DB}`, { query: { graph_type: 'schema', ...w('system', 'schema') }, body: schema });
|
||||
say(`schema: ${s.status} ${s.status === 200 ? '' : short(s.data)}`);
|
||||
|
||||
const card = (id, title, listId, sort, archived = false) => ({ '@type': 'Card', cardId: id, title, listId, sort, archived });
|
||||
const log = async () => (await api('GET', `/api/log/admin/${DB}`)).data;
|
||||
|
||||
say('\n# 1. A commit per write, with the author and message of the person who made it');
|
||||
const ins = await api('POST', `/api/document/admin/${DB}`, { query: w('alice', 'add cards'), body: [card('c1', 'Write docs', 'l1', 1), card('c2', 'Fix bug', 'l1', 2)] });
|
||||
say(`insert: ${ins.status} ${short(ins.data)}`);
|
||||
const hashes = {};
|
||||
const ops = [
|
||||
['edit title', 'alice', () => api('PUT', `/api/document/admin/${DB}`, { query: w('alice', 'edit title'), body: card('c1', 'Write docs v2', 'l1', 1) })],
|
||||
['move to Done', 'bob', () => api('PUT', `/api/document/admin/${DB}`, { query: w('bob', 'move to Done'), body: card('c1', 'Write docs v2', 'l2', 5) })],
|
||||
['archive', 'carol', () => api('PUT', `/api/document/admin/${DB}`, { query: w('carol', 'archive'), body: card('c1', 'Write docs v2', 'l2', 5, true) })],
|
||||
['hard delete c2', 'bob', () => api('DELETE', `/api/document/admin/${DB}`, { query: { ...w('bob', 'hard delete c2'), id: 'Card/c2' } })],
|
||||
];
|
||||
for (const [name, , run] of ops) {
|
||||
const before = (await log()).length;
|
||||
const r = await run();
|
||||
const l = await log();
|
||||
hashes[name] = l[0].identifier;
|
||||
say(`${name.padEnd(16)} HTTP ${r.status} commits +${l.length - before} ${l[0].author} | ${l[0].message}`);
|
||||
}
|
||||
say((await log()).slice(0, 5).map((c) => `${c.author} | ${c.message} | ${new Date(c.timestamp * 1000).toISOString()} | ${String(c.identifier).slice(0, 10)}`).join('\n'));
|
||||
|
||||
say('\n# 2. Time travel: the cards as of an earlier commit');
|
||||
const asOf = async (commit) => (await api('GET', `/api/document/admin/${DB}/local/commit/${commit}`, { query: { type: 'Card', as_list: 'true' } })).data;
|
||||
const nowCards = (await api('GET', `/api/document/admin/${DB}`, { query: { type: 'Card', as_list: 'true' } })).data;
|
||||
say(`now: ${nowCards.length} cards; as of "move to Done": ${short(await asOf(hashes['move to Done']))}`);
|
||||
|
||||
say('\n# 3. Undo the last change (WeKan: Ctrl+Z): a diff between two commits, applied backwards');
|
||||
const lastTwo = (await log()).slice(0, 2);
|
||||
const diffBack = await api('POST', `/api/diff/admin/${DB}`, { body: { before_data_version: lastTwo[0].identifier, after_data_version: lastTwo[1].identifier } });
|
||||
say(`diff (hard delete -> before it): ${diffBack.status} ${short(diffBack.data)}`);
|
||||
const patched = await api('POST', `/api/patch/admin/${DB}`, { body: { patch: diffBack.data, ...w('alice', 'undo: hard delete c2') } });
|
||||
say(`patch: ${patched.status} ${short(patched.data)}`);
|
||||
say(`c2 exists after undo: ${(await api('GET', `/api/document/admin/${DB}`, { query: { id: 'Card/c2' } })).status === 200}`);
|
||||
|
||||
say('\n# 4. Restore ONE older change while later ones stay (WeKan: Restore of a selected History row)');
|
||||
// The "edit title" commit and the one before it.
|
||||
const l = await log();
|
||||
const idx = l.findIndex((c) => c.identifier === hashes['edit title']);
|
||||
const titleBack = await api('POST', `/api/diff/admin/${DB}`, { body: { before_data_version: l[idx].identifier, after_data_version: l[idx + 1].identifier } });
|
||||
say(`diff to undo the title edit: ${short(titleBack.data)}`);
|
||||
const applied = await api('POST', `/api/patch/admin/${DB}`, { body: { patch: titleBack.data, ...w('alice', 'restore: title') } });
|
||||
say(`patch onto HEAD, where c1 was moved and archived afterwards: ${applied.status} ${short(applied.data)}`);
|
||||
const c1 = (await api('GET', `/api/document/admin/${DB}`, { query: { id: 'Card/c1' } })).data;
|
||||
say(`c1 now: ${short(c1)}`);
|
||||
|
||||
say('\n# 5. What it costs');
|
||||
const t0 = Date.now();
|
||||
for (let i = 0; i < 100; i += 1) await api('PUT', `/api/document/admin/${DB}`, { query: w('alice', `edit ${i}`), body: card('c1', `Edit ${i}`, 'l2', 5) });
|
||||
say(`PUT of a card: ${((Date.now() - t0) / 100).toFixed(1)} ms per call (an HTTP call each, a commit each); commits: ${(await log()).length}`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -89,13 +89,98 @@ stylesheets, a `role="button"` on the heading, `h3` in the title, a `#999`
|
||||
tile colour, an editor harness) and were updated to the new markup; each keeps
|
||||
the assertion that made it worth having.
|
||||
|
||||
### Dark themes (#6863), found after the fix above
|
||||
|
||||
The colour change above made the quiet text greys dark for every theme, and the
|
||||
dark themes (`cleandark`, `moderndark`, `dark`, `exodark`) have dark surfaces:
|
||||
dark grey text on them was nearly invisible. [Issue #6863](https://github.com/wekan/wekan/issues/6863)
|
||||
reported it ("Dark Themes broken in 12.87"), together with faults that were
|
||||
older than the audit: Card Settings drew white text on light grey blocks
|
||||
(1.07:1), popup items, the sidebar's shortcuts row and the "quiet buttons" were
|
||||
black on dark (1.18 to 2.19:1), and a list header's `!important` light grey beat
|
||||
each theme's own and left its light text at 1.09:1 and 1.27:1. The audit had
|
||||
scanned the default theme only, which is why none of this showed.
|
||||
|
||||
What was done:
|
||||
|
||||
- The quiet text colour is a variable, `--wekan-quiet-text`: `#595959` on the
|
||||
light themes (the fallback), `#b4b4b4` on `cleandark`, `#dcdcdc` on
|
||||
`moderndark` (lighter greys), and on `dark` and `exodark` for the board, the
|
||||
opened card and the sidebar (`exodark`'s popups too). `dark`'s popups are light,
|
||||
so they keep the dark one.
|
||||
- Card Settings' sections take their background from a variable, a faint lift of
|
||||
the dark surface instead of `#f7f7f7`; its muted text was `#808080` on
|
||||
`#f7f7f7` (3.68:1) and is the quiet variable now, also on the light themes.
|
||||
- Popup items and the board sidebar's lists were forced black with `!important`
|
||||
by popup.css; `moderndark` and `exodark` now force them white where their
|
||||
surfaces are dark. Quiet buttons, the sidebar's labels and swimlane lists, the
|
||||
shortcuts row, `dark`'s swimlane header and `moderndark`'s selected minicard
|
||||
and primary buttons got light text or a darker fill.
|
||||
- The list header's `!important` background is gone, so each theme's own list
|
||||
header applies.
|
||||
|
||||
`tests/playwright/specs/46-axe-theme-contrast.e2e.js` scans the four themes, in the
|
||||
board, the sidebar, Card Settings, an opened card and the custom fields popup,
|
||||
and finds nothing; `tests/darkThemeContrast6863.test.cjs` pins each rule. A
|
||||
crawl of more popups and sidebar tabs in `cleandark`, `moderndark`, `dark` and
|
||||
`exodark` also found nothing.
|
||||
|
||||
### The other themes, and colors a member chooses
|
||||
|
||||
The same scan was then run on **all 25 board themes** (the board, the filter and
|
||||
board sidebars, an opened card, Card Settings, and the popups of the card), with
|
||||
this result before the fix: 0 failures in `belize`, `pomegranate`, `wisteria`,
|
||||
`natural`, `midnight` and `appleglasspastel`, and failures in the rest:
|
||||
|
||||
- **The header and the buttons of eight themes**: white text on the theme's
|
||||
accent was 2.1 to 4.3:1 in `nephritis` (`#27ae60`), `pumpkin`, `moderatepink`,
|
||||
`strongcyan`, `limegreen`, `relax`, `corteza` and `modern`, and their dark,
|
||||
hover and emphasis shades were just as low. The accents are darkened, with the
|
||||
same hue, until white text and the header's `#f2f2f2` labels read at 4.5:1; the
|
||||
hover, selected and emphasis shades follow, so a hovered button is still
|
||||
visibly different. This is the one change that is easy to see: `limegreen` is a
|
||||
forest green now, `strongcyan` a teal, `pumpkin` a burnt orange. White text at
|
||||
4.5:1 cannot be had on a bright accent. The colour-slide themes `clearblue`,
|
||||
`cleargreen`, `clearorange` and `clearpink` end in the same colours, so their
|
||||
bottom colour changed too, and their buttons (Start Flow) read.
|
||||
- **The filter sidebar's headings** were `#808080` on `#f7f7f7` (3.68:1) in every
|
||||
theme, the default one too; they use the quiet text variable.
|
||||
- **`clear*` themes' quiet text** was `rgba(0,0,0,0.4)` (2.83:1); it is 0.68.
|
||||
- **`cleanlight`'s add-dependency link** was 50% black on `#f8f8f9` (3.64:1); it
|
||||
is 70%.
|
||||
|
||||
Afterwards **all 25 themes show no colour contrast failure** in those states.
|
||||
`tests/playwright/specs/46-axe-theme-contrast.e2e.js` runs the scan on every
|
||||
theme.
|
||||
|
||||
What the scan cannot see: axe-core cannot measure text over a **gradient**, so the
|
||||
headers of the six `clear*` slides are not covered by it (it reports them as
|
||||
"needs review"). Their ends were not changed beyond the shared accent colours
|
||||
above, and white text on the light top of a slide (`clearblue`'s `#499bea` is
|
||||
3.0:1) is not verified here.
|
||||
|
||||
**Colors a member chooses.** A free color choice is one half of a pair, and the
|
||||
other half is worked out so the pair is legible, whatever was picked
|
||||
(`models/lib/contrastColor.js`, `tests/customColorContrast.test.cjs`):
|
||||
|
||||
| Chosen | Paired with | What is done |
|
||||
|---|---|---|
|
||||
| A custom label, card, list or swimlane color | its text | The text is black or white, whichever has the higher contrast (`contrastText`): never below 4.5:1 on any color, checked on a sample of every color and on the named colors. Kanri's export of a label's text follows the same rule. |
|
||||
| A custom theme accent, or the two ends of a custom slide | the header's white text and `#f2f2f2` labels | The color is darkened, with the same hue, until both are at 4.5:1 (`readableAccent`). The picker's preview shows the darkened color and says so. |
|
||||
| The text color a member picks (Member Settings / Font) | the surface of the theme in use | The color is lightened or darkened, with the same hue, until it reads at 4.5:1 on the surfaces of that theme (`readableUiTextColor`); a color that already reads is applied as chosen. The popup says when it changed it. The text color no longer reaches the header bars, labels and primary buttons, which sit on a color of their own: a grey `#626262` was 1.18:1 on the blue header. |
|
||||
| A dependency line's color | the board | The line is drawn at 3:1 against the theme's surface (WCAG 1.4.11); the stored color is untouched. |
|
||||
|
||||
A text color cannot be legible on a light and a dark surface at once. The four
|
||||
themes with a dark board (`cleandark`, `moderndark`, `dark`, `exodark`) use the
|
||||
dark surfaces, the others the light ones; the light popups of `dark` and
|
||||
`exodark` are a case the choice cannot cover.
|
||||
|
||||
### Known side effects of the colour change
|
||||
|
||||
- `#2980b9` was the default blue in 21 files; `#2573a7` is slightly darker and
|
||||
was checked by eye on the card and the sign-in page only.
|
||||
- The quiet greys changed in 11 stylesheets for the light themes. The dark
|
||||
themes have their own rules for the same text, and were not scanned, so a dark
|
||||
theme could show these greys differently.
|
||||
- The quiet greys changed in 12 stylesheets. The dark themes were not scanned at
|
||||
first and showed them dark on dark; that is fixed above (#6863).
|
||||
- The PDF export's progress bar still draws `#2980b9`; it is not part of the
|
||||
web pages.
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ were wrong once it moved:
|
||||
|
||||
- **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: #00aecc` from the rule beside it — which is
|
||||
took the flat `background-color: #00798d` from the rule beside it — which is
|
||||
**strongcyan's colour exactly**, so 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
|
||||
@@ -136,7 +136,7 @@ right for the eighteen flat themes. clearblue cannot be carried by it: a variabl
|
||||
holds a colour, and a gradient is not one. So the rows that must *slide* are
|
||||
named in clearblue's own rule — the Admin Panel's selected left-menu row, which
|
||||
always was, and the All Boards one, which now is. Without that, All Boards' row
|
||||
sat flat `#00aecc` beside an Admin Panel row that slid.
|
||||
sat flat `#00798d` beside an Admin Panel row that slid.
|
||||
|
||||
`tests/themeAccents.test.cjs` pins all of it: every theme paints the first bar,
|
||||
paints it its *own* colour, and **no two themes paint it the same** — which is
|
||||
@@ -156,10 +156,10 @@ categories cover the same colour families:
|
||||
|
||||
| Theme | Slide | Base is |
|
||||
| --- | --- | --- |
|
||||
| `clearblue` | `#499bea` → `#00aecc` | (hand-tuned, the original) |
|
||||
| `cleargreen` | `#8ad59f` → `#4bbf6b` | limegreen |
|
||||
| `clearorange` | `#efab6f` → `#e67e22` | pumpkin |
|
||||
| `clearpink` | `#df94b8` → `#cd5a91` | moderatepink |
|
||||
| `clearblue` | `#499bea` → `#00798d` | (hand-tuned, the original) |
|
||||
| `cleargreen` | `#8ad59f` → `#277c48` | limegreen |
|
||||
| `clearorange` | `#efab6f` → `#a75919` | pumpkin |
|
||||
| `clearpink` | `#df94b8` → `#bd3a79` | moderatepink |
|
||||
| `clearpurple` | `#b685ca` → `#8e44ad` | wisteria |
|
||||
| `clearred` | `#d67e75` → `#c0392b` | pomegranate |
|
||||
|
||||
|
||||
@@ -82,8 +82,20 @@ early.
|
||||
|
||||
## What the bar holds
|
||||
|
||||
In source order: the house and the page title, the logo, the phone/desktop
|
||||
toggle, the drag-handles toggle, and then everything else.
|
||||
In source order: the house and the page title, the logo, the full screen button,
|
||||
the phone/desktop toggle, the drag-handles toggle, and then everything else.
|
||||
|
||||
**Full screen.** A button to the right of the logo puts the whole page in full
|
||||
screen and takes it out again, with the Fullscreen API (`models/lib/fullscreen.js`):
|
||||
the icon is an expand arrow, and a compress arrow while the page is in full screen,
|
||||
and it follows the browser's own way out (Esc) too. Its label is the existing
|
||||
"Maximize", and "Minimize" while the page is in full screen, so the same button
|
||||
says how to leave (Esc and the browser's own control work too). It is drawn only where the browser offers the API and the page may use
|
||||
it - iPad Safari, desktop browsers and Android browsers do; **iPhone Safari does not**
|
||||
(the API is for video there) and neither does a page inside an `<iframe>` without
|
||||
`allowfullscreen`, so no dead button is shown. On an iPhone, "Add to Home Screen"
|
||||
opens WeKan without the browser's bars (the manifest says `display: standalone`).
|
||||
It sits before the first icon the fold button collects, so folding the icons keeps it.
|
||||
|
||||
**The board's controls fold.** A caret leads the group, at its leading edge —
|
||||
left of the lock in a left-to-right page, right of it in Arabic. Folded, the
|
||||
|
||||
@@ -43,3 +43,20 @@ For developers: the switch is `profile.keepAddCardFormOpen`, toggled by the
|
||||
`client/components/lists/listBody.js` closes the form after its last title,
|
||||
pinned by `tests/keepAddCardFormOpen6827.test.cjs` and
|
||||
`tests/playwright/specs/keep-add-card-form-open-6827.e2e.js`.
|
||||
|
||||
## The focus outline
|
||||
|
||||
**Show focus outline** is personal and **on** by default. The blue ring round the
|
||||
focused control is an accessibility feature (WCAG 2.4.7, Focus Visible): it tells
|
||||
a keyboard user where they are, and it also shows after a click on a minicard,
|
||||
which some members find distracting
|
||||
([#6755](https://github.com/wekan/wekan/issues/6755)). Untick it and the ring - the
|
||||
outline and its halo - is hidden for you, on every board; other members keep
|
||||
theirs. Field borders and the colour a button takes on focus are not the ring and
|
||||
stay. If you use the keyboard to move through WeKan, leave it on.
|
||||
|
||||
For developers: the switch is `profile.hideFocusOutline` (stored as the exception,
|
||||
so an account that never touched it keeps the ring), toggled by the
|
||||
`toggleHideFocusOutline` method; `client/components/main/uiFont.js` puts the class
|
||||
`has-hide-focus-outline` on `<body>` and `client/components/main/layouts.css` turns
|
||||
it into no ring. Pinned by `tests/hideFocusOutline6755.test.cjs`.
|
||||
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
|
||||
|
||||
Right click and download files 1-4:
|
||||
|
||||
1. [wekan-12.85-win64.zip](https://github.com/wekan/wekan/releases/download/v12.85/wekan-12.85-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
1. [wekan-12.96-win64.zip](https://github.com/wekan/wekan/releases/download/v12.96/wekan-12.96-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
|
||||
|
||||
2. [node.exe](https://nodejs.org/dist/latest-v26.x/win-x64/node.exe)
|
||||
|
||||
@@ -22,7 +22,7 @@ Right click and download files 1-4:
|
||||
|
||||
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
|
||||
|
||||
7. Unzip `wekan-12.85-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
7. Unzip `wekan-12.96-win64.zip` , inside it is directory `bundle`, to it copy other files:
|
||||
|
||||
```
|
||||
bundle (directory)
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "URL tautan logo login khusus",
|
||||
"custom-help-link-url": "URL Tautan Bantu Khusuih",
|
||||
"text-below-custom-login-logo": "Teks di miyup logo login khusus",
|
||||
"login-background-color": "Warna latar halaman login",
|
||||
"automatic-linked-url-schemes": "Skema URL khusus nyang haroh jeuet geuklik ngon otomatis. Saboh skema URL bak tiep bareh",
|
||||
"automatic-linked-url-schemes-hint": "Bak atôran awai mantöng soh, jadi nyang jeuet teuklik cit tautan web ngon imèl. Tiep jeunéh tautan nyang na lam dapeuta, lagee thunderlink atawa onenote, jipeuhah aplikasi nyang ka teudaftar keu jeunéh nyan. javascript, data, vbscript ngon jeunéh nyang saban hana tom jeuet keu tautan.",
|
||||
"external-link-pattern": "Tautan Keudroe u Peuturi Masalah Luwa",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Watèe judul kad barô geupeuphôn ngon [Haba] lheuh nyan na haba laén, Haba jeut keu label kad nyan (geupeugöt meunyo papan hana label ngon nan nyan) dan geupeugadoh nibak judul. Mate: judul teutap lagée nyan.",
|
||||
"keep-add-card-form-open": "Peutinggai formulir Tamah Kad teuhah lheueh kad ka geutamah",
|
||||
"keep-add-card-form-open-description": "Udep: lheueh kad ka geutamah, formulir teutap teuhah ngon judul soh keu kad lheueh nyoe. Mate: formulir teutôp meunan kad ka geutamah.",
|
||||
"show-focus-outline": "Peuĝah garis fokus",
|
||||
"checklist-ding-sound": "Peubunyi su watèe item checklist geuseuleusoe",
|
||||
"checklist-ding-sound-description": "Peubunyi su \"ding\" paneuk watèe droeneueh cek saboh item checklist. Matê meunurôt default.",
|
||||
"assignee": "Ureueng nyang geubri buet",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "Gepasmaakte aanmeldlogo-skakel-URL",
|
||||
"custom-help-link-url": "Gepasmaakte hulpskakel-URL",
|
||||
"text-below-custom-login-logo": "Teks onder Custom Login Logo",
|
||||
"login-background-color": "Agtergrondkleur van aanmeldblad",
|
||||
"automatic-linked-url-schemes": "Pasgemaakte URL-skemas wat outomaties klikbaar moet wees. Een URL-skema per reël",
|
||||
"automatic-linked-url-schemes-hint": "By verstek leeg, sodat slegs web- en posskakels klikbaar is. Elke gelyste skema, byvoorbeeld thunderlink of onenote, open die toepassing wat daarvoor geregistreer is. javascript, data, vbscript en soortgelyke skemas word nooit skakels nie.",
|
||||
"external-link-pattern": "Outomatiese skakel na eksterne kwessie-opspoorder",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Wanneer 'n nuwe kaart se titel begin met [Teks] gevolg deur meer teks, word Teks 'n etiket van die kaart (geskep as die raad nog geen etiket met daardie naam het nie) en uit die titel verwyder. Af: die titel bly soos dit is.",
|
||||
"keep-add-card-form-open": "Hou die Voeg kaart by-vorm oop nadat 'n kaart bygevoeg is",
|
||||
"keep-add-card-form-open-description": "Aan: nadat 'n kaart bygevoeg is, bly die vorm oop met 'n leë titel vir die volgende kaart. Af: die vorm maak toe sodra die kaart bygevoeg is.",
|
||||
"show-focus-outline": "Wys fokusomlyn",
|
||||
"checklist-ding-sound": "Speel 'n klank wanneer 'n kontrolelys-item voltooi word",
|
||||
"checklist-ding-sound-description": "Speel 'n kort klokkie wanneer jy 'n kontrolelysitem afmerk. Standaard af.",
|
||||
"assignee": "Gevolmagtigde",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "Gepasmaakte aanmeldlogo-skakel-URL",
|
||||
"custom-help-link-url": "Gepasmaakte hulpskakel-URL",
|
||||
"text-below-custom-login-logo": "Teks onder Custom Login Logo",
|
||||
"login-background-color": "Agtergrondkleur van aanmeldblad",
|
||||
"automatic-linked-url-schemes": "Pasgemaakte URL-skemas wat outomaties klikbaar moet wees. Een URL-skema per reël",
|
||||
"automatic-linked-url-schemes-hint": "By verstek leeg, sodat slegs web- en posskakels klikbaar is. Elke gelyste skema, byvoorbeeld thunderlink of onenote, open die toepassing wat daarvoor geregistreer is. javascript, data, vbscript en soortgelyke skemas word nooit skakels nie.",
|
||||
"external-link-pattern": "Outomatiese skakel na eksterne kwessie-opspoorder",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Wanneer 'n nuwe kaart se titel begin met [Teks] gevolg deur meer teks, word Teks 'n etiket van die kaart (geskep as die raad nog geen etiket met daardie naam het nie) en uit die titel verwyder. Af: die titel bly soos dit is.",
|
||||
"keep-add-card-form-open": "Hou die Voeg kaart by-vorm oop nadat 'n kaart bygevoeg is",
|
||||
"keep-add-card-form-open-description": "Aan: nadat 'n kaart bygevoeg is, bly die vorm oop met 'n leë titel vir die volgende kaart. Af: die vorm maak toe sodra die kaart bygevoeg is.",
|
||||
"show-focus-outline": "Wys fokusomlyn",
|
||||
"checklist-ding-sound": "Speel 'n klank wanneer 'n kontrolelys-item voltooi word",
|
||||
"checklist-ding-sound-description": "Speel 'n kort klokkie wanneer jy 'n kontrolelysitem afmerk. Standaard af.",
|
||||
"assignee": "Gevolmagtigde",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "Wo ankasa kɔ-mu agyiraehyɛde link URL",
|
||||
"custom-help-link-url": "W’ankasa mmoa link URL",
|
||||
"text-below-custom-login-logo": "Nsɛm a ɛwɔ wo ankasa kɔ-mu agyiraehyɛde no ase",
|
||||
"login-background-color": "Login kratafa no akyi ahosu",
|
||||
"automatic-linked-url-schemes": "URL akwan a woahyɛ da ayɛ a ɛsɛ sɛ wotumi mia so ankasa. URL kwan biako wɔ nkyerɛw no ɔfa biara so",
|
||||
"automatic-linked-url-schemes-hint": "Mfitiaseɛ no, ɛyɛ hunu, enti wɛb ne ɛmɛl nkitahodie nko ara na wobɛtumi aklika so. Scheme biara a ɛwɔ din no mu, sɛ nhwɛsoɔ no thunderlink anaa onenote, bue app a wɔakyerɛw ne din ama no no. Wɔnyɛ javascript, data, vbscript ne scheme a ɛte saa no nkitahodie da.",
|
||||
"external-link-pattern": "Ntoatoaso ahomaso a ɛkɔ nsɛnnennennee foforo so",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Sɛ kaad foforɔ asɛmti fi ase de [Nkyerɛwee] na nkyerɛwee foforɔ di akyi a, Nkyerɛwee no bɛyɛ kaad no nsɛnkyerɛnne (wɔbɛyɛ no sɛ bɔɔd no nni nsɛnkyerɛnne a ne din saa) na wɔayi afi asɛmti no mu. Mmom: asɛmti no tena sɛdeɛ ɛte.",
|
||||
"keep-add-card-form-open": "Ma Fa kaad ka ho krataa no nkɔ so mmue wɔ kaad bi a wode aka ho akyi",
|
||||
"keep-add-card-form-open-description": "Bue: sɛ wode kaad aka ho a, krataa no kɔ so bue a ne din yɛ hwee ma kaad a edi hɔ. To mu: krataa no to mu bere a wode kaad no aka ho.",
|
||||
"show-focus-outline": "Kyerɛ ahwɛyie ntwerɛ",
|
||||
"checklist-ding-sound": "Bɔ nnyigyei bere a wɔawie nhwehwɛmu no mu ade bi",
|
||||
"checklist-ding-sound-description": "Bɔ nnyigyei tiaa bi bere a wohyɛ nhwehwɛmu no mu ade bi agyirae sɛ wɔawie. Wɔadum fi mfiase.",
|
||||
"assignee": "Nea wɔde adwuma ama no",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "ብጁ የመግቢያ አርማ አገናኝ URL",
|
||||
"custom-help-link-url": "ብጁ የእገዛ አገናኝ URL",
|
||||
"text-below-custom-login-logo": "ከብጁ የመግቢያ አርማ በታች ያለ ጽሑፍ",
|
||||
"login-background-color": "የመግቢያ ገጽ የጀርባ ቀለም",
|
||||
"automatic-linked-url-schemes": "በራስ-ሰር ሊጫኑ የሚገባቸው ብጁ የURL መርሃግብሮች። በእያንዳንዱ መስመር አንድ የURL መርሃግብር",
|
||||
"automatic-linked-url-schemes-hint": "በነባሪ ባዶ ነው፣ ስለዚህ የድር እና የኢሜይል አገናኞች ብቻ ጠቅ ሊደረጉ ይችላሉ። እያንዳንዱ የተዘረዘረ ስኪማ፣ ለምሳሌ thunderlink ወይም onenote፣ ለእሱ የተመዘገበውን መተግበሪያ ይከፍታል። javascript፣ data፣ vbscript እና ተመሳሳይ ስኪማዎች ፈጽሞ አገናኝ አይሆኑም።",
|
||||
"external-link-pattern": "ወደ ውጫዊ ችግር መከታተያ ራስ-ሰር አገናኝ",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "የአዲስ ካርድ ርዕስ በ[ጽሑፍ] ተጀምሮ ሌላ ጽሑፍ ከተከተለው፣ ጽሑፍ የካርዱ መለያ ይሆናል (ሰሌዳው በዚያ ስም መለያ ከሌለው ይፈጠራል) እና ከርዕሱ ይወገዳል። ጠፍቷል፦ ርዕሱ እንዳለ ይቆያል።",
|
||||
"keep-add-card-form-open": "ካርድ ከታከለ በኋላ የካርድ ጨምር ቅጹ ክፍት ሆኖ ይቆይ",
|
||||
"keep-add-card-form-open-description": "በርቷል: ካርድ ከታከለ በኋላ ቅጹ ለቀጣዩ ካርድ ባዶ ርዕስ ይዞ ክፍት ሆኖ ይቆያል። ጠፍቷል: ካርዱ እንደታከለ ቅጹ ይዘጋል።",
|
||||
"show-focus-outline": "የትኩረት ዙሪያ መስመር አሳይ",
|
||||
"checklist-ding-sound": "የማጣሪያ ዝርዝር ንጥል ሲጠናቀቅ ድምፅ አጫውት",
|
||||
"checklist-ding-sound-description": "የማጣሪያ ዝርዝር ንጥል ሲያመልክቱ አጭር ድምፅ ያጫውቱ። ነባሪው ጠፍቷል።",
|
||||
"assignee": "ኃላፊ",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "URL de enlace d'o logo personalizado de inicio de sesión",
|
||||
"custom-help-link-url": "URL d’o vinclo d’aduya personalizau",
|
||||
"text-below-custom-login-logo": "Texto debajo d'o logo personalizado de inicio de sesión",
|
||||
"login-background-color": "Color de fondo d'a pachina d'inicio de sesión",
|
||||
"automatic-linked-url-schemes": "Esquemas d’URL personalizaus que han de poder clicar-se automaticament. Un esquema d’URL por linia.",
|
||||
"automatic-linked-url-schemes-hint": "Vuedo por defecto, asinas que nomás se puede fer clic en os vinclos web y de correu. Cada esquema d'a lista, por eixemplo thunderlink u onenote, ubre l'aplicación rechistrada ta ell. javascript, data, vbscript y esquemas pareixius nunca no se tornan vinclos.",
|
||||
"external-link-pattern": "Vinclo automatico ta un rastrexador de problemas externo",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Quan o titol d'una tarcheta nueva empecipia con [Texto] seguiu de mas texto, Texto se convierte en una etiqueta d'a tarcheta (se crea si o tablero no en tiene garra con ixe nombre) y se quita d'o titol. Desactivau: o titol se mantiene tal como ye.",
|
||||
"keep-add-card-form-open": "Mantener ubierto o formulario d'adhibir tarcheta dimpués d'adhibir una tarcheta",
|
||||
"keep-add-card-form-open-description": "Activau: dimpués d'adhibir una tarcheta, o formulario se mantién ubierto con un titol vuedo pa la siguient tarcheta. Desactivau: o formulario se zarra en cuanto s'adhibe la tarcheta.",
|
||||
"show-focus-outline": "Amostrar o contorno de foco",
|
||||
"checklist-ding-sound": "Sonar quan se completa un elemento d'a lista de verificación",
|
||||
"checklist-ding-sound-description": "Sonar un tono curto quan marcas un elemento d'a lista de verificación. Desactivau por defecto.",
|
||||
"assignee": "Asignau",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "عنوان URL لرابط شعار تسجيل الدخول المخصص",
|
||||
"custom-help-link-url": "رابط صفحة المساعدة المخصص URL",
|
||||
"text-below-custom-login-logo": "النص أسفل شعار تسجيل الدخول المخصص",
|
||||
"login-background-color": "لون خلفية صفحة تسجيل الدخول",
|
||||
"automatic-linked-url-schemes": "مخططات URL المخصصة التي يجب أن تكون قابلة للنقر تلقائياً. مخطط URL واحد لكل سطر",
|
||||
"automatic-linked-url-schemes-hint": "تكون فارغة افتراضيًا، لذا يمكن النقر على روابط الويب والبريد فقط. يفتح كل مخطط مدرج، مثل thunderlink أو onenote، التطبيق المسجل له. لا تُحوَّل مخططات javascript وdata وvbscript والمخططات المشابهة إلى روابط أبدًا.",
|
||||
"external-link-pattern": "الربط التلقائي بمتتبع مشاكل خارجي",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "عندما يبدأ عنوان بطاقة جديدة بـ [نص] متبوعًا بنص آخر، يصبح النص علامة للبطاقة (تُنشأ إذا لم تكن في اللوحة علامة بهذا الاسم) ويُحذف من العنوان. عند الإيقاف: يبقى العنوان كما هو.",
|
||||
"keep-add-card-form-open": "إبقاء نموذج إضافة بطاقة مفتوحًا بعد إضافة بطاقة",
|
||||
"keep-add-card-form-open-description": "تشغيل: بعد إضافة بطاقة، يبقى النموذج مفتوحًا بعنوان فارغ للبطاقة التالية. إيقاف: يُغلق النموذج بمجرد إضافة البطاقة.",
|
||||
"show-focus-outline": "إظهار حدود التركيز",
|
||||
"checklist-ding-sound": "تشغيل صوت عند إتمام عنصر في قائمة المهام",
|
||||
"checklist-ding-sound-description": "تشغيل نغمة قصيرة عند تحديد عنصر في قائمة المهام. معطّل افتراضيًا.",
|
||||
"assignee": "المكلّف",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "عنوان URL لرابط شعار تسجيل الدخول المخصص",
|
||||
"custom-help-link-url": "رابط صفحة المساعدة المخصص URL",
|
||||
"text-below-custom-login-logo": "النص أسفل شعار تسجيل الدخول المخصص",
|
||||
"login-background-color": "لون خلفية صفحة تسجيل الدخول",
|
||||
"automatic-linked-url-schemes": "مخططات URL المخصصة التي يجب أن تكون قابلة للنقر تلقائياً. مخطط URL واحد لكل سطر",
|
||||
"automatic-linked-url-schemes-hint": "تكون فارغة افتراضيًا، لذا يمكن النقر على روابط الويب والبريد فقط. يفتح كل مخطط مدرج، مثل thunderlink أو onenote، التطبيق المسجل له. لا تُحوَّل مخططات javascript وdata وvbscript والمخططات المشابهة إلى روابط أبدًا.",
|
||||
"external-link-pattern": "الربط التلقائي بمتتبع مشاكل خارجي",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "عندما يبدأ عنوان بطاقة جديدة بـ [نص] متبوعًا بنص آخر، يصبح النص علامة للبطاقة (تُنشأ إذا لم تكن في اللوحة علامة بهذا الاسم) ويُحذف من العنوان. عند الإيقاف: يبقى العنوان كما هو.",
|
||||
"keep-add-card-form-open": "إبقاء نموذج إضافة بطاقة مفتوحًا بعد إضافة بطاقة",
|
||||
"keep-add-card-form-open-description": "تشغيل: بعد إضافة بطاقة، يبقى النموذج مفتوحًا بعنوان فارغ للبطاقة التالية. إيقاف: يُغلق النموذج بمجرد إضافة البطاقة.",
|
||||
"show-focus-outline": "إظهار حدود التركيز",
|
||||
"checklist-ding-sound": "تشغيل صوت عند إتمام عنصر في قائمة المهام",
|
||||
"checklist-ding-sound-description": "تشغيل نغمة قصيرة عند تحديد عنصر في قائمة المهام. معطّل افتراضيًا.",
|
||||
"assignee": "المكلّف",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "عنوان URL لرابط شعار تسجيل الدخول المخصص",
|
||||
"custom-help-link-url": "رابط صفحة المساعدة المخصص URL",
|
||||
"text-below-custom-login-logo": "النص أسفل شعار تسجيل الدخول المخصص",
|
||||
"login-background-color": "لون خلفية صفحة تسجيل الدخول",
|
||||
"automatic-linked-url-schemes": "مخططات URL المخصصة التي يجب أن تكون قابلة للنقر تلقائياً. مخطط URL واحد لكل سطر",
|
||||
"automatic-linked-url-schemes-hint": "تكون فارغة افتراضيًا، لذا يمكن النقر على روابط الويب والبريد فقط. يفتح كل مخطط مدرج، مثل thunderlink أو onenote، التطبيق المسجل له. لا تُحوَّل مخططات javascript وdata وvbscript والمخططات المشابهة إلى روابط أبدًا.",
|
||||
"external-link-pattern": "الربط التلقائي بمتتبع مشاكل خارجي",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "عندما يبدأ عنوان بطاقة جديدة بـ [نص] متبوعًا بنص آخر، يصبح النص علامة للبطاقة (تُنشأ إذا لم تكن في اللوحة علامة بهذا الاسم) ويُحذف من العنوان. عند الإيقاف: يبقى العنوان كما هو.",
|
||||
"keep-add-card-form-open": "إبقاء نموذج إضافة بطاقة مفتوحًا بعد إضافة بطاقة",
|
||||
"keep-add-card-form-open-description": "تشغيل: بعد إضافة بطاقة، يبقى النموذج مفتوحًا بعنوان فارغ للبطاقة التالية. إيقاف: يُغلق النموذج بمجرد إضافة البطاقة.",
|
||||
"show-focus-outline": "إظهار حدود التركيز",
|
||||
"checklist-ding-sound": "تشغيل صوت عند إتمام عنصر في قائمة المهام",
|
||||
"checklist-ding-sound-description": "تشغيل نغمة قصيرة عند تحديد عنصر في قائمة المهام. معطّل افتراضيًا.",
|
||||
"assignee": "المكلّف",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "آدرس لینک لوگوی سفارشی در لاگین",
|
||||
"custom-help-link-url": "رابط صفحة المساعدة المخصص URL",
|
||||
"text-below-custom-login-logo": "متن پایین لوگوی سفارشی در فرم لاگین",
|
||||
"login-background-color": "لون خلفية صفحة تسجيل الدخول",
|
||||
"automatic-linked-url-schemes": "مخططات URL المخصصة التي يجب أن تكون قابلة للنقر تلقائياً. مخطط URL واحد لكل سطر",
|
||||
"automatic-linked-url-schemes-hint": "كتكون خاوية فالإعدادات الأصلية، وداكشي علاش غير روابط الويب والإيميل اللي كتقدر تكليكي عليها. كل نوع رابط فاللائحة، بحال thunderlink ولا onenote، كيفتح التطبيق المسجل ديالو. javascript وdata وvbscript والأنواع المشابهة ما كيتحوّلوش لروابط نهائياً.",
|
||||
"external-link-pattern": "الربط التلقائي بمتتبع مشاكل خارجي",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "ملي كيبدا عنوان بطاقة جديدة بـ [نص] ومن بعدو نص آخر، النص كيولّي علامة ديال البطاقة (كتتصاوب إلا ما كانتش فالبرد علامة بهاد السمية) وكيتحيّد من العنوان. مطفي: العنوان كيبقى كيف ما هو.",
|
||||
"keep-add-card-form-open": "خلّي الفورمولير ديال إضافة بطاقة محلول من بعد ما تزيد بطاقة",
|
||||
"keep-add-card-form-open-description": "مشعول: من بعد ما تتزاد بطاقة، الفورمولير كيبقى محلول بعنوان خاوي للبطاقة الجاية. مطفي: الفورمولير كيتسد غير تتزاد البطاقة.",
|
||||
"show-focus-outline": "بين حدود التركيز",
|
||||
"checklist-ding-sound": "شغّل صوت ملي كيتكمل عنصر ديال لائحة تاشيك",
|
||||
"checklist-ding-sound-description": "شغّل نغمة قصيرة ملي تعلّم عنصر ديال لائحة تاشيك. راه مطفي من الأصل.",
|
||||
"assignee": "محولشده",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "স্বনিৰ্বাচিত লগইন ল'গ' সংযোগৰ URL",
|
||||
"custom-help-link-url": "স্বনিৰ্বাচিত সহায় সংযোগৰ URL",
|
||||
"text-below-custom-login-logo": "স্বনিৰ্বাচিত লগইন ল'গ'ৰ তলৰ পাঠ",
|
||||
"login-background-color": "লগইন পৃষ্ঠাৰ পটভূমিৰ ৰং",
|
||||
"automatic-linked-url-schemes": "স্বয়ংক্ৰিয়ভাৱে ক্লিক কৰিব পৰা স্বনিৰ্বাচিত URL আঁচনি। প্ৰতি শাৰীত এটা URL আঁচনি",
|
||||
"automatic-linked-url-schemes-hint": "অবিকল্পিতভাৱে খালী থাকে, সেয়ে কেৱল ৱেব আৰু মেইল লিংকত ক্লিক কৰিব পাৰি। তালিকাভুক্ত প্ৰতিটো স্কীমে, যেনে thunderlink বা onenote, তাৰ বাবে পঞ্জীয়ন কৰা এপ্লিকেচন খোলে। javascript, data, vbscript আৰু একেধৰণৰ স্কীম কেতিয়াও লিংক কৰা নহয়।",
|
||||
"external-link-pattern": "বাহিৰৰ সমস্যা ট্ৰেকাৰলৈ স্বয়ংক্ৰিয় লিংক",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "নতুন কাৰ্ডৰ শিৰোনাম [লিখনি]ৰে আৰম্ভ হৈ তাৰ পিছত আৰু লিখনি থাকিলে, লিখনি কাৰ্ডখনৰ লেবেল হয় (ফলকত সেই নামৰ লেবেল নাথাকিলে সৃষ্টি কৰা হয়) আৰু শিৰোনামৰ পৰা আঁতৰোৱা হয়। বন্ধ: শিৰোনাম যেনে আছে তেনেকৈ থাকে।",
|
||||
"keep-add-card-form-open": "কাৰ্ড যোগ কৰাৰ পিছত কাৰ্ড যোগ কৰক ফৰ্মখন খোলা ৰাখক",
|
||||
"keep-add-card-form-open-description": "সক্ৰিয়: কাৰ্ড যোগ কৰাৰ পিছত পৰৱৰ্তী কাৰ্ডৰ বাবে খালী শিৰোনামৰ সৈতে ফৰ্মখন খোলা থাকে। নিষ্ক্ৰিয়: কাৰ্ডখন যোগ হোৱাৰ লগে লগে ফৰ্মখন বন্ধ হয়।",
|
||||
"show-focus-outline": "ফোকাছ আউটলাইন দেখুৱাওক",
|
||||
"checklist-ding-sound": "চেকলিষ্ট বস্তু সম্পূৰ্ণ হ'লে শব্দ বজাওক",
|
||||
"checklist-ding-sound-description": "চেকলিষ্ট বস্তু চেক কৰাৰ সময়ত চুটি শব্দ বজাওক। অবিকল্পিতভাৱে অফ।",
|
||||
"assignee": "দায়িত্বপ্ৰাপ্ত",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "URL del enllaz del logotipu personalizáu d’aniciu de sesión",
|
||||
"custom-help-link-url": "URL del enllaz d’ayuda personalizáu",
|
||||
"text-below-custom-login-logo": "Testu debaxo del logotipu personalizáu d’aniciu de sesión",
|
||||
"login-background-color": "Color de fondu de la páxina d'aniciu de sesión",
|
||||
"automatic-linked-url-schemes": "Esquemes d’URL personalizaos que tienen de poder calcase automáticamente. Un esquema d’URL per llinia.",
|
||||
"automatic-linked-url-schemes-hint": "Balero por defeutu, polo que namás se pue calcar nos enllaces web y de corréu. Cada esquema llistáu, por exemplu thunderlink o onenote, abre l'aplicación rexistrada pa él. javascript, data, vbscript y esquemes asemeyaos nunca se convierten n'enllaces.",
|
||||
"external-link-pattern": "Enllaz automáticu a un rastreador d'asuntos esternu",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Cuando'l títulu d'una tarxeta nueva entama con [Testu] siguíu de más testu, Testu conviértese nuna etiqueta de la tarxeta (críase si'l tableru nun tien nenguna con esi nome) y quítase del títulu. Desactiváu: el títulu caltiénse como ta.",
|
||||
"keep-add-card-form-open": "Caltener abiertu'l formulariu d'amestar tarxeta dempués d'amestar una tarxeta",
|
||||
"keep-add-card-form-open-description": "Activáu: dempués d'amestar una tarxeta, el formulariu sigue abiertu con un títulu baleru pa la tarxeta siguiente. Desactiváu: el formulariu ciérrase en cuantes s'amiesta la tarxeta.",
|
||||
"show-focus-outline": "Amosar el contornu del focu",
|
||||
"checklist-ding-sound": "Sonar cuando se completa un elementu de llista de verificación",
|
||||
"checklist-ding-sound-description": "Sonar un tonu curtiu cuando marques un elementu de llista de verificación. Desactiváu por defeutu.",
|
||||
"assignee": "Asignado",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "Custom Login Logo chinu URL",
|
||||
"custom-help-link-url": "Custom Help chinu URL",
|
||||
"text-below-custom-login-logo": "qillqa below Custom Login Logo",
|
||||
"login-background-color": "Login panka qhipa color",
|
||||
"automatic-linked-url-schemes": "Aymar aruna: Custom URL Schemes which should automatically be clickable. One URL Scheme per line",
|
||||
"automatic-linked-url-schemes-hint": "Qalltanxa chʼusawa, ukatwa web ukat correo electrónico chinuwinakaki limtʼasma. Listankir sapa esquema, sañäni thunderlink jan ukax onenote, jupatak qillqantat programa jistʼari. javascript, data, vbscript ukat ukham esquemanakax janipuniw chinuwir tukuyatäkiti.",
|
||||
"external-link-pattern": "Autoenlace hacia jach'a tracker jan p'ampachaña",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Machaqa kartan p'iqi sutipax [Qillqa] ukamp qalltani ukatsti juk'amp qillqax arkani ukhaxa, Qillqax kartan unanchapaw tuku (pirqan uka sutin unanchax janiw utjkchi ukhax lurataw) ukatsti p'iqi sutit apsutaw. Jist'arata: p'iqi sutix kunjamatix ukhamaw qhipari.",
|
||||
"keep-add-card-form-open": "Karta yapañ formulario jist'aratapa uñjaña, karta yapatatxa",
|
||||
"keep-add-card-form-open-description": "Naktayata: karta yapatatxa, formulariox jist'aratakiw qhipa kartataki ch'usa sutimpi. Jiwt'ayata: formulariox jist'antasiwa karta yapatatxa.",
|
||||
"show-focus-outline": "Qhanstayaña muyu uñachtʼayaña",
|
||||
"checklist-ding-sound": "Checklist amtata phuqhasitaxa qhanañchaña uñstayaña",
|
||||
"checklist-ding-sound-description": "Checklist amtata qillqasina taqi kunaru jisk'a qhanañcha uñstayi. Jan apnaqata.",
|
||||
"assignee": "Lurañataki churata",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "Fərdi Giriş Loqo Linki URL",
|
||||
"custom-help-link-url": "Fərdi Yardım Linki URL",
|
||||
"text-below-custom-login-logo": "Xüsusi Giriş Loqosunun altındakı mətn",
|
||||
"login-background-color": "Giriş səhifəsinin fon rəngi",
|
||||
"automatic-linked-url-schemes": "Avtomatik olaraq klik edilə bilən fərdi URL sxemləri. Hər sətir üçün bir URL sxemi",
|
||||
"automatic-linked-url-schemes-hint": "Standart olaraq boşdur, buna görə yalnız veb və poçt keçidləri kliklənə bilər. Siyahıdakı hər sxem, məsələn, thunderlink və ya onenote, onun üçün qeydiyyatdan keçmiş tətbiqi açır. javascript, data, vbscript və oxşar sxemlər heç vaxt keçidə çevrilmir.",
|
||||
"external-link-pattern": "Xarici xəta izləyicisinə avtomatik keçid",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Yeni kartın başlığı [Mətn] ilə başlayıb ardınca başqa mətn gəldikdə, Mətn kartın etiketi olur (lövhədə bu adda etiket yoxdursa, yaradılır) və başlıqdan silinir. Söndürülüb: başlıq olduğu kimi saxlanılır.",
|
||||
"keep-add-card-form-open": "Kart əlavə edildikdən sonra Kart əlavə et formasını açıq saxla",
|
||||
"keep-add-card-form-open-description": "Aktiv: kart əlavə edildikdən sonra forma növbəti kart üçün boş başlıqla açıq qalır. Deaktiv: kart əlavə edilən kimi forma bağlanır.",
|
||||
"show-focus-outline": "Fokus konturunu göstər",
|
||||
"checklist-ding-sound": "Yoxlama siyahısı elementi tamamlandıqda səs çal",
|
||||
"checklist-ding-sound-description": "Yoxlama siyahısı elementini işarələyəndə qısa siqnal səsi çal. Standart olaraq söndürülüb.",
|
||||
"assignee": "Təyin edilmiş şəxs",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "Fərdi Giriş Loqo Linki URL",
|
||||
"custom-help-link-url": "Fərdi Yardım Linki URL",
|
||||
"text-below-custom-login-logo": "Xüsusi Giriş Loqosunun altındakı mətn",
|
||||
"login-background-color": "Giriş səhifəsinin fon rəngi",
|
||||
"automatic-linked-url-schemes": "Avtomatik olaraq klik edilə bilən fərdi URL sxemləri. Hər sətir üçün bir URL sxemi",
|
||||
"automatic-linked-url-schemes-hint": "Standart olaraq boşdur, buna görə yalnız veb və poçt keçidləri kliklənə bilər. Siyahıdakı hər sxem, məsələn, thunderlink və ya onenote, onun üçün qeydiyyatdan keçmiş tətbiqi açır. javascript, data, vbscript və oxşar sxemlər heç vaxt keçidə çevrilmir.",
|
||||
"external-link-pattern": "Xarici xəta izləyicisinə avtomatik keçid",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Yeni kartın başlığı [Mətn] ilə başlayıb ardınca başqa mətn gəldikdə, Mətn kartın etiketi olur (lövhədə bu adda etiket yoxdursa, yaradılır) və başlıqdan silinir. Söndürülüb: başlıq olduğu kimi saxlanılır.",
|
||||
"keep-add-card-form-open": "Kart əlavə edildikdən sonra Kart əlavə et formasını açıq saxla",
|
||||
"keep-add-card-form-open-description": "Aktiv: kart əlavə edildikdən sonra forma növbəti kart üçün boş başlıqla açıq qalır. Deaktiv: kart əlavə edilən kimi forma bağlanır.",
|
||||
"show-focus-outline": "Fokus konturunu göstər",
|
||||
"checklist-ding-sound": "Yoxlama siyahısı elementi tamamlandıqda səs çal",
|
||||
"checklist-ding-sound-description": "Yoxlama siyahısı elementini işarələyəndə qısa siqnal səsi çal. Standart olaraq söndürülüb.",
|
||||
"assignee": "Təyin edilmiş şəxs",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "Fərdi Giriş Loqo Linki URL",
|
||||
"custom-help-link-url": "Fərdi Yardım Linki URL",
|
||||
"text-below-custom-login-logo": "Xüsusi Giriş Loqosunun altındakı mətn",
|
||||
"login-background-color": "Giriş səhifəsinin fon rəngi",
|
||||
"automatic-linked-url-schemes": "Avtomatik olaraq klik edilə bilən fərdi URL sxemləri. Hər sətir üçün bir URL sxemi",
|
||||
"automatic-linked-url-schemes-hint": "Standart olaraq boşdur, buna görə yalnız veb və poçt keçidləri kliklənə bilər. Siyahıdakı hər sxem, məsələn, thunderlink və ya onenote, onun üçün qeydiyyatdan keçmiş tətbiqi açır. javascript, data, vbscript və oxşar sxemlər heç vaxt keçidə çevrilmir.",
|
||||
"external-link-pattern": "Xarici xəta izləyicisinə avtomatik keçid",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Yeni kartın başlığı [Mətn] ilə başlayıb ardınca başqa mətn gəldikdə, Mətn kartın etiketi olur (lövhədə bu adda etiket yoxdursa, yaradılır) və başlıqdan silinir. Söndürülüb: başlıq olduğu kimi saxlanılır.",
|
||||
"keep-add-card-form-open": "Kart əlavə edildikdən sonra Kart əlavə et formasını açıq saxla",
|
||||
"keep-add-card-form-open-description": "Aktiv: kart əlavə edildikdən sonra forma növbəti kart üçün boş başlıqla açıq qalır. Deaktiv: kart əlavə edilən kimi forma bağlanır.",
|
||||
"show-focus-outline": "Fokus konturunu göstər",
|
||||
"checklist-ding-sound": "Yoxlama siyahısı elementi tamamlandıqda səs çal",
|
||||
"checklist-ding-sound-description": "Yoxlama siyahısı elementini işarələyəndə qısa siqnal səsi çal. Standart olaraq söndürülüb.",
|
||||
"assignee": "Təyin edilmiş şəxs",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "Махсус инеү логотип һылтанмаһының URL-ы",
|
||||
"custom-help-link-url": "Махсус ярҙам һылтанмаһының URL-ы",
|
||||
"text-below-custom-login-logo": "Махсус инеү логотибы аҫтындағы текст",
|
||||
"login-background-color": "Керү битенең фон төсө",
|
||||
"automatic-linked-url-schemes": "Автоматик баҫыла торған махсус URL схемалары. Һәр юлға бер URL схемаһы",
|
||||
"automatic-linked-url-schemes-hint": "Ғәҙәттә буш, шуға күрә веб һәм почта һылтанмаларына ғына баҫып була. Исемлектәге һәр схема, мәҫәлән thunderlink йәки onenote, уның өсөн теркәлгән ҡушымтаны аса. javascript, data, vbscript һәм оҡшаш схемалар бер ҡасан да һылтанмаға әйләндерелмәй.",
|
||||
"external-link-pattern": "Тышҡы проблема күҙәтеүсеһенә автоматик һылтанма",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Яңы карточканың баш һүҙе [Текст] менән башланып, артынан башҡа текст килһә, Текст карточканың билдәһе була (тактала был исемле билдә булмаһа, булдырыла) һәм баш һүҙҙән алына. Һүндерелгән: баш һүҙ нисек бар, шулай ҡала.",
|
||||
"keep-add-card-form-open": "Карточка өҫтәгәндән һуң «Карточка өҫтәү» формаһын асыҡ ҡалдырырға",
|
||||
"keep-add-card-form-open-description": "Ҡабыҙылған: карточка өҫтәлгәндән һуң форма киләһе карточка өсөн буш исем менән асыҡ ҡала. Һүндерелгән: карточка өҫтәлеү менән форма ябыла.",
|
||||
"show-focus-outline": "Фокус рамкаһын күрһәтергә",
|
||||
"checklist-ding-sound": "Тикшереү исемлеге пункты тамамланғанда тауыш уйнат",
|
||||
"checklist-ding-sound-description": "Тикшереү исемлегендәге пунктты билдәләгәндә кыска тауыш уйнат. Стандарт буйынса һүнгән.",
|
||||
"assignee": "Яуаплы",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "URL спасылкі карыстальніцкага лагатыпа ўваходу",
|
||||
"custom-help-link-url": "URL карыстальніцкай спасылкі даведкі",
|
||||
"text-below-custom-login-logo": "Тэкст пад карыстальніцкім лагатыпам уваходу",
|
||||
"login-background-color": "Колер фону старонкі ўваходу",
|
||||
"automatic-linked-url-schemes": "Карыстальніцкія схемы URL, якія павінны аўтаматычна станавіцца націскальнымі. Адна схема URL на радок",
|
||||
"automatic-linked-url-schemes-hint": "Прадвызначана пустое, таму націскаць можна толькі на вэб-спасылкі і спасылкі электроннай пошты. Кожная пералічаная схема, напрыклад thunderlink або onenote, адкрывае зарэгістраваную для яе праграму. Для схем javascript, data, vbscript і падобных спасылкі ніколі не ствараюцца.",
|
||||
"external-link-pattern": "Аўтаспасылка на знешні трэкер задач",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Калі назва новай карткі пачынаецца з [Тэкст], за якім ідзе іншы тэкст, Тэкст становіцца меткай карткі (ствараецца, калі на дошцы няма меткі з такой назвай) і выдаляецца з назвы. Выключана: назва застаецца як ёсць.",
|
||||
"keep-add-card-form-open": "Пакідаць форму «Дадаць картку» адкрытай пасля дадання карткі",
|
||||
"keep-add-card-form-open-description": "Уключана: пасля дадання карткі форма застаецца адкрытай з пустой назвай для наступнай карткі. Выключана: форма закрываецца, як толькі картка дададзена.",
|
||||
"show-focus-outline": "Паказваць контур фокусу",
|
||||
"checklist-ding-sound": "Прайграваць гук пры выкананні пункта спіса",
|
||||
"checklist-ding-sound-description": "Прайграваць кароткі гук пры адзначэнні пункта спіса задач. Па змаўчанні выключана.",
|
||||
"assignee": "Прызначаны",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "URL на връзката за персонализираното лого при вход",
|
||||
"custom-help-link-url": "URL на персонализираната връзка за помощ",
|
||||
"text-below-custom-login-logo": "Текст под персонализираното лого при вход",
|
||||
"login-background-color": "Цвят на фона на страницата за вход",
|
||||
"automatic-linked-url-schemes": "Персонализирани URL схеми, които автоматично стават активни връзки. По една URL схема на ред",
|
||||
"automatic-linked-url-schemes-hint": "По подразбиране е празно, така че могат да се отварят само уеб връзки и връзки за имейл. Всяка изброена схема, например thunderlink или onenote, отваря приложението, регистрирано за нея. javascript, data, vbscript и подобни схеми никога не стават активни връзки.",
|
||||
"external-link-pattern": "Автоматично свързване с външна система за проследяване на проблеми",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Когато заглавието на нова карта започва с [Текст], последван от още текст, Текст става етикет на картата (създава се, ако таблото няма етикет с това име) и се премахва от заглавието. Изключено: заглавието се запазва както е.",
|
||||
"keep-add-card-form-open": "Формата „Добави карта“ да остава отворена след добавяне на карта",
|
||||
"keep-add-card-form-open-description": "Включено: след добавяне на карта формата остава отворена с празно заглавие за следващата карта. Изключено: формата се затваря веднага щом картата бъде добавена.",
|
||||
"show-focus-outline": "Показване на контура на фокуса",
|
||||
"checklist-ding-sound": "Изпълни звук, когато елемент от списъка за проверка е завършен",
|
||||
"checklist-ding-sound-description": "Изпълни кратък звук при отбелязване на елемент от списъка за проверка. По подразбиране е изключено.",
|
||||
"assignee": "Изпълнител",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "मनपसंद लॉग इन लोगो कड़ी URL",
|
||||
"custom-help-link-url": "मनपसंद मदद कड़ी URL",
|
||||
"text-below-custom-login-logo": "मनपसंद लॉग इन लोगो के नीचे लिखाई",
|
||||
"login-background-color": "लॉगिन पन्ना के पृष्ठभूमि रंग",
|
||||
"automatic-linked-url-schemes": "मनपसंद URL योजना जे अपने-आप क्लिक करे लायक होखे। हर पाँति में एगो URL योजना",
|
||||
"automatic-linked-url-schemes-hint": "डिफॉल्ट रूप से खाली बा, एहसे खाली वेब आ ईमेल के लिंक पर क्लिक कइल जा सकेला। सूची में दिहल हर लिंक स्कीम, जइसे thunderlink भा onenote, अपना खातिर पंजीकृत ऐप खोलेला। javascript, data, vbscript आ एह तरह के स्कीम के कबो क्लिक करे लायक लिंक ना बनावल जाला।",
|
||||
"external-link-pattern": "बाहरी इश्यू ट्रैकर से खुदे-खुद लिंक",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "जब नया कार्ड के शीर्षक [पाठ] से शुरू होखे आ ओकरा बाद अउरी लिखल होखे, त पाठ कार्ड के लेबल बन जाला (अगर बोर्ड में ओह नाम के लेबल ना होखे त बनावल जाला) आ शीर्षक से हटा दिहल जाला। बंद: शीर्षक जइसन बा ओइसने रहेला।",
|
||||
"keep-add-card-form-open": "कार्ड जोड़ला के बाद कार्ड जोड़े वाला फॉर्म खुलल रखीं",
|
||||
"keep-add-card-form-open-description": "चालू: कार्ड जोड़ला के बाद, अगिला कार्ड खातिर खाली शीर्षक के साथे फॉर्म खुलल रहेला। बंद: कार्ड जुड़ते फॉर्म बंद हो जाला।",
|
||||
"show-focus-outline": "फोकस आउटलाइन देखाईं",
|
||||
"checklist-ding-sound": "चेकलिस्ट आइटम पूरा होखला पर आवाज बजाईं",
|
||||
"checklist-ding-sound-description": "चेकलिस्ट आइटम चेक करला पर छोट आवाज बजाईं। डिफॉल्ट रूप से बंद।",
|
||||
"assignee": "जिम्मेदार",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "URL blong Link blong Spesel Logo blong Go insaed",
|
||||
"custom-help-link-url": "URL blong Spesel Link blong Help",
|
||||
"text-below-custom-login-logo": "Raeting aninit long Spesel Logo blong Go insaed",
|
||||
"login-background-color": "Kala blong baksaed blong pej blong login",
|
||||
"automatic-linked-url-schemes": "Ol Spesel Fasin blong URL we yu save klik otomatik. Wan Fasin blong URL long wanwan laen",
|
||||
"automatic-linked-url-schemes-hint": "Long fas seting, hem i emti, mekem se yu save klik long ol link blong web mo imel nomo. Evri kaen link long lis, olsem thunderlink o onenote, i openem program we oli rejistarem blong hem. javascript, data, vbscript mo ol kaen olsem oli neva kam ol link we yu save klik long olgeta.",
|
||||
"external-link-pattern": "Otomatik link i go long wan aotsaed traka blong problem",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Taem taetol blong wan niu kad i stat wetem [Tekis] mo i gat moa tekis biaen, Tekis i kam wan mak blong kad (oli mekem sapos bod i no gat mak wetem nem ia) mo oli tekemaot long taetol. Of: taetol i stap olsem we i stap.",
|
||||
"keep-add-card-form-open": "Livim fom blong Adem kad i stap open afta we yu adem wan kad",
|
||||
"keep-add-card-form-open-description": "On: afta we yu adem wan kad, fom i stap open wetem wan taetel we i emti blong nekis kad. Of: fom i satem stret taem we kad i kam insaed.",
|
||||
"show-focus-outline": "Soem aot-lain blong fokas",
|
||||
"checklist-ding-sound": "Plei wan saon taem wan samting long jeklis i finis",
|
||||
"checklist-ding-sound-description": "Plei wan sotfala saon taem yu makem wan samting long jeklis. Long stat, hem i of.",
|
||||
"assignee": "Man we i gat wok",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "Donni logo lasigiden URL kɛrɛnkɛrɛnnen",
|
||||
"custom-help-link-url": "Dɛmɛ lasigiden URL kɛrɛnkɛrɛnnen",
|
||||
"text-below-custom-login-logo": "Sɛbɛnni donni logo kɛrɛnkɛrɛnnen duguma",
|
||||
"login-background-color": "Kuntilenni gafe jukɔrɔ kulɛri",
|
||||
"automatic-linked-url-schemes": "URL cogoya kɛrɛnkɛrɛnnen minnu ka kan ka digi yɛrɛ ma. URL cogoya kelen sira kelen kelen bɛɛ la",
|
||||
"automatic-linked-url-schemes-hint": "A daminɛ na, a bɛ to kolon, o la i bɛ se ka web ani imɛli siriliw dɔrɔn digi. Sɛbɛnni kɔnɔ sikiima kelen-kelen bɛɛ, misali la thunderlink walima onenote, bɛ porogaramu min tɔgɔ sɛbɛnna a kama, o dayɛlɛ. javascript, data, vbscript ani sikiimaw minnu bɛ i nʼa fɔ olu, olu tɛ kɛ siriliw ye abada.",
|
||||
"external-link-pattern": "Sɛbɛnni ka baara kɛ ni kunbɛnjuru kunkan ka gafe ye kɔfɛ",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Ni karti kura kunnafoni kuntigi daminɛna ni [Sɛbɛn] ye ani sɛbɛn wɛrɛ b'a kɔfɛ, Sɛbɛn bɛ kɛ karti in taamasiyɛn ye (a bɛ dila ni walan tɛ taamasiyɛn si ye o tɔgɔ la) ani a bɛ bɔ kunnafoni kuntigi la. A dabilalen: kunnafoni kuntigi bɛ to i n'a fɔ a bɛ cogo min na.",
|
||||
"keep-add-card-form-open": "Karti fara a kan fɔrimu to a dayɛlɛlen na karti farali kɔfɛ",
|
||||
"keep-add-card-form-open-description": "A bɛ baara la: ni karti farala a kan, fɔrimu bɛ to a dayɛlɛlen na ni tɔgɔ lankolon ye karti nata kama. A tɛ baara la: fɔrimu bɛ datugu ni karti farala a kan dɔrɔn.",
|
||||
"show-focus-outline": "Fokisi lamini jira",
|
||||
"checklist-ding-sound": "Kanfa mankan tuma min na checklist fɛn dafara",
|
||||
"checklist-ding-sound-description": "Kanfa mankan surunman tuma min na i bɛ checklist fɛn sɛbɛn. A tɛ baara la ka bɔ a bɔyɔrɔ la.",
|
||||
"assignee": "Baarakɛla",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "কাস্টম প্রবেশ লোগো লিংকের URL",
|
||||
"custom-help-link-url": "কাস্টম সহায়তা লিংকের URL",
|
||||
"text-below-custom-login-logo": "কাস্টম প্রবেশ লোগোর নিচের লেখা",
|
||||
"login-background-color": "লগইন পৃষ্ঠার পটভূমির রং",
|
||||
"automatic-linked-url-schemes": "স্বয়ংক্রিয়ভাবে ক্লিকযোগ্য কাস্টম URL স্কিম। প্রতি লাইনে একটি URL স্কিম",
|
||||
"automatic-linked-url-schemes-hint": "ডিফল্টভাবে ফাঁকা, তাই শুধু ওয়েব ও মেইল লিংক ক্লিক করা যায়। তালিকাভুক্ত প্রতিটি স্কিম, যেমন thunderlink বা onenote, তার জন্য নিবন্ধিত অ্যাপ খোলে। javascript, data, vbscript ও একই ধরনের স্কিম কখনো লিংক করা হয় না।",
|
||||
"external-link-pattern": "বহিরাগত ইস্যু ট্র্যাকারে স্বয়ংক্রিয় লিঙ্ক",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "নতুন কার্ডের শিরোনাম [লেখা] দিয়ে শুরু হয়ে তারপর আরও লেখা থাকলে, লেখা কার্ডটির লেবেল হয়ে যায় (বোর্ডে সেই নামের লেবেল না থাকলে তৈরি করা হয়) এবং শিরোনাম থেকে সরানো হয়। বন্ধ: শিরোনাম যেমন আছে তেমনই থাকে।",
|
||||
"keep-add-card-form-open": "কার্ড যোগ করার পরে কার্ড যোগ করুন ফর্মটি খোলা রাখুন",
|
||||
"keep-add-card-form-open-description": "চালু: কার্ড যোগ করার পরে, পরের কার্ডের জন্য খালি শিরোনাম সহ ফর্মটি খোলা থাকে। বন্ধ: কার্ড যোগ হওয়ার সাথে সাথে ফর্মটি বন্ধ হয়ে যায়।",
|
||||
"show-focus-outline": "ফোকাস আউটলাইন দেখান",
|
||||
"checklist-ding-sound": "চেকলিস্ট আইটেম সম্পূর্ণ হলে শব্দ বাজান",
|
||||
"checklist-ding-sound-description": "চেকলিস্ট আইটেম চেক করার সময় সংক্ষিপ্ত শব্দ বাজান। ডিফল্টভাবে বন্ধ।",
|
||||
"assignee": "দায়িত্বপ্রাপ্ত",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "རང་བཟོས་ཐོ་འཇུག་རྟགས་རིས་མཐུད་ཐག་ URL",
|
||||
"custom-help-link-url": "རང་བཟོས་རོགས་རམ་མཐུད་ཐག་ URL",
|
||||
"text-below-custom-login-logo": "རང་བཟོས་ཐོ་འཇུག་རྟགས་རིས་འོག་གི་ཡི་གེ",
|
||||
"login-background-color": "ནང་འཛུལ་ཤོག་ངོས་ཀྱི་རྒྱབ་ལྗོངས་ཚོན་མདོག",
|
||||
"automatic-linked-url-schemes": "རང་འགུལ་གྱིས་མནན་ཐུབ་པའི་རང་བཟོས་ URL འཆར་གཞི། ཐིག་རེར་ URL འཆར་གཞི་གཅིག",
|
||||
"automatic-linked-url-schemes-hint": "སྔོན་སྒྲིག་ལྟར་སྟོང་པ་ཡིན་པས། དྲ་རྒྱ་དང་གློག་འཕྲིན་གྱི་དྲ་མཐུད་ཁོ་ན་ལ་མནན་ཆོག ཐོ་གཞུང་ནང་བཀོད་པའི་འཆར་གཞི་རེ་རེས། དཔེར་ན་ thunderlink ཡང་ན་ onenote ལྟ་བུས། དེའི་ཆེད་དུ་ཐོ་འགོད་བྱས་པའི་ཉེར་སྤྱོད་ཁ་ཕྱེ་བ་ཡིན། javascript དང་ data དང་ vbscript དང་དེ་དག་དང་འདྲ་བའི་འཆར་གཞི་རྣམས་ནམ་ཡང་དྲ་མཐུད་དུ་མི་བསྒྱུར།",
|
||||
"external-link-pattern": "ཕྱིའི་དཀའ་ངལ་རྗེས་འདེད་ལུ་རང་བཞིན་མཐུད་ལམ།",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "ཤོག་བྱང་གསར་པའི་མཚན་བྱང་[ཡི་གེ]ནས་འགོ་ཚུགས་ཏེ་རྗེས་སུ་ཡི་གེ་གཞན་ཡོད་ན། ཡི་གེ་དེ་ཤོག་བྱང་གི་ཁ་བྱང་དུ་འགྱུར། (བྱང་བུ་ལ་མིང་དེའི་ཁ་བྱང་མེད་ན་གསར་བཟོ་བྱེད།) མཚན་བྱང་ནས་བསུབ། བཀག་ན། མཚན་བྱང་ཇི་བཞིན་ཉར།",
|
||||
"keep-add-card-form-open": "ཤོག་བྱང་སྣོན་རྗེས་ཤོག་བྱང་སྣོན་པའི་རེའུ་མིག་ཁ་ཕྱེ་སྟེ་བཞག",
|
||||
"keep-add-card-form-open-description": "ཁ་ཕྱེ་: ཤོག་བྱང་སྣོན་རྗེས་རེའུ་མིག་དེ་ཤོག་བྱང་རྗེས་མའི་ཆེད་དུ་ཁ་བྱང་སྟོང་པ་དང་བཅས་ཁ་ཕྱེ་སྟེ་སྡོད། ཁ་རྒྱག་: ཤོག་བྱང་སྣོན་མ་ཐག་རེའུ་མིག་ཁ་རྒྱག་གི་རེད།",
|
||||
"show-focus-outline": "ཆེད་སྦྱོར་གྱི་མཐའ་ཐིག་སྟོན།",
|
||||
"checklist-ding-sound": "ཞིབ་བཤེར་ཐོ་གཞུང་གི་རྣམ་གྲངས་ཚང་བའི་སྐབས་སྒྲ་སྐད་གཏོང་།",
|
||||
"checklist-ding-sound-description": "ཞིབ་བཤེར་ཐོ་གཞུང་གི་རྣམ་གྲངས་ཞིག་ལ་རྟགས་བཀོད་སྐབས་སྒྲ་སྐད་ཐུང་ངུ་ཞིག་གཏོང་། སྔོན་སྒྲིག་ནི་ཁ་རྒྱག་པ་ཡིན།",
|
||||
"assignee": "འགན་འཁུར་བ།",
|
||||
|
||||
@@ -1311,6 +1311,7 @@
|
||||
"custom-login-logo-link-url": "URL liamm al logo kennaskañ personelaet",
|
||||
"custom-help-link-url": "URL al liamm skoazell personelaet",
|
||||
"text-below-custom-login-logo": "Testenn dindan al logo kennaskañ personelaet",
|
||||
"login-background-color": "Liv drekleur ar bajenn kevreañ",
|
||||
"automatic-linked-url-schemes": "Steuñvioù URL personelaet a rank bezañ klikadus ent emgefre. Ur steuñv URL dre linenn",
|
||||
"automatic-linked-url-schemes-hint": "Goullo dre ziouer, setu al liammoù web ha postel hepken zo klikadus. Pep skouerenn listennet, da skouer thunderlink pe onenote, a zigor an arload enrollet eviti. Ne vez ket krouet liammoù evit javascript, data, vbscript pe skouerennoù heñvel morse.",
|
||||
"external-link-pattern": "Liamm emgefre ouzh un heuliad kudennoù diavaez",
|
||||
@@ -1991,6 +1992,7 @@
|
||||
"title-bracket-label-description": "Pa grog titl ur gartenn nevez gant [Testenn] heuliet gant muioc'h a destenn, e teu Testenn da vezañ un dikedenn eus ar gartenn (krouet ma n'eus hini ebet gant an anv-se en daolenn) hag e vez lamet eus an titl. Lazhet: miret e vez an titl evel m'emañ.",
|
||||
"keep-add-card-form-open": "Derc'hel digor ar furmskrid Ouzhpennañ ur gartenn goude bezañ ouzhpennet ur gartenn",
|
||||
"keep-add-card-form-open-description": "Enaouet: goude ouzhpennañ ur gartenn e chom digor ar furmskrid gant un titl goullo evit ar gartenn war-lerc'h. Lazhet: serret e vez ar furmskrid kerkent ha ma vez ouzhpennet ar gartenn.",
|
||||
"show-focus-outline": "Diskouez trolinenn ar fokus",
|
||||
"checklist-ding-sound": "Seniñ ur son pa vez echuet un elfenn eus roll gwiriañ",
|
||||
"checklist-ding-sound-description": "Seniñ ur son berr pa vez merket un elfenn eus roll gwiriañ. Diweredekaet dre ziouer.",
|
||||
"assignee": "Den deverket",
|
||||
|
||||
Loaded 100 of 424 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user