Files
wekan/models/lib/insertPosition.js
Lauri Ojansivu d11f5ffdb7 Import into a swimlane, a list or a card, beside the one whose menu was
used.

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

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

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

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

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

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

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

Thanks to xet7 !
2026-08-14 01:03:08 +03:00

70 lines
3.0 KiB
JavaScript

'use strict';
// WHERE an imported thing lands (#1173).
//
// A swimlane imported from a swimlane's menu goes BELOW that swimlane. A list
// imported from a list's menu goes after it - which is to its RIGHT in a
// left-to-right language and to its LEFT in a right-to-left one, and that is one
// rule rather than two: the page carries `dir` (client/components/main/
// layouts.jade), so the board's row of lists mirrors itself and "after in sort
// order" is already "the other side" in Arabic, Hebrew or Persian. Writing
// separate LTR and RTL branches here would mirror it twice and put the list back
// on the wrong side. A card goes below the card it was imported from.
//
// All three are the same question - what sort value sits between this one and
// the next - and `sort` is a Number in every one of the three schemas, so the
// answer is a fraction rather than a renumbering of every sibling. Renumbering
// is what a board with ten thousand cards cannot afford, and what a second
// client doing the same thing at the same time gets wrong.
// The sort values for `count` new items placed directly after `targetSort`,
// spread evenly in the gap before whatever comes next.
//
// existingSorts every sibling's sort, in any order
// targetSort the sort of the item they go after; null/undefined puts them
// at the very top, before everything
// count how many are being inserted
function sortsAfter(existingSorts, targetSort, count = 1) {
if (!Number.isFinite(count) || count < 1) return [];
const sorts = (existingSorts || [])
.filter(value => Number.isFinite(value))
.sort((a, b) => a - b);
// Nothing to sit between: start at 0 and count up, the way an empty board does.
if (sorts.length === 0) {
const from = Number.isFinite(targetSort) ? targetSort + 1 : 0;
return Array.from({ length: count }, (unused, index) => from + index);
}
if (!Number.isFinite(targetSort)) {
// Before everything, which is what "no target" means: below the top edge.
const first = sorts[0];
return Array.from({ length: count }, (unused, index) =>
first - (count - index));
}
const next = sorts.find(value => value > targetSort);
if (next === undefined) {
// Last: there is no gap to divide, so count on from the end.
return Array.from({ length: count }, (unused, index) => targetSort + 1 + index);
}
// Evenly through the gap. With one item that is the midpoint; with three it is
// the quarter points, so importing a swimlane of ten lists does not put nine
// of them in the same position.
const step = (next - targetSort) / (count + 1);
return Array.from({ length: count }, (unused, index) => targetSort + step * (index + 1));
}
// A gap can be divided only so many times before two sorts are the same double.
// When that happens the caller has to renumber; this says when.
function gapIsExhausted(sorts) {
for (let i = 1; i < sorts.length; i += 1) {
if (!(sorts[i] > sorts[i - 1])) return true;
}
return false;
}
export { sortsAfter, gapIsExhausted };