Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8be9442cb7 | |||
| f9a7637e44 | |||
| d079198103 | |||
| 7ba4348de1 | |||
| 019b08084b | |||
| 2f78aa2087 | |||
| 8057272b9c | |||
| dc22739647 | |||
| 2d3c716852 | |||
| 269964d4cc | |||
| ac8e3b9544 | |||
| 717095848b | |||
| a905079abc | |||
| e032d5d2b4 | |||
| b63d366296 | |||
| 89888235d1 | |||
| 4ebebd5ffb | |||
| 9b637160b2 | |||
| e85d5d71f9 | |||
| ce3205646f | |||
| 4ce18d9e5f | |||
| 4d7f88aa94 | |||
| bc266ff969 | |||
| 8a97c5cb94 | |||
| d1178bc463 | |||
| c747d33491 | |||
| 850127e712 | |||
| 70f42352b0 | |||
| d57544ebfd | |||
| c3896e1962 | |||
| 065825269e | |||
| 6726dfa456 |
@@ -13,7 +13,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
var THRESHOLD = 100; // ms; a press on a card held longer than this is a drag, not a click
|
var THRESHOLD = 120; // ms; a press on a card held longer than this is a drag, not a click
|
||||||
var pressStart = 0;
|
var pressStart = 0;
|
||||||
|
|
||||||
function onDown(e) {
|
function onDown(e) {
|
||||||
|
|||||||
@@ -18,24 +18,35 @@
|
|||||||
var group = document.createElement("span");
|
var group = document.createElement("span");
|
||||||
group.className = "ot-recurrence-status";
|
group.className = "ot-recurrence-status";
|
||||||
|
|
||||||
|
var radios = [];
|
||||||
|
|
||||||
for (var i = 0; i < select.options.length; i++) {
|
for (var i = 0; i < select.options.length; i++) {
|
||||||
var opt = select.options[i];
|
var opt = select.options[i];
|
||||||
|
|
||||||
var label = document.createElement("label");
|
var label = document.createElement("label");
|
||||||
|
// Kanboard styles form labels as display:block; force inline so Yes/No sit
|
||||||
|
// side by side instead of stacked.
|
||||||
|
label.style.display = "inline-block";
|
||||||
label.style.marginRight = "14px";
|
label.style.marginRight = "14px";
|
||||||
label.style.cursor = "pointer";
|
label.style.cursor = "pointer";
|
||||||
|
|
||||||
var radio = document.createElement("input");
|
var radio = document.createElement("input");
|
||||||
radio.type = "radio";
|
radio.type = "radio";
|
||||||
radio.name = "ot-recurrence-status";
|
// No name attribute on purpose: a named radio would be SUBMITTED with the form,
|
||||||
|
// and Kanboard rejects the extra key ("invalid identifier"). These are display
|
||||||
|
// only -- the hidden <select> carries the value -- so we enforce the mutual
|
||||||
|
// exclusivity ourselves.
|
||||||
radio.value = opt.value;
|
radio.value = opt.value;
|
||||||
radio.checked = opt.value === select.value;
|
radio.checked = opt.value === select.value;
|
||||||
radio.style.marginRight = "4px";
|
radio.style.marginRight = "4px";
|
||||||
|
radios.push(radio);
|
||||||
|
|
||||||
radio.addEventListener("change", function () {
|
radio.addEventListener("change", function () {
|
||||||
if (this.checked) {
|
for (var k = 0; k < radios.length; k++) {
|
||||||
select.value = this.value;
|
radios[k].checked = (radios[k] === this);
|
||||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
|
||||||
}
|
}
|
||||||
|
select.value = this.value;
|
||||||
|
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
});
|
});
|
||||||
|
|
||||||
label.appendChild(radio);
|
label.appendChild(radio);
|
||||||
|
|||||||
@@ -70,9 +70,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideNativeSort() {
|
||||||
|
// Only when our persistent-sort control is present (the tweak is on); otherwise leave the
|
||||||
|
// native fa-sort menu alone.
|
||||||
|
if (!document.querySelector(".organontweaks-sort")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sorts = document.querySelectorAll("#board .fa-sort");
|
||||||
|
|
||||||
|
for (var i = 0; i < sorts.length; i++) {
|
||||||
|
var dd = sorts[i].closest(".dropdown");
|
||||||
|
|
||||||
|
// Skip our own control (its OFF-state icon is also fa-sort).
|
||||||
|
if (dd && !dd.classList.contains("organontweaks-sort")) {
|
||||||
|
dd.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
relocateColumnMenuItems();
|
relocateColumnMenuItems();
|
||||||
repositionCommentIcons();
|
repositionCommentIcons();
|
||||||
|
hideNativeSort();
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
|||||||
42
Asset/js/shortcut-labels.js
Normal file
42
Asset/js/shortcut-labels.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* OrganonTweaks -- show the keyboard shortcuts Kanboard already binds, next to the task Actions
|
||||||
|
* menu items. Display only: this adds NO key bindings, it just surfaces the existing ones (listed
|
||||||
|
* in Kanboard's own "Keyboard shortcuts" help). Items are matched by their FontAwesome icon class,
|
||||||
|
* so it is independent of the URL format (pretty vs query-string) and the interface language.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var HINTS = [
|
||||||
|
{ icon: "fa-edit", key: "e" }, // Edit the task
|
||||||
|
{ icon: "fa-plus", key: "s" }, // Add a sub-task
|
||||||
|
{ icon: "fa-comment-o", key: "c" }, // Add a comment
|
||||||
|
{ icon: "fa-code-fork", key: "l" } // Add internal link
|
||||||
|
];
|
||||||
|
|
||||||
|
function annotate() {
|
||||||
|
var sidebar = document.querySelector(".sidebar");
|
||||||
|
if (!sidebar) { return; }
|
||||||
|
|
||||||
|
HINTS.forEach(function (hint) {
|
||||||
|
var icon = sidebar.querySelector("li a i." + hint.icon);
|
||||||
|
if (!icon) { return; }
|
||||||
|
|
||||||
|
var link = icon.closest("a");
|
||||||
|
if (!link || link.querySelector(".organon-kbd")) { return; }
|
||||||
|
|
||||||
|
var kbd = document.createElement("span");
|
||||||
|
kbd.className = "organon-kbd";
|
||||||
|
kbd.textContent = hint.key;
|
||||||
|
kbd.style.cssText = "margin-left:8px;padding:0 5px;border:1px solid #ccc;" +
|
||||||
|
"border-radius:3px;font-family:monospace;font-size:0.85em;color:#777;background:#f7f7f7;";
|
||||||
|
link.appendChild(kbd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState !== "loading") {
|
||||||
|
annotate();
|
||||||
|
} else {
|
||||||
|
document.addEventListener("DOMContentLoaded", annotate);
|
||||||
|
}
|
||||||
|
})();
|
||||||
67
Asset/js/title-click-edit.js
Normal file
67
Asset/js/title-click-edit.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* OrganonTweaks -- clicking a task title opens the edit modal instead of the read-only card view,
|
||||||
|
* reusing Kanboard's own modal opener (KB.modal.open).
|
||||||
|
* - Board: the title is a link, and a hidden marker rendered next to it (only when the user may
|
||||||
|
* edit) carries the server-generated edit URL.
|
||||||
|
* - Card view: the title (#task-summary > h2) opens the same modal via #task-view's data-edit-url,
|
||||||
|
* but only when the user may edit -- detected by the presence of the sidebar "Edit" action.
|
||||||
|
*
|
||||||
|
* A capture-phase delegated click runs before Kanboard's own handlers, survives the board's AJAX
|
||||||
|
* re-renders, and stops both the link navigation and the card's own click.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function openEdit(url) {
|
||||||
|
if (url && window.KB && KB.modal) {
|
||||||
|
KB.modal.open(url, "large", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sidebar "Edit the task" action (fa-edit) only renders when the current user may edit.
|
||||||
|
function canEdit() {
|
||||||
|
return !!document.querySelector(".sidebar a i.fa-edit");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
if (!e.target || !e.target.closest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Board card title -> open the edit modal via the per-card marker URL.
|
||||||
|
var link = e.target.closest(".task-board-title a");
|
||||||
|
if (link) {
|
||||||
|
var card = link.closest(".task-board");
|
||||||
|
var marker = card ? card.querySelector(".organon-edit-url") : null;
|
||||||
|
if (marker) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openEdit(marker.getAttribute("data-url"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card-view title -> open the same edit modal (#task-view carries the edit URL).
|
||||||
|
var title = e.target.closest("#task-summary > h2");
|
||||||
|
if (title && canEdit()) {
|
||||||
|
var taskView = document.querySelector("#task-view");
|
||||||
|
if (taskView && taskView.getAttribute("data-edit-url")) {
|
||||||
|
openEdit(taskView.getAttribute("data-edit-url"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// Make the card-view title look clickable (the board title is already a link).
|
||||||
|
function markTitle() {
|
||||||
|
var title = document.querySelector("#task-summary > h2");
|
||||||
|
if (title && canEdit()) {
|
||||||
|
title.style.cursor = "pointer";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState !== "loading") {
|
||||||
|
markTitle();
|
||||||
|
} else {
|
||||||
|
document.addEventListener("DOMContentLoaded", markTitle);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -18,6 +18,13 @@ class ConfigController extends BaseController
|
|||||||
'organon_tweaks_keep_scroll' => (int) $this->configModel->get('organon_tweaks_keep_scroll', 1),
|
'organon_tweaks_keep_scroll' => (int) $this->configModel->get('organon_tweaks_keep_scroll', 1),
|
||||||
'organon_tweaks_quick_click_only' => (int) $this->configModel->get('organon_tweaks_quick_click_only', 1),
|
'organon_tweaks_quick_click_only' => (int) $this->configModel->get('organon_tweaks_quick_click_only', 1),
|
||||||
'organon_tweaks_enhance_recurrence' => (int) $this->configModel->get('organon_tweaks_enhance_recurrence', 1),
|
'organon_tweaks_enhance_recurrence' => (int) $this->configModel->get('organon_tweaks_enhance_recurrence', 1),
|
||||||
|
'organon_tweaks_emphasize_duedate_default' => (int) $this->configModel->get('organon_tweaks_emphasize_duedate_default', 0),
|
||||||
|
'organon_tweaks_show_all_filter' => (int) $this->configModel->get('organon_tweaks_show_all_filter', 1),
|
||||||
|
'organon_tweaks_month_filters' => (int) $this->configModel->get('organon_tweaks_month_filters', 1),
|
||||||
|
'organon_tweaks_recurring_filters' => (int) $this->configModel->get('organon_tweaks_recurring_filters', 1),
|
||||||
|
'organon_tweaks_shortcut_labels' => (int) $this->configModel->get('organon_tweaks_shortcut_labels', 1),
|
||||||
|
'organon_tweaks_title_click_edit' => (int) $this->configModel->get('organon_tweaks_title_click_edit', 1),
|
||||||
|
'organon_tweaks_persistent_sort' => (int) $this->configModel->get('organon_tweaks_persistent_sort', 1),
|
||||||
),
|
),
|
||||||
'errors' => array(),
|
'errors' => array(),
|
||||||
)));
|
)));
|
||||||
@@ -30,17 +37,55 @@ class ConfigController extends BaseController
|
|||||||
$alwaysCommentIcon = isset($values['organon_tweaks_always_comment_icon']) ? 1 : 0;
|
$alwaysCommentIcon = isset($values['organon_tweaks_always_comment_icon']) ? 1 : 0;
|
||||||
$keepScroll = isset($values['organon_tweaks_keep_scroll']) ? 1 : 0;
|
$keepScroll = isset($values['organon_tweaks_keep_scroll']) ? 1 : 0;
|
||||||
$quickClickOnly = isset($values['organon_tweaks_quick_click_only']) ? 1 : 0;
|
$quickClickOnly = isset($values['organon_tweaks_quick_click_only']) ? 1 : 0;
|
||||||
|
$enhanceRecurrence = isset($values['organon_tweaks_enhance_recurrence']) ? 1 : 0;
|
||||||
|
$emphasizeDueDateDefault = isset($values['organon_tweaks_emphasize_duedate_default']) ? 1 : 0;
|
||||||
|
$showAllFilter = isset($values['organon_tweaks_show_all_filter']) ? 1 : 0;
|
||||||
|
$monthFilters = isset($values['organon_tweaks_month_filters']) ? 1 : 0;
|
||||||
|
$recurringFilters = isset($values['organon_tweaks_recurring_filters']) ? 1 : 0;
|
||||||
|
$shortcutLabels = isset($values['organon_tweaks_shortcut_labels']) ? 1 : 0;
|
||||||
|
$titleClickEdit = isset($values['organon_tweaks_title_click_edit']) ? 1 : 0;
|
||||||
|
$persistentSort = isset($values['organon_tweaks_persistent_sort']) ? 1 : 0;
|
||||||
|
|
||||||
if ($this->configModel->save(array(
|
if ($this->configModel->save(array(
|
||||||
'organon_tweaks_always_comment_icon' => $alwaysCommentIcon,
|
'organon_tweaks_always_comment_icon' => $alwaysCommentIcon,
|
||||||
'organon_tweaks_keep_scroll' => $keepScroll,
|
'organon_tweaks_keep_scroll' => $keepScroll,
|
||||||
'organon_tweaks_quick_click_only' => $quickClickOnly,
|
'organon_tweaks_quick_click_only' => $quickClickOnly,
|
||||||
|
'organon_tweaks_enhance_recurrence' => $enhanceRecurrence,
|
||||||
|
'organon_tweaks_emphasize_duedate_default' => $emphasizeDueDateDefault,
|
||||||
|
'organon_tweaks_show_all_filter' => $showAllFilter,
|
||||||
|
'organon_tweaks_month_filters' => $monthFilters,
|
||||||
|
'organon_tweaks_recurring_filters' => $recurringFilters,
|
||||||
|
'organon_tweaks_shortcut_labels' => $shortcutLabels,
|
||||||
|
'organon_tweaks_title_click_edit' => $titleClickEdit,
|
||||||
|
'organon_tweaks_persistent_sort' => $persistentSort,
|
||||||
))) {
|
))) {
|
||||||
$this->flash->success(t('Settings saved successfully.'));
|
$this->flash->success(t('Settings saved successfully.'));
|
||||||
} else {
|
} else {
|
||||||
$this->flash->failure(t('Unable to save your settings.'));
|
$this->flash->failure(t('Unable to save your settings.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply each filter-group toggle across all boards immediately: on -> (re)create the group's
|
||||||
|
// reserved-name filters everywhere, off -> remove them (they are plugin-managed).
|
||||||
|
$projectHelper = new \Kanboard\Plugin\OrganonTweaks\Helper\OrganonProjectHelper($this->container);
|
||||||
|
|
||||||
|
if ($showAllFilter === 1) {
|
||||||
|
$projectHelper->addGroupToAllProjects($projectHelper::GROUP_SHOW_ALL);
|
||||||
|
} else {
|
||||||
|
$projectHelper->removeGroup($projectHelper::GROUP_SHOW_ALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($monthFilters === 1) {
|
||||||
|
$projectHelper->addGroupToAllProjects($projectHelper::GROUP_MONTH);
|
||||||
|
} else {
|
||||||
|
$projectHelper->removeGroup($projectHelper::GROUP_MONTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($recurringFilters === 1) {
|
||||||
|
$projectHelper->addGroupToAllProjects($projectHelper::GROUP_RECURRING);
|
||||||
|
} else {
|
||||||
|
$projectHelper->removeGroup($projectHelper::GROUP_RECURRING);
|
||||||
|
}
|
||||||
|
|
||||||
$this->response->redirect($this->helper->url->to('ConfigController', 'show', array('plugin' => 'OrganonTweaks')));
|
$this->response->redirect($this->helper->url->to('ConfigController', 'show', array('plugin' => 'OrganonTweaks')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
Controller/OrganonSortController.php
Normal file
61
Controller/OrganonSortController.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Kanboard\Plugin\OrganonTweaks\Controller;
|
||||||
|
|
||||||
|
use Kanboard\Controller\BaseController;
|
||||||
|
use Kanboard\Core\Controller\AccessForbiddenException;
|
||||||
|
use Kanboard\Plugin\OrganonTweaks\Helper\OrganonSortHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-column persistent sort, driven from the column's sort control on the board. Project-member
|
||||||
|
* (task-update) gated + CSRF, mirroring core TaskReorderController; returns to the board.
|
||||||
|
*/
|
||||||
|
class OrganonSortController extends BaseController
|
||||||
|
{
|
||||||
|
private function authorize()
|
||||||
|
{
|
||||||
|
$project = $this->getProject();
|
||||||
|
|
||||||
|
if (! $this->helper->user->hasProjectAccess('TaskModificationController', 'update', $project['id'])) {
|
||||||
|
throw new AccessForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $project;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sorter()
|
||||||
|
{
|
||||||
|
return new OrganonSortHelper($this->container);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backToBoard(array $project)
|
||||||
|
{
|
||||||
|
$this->response->redirect($this->helper->url->to('BoardViewController', 'show', array('project_id' => $project['id'])));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sort()
|
||||||
|
{
|
||||||
|
$this->checkCSRFParam();
|
||||||
|
$project = $this->authorize();
|
||||||
|
|
||||||
|
$this->sorter()->applySort(
|
||||||
|
$project['id'],
|
||||||
|
$this->request->getIntegerParam('swimlane_id'),
|
||||||
|
$this->request->getIntegerParam('column_id'),
|
||||||
|
$this->request->getStringParam('sort'),
|
||||||
|
$this->request->getStringParam('direction')
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->backToBoard($project);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggle()
|
||||||
|
{
|
||||||
|
$this->checkCSRFParam();
|
||||||
|
$project = $this->authorize();
|
||||||
|
|
||||||
|
$this->sorter()->toggle($project['id'], $this->request->getIntegerParam('column_id'));
|
||||||
|
|
||||||
|
$this->backToBoard($project);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
Filter/TaskMonthRangeFilter.php
Normal file
92
Filter/TaskMonthRangeFilter.php
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Kanboard\Plugin\OrganonTweaks\Filter;
|
||||||
|
|
||||||
|
use Kanboard\Core\Filter\FilterInterface;
|
||||||
|
use Kanboard\Filter\BaseFilter;
|
||||||
|
use Kanboard\Model\TaskModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter tasks by a whole calendar month, optionally padded.
|
||||||
|
*
|
||||||
|
* Registered twice by the plugin (via $container->extend('taskLexer', ...)):
|
||||||
|
* - `duemonth:VALUE` -- padDays 0: due within the target month.
|
||||||
|
* - `aroundmonth:VALUE` -- padDays 10: the month minus 10 days .. plus 10 days (i.e. the last
|
||||||
|
* 10 days of the previous month, the whole month, and the first 10 days of the next month).
|
||||||
|
*
|
||||||
|
* VALUE is `this` (also `next` / `last`) or an explicit month such as `2026-01` or `january-2026`
|
||||||
|
* (anything strtotime() can parse). Bounds are computed with mktime (correct for any month length)
|
||||||
|
* at search time, so the filter auto-rolls.
|
||||||
|
*
|
||||||
|
* Both bounds (gte start, lte end) are added inside this single apply() on the single-value path,
|
||||||
|
* so they AND -- the range Kanboard's saved-filter string syntax cannot express (repeating the
|
||||||
|
* same attribute ORs via OrCriteria, and there is no AND keyword).
|
||||||
|
*/
|
||||||
|
class TaskMonthRangeFilter extends BaseFilter implements FilterInterface
|
||||||
|
{
|
||||||
|
private $attribute = 'duemonth';
|
||||||
|
private $padDays = 0;
|
||||||
|
|
||||||
|
public function setAttribute($attribute)
|
||||||
|
{
|
||||||
|
$this->attribute = $attribute;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPadDays($days)
|
||||||
|
{
|
||||||
|
$this->padDays = (int) $days;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAttributes()
|
||||||
|
{
|
||||||
|
return array($this->attribute);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply()
|
||||||
|
{
|
||||||
|
$reference = $this->resolveMonthTimestamp((string) $this->value);
|
||||||
|
|
||||||
|
if ($reference === false) {
|
||||||
|
return $this; // unknown/typo value -> no-op (matches nothing added)
|
||||||
|
}
|
||||||
|
|
||||||
|
$month = (int) date('n', $reference);
|
||||||
|
$year = (int) date('Y', $reference);
|
||||||
|
$first = mktime(0, 0, 0, $month, 1, $year);
|
||||||
|
$last = mktime(23, 59, 59, $month + 1, 0, $year); // day 0 of next month = last day of this
|
||||||
|
$pad = $this->padDays * 86400;
|
||||||
|
|
||||||
|
$this->query->neq(TaskModel::TABLE.'.date_due', 0);
|
||||||
|
$this->query->gte(TaskModel::TABLE.'.date_due', $first - $pad);
|
||||||
|
$this->query->lte(TaskModel::TABLE.'.date_due', $last + $pad);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the filter value to any timestamp inside the target month (or false if unparseable).
|
||||||
|
*
|
||||||
|
* @param string $value
|
||||||
|
* @return int|false
|
||||||
|
*/
|
||||||
|
private function resolveMonthTimestamp($value)
|
||||||
|
{
|
||||||
|
$value = strtolower(trim($value));
|
||||||
|
|
||||||
|
if ($value === '' || $value === 'this') {
|
||||||
|
return strtotime('first day of this month');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === 'next') {
|
||||||
|
return strtotime('first day of next month');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === 'last' || $value === 'prev' || $value === 'previous') {
|
||||||
|
return strtotime('first day of last month');
|
||||||
|
}
|
||||||
|
|
||||||
|
return strtotime($value); // 2026-01, january-2026, "last day of january 2026", ...
|
||||||
|
}
|
||||||
|
}
|
||||||
100
Filter/TaskRecurringFilter.php
Normal file
100
Filter/TaskRecurringFilter.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Kanboard\Plugin\OrganonTweaks\Filter;
|
||||||
|
|
||||||
|
use Kanboard\Core\Filter\FilterInterface;
|
||||||
|
use Kanboard\Filter\BaseFilter;
|
||||||
|
use Kanboard\Model\TaskModel;
|
||||||
|
use PicoDb\Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter tasks by recurrence: `recurring:XY`, one attribute with one composite value so it can
|
||||||
|
* INTERSECT the two axes (stacking two values of the same attribute would only OR them).
|
||||||
|
*
|
||||||
|
* X = system : evt (event-triggered = Kanboard native) | cal (calendar = RecoReco) | any
|
||||||
|
* Y = role : ori (original/template) | dup (duplicate/copy) | all
|
||||||
|
*
|
||||||
|
* Conditions are authoritative -- taken straight from how core classifies these cards:
|
||||||
|
* - evtori = recurrence_status PENDING, evtdup = recurrence_status PROCESSED. This is exactly how
|
||||||
|
* core colors the board refresh icon (app/Template/board/task_footer.php: normal icon = PENDING,
|
||||||
|
* fa-inverse = PROCESSED), so the filter matches what the board shows.
|
||||||
|
* - calori = RecoReco metadata recoreco_enabled=1, caldup = recoreco_clone=1.
|
||||||
|
* - *all / any* = the OR-unions of the above.
|
||||||
|
*
|
||||||
|
* Cross-plugin: the calendar side reads RecoReco metadata keys. If RecoReco is not installed the
|
||||||
|
* metadata sets are empty, so `cal*` matches nothing and `evt*` still works (event-only degrade).
|
||||||
|
*
|
||||||
|
* One attribute + one value -> single-value path, so the predicates below are grouped in a scoped
|
||||||
|
* beginOr/closeOr and the whole group still ANDs with any other search attribute.
|
||||||
|
*/
|
||||||
|
class TaskRecurringFilter extends BaseFilter implements FilterInterface
|
||||||
|
{
|
||||||
|
private $db;
|
||||||
|
|
||||||
|
public function setDatabase(Database $db)
|
||||||
|
{
|
||||||
|
$this->db = $db;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAttributes()
|
||||||
|
{
|
||||||
|
return array('recurring');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function apply()
|
||||||
|
{
|
||||||
|
$value = strtolower(trim((string) $this->value));
|
||||||
|
$system = substr($value, 0, 3); // evt | cal | any
|
||||||
|
$role = substr($value, 3, 3); // ori | dup | all
|
||||||
|
|
||||||
|
if (! in_array($system, array('evt', 'cal', 'any'), true) ||
|
||||||
|
! in_array($role, array('ori', 'dup', 'all'), true)) {
|
||||||
|
return $this; // unknown/typo value -> no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
$wantEvt = $system === 'evt' || $system === 'any';
|
||||||
|
$wantCal = $system === 'cal' || $system === 'any';
|
||||||
|
$wantOri = $role === 'ori' || $role === 'all';
|
||||||
|
$wantDup = $role === 'dup' || $role === 'all';
|
||||||
|
|
||||||
|
$this->query->beginOr();
|
||||||
|
|
||||||
|
if ($wantEvt && $wantOri) {
|
||||||
|
$this->query->eq(TaskModel::TABLE.'.recurrence_status', TaskModel::RECURRING_STATUS_PENDING);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($wantEvt && $wantDup) {
|
||||||
|
$this->query->eq(TaskModel::TABLE.'.recurrence_status', TaskModel::RECURRING_STATUS_PROCESSED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($wantCal && $wantOri) {
|
||||||
|
$this->query->in(TaskModel::TABLE.'.id', $this->metaIds('recoreco_enabled'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($wantCal && $wantDup) {
|
||||||
|
$this->query->in(TaskModel::TABLE.'.id', $this->metaIds('recoreco_clone'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->query->closeOr();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task ids carrying a RecoReco metadata flag set to 1. Returns a non-matching sentinel when the
|
||||||
|
* set is empty so the IN clause matches nothing (RecoReco absent -> event-only degrade).
|
||||||
|
*
|
||||||
|
* @param string $name
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
private function metaIds($name)
|
||||||
|
{
|
||||||
|
$ids = $this->db->table('task_has_metadata')
|
||||||
|
->eq('name', $name)
|
||||||
|
->eq('value', '1')
|
||||||
|
->findAllByColumn('task_id');
|
||||||
|
|
||||||
|
return ! empty($ids) ? $ids : array(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
189
Helper/OrganonProjectHelper.php
Normal file
189
Helper/OrganonProjectHelper.php
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Kanboard\Plugin\OrganonTweaks\Helper;
|
||||||
|
|
||||||
|
use Kanboard\Core\Base;
|
||||||
|
use Kanboard\Model\CustomFilterModel;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-board helper for OrganonTweaks.
|
||||||
|
*
|
||||||
|
* Resolves the 3-state "emphasize due date" setting for the CURRENT board (read from the route
|
||||||
|
* project_id, which the router populates for pretty URLs before the layout renders):
|
||||||
|
* per-board 'on' -> on, 'off' -> off, 'default'/absent -> follow the LIVE global default.
|
||||||
|
*
|
||||||
|
* The stored values are the non-falsy strings 'on'/'off' (not '1'/'0'): Kanboard's
|
||||||
|
* MetadataModel::get() uses `?:`, and PHP treats the string "0" as falsy, so a stored "0" would
|
||||||
|
* come back as the 'default' fallback and "Always off" would never be honored.
|
||||||
|
*
|
||||||
|
* Also maintains shared, reserved-name custom filters on every board (v1.5 "Show all tasks", v1.6
|
||||||
|
* "Due this month" / "Around this month"), each group gated by its own config toggle.
|
||||||
|
*/
|
||||||
|
class OrganonProjectHelper extends Base
|
||||||
|
{
|
||||||
|
const EMPHASIZE_KEY = 'organon_tweaks_emphasize_duedate';
|
||||||
|
const EMPHASIZE_DEFAULT_KEY = 'organon_tweaks_emphasize_duedate_default';
|
||||||
|
|
||||||
|
// Config toggles for the auto-managed custom-filter groups.
|
||||||
|
const SHOW_ALL_FILTER_KEY = 'organon_tweaks_show_all_filter';
|
||||||
|
const MONTH_FILTERS_KEY = 'organon_tweaks_month_filters';
|
||||||
|
const RECURRING_FILTERS_KEY = 'organon_tweaks_recurring_filters';
|
||||||
|
|
||||||
|
// Reserved custom-filter groups: each row is [name, query, append]. The NAME is reserved by the
|
||||||
|
// plugin (fixed, non-translated strings so dedup is stable across locales); a user's own filter
|
||||||
|
// with a reserved name is absorbed into the canonical form. "Show all tasks" is a REPLACE filter
|
||||||
|
// (append 0) so it clears; the rest are APPEND (1) so clicks stack (distinct attributes AND).
|
||||||
|
// Names are prefixed by group so the dropdown -- which core sorts alphabetically by name
|
||||||
|
// (CustomFilterModel::getAll -> asc(name)) -- clusters them: Board < Month < Recurring.
|
||||||
|
const GROUP_SHOW_ALL = array(
|
||||||
|
array('Board: show all tasks', '', 0),
|
||||||
|
);
|
||||||
|
const GROUP_MONTH = array(
|
||||||
|
array('Month: due this month', 'duemonth:this', 1),
|
||||||
|
array('Month: around this month', 'aroundmonth:this', 1),
|
||||||
|
);
|
||||||
|
const GROUP_RECURRING = array(
|
||||||
|
array('Recurring: all', 'recurring:anyall', 1),
|
||||||
|
array('Recurring: event', 'recurring:evtall', 1),
|
||||||
|
array('Recurring: calendar', 'recurring:calall', 1),
|
||||||
|
array('Recurring: templates', 'recurring:anyori', 1),
|
||||||
|
array('Recurring: duplicates', 'recurring:anydup', 1),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should the current board emphasize the due date? Board pages only (project_id in the route).
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isEmphasizeDueDate()
|
||||||
|
{
|
||||||
|
$project_id = $this->request->getIntegerParam('project_id');
|
||||||
|
|
||||||
|
if ($project_id <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $this->projectMetadataModel->get($project_id, self::EMPHASIZE_KEY, 'default');
|
||||||
|
|
||||||
|
if ($value === 'on') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($value === 'off') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'default' (or absent) -> follow the live global default.
|
||||||
|
return (int) $this->configModel->get(self::EMPHASIZE_DEFAULT_KEY, 0) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure one canonical reserved custom filter on a board (shared). Idempotent: create when
|
||||||
|
* absent, force a same-named one back to the canonical form (query + shared + append) since the
|
||||||
|
* name is reserved, and drop duplicates. No-op without a real project and a real owner
|
||||||
|
* (custom_filters.user_id has a FK to users, so a system id like 0 would fail the insert).
|
||||||
|
*
|
||||||
|
* @param int $project_id
|
||||||
|
* @param string $name
|
||||||
|
* @param string $query
|
||||||
|
* @param int $append
|
||||||
|
*/
|
||||||
|
public function ensureReservedFilter($project_id, $name, $query, $append)
|
||||||
|
{
|
||||||
|
$project_id = (int) $project_id;
|
||||||
|
$user_id = (int) $this->userSession->getId();
|
||||||
|
|
||||||
|
if ($project_id <= 0 || $user_id <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$append = $append ? 1 : 0;
|
||||||
|
|
||||||
|
$existing = $this->db->table(CustomFilterModel::TABLE)
|
||||||
|
->eq('project_id', $project_id)
|
||||||
|
->eq('name', $name)
|
||||||
|
->findAll();
|
||||||
|
|
||||||
|
if (empty($existing)) {
|
||||||
|
$this->db->table(CustomFilterModel::TABLE)->insert(array(
|
||||||
|
'filter' => $query,
|
||||||
|
'project_id' => $project_id,
|
||||||
|
'user_id' => $user_id,
|
||||||
|
'name' => $name,
|
||||||
|
'is_shared' => 1,
|
||||||
|
'append' => $append,
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved name: keep one, force it to the canonical form, drop the rest.
|
||||||
|
$keep = array_shift($existing);
|
||||||
|
|
||||||
|
if ($keep['filter'] !== $query || (int) $keep['is_shared'] !== 1 || (int) $keep['append'] !== $append) {
|
||||||
|
$this->db->table(CustomFilterModel::TABLE)
|
||||||
|
->eq('id', $keep['id'])
|
||||||
|
->update(array('filter' => $query, 'is_shared' => 1, 'append' => $append));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($existing as $duplicate) {
|
||||||
|
$this->db->table(CustomFilterModel::TABLE)->eq('id', $duplicate['id'])->remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure every ENABLED group's filters on a board. Called lazily on each project-header render,
|
||||||
|
* so it covers old and new boards alike.
|
||||||
|
*
|
||||||
|
* @param int $project_id
|
||||||
|
*/
|
||||||
|
public function ensureBoardFilters($project_id)
|
||||||
|
{
|
||||||
|
if ((int) $this->configModel->get(self::SHOW_ALL_FILTER_KEY, 1) === 1) {
|
||||||
|
$this->ensureGroup($project_id, self::GROUP_SHOW_ALL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $this->configModel->get(self::MONTH_FILTERS_KEY, 1) === 1) {
|
||||||
|
$this->ensureGroup($project_id, self::GROUP_MONTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) $this->configModel->get(self::RECURRING_FILTERS_KEY, 1) === 1) {
|
||||||
|
$this->ensureGroup($project_id, self::GROUP_RECURRING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureGroup($project_id, array $rows)
|
||||||
|
{
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$this->ensureReservedFilter($project_id, $row[0], $row[1], $row[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle turned ON: eagerly (re)create a group's filters on every existing board.
|
||||||
|
* ensureBoardFilters() keeps covering boards created afterwards.
|
||||||
|
*
|
||||||
|
* @param array $rows
|
||||||
|
*/
|
||||||
|
public function addGroupToAllProjects(array $rows)
|
||||||
|
{
|
||||||
|
foreach ($this->projectModel->getAll() as $project) {
|
||||||
|
$this->ensureGroup((int) $project['id'], $rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle turned OFF: remove a group's filters across all boards (by their reserved names).
|
||||||
|
*
|
||||||
|
* @param array $rows
|
||||||
|
*/
|
||||||
|
public function removeGroup(array $rows)
|
||||||
|
{
|
||||||
|
$names = array();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$names[] = $row[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table(CustomFilterModel::TABLE)->in('name', $names)->remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
176
Helper/OrganonSortHelper.php
Normal file
176
Helper/OrganonSortHelper.php
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
Plugin.php
90
Plugin.php
@@ -3,6 +3,7 @@
|
|||||||
namespace Kanboard\Plugin\OrganonTweaks;
|
namespace Kanboard\Plugin\OrganonTweaks;
|
||||||
|
|
||||||
use Kanboard\Core\Plugin\Base;
|
use Kanboard\Core\Plugin\Base;
|
||||||
|
use Kanboard\Model\TaskModel;
|
||||||
|
|
||||||
class Plugin extends Base
|
class Plugin extends Base
|
||||||
{
|
{
|
||||||
@@ -20,6 +21,13 @@ class Plugin extends Base
|
|||||||
// Settings page for the plugin's tweaks.
|
// Settings page for the plugin's tweaks.
|
||||||
$this->template->hook->attach('template:config:sidebar', 'organonTweaks:config/sidebar');
|
$this->template->hook->attach('template:config:sidebar', 'organonTweaks:config/sidebar');
|
||||||
|
|
||||||
|
// Per-board tweak: emphasize the due date on the board. Configured with 3-state radios on the
|
||||||
|
// board's Settings -> Integrations (Use default / Always on / Always off); the global default
|
||||||
|
// (for "Use default" boards) lives in the plugin settings above. The emphasis <style> is
|
||||||
|
// emitted into the head only on enabled board pages (gated by OrganonProjectHelper).
|
||||||
|
$this->template->hook->attach('template:project:integrations', 'organonTweaks:project/integration');
|
||||||
|
$this->template->hook->attach('template:layout:head', 'organonTweaks:layout/emphasize_duedate');
|
||||||
|
|
||||||
// Tweak: always show a comment icon on board cards. When a card has no comments
|
// Tweak: always show a comment icon on board cards. When a card has no comments
|
||||||
// yet, add a bubble "+" that opens the same comment modal (add the first comment
|
// yet, add a bubble "+" that opens the same comment modal (add the first comment
|
||||||
// straight from the board). Cards with comments already show the native count.
|
// straight from the board). Cards with comments already show the native count.
|
||||||
@@ -50,12 +58,90 @@ class Plugin extends Base
|
|||||||
'template' => 'plugins/OrganonTweaks/Asset/js/enhance-recurrence.js',
|
'template' => 'plugins/OrganonTweaks/Asset/js/enhance-recurrence.js',
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tweak: show the keyboard shortcuts Kanboard already binds next to the task Actions menu
|
||||||
|
// items (display only -- no new bindings). Done in JS since those menu <li> have no hook.
|
||||||
|
if ((int) $this->configModel->get('organon_tweaks_shortcut_labels', 1) === 1) {
|
||||||
|
$this->hook->on('template:layout:js', array(
|
||||||
|
'template' => 'plugins/OrganonTweaks/Asset/js/shortcut-labels.js',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tweak: clicking a task title opens the edit modal (instead of the read-only card view).
|
||||||
|
// JS reuses Kanboard's own modal opener. On the board the title is a link, so a hidden marker
|
||||||
|
// (rendered by the hook below, only when the user may edit) carries the server-generated edit
|
||||||
|
// URL for the JS to open; on the card view the JS reads #task-view's data-edit-url.
|
||||||
|
if ((int) $this->configModel->get('organon_tweaks_title_click_edit', 1) === 1) {
|
||||||
|
$this->hook->on('template:layout:js', array(
|
||||||
|
'template' => 'plugins/OrganonTweaks/Asset/js/title-click-edit.js',
|
||||||
|
));
|
||||||
|
$this->template->hook->attach('template:board:private:task:before-title', 'organonTweaks:board/title_edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tweak: per-column persistent sort. A sort control (number-aware title sort + a
|
||||||
|
// "Persistent sort: ON/OFF" toggle) replaces the native column sort. Sorting always reorders
|
||||||
|
// physically (writes positions, like native); when persistent is ON, the column is re-sorted
|
||||||
|
// whenever a card is dropped in or created, so it stays sorted. Persistent OFF = native
|
||||||
|
// one-shot. Control injected via the column dropdown hook + relocate.js (which also hides the
|
||||||
|
// native fa-sort menu).
|
||||||
|
if ((int) $this->configModel->get('organon_tweaks_persistent_sort', 1) === 1) {
|
||||||
|
$this->template->hook->attach('template:board:column:dropdown', 'organonTweaks:board/sort_control');
|
||||||
|
|
||||||
|
$container = $this->container;
|
||||||
|
$reapply = function ($project_id, $swimlane_id, $column_id) use ($container) {
|
||||||
|
$helper = new \Kanboard\Plugin\OrganonTweaks\Helper\OrganonSortHelper($container);
|
||||||
|
if ($helper->isSticky($project_id, $column_id)) {
|
||||||
|
$helper->reapply($project_id, $swimlane_id, $column_id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Card dropped into a column -> re-apply that column's sort (the plugin on() wrapper drops
|
||||||
|
// the event, so subscribe on the dispatcher to receive the TaskEvent).
|
||||||
|
$this->dispatcher->addListener(TaskModel::EVENT_MOVE_COLUMN, function ($event) use ($reapply) {
|
||||||
|
$task = $event['task'];
|
||||||
|
$reapply((int) $task['project_id'], (int) $task['swimlane_id'], (int) $event['dst_column_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// New card created in a column -> re-apply that column's sort.
|
||||||
|
$this->hook->on('model:task:creation:aftersave', function ($task_id) use ($container, $reapply) {
|
||||||
|
$task = $container['taskFinderModel']->getById($task_id);
|
||||||
|
if (! empty($task)) {
|
||||||
|
$reapply((int) $task['project_id'], (int) $task['swimlane_id'], (int) $task['column_id']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-managed shared custom filters (v1.5 "Show all tasks", v1.6 month filters, v1.7
|
||||||
|
// recurring filters). Ensured lazily whenever a project header renders (covers old + new
|
||||||
|
// boards); each group is gated by its own config toggle inside
|
||||||
|
// OrganonProjectHelper::ensureBoardFilters(). The toggles also bulk add/remove the filters.
|
||||||
|
$showAllFilters = (int) $this->configModel->get('organon_tweaks_show_all_filter', 1) === 1;
|
||||||
|
$monthFilters = (int) $this->configModel->get('organon_tweaks_month_filters', 1) === 1;
|
||||||
|
$recurringFilters = (int) $this->configModel->get('organon_tweaks_recurring_filters', 1) === 1;
|
||||||
|
if ($showAllFilters || $monthFilters || $recurringFilters) {
|
||||||
|
$this->template->hook->attach('template:project:header:before', 'organonTweaks:project_header/board_filters');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom board-search keywords, registered into the task lexer by extending its factory
|
||||||
|
// service (Pimple preserves factory status on extend, so the lexer stays per-request fresh).
|
||||||
|
// `duemonth:VALUE` / `aroundmonth:VALUE` = a calendar month (padded by 10 days for around);
|
||||||
|
// `recurring:XY` = classify recurrence (X system evt/cal/any, Y role ori/dup/all). Always
|
||||||
|
// available to type, independent of the bookmark toggles above. Guarded so a future Kanboard
|
||||||
|
// service rename no-ops (keyword absent) instead of breaking.
|
||||||
|
if (isset($this->container['taskLexer'])) {
|
||||||
|
$this->container->extend('taskLexer', function ($builder, $c) {
|
||||||
|
return $builder
|
||||||
|
->withFilter((new \Kanboard\Plugin\OrganonTweaks\Filter\TaskMonthRangeFilter())->setAttribute('duemonth')->setPadDays(0))
|
||||||
|
->withFilter((new \Kanboard\Plugin\OrganonTweaks\Filter\TaskMonthRangeFilter())->setAttribute('aroundmonth')->setPadDays(10))
|
||||||
|
->withFilter((new \Kanboard\Plugin\OrganonTweaks\Filter\TaskRecurringFilter())->setDatabase($c['db']));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getHelpers()
|
public function getHelpers()
|
||||||
{
|
{
|
||||||
return array(
|
return array(
|
||||||
'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper'),
|
'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper', 'OrganonProjectHelper', 'OrganonSortHelper'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +162,7 @@ class Plugin extends Base
|
|||||||
|
|
||||||
public function getPluginVersion()
|
public function getPluginVersion()
|
||||||
{
|
{
|
||||||
return '1.3.0';
|
return '1.9.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPluginHomepage()
|
public function getPluginHomepage()
|
||||||
|
|||||||
95
README.md
95
README.md
@@ -52,12 +52,37 @@ board stays where you were.
|
|||||||
Dropping a card sometimes leaks a trailing "click" that opens the card -- an unwanted side
|
Dropping a card sometimes leaks a trailing "click" that opens the card -- an unwanted side
|
||||||
effect of the drag. A real click is a quick press-release; a drag is a press held much
|
effect of the drag. A real click is a quick press-release; a drag is a press held much
|
||||||
longer. So `Asset/js/click-guard.js` times the press on a card (`mousedown`/`touchstart` to
|
longer. So `Asset/js/click-guard.js` times the press on a card (`mousedown`/`touchstart` to
|
||||||
the following `click`) and, if it was held longer than **100 ms**, swallows the click in the
|
the following `click`) and, if it was held longer than **120 ms**, swallows the click in the
|
||||||
capture phase before Kanboard's open handler -- so a drag never opens the card. Normal
|
capture phase before Kanboard's open handler -- so a drag never opens the card. Normal
|
||||||
clicks (well under 100 ms) still open cards as usual. The 100 ms threshold is fixed.
|
clicks (well under 120 ms) still open cards as usual. The 120 ms threshold is fixed.
|
||||||
|
|
||||||
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
|
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
|
||||||
|
|
||||||
|
### Show keyboard shortcuts on the task menu
|
||||||
|
|
||||||
|
Kanboard already binds handy keys on the task view (`e` = edit, `s` = new sub-task, `c` = new
|
||||||
|
comment, `l` = new internal link) but never shows them, so they go undiscovered. This tweak adds a
|
||||||
|
small key hint next to each of those items in the task **Actions** sidebar. It is **display only**:
|
||||||
|
it adds no new bindings, it just surfaces the ones Kanboard already has.
|
||||||
|
|
||||||
|
- Implemented in `Asset/js/shortcut-labels.js`, matching the menu items by their FontAwesome icon
|
||||||
|
class, so it is independent of the URL format and the interface language.
|
||||||
|
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
|
||||||
|
|
||||||
|
### Click a title to edit
|
||||||
|
|
||||||
|
By default, clicking a card title (on the board) or the title on the card view lands you on the
|
||||||
|
read-only card view. With this tweak on, clicking the title opens the **edit** form directly,
|
||||||
|
reusing Kanboard's own modal -- so from the board you edit and, on Save, come straight back to the
|
||||||
|
board. It applies only where you may edit (a read-only viewer keeps the normal title), and
|
||||||
|
shared/public boards are untouched.
|
||||||
|
|
||||||
|
- Implemented in `Asset/js/title-click-edit.js` -- a capture-phase click that reuses `KB.modal.open`.
|
||||||
|
On the board, a hidden marker (`Template/board/title_edit.php`, rendered by
|
||||||
|
`template:board:private:task:before-title` only when the user may edit) carries the
|
||||||
|
server-generated edit URL; on the card view the title reuses `#task-view`'s `data-edit-url`.
|
||||||
|
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
|
||||||
|
|
||||||
### Enhance the recurrence dialog
|
### Enhance the recurrence dialog
|
||||||
|
|
||||||
Kanboard's native "Edit recurrence" dialog is a plain form. This makes two small usability
|
Kanboard's native "Edit recurrence" dialog is a plain form. This makes two small usability
|
||||||
@@ -71,13 +96,56 @@ edits when it opens (there is no template hook, so it is done in JS,
|
|||||||
|
|
||||||
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
|
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
|
||||||
|
|
||||||
|
### Persistent column sort
|
||||||
|
|
||||||
|
Kanboard's native column sort is a one-shot physical reorder: it rewrites task positions once, so
|
||||||
|
the order already survives refreshes, but a newly created or dropped-in card lands out of order
|
||||||
|
until you sort again -- and there is no sort-by-title. This tweak replaces the per-column sort
|
||||||
|
control with one that:
|
||||||
|
|
||||||
|
- adds a **number-aware Title sort** (A to Z / Z to A), so "Task 2" sorts before "Task 10";
|
||||||
|
- adds a **Persistent sort: ON / OFF** toggle. When ON, the column is re-sorted (server-side,
|
||||||
|
physically) whenever a card is dropped into it or created in it, so it **stays** sorted; when
|
||||||
|
OFF it behaves exactly like the native one-shot sort.
|
||||||
|
|
||||||
|
The header icon shows the state: the native double-arrow when persistent is OFF, and a **red
|
||||||
|
single arrow** (up for ascending, down for descending) when it is ON.
|
||||||
|
|
||||||
|
- Reorders reuse core's `TaskReorderModel` for the native criteria (id / priority / assignee /
|
||||||
|
due date) and add the title sort; the sticky re-apply runs on the move / creation events.
|
||||||
|
- The control is injected via the `template:board:column:dropdown` hook + `Asset/js/relocate.js`
|
||||||
|
(which also hides the native sort menu). Per-column state lives in project metadata.
|
||||||
|
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
Global (per Kanboard instance) and admin-only, under "Settings -> Organon Tweaks":
|
Global (per Kanboard instance) and admin-only, under "Settings -> Organon Tweaks", grouped as on
|
||||||
|
the page:
|
||||||
|
|
||||||
|
**Board card readability**
|
||||||
|
|
||||||
- **Always show the comment icon on board cards** -- default on.
|
- **Always show the comment icon on board cards** -- default on.
|
||||||
- **Keep the board scroll position across refreshes** -- default on.
|
- **Emphasize due date by default** -- default off (overridable per board under Settings -> Integrations).
|
||||||
|
|
||||||
|
**Board filters** (shared custom filters added to every board)
|
||||||
|
|
||||||
|
- **Add a "Show all tasks" filter to every board** -- default on.
|
||||||
|
- **Add month filters to every board** -- default on.
|
||||||
|
- **Add recurring filters to every board** -- default on.
|
||||||
|
|
||||||
|
**Card interaction**
|
||||||
|
|
||||||
- **Open a card only on a quick click** -- default on.
|
- **Open a card only on a quick click** -- default on.
|
||||||
|
- **Show keyboard shortcuts on the task menu** -- default on.
|
||||||
|
- **Clicking a task title opens the edit form** -- default on.
|
||||||
|
|
||||||
|
**Board columns**
|
||||||
|
|
||||||
|
- **Per-column persistent sort** -- default on.
|
||||||
|
|
||||||
|
Standalone:
|
||||||
|
|
||||||
|
- **Keep the board scroll position across refreshes** -- default on.
|
||||||
- **Enhance the recurrence dialog** -- default on.
|
- **Enhance the recurrence dialog** -- default on.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -93,3 +161,22 @@ name). No build step and no database migration.
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
AGPL-3.0. See LICENSE.
|
AGPL-3.0. See LICENSE.
|
||||||
|
|
||||||
|
## More Kanboard plugins by Dr. Beco
|
||||||
|
|
||||||
|
All free and AGPL-3.0, at [code.beco.cc](https://code.beco.cc/beco):
|
||||||
|
|
||||||
|
- **[RecoReco](https://code.beco.cc/beco/RecoReco)** -- calendar-scheduled recurring cards (yearly,
|
||||||
|
monthly, weekly, daily), driven by the card due date.
|
||||||
|
- **[FinanceBuddy](https://code.beco.cc/beco/FinanceBuddy)** -- attach a money value (debit/credit
|
||||||
|
and installments) to cards, shown on the card and totalled per column.
|
||||||
|
- **[QualKard](https://code.beco.cc/beco/QualKard)** -- flashcard spaced repetition: grade a card by
|
||||||
|
dragging it to a column, and it comes back when due (needs RecoReco).
|
||||||
|
- **[WorkspaceOrg](https://code.beco.cc/beco/WorkspaceOrg)** -- group your projects into personal
|
||||||
|
per-user workspaces, shown on a "My workspaces" dashboard page with a badge on each project.
|
||||||
|
- **[BulkMoveTasks](https://code.beco.cc/beco/BulkMoveTasks)** -- move every task from one board
|
||||||
|
column to another in a single action.
|
||||||
|
- **[ShrinkVertically](https://code.beco.cc/beco/ShrinkVertically)** -- shrink vertically-collapsed
|
||||||
|
board columns so the horizontal scrollbar stays within reach.
|
||||||
|
- **[TweakDrag](https://code.beco.cc/beco/TweakDrag)** -- board drag and touch niceties:
|
||||||
|
drag-to-scroll, a wider column gap, and smoother card dragging.
|
||||||
|
|||||||
69
Template/board/sort_control.php
Normal file
69
Template/board/sort_control.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
// Per-column persistent-sort control. Rendered by template:board:column:dropdown (so it sits in the
|
||||||
|
// column header, as its own dropdown); relocate.js hides the native fa-sort menu in its favour.
|
||||||
|
// Icon: native double-arrow when persistent is off, a red single arrow (up=asc / down=desc) when on.
|
||||||
|
if ($column['nb_tasks'] <= 0 || ! $this->user->hasProjectAccess('TaskModificationController', 'update', $column['project_id'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pid = $column['project_id'];
|
||||||
|
$cid = $column['id'];
|
||||||
|
$sid = $swimlane['id'];
|
||||||
|
|
||||||
|
$sticky = $this->OrganonSortHelper->isSticky($pid, $cid);
|
||||||
|
$curBy = $this->OrganonSortHelper->sortBy($pid, $cid);
|
||||||
|
$curDir = $this->OrganonSortHelper->sortDir($pid, $cid);
|
||||||
|
$applied = $curBy !== '';
|
||||||
|
$desc = $curDir === 'desc';
|
||||||
|
|
||||||
|
$criteria = array(
|
||||||
|
array('title', 'asc', t('Title (A to Z)')),
|
||||||
|
array('title', 'desc', t('Title (Z to A)')),
|
||||||
|
array('id', 'asc', t('Id (ASC)')),
|
||||||
|
array('id', 'desc', t('Id (DESC)')),
|
||||||
|
array('priority', 'asc', t('Priority (ASC)')),
|
||||||
|
array('priority', 'desc', t('Priority (DESC)')),
|
||||||
|
array('due-date', 'asc', t('Due date (ASC)')),
|
||||||
|
array('due-date', 'desc', t('Due date (DESC)')),
|
||||||
|
array('assignee', 'asc', t('Assignee (A-Z)')),
|
||||||
|
array('assignee', 'desc', t('Assignee (Z-A)')),
|
||||||
|
array('assignee-priority', 'asc', t('Assignee + priority (ASC)')),
|
||||||
|
array('assignee-priority', 'desc', t('Assignee + priority (DESC)')),
|
||||||
|
);
|
||||||
|
?>
|
||||||
|
<span class="dropdown organontweaks-sort">
|
||||||
|
<a href="#" class="dropdown-menu" title="<?= t('Sort this column') ?>">
|
||||||
|
<?php if ($sticky && $applied): ?>
|
||||||
|
<?php /* Persistent ON: a single red arrow for the direction (8593 up / 8595 down),
|
||||||
|
written as HTML entities so the source stays ASCII. OFF = the native
|
||||||
|
double-arrow (fa-sort) below -- the natural, non-sticky state. */ ?>
|
||||||
|
<span class="organontweaks-sort-arrow" style="color: #c0392b;"><?= $desc ? '↓' : '↑' ?></span>
|
||||||
|
<?php else: ?>
|
||||||
|
<i class="fa fa-sort fa-fw" aria-hidden="true"></i>
|
||||||
|
<?php endif ?>
|
||||||
|
</a>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<?= $this->url->link(
|
||||||
|
'<i class="fa fa-thumb-tack fa-fw"></i> '.($sticky ? t('Persistent sort: ON') : t('Persistent sort: OFF')),
|
||||||
|
'OrganonSortController',
|
||||||
|
'toggle',
|
||||||
|
array('plugin' => 'OrganonTweaks', 'project_id' => $pid, 'column_id' => $cid),
|
||||||
|
true
|
||||||
|
) ?>
|
||||||
|
</li>
|
||||||
|
<li class="no-hover"><strong><?= t('Sort by') ?></strong></li>
|
||||||
|
<?php foreach ($criteria as $c): ?>
|
||||||
|
<?php $active = ($curBy === $c[0] && $curDir === $c[1]); ?>
|
||||||
|
<li>
|
||||||
|
<?= $this->url->link(
|
||||||
|
'<i class="fa '.($active ? 'fa-check' : '').' fa-fw"></i> '.$this->text->e($c[2]),
|
||||||
|
'OrganonSortController',
|
||||||
|
'sort',
|
||||||
|
array('plugin' => 'OrganonTweaks', 'project_id' => $pid, 'column_id' => $cid, 'swimlane_id' => $sid, 'sort' => $c[0], 'direction' => $c[1]),
|
||||||
|
true
|
||||||
|
) ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach ?>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
3
Template/board/title_edit.php
Normal file
3
Template/board/title_edit.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php if ($this->user->hasProjectAccess('TaskModificationController', 'edit', $task['project_id'])): ?>
|
||||||
|
<span class="organon-edit-url" style="display:none" data-url="<?= $this->url->href('TaskModificationController', 'edit', array('task_id' => $task['id'])) ?>"></span>
|
||||||
|
<?php endif ?>
|
||||||
@@ -6,10 +6,13 @@
|
|||||||
<?= $this->form->csrf() ?>
|
<?= $this->form->csrf() ?>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
<legend><?= t('Board card readability') ?></legend>
|
||||||
|
|
||||||
<?= $this->form->checkbox('organon_tweaks_always_comment_icon', t('Always show the comment icon on board cards'), 1, isset($values['organon_tweaks_always_comment_icon']) && $values['organon_tweaks_always_comment_icon'] == 1) ?>
|
<?= $this->form->checkbox('organon_tweaks_always_comment_icon', t('Always show the comment icon on board cards'), 1, isset($values['organon_tweaks_always_comment_icon']) && $values['organon_tweaks_always_comment_icon'] == 1) ?>
|
||||||
<p class="form-help">
|
<p class="form-help"><?= t('A "+" bubble on cards with no comments yet, to add the first one from the board.') ?></p>
|
||||||
<?= t('Show a comment bubble with a "+" on cards that have no comments yet, so you can add the first comment straight from the board. Cards that already have comments keep showing the count as usual.') ?>
|
|
||||||
</p>
|
<?= $this->form->checkbox('organon_tweaks_emphasize_duedate_default', t('Emphasize due date by default'), 1, isset($values['organon_tweaks_emphasize_duedate_default']) && $values['organon_tweaks_emphasize_duedate_default'] == 1) ?>
|
||||||
|
<p class="form-help"><?= t('Enlarge and bold the due date; the default for boards set to "Use default" (overridable per board under Settings -> Integrations).') ?></p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -20,17 +23,43 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<?= $this->form->checkbox('organon_tweaks_quick_click_only', t('Open a card only on a quick click'), 1, isset($values['organon_tweaks_quick_click_only']) && $values['organon_tweaks_quick_click_only'] == 1) ?>
|
<?= $this->form->checkbox('organon_tweaks_enhance_recurrence', t('Enhance the native recurrence dialog'), 1, isset($values['organon_tweaks_enhance_recurrence']) && $values['organon_tweaks_enhance_recurrence'] == 1) ?>
|
||||||
<p class="form-help">
|
<p class="form-help">
|
||||||
<?= t('A press on a card held longer than 100 ms is treated as a drag, so releasing it does not open the card. Fixes a drag sometimes opening the card on release. Normal clicks are well under 100 ms.') ?>
|
<?= t('In the native "Edit recurrence" dialog, replace the Generate recurrent task dropdown with Yes/No radios, and add a [+7] button on the factor to add a week at once.') ?>
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<?= $this->form->checkbox('organon_tweaks_enhance_recurrence', t('Enhance the recurrence dialog'), 1, isset($values['organon_tweaks_enhance_recurrence']) && $values['organon_tweaks_enhance_recurrence'] == 1) ?>
|
<legend><?= t('Board filters') ?></legend>
|
||||||
<p class="form-help">
|
|
||||||
<?= t('In the native "Edit recurrence" dialog, replace the Generate recurrent task dropdown with Yes/No radio buttons (one click), and add a [+7] button next to the factor that adds 7 (one week of days).') ?>
|
<?= $this->form->checkbox('organon_tweaks_show_all_filter', t('Add a "Show all tasks" filter to every board'), 1, isset($values['organon_tweaks_show_all_filter']) && $values['organon_tweaks_show_all_filter'] == 1) ?>
|
||||||
</p>
|
<p class="form-help"><?= t('One-click clear that shows open and closed tasks.') ?></p>
|
||||||
|
|
||||||
|
<?= $this->form->checkbox('organon_tweaks_month_filters', t('Add month filters to every board'), 1, isset($values['organon_tweaks_month_filters']) && $values['organon_tweaks_month_filters'] == 1) ?>
|
||||||
|
<p class="form-help"><?= t('"Due this month" and "Around this month" (duemonth: / aroundmonth: keywords).') ?></p>
|
||||||
|
|
||||||
|
<?= $this->form->checkbox('organon_tweaks_recurring_filters', t('Add recurring filters to every board'), 1, isset($values['organon_tweaks_recurring_filters']) && $values['organon_tweaks_recurring_filters'] == 1) ?>
|
||||||
|
<p class="form-help"><?= t('Adds recurrence bookmarks. Also a recurring:XY search keyword -- X = evt/cal/any (native/calendar/both), Y = ori/dup/all (template/duplicate/both). Example: recurring:evtori.') ?></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend><?= t('Card interaction') ?></legend>
|
||||||
|
|
||||||
|
<?= $this->form->checkbox('organon_tweaks_quick_click_only', t('Open a card only on a quick click'), 1, isset($values['organon_tweaks_quick_click_only']) && $values['organon_tweaks_quick_click_only'] == 1) ?>
|
||||||
|
<p class="form-help"><?= t('A press held longer than 120 ms is treated as a drag, so releasing it does not open the card. Fixes a drag sometimes opening the card on release.') ?></p>
|
||||||
|
|
||||||
|
<?= $this->form->checkbox('organon_tweaks_shortcut_labels', t('Show keyboard shortcuts on the task menu'), 1, isset($values['organon_tweaks_shortcut_labels']) && $values['organon_tweaks_shortcut_labels'] == 1) ?>
|
||||||
|
<p class="form-help"><?= t('Adds the existing key hints (e, s, c, l) next to the task Actions items.') ?></p>
|
||||||
|
|
||||||
|
<?= $this->form->checkbox('organon_tweaks_title_click_edit', t('Clicking a task title opens the edit form'), 1, isset($values['organon_tweaks_title_click_edit']) && $values['organon_tweaks_title_click_edit'] == 1) ?>
|
||||||
|
<p class="form-help"><?= t('On the board and the card view, click the title to open the edit modal (only where you may edit).') ?></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend><?= t('Board columns') ?></legend>
|
||||||
|
|
||||||
|
<?= $this->form->checkbox('organon_tweaks_persistent_sort', t('Per-column persistent sort'), 1, isset($values['organon_tweaks_persistent_sort']) && $values['organon_tweaks_persistent_sort'] == 1) ?>
|
||||||
|
<p class="form-help"><?= t('Replaces the native column sort with a control that adds a number-aware Title sort and a "Persistent sort: ON/OFF" toggle. When ON, the column is re-sorted whenever a card is dropped in or created, so it stays sorted (a red up/down arrow marks it). OFF is the native one-shot sort.') ?></p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
|
|||||||
3
Template/layout/emphasize_duedate.php
Normal file
3
Template/layout/emphasize_duedate.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php if ($this->OrganonProjectHelper->isEmphasizeDueDate()): ?>
|
||||||
|
<style>.task-date { font-size: 1.375em; font-weight: bold; }</style>
|
||||||
|
<?php endif ?>
|
||||||
25
Template/project/integration.php
Normal file
25
Template/project/integration.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php $choice = isset($values['organon_tweaks_emphasize_duedate']) && in_array($values['organon_tweaks_emphasize_duedate'], array('on', 'off'), true) ? $values['organon_tweaks_emphasize_duedate'] : 'default' ?>
|
||||||
|
|
||||||
|
<h3><i class="fa fa-umbrella fa-fw" aria-hidden="true"></i> <?= t('OrganonTweaks') ?></h3>
|
||||||
|
<div class="listing">
|
||||||
|
<p><strong><?= t('Emphasize due date on the board') ?></strong></p>
|
||||||
|
|
||||||
|
<label style="display:inline-block; margin-right:16px;">
|
||||||
|
<input type="radio" name="organon_tweaks_emphasize_duedate" value="default" <?= $choice === 'default' ? 'checked="checked"' : '' ?>> <?= t('Use default') ?>
|
||||||
|
</label>
|
||||||
|
<label style="display:inline-block; margin-right:16px;">
|
||||||
|
<input type="radio" name="organon_tweaks_emphasize_duedate" value="on" <?= $choice === 'on' ? 'checked="checked"' : '' ?>> <?= t('Always on') ?>
|
||||||
|
</label>
|
||||||
|
<label style="display:inline-block; margin-right:16px;">
|
||||||
|
<input type="radio" name="organon_tweaks_emphasize_duedate" value="off" <?= $choice === 'off' ? 'checked="checked"' : '' ?>> <?= t('Always off') ?>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p class="form-help">
|
||||||
|
<?= t('Enlarge and bold the due date on this board. "Use default" follows the global OrganonTweaks setting.') ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-blue"><?= t('Save') ?></button>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
1
Template/project_header/board_filters.php
Normal file
1
Template/project_header/board_filters.php
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?php $this->OrganonProjectHelper->ensureBoardFilters($project['id']) ?>
|
||||||
Reference in New Issue
Block a user