@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3final class PhabricatorBoardLayoutEngine extends Phobject {
4
5 private $viewer;
6 private $boardPHIDs;
7 private $objectPHIDs = array();
8 private $boards;
9 private $columnMap = array();
10 private $objectColumnMap = array();
11 private $boardLayout = array();
12 private $fetchAllBoards;
13
14 private $remQueue = array();
15 private $addQueue = array();
16
17 public function setViewer(PhabricatorUser $viewer) {
18 $this->viewer = $viewer;
19 return $this;
20 }
21
22 public function getViewer() {
23 return $this->viewer;
24 }
25
26 public function setBoardPHIDs(array $board_phids) {
27 $this->boardPHIDs = array_fuse($board_phids);
28 return $this;
29 }
30
31 public function getBoardPHIDs() {
32 return $this->boardPHIDs;
33 }
34
35 public function setObjectPHIDs(array $object_phids) {
36 $this->objectPHIDs = array_fuse($object_phids);
37 return $this;
38 }
39
40 public function getObjectPHIDs() {
41 return $this->objectPHIDs;
42 }
43
44 /**
45 * Fetch all boards, even if the board is disabled.
46 */
47 public function setFetchAllBoards($fetch_all) {
48 $this->fetchAllBoards = $fetch_all;
49 return $this;
50 }
51
52 public function getFetchAllBoards() {
53 return $this->fetchAllBoards;
54 }
55
56 public function executeLayout() {
57 $viewer = $this->getViewer();
58
59 $boards = $this->loadBoards();
60 if (!$boards) {
61 return $this;
62 }
63
64 $columns = $this->loadColumns($boards);
65 $positions = $this->loadPositions($boards);
66
67 foreach ($boards as $board_phid => $board) {
68 $board_columns = idx($columns, $board_phid);
69
70 // Don't layout boards with no columns. These boards need to be formally
71 // created first.
72 if (!$columns) {
73 continue;
74 }
75
76 $board_positions = idx($positions, $board_phid, array());
77
78 $this->layoutBoard($board, $board_columns, $board_positions);
79 }
80
81 return $this;
82 }
83
84 public function getColumns($board_phid) {
85 $columns = idx($this->boardLayout, $board_phid, array());
86 return array_select_keys($this->columnMap, array_keys($columns));
87 }
88
89 public function getColumnObjectPositions($board_phid, $column_phid) {
90 $columns = idx($this->boardLayout, $board_phid, array());
91 return idx($columns, $column_phid, array());
92 }
93
94
95 public function getColumnObjectPHIDs($board_phid, $column_phid) {
96 $positions = $this->getColumnObjectPositions($board_phid, $column_phid);
97 return mpull($positions, 'getObjectPHID');
98 }
99
100 public function getObjectColumns($board_phid, $object_phid) {
101 $board_map = idx($this->objectColumnMap, $board_phid, array());
102
103 $column_phids = idx($board_map, $object_phid);
104 if (!$column_phids) {
105 return array();
106 }
107
108 return array_select_keys($this->columnMap, $column_phids);
109 }
110
111 public function queueRemovePosition(
112 $board_phid,
113 $column_phid,
114 $object_phid) {
115
116 $board_layout = idx($this->boardLayout, $board_phid, array());
117 $positions = idx($board_layout, $column_phid, array());
118 $position = idx($positions, $object_phid);
119
120 if ($position) {
121 $this->remQueue[] = $position;
122
123 // If this position hasn't been saved yet, get it out of the add queue.
124 if (!$position->getID()) {
125 foreach ($this->addQueue as $key => $add_position) {
126 if ($add_position === $position) {
127 unset($this->addQueue[$key]);
128 }
129 }
130 }
131 }
132
133 unset($this->boardLayout[$board_phid][$column_phid][$object_phid]);
134
135 return $this;
136 }
137
138 public function queueAddPosition(
139 $board_phid,
140 $column_phid,
141 $object_phid,
142 array $after_phids,
143 array $before_phids) {
144
145 $board_layout = idx($this->boardLayout, $board_phid, array());
146 $positions = idx($board_layout, $column_phid, array());
147
148 // Check if the object is already in the column, and remove it if it is.
149 $object_position = idx($positions, $object_phid);
150 unset($positions[$object_phid]);
151
152 if (!$object_position) {
153 $object_position = id(new PhabricatorProjectColumnPosition())
154 ->setBoardPHID($board_phid)
155 ->setColumnPHID($column_phid)
156 ->setObjectPHID($object_phid);
157 }
158
159 if (!$positions) {
160 $object_position->setSequence(0);
161 } else {
162 // The user's view of the board may fall out of date, so they might
163 // try to drop a card under a different card which is no longer where
164 // they thought it was.
165
166 // When this happens, we perform the move anyway, since this is almost
167 // certainly what users want when interacting with the UI. We'l try to
168 // fall back to another nearby card if the client provided us one. If
169 // we don't find any of the cards the client specified in the column,
170 // we'll just move the card to the default position.
171
172 $search_phids = array();
173 foreach ($after_phids as $after_phid) {
174 $search_phids[] = array($after_phid, false);
175 }
176
177 foreach ($before_phids as $before_phid) {
178 $search_phids[] = array($before_phid, true);
179 }
180
181 // This makes us fall back to the default position if we fail every
182 // candidate position. The default position counts as a "before" position
183 // because we want to put the new card at the top of the column.
184 $search_phids[] = array(null, true);
185
186 $found = false;
187 foreach ($search_phids as $search_position) {
188 list($relative_phid, $is_before) = $search_position;
189 foreach ($positions as $position) {
190 if (!$found) {
191 if ($relative_phid === null) {
192 $is_match = true;
193 } else {
194 $position_phid = $position->getObjectPHID();
195 $is_match = ($relative_phid === $position_phid);
196 }
197
198 if ($is_match) {
199 $found = true;
200
201 $sequence = $position->getSequence();
202
203 if (!$is_before) {
204 $sequence++;
205 }
206
207 $object_position->setSequence($sequence++);
208
209 if (!$is_before) {
210 // If we're inserting after this position, continue the loop so
211 // we don't update it.
212 continue;
213 }
214 }
215 }
216
217 if ($found) {
218 $position->setSequence($sequence++);
219 $this->addQueue[] = $position;
220 }
221 }
222
223 if ($found) {
224 break;
225 }
226 }
227 }
228
229 $this->addQueue[] = $object_position;
230
231 $positions[$object_phid] = $object_position;
232 $positions = msortv($positions, 'newColumnPositionOrderVector');
233
234 $this->boardLayout[$board_phid][$column_phid] = $positions;
235
236 return $this;
237 }
238
239 public function applyPositionUpdates() {
240 foreach ($this->remQueue as $position) {
241 if ($position->getID()) {
242 $position->delete();
243 }
244 }
245 $this->remQueue = array();
246
247 $adds = array();
248 $updates = array();
249
250 foreach ($this->addQueue as $position) {
251 $id = $position->getID();
252 if ($id) {
253 $updates[$id] = $position;
254 } else {
255 $adds[] = $position;
256 }
257 }
258 $this->addQueue = array();
259
260 $table = new PhabricatorProjectColumnPosition();
261 $conn_w = $table->establishConnection('w');
262
263 $pairs = array();
264 foreach ($updates as $id => $position) {
265 // This is ugly because MySQL gets upset with us if it is configured
266 // strictly and we attempt inserts which can't work. We'll never actually
267 // do these inserts since they'll always collide (triggering the ON
268 // DUPLICATE KEY logic), so we just provide dummy values in order to get
269 // there.
270
271 $pairs[] = qsprintf(
272 $conn_w,
273 '(%d, %d, "", "", "")',
274 $id,
275 $position->getSequence());
276 }
277
278 if ($pairs) {
279 queryfx(
280 $conn_w,
281 'INSERT INTO %T (id, sequence, boardPHID, columnPHID, objectPHID)
282 VALUES %LQ ON DUPLICATE KEY UPDATE sequence = VALUES(sequence)',
283 $table->getTableName(),
284 $pairs);
285 }
286
287 foreach ($adds as $position) {
288 $position->save();
289 }
290
291 return $this;
292 }
293
294 private function loadBoards() {
295 $viewer = $this->getViewer();
296 $board_phids = $this->getBoardPHIDs();
297
298 $boards = id(new PhabricatorObjectQuery())
299 ->setViewer($viewer)
300 ->withPHIDs($board_phids)
301 ->execute();
302 $boards = mpull($boards, null, 'getPHID');
303
304 foreach ($boards as $key => $board) {
305 if (!($board instanceof PhabricatorWorkboardInterface)) {
306 unset($boards[$key]);
307 }
308 }
309
310 if (!$this->fetchAllBoards) {
311 foreach ($boards as $key => $board) {
312 if (!$board->getHasWorkboard()) {
313 unset($boards[$key]);
314 }
315 }
316 }
317
318 return $boards;
319 }
320
321 private function loadColumns(array $boards) {
322 $viewer = $this->getViewer();
323
324 $columns = id(new PhabricatorProjectColumnQuery())
325 ->setViewer($viewer)
326 ->withProjectPHIDs(array_keys($boards))
327 ->needTriggers(true)
328 ->execute();
329 $columns = msort($columns, 'getOrderingKey');
330 $columns = mpull($columns, null, 'getPHID');
331
332 $need_children = array();
333 foreach ($boards as $phid => $board) {
334 if ($board->getHasMilestones() || $board->getHasSubprojects()) {
335 $need_children[] = $phid;
336 }
337 }
338
339 if ($need_children) {
340 $children = id(new PhabricatorProjectQuery())
341 ->setViewer($viewer)
342 ->withParentProjectPHIDs($need_children)
343 ->execute();
344 $children = mpull($children, null, 'getPHID');
345 $children = mgroup($children, 'getParentProjectPHID');
346 } else {
347 $children = array();
348 }
349
350 $columns = mgroup($columns, 'getProjectPHID');
351 foreach ($boards as $board_phid => $board) {
352 $board_columns = idx($columns, $board_phid, array());
353
354 // If the project has milestones, create any missing columns.
355 if ($board->getHasMilestones() || $board->getHasSubprojects()) {
356 $child_projects = idx($children, $board_phid, array());
357
358 if ($board_columns) {
359 $next_sequence = last($board_columns)->getSequence() + 1;
360 } else {
361 $next_sequence = 1;
362 }
363
364 $proxy_columns = mpull($board_columns, null, 'getProxyPHID');
365 foreach ($child_projects as $child_phid => $child) {
366 if (isset($proxy_columns[$child_phid])) {
367 continue;
368 }
369
370 $new_column = PhabricatorProjectColumn::initializeNewColumn($viewer)
371 ->attachProject($board)
372 ->attachProxy($child)
373 ->setSequence($next_sequence++)
374 ->setProjectPHID($board_phid)
375 ->setProxyPHID($child_phid);
376
377 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites();
378 $new_column->save();
379 unset($unguarded);
380
381 $board_columns[$new_column->getPHID()] = $new_column;
382 }
383 }
384
385 $board_columns = msort($board_columns, 'getOrderingKey');
386
387 $columns[$board_phid] = $board_columns;
388 }
389
390 foreach ($columns as $board_phid => $board_columns) {
391 foreach ($board_columns as $board_column) {
392 $column_phid = $board_column->getPHID();
393 $this->columnMap[$column_phid] = $board_column;
394 }
395 }
396
397 return $columns;
398 }
399
400 private function loadPositions(array $boards) {
401 $viewer = $this->getViewer();
402
403 $object_phids = $this->getObjectPHIDs();
404 if (!$object_phids) {
405 return array();
406 }
407
408 $positions = id(new PhabricatorProjectColumnPositionQuery())
409 ->setViewer($viewer)
410 ->withBoardPHIDs(array_keys($boards))
411 ->withObjectPHIDs($object_phids)
412 ->execute();
413 $positions = msortv($positions, 'newColumnPositionOrderVector');
414 $positions = mgroup($positions, 'getBoardPHID');
415
416 return $positions;
417 }
418
419 private function layoutBoard(
420 $board,
421 array $columns,
422 array $positions) {
423
424 $viewer = $this->getViewer();
425
426 $board_phid = $board->getPHID();
427 $position_groups = mgroup($positions, 'getObjectPHID');
428
429 $layout = array();
430 $default_phid = null;
431 foreach ($columns as $column) {
432 $column_phid = $column->getPHID();
433 $layout[$column_phid] = array();
434
435 if ($column->isDefaultColumn()) {
436 $default_phid = $column_phid;
437 }
438 }
439
440 // Find all the columns which are proxies for other objects.
441 $proxy_map = array();
442 foreach ($columns as $column) {
443 $proxy_phid = $column->getProxyPHID();
444 if ($proxy_phid) {
445 $proxy_map[$proxy_phid] = $column->getPHID();
446 }
447 }
448
449 $object_phids = $this->getObjectPHIDs();
450
451 // If we have proxies, we need to force cards into the correct proxy
452 // columns.
453 if ($proxy_map && $object_phids) {
454 $edge_query = id(new PhabricatorEdgeQuery())
455 ->withSourcePHIDs($object_phids)
456 ->withEdgeTypes(
457 array(
458 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
459 ));
460 $edge_query->execute();
461
462 $project_phids = $edge_query->getDestinationPHIDs();
463 $project_phids = array_fuse($project_phids);
464 } else {
465 $project_phids = array();
466 }
467
468 if ($project_phids) {
469 $projects = id(new PhabricatorProjectQuery())
470 ->setViewer($viewer)
471 ->withPHIDs($project_phids)
472 ->execute();
473 $projects = mpull($projects, null, 'getPHID');
474 } else {
475 $projects = array();
476 }
477
478 // Build a map from every project that any task is tagged with to the
479 // ancestor project which has a column on this board, if one exists.
480 $ancestor_map = array();
481 foreach ($projects as $phid => $project) {
482 if (isset($proxy_map[$phid])) {
483 $ancestor_map[$phid] = $proxy_map[$phid];
484 } else {
485 $seen = array($phid);
486 foreach ($project->getAncestorProjects() as $ancestor) {
487 $ancestor_phid = $ancestor->getPHID();
488 $seen[] = $ancestor_phid;
489 if (isset($proxy_map[$ancestor_phid])) {
490 foreach ($seen as $project_phid) {
491 $ancestor_map[$project_phid] = $proxy_map[$ancestor_phid];
492 }
493 }
494 }
495 }
496 }
497
498 $view_sequence = 1;
499 foreach ($object_phids as $object_phid) {
500 $positions = idx($position_groups, $object_phid, array());
501
502 // First, check for objects that have corresponding proxy columns. We're
503 // going to overwrite normal column positions if a tag belongs to a proxy
504 // column, since you can't be in normal columns if you're in proxy
505 // columns.
506 $proxy_hits = array();
507 if ($proxy_map) {
508 $object_project_phids = $edge_query->getDestinationPHIDs(
509 array(
510 $object_phid,
511 ));
512
513 foreach ($object_project_phids as $project_phid) {
514 if (isset($ancestor_map[$project_phid])) {
515 $proxy_hits[] = $ancestor_map[$project_phid];
516 }
517 }
518 }
519
520 if ($proxy_hits) {
521 // TODO: For now, only one column hit is permissible.
522 $proxy_hits = array_slice($proxy_hits, 0, 1);
523
524 $proxy_hits = array_fuse($proxy_hits);
525
526 // Check the object positions: we hope to find a position in each
527 // column the object should be part of. We're going to drop any
528 // invalid positions and create new positions where positions are
529 // missing.
530 foreach ($positions as $key => $position) {
531 $column_phid = $position->getColumnPHID();
532 if (isset($proxy_hits[$column_phid])) {
533 // Valid column, mark the position as found.
534 unset($proxy_hits[$column_phid]);
535 } else {
536 // Invalid column, ignore the position.
537 unset($positions[$key]);
538 }
539 }
540
541 // Create new positions for anything we haven't found.
542 foreach ($proxy_hits as $proxy_hit) {
543 $new_position = id(new PhabricatorProjectColumnPosition())
544 ->setBoardPHID($board_phid)
545 ->setColumnPHID($proxy_hit)
546 ->setObjectPHID($object_phid)
547 ->setSequence(0)
548 ->setViewSequence($view_sequence++);
549
550 $this->addQueue[] = $new_position;
551
552 $positions[] = $new_position;
553 }
554 } else {
555 // Ignore any positions in columns which no longer exist. We don't
556 // actively destory them because the rest of the code ignores them and
557 // there's no real need to destroy the data.
558 foreach ($positions as $key => $position) {
559 $column_phid = $position->getColumnPHID();
560 if (empty($columns[$column_phid])) {
561 unset($positions[$key]);
562 }
563 }
564
565 // If the object has no position, put it on the default column if
566 // one exists.
567 if (!$positions && $default_phid) {
568 $new_position = id(new PhabricatorProjectColumnPosition())
569 ->setBoardPHID($board_phid)
570 ->setColumnPHID($default_phid)
571 ->setObjectPHID($object_phid)
572 ->setSequence(0)
573 ->setViewSequence($view_sequence++);
574
575 $this->addQueue[] = $new_position;
576
577 $positions = array(
578 $new_position,
579 );
580 }
581 }
582
583 foreach ($positions as $position) {
584 $column_phid = $position->getColumnPHID();
585 $layout[$column_phid][$object_phid] = $position;
586 }
587 }
588
589 foreach ($layout as $column_phid => $map) {
590 $map = msortv($map, 'newColumnPositionOrderVector');
591 $layout[$column_phid] = $map;
592
593 foreach ($map as $object_phid => $position) {
594 $this->objectColumnMap[$board_phid][$object_phid][] = $column_phid;
595 }
596 }
597
598 $this->boardLayout[$board_phid] = $layout;
599 }
600
601}