177 lines
7.0 KiB
PHP
177 lines
7.0 KiB
PHP
<?php
|
|
|
|
namespace Kanboard\Plugin\OrganonTweaks\Helper;
|
|
|
|
use Kanboard\Core\Base;
|
|
use Kanboard\Model\TaskModel;
|
|
|
|
/**
|
|
* Per-column persistent sort. Kanboard's native column sort is a one-shot physical reorder (writes
|
|
* task positions); this stores the chosen sort per column and, when "persistent" is on, re-applies it
|
|
* whenever a card is dropped in or created -- so the column stays sorted. Adds a number-aware title
|
|
* sort on top of the native criteria.
|
|
*
|
|
* Everything lives in project metadata, per column, as literal strings:
|
|
* organon_sort_by_<col> = id | priority | assignee-priority | assignee | due-date | title
|
|
* organon_sort_dir_<col> = asc | desc
|
|
* organon_sort_stick_<col> = on | off (default off = native one-shot behavior)
|
|
*/
|
|
class OrganonSortHelper extends Base
|
|
{
|
|
const BY_PREFIX = 'organon_sort_by_';
|
|
const DIR_PREFIX = 'organon_sort_dir_';
|
|
const STICK_PREFIX = 'organon_sort_stick_';
|
|
|
|
// The sort criteria this control offers. Native ones dispatch to core TaskReorderModel; 'title'
|
|
// is our own number-aware sort. Any value not here is ignored (no reorder, nothing stored).
|
|
const CRITERIA = array('id', 'priority', 'assignee-priority', 'assignee', 'due-date', 'title');
|
|
|
|
public function isSticky($project_id, $column_id)
|
|
{
|
|
return $this->projectMetadataModel->get((int) $project_id, self::STICK_PREFIX.(int) $column_id, 'off') === 'on';
|
|
}
|
|
|
|
public function sortBy($project_id, $column_id)
|
|
{
|
|
return (string) $this->projectMetadataModel->get((int) $project_id, self::BY_PREFIX.(int) $column_id, '');
|
|
}
|
|
|
|
public function sortDir($project_id, $column_id)
|
|
{
|
|
return $this->projectMetadataModel->get((int) $project_id, self::DIR_PREFIX.(int) $column_id, 'asc') === 'desc' ? 'desc' : 'asc';
|
|
}
|
|
|
|
/**
|
|
* Sort the column now and record the choice (for the icon + the sticky re-apply).
|
|
*/
|
|
public function applySort($project_id, $swimlane_id, $column_id, $sort, $direction)
|
|
{
|
|
if (! in_array($sort, self::CRITERIA, true)) {
|
|
return;
|
|
}
|
|
|
|
$direction = $direction === 'desc' ? 'desc' : 'asc';
|
|
$this->reorder((int) $project_id, (int) $swimlane_id, (int) $column_id, $sort, $direction);
|
|
$this->projectMetadataModel->save((int) $project_id, array(
|
|
self::BY_PREFIX.(int) $column_id => $sort,
|
|
self::DIR_PREFIX.(int) $column_id => $direction,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Re-apply the stored sort (called on card drop / creation when persistent is on). No-op if the
|
|
* column has no stored sort yet.
|
|
*/
|
|
public function reapply($project_id, $swimlane_id, $column_id)
|
|
{
|
|
$sort = $this->sortBy($project_id, $column_id);
|
|
|
|
if ($sort !== '' && in_array($sort, self::CRITERIA, true)) {
|
|
$this->reorder((int) $project_id, (int) $swimlane_id, (int) $column_id, $sort, $this->sortDir($project_id, $column_id));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flip the persistent flag for a column; returns the new state.
|
|
*
|
|
* @return string 'on' | 'off'
|
|
*/
|
|
public function toggle($project_id, $column_id)
|
|
{
|
|
$new = $this->isSticky($project_id, $column_id) ? 'off' : 'on';
|
|
$this->projectMetadataModel->save((int) $project_id, array(self::STICK_PREFIX.(int) $column_id => $new));
|
|
|
|
return $new;
|
|
}
|
|
|
|
/**
|
|
* Dispatch the physical reorder: native criteria reuse core TaskReorderModel; 'title' is ours.
|
|
*/
|
|
private function reorder($project_id, $swimlane_id, $column_id, $sort, $direction)
|
|
{
|
|
switch ($sort) {
|
|
case 'id':
|
|
$this->taskReorderModel->reorderByTaskId($project_id, $swimlane_id, $column_id, $direction);
|
|
break;
|
|
case 'priority':
|
|
$this->taskReorderModel->reorderByPriority($project_id, $swimlane_id, $column_id, $direction);
|
|
break;
|
|
case 'assignee-priority':
|
|
$this->taskReorderModel->reorderByAssigneeAndPriority($project_id, $swimlane_id, $column_id, $direction);
|
|
break;
|
|
case 'assignee':
|
|
$this->taskReorderModel->reorderByAssignee($project_id, $swimlane_id, $column_id, $direction);
|
|
break;
|
|
case 'due-date':
|
|
$this->taskReorderModel->reorderByDueDate($project_id, $swimlane_id, $column_id, $direction);
|
|
break;
|
|
case 'title':
|
|
$this->reorderByTitle($project_id, $swimlane_id, $column_id, $direction);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Title sort, then write positions. Mirrors the native reorder scope (all tasks in the
|
|
* column+swimlane, no status filter). Core's TaskReorderModel::reorderTasks is protected, so the
|
|
* 3-line position write is replicated here.
|
|
*
|
|
* Each title is reduced to a comparison slug -- lowercased, accent-stripped, spaces -> dashes --
|
|
* then compared with strnatcasecmp. The dash is deliberate: strnatcasecmp SKIPS whitespace, so a
|
|
* plain space would make "Luiz Eduardo" sort after "Luiza..." (it would compare "LuizEduardo" vs
|
|
* "Luiza..."); a dash is not whitespace, so the separator is honoured, while strnatcasecmp still
|
|
* groups digit runs ("Task 2" < "Task 10"). The result is case-insensitive, accent-insensitive,
|
|
* whitespace-correct and number-aware.
|
|
*/
|
|
private function reorderByTitle($project_id, $swimlane_id, $column_id, $direction)
|
|
{
|
|
$rows = $this->db->table(TaskModel::TABLE)
|
|
->columns('id', 'title')
|
|
->eq('project_id', $project_id)
|
|
->eq('swimlane_id', $swimlane_id)
|
|
->eq('column_id', $column_id)
|
|
->findAll();
|
|
|
|
foreach ($rows as &$row) {
|
|
$row['sortkey'] = str_replace(' ', '-', $this->stripAccents(mb_strtolower((string) $row['title'], 'UTF-8')));
|
|
}
|
|
unset($row);
|
|
|
|
usort($rows, function ($a, $b) use ($direction) {
|
|
$cmp = strnatcasecmp($a['sortkey'], $b['sortkey']);
|
|
|
|
return $direction === 'desc' ? -$cmp : $cmp;
|
|
});
|
|
|
|
$position = 1;
|
|
foreach ($rows as $row) {
|
|
$this->db->table(TaskModel::TABLE)->eq('id', $row['id'])->update(array('position' => $position));
|
|
$position++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flatten a string for sorting: strip accents to their base letter so accented names sort by that
|
|
* base (Avila near A, not after Z). Uses ext-intl Normalizer when available (deterministic and
|
|
* locale-independent), falling back to iconv //TRANSLIT (locale-dependent), then the raw string
|
|
* (still sorts, just accent-naive). Case is already lowered by the caller.
|
|
*
|
|
* @param string $s
|
|
* @return string
|
|
*/
|
|
private function stripAccents($s)
|
|
{
|
|
if (class_exists('Normalizer')) {
|
|
$decomposed = \Normalizer::normalize($s, \Normalizer::FORM_D);
|
|
|
|
if ($decomposed !== false) {
|
|
return preg_replace('/\p{Mn}/u', '', $decomposed);
|
|
}
|
|
}
|
|
|
|
$translit = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
|
|
|
|
return $translit !== false ? $translit : $s;
|
|
}
|
|
}
|