44 Commits

Author SHA1 Message Date
ddbd4c42a6 New tweak JS for clickable gear subtasks bugfix v2.3.0 2026-08-04 10:57:10 -03:00
ca57a4044e TODO/DONE auto-mark done when all subtasks done 2026-07-20 17:35:20 -03:00
cc397e9982 restore vertical axes of scroll bars 2026-07-20 10:13:06 -03:00
8136c01403 Todo/Done menu mark all in this column 2026-07-18 22:43:41 -03:00
c88e52ab01 Position and bugfixes of the done/due checkbox 2026-07-18 21:49:15 -03:00
f2f690ef98 The [x] Done / [ ] Due checkbox v2.0 2026-07-18 21:24:36 -03:00
8be9442cb7 title sort via accent-stripped dashed slug (fixes names, keeps numbers) (v1.9.1) 2026-07-13 14:38:31 -03:00
f9a7637e44 OrganonTweaks: per-column persistent sort -- number-aware title sort + sticky toggle (v1.9.0) 2026-07-13 10:32:04 -03:00
d079198103 feature : persistent sort 2026-07-13 10:25:28 -03:00
7ba4348de1 README ASCII 2026-07-13 08:56:18 -03:00
019b08084b README: More-plugins section -> Dr. Beco heading + full sibling list (v1.8.5) 2026-07-13 08:49:00 -03:00
2f78aa2087 separate the Integrations section with a trailing rule (v1.8.4) 2026-07-11 15:42:36 -03:00
8057272b9c document card-interaction tweaks in README (v1.8.3) 2026-07-10 18:35:42 -03:00
dc22739647 v1.8.2 admin global settings groupped 2026-07-10 18:30:34 -03:00
2d3c716852 v1.8.1 stage D button save & close unnecessary bugfix 2026-07-10 18:26:44 -03:00
269964d4cc add Save & Close button on the task edit form v1.8 t2 2026-07-10 18:18:26 -03:00
ac8e3b9544 add Save & Close button on the task edit form v1.8 2026-07-10 18:13:19 -03:00
717095848b card-view title click opens the edit modal 2026-07-10 18:09:06 -03:00
a905079abc board card title click opens the edit modal 2026-07-10 18:06:24 -03:00
e032d5d2b4 show existing keyboard-shortcut hints on the task Actions menu 2026-07-10 17:56:55 -03:00
b63d366296 add cross-promo plugin section to READMEs; bump patch (v*.*.+1) 2026-07-10 09:15:36 -03:00
89888235d1 custom filter renames for better grouping v1.7.1 2026-07-09 07:54:12 -03:00
4ebebd5ffb recurring:XY help text explains keywords X={evt,cal,any} Y={ori,dup,all} 2026-07-09 07:44:00 -03:00
9b637160b2 recurring:XY search keyword + auto-added recurring filter bookmarks v1.7 2026-07-09 07:40:52 -03:00
e85d5d71f9 settings page optimized 2026-07-09 07:13:11 -03:00
ce3205646f group card-readability + filter settings, trim help texts, 120ms click-guard 2026-07-09 07:08:38 -03:00
4ce18d9e5f cleaning help text in global OrganonTweaks settings page 2026-07-09 07:03:34 -03:00
4d7f88aa94 v1.6 duemonth/aroundmonth search keywords + auto-added month filter bookmarks 2026-07-09 06:49:15 -03:00
bc266ff969 auto-managed shared "Show all tasks" filter on every board + config toggle v1.5 2026-07-08 21:09:30 -03:00
8a97c5cb94 fix Always-off (on/off/default safe values) + bump emphasis to 1.375em (v1.4.2) 2026-07-08 13:03:35 -03:00
d1178bc463 fix stuck enhance-recurrence toggle + clarify label (v1.4.1) 2026-07-08 09:13:07 -03:00
c747d33491 per-board "Emphasize due date" with global default (v1.4) 2026-07-08 09:00:50 -03:00
850127e712 increase click-guard.js to 110ms differentiate drag/click t3 try remove cached js first 2026-07-07 22:05:19 -03:00
70f42352b0 increase click-guard.js to 120ms differentiate drag/click t2 2026-07-07 22:04:14 -03:00
d57544ebfd increase Asset/js/click-guard.js 100ms to 110ms differentiate drag/click v1.3.3 2026-07-07 22:02:34 -03:00
c3896e1962 enhance-recurrence radio button only on display bugfix v1.3.2 2026-07-07 09:51:38 -03:00
065825269e v1.3.1 bump 2026-07-07 09:45:17 -03:00
6726dfa456 enhance recurrence dialog yes/no side-by-side v1.3.1 2026-07-07 09:43:54 -03:00
17ebfcccd9 OrganonTweaks: enhance recurrence dialog -- Yes/No radios + [+7] factor button (JS); added asset , v1.3.0 2026-07-07 09:41:15 -03:00
95561e5565 OrganonTweaks: enhance recurrence dialog -- Yes/No radios + [+7] factor button (JS); v1.3.0 2026-07-07 09:40:51 -03:00
ff6abf1d49 fix comment "+" position: anchor on description span (tooltipLink), not <a>, so it sits left; v1.2.1 2026-07-07 09:19:50 -03:00
310c34620f OrganonTweaks: swallow post-drag click so a held press (>100ms) never opens the card; v1.2 2026-07-06 22:27:33 -03:00
a5721890a6 harden relocate.js: observe stable parent so items re-relocate after #board-container rebuild; v1.1 2026-07-06 21:19:06 -03:00
9c27b51eed keep board horizontal scroll across refresh: restore scrollLeft after #board-container rebuild; v1.0 2026-07-06 21:12:26 -03:00
29 changed files with 2072 additions and 20 deletions

35
Asset/js/click-guard.js Normal file
View File

@@ -0,0 +1,35 @@
/*
* OrganonTweaks -- only open a card on a quick click.
*
* A real click is a quick press-release; a drag is a press, some movement, and a release
* much later. When you drop a card, jQuery UI can leak a trailing "click" on it, and
* Kanboard opens the card -- an unwanted side effect of the drag.
*
* We simply time the press on a card: record the moment of mousedown / touchstart, and on
* the click that follows, if more than THRESHOLD ms elapsed the press was a drag (held),
* not a click, so we swallow the click in the capture phase before Kanboard's open handler
* runs. A normal click is well under the threshold and opens the card as usual.
*/
(function () {
"use strict";
var THRESHOLD = 120; // ms; a press on a card held longer than this is a drag, not a click
var pressStart = 0;
function onDown(e) {
pressStart = (e.target && e.target.closest && e.target.closest(".task-board")) ? Date.now() : 0;
}
document.addEventListener("mousedown", onDown, true);
document.addEventListener("touchstart", onDown, true);
document.addEventListener("click", function (e) {
if (pressStart &&
e.target && e.target.closest && e.target.closest(".task-board") &&
(Date.now() - pressStart) > THRESHOLD) {
e.stopImmediatePropagation();
e.preventDefault();
}
pressStart = 0;
}, true);
})();

View File

@@ -0,0 +1,105 @@
/*
* OrganonTweaks -- enhance Kanboard's native "Edit recurrence" dialog.
*
* That template (task_recurrence/edit.php) has no hook, so we transform the form in JS when
* the modal opens:
* 1. Replace the "Generate recurrent task" Yes/No dropdown with two radio buttons (one
* click instead of open + select). The original <select> is kept, hidden, and stays in
* sync, so the form still submits recurrence_status normally.
* 2. Add a small [+7] button next to the "Factor" input that adds 7 (a week of days).
*
* Detection: the modal is injected into #modal-box dynamically, so we watch the document for
* an un-enhanced recurrence form appearing. Loaded only when the setting is enabled.
*/
(function () {
"use strict";
function enhanceStatus(select) {
var group = document.createElement("span");
group.className = "ot-recurrence-status";
var radios = [];
for (var i = 0; i < select.options.length; i++) {
var opt = select.options[i];
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.cursor = "pointer";
var radio = document.createElement("input");
radio.type = "radio";
// 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.checked = opt.value === select.value;
radio.style.marginRight = "4px";
radios.push(radio);
radio.addEventListener("change", function () {
for (var k = 0; k < radios.length; k++) {
radios[k].checked = (radios[k] === this);
}
select.value = this.value;
select.dispatchEvent(new Event("change", { bubbles: true }));
});
label.appendChild(radio);
label.appendChild(document.createTextNode(opt.text));
group.appendChild(label);
}
select.style.display = "none";
select.parentNode.insertBefore(group, select.nextSibling);
}
function enhanceFactor(input) {
var btn = document.createElement("button");
btn.type = "button";
btn.className = "btn";
btn.textContent = "+7";
btn.title = "Add 7 (one week of days)";
btn.style.marginLeft = "6px";
btn.addEventListener("click", function () {
input.value = (parseInt(input.value, 10) || 0) + 7;
input.dispatchEvent(new Event("change", { bubbles: true }));
});
input.parentNode.insertBefore(btn, input.nextSibling);
}
function enhance() {
var select = document.querySelector('select[name="recurrence_status"]:not([data-ot-recurrence])');
if (!select) {
return;
}
select.setAttribute("data-ot-recurrence", "1");
enhanceStatus(select);
var factor = document.querySelector('input[name="recurrence_factor"]:not([data-ot-recurrence])');
if (factor) {
factor.setAttribute("data-ot-recurrence", "1");
enhanceFactor(factor);
}
}
function init() {
enhance();
if (window.MutationObserver) {
new MutationObserver(enhance).observe(document.body, { childList: true, subtree: true });
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

176
Asset/js/keep-scroll.js Normal file
View File

@@ -0,0 +1,176 @@
/*
* OrganonTweaks -- keep the board's scroll position (horizontal AND vertical) across board AJAX
* rebuilds and full page reloads.
*
* Two events reset the scroll and jump the board around:
* 1. AJAX rebuild -- dropping a card / periodic polling replaces #board-container
* (BoardDragAndDrop.refresh); the new element starts at scroll 0.
* 2. Full page reload -- e.g. clicking the Todo/Done badge or the "Mark all" action, which
* navigate and redirect back to the board; a fresh page starts at scroll 0.
*
* Axes restored (all persisted per board in sessionStorage, so they survive a full reload):
* - horizontal: #board-container.scrollLeft (key :hx)
* - page vertical (expanded mode): window.scrollY (key :vy)
* - per-column vertical (compact mode): each native .board-task-list-compact scrollTop, keyed by
* its data-swimlane-id + data-column-id (key :vcol:<sw>:<col>)
*
* The per-column container is native (core board.css: .board-task-list-compact { overflow-y:auto });
* ShrinkVertically only re-tunes its max-height, so this works with or without that plugin. Doing all
* axes every time is safe: in expanded mode there are no compact lists (that loop is a no-op) and the
* page owns the vertical scroll; in compact mode the columns own it and the page barely moves.
*/
(function () {
"use strict";
function baseKey() {
var m = location.href.match(/board\/(\d+)/) || location.href.match(/project_id=(\d+)/);
return "organon-board-scroll-" + (m ? m[1] : location.pathname);
}
function readNum(suffix) {
try {
var v = window.sessionStorage.getItem(baseKey() + suffix);
return v !== null ? parseInt(v, 10) : null;
} catch (e) {
return null;
}
}
function writeNum(suffix, value) {
try {
window.sessionStorage.setItem(baseKey() + suffix, value);
} catch (e) {
// sessionStorage unavailable (private mode / disabled) -- degrade to AJAX-only restore.
}
}
// While we restore programmatically, the browser fires scroll events; suppress the save briefly so
// a not-yet-scrollable column (whose scrollTop stays 0) does not overwrite the stored position.
var suppress = 0;
function suppressBriefly() {
suppress++;
window.setTimeout(function () {
if (suppress > 0) {
suppress--;
}
}, 150);
}
function colSuffix(el) {
return ":vcol:" + (el.getAttribute("data-swimlane-id") || "0") + ":" + (el.getAttribute("data-column-id") || "0");
}
// Restore the vertical axes. Called on load, again on the next frame (after ShrinkVertically has
// settled the column heights), and on every board rebuild.
function restoreVertical() {
var y = readNum(":vy");
if (y !== null && Math.round(window.scrollY) !== y) {
suppressBriefly();
window.scrollTo(window.scrollX, y);
}
var lists = document.querySelectorAll("#board .board-task-list-compact");
for (var i = 0; i < lists.length; i++) {
var el = lists[i];
var s = readNum(colSuffix(el));
if (s !== null && el.scrollTop !== s) {
suppressBriefly();
el.scrollTop = s;
}
}
}
// Core adds the .board-task-list-compact class via JS AFTER load (BoardVerticalScrolling.render),
// and re-adds it on every board render -- so the per-column scroll containers often do not exist
// yet when we first restore (this is why compact restore worked on F5, where the browser restores
// element scroll natively, but not after a badge/redirect navigation). Re-run the vertical restore
// whenever the board mutates or a class changes, rAF-coalesced. Idempotent (only sets scrollTop on
// a mismatch), so it lands once the compact lists appear and never fights the user afterwards.
var restoreScheduled = false;
function scheduleRestore() {
if (restoreScheduled) {
return;
}
restoreScheduled = true;
var run = function () {
restoreScheduled = false;
restoreVertical();
};
if (window.requestAnimationFrame) {
window.requestAnimationFrame(run);
} else {
window.setTimeout(run, 16);
}
}
function init() {
var container = document.getElementById("board-container");
if (!container) {
return;
}
var parent = container.parentNode;
// --- horizontal ---
var storedX = readNum(":hx");
var lastScrollLeft = storedX !== null ? storedX : container.scrollLeft;
var lastContainer = container;
if (storedX !== null && container.scrollLeft !== storedX) {
suppressBriefly();
container.scrollLeft = storedX;
}
// --- vertical (page + per-column), now and again after layout settles ---
restoreVertical();
if (window.requestAnimationFrame) {
window.requestAnimationFrame(restoreVertical);
}
// Save on scroll. Capture phase: scroll does not bubble, but a capture listener on document
// still receives it from #board-container and from any per-column list.
document.addEventListener("scroll", function (e) {
if (suppress > 0) {
return;
}
var t = e.target;
var c = document.getElementById("board-container");
if (t === c) {
lastScrollLeft = c.scrollLeft;
writeNum(":hx", c.scrollLeft);
} else if (t && t.nodeType === 1 && t.classList && t.classList.contains("board-task-list-compact")) {
writeNum(colSuffix(t), t.scrollTop);
}
}, true);
// Page vertical scroll targets the document, so listen on window.
window.addEventListener("scroll", function () {
if (suppress === 0) {
writeNum(":vy", Math.round(window.scrollY));
}
});
// Observe the STABLE parent for: (a) #board-container being replaced on an AJAX rebuild
// (childList) -> restore horizontal; and (b) class changes anywhere below (attributes) -> the
// moment core adds .board-task-list-compact, restore the per-column verticals. subtree covers
// the deep .board-task-list elements. Both funnel into the rAF-coalesced scheduleRestore.
if (window.MutationObserver) {
new MutationObserver(function () {
var c = document.getElementById("board-container");
if (c && c !== lastContainer) {
lastContainer = c;
if (c.scrollLeft !== lastScrollLeft) {
suppressBriefly();
c.scrollLeft = lastScrollLeft;
}
}
scheduleRestore();
}).observe(parent, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View File

@@ -18,7 +18,7 @@
"use strict"; "use strict";
function relocateColumnMenuItems() { function relocateColumnMenuItems() {
var items = document.querySelectorAll(".organontweaks-remove-column-item"); var items = document.querySelectorAll(".organontweaks-remove-column-item, .organontweaks-markall-item");
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
var li = items[i]; var li = items[i];
@@ -53,40 +53,64 @@
// Native comment icon sits just before the description icon; if there is no // Native comment icon sits just before the description icon; if there is no
// description, before the age / closed block. That is the slot we move into. // description, before the age / closed block. That is the slot we move into.
var anchor = null; // NB: the description icon is wrapped in <span class="tooltip"> (Kanboard's
var descIcon = row.querySelector("i.fa-file-text-o"); // tooltipLink), not an <a>, so we walk up to whatever is the row's direct child.
var anchor = row.querySelector("i.fa-file-text-o") ||
row.querySelector(".task-icon-age") ||
row.querySelector(".task-board-closed");
if (descIcon) { while (anchor && anchor.parentNode !== row) {
anchor = descIcon.closest("a"); anchor = anchor.parentNode;
}
if (!anchor) {
anchor = row.querySelector(".task-icon-age") || row.querySelector(".task-board-closed");
} }
// Move only if needed (keeps the MutationObserver from looping). // Move only if needed (keeps the MutationObserver from looping).
if (anchor && anchor.parentNode === row && item.nextElementSibling !== anchor) { if (anchor && anchor !== item && item.nextElementSibling !== anchor) {
row.insertBefore(item, anchor); row.insertBefore(item, anchor);
} }
} }
} }
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() {
if (!document.getElementById("board")) { var container = document.getElementById("board-container");
if (!container) {
return; return;
} }
apply(); apply();
var container = document.getElementById("board-container"); // Observe the STABLE parent, not #board-container: Kanboard replaces the container
// on every card drop / AJAX poll (BoardDragAndDrop.refresh), which would kill an
// observer bound to the container itself and leave the items un-relocated after a
// move. The parent survives, so apply() keeps running on every rebuild.
var parent = container.parentNode;
if (container && window.MutationObserver) { if (parent && window.MutationObserver) {
new MutationObserver(apply).observe(container, { childList: true, subtree: true }); new MutationObserver(apply).observe(parent, { childList: true, subtree: true });
} }
} }

View 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);
}
})();

View File

@@ -0,0 +1,57 @@
/*
* OrganonTweaks -- fix a core Kanboard touch bug on reorder tables.
*
* Kanboard inits the subtask / board-column / swimlane reorder sortables with
* handle:"td:first i", which matches EVERY <i> in a row's first cell -- so the gear/caret menu
* icons and the subtask status checkbox become drag handles too. On touch devices the bundled
* jQuery UI Touch Punch then captures the press on those icons, preventDefault()s the native
* tap-click, and only re-fires a click if the finger did not move at all -- so almost every real
* tap is swallowed and the menu never opens (the gear "drags" instead of opening).
*
* Fix: re-scope the handle option to the real drag icon (.draggable-row-handle) on every such
* sortable, so only the arrows drag and the other first-cell icons are plain clicks again.
* jQuery UI reads options.handle at press time, so this takes effect immediately, no re-init.
* Kanboard re-inits the sortable whenever it re-renders a table (subtask add/edit, column
* reorder, and so on), which re-applies the bad handle -- so we re-apply the fix after every
* render via a debounced MutationObserver. Always on (no setting). Harmless on desktop, where
* the mouse never takes the Touch Punch path.
*/
(function () {
"use strict";
var $j = window.jQuery;
if (! $j) {
return;
}
function fixHandles() {
$j(".ui-sortable").each(function () {
try {
var $s = $j(this);
if ($s.sortable("option", "handle") === "td:first i") {
$s.sortable("option", "handle", ".draggable-row-handle");
}
} catch (e) {
// element is not an initialized sortable -- skip it
}
});
}
var pending = false;
function schedule() {
if (pending) {
return;
}
pending = true;
window.setTimeout(function () {
pending = false;
fixHandles();
}, 0);
}
$j(fixHandles); // initial pass on DOM ready
new MutationObserver(schedule).observe(document.body, {
childList: true,
subtree: true
});
})();

View 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);
}
})();

View File

@@ -15,6 +15,19 @@ class ConfigController extends BaseController
'title' => t('Settings').' &gt; '.t('Organon Tweaks'), 'title' => t('Settings').' &gt; '.t('Organon Tweaks'),
'values' => array( 'values' => array(
'organon_tweaks_always_comment_icon' => (int) $this->configModel->get('organon_tweaks_always_comment_icon', 1), 'organon_tweaks_always_comment_icon' => (int) $this->configModel->get('organon_tweaks_always_comment_icon', 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_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),
'organon_tweaks_done_badge' => (int) $this->configModel->get('organon_tweaks_done_badge', 0),
'organon_tweaks_done_closes_task' => (int) $this->configModel->get('organon_tweaks_done_closes_task', 0),
'organon_tweaks_done_autosubtasks' => (int) $this->configModel->get('organon_tweaks_done_autosubtasks', 0),
), ),
'errors' => array(), 'errors' => array(),
))); )));
@@ -25,13 +38,63 @@ class ConfigController extends BaseController
$values = $this->request->getValues(); $values = $this->request->getValues();
$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;
$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;
$doneBadge = isset($values['organon_tweaks_done_badge']) ? 1 : 0;
$doneClosesTask = isset($values['organon_tweaks_done_closes_task']) ? 1 : 0;
$doneAutoSubtasks = isset($values['organon_tweaks_done_autosubtasks']) ? 1 : 0;
if ($this->configModel->save(array('organon_tweaks_always_comment_icon' => $alwaysCommentIcon))) { if ($this->configModel->save(array(
'organon_tweaks_always_comment_icon' => $alwaysCommentIcon,
'organon_tweaks_keep_scroll' => $keepScroll,
'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,
'organon_tweaks_done_badge' => $doneBadge,
'organon_tweaks_done_closes_task' => $doneClosesTask,
'organon_tweaks_done_autosubtasks' => $doneAutoSubtasks,
))) {
$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')));
} }
} }

View File

@@ -0,0 +1,118 @@
<?php
namespace Kanboard\Plugin\OrganonTweaks\Controller;
use Kanboard\Controller\BaseController;
use Kanboard\Core\Controller\AccessForbiddenException;
use Kanboard\Model\TaskModel;
use Kanboard\Plugin\OrganonTweaks\Helper\OrganonDoneHelper;
/**
* Toggle a task's Done/Todo badge (OrganonTweaks).
*
* One-click CSRF link from the board card face and the task view. Single source of truth per mode:
* - close-mode on: Done == closed, so this just closes/opens the task (no metadata); native
* Close/Open stay in sync automatically because the badge reads is_active.
* - close-mode off: this flips the 'organon_done' metadata marker and never touches open/closed.
* Redirects back to wherever it was clicked (board or task view).
*
* Also provides the per-column bulk action (confirmColumn opens a two-button modal; markColumn sets
* EVERY task in the column+swimlane to the chosen state -- overwrite, not a per-task toggle).
*/
class DoneController extends BaseController
{
public function toggle()
{
$task = $this->getTask();
$this->checkCSRFParam();
$helper = new OrganonDoneHelper($this->container);
$done = $helper->isDone($task);
if ($helper->closesTask()) {
if ($done) {
$this->taskStatusModel->open($task['id']);
} else {
$this->taskStatusModel->close($task['id']);
}
} else {
if ($done) {
$this->taskMetadataModel->remove($task['id'], OrganonDoneHelper::DONE_KEY);
} else {
$this->taskMetadataModel->save($task['id'], array(OrganonDoneHelper::DONE_KEY => 'on'));
}
}
if ($this->request->getStringParam('from') === 'board') {
$this->response->redirect($this->helper->url->to('BoardViewController', 'show', array('project_id' => $task['project_id'])), true);
} else {
$this->response->redirect($this->helper->url->to('TaskViewController', 'show', array('task_id' => $task['id'])), true);
}
}
/**
* Open the "Mark all as Done/Todo" modal for a column: one modal that both confirms and lets the
* user pick the direction (two buttons). No state is tracked -- the direction is chosen here.
*/
public function confirmColumn()
{
$project = $this->getProject();
$this->checkColumnWriteAccess($project['id']);
$this->response->html($this->template->render('organonTweaks:board/mark_all_confirm', array(
'project_id' => $project['id'],
'column_id' => $this->request->getIntegerParam('column_id'),
'swimlane_id' => $this->request->getIntegerParam('swimlane_id'),
)));
}
/**
* Set EVERY task in the column+swimlane to Done or Todo (direction param). Overwrites uniformly --
* already-done tasks are harmlessly re-set. Mode-aware, like the badge: close-mode closes/opens
* all; marker-mode sets/clears the metadata on all.
*/
public function markColumn()
{
$project = $this->getProject();
$this->checkColumnWriteAccess($project['id']);
$this->checkCSRFParam();
$column_id = $this->request->getIntegerParam('column_id');
$swimlane_id = $this->request->getIntegerParam('swimlane_id');
$done = $this->request->getStringParam('direction') === 'done';
$helper = new OrganonDoneHelper($this->container);
$closes = $helper->closesTask();
$tasks = $this->db->table(TaskModel::TABLE)
->columns('id')
->eq('project_id', $project['id'])
->eq('column_id', $column_id)
->eq('swimlane_id', $swimlane_id)
->findAll();
foreach ($tasks as $task) {
if ($closes) {
$done ? $this->taskStatusModel->close($task['id']) : $this->taskStatusModel->open($task['id']);
} elseif ($done) {
$this->taskMetadataModel->save($task['id'], array(OrganonDoneHelper::DONE_KEY => 'on'));
} else {
$this->taskMetadataModel->remove($task['id'], OrganonDoneHelper::DONE_KEY);
}
}
$this->flash->success(t('All tasks updated.'));
$this->response->redirect($this->helper->url->to('BoardViewController', 'show', array('project_id' => $project['id'])), true);
}
/**
* Guard the bulk action to users who may modify tasks on this board (the menu item is already
* gated the same way; this backstops a direct request).
*/
private function checkColumnWriteAccess($project_id)
{
if (! $this->helper->user->hasProjectAccess('TaskModificationController', 'update', $project_id)) {
throw new AccessForbiddenException();
}
}
}

View 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);
}
}

View 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", ...
}
}

View 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);
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace Kanboard\Plugin\OrganonTweaks\Helper;
use Kanboard\Core\Base;
use Kanboard\Model\SubtaskModel;
/**
* Done/Todo badge helper.
*
* "Done" has a single source of truth PER MODE (no drift with native Close/Open):
* - close-mode on ("check done also closes tasks"): Done == the task is CLOSED (is_active == 0).
* The badge reads the task's own is_active, so however a task is closed/reopened (badge, native
* sidebar, bulk, API) the badge always matches. No metadata is stored in this mode.
* - close-mode off (marker only): Done == the task metadata DONE_KEY == 'on', independent of the
* open/closed status. Stored as the non-falsy string 'on' (never '0'/'1'): MetadataModel::get()
* uses `?:` and PHP treats "0" as falsy, so a stored "0" would read back as the default.
*
* Templates cannot read models directly, so the board/task-view badge templates call isDone() here,
* passing the task array (which carries both id and is_active) so the close-mode read needs no query.
*/
class OrganonDoneHelper extends Base
{
const DONE_KEY = 'organon_done';
const CLOSES_KEY = 'organon_tweaks_done_closes_task';
const AUTOSUB_KEY = 'organon_tweaks_done_autosubtasks';
const ALLDONE_KEY = 'organon_subtasks_alldone';
/**
* Is this task Done? (mode-aware: closed status in close-mode, else the metadata marker)
*
* @param array $task a task array carrying 'id' and 'is_active'
* @return bool
*/
public function isDone(array $task)
{
if ($this->closesTask()) {
return isset($task['is_active']) && (int) $task['is_active'] === 0;
}
return $this->taskMetadataModel->get((int) $task['id'], self::DONE_KEY, '') === 'on';
}
/**
* Should toggling Done also close (and reopen) the task? Global setting, default off.
*
* @return bool
*/
public function closesTask()
{
return (int) $this->configModel->get(self::CLOSES_KEY, 0) === 1;
}
/**
* Mark a task Done in the current mode. Idempotent -- a no-op if it is already Done. Close-mode
* closes the task; marker-mode sets the DONE_KEY metadata.
*
* @param int $task_id
*/
public function markDone($task_id)
{
$task = $this->taskFinderModel->getById((int) $task_id);
if (empty($task) || $this->isDone($task)) {
return;
}
if ($this->closesTask()) {
$this->taskStatusModel->close((int) $task_id);
} else {
$this->taskMetadataModel->save((int) $task_id, array(self::DONE_KEY => 'on'));
}
}
/**
* Recompute whether the task's subtasks are ALL done and, only on the up-transition
* (was-not-all-done -> now-all-done), auto-mark the task Done. The last-seen all-done state is kept
* in ALLDONE_KEY ('on'/'off') to detect that edge -- so a manual unmark is honored (nothing
* re-fires) and title edits / refreshes never re-mark. Never auto-unmarks.
*
* @param int $task_id
* @param int $exclude_id a subtask id to exclude from the counts (the row being deleted --
* EVENT_DELETE fires BEFORE the row is removed, so it still counts)
*/
public function syncSubtasksDone($task_id, $exclude_id = 0)
{
$task_id = (int) $task_id;
$exclude_id = (int) $exclude_id;
$totalQuery = $this->db->table(SubtaskModel::TABLE)->eq('task_id', $task_id);
$doneQuery = $this->db->table(SubtaskModel::TABLE)->eq('task_id', $task_id)->eq('status', SubtaskModel::STATUS_DONE);
if ($exclude_id > 0) {
$totalQuery->neq('id', $exclude_id);
$doneQuery->neq('id', $exclude_id);
}
$total = $totalQuery->count();
$done = $doneQuery->count();
$allDone = $total > 0 && $done === $total;
$prev = $this->taskMetadataModel->get($task_id, self::ALLDONE_KEY, 'off') === 'on';
// Save the new state BEFORE marking: in close-mode markDone() -> close() -> closeAll() re-fires
// subtask events into this method; with the marker already 'on', that re-entry sees no
// transition and is a no-op (no loop).
$this->taskMetadataModel->save($task_id, array(self::ALLDONE_KEY => $allDone ? 'on' : 'off'));
if ($allDone && ! $prev) {
$this->markDone($task_id);
}
}
}

View 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();
}
}

View 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;
}
}

View File

@@ -3,6 +3,8 @@
namespace Kanboard\Plugin\OrganonTweaks; namespace Kanboard\Plugin\OrganonTweaks;
use Kanboard\Core\Plugin\Base; use Kanboard\Core\Plugin\Base;
use Kanboard\Model\SubtaskModel;
use Kanboard\Model\TaskModel;
class Plugin extends Base class Plugin extends Base
{ {
@@ -17,21 +19,174 @@ class Plugin extends Base
'template' => 'plugins/OrganonTweaks/Asset/js/relocate.js', 'template' => 'plugins/OrganonTweaks/Asset/js/relocate.js',
)); ));
// Fix a core Kanboard touch bug: the subtask / board-column / swimlane reorder sortables
// use handle:"td:first i", which makes the gear/caret menu icons and the subtask status
// checkbox (all <i> in the first cell) drag handles too, so on touch devices Touch Punch
// swallows their taps -- the gear "drags" instead of opening. Re-scope the handle to the
// real drag icon (.draggable-row-handle) in JS. Always on; harmless on desktop.
$this->hook->on('template:layout:js', array(
'template' => 'plugins/OrganonTweaks/Asset/js/sortable-handle-fix.js',
));
// 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.
if ((int) $this->configModel->get('organon_tweaks_always_comment_icon', 1) === 1) { if ((int) $this->configModel->get('organon_tweaks_always_comment_icon', 1) === 1) {
$this->template->hook->attach('template:board:task:icons', 'organonTweaks:board/task_comment_icon'); $this->template->hook->attach('template:board:task:icons', 'organonTweaks:board/task_comment_icon');
} }
// Tweak: keep the board horizontal scroll position across refreshes (Kanboard
// rebuilds #board-container on card drop / polling, resetting scrollLeft to 0).
if ((int) $this->configModel->get('organon_tweaks_keep_scroll', 1) === 1) {
$this->hook->on('template:layout:js', array(
'template' => 'plugins/OrganonTweaks/Asset/js/keep-scroll.js',
));
}
// Tweak: only open a card on a quick click (a press held > 100ms is a drag, so the
// trailing click is swallowed and the card does not open).
if ((int) $this->configModel->get('organon_tweaks_quick_click_only', 1) === 1) {
$this->hook->on('template:layout:js', array(
'template' => 'plugins/OrganonTweaks/Asset/js/click-guard.js',
));
}
// Tweak: enhance the native "Edit recurrence" dialog (Yes/No radios + a [+7] button
// on the factor). No template hook there, so it is done in JS.
if ((int) $this->configModel->get('organon_tweaks_enhance_recurrence', 1) === 1) {
$this->hook->on('template:layout:js', array(
'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']);
}
});
}
// Tweak: a Done/Todo status badge on the card face (top-right, below the header) and in the
// task view (4th column, near the due date). Two-state toggle; single source of truth per
// mode -- close-mode ON => Done == closed (reads is_active, so native Close/Open stay in
// sync); close-mode OFF => a task-metadata marker (organon_done). Server-rendered, no JS.
if ((int) $this->configModel->get('organon_tweaks_done_badge', 0) === 1) {
$this->template->hook->attach('template:board:private:task:before-title', 'organonTweaks:board/done_badge');
$this->template->hook->attach('template:task:details:fourth-column', 'organonTweaks:task/done_badge');
$this->template->hook->attach('template:layout:head', 'organonTweaks:layout/done_style');
// Bulk "Mark all as Done/Todo" entry in the column header dropdown (moved into the native
// menu by relocate.js). Opens a two-button modal that confirms + picks the direction.
$this->template->hook->attach('template:board:column:dropdown', 'organonTweaks:board/mark_all_item');
// Sub-tweak (opt-in): auto-mark a card Done when its LAST subtask is completed. On every
// subtask change, recompute all-done and mark Done only on the up-transition
// (state-comparison in the helper), so a manual unmark is honored -- nothing re-fires.
// Dispatcher direct (the plugin on() wrapper drops the event). DELETE fires before the row
// is removed, so its id is excluded from the recount.
if ((int) $this->configModel->get('organon_tweaks_done_autosubtasks', 0) === 1) {
$container = $this->container;
$sync = function ($task_id, $exclude_id = 0) use ($container) {
$helper = new \Kanboard\Plugin\OrganonTweaks\Helper\OrganonDoneHelper($container);
$helper->syncSubtasksDone((int) $task_id, (int) $exclude_id);
};
$this->dispatcher->addListener(SubtaskModel::EVENT_UPDATE, function ($event) use ($sync) {
$sync($event['subtask']['task_id']);
});
$this->dispatcher->addListener(SubtaskModel::EVENT_CREATE, function ($event) use ($sync) {
$sync($event['subtask']['task_id']);
});
$this->dispatcher->addListener(SubtaskModel::EVENT_DELETE, function ($event) use ($sync) {
$sync($event['subtask']['task_id'], $event['subtask']['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', 'OrganonDoneHelper'),
); );
} }
@@ -52,7 +207,7 @@ class Plugin extends Base
public function getPluginVersion() public function getPluginVersion()
{ {
return '0.3.1'; return '2.3.0';
} }
public function getPluginHomepage() public function getPluginHomepage()

188
README.md
View File

@@ -35,11 +35,178 @@ have comments keep showing the count as before.
read-only viewers). read-only viewers).
- **On by default.** Toggle it under "Settings -> Organon Tweaks". - **On by default.** Toggle it under "Settings -> Organon Tweaks".
### Keep the board scroll position across refreshes
When you drop a card (or Kanboard auto-refreshes the board via polling), it rebuilds the
board with `$("#board-container").replaceWith(...)`, and the new element starts scrolled to
the far left -- so the view jumps back to the first column. A **full page reload** (for
example clicking the Todo/Done badge or the "Mark all" action, which navigate and redirect back to
the board) does the same. This tweak remembers the scroll position -- **horizontal and vertical** --
and restores it in both cases, so the board stays where you were.
- Implemented in `Asset/js/keep-scroll.js`: it observes the stable parent (the container itself is
replaced) and restores, on whichever `#board-container` is current after an AJAX rebuild and again
after a full reload (persisted per board in `sessionStorage`):
- the **horizontal** position (`#board-container.scrollLeft`);
- the **page vertical** position in expanded mode (`window.scrollY`);
- the **per-column vertical** position in compact/collapsed mode -- each native
`.board-task-list-compact` list, keyed by its `data-swimlane-id` + `data-column-id`, so every
column keeps its own place independently (works with or without ShrinkVertically).
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
### Open a card only on a quick click
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
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 **120 ms**, swallows the click in the
capture phase before Kanboard's open handler -- so a drag never opens the card. Normal
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".
### 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
Kanboard's native "Edit recurrence" dialog is a plain form. This makes two small usability
edits when it opens (there is no template hook, so it is done in JS,
`Asset/js/enhance-recurrence.js`):
- the "Generate recurrent task" **Yes/No dropdown** becomes two **radio buttons** (one click
to set, instead of open + select). The original select is kept hidden and stays in sync, so
the form submits exactly as before.
- a small **[+7]** button next to the "Factor" input adds 7 to it (one week of days).
- **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".
### Done/Todo badge
A two-state toggle badge for marking a card done, shown on the board card face (above the title) and
on the task view (4th column, near the due date). It reads **Todo** (black on light red) while the
task is pending; one click flips it to **Done** (black on light green). The card itself is not
recolored -- only the badge.
- Rendered server-side (no JavaScript) via `template:board:private:task:before-title`
(`Template/board/done_badge.php`), `template:task:details:fourth-column`
(`Template/task/done_badge.php`), and a small head `<style>` (`Template/layout/done_style.php`).
Shown only to users who may edit the task; on the board it appears on expanded (non-collapsed) cards.
- **Single source of truth, no drift with native Close/Open.** With *"Marking Done also closes the
task"* **on**, "Done" *means* the task is closed: the badge reads the task's own open/closed status,
so closing or reopening a task by any route (the badge, the native sidebar, bulk actions, the API)
always keeps the badge correct. A closed "Done" card is hidden from the board by Kanboard's default
open-only filter, but reappears if you clear the filter (or use the "Board: show all tasks" filter).
- With that option **off**, the badge is an independent marker stored in task metadata
(`organon_done`) that never touches the open/closed status.
- **Auto-mark on subtasks (opt-in).** When every subtask on a card is completed, the badge flips to
Done automatically (honoring the mode above -- marker or close). It only nudges at the moment the
last subtask completes: recomputing all-done on each subtask change and acting only on the
not-all-done -> all-done transition, so if you then set the badge back to Todo it is respected (the
subtasks stay done -- the description may hold unfinished business). It never auto-reverts to Todo.
Off by default; enable it in the Done badge settings.
- **Bulk per-column action.** The board column header dropdown gains a **Mark all as Done/Todo** entry
(`Template/board/mark_all_item.php`, moved into the menu by `relocate.js`). It opens a single modal
that both confirms and picks the direction -- **Mark all Done** or **Mark all Todo** -- then sets
every task in that column/swimlane to the chosen state (an overwrite, not a per-task toggle), honoring
the same close-mode/marker-mode semantics as the badge.
- **Off by default.** Toggle it under "Settings -> Organon Tweaks".
### Fix the subtask / column / swimlane menus on touch devices
On phones and tablets the little **gear menu** (edit / remove / convert) on a subtask row -- and the
same gear on the board **Columns** and **Swimlanes** config tables -- was almost impossible to tap:
roughly one tap in fifty opened it, while dragging to reorder worked fine. The cause is an upstream
Kanboard bug: those reorder tables set the drag handle to *every* icon in the row's first cell
(`handle: "td:first i"`), so the gear, its caret and the subtask status checkbox all count as drag
handles. On touch, Kanboard's bundled jQuery UI Touch Punch then treats a tap on them as a drag and
swallows the click. Desktop (mouse) is unaffected.
- `Asset/js/sortable-handle-fix.js` re-scopes those sortables' `handle` to the real drag icon
(`.draggable-row-handle`), so only the four-arrows drag and the gear/caret/checkbox are plain taps
again -- reordering still works. It re-applies after Kanboard re-renders a table (a debounced
MutationObserver), and only touches sortables whose handle is the buggy `td:first i`, so it is inert
everywhere else.
- **Always on** (no setting) -- it only corrects a broken interaction and does nothing on desktop.
## 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.
- **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.
- **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.
**Done badge**
- **Show a Done/Todo badge on cards** -- default off.
- **Marking Done also closes the task** -- default off.
Standalone:
- **Keep the board scroll position across refreshes** -- default on.
- **Enhance the recurrence dialog** -- default on.
## Requirements ## Requirements
@@ -54,3 +221,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.

View File

@@ -0,0 +1,23 @@
<?php
/**
* Done/Todo badge on the board card face. Rendered by template:board:private:task:before-title; the
* CSS in layout/done_style.php pins it to the top-right (below the header) and colors the two states.
* Only shown to users who may edit the task (a read-only viewer sees no badge). Expanded cards only
* (collapsed cards do not fire this hook).
*/
if (! $this->user->hasProjectAccess('TaskModificationController', 'edit', $task['project_id'])) {
return;
}
$done = $this->OrganonDoneHelper->isDone($task);
?>
<span class="organontweaks-done<?= $done ? ' is-done' : '' ?>">
<?= $this->url->link(
$done ? '<i class="fa fa-check-square"></i> '.t('Done') : '<i class="fa fa-square"></i> '.t('Todo'),
'DoneController',
'toggle',
array('plugin' => 'OrganonTweaks', 'task_id' => $task['id'], 'project_id' => $task['project_id'], 'from' => 'board'),
true,
'',
$done ? t('Mark as not done') : t('Mark as done')
) ?>
</span>

View File

@@ -0,0 +1,35 @@
<?php
/**
* The "Mark all as Done/Todo" modal: one modal serving both purposes -- confirm and pick direction.
* Two buttons set every task in the column to the same state; cancel closes the modal. Each button is
* a CSRF link to DoneController::markColumn.
*/
?>
<div class="page-header">
<h2><?= t('Mark all tasks in this column') ?></h2>
</div>
<p class="confirm">
<?= t('Set every task in this column to the same state. Already-done tasks are overwritten (no harm).') ?>
</p>
<div class="form-actions">
<?= $this->url->link(t('Mark all Done'), 'DoneController', 'markColumn', array(
'plugin' => 'OrganonTweaks',
'project_id' => $project_id,
'column_id' => $column_id,
'swimlane_id' => $swimlane_id,
'direction' => 'done',
), true, 'btn btn-blue') ?>
<?= $this->url->link(t('Mark all Todo'), 'DoneController', 'markColumn', array(
'plugin' => 'OrganonTweaks',
'project_id' => $project_id,
'column_id' => $column_id,
'swimlane_id' => $swimlane_id,
'direction' => 'todo',
), true, 'btn btn-red') ?>
<?= t('or') ?>
<?= $this->url->link(t('cancel'), 'BoardViewController', 'show', array('project_id' => $project_id), false, 'close-popover') ?>
</div>

View File

@@ -0,0 +1,19 @@
<?php
/**
* "Mark all as Done/Todo" entry in the board column header dropdown. Like the "Remove this Column"
* item, the column hook renders outside the native menu <ul>, so this is hidden and moved into the
* menu by Asset/js/relocate.js. Shown only on non-empty columns to users who may modify tasks.
* Clicking opens a modal (DoneController::confirmColumn) that both confirms and picks the direction.
*/
if ($column['nb_tasks'] <= 0 || ! $this->user->hasProjectAccess('TaskModificationController', 'update', $column['project_id'])) {
return;
}
?>
<li class="organontweaks-markall-item" style="display: none;">
<?= $this->modal->medium('check-square-o', t('Mark all as Done/Todo'), 'DoneController', 'confirmColumn', array(
'plugin' => 'OrganonTweaks',
'project_id' => $column['project_id'],
'column_id' => $column['id'],
'swimlane_id' => $swimlane['id'],
)) ?>
</li>

View 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 ? '&#8595;' : '&#8593;' ?></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>

View 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 ?>

View File

@@ -6,12 +6,75 @@
<?= $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"><?= t('A "+" bubble on cards with no comments yet, to add the first one from the board.') ?></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>
<?= $this->form->checkbox('organon_tweaks_keep_scroll', t('Keep the board scroll position across refreshes'), 1, isset($values['organon_tweaks_keep_scroll']) && $values['organon_tweaks_keep_scroll'] == 1) ?>
<p class="form-help"> <p class="form-help">
<?= 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.') ?> <?= t('When a card is dropped (or the board auto-refreshes), Kanboard rebuilds the board and jumps back to the first column. This restores the horizontal scroll position so the board stays where you were.') ?>
</p> </p>
</fieldset> </fieldset>
<fieldset>
<?= $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">
<?= 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>
</fieldset>
<fieldset>
<legend><?= t('Board filters') ?></legend>
<?= $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 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>
<legend><?= t('Done badge') ?></legend>
<?= $this->form->checkbox('organon_tweaks_done_badge', t('Show a Done/Todo badge on cards'), 1, isset($values['organon_tweaks_done_badge']) && $values['organon_tweaks_done_badge'] == 1) ?>
<p class="form-help"><?= t('A two-state toggle badge on the board card (above the title) and the task view (near the due date): "Todo" (red) until you click it, then "Done" (green). Off by default.') ?></p>
<?= $this->form->checkbox('organon_tweaks_done_closes_task', t('Marking Done also closes the task'), 1, isset($values['organon_tweaks_done_closes_task']) && $values['organon_tweaks_done_closes_task'] == 1) ?>
<p class="form-help"><?= t('When on, "Done" means the task is closed (so it leaves the board unless you clear the status filter), and clicking Todo reopens it -- native Close/Open stay in sync. When off, the badge is an independent marker that never changes the open/closed status.') ?></p>
<?= $this->form->checkbox('organon_tweaks_done_autosubtasks', t('Auto-mark Done when the last subtask is completed'), 1, isset($values['organon_tweaks_done_autosubtasks']) && $values['organon_tweaks_done_autosubtasks'] == 1) ?>
<p class="form-help"><?= t('When every subtask on a card is done, flip the badge to Done automatically (honoring the mode above). It only nudges at that moment -- if you then set it back to Todo, that is respected (the subtasks stay done).') ?></p>
</fieldset>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-blue"><?= t('Save') ?></button> <button type="submit" class="btn btn-blue"><?= t('Save') ?></button>
</div> </div>

View File

@@ -0,0 +1,29 @@
<?php
/**
* Done/Todo badge styling (emitted into the head when the feature is enabled). NATURAL document flow --
* the badge renders above the title (board card) or as a list item (task view); no absolute
* positioning, so it adapts to any card / column / avatar / username size. Two states via .is-done:
* Todo = black filled box + black text on a light red (#ef9a9a); Done = checked box, black on light
* green (#a5d6a7). Both states use black text (core's `.task-board a { color:#000 }` forces the icon
* black anyway, so the backgrounds are kept light for contrast). Colors are tunable.
*/
?>
<style>
span.organontweaks-done a {
display: inline-block;
text-decoration: none;
padding: 3px 10px;
border-radius: 3px;
background: #ef9a9a;
color: #000;
font-weight: bold;
}
span.organontweaks-done.is-done a {
background: #a5d6a7;
color: #000;
}
.organontweaks-done {
display: inline-block;
margin: 1px 0;
}
</style>

View File

@@ -0,0 +1,3 @@
<?php if ($this->OrganonProjectHelper->isEmphasizeDueDate()): ?>
<style>.task-date { font-size: 1.375em; font-weight: bold; }</style>
<?php endif ?>

View 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>

View File

@@ -0,0 +1 @@
<?php $this->OrganonProjectHelper->ensureBoardFilters($project['id']) ?>

View File

@@ -0,0 +1,24 @@
<?php
/**
* Done/Todo badge in the task view, 4th column (near the due date). Rendered by
* template:task:details:fourth-column as a list item, matching the surrounding <li> fields. Same
* two-state toggle as the board badge; only shown to users who may edit the task.
*/
if (! $this->user->hasProjectAccess('TaskModificationController', 'edit', $task['project_id'])) {
return;
}
$done = $this->OrganonDoneHelper->isDone($task);
?>
<li>
<span class="organontweaks-done<?= $done ? ' is-done' : '' ?>">
<?= $this->url->link(
$done ? '<i class="fa fa-check-square"></i> '.t('Done') : '<i class="fa fa-square"></i> '.t('Todo'),
'DoneController',
'toggle',
array('plugin' => 'OrganonTweaks', 'task_id' => $task['id'], 'project_id' => $task['project_id'], 'from' => 'task'),
true,
'',
$done ? t('Mark as not done') : t('Mark as done')
) ?>
</span>
</li>

View File

@@ -1 +1 @@
OrganonTweaks v0.3.1 OrganonTweaks v2.3.0