Files
OrganonTweaks/Helper/OrganonSortHelper.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;
}
}