mirror of
https://github.com/wekan/wekan.git
synced 2026-11-04 17:10:08 -05:00
- #6459: card More menu rendered empty (no Delete): the vendored jade compiler's htmljs patch made isConstructedObject(Array) false, so 'label {{_ ...}}:' lines got their content array mis-assigned as tag attributes and Blaze threw 'foreign objects in attributes' killing the whole cardMorePopup. Also fixed the list More link input (nonexistent rootUrl helper -> absoluteUrl). - #6455: saving a card description never removed a pre-existing unsaved- edits record, and the draft/description comparison mismatched on Markdown hard-breaks and empty descriptions, re-creating a phantom draft on every save. Save now clears the draft; both sides normalized the same way. - #5547: comment drafts: the draft-saving escape handler had been disarmed since 2019 (flag with no setter), so a comment being written was lost when the card closed. Drafts are now saved continuously (debounced), flushed on teardown, cleared on submit; text no longer visually vanishes on outside clicks. - #5282: attachment delete: guard against the doc being already evicted from Minimongo ('Removed nonexistent document'), like the existing comment/checklist guards. - #6472: rules move card to top/bottom: fall back to the card's current list when listName does not resolve (was a swallowed TypeError = rule silently did nothing); wizard stored listTitle which the engine never reads (listName); empty destination wrote sort +-Infinity; rules JSON/CSV import now uses the rules.createRule method instead of client inserts rejected into minimongo limbo, and defaults missing trigger matching fields to '*'; empty boardId no longer overrides the real board; the server-side orphaned-cards fallback in List.cards()/cardsUnfiltered() now actually resolves its async swimlane lookup. - #6465: desktop density back to WeKan 6.09: base font 14px and headings 22/18/16px (vw clamps resolved to 18/24px on desktop), All Boards wrapper padding 10px/15px, card details docks to the right edge instead of floating on top of its own card (still draggable), 20px canvas padding instead of ~48px white borders, removed the force-all-card-text- to-title-size rule, admin org/team table Select/Unselect-all header links are compact icons and header cells may wrap, zoom pill no longer cut off, one-click board settings cog in the board header. - #6458 #6466: snap: mongodb-control now checks mongod's exit status and does an AVX pre-flight (MongoDB 5+ needs AVX; without it mongod SIGILLs) instead of pinging a dead port forever behind a 502: it switches to a completed FerretDB migration, or (re)runs the migration (AVX-free) with a 3-attempt guard. Migration importers no longer fail the whole run on >=10 per-item errors ('Avatars Errors' etc.), and a failed 3.x-source migration keeps the copied FerretDB data + checkpoint and resumes next start instead of deleting it and 'falling back' to a mongod 7 that cannot open 3.x data files. - #6467 #6468: with FerretDB, calm Meteor's no-oplog polling storm (METEOR_POLLING_THROTTLE_MS=2000, METEOR_POLLING_INTERVAL_MS=30000 defaults in the snap and bundle launcher; overridable). The FerretDB fork gets filter pushdown, a sane pool size and less locking in its own repo. - Updated ChangeLog. Thanks to xet7.
This commit is contained in:
1 parent
1193f3a4b5
commit
ed19445d30
24 files changed
+634
-120
No files matched your search
+125
@@ -104,6 +104,131 @@ This release adds the following updates:
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
- **GUI: the "More" menu of cards was completely empty, so cards could not be deleted from it**
|
||||
([#6459](https://github.com/wekan/wekan/issues/6459),
|
||||
`npm-packages/meteor-jade-loader/lib/vendor/htmljs.js`, `client/components/lists/listHeader.jade`).
|
||||
A WeKan-local vm-sandbox patch in the vendored jade compiler's htmljs made
|
||||
`isConstructedObject(Array)` return `false` (upstream returns `true`), so a tag whose inline text
|
||||
compiles to an ARRAY — the `label {{_ 'source-board'}}:` and `label {{_ 'parent-card'}}:` lines in
|
||||
cardMorePopup — got its content array mis-assigned as the tag's *attributes*. At runtime Blaze then
|
||||
found a template view object inside the attributes and threw *"The basic TransformingVisitor does
|
||||
not support foreign objects in attributes"* on every render, killing the whole popup — including
|
||||
the card Delete link. The tag constructor now treats an array first-argument as content, like
|
||||
upstream htmljs. Also fixed the list "More" popup's copy-link input, which was always empty because
|
||||
it referenced a `rootUrl` helper that does not exist (now `absoluteUrl`). Board and swimlane menus
|
||||
intentionally offer archive (delete lives in Sidebar → Archive), so only the card menu was actually
|
||||
broken. Thanks to thku and xet7.
|
||||
|
||||
- **Cards: the "You have an unsaved description" warning could never be cleared by saving**
|
||||
([#6455](https://github.com/wekan/wekan/issues/6455), reincarnation of
|
||||
[#1287](https://github.com/wekan/wekan/issues/1287), `client/components/cards/cardDetails.js`,
|
||||
`client/components/cards/cardDescription.js`). Two bugs: closing the description editor only
|
||||
*avoided adding* a draft when the text matched the saved description — it never *removed* a
|
||||
pre-existing draft record, so once the warning appeared, "View it" → Save could not clear it, only
|
||||
Discard could. And the comparison matched a per-line-whitespace-stripped draft against the raw
|
||||
stored description (or `null` when empty), so descriptions with Markdown `" "` hard-breaks
|
||||
re-created a phantom draft on every save. Saving now removes the draft record explicitly, and both
|
||||
sides of the comparison are normalized the same way. Thanks to C0rn3j and xet7.
|
||||
|
||||
- **Comments: a comment being written was lost when the card was closed or a click landed outside
|
||||
the card** ([#5547](https://github.com/wekan/wekan/issues/5547),
|
||||
`client/components/activities/comments.js`). The comment-draft machinery existed (the form even
|
||||
prefills from it) but had been disarmed since 2019: the escape handler that saved the draft was
|
||||
gated on a "form is open" flag that nothing ever set — the setter was removed back then because the
|
||||
handler also *cleared the visible text* on every outside click. Now the draft is saved continuously
|
||||
(debounced) while typing and flushed when the form is torn down, submit removes the draft, and the
|
||||
escape handler no longer clears the visible text — so an unfinished comment survives closing the
|
||||
card, and reopening the card restores it into the form. Thanks to Finnlife, webenefits and xet7.
|
||||
|
||||
- **Attachments: deleting an attachment could log a client-side "Removed nonexistent document"
|
||||
exception even though the delete succeeded**
|
||||
([#5282](https://github.com/wekan/wekan/issues/5282),
|
||||
`client/components/cards/attachments.js`). Same class as the fixed #3252 for comments and
|
||||
checklists: under publication churn the attachment document can already be evicted from Minimongo
|
||||
when the confirm handler runs, and removing a missing _id throws. The delete now only runs when
|
||||
the document is still in the local cache — the comment/checklist guards' missing sibling. Thanks
|
||||
to lupuszr and xet7.
|
||||
|
||||
- **Rules: the "move card to top/bottom" actions did nothing**
|
||||
([#6472](https://github.com/wekan/wekan/issues/6472), `server/rulesHelper.js`,
|
||||
`client/components/rules/actions/boardActions.js`, `client/components/rules/rulesImportExport.js`,
|
||||
`server/rulesButton.js`, `models/lists.js`). A pile-up of five bugs, all silent because the
|
||||
activity hook swallows rule-action errors: (1) an unresolved destination list (typo'd, renamed,
|
||||
case-mismatched, or on another board) crashed on `list.cardsUnfiltered` — now it falls back to the
|
||||
card's current list; (2) the classic rule wizard's generic "move to top/bottom" stored the field as
|
||||
`listTitle`, which the rule engine never reads (`listName`) — so every such rule created from the
|
||||
wizard has never worked; (3) an empty destination list made `Math.min()/Math.max()` of nothing
|
||||
write a corrupt `sort: ±Infinity`; (4) the rules JSON/CSV import created rules with raw client
|
||||
inserts that the board-admin-only allow rules reject into minimongo limbo — it now uses the same
|
||||
`rules.createRule` server method as the wizard, and defaults missing trigger matching fields (e.g.
|
||||
`userId`) to the `*` wildcard so hand-written JSON matches; (5) `rules.createRule` let an empty
|
||||
`boardId: ''` from a not-yet-loaded board selector override the real board. Also fixed the
|
||||
server-side orphaned-cards fallback in `List.cards()/cardsUnfiltered()`, which silently never
|
||||
applied because an async lookup was read synchronously. Thanks to jmb26240 and xet7.
|
||||
|
||||
- **GUI: desktop density regressions from the v7.98–v8.18 mobile UI work**
|
||||
([#6465](https://github.com/wekan/wekan/issues/6465), `client/components/main/layouts.css`,
|
||||
`client/components/cards/cardDetails.css`, `client/components/main/header.css`,
|
||||
`client/components/settings/peopleBody.jade`, `client/components/settings/settingBody.css`,
|
||||
`client/components/boards/boardHeader.jade`). Restores the WeKan 6.09 desktop look users asked
|
||||
for: the base font is back to 14px (the `clamp(...2.5vw...)` introduced in v7.98 and raised in
|
||||
v8.02 resolved to 18px on any window wider than 720px — +28.5% on every font, button and input,
|
||||
the "everything is way too big" complaint) and headings back to 22/18/16px; the card details
|
||||
window no longer opens as a huge floating sheet ON TOP of its own card — it docks to the right
|
||||
edge like the classic side panel (still movable by its drag handle), with 6.09's 20px content
|
||||
padding instead of ~48px white borders, a 3px corner radius, and without the v8.18 rule that
|
||||
forced ALL card text to the title's size; the All Boards page header band is back to 6.09's
|
||||
compact padding; the Admin Panel Organizations/Teams tables no longer explode column widths
|
||||
("Select all / Unselect all" header links are now compact icons and header cells may wrap); the
|
||||
zoom pill no longer renders cut off (fixed-pixel pill inside the 28px quick-access row); and
|
||||
board settings opens with ONE click from a new cog button in the board header (the sidebar path
|
||||
still works). Note: a missing watch "eye" icon is the Admin Panel → Features → Notifications
|
||||
"disable watch" setting, not a regression. Thanks to Mintyt, csonkaoszimt, micha141076 and xet7.
|
||||
|
||||
- **Snap: WeKan served "502 Bad Gateway" forever when mongod could not start — including on CPUs
|
||||
without AVX ("Illegal instruction")** ([#6458](https://github.com/wekan/wekan/issues/6458),
|
||||
[#6466](https://github.com/wekan/wekan/issues/6466), `snap-src/bin/mongodb-control`,
|
||||
`snap-src/bin/migration-control`). MongoDB 5.0+ x86_64 binaries require AVX; on CPUs without it
|
||||
mongod dies instantly with SIGILL (exit 132). mongodb-control never checked the mongod fork's
|
||||
exit status: it pinged the dead port for ~10 minutes, snapd restarted the service, and the cycle
|
||||
repeated forever — same limbo as when the data files are still MongoDB 3.x ("This version of
|
||||
MongoDB is too recent"). Now there is an AVX pre-flight and the fork/final-start exit codes are
|
||||
checked: if a COMPLETED FerretDB migration exists the snap switches to it; otherwise the
|
||||
MongoDB → FerretDB migration is (re)run — FerretDB is pure Go + SQLite and the 3.x reader uses
|
||||
the bundled MongoDB 3.2 tools, so neither needs AVX — with a 3-attempt counter so a persistently
|
||||
failing migration cannot ping-pong, and clear log guidance (`snap run wekan.migrate`).
|
||||
migration-control also skips the pointless mongod 7 probe when AVX is missing. Thanks to kiarn
|
||||
and xet7.
|
||||
|
||||
- **Snap: a migration that finished with a few per-item errors ("Avatars Errors") deleted the
|
||||
fully-copied FerretDB database and left the snap serving 502 Bad Gateway**
|
||||
([#6466](https://github.com/wekan/wekan/issues/6466), `snap-src/bin/migrate-mongo3-to-ferretdb.mjs`,
|
||||
`releases/migrate-mongodb-to-ferretdb.mjs`, `snap-src/bin/migration-control`). Both importers
|
||||
treated ≥10 logged errors of ANY kind as failure — but per-item errors (one document that fails
|
||||
JSON parsing, one avatar that fails to extract) don't invalidate everything that DID copy. The
|
||||
failure path then discarded the whole migrated SQLite, set `migrate=off`, and "fell back" to
|
||||
MongoDB — impossible for a 6.09 upgrade, whose 3.x data files the bundled mongod 7 cannot open,
|
||||
producing the reported endless `db-eval.mjs ping` loop and 502. Per-item errors are now logged
|
||||
but non-fatal (only real failures — disk full, unreachable target/source — still fail), and a
|
||||
failed 3.x-source migration keeps the partial FerretDB SQLite plus its checkpoint and RESUMES on
|
||||
the next start instead of deleting hours of copied data. Thanks to Nissulya, S0QR2, lezioul,
|
||||
usrflo and xet7.
|
||||
|
||||
- **Snap/Bundle: FerretDB v1 pinned 250–400% CPU and boards took minutes to load after migration**
|
||||
([#6467](https://github.com/wekan/wekan/issues/6467),
|
||||
[#6468](https://github.com/wekan/wekan/issues/6468), `snap-src/bin/wekan-control`,
|
||||
`releases/ferretdb/start-wekan.sh`, and the wekan/FerretDB fork). Two sides. WeKan side: with
|
||||
FerretDB there is no oplog, so Meteor observes every query by POLLING — and its defaults re-run
|
||||
every observed query 50 ms after ANY write and at least every 10 s, which on an active board
|
||||
multiplies into hundreds of full queries per second; with FerretDB the snap and the bundle
|
||||
launcher now default to `METEOR_POLLING_THROTTLE_MS=2000` / `METEOR_POLLING_INTERVAL_MS=30000`
|
||||
(overridable; own changes still appear instantly, other users' changes may take ~2 s longer).
|
||||
FerretDB side (fork v1.28): real filter pushdown so `{boardId: X}` uses the SQLite expression
|
||||
indexes instead of decoding the whole 53k-card collection per query, a connection pool cap of
|
||||
2×CPUs (was 100 — dozens of concurrent full scans thrashing the pure-Go SQLite mutexes were the
|
||||
reported 821k futex calls/30 s), and inserts no longer take the registry's global write lock.
|
||||
Thanks to anlx-sw, markusst1982 and xet7.
|
||||
|
||||
- **LDAP: group search filters were double-escaped and broke group filtering**
|
||||
([#6460](https://github.com/wekan/wekan/issues/6460),
|
||||
[PR #6469](https://github.com/wekan/wekan/pull/6469), `packages/wekan-ldap/server/ldap.js`).
|
||||
|
||||
@@ -10,7 +10,61 @@ const commentFormIsOpen = new ReactiveVar(false);
|
||||
// Empty string means a new top-level comment.
|
||||
const replyToCommentId = new ReactiveVar('');
|
||||
|
||||
// #5547: persist the comment draft (debounced) into UnsavedEdits while the user
|
||||
// types, so a comment in progress survives closing the card, clicking outside
|
||||
// it, or navigating away — the form prefills from {{getUnsavedValue
|
||||
// 'cardComment' ...}} on the next open. Historical note: the click-open flag
|
||||
// that armed the old escape-time draft save was removed in 2019 (3b3950369)
|
||||
// because the escape handler CLEARED the visible text; since then nothing saved
|
||||
// the draft at all, so any close of the card discarded the comment.
|
||||
let commentDraftTimer = null;
|
||||
let commentDraftPending = null; // { cardId, value }
|
||||
|
||||
function commentDraftWrite({ cardId, value }) {
|
||||
if (!cardId) return;
|
||||
const draftKey = { fieldName: 'cardComment', docId: cardId };
|
||||
if ((value || '').trim()) {
|
||||
UnsavedEdits.set(draftKey, value);
|
||||
} else {
|
||||
UnsavedEdits.reset(draftKey);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleCommentDraftSave(cardId, value) {
|
||||
commentDraftPending = { cardId, value };
|
||||
if (commentDraftTimer) clearTimeout(commentDraftTimer);
|
||||
commentDraftTimer = setTimeout(() => {
|
||||
commentDraftTimer = null;
|
||||
const pending = commentDraftPending;
|
||||
commentDraftPending = null;
|
||||
if (pending) commentDraftWrite(pending);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Run a pending (debounced) save immediately — e.g. the card is closing.
|
||||
function flushCommentDraftSave() {
|
||||
if (commentDraftTimer) {
|
||||
clearTimeout(commentDraftTimer);
|
||||
commentDraftTimer = null;
|
||||
}
|
||||
const pending = commentDraftPending;
|
||||
commentDraftPending = null;
|
||||
if (pending) commentDraftWrite(pending);
|
||||
}
|
||||
|
||||
// Drop any pending save — the comment was submitted, so there is no draft.
|
||||
function cancelCommentDraftSave() {
|
||||
if (commentDraftTimer) {
|
||||
clearTimeout(commentDraftTimer);
|
||||
commentDraftTimer = null;
|
||||
}
|
||||
commentDraftPending = null;
|
||||
}
|
||||
|
||||
Template.commentForm.onDestroyed(function () {
|
||||
// #5547: the card can close without the escape handler ever running (the X
|
||||
// button, a route change); make sure the last keystrokes reach the draft.
|
||||
flushCommentDraftSave();
|
||||
commentFormIsOpen.set(false);
|
||||
$('.note-popover').hide();
|
||||
});
|
||||
@@ -28,6 +82,15 @@ Template.commentForm.helpers({
|
||||
});
|
||||
|
||||
Template.commentForm.events({
|
||||
// #5547: arm the escape handler below (it saves the draft and closes the
|
||||
// form); this flag had no setter since 2019, so the handler never ran.
|
||||
'focus .js-new-comment-input'() {
|
||||
commentFormIsOpen.set(true);
|
||||
},
|
||||
// #5547: keep the draft continuously saved (debounced) while typing.
|
||||
'input .js-new-comment-input'(evt) {
|
||||
scheduleCommentDraftSave(Utils.getCurrentCardId(), evt.currentTarget.value);
|
||||
},
|
||||
'submit .js-new-comment-form'(evt, tpl) {
|
||||
const input = tpl.$('.js-new-comment-input');
|
||||
const text = input.val().trim();
|
||||
@@ -48,6 +111,13 @@ Template.commentForm.events({
|
||||
cardId,
|
||||
parentId: parentId || '',
|
||||
});
|
||||
// #5547: the comment is saved — cancel any debounced draft save still in
|
||||
// flight and remove the stored draft so it does not resurface next open.
|
||||
cancelCommentDraftSave();
|
||||
UnsavedEdits.reset({
|
||||
fieldName: 'cardComment',
|
||||
docId: Utils.getCurrentCardId(),
|
||||
});
|
||||
replyToCommentId.set('');
|
||||
resetCommentInput(input);
|
||||
Tracker.flush();
|
||||
@@ -159,13 +229,20 @@ EscapeActions.register(
|
||||
docId: Utils.getCurrentCardId(),
|
||||
};
|
||||
const commentInput = $('.js-new-comment-input');
|
||||
const draft = commentInput.val().trim();
|
||||
const draft = (commentInput.val() || '').trim();
|
||||
cancelCommentDraftSave();
|
||||
if (draft) {
|
||||
UnsavedEdits.set(draftKey, draft);
|
||||
} else {
|
||||
UnsavedEdits.reset(draftKey);
|
||||
}
|
||||
resetCommentInput(commentInput);
|
||||
// #5547: do NOT clear the input — clearing it on every click outside the
|
||||
// comment box is exactly the "comment text disappearing" bug that got this
|
||||
// handler disarmed in 2019 (3b3950369). The draft is saved above; if the
|
||||
// card stays open, the text stays visible; if the card closes, the next
|
||||
// open prefills the form from the draft.
|
||||
commentInput.blur();
|
||||
commentFormIsOpen.set(false);
|
||||
},
|
||||
() => {
|
||||
return commentFormIsOpen.get();
|
||||
|
||||
@@ -159,6 +159,12 @@ template(name="boardHeaderBar")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
.separator
|
||||
//- #6465: one-click board settings — the boardMenu popup used to need
|
||||
//- two clicks (open sidebar, then Boardeinstellungen). The click
|
||||
//- handler already exists in boardHeader.js ('click .js-open-board-menu').
|
||||
if currentUser.isBoardMember
|
||||
a.board-header-btn.js-open-board-menu(title="{{_ 'boardMenuPopup-title'}}")
|
||||
i.fa.fa-cog
|
||||
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
|
||||
i.fa.fa-bars
|
||||
|
||||
|
||||
@@ -49,7 +49,14 @@ Template.attachmentGallery.events({
|
||||
if (card && card.coverId === this._id) {
|
||||
await card.unsetCover();
|
||||
}
|
||||
await Attachments.removeAsync(this._id);
|
||||
// #5282 (same class as #3252 for comments/checklists): only remove if the
|
||||
// doc is still in the local cache. Under publication churn the attachment
|
||||
// can already be evicted from Minimongo, and removing a missing _id
|
||||
// throws "Removed nonexistent document" even though the delete itself
|
||||
// succeeded on the server.
|
||||
if (this._id && ReactiveCache.getAttachment(this._id)) {
|
||||
await Attachments.removeAsync(this._id);
|
||||
}
|
||||
Popup.back();
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UnsavedEdits } from '/client/lib/unsavedEdits';
|
||||
|
||||
const descriptionFormIsOpen = new ReactiveVar(false);
|
||||
|
||||
Template.descriptionForm.onDestroyed(function () {
|
||||
@@ -16,6 +18,10 @@ Template.descriptionForm.events({
|
||||
event.preventDefault();
|
||||
const description = tpl.currentComponent ? tpl.currentComponent().getValue() : tpl.$('textarea').val();
|
||||
await this.setDescription(description);
|
||||
// #6455: a successful save means there is no unsaved draft anymore; clear
|
||||
// any pre-existing draft record so the "You have an unsaved description"
|
||||
// warning does not stick around after saving.
|
||||
UnsavedEdits.reset({ fieldName: 'cardDescription', docId: this._id });
|
||||
},
|
||||
// Pressing Ctrl+Enter should submit the form
|
||||
'keydown form textarea'(evt, tpl) {
|
||||
|
||||
@@ -129,50 +129,55 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Desktop mode: position card below board header */
|
||||
/* Desktop mode: position card below board header.
|
||||
#6465: the floating window used to span nearly the whole board (insets
|
||||
20px/20px from BOTH edges, top-left anchored) — right on top of the card it
|
||||
belongs to, with big rounded borders. Dock it to the END edge (right in LTR)
|
||||
like the classic 6.09 side panel instead: the board and the card stay
|
||||
visible, and the drag handle still moves it anywhere. */
|
||||
body.desktop-mode .card-details:not(.card-details-popup) {
|
||||
position: fixed;
|
||||
width: auto;
|
||||
max-width: 800px;
|
||||
flex-basis: auto;
|
||||
border-radius: 8px;
|
||||
border-radius: 3px;
|
||||
z-index: 2001;
|
||||
}
|
||||
|
||||
/* Default position for first card or when dragged */
|
||||
body.desktop-mode .card-details:not(.card-details-popup):not([style*="left"]):not([style*="top"]) {
|
||||
top: 8px;
|
||||
inset-inline-start: 20px;
|
||||
inset-inline-end: 20px;
|
||||
bottom: 20px;
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 8px;
|
||||
width: min(650px, 90vw);
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
/* Stagger positions for multiple cards using nth-of-type */
|
||||
/* Stagger positions for multiple cards using nth-of-type (from the end edge) */
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(1) {
|
||||
top: 8px;
|
||||
inset-inline-start: 20px;
|
||||
inset-inline-end: 8px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(2) {
|
||||
top: 38px;
|
||||
inset-inline-start: 50px;
|
||||
inset-inline-end: 38px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(3) {
|
||||
top: 68px;
|
||||
inset-inline-start: 80px;
|
||||
inset-inline-end: 68px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(4) {
|
||||
top: 98px;
|
||||
inset-inline-start: 110px;
|
||||
inset-inline-end: 98px;
|
||||
}
|
||||
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(5) {
|
||||
top: 128px;
|
||||
inset-inline-start: 140px;
|
||||
inset-inline-end: 128px;
|
||||
}
|
||||
|
||||
/* For expanded cards, set dimensions */
|
||||
body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-collapsed) {
|
||||
inset-inline-end: 20px;
|
||||
bottom: 20px;
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
/* Collapsed card state - hide content and set height to title row only */
|
||||
@@ -192,7 +197,9 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
}
|
||||
.card-details .card-details-canvas {
|
||||
width: auto;
|
||||
padding: 0 2.5vw;
|
||||
/* #6465: fixed 20px as in 6.09 — 2.5vw was ~48px of white border per side
|
||||
on a FullHD screen. */
|
||||
padding: 0 20px;
|
||||
}
|
||||
.card-details .card-details-header {
|
||||
margin: 0 -20px 5px;
|
||||
@@ -300,25 +307,14 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
float: inline-end;
|
||||
}
|
||||
|
||||
/* Unify all card text to match title size */
|
||||
/* #6465: the "unify all card text to match title size" block (v8.18) forced
|
||||
EVERY element in card details to the base font size with line-height 1.5,
|
||||
flattening the 6.09 hierarchy where labels/metadata were smaller than body
|
||||
text — a big part of the "card window is WAAAY too big" complaint. Removed:
|
||||
components keep their own sizes again. */
|
||||
.card-details {
|
||||
font-size: 1em;
|
||||
}
|
||||
.card-details p,
|
||||
.card-details span,
|
||||
.card-details div,
|
||||
.card-details a,
|
||||
.card-details label,
|
||||
.card-details input,
|
||||
.card-details textarea,
|
||||
.card-details select,
|
||||
.card-details button,
|
||||
.card-details .card-details-item-title,
|
||||
.card-details .card-label,
|
||||
.card-details .viewer {
|
||||
font-size: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.card-details .card-details-header .card-details-watch {
|
||||
font-size: 17px;
|
||||
padding-inline-start: 7px;
|
||||
|
||||
@@ -867,6 +867,12 @@ Template.cardDetails.events({
|
||||
// permission denial that otherwise vanished with no feedback.
|
||||
try {
|
||||
await card.setDescription(description);
|
||||
// #6455: a successful save means there is no unsaved draft anymore.
|
||||
// Clear the record explicitly instead of relying on the inlined form's
|
||||
// close-time draft/description comparison, which can run before the
|
||||
// updated description is visible and leave a stale "You have an unsaved
|
||||
// description" warning behind.
|
||||
UnsavedEdits.reset({ fieldName: 'cardDescription', docId: card._id });
|
||||
} catch (error) {
|
||||
alert(error?.reason || error?.message || 'Failed to save description');
|
||||
}
|
||||
@@ -1367,10 +1373,24 @@ Template.inlinedCardDescription.onCreated(function () {
|
||||
|
||||
this._close = (isReset = false) => {
|
||||
if (this.isOpen.get() && !isReset) {
|
||||
const draft = (this._getValue() || '').trim();
|
||||
// #6455: normalize BOTH sides the same way before comparing. The textarea
|
||||
// value has per-line trailing whitespace stripped (_getValue), while
|
||||
// getDescription() returns the raw stored text — or null for an empty
|
||||
// description. Comparing them raw made the comparison permanently unequal
|
||||
// for descriptions containing Markdown " \n" hard breaks (or after
|
||||
// emptying the description), re-creating the phantom draft on every save.
|
||||
const normalize = (s) => (s || '').replaceAll(/[ \f\r\t\v]+$/gm, '').trim();
|
||||
const draft = normalize(this._getValue());
|
||||
const card = getCurrentCardFromContext();
|
||||
if (card && draft !== card.getDescription()) {
|
||||
if (card && draft !== normalize(card.getDescription())) {
|
||||
UnsavedEdits.set(this._getUnsavedEditKey(), this._getValue());
|
||||
} else {
|
||||
// #6455: closing with the draft equal to the saved description must
|
||||
// REMOVE any pre-existing unsaved-edits record. Previously this branch
|
||||
// did nothing, so once a draft record existed, pressing Save ("View it"
|
||||
// -> Save) could never clear the "You have an unsaved description"
|
||||
// warning — only Discard could.
|
||||
UnsavedEdits.reset(this._getUnsavedEditKey());
|
||||
}
|
||||
}
|
||||
this.isOpen.set(false);
|
||||
|
||||
@@ -208,7 +208,10 @@ template(name="listMorePopup")
|
||||
span {{_ 'link-list'}}
|
||||
= ' '
|
||||
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
|
||||
input.inline-input(type="text" readonly value="{{ rootUrl }}")
|
||||
//- #6459: was {{ rootUrl }}, a helper that does not exist anywhere, so the
|
||||
//- link input was always empty. absoluteUrl resolves a URL via a card on
|
||||
//- this list.
|
||||
input.inline-input(type="text" readonly value="{{ absoluteUrl }}")
|
||||
| {{_ 'added'}}
|
||||
span.date(title=list.createdAt) {{ displayDate createdAt 'LLL' }}
|
||||
//unless currentUser.isWorker
|
||||
|
||||
@@ -290,15 +290,19 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
}
|
||||
|
||||
/* Zoom Controls in Header */
|
||||
/* #6465: compact fixed-pixel pill. The vh/vw padding+shadow made the pill
|
||||
taller than the 28px overflow-hidden quick-access row, so it rendered cut
|
||||
off at every window size. */
|
||||
#header-quick-access .zoom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5vw;
|
||||
gap: 4px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 0.5vh 1vw;
|
||||
border-radius: 0.5vw;
|
||||
box-shadow: 0 0.2vh 0.5vh rgba(0,0,0,0.1);
|
||||
margin: 0 1vw;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
margin: 0 8px;
|
||||
max-height: 24px;
|
||||
float: inline-start;
|
||||
}
|
||||
|
||||
|
||||
@@ -157,8 +157,14 @@ input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font: clamp(14px, 2.5vw, 18px) Roboto, Poppins, "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
line-height: 1.4;
|
||||
/* #6465: back to the WeKan 6.09 desktop density. The clamp() introduced in
|
||||
v7.98 (20e9cf114) and raised in v8.02 (67b078b80) resolved to 18px on any
|
||||
window wider than 720px — +28.5% on EVERY font, input and button versus
|
||||
6.09's 14px — which is the "everything is way too big" regression. Mobile
|
||||
readability is handled by the mobile media queries, not by inflating the
|
||||
desktop base font. */
|
||||
font: 14px Roboto, Poppins, "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
line-height: 18px;
|
||||
color: #4d4d4d;
|
||||
/* Improve text rendering */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -217,8 +223,10 @@ body.mobile-mode #content {
|
||||
margin-top: 30vh;
|
||||
}
|
||||
#content > .wrapper {
|
||||
margin-top: 1vh;
|
||||
padding: 2vh 2vw;
|
||||
/* #6465: 6.09 values; 2vh/2vw padding was ~21x38px on FullHD — the huge
|
||||
empty header band on the All Boards page. */
|
||||
margin-top: 10px;
|
||||
padding: 15px;
|
||||
}
|
||||
#modal {
|
||||
position: absolute;
|
||||
@@ -264,13 +272,15 @@ body.mobile-mode #content {
|
||||
float: inline-end;
|
||||
font-size: clamp(18px, 4vw, 24px);
|
||||
}
|
||||
/* #6465: heading sizes back to the 6.09 values (22/18/16px) — the vw clamps
|
||||
resolved to 24/20/18px on desktop and compounded the density regression. */
|
||||
h1 {
|
||||
font-size: clamp(18px, 4vw, 24px);
|
||||
font-size: 22px;
|
||||
line-height: 1.2em;
|
||||
margin: 0 0 1vh;
|
||||
}
|
||||
h2 {
|
||||
font-size: clamp(16px, 3.5vw, 20px);
|
||||
font-size: 18px;
|
||||
line-height: 1.2em;
|
||||
margin: 0 0 0.8vh;
|
||||
}
|
||||
@@ -278,7 +288,7 @@ h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: clamp(14px, 3vw, 18px);
|
||||
font-size: 16px;
|
||||
line-height: 1.25em;
|
||||
margin: 0 0 0.6vh;
|
||||
}
|
||||
|
||||
@@ -134,7 +134,11 @@ Template.boardActions.events({
|
||||
// documents land in minimongo limbo when the wizard subscription stops.
|
||||
Meteor.call('rules.createRule', boardId, ruleName, trigger, {
|
||||
actionType,
|
||||
listTitle: '*',
|
||||
// #6472: was `listTitle: '*'` since the original rules implementation,
|
||||
// but performAction reads action.listName/action.swimlaneName — so every
|
||||
// generic "move to top/bottom" rule created by this wizard did nothing.
|
||||
listName: '*',
|
||||
swimlaneName: '*',
|
||||
boardId,
|
||||
desc,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import Papa from 'papaparse';
|
||||
import Actions from '/models/actions';
|
||||
import Rules from '/models/rules';
|
||||
import Triggers from '/models/triggers';
|
||||
|
||||
const RULES_FORMAT = 'wekan-rules-1.0.0';
|
||||
const STRIP_FIELDS = ['_id', 'boardId', 'createdAt', 'modifiedAt', 'updatedAt'];
|
||||
@@ -33,14 +30,43 @@ function collectBoardRules(boardId) {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// #6472: the trigger matcher (server/rulesHelper.js buildMatchingFieldsMap)
|
||||
// queries every matching field of the trigger type with {$in: [value, '*']} —
|
||||
// a trigger DOCUMENT that lacks one of those fields can never match. Hand-
|
||||
// written or third-party JSON often omits fields like userId, so default every
|
||||
// known matching field to the '*' wildcard when absent. Extra fields on
|
||||
// trigger types that do not use them are harmless (never queried).
|
||||
const TRIGGER_MATCHING_FIELDS = [
|
||||
'userId', 'username', 'cardTitle', 'listName', 'oldListName',
|
||||
'swimlaneName', 'checklistName', 'checklistItemName', 'labelId',
|
||||
'attachmentName',
|
||||
];
|
||||
|
||||
function normalizeTrigger(trigger) {
|
||||
const out = { ...trigger };
|
||||
TRIGGER_MATCHING_FIELDS.forEach(f => {
|
||||
if (out[f] === undefined || out[f] === null || out[f] === '') out[f] = '*';
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Insert an array of {title, trigger, action} onto the given target board.
|
||||
function importRules(rulesArray, boardId) {
|
||||
let count = 0;
|
||||
(rulesArray || []).forEach(entry => {
|
||||
if (!entry || !entry.trigger || !entry.action) return;
|
||||
const triggerId = Triggers.insert({ ...stripDoc(entry.trigger), boardId });
|
||||
const actionId = Actions.insert({ ...stripDoc(entry.action), boardId });
|
||||
Rules.insert({ title: entry.title || 'Imported rule', triggerId, actionId, boardId });
|
||||
// #6472: create via the rules.createRule server method (like the rules
|
||||
// wizard since #5536) instead of three optimistic client inserts — those
|
||||
// are rejected by the board-admin-only allow() rules for non-admins and
|
||||
// land in minimongo limbo: the imported rule LOOKS created but never
|
||||
// exists on the server, so it silently does nothing until it vanishes.
|
||||
Meteor.call(
|
||||
'rules.createRule',
|
||||
boardId,
|
||||
entry.title || 'Imported rule',
|
||||
normalizeTrigger(stripDoc(entry.trigger)),
|
||||
stripDoc(entry.action),
|
||||
);
|
||||
count += 1;
|
||||
});
|
||||
return count;
|
||||
|
||||
@@ -146,21 +146,27 @@ template(name="orgGeneral")
|
||||
th
|
||||
| {{_ 'org-shared-templates'}}
|
||||
br
|
||||
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="true") {{_ 'select-all'}}
|
||||
| /
|
||||
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="false") {{_ 'unselect-all'}}
|
||||
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="true", title="{{_ 'select-all'}}")
|
||||
i.fa.fa-check-square-o
|
||||
|
|
||||
a.js-org-feature-all(href="#", data-feature="orgSharedTemplates", data-value="false", title="{{_ 'unselect-all'}}")
|
||||
i.fa.fa-square-o
|
||||
th
|
||||
| {{_ 'org-propagate-members-to-boards'}}
|
||||
br
|
||||
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="true") {{_ 'select-all'}}
|
||||
| /
|
||||
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="false") {{_ 'unselect-all'}}
|
||||
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="true", title="{{_ 'select-all'}}")
|
||||
i.fa.fa-check-square-o
|
||||
|
|
||||
a.js-org-feature-all(href="#", data-feature="orgPropagateMembersToBoards", data-value="false", title="{{_ 'unselect-all'}}")
|
||||
i.fa.fa-square-o
|
||||
th
|
||||
| {{_ 'org-sync-members-from-auth'}}
|
||||
br
|
||||
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="true") {{_ 'select-all'}}
|
||||
| /
|
||||
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="false") {{_ 'unselect-all'}}
|
||||
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="true", title="{{_ 'select-all'}}")
|
||||
i.fa.fa-check-square-o
|
||||
|
|
||||
a.js-org-feature-all(href="#", data-feature="orgSyncMembersFromAuth", data-value="false", title="{{_ 'unselect-all'}}")
|
||||
i.fa.fa-square-o
|
||||
tbody
|
||||
tr
|
||||
each org in orgList
|
||||
@@ -187,21 +193,27 @@ template(name="teamGeneral")
|
||||
th
|
||||
| {{_ 'team-shared-templates'}}
|
||||
br
|
||||
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="true") {{_ 'select-all'}}
|
||||
| /
|
||||
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="false") {{_ 'unselect-all'}}
|
||||
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="true", title="{{_ 'select-all'}}")
|
||||
i.fa.fa-check-square-o
|
||||
|
|
||||
a.js-team-feature-all(href="#", data-feature="teamSharedTemplates", data-value="false", title="{{_ 'unselect-all'}}")
|
||||
i.fa.fa-square-o
|
||||
th
|
||||
| {{_ 'team-propagate-members-to-boards'}}
|
||||
br
|
||||
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="true") {{_ 'select-all'}}
|
||||
| /
|
||||
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="false") {{_ 'unselect-all'}}
|
||||
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="true", title="{{_ 'select-all'}}")
|
||||
i.fa.fa-check-square-o
|
||||
|
|
||||
a.js-team-feature-all(href="#", data-feature="teamPropagateMembersToBoards", data-value="false", title="{{_ 'unselect-all'}}")
|
||||
i.fa.fa-square-o
|
||||
th
|
||||
| {{_ 'team-sync-members-from-auth'}}
|
||||
br
|
||||
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="true") {{_ 'select-all'}}
|
||||
| /
|
||||
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="false") {{_ 'unselect-all'}}
|
||||
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="true", title="{{_ 'select-all'}}")
|
||||
i.fa.fa-check-square-o
|
||||
|
|
||||
a.js-team-feature-all(href="#", data-feature="teamSyncMembersFromAuth", data-value="false", title="{{_ 'unselect-all'}}")
|
||||
i.fa.fa-square-o
|
||||
tbody
|
||||
tr
|
||||
each team in teamList
|
||||
|
||||
@@ -117,11 +117,17 @@
|
||||
}
|
||||
|
||||
/* Force tables to always trigger horizontal scrolling */
|
||||
.setting-content .content-body .main-body table td,
|
||||
.setting-content .content-body .main-body table th {
|
||||
.setting-content .content-body .main-body table td {
|
||||
white-space: nowrap;
|
||||
min-width: 120px;
|
||||
}
|
||||
/* #6465: headers may wrap — nowrap on long header labels (e.g. the org/team
|
||||
feature columns) blew the column widths up to the whole label length. */
|
||||
.setting-content .content-body .main-body table th {
|
||||
white-space: normal;
|
||||
min-width: 120px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
/* Ensure there's enough content to trigger vertical scrolling */
|
||||
.setting-content .content-body .main-body {
|
||||
|
||||
+36
-22
@@ -331,15 +331,25 @@ Lists.helpers({
|
||||
if (!swimlaneId) {
|
||||
return undefined;
|
||||
}
|
||||
// Only the first swimlane surfaces orphaned cards.
|
||||
const pick = (swimlanes) => {
|
||||
if (!swimlanes || !swimlanes.length || swimlanes[0]._id !== swimlaneId) {
|
||||
return undefined;
|
||||
}
|
||||
return swimlanes.map(s => s._id).filter(id => id !== swimlaneId);
|
||||
};
|
||||
const swimlanes = ReactiveCache.getSwimlanes(
|
||||
{ boardId: this.boardId, archived: false },
|
||||
{ sort: ['sort'] },
|
||||
);
|
||||
// Only the first swimlane surfaces orphaned cards.
|
||||
if (!swimlanes.length || swimlanes[0]._id !== swimlaneId) {
|
||||
return undefined;
|
||||
// On the SERVER ReactiveCache.getSwimlanes returns a Promise; reading
|
||||
// .length off it made this always return undefined there (the orphan
|
||||
// fallback silently never applied server-side). Resolve it instead; the
|
||||
// client path stays synchronous.
|
||||
if (swimlanes && typeof swimlanes.then === 'function') {
|
||||
return swimlanes.then(pick);
|
||||
}
|
||||
return swimlanes.map(s => s._id).filter(id => id !== swimlaneId);
|
||||
return pick(swimlanes);
|
||||
},
|
||||
|
||||
cards(swimlaneId) {
|
||||
@@ -348,28 +358,32 @@ Lists.helpers({
|
||||
// bare top-level `$or`, so it never competes with the board Filter's own
|
||||
// top-level `$or` when the two selectors are combined.
|
||||
// #6443: also surface orphaned cards in the first swimlane.
|
||||
const selector = listCardsSelector(
|
||||
this._id,
|
||||
swimlaneId,
|
||||
this.orphanedCardsSwimlaneIds(swimlaneId),
|
||||
);
|
||||
const filterSelector =
|
||||
typeof Filter !== 'undefined' && typeof Filter.mongoSelector === 'function'
|
||||
? Filter.mongoSelector(selector)
|
||||
: selector;
|
||||
const ret = ReactiveCache.getCards(filterSelector, { sort: ['sort'] });
|
||||
return ret;
|
||||
const query = (orphanedIds) => {
|
||||
const selector = listCardsSelector(this._id, swimlaneId, orphanedIds);
|
||||
const filterSelector =
|
||||
typeof Filter !== 'undefined' && typeof Filter.mongoSelector === 'function'
|
||||
? Filter.mongoSelector(selector)
|
||||
: selector;
|
||||
return ReactiveCache.getCards(filterSelector, { sort: ['sort'] });
|
||||
};
|
||||
const orphaned = this.orphanedCardsSwimlaneIds(swimlaneId);
|
||||
if (orphaned && typeof orphaned.then === 'function') {
|
||||
return orphaned.then(query); // server (async ReactiveCache)
|
||||
}
|
||||
return query(orphaned);
|
||||
},
|
||||
|
||||
cardsUnfiltered(swimlaneId) {
|
||||
// Same swimlane-membership fallback as cards() (#6441/#6443), without the Filter.
|
||||
const selector = listCardsSelector(
|
||||
this._id,
|
||||
swimlaneId,
|
||||
this.orphanedCardsSwimlaneIds(swimlaneId),
|
||||
);
|
||||
const ret = ReactiveCache.getCards(selector, { sort: ['sort'] });
|
||||
return ret;
|
||||
const query = (orphanedIds) =>
|
||||
ReactiveCache.getCards(listCardsSelector(this._id, swimlaneId, orphanedIds), {
|
||||
sort: ['sort'],
|
||||
});
|
||||
const orphaned = this.orphanedCardsSwimlaneIds(swimlaneId);
|
||||
if (orphaned && typeof orphaned.then === 'function') {
|
||||
return orphaned.then(query); // server (async ReactiveCache)
|
||||
}
|
||||
return query(orphaned);
|
||||
},
|
||||
|
||||
allCards(swimlaneId) {
|
||||
|
||||
+10
-1
@@ -408,7 +408,16 @@ var makeTagConstructor = function (tagName) {
|
||||
var attrs = arguments.length && arguments[0]; // 19
|
||||
if (attrs && (typeof attrs === 'object')) { // 20
|
||||
// Treat vanilla JS object as an attributes dictionary. // 21
|
||||
if (! HTML.isConstructedObject(attrs)) { // 22
|
||||
// Patched (WeKan #6459): an ARRAY first argument is tag CONTENT, never an
|
||||
// attributes dictionary. The vm-sandbox patch below made
|
||||
// isConstructedObject(Array) === false (upstream htmljs returns true), so a
|
||||
// tag whose inline text compiles to an array — e.g. the jade line
|
||||
// `label {{_ 'source-board'}}:`, i.e. [TemplateTag, ":"] — got its content
|
||||
// array assigned to instance.attrs. blaze-tools then serialized it as
|
||||
// HTML.Attrs(...), and at runtime Blaze._expandAttributes threw "The basic
|
||||
// TransformingVisitor does not support foreign objects in attributes",
|
||||
// rendering the whole cardMorePopup (with the Delete option) empty.
|
||||
if (! Array.isArray(attrs) && ! HTML.isConstructedObject(attrs)) { // 22
|
||||
instance.attrs = attrs; // 23
|
||||
i++; // 24
|
||||
} else if (attrs instanceof HTML.Attrs) { // 25
|
||||
|
||||
@@ -69,6 +69,15 @@ FERRET_PID=""
|
||||
stop_ferret() { [ -n "$FERRET_PID" ] && kill "$FERRET_PID" 2>/dev/null || true; }
|
||||
trap 'stop_ferret; exit 0' INT TERM
|
||||
|
||||
# #6467/#6468: with FerretDB (no oplog) Meteor observes queries by POLLING, and
|
||||
# its defaults (re-poll 50 ms after any write, at least every 10 s) hammer the
|
||||
# database. Calmer polling defaults; other users' changes may take ~2 s extra to
|
||||
# appear. Overridable by exporting different values before running this script.
|
||||
if [ "$want_ferret" = true ]; then
|
||||
export METEOR_POLLING_THROTTLE_MS="${METEOR_POLLING_THROTTLE_MS:-2000}"
|
||||
export METEOR_POLLING_INTERVAL_MS="${METEOR_POLLING_INTERVAL_MS:-30000}"
|
||||
fi
|
||||
|
||||
while true; do
|
||||
if [ "$want_ferret" = true ]; then
|
||||
export DO_NOT_TRACK=1 FERRETDB_TELEMETRY=disable
|
||||
|
||||
@@ -1239,7 +1239,14 @@ async function run() {
|
||||
console.log('[migrate] ✓ Migration completed successfully.');
|
||||
} else {
|
||||
state.phase = 'completed-with-errors';
|
||||
state.success = totalErrors < 10; // treat <10 isolated errors as success
|
||||
// #6466: per-item errors (one unparsable document, one attachment/avatar that
|
||||
// fails to extract) must NOT fail the whole migration — everything that DID
|
||||
// copy is valid. The old `totalErrors < 10` threshold turned ten cosmetic
|
||||
// per-item errors into a non-zero exit, which made the snap's
|
||||
// migration-control discard the fully-copied FerretDB SQLite and leave WeKan
|
||||
// serving 502 Bad Gateway. Fatal conditions (disk-full, unreachable
|
||||
// source/target) already set state.success = false and never reach here.
|
||||
if (state.success !== false) state.success = true;
|
||||
console.log(`[migrate] ⚠ Migration done with ${totalErrors} error(s). See dashboard.`);
|
||||
}
|
||||
state.finishedAt = new Date().toISOString();
|
||||
|
||||
@@ -72,7 +72,12 @@ Meteor.methods({
|
||||
// to board X" / "link card to board X"). Let it win over the source boardId
|
||||
// so cross-board move/link rules created through this method keep their
|
||||
// target — only fall back to the source board when the action omits one.
|
||||
const actionId = await Actions.insertAsync({ boardId, ...clean(action) });
|
||||
// #6472: "omits" must include an EMPTY boardId (a not-yet-loaded board
|
||||
// select posts ''), which the spread would otherwise let override the
|
||||
// fallback and make every list lookup in the action fail silently.
|
||||
const actionDoc = { boardId, ...clean(action) };
|
||||
if (!actionDoc.boardId) actionDoc.boardId = boardId;
|
||||
const actionId = await Actions.insertAsync(actionDoc);
|
||||
const ruleDoc = {
|
||||
title: title || 'Rule',
|
||||
triggerId,
|
||||
|
||||
+40
-13
@@ -157,7 +157,11 @@ export const RulesHelper = {
|
||||
) {
|
||||
let list;
|
||||
let listId;
|
||||
if (action.listName === '*') {
|
||||
if (action.listName === '*' || !action.listName) {
|
||||
// #6472: rules created by the classic wizard's generic "move to
|
||||
// top/bottom" action stored the field as listTitle (never read here),
|
||||
// so action.listName was undefined and the exact-title lookup below
|
||||
// always failed. An unset listName means "the card's current list".
|
||||
list = await card.list();
|
||||
if (boardId !== action.boardId) {
|
||||
list = await ReactiveCache.getList({ title: list.title, boardId: action.boardId });
|
||||
@@ -168,11 +172,22 @@ export const RulesHelper = {
|
||||
boardId: action.boardId,
|
||||
});
|
||||
}
|
||||
if (list === undefined) {
|
||||
listId = '';
|
||||
} else {
|
||||
listId = list._id;
|
||||
// #6472: an unresolved list (typo'd/renamed/case-mismatched listName, or
|
||||
// the list only exists on another board) crashed below on
|
||||
// list.cardsUnfiltered — the error was swallowed by the activity hook, so
|
||||
// the rule silently "did nothing". Fall back to the card's own list so
|
||||
// moveCardToTop/Bottom still does the sensible thing within the card's
|
||||
// current list.
|
||||
let fellBackToCardList = false;
|
||||
if (!list) {
|
||||
console.warn(
|
||||
`WeKan rule action ${action.actionType}: list "${action.listName}" not found on board ${action.boardId}; using the card's current list instead.`,
|
||||
);
|
||||
list = await card.list();
|
||||
if (!list) return;
|
||||
fellBackToCardList = true;
|
||||
}
|
||||
listId = list._id;
|
||||
|
||||
let swimlane;
|
||||
let swimlaneId;
|
||||
@@ -201,16 +216,28 @@ export const RulesHelper = {
|
||||
await getDestBoardDefaultSwimlane(action.boardId),
|
||||
);
|
||||
|
||||
// #6472: the fallback list is on the CARD's board, so the move must stay
|
||||
// there too — an action.boardId/swimlaneId from a different board would
|
||||
// produce an inconsistent card. Also move within the card's own swimlane.
|
||||
let destBoardId = action.boardId;
|
||||
if (fellBackToCardList) {
|
||||
destBoardId = list.boardId;
|
||||
swimlaneId = card.swimlaneId;
|
||||
}
|
||||
|
||||
// #6472: an empty destination (no cards in that list+swimlane yet) made
|
||||
// Math.min()/Math.max() of no arguments return ±Infinity, writing a
|
||||
// corrupt sort value; a non-finite stored sort would poison it again.
|
||||
const destSorts = (await list.cardsUnfiltered(swimlaneId))
|
||||
.map(c => c.sort)
|
||||
.filter(Number.isFinite);
|
||||
|
||||
if (action.actionType === 'moveCardToTop') {
|
||||
const minOrder = Math.min(
|
||||
...(await list.cardsUnfiltered(swimlaneId)).map(c => c.sort),
|
||||
);
|
||||
await withUserId(activity.userId, () => card.move(action.boardId, swimlaneId, listId, minOrder - 1));
|
||||
const minOrder = destSorts.length ? Math.min(...destSorts) : 0;
|
||||
await withUserId(activity.userId, () => card.move(destBoardId, swimlaneId, listId, minOrder - 1));
|
||||
} else {
|
||||
const maxOrder = Math.max(
|
||||
...(await list.cardsUnfiltered(swimlaneId)).map(c => c.sort),
|
||||
);
|
||||
await withUserId(activity.userId, () => card.move(action.boardId, swimlaneId, listId, maxOrder + 1));
|
||||
const maxOrder = destSorts.length ? Math.max(...destSorts) : 0;
|
||||
await withUserId(activity.userId, () => card.move(destBoardId, swimlaneId, listId, maxOrder + 1));
|
||||
}
|
||||
}
|
||||
if (action.actionType === 'sendEmail') {
|
||||
|
||||
@@ -833,7 +833,16 @@ async function run() {
|
||||
|
||||
await client.close();
|
||||
state.phase = state.errors.length ? 'completed-with-errors' : 'completed';
|
||||
state.success = state.errors.length < 10;
|
||||
// #6466: per-item errors (a document that fails EJSON parse — "no valid json" —
|
||||
// or a single attachment/avatar that fails to extract) must NOT fail the whole
|
||||
// migration: everything that DID copy is valid and consistent. The old
|
||||
// `state.errors.length < 10` threshold meant ten cosmetic per-item errors
|
||||
// (e.g. the "Avatars Errors" list) exited non-zero, which made migration-control
|
||||
// discard the fully-copied FerretDB SQLite and leave the snap serving 502 Bad
|
||||
// Gateway forever. Genuinely fatal conditions (disk-full abort, unusable source
|
||||
// tools, unreachable target) already returned early with state.success = false;
|
||||
// reaching this point means the run completed.
|
||||
if (state.success !== false) state.success = true;
|
||||
saveCheckpoint(true);
|
||||
writeStatus();
|
||||
}
|
||||
|
||||
@@ -239,6 +239,9 @@ finish_success() {
|
||||
log "FerretDB SQLite verified (${sq:-?})${wal:+; WAL log present}."
|
||||
log "Migration succeeded. Switching to FerretDB (SQLite); MongoDB will be shut down."
|
||||
touch "$MARKER"
|
||||
# #6458/#6466: clear mongodb-control's start-failure counter — the migration
|
||||
# completing is exactly the recovery those failures were counting toward.
|
||||
rm -f "$SNAP_COMMON/.mongod-start-failures" 2>/dev/null || true
|
||||
snapctl set database=ferretdb 2>/dev/null || true
|
||||
# CRUCIAL: an INTERNAL `snapctl set` does NOT re-run the configure hook, so we must flip
|
||||
# the database services here ourselves — exactly as snap/hooks/configure's ferretdb
|
||||
@@ -299,6 +302,24 @@ fall_back_to_mongodb() { # $1 = human-readable reason
|
||||
snapctl set migrate=off 2>/dev/null || true
|
||||
exec bash "$SNAP/bin/mongodb-control"
|
||||
}
|
||||
# #6466: failure handler for the MongoDB 3.x branch. For a 3.x source there is NO
|
||||
# working MongoDB fallback: the only service mongod is 7.x, which cannot open 3.x
|
||||
# data files ("This version of MongoDB is too recent ... Try MongoDB 4.2 or
|
||||
# earlier"), so fail_and_run_mongodb used to delete the (possibly fully-copied)
|
||||
# FerretDB SQLite over a handful of per-item errors and then strand the snap in a
|
||||
# mongod crash loop serving 502 Bad Gateway. Instead: KEEP the partial FerretDB
|
||||
# SQLite and its checkpoint (they are consistent with each other and resume-safe),
|
||||
# leave auto-migration ON, and exit non-zero so snapd restarts the service and the
|
||||
# next start RESUMES the migration from the checkpoint. The source MongoDB data is
|
||||
# never modified either way.
|
||||
fail_3x_keep_progress() { # $1 = importer exit code
|
||||
log "ERROR: migration did not complete (importer rc=$1)."
|
||||
log "MongoDB 7 cannot serve this MongoDB 3.x data, so there is no MongoDB fallback."
|
||||
log "Keeping the partial FerretDB SQLite in $SQLITE_DIR and the checkpoint; the migration RESUMES from there on the next start, or run: snap run wekan.migrate"
|
||||
trap - EXIT INT TERM
|
||||
cleanup_all
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Any modern MongoDB (6/7, incl. a DB earlier migrated from 3.x)? ───────────
|
||||
# The WeKan snap moves every existing MongoDB database — MongoDB 3, MongoDB 7, or any
|
||||
@@ -306,7 +327,16 @@ fall_back_to_mongodb() { # $1 = human-readable reason
|
||||
# CollectionFS + Meteor-Files GridFS attachments/avatars onto the filesystem
|
||||
# (files/attachments, files/avatars). Version only decides HOW we read the source.
|
||||
log "Checking whether mongod 7 can open the existing data ..."
|
||||
if start_mongod "$SNAP/bin/mongod" "" "--wiredTigerCacheSizeGB ${SRC_WT_CACHE_GB}" && ready_via_node; then
|
||||
# #6458: MongoDB >= 5.0 x86_64 requires AVX. Without it the mongod 7 probe below
|
||||
# can only die with SIGILL, so skip it and go straight to the MongoDB 3.x reader —
|
||||
# the bundled 3.2 tools, the Node importer and FerretDB are all AVX-free, so the
|
||||
# migration itself works fine on such CPUs.
|
||||
HAVE_AVX=1
|
||||
if [ "$(uname -m)" = "x86_64" ] && ! grep -qw avx /proc/cpuinfo 2>/dev/null; then
|
||||
HAVE_AVX=0
|
||||
log "CPU has no AVX support: skipping the mongod 7 probe (it would SIGILL); trying the MongoDB 3.x reader directly."
|
||||
fi
|
||||
if [ "$HAVE_AVX" = "1" ] && start_mongod "$SNAP/bin/mongod" "" "--wiredTigerCacheSizeGB ${SRC_WT_CACHE_GB}" && ready_via_node; then
|
||||
if [ ! -x "$FERRETDB" ] || [ ! -x "$NODE" ] || [ ! -f "$IMPORTER_MODERN" ]; then
|
||||
fall_back_to_mongodb "Migration tools unavailable; cannot migrate this modern MongoDB to FerretDB."
|
||||
fi
|
||||
@@ -361,8 +391,10 @@ rc=$?
|
||||
if [ "$rc" -eq 0 ] && finish_success; then :
|
||||
else
|
||||
[ "$rc" -ge 128 ] && resume_next_start "$rc"
|
||||
log "ERROR: migration did not complete (importer rc=$rc). Keeping WeKan on MongoDB."
|
||||
fail_and_run_mongodb
|
||||
# #6466: NOT fail_and_run_mongodb — mongod 7 cannot serve this 3.x data, so that
|
||||
# path stranded the snap in a mongod crash loop with 502s AND deleted the copied
|
||||
# data. Keep the progress and resume on the next start instead.
|
||||
fail_3x_keep_progress "$rc"
|
||||
fi
|
||||
|
||||
# temp FerretDB + source mongod are stopped by the EXIT trap.
|
||||
|
||||
@@ -184,6 +184,75 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── #6458/#6466: decide a real outcome when mongod cannot start ──────────────
|
||||
# mongod can be unable to start at all: SIGILL (exit 132) on x86_64 CPUs without
|
||||
# AVX (MongoDB >= 5.0 requires AVX — #6458), or "This version of MongoDB is too
|
||||
# recent to start up on the existing data files" when the data is still MongoDB
|
||||
# 3.x after a failed/paused migration (#6466). Previously the exit status of the
|
||||
# mongod fork was never checked: the script pinged a dead port for ~10 minutes,
|
||||
# snapd restarted the service, and the cycle repeated forever while WeKan served
|
||||
# 502 Bad Gateway. Instead: switch to a COMPLETED FerretDB migration if one
|
||||
# exists, else (re)run the MongoDB → FerretDB migration (FerretDB is pure Go +
|
||||
# SQLite — no AVX needed; MongoDB 3.x data is read with the bundled 3.2 tools),
|
||||
# with a counter so a persistently failing migration cannot ping-pong forever.
|
||||
MONGOD_FAIL_COUNT_FILE="${SNAP_COMMON}/.mongod-start-failures"
|
||||
handle_mongod_start_failure() {
|
||||
local rc="$1"
|
||||
local svc="${SNAP_INSTANCE_NAME:-${SNAP_NAME:-wekan}}"
|
||||
echo "ERROR: mongod failed to start (exit code ${rc})."
|
||||
if [ "$rc" -eq 132 ]; then
|
||||
echo "Exit 132 = SIGILL (Illegal instruction): MongoDB 5.0+ x86_64 binaries require a CPU"
|
||||
echo "with AVX support, and this CPU has none — the bundled MongoDB 7 can never run here."
|
||||
echo "FerretDB (pure Go + SQLite) has no AVX requirement and is the supported fallback."
|
||||
fi
|
||||
echo "Last mongod log lines:"
|
||||
tail -n 5 "$MONGO_LOG_FILE" 2>/dev/null || true
|
||||
|
||||
# A COMPLETED FerretDB migration exists (marker + non-empty SQLite): the snap
|
||||
# should be running FerretDB anyway — switch now instead of crash-looping.
|
||||
# (A PARTIAL migration — SQLite present but no marker — is NOT switched to.)
|
||||
if [ -f "$SNAP_COMMON/.migration-to-ferretdb-done" ] && \
|
||||
bash "$SNAP/bin/ferretdb-has-data" "$SNAP_COMMON/files/db" 2>/dev/null; then
|
||||
echo "A completed FerretDB migration exists; switching to database=ferretdb."
|
||||
snapctl set database=ferretdb 2>/dev/null || true
|
||||
snapctl start --enable "${svc}.ferretdb" 2>/dev/null || true
|
||||
snapctl restart "${svc}.ferretdb" 2>/dev/null || true
|
||||
snapctl restart "${svc}.wekan" 2>/dev/null || true
|
||||
snapctl stop --disable "${svc}.mongodb" 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
local fails=0
|
||||
[ -f "$MONGOD_FAIL_COUNT_FILE" ] && fails="$(cat "$MONGOD_FAIL_COUNT_FILE" 2>/dev/null || echo 0)"
|
||||
case "$fails" in (*[!0-9]*|'') fails=0;; esac
|
||||
fails=$((fails + 1))
|
||||
echo "$fails" > "$MONGOD_FAIL_COUNT_FILE"
|
||||
|
||||
# MongoDB data that this mongod cannot serve: the only way forward is the
|
||||
# MongoDB → FerretDB migration. Re-enable and run it now (it RESUMES from its
|
||||
# checkpoint if one exists), unless it keeps failing too (counter guards
|
||||
# against a migration↔mongod restart loop).
|
||||
if [ "$fails" -le 3 ] && [ ! -f "$SNAP_COMMON/.migration-to-ferretdb-done" ] && \
|
||||
{ [ -f "$SNAP_COMMON/WiredTiger" ] || ls "$SNAP_COMMON"/collection-*.wt >/dev/null 2>&1; }; then
|
||||
echo "mongod cannot serve this data; running the MongoDB -> FerretDB migration instead (attempt ${fails}/3)."
|
||||
snapctl set migrate=on 2>/dev/null || true
|
||||
rm -f "${SNAP_COMMON}/mongodb.pid"
|
||||
exec bash "$SNAP/bin/migration-control"
|
||||
fi
|
||||
|
||||
echo "mongod cannot start, and the MongoDB -> FerretDB migration has not completed (attempt ${fails})."
|
||||
echo "Fix the problem, then retry/resume the migration with: snap run wekan.migrate"
|
||||
echo "Diagnostics: ${MONGO_LOG_FILE} and ${SNAP_COMMON}/migration-*.log"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# #6458: pre-flight AVX check — without AVX, starting mongod below would only
|
||||
# produce the SIGILL crash loop, so fail over right away with a clear message.
|
||||
if [ "$(uname -m)" = "x86_64" ] && ! grep -qw avx /proc/cpuinfo 2>/dev/null; then
|
||||
echo "ERROR: This x86_64 CPU has no AVX support; MongoDB 7 would exit with SIGILL."
|
||||
handle_mongod_start_failure 132
|
||||
fi
|
||||
|
||||
# Check if MongoDB is already running
|
||||
if check_mongodb_running; then
|
||||
echo "MongoDB is already running. Exiting to prevent multiple instances."
|
||||
@@ -223,7 +292,11 @@ $SNAP/bin/mongod \
|
||||
--setParameter logicalSessionRefreshMillis=900000 \
|
||||
--setParameter localLogicalSessionTimeoutMinutes=45 \
|
||||
--replSet rs0 --oplogSize 20480 --fork \
|
||||
--pidfilepath="$TEMP_PID_FILE"
|
||||
--pidfilepath="$TEMP_PID_FILE" \
|
||||
|| handle_mongod_start_failure $?
|
||||
# ^ #6458/#6466: with --fork, mongod's parent only returns 0 once the child is up;
|
||||
# a SIGILL (no AVX) or "data files too recent" (3.x data) failure returns non-zero
|
||||
# and previously fell through to ~10 minutes of pinging a dead port, forever.
|
||||
|
||||
# Wait for MongoDB to accept connections (up to 30 s). db-eval uses the bundled
|
||||
# Node.js + `mongodb` driver; directConnection reaches this node before the
|
||||
@@ -243,6 +316,9 @@ until "$DB_EVAL" ping "$_RS_URL" >/dev/null 2>&1; do
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# mongod is answering: clear the start-failure counter (see
|
||||
# handle_mongod_start_failure) so past failures don't count against future ones.
|
||||
[ $_wait -lt 90 ] && rm -f "$MONGOD_FAIL_COUNT_FILE"
|
||||
|
||||
# Check replica set status and current member host (prints OK:<host> or ERROR:<msg>)
|
||||
_rs_check=$("$DB_EVAL" rs-conf-host "$_RS_URL" 2>/dev/null)
|
||||
@@ -277,8 +353,12 @@ if [ -f "$TEMP_PID_FILE" ]; then
|
||||
fi
|
||||
sleep 1
|
||||
|
||||
# Final start of MongoDB in foreground (this becomes the main process)
|
||||
exec $SNAP/bin/mongod \
|
||||
# Final start of MongoDB in foreground (this stays the main process; #6458/#6466:
|
||||
# not `exec` anymore, so an IMMEDIATE startup crash — SIGILL on non-AVX CPUs,
|
||||
# incompatible data files — is caught below instead of snapd blindly restarting
|
||||
# this service into the same crash forever)
|
||||
_mongod_start_ts=$(date +%s)
|
||||
$SNAP/bin/mongod \
|
||||
--dbpath="$MONGO_DATA_DIR" \
|
||||
--logpath="$MONGO_LOG_FILE" \
|
||||
--logappend $BIND_OPTIONS \
|
||||
@@ -287,3 +367,9 @@ exec $SNAP/bin/mongod \
|
||||
--setParameter logicalSessionRefreshMillis=900000 \
|
||||
--setParameter localLogicalSessionTimeoutMinutes=45 \
|
||||
--replSet rs0 --oplogSize 20480
|
||||
_mongod_rc=$?
|
||||
if [ $_mongod_rc -ne 0 ] && [ $(( $(date +%s) - _mongod_start_ts )) -lt 15 ]; then
|
||||
# Died within seconds of starting: a startup failure, not a normal shutdown.
|
||||
handle_mongod_start_failure $_mongod_rc
|
||||
fi
|
||||
exit $_mongod_rc
|
||||
@@ -86,6 +86,20 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# #6467/#6468: with FerretDB (no oplog) Meteor observes every query by POLLING.
|
||||
# The Meteor defaults are aggressive — re-poll every observed query 50 ms after
|
||||
# ANY write, and at least every 10 s — which multiplied into hundreds of
|
||||
# re-executed queries per second on an active board and pinned FerretDB's CPU.
|
||||
# Calmer defaults for the polling driver: re-poll at most every 2 s after a
|
||||
# write and every 30 s otherwise. Own writes still show instantly (Meteor
|
||||
# applies them to the local cache); this only delays seeing OTHER users' changes
|
||||
# by up to ~2 s. Both remain overridable via snap environment settings.
|
||||
if [ "ferretdb" = "${DATABASE}" ]; then
|
||||
[ -z "${METEOR_POLLING_THROTTLE_MS}" ] && export METEOR_POLLING_THROTTLE_MS=2000
|
||||
[ -z "${METEOR_POLLING_INTERVAL_MS}" ] && export METEOR_POLLING_INTERVAL_MS=30000
|
||||
echo "FerretDB polling: METEOR_POLLING_THROTTLE_MS=${METEOR_POLLING_THROTTLE_MS} METEOR_POLLING_INTERVAL_MS=${METEOR_POLLING_INTERVAL_MS}"
|
||||
fi
|
||||
|
||||
# If WRITABLE_PATH is not set, use $SNAP_COMMON/files
|
||||
# Wekan will store attachments there.
|
||||
if [ -z "$WRITABLE_PATH" ]; then
|
||||
|
||||
Reference in new issue
Block a user