Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ebebd5ffb | |||
| 9b637160b2 | |||
| e85d5d71f9 | |||
| ce3205646f | |||
| 4ce18d9e5f | |||
| 4d7f88aa94 | |||
| bc266ff969 | |||
| 8a97c5cb94 | |||
| d1178bc463 | |||
| c747d33491 | |||
| 850127e712 | |||
| 70f42352b0 | |||
| d57544ebfd | |||
| c3896e1962 | |||
| 065825269e | |||
| 6726dfa456 | |||
| 17ebfcccd9 | |||
| 95561e5565 | |||
| ff6abf1d49 | |||
| 310c34620f | |||
| a5721890a6 | |||
| 9c27b51eed | |||
| 1bea749fed | |||
| 230982dd4c | |||
| d47809c713 | |||
| 59955097da | |||
| 6cca6ce562 | |||
| 8b7b6478fd |
1
Asset/css/skeleton.css
vendored
1
Asset/css/skeleton.css
vendored
@@ -1 +0,0 @@
|
||||
/* Skeleton plugin styles -- add CSS here. Loaded via template:layout:css. */
|
||||
35
Asset/js/click-guard.js
Normal file
35
Asset/js/click-guard.js
Normal 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);
|
||||
})();
|
||||
105
Asset/js/enhance-recurrence.js
Normal file
105
Asset/js/enhance-recurrence.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
55
Asset/js/keep-scroll.js
Normal file
55
Asset/js/keep-scroll.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* OrganonTweaks -- keep the board's horizontal scroll position across refreshes.
|
||||
*
|
||||
* When you drop a card (and on Kanboard's periodic AJAX polling) the board is rebuilt with
|
||||
* `$("#board-container").replaceWith(data)` (BoardDragAndDrop.refresh). The brand-new
|
||||
* element starts at scrollLeft 0, so the board snaps back to the first column -- a jarring
|
||||
* jump. This remembers the last scroll position and restores it the moment a replacement
|
||||
* container appears, so the view stays put.
|
||||
*
|
||||
* We observe the STABLE parent (the container itself is replaced) and read the scroll from
|
||||
* whichever #board-container is current, so it keeps working after every rebuild.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function init() {
|
||||
var container = document.getElementById("board-container");
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
var parent = container.parentNode;
|
||||
var lastScrollLeft = container.scrollLeft;
|
||||
var lastContainer = container;
|
||||
|
||||
// Remember the position whenever the board is scrolled (capture: scroll does not
|
||||
// bubble, and this survives the container being replaced).
|
||||
document.addEventListener("scroll", function (e) {
|
||||
var c = document.getElementById("board-container");
|
||||
if (c && e.target === c) {
|
||||
lastScrollLeft = c.scrollLeft;
|
||||
}
|
||||
}, true);
|
||||
|
||||
// When the board is rebuilt, #board-container becomes a new element at scrollLeft 0;
|
||||
// restore the remembered position before the browser paints it.
|
||||
if (window.MutationObserver) {
|
||||
new MutationObserver(function () {
|
||||
var c = document.getElementById("board-container");
|
||||
if (c && c !== lastContainer) {
|
||||
lastContainer = c;
|
||||
if (c.scrollLeft !== lastScrollLeft) {
|
||||
c.scrollLeft = lastScrollLeft;
|
||||
}
|
||||
}
|
||||
}).observe(parent, { childList: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
102
Asset/js/relocate.js
Normal file
102
Asset/js/relocate.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* OrganonTweaks -- move plugin-injected DOM bits into their native positions on the board.
|
||||
*
|
||||
* Both fixes are needed because Kanboard's relevant hooks render at the END of their
|
||||
* container, not where the item belongs:
|
||||
*
|
||||
* 1. "Remove this Column": the template:board:column:dropdown hook renders AFTER the column
|
||||
* menu <ul> (and the dropdown JS clones only that <ul>), so we move the hidden <li> into
|
||||
* the column's real <ul>.
|
||||
* 2. "Always show comment icon": the template:board:task:icons hook renders at the end of
|
||||
* the card icons row, so the "+" bubble lands after priority instead of in the native
|
||||
* comment slot. We move it just before the description icon (where the native comment
|
||||
* icon sits), so the order matches whether or not the card has comments.
|
||||
*
|
||||
* Both re-run when the board is redrawn (AJAX polling / drag-and-drop refresh).
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function relocateColumnMenuItems() {
|
||||
var items = document.querySelectorAll(".organontweaks-remove-column-item");
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var li = items[i];
|
||||
|
||||
// Already moved into a menu list: just make sure it is visible.
|
||||
if (li.closest("ul")) {
|
||||
li.style.display = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
// The hook renders the item as a child of the column's dropdown wrapper;
|
||||
// its first <ul> is the native menu.
|
||||
var menu = li.parentNode ? li.parentNode.querySelector("ul") : null;
|
||||
|
||||
if (menu) {
|
||||
menu.appendChild(li);
|
||||
li.style.display = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function repositionCommentIcons() {
|
||||
var items = document.querySelectorAll(".organontweaks-comment-add");
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
var row = item.parentNode;
|
||||
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// NB: the description icon is wrapped in <span class="tooltip"> (Kanboard's
|
||||
// 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");
|
||||
|
||||
while (anchor && anchor.parentNode !== row) {
|
||||
anchor = anchor.parentNode;
|
||||
}
|
||||
|
||||
// Move only if needed (keeps the MutationObserver from looping).
|
||||
if (anchor && anchor !== item && item.nextElementSibling !== anchor) {
|
||||
row.insertBefore(item, anchor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
relocateColumnMenuItems();
|
||||
repositionCommentIcons();
|
||||
}
|
||||
|
||||
function init() {
|
||||
var container = document.getElementById("board-container");
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
apply();
|
||||
|
||||
// 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 (parent && window.MutationObserver) {
|
||||
new MutationObserver(apply).observe(parent, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -1 +0,0 @@
|
||||
// Skeleton plugin script -- add JS here. Loaded via template:layout:js.
|
||||
71
Controller/ColumnController.php
Normal file
71
Controller/ColumnController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Kanboard\Plugin\OrganonTweaks\Controller;
|
||||
|
||||
use Kanboard\Controller\BaseController;
|
||||
use Kanboard\Core\Controller\AccessForbiddenException;
|
||||
use Kanboard\Model\TaskModel;
|
||||
|
||||
/**
|
||||
* Remove an empty board column straight from its header menu.
|
||||
*/
|
||||
class ColumnController extends BaseController
|
||||
{
|
||||
/**
|
||||
* Confirmation dialog.
|
||||
*/
|
||||
public function confirm()
|
||||
{
|
||||
$project = $this->getProject();
|
||||
$column_id = $this->request->getIntegerParam('column_id');
|
||||
$this->checkAccess($project['id'], $column_id);
|
||||
|
||||
$this->response->html($this->template->render('organonTweaks:board/remove_column', array(
|
||||
'project' => $project,
|
||||
'column' => $this->columnModel->getColumnTitleById($column_id),
|
||||
'column_id' => $column_id,
|
||||
)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the column and return to the board.
|
||||
*/
|
||||
public function remove()
|
||||
{
|
||||
$project = $this->getProject();
|
||||
$values = $this->request->getValues();
|
||||
$column_id = isset($values['column_id']) ? (int) $values['column_id'] : 0;
|
||||
|
||||
$this->checkAccess($project['id'], $column_id);
|
||||
|
||||
if ($this->columnModel->remove($column_id)) {
|
||||
$this->flash->success(t('Column removed successfully.'));
|
||||
} else {
|
||||
$this->flash->failure(t('Unable to remove this column.'));
|
||||
}
|
||||
|
||||
$this->response->redirect($this->helper->url->to('BoardViewController', 'show', array('project_id' => $project['id'])));
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow only users who can manage columns, and only for a column with no tasks at all
|
||||
* (open or closed) -- ColumnModel::remove() is a bare delete, so a non-empty column
|
||||
* would orphan its tasks.
|
||||
*/
|
||||
private function checkAccess($project_id, $column_id)
|
||||
{
|
||||
if (! $this->helper->user->hasProjectAccess('ColumnController', 'remove', $project_id)) {
|
||||
throw new AccessForbiddenException();
|
||||
}
|
||||
|
||||
$count = $this->taskFinderModel->countByColumnId(
|
||||
$project_id,
|
||||
$column_id,
|
||||
array(TaskModel::STATUS_OPEN, TaskModel::STATUS_CLOSED)
|
||||
);
|
||||
|
||||
if ($count > 0) {
|
||||
throw new AccessForbiddenException();
|
||||
}
|
||||
}
|
||||
}
|
||||
82
Controller/ConfigController.php
Normal file
82
Controller/ConfigController.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Kanboard\Plugin\OrganonTweaks\Controller;
|
||||
|
||||
use Kanboard\Controller\BaseController;
|
||||
|
||||
/**
|
||||
* Settings page for the OrganonTweaks plugin: one toggle per tweak.
|
||||
*/
|
||||
class ConfigController extends BaseController
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
$this->response->html($this->helper->layout->config('organonTweaks:config/show', array(
|
||||
'title' => t('Settings').' > '.t('Organon Tweaks'),
|
||||
'values' => array(
|
||||
'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),
|
||||
),
|
||||
'errors' => array(),
|
||||
)));
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$values = $this->request->getValues();
|
||||
|
||||
$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;
|
||||
|
||||
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,
|
||||
))) {
|
||||
$this->flash->success(t('Settings saved successfully.'));
|
||||
} else {
|
||||
$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')));
|
||||
}
|
||||
}
|
||||
92
Filter/TaskMonthRangeFilter.php
Normal file
92
Filter/TaskMonthRangeFilter.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace Kanboard\Plugin\OrganonTweaks\Filter;
|
||||
|
||||
use Kanboard\Core\Filter\FilterInterface;
|
||||
use Kanboard\Filter\BaseFilter;
|
||||
use Kanboard\Model\TaskModel;
|
||||
|
||||
/**
|
||||
* Filter tasks by a whole calendar month, optionally padded.
|
||||
*
|
||||
* Registered twice by the plugin (via $container->extend('taskLexer', ...)):
|
||||
* - `duemonth:VALUE` -- padDays 0: due within the target month.
|
||||
* - `aroundmonth:VALUE` -- padDays 10: the month minus 10 days .. plus 10 days (i.e. the last
|
||||
* 10 days of the previous month, the whole month, and the first 10 days of the next month).
|
||||
*
|
||||
* VALUE is `this` (also `next` / `last`) or an explicit month such as `2026-01` or `january-2026`
|
||||
* (anything strtotime() can parse). Bounds are computed with mktime (correct for any month length)
|
||||
* at search time, so the filter auto-rolls.
|
||||
*
|
||||
* Both bounds (gte start, lte end) are added inside this single apply() on the single-value path,
|
||||
* so they AND -- the range Kanboard's saved-filter string syntax cannot express (repeating the
|
||||
* same attribute ORs via OrCriteria, and there is no AND keyword).
|
||||
*/
|
||||
class TaskMonthRangeFilter extends BaseFilter implements FilterInterface
|
||||
{
|
||||
private $attribute = 'duemonth';
|
||||
private $padDays = 0;
|
||||
|
||||
public function setAttribute($attribute)
|
||||
{
|
||||
$this->attribute = $attribute;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setPadDays($days)
|
||||
{
|
||||
$this->padDays = (int) $days;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAttributes()
|
||||
{
|
||||
return array($this->attribute);
|
||||
}
|
||||
|
||||
public function apply()
|
||||
{
|
||||
$reference = $this->resolveMonthTimestamp((string) $this->value);
|
||||
|
||||
if ($reference === false) {
|
||||
return $this; // unknown/typo value -> no-op (matches nothing added)
|
||||
}
|
||||
|
||||
$month = (int) date('n', $reference);
|
||||
$year = (int) date('Y', $reference);
|
||||
$first = mktime(0, 0, 0, $month, 1, $year);
|
||||
$last = mktime(23, 59, 59, $month + 1, 0, $year); // day 0 of next month = last day of this
|
||||
$pad = $this->padDays * 86400;
|
||||
|
||||
$this->query->neq(TaskModel::TABLE.'.date_due', 0);
|
||||
$this->query->gte(TaskModel::TABLE.'.date_due', $first - $pad);
|
||||
$this->query->lte(TaskModel::TABLE.'.date_due', $last + $pad);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the filter value to any timestamp inside the target month (or false if unparseable).
|
||||
*
|
||||
* @param string $value
|
||||
* @return int|false
|
||||
*/
|
||||
private function resolveMonthTimestamp($value)
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
|
||||
if ($value === '' || $value === 'this') {
|
||||
return strtotime('first day of this month');
|
||||
}
|
||||
|
||||
if ($value === 'next') {
|
||||
return strtotime('first day of next month');
|
||||
}
|
||||
|
||||
if ($value === 'last' || $value === 'prev' || $value === 'previous') {
|
||||
return strtotime('first day of last month');
|
||||
}
|
||||
|
||||
return strtotime($value); // 2026-01, january-2026, "last day of january 2026", ...
|
||||
}
|
||||
}
|
||||
100
Filter/TaskRecurringFilter.php
Normal file
100
Filter/TaskRecurringFilter.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Kanboard\Plugin\OrganonTweaks\Filter;
|
||||
|
||||
use Kanboard\Core\Filter\FilterInterface;
|
||||
use Kanboard\Filter\BaseFilter;
|
||||
use Kanboard\Model\TaskModel;
|
||||
use PicoDb\Database;
|
||||
|
||||
/**
|
||||
* Filter tasks by recurrence: `recurring:XY`, one attribute with one composite value so it can
|
||||
* INTERSECT the two axes (stacking two values of the same attribute would only OR them).
|
||||
*
|
||||
* X = system : evt (event-triggered = Kanboard native) | cal (calendar = RecoReco) | any
|
||||
* Y = role : ori (original/template) | dup (duplicate/copy) | all
|
||||
*
|
||||
* Conditions are authoritative -- taken straight from how core classifies these cards:
|
||||
* - evtori = recurrence_status PENDING, evtdup = recurrence_status PROCESSED. This is exactly how
|
||||
* core colors the board refresh icon (app/Template/board/task_footer.php: normal icon = PENDING,
|
||||
* fa-inverse = PROCESSED), so the filter matches what the board shows.
|
||||
* - calori = RecoReco metadata recoreco_enabled=1, caldup = recoreco_clone=1.
|
||||
* - *all / any* = the OR-unions of the above.
|
||||
*
|
||||
* Cross-plugin: the calendar side reads RecoReco metadata keys. If RecoReco is not installed the
|
||||
* metadata sets are empty, so `cal*` matches nothing and `evt*` still works (event-only degrade).
|
||||
*
|
||||
* One attribute + one value -> single-value path, so the predicates below are grouped in a scoped
|
||||
* beginOr/closeOr and the whole group still ANDs with any other search attribute.
|
||||
*/
|
||||
class TaskRecurringFilter extends BaseFilter implements FilterInterface
|
||||
{
|
||||
private $db;
|
||||
|
||||
public function setDatabase(Database $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAttributes()
|
||||
{
|
||||
return array('recurring');
|
||||
}
|
||||
|
||||
public function apply()
|
||||
{
|
||||
$value = strtolower(trim((string) $this->value));
|
||||
$system = substr($value, 0, 3); // evt | cal | any
|
||||
$role = substr($value, 3, 3); // ori | dup | all
|
||||
|
||||
if (! in_array($system, array('evt', 'cal', 'any'), true) ||
|
||||
! in_array($role, array('ori', 'dup', 'all'), true)) {
|
||||
return $this; // unknown/typo value -> no-op
|
||||
}
|
||||
|
||||
$wantEvt = $system === 'evt' || $system === 'any';
|
||||
$wantCal = $system === 'cal' || $system === 'any';
|
||||
$wantOri = $role === 'ori' || $role === 'all';
|
||||
$wantDup = $role === 'dup' || $role === 'all';
|
||||
|
||||
$this->query->beginOr();
|
||||
|
||||
if ($wantEvt && $wantOri) {
|
||||
$this->query->eq(TaskModel::TABLE.'.recurrence_status', TaskModel::RECURRING_STATUS_PENDING);
|
||||
}
|
||||
|
||||
if ($wantEvt && $wantDup) {
|
||||
$this->query->eq(TaskModel::TABLE.'.recurrence_status', TaskModel::RECURRING_STATUS_PROCESSED);
|
||||
}
|
||||
|
||||
if ($wantCal && $wantOri) {
|
||||
$this->query->in(TaskModel::TABLE.'.id', $this->metaIds('recoreco_enabled'));
|
||||
}
|
||||
|
||||
if ($wantCal && $wantDup) {
|
||||
$this->query->in(TaskModel::TABLE.'.id', $this->metaIds('recoreco_clone'));
|
||||
}
|
||||
|
||||
$this->query->closeOr();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task ids carrying a RecoReco metadata flag set to 1. Returns a non-matching sentinel when the
|
||||
* set is empty so the IN clause matches nothing (RecoReco absent -> event-only degrade).
|
||||
*
|
||||
* @param string $name
|
||||
* @return array
|
||||
*/
|
||||
private function metaIds($name)
|
||||
{
|
||||
$ids = $this->db->table('task_has_metadata')
|
||||
->eq('name', $name)
|
||||
->eq('value', '1')
|
||||
->findAllByColumn('task_id');
|
||||
|
||||
return ! empty($ids) ? $ids : array(-1);
|
||||
}
|
||||
}
|
||||
31
Helper/OrganonColumnHelper.php
Normal file
31
Helper/OrganonColumnHelper.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Kanboard\Plugin\OrganonTweaks\Helper;
|
||||
|
||||
use Kanboard\Core\Base;
|
||||
use Kanboard\Model\TaskModel;
|
||||
|
||||
/**
|
||||
* Template helper for column tweaks. Exposes an emptiness check that counts BOTH open and
|
||||
* closed tasks (the board's own $column['nb_tasks'] is open-only), so a column that only
|
||||
* holds closed tasks is still treated as non-empty.
|
||||
*/
|
||||
class OrganonColumnHelper extends Base
|
||||
{
|
||||
private static $emptyCache = array();
|
||||
|
||||
public function hasNoTasks($project_id, $column_id)
|
||||
{
|
||||
if (! isset(self::$emptyCache[$column_id])) {
|
||||
$count = $this->taskFinderModel->countByColumnId(
|
||||
$project_id,
|
||||
$column_id,
|
||||
array(TaskModel::STATUS_OPEN, TaskModel::STATUS_CLOSED)
|
||||
);
|
||||
|
||||
self::$emptyCache[$column_id] = ($count === 0);
|
||||
}
|
||||
|
||||
return self::$emptyCache[$column_id];
|
||||
}
|
||||
}
|
||||
187
Helper/OrganonProjectHelper.php
Normal file
187
Helper/OrganonProjectHelper.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?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).
|
||||
const GROUP_SHOW_ALL = array(
|
||||
array('Show all tasks', '', 0),
|
||||
);
|
||||
const GROUP_MONTH = array(
|
||||
array('Due this month', 'duemonth:this', 1),
|
||||
array('Around this month', 'aroundmonth:this', 1),
|
||||
);
|
||||
const GROUP_RECURRING = array(
|
||||
array('All recurring', 'recurring:anyall', 1),
|
||||
array('Event recurring', 'recurring:evtall', 1),
|
||||
array('Calendar recurring', '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();
|
||||
}
|
||||
}
|
||||
98
Plugin.php
98
Plugin.php
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace Kanboard\Plugin\Skeleton;
|
||||
namespace Kanboard\Plugin\OrganonTweaks;
|
||||
|
||||
use Kanboard\Core\Plugin\Base;
|
||||
|
||||
@@ -8,28 +8,98 @@ class Plugin extends Base
|
||||
{
|
||||
public function initialize()
|
||||
{
|
||||
// 1. Render a visible word at the top of every page (the demo output).
|
||||
$this->template->hook->attach('template:layout:top', 'skeleton:layout/header');
|
||||
// "Remove this Column" entry in the board column header dropdown. The only
|
||||
// column hook renders outside the native menu <ul>, so the item is rendered
|
||||
// hidden and relocate.js moves it into the menu (like BulkMoveTasks).
|
||||
$this->template->hook->attach('template:board:column:dropdown', 'organonTweaks:board/column_dropdown');
|
||||
|
||||
// 2. Load the plugin stylesheet (currently empty -- proves the CSS hook fires).
|
||||
$this->hook->on('template:layout:css', array(
|
||||
'template' => 'plugins/Skeleton/Asset/css/skeleton.css',
|
||||
));
|
||||
|
||||
// 3. Load the plugin script (currently empty -- proves the JS hook fires).
|
||||
$this->hook->on('template:layout:js', array(
|
||||
'template' => 'plugins/Skeleton/Asset/js/skeleton.js',
|
||||
'template' => 'plugins/OrganonTweaks/Asset/js/relocate.js',
|
||||
));
|
||||
|
||||
// Settings page for the plugin's tweaks.
|
||||
$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
|
||||
// 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.
|
||||
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');
|
||||
}
|
||||
|
||||
// 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',
|
||||
));
|
||||
}
|
||||
|
||||
// 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()
|
||||
{
|
||||
return array(
|
||||
'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper', 'OrganonProjectHelper'),
|
||||
);
|
||||
}
|
||||
|
||||
public function getPluginName()
|
||||
{
|
||||
return 'Skeleton';
|
||||
return 'OrganonTweaks';
|
||||
}
|
||||
|
||||
public function getPluginDescription()
|
||||
{
|
||||
return t('Reusable skeleton/template for building Kanboard plugins.');
|
||||
return t('Umbrella plugin for small Kanboard tweaks: remove an empty board column, always show the card comment icon, and more.');
|
||||
}
|
||||
|
||||
public function getPluginAuthor()
|
||||
@@ -39,12 +109,12 @@ class Plugin extends Base
|
||||
|
||||
public function getPluginVersion()
|
||||
{
|
||||
return '0.1.0';
|
||||
return '1.7.0';
|
||||
}
|
||||
|
||||
public function getPluginHomepage()
|
||||
{
|
||||
return 'https://code.beco.cc/beco/kanboard-plugin-skeleton';
|
||||
return 'https://code.beco.cc/beco/OrganonTweaks';
|
||||
}
|
||||
|
||||
public function getCompatibleVersion()
|
||||
|
||||
133
README.md
133
README.md
@@ -1,19 +1,84 @@
|
||||
# Skeleton -- a Kanboard plugin template
|
||||
# OrganonTweaks -- umbrella plugin for small Kanboard tweaks
|
||||
|
||||
A minimal, working Kanboard plugin that you copy and rename as the starting point for a
|
||||
real plugin. By itself it does only one trivial thing: it renders the word "Skeleton" at
|
||||
the top of every page. It changes no data and runs no database migration.
|
||||
A home for small, unrelated Kanboard adjustments that are not big enough to deserve their
|
||||
own plugin. Each tweak is self-contained; more will be added over time. If a tweak grows
|
||||
into a real subsystem it graduates into its own plugin (see PLAN-organon-tweaks-plugin.md).
|
||||
|
||||
## What it demonstrates
|
||||
## Tweaks
|
||||
|
||||
- A complete `Plugin.php` registration class with all the metadata Kanboard shows in
|
||||
Settings -> Plugins (name, description, author, version, homepage, compatible version).
|
||||
- A template hook (`template:layout:top`) that injects a template into the page.
|
||||
- Asset hooks (`template:layout:css` and `template:layout:js`) that load a stylesheet and
|
||||
a script. They are empty for now but prove the injection path works -- handy when a real
|
||||
plugin needs custom CSS or JS.
|
||||
- The standard plugin directory layout, with stub folders (`Controller/`, `Model/`,
|
||||
`Schema/`, `Locale/`, `Test/`) ready to grow into.
|
||||
### Remove an empty column
|
||||
|
||||
Adds a **Remove this Column** entry (trash icon) to a board column's header dropdown, next
|
||||
to the other column actions. It appears only when the column is completely empty -- no
|
||||
tasks, open or closed -- because Kanboard's column removal is a plain delete and a non-empty
|
||||
column would orphan its tasks. Clicking it asks for confirmation, then removes the column
|
||||
and returns to the board.
|
||||
|
||||
- The menu entry is rendered by the `template:board:column:dropdown` hook (which sits
|
||||
outside the native menu list), then moved into the menu by `Asset/js/relocate.js`.
|
||||
- The empty check counts open and closed tasks via a helper
|
||||
(`Helper/OrganonColumnHelper.php`, `taskFinderModel->countByColumnId(...)`), since the
|
||||
board's own count is open-only.
|
||||
- Only users allowed to manage columns see it, and the controller re-checks both the
|
||||
permission and the emptiness before deleting.
|
||||
|
||||
### Always show the comment icon
|
||||
|
||||
Kanboard shows the comment icon on a board card only once it has at least one comment, so
|
||||
adding the *first* comment means opening the card and finding the sidebar link. With this
|
||||
tweak on, a card with no comments shows a comment bubble with a **"+"** that opens the same
|
||||
comment modal, letting you add the first comment straight from the board. Cards that already
|
||||
have comments keep showing the count as before.
|
||||
|
||||
- Rendered by the `template:board:task:icons` hook (`Template/board/task_comment_icon.php`),
|
||||
only when the card has no comments and the user may create one (so it does not show for
|
||||
read-only viewers).
|
||||
- **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. This tweak remembers the
|
||||
horizontal scroll position and restores it as soon as the rebuilt board appears, so the
|
||||
board stays where you were.
|
||||
|
||||
- Implemented in `Asset/js/keep-scroll.js`: it observes the stable parent (the container
|
||||
itself is replaced), reads/writes `scrollLeft` on whichever `#board-container` is current.
|
||||
- **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 **100 ms**, swallows the click in the
|
||||
capture phase before Kanboard's open handler -- so a drag never opens the card. Normal
|
||||
clicks (well under 100 ms) still open cards as usual. The 100 ms threshold is fixed.
|
||||
|
||||
- **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".
|
||||
|
||||
## Settings
|
||||
|
||||
Global (per Kanboard instance) and admin-only, under "Settings -> Organon Tweaks":
|
||||
|
||||
- **Always show the comment icon on board cards** -- default on.
|
||||
- **Keep the board scroll position across refreshes** -- default on.
|
||||
- **Open a card only on a quick click** -- default on.
|
||||
- **Enhance the recurrence dialog** -- default on.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -21,45 +86,9 @@ the top of every page. It changes no data and runs no database migration.
|
||||
|
||||
## Installation
|
||||
|
||||
No build step and no dependencies.
|
||||
|
||||
1. Copy this folder into your Kanboard installation as `plugins/Skeleton/`.
|
||||
2. Reload any page. The word "Skeleton" appears at the top.
|
||||
3. Confirm it under Settings -> Plugins.
|
||||
|
||||
To uninstall, delete `plugins/Skeleton/`. Nothing else is left behind.
|
||||
|
||||
## Directory layout
|
||||
|
||||
```
|
||||
Skeleton/
|
||||
Plugin.php Registration and hook wiring (the only required file).
|
||||
README.md
|
||||
LICENSE AGPL-3.0.
|
||||
Template/
|
||||
layout/header.php The visible "Skeleton" word.
|
||||
Asset/
|
||||
css/skeleton.css Loaded via template:layout:css.
|
||||
js/skeleton.js Loaded via template:layout:js.
|
||||
Controller/ Stub for future request handlers.
|
||||
Model/ Stub for future business logic / DB access.
|
||||
Schema/ Stub for future database migrations.
|
||||
Locale/ Stub for future translations (e.g. pt_BR/, fr_FR/).
|
||||
Test/ Stub for future unit tests.
|
||||
```
|
||||
|
||||
## How to fork this into a new plugin
|
||||
|
||||
1. Copy the folder and rename it, e.g. `plugins/MyPlugin/`. The folder name must match the
|
||||
namespace and start with a capital letter.
|
||||
2. In `Plugin.php`, change the namespace from `Kanboard\Plugin\Skeleton` to
|
||||
`Kanboard\Plugin\MyPlugin`.
|
||||
3. Update the metadata methods (`getPluginName`, `getPluginDescription`, `getPluginAuthor`,
|
||||
`getPluginVersion`, `getPluginHomepage`, `getCompatibleVersion`).
|
||||
4. Update the hook target paths: the lowercase prefix in `'skeleton:layout/header'` and the
|
||||
`plugins/Skeleton/Asset/...` asset paths must match the new plugin name.
|
||||
5. Replace `Template/layout/header.php` with your real template, or attach to a different
|
||||
hook. See the Kanboard plugin hooks documentation for the full list of hook points.
|
||||
Copy this folder into your Kanboard installation as `plugins/OrganonTweaks/`. The directory
|
||||
name must be exactly `OrganonTweaks` (Kanboard derives the plugin namespace from the folder
|
||||
name). No build step and no database migration.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
12
Template/board/column_dropdown.php
Normal file
12
Template/board/column_dropdown.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php if ($this->user->hasProjectAccess('ColumnController', 'remove', $column['project_id']) && $this->OrganonColumnHelper->hasNoTasks($column['project_id'], $column['id'])): ?>
|
||||
<?php /* Rendered outside the menu <ul> by the only column hook, then moved into it by
|
||||
Asset/js/relocate.js. Hidden until relocated so it never flashes in the header.
|
||||
Shown only for empty columns (no open or closed tasks). */ ?>
|
||||
<li class="organontweaks-remove-column-item" style="display: none;">
|
||||
<?= $this->modal->confirm('trash-o', t('Remove this Column'), 'ColumnController', 'confirm', array(
|
||||
'plugin' => 'OrganonTweaks',
|
||||
'project_id' => $column['project_id'],
|
||||
'column_id' => $column['id'],
|
||||
)) ?>
|
||||
</li>
|
||||
<?php endif ?>
|
||||
17
Template/board/remove_column.php
Normal file
17
Template/board/remove_column.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<div class="page-header">
|
||||
<h2><?= t('Remove this column') ?></h2>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<?= $this->url->href('ColumnController', 'remove', array('plugin' => 'OrganonTweaks', 'project_id' => $project['id'])) ?>">
|
||||
<?= $this->form->csrf() ?>
|
||||
<?= $this->form->hidden('column_id', array('column_id' => $column_id)) ?>
|
||||
|
||||
<p class="alert alert-info">
|
||||
<?= t('Do you really want to remove this column: "%s"?', $column) ?>
|
||||
</p>
|
||||
|
||||
<?= $this->modal->submitButtons(array(
|
||||
'submitLabel' => t('Remove'),
|
||||
'color' => 'red',
|
||||
)) ?>
|
||||
</form>
|
||||
18
Template/board/task_comment_icon.php
Normal file
18
Template/board/task_comment_icon.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php /*
|
||||
* Rendered by the template:board:task:icons hook, inside the card icons row. When a card
|
||||
* has no comments yet, Kanboard shows no comment icon at all -- so adding the first comment
|
||||
* means opening the card and finding the sidebar link. This adds a comment bubble with a
|
||||
* "+" that opens the same comment modal (CommentListController::show) the native count icon
|
||||
* uses, so the first comment can be added straight from the board. Cards that already have
|
||||
* comments render nothing here (the native count icon already shows).
|
||||
*
|
||||
* Only shown to users who may create a comment (CommentController::save is PROJECT_MEMBER),
|
||||
* so it does not appear for read-only viewers.
|
||||
*/ ?>
|
||||
<?php if (empty($task['nb_comments']) && $this->user->hasProjectAccess('CommentController', 'save', $task['project_id'])): ?>
|
||||
<?php /* The hook renders at the END of the icons row; relocate.js moves this span up to
|
||||
the native comment slot (before the description icon) so the order matches. */ ?>
|
||||
<span class="organontweaks-comment-add">
|
||||
<?= $this->modal->medium('comments-o', '+', 'CommentListController', 'show', array('task_id' => $task['id']), t('Add a comment')) ?>
|
||||
</span>
|
||||
<?php endif ?>
|
||||
55
Template/config/show.php
Normal file
55
Template/config/show.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<div class="page-header">
|
||||
<h2><?= t('Organon Tweaks') ?></h2>
|
||||
</div>
|
||||
|
||||
<form method="post" action="<?= $this->url->href('ConfigController', 'save', array('plugin' => 'OrganonTweaks')) ?>" autocomplete="off">
|
||||
<?= $this->form->csrf() ?>
|
||||
|
||||
<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) ?>
|
||||
<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">
|
||||
<?= 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>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<?= $this->form->checkbox('organon_tweaks_quick_click_only', t('Open a card only on a quick click'), 1, isset($values['organon_tweaks_quick_click_only']) && $values['organon_tweaks_quick_click_only'] == 1) ?>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-blue"><?= t('Save') ?></button>
|
||||
</div>
|
||||
</form>
|
||||
3
Template/config/sidebar.php
Normal file
3
Template/config/sidebar.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<li <?= $this->app->checkMenuSelection('ConfigController', 'show', 'OrganonTweaks') ?>>
|
||||
<?= $this->url->link(t('Organon Tweaks'), 'ConfigController', 'show', array('plugin' => 'OrganonTweaks')) ?>
|
||||
</li>
|
||||
3
Template/layout/emphasize_duedate.php
Normal file
3
Template/layout/emphasize_duedate.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<?php if ($this->OrganonProjectHelper->isEmphasizeDueDate()): ?>
|
||||
<style>.task-date { font-size: 1.375em; font-weight: bold; }</style>
|
||||
<?php endif ?>
|
||||
@@ -1 +0,0 @@
|
||||
<div class="skeleton-plugin-marker">Skeleton</div>
|
||||
24
Template/project/integration.php
Normal file
24
Template/project/integration.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?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>
|
||||
1
Template/project_header/board_filters.php
Normal file
1
Template/project_header/board_filters.php
Normal file
@@ -0,0 +1 @@
|
||||
<?php $this->OrganonProjectHelper->ensureBoardFilters($project['id']) ?>
|
||||
Reference in New Issue
Block a user