From 4d7f88aa941b5a783ac952257c24b032a28bf388 Mon Sep 17 00:00:00 2001
From: Ruben Carlo Benante
Date: Thu, 9 Jul 2026 06:49:15 -0300
Subject: [PATCH] v1.6 duemonth/aroundmonth search keywords + auto-added month
filter bookmarks
---
Controller/ConfigController.php | 17 +++-
Filter/TaskMonthRangeFilter.php | 92 +++++++++++++++++
Helper/OrganonProjectHelper.php | 104 ++++++++++++++------
Plugin.php | 28 ++++--
Template/config/show.php | 7 ++
Template/project_header/board_filters.php | 1 +
Template/project_header/show_all_filter.php | 1 -
VERSION | 2 +-
8 files changed, 211 insertions(+), 41 deletions(-)
create mode 100644 Filter/TaskMonthRangeFilter.php
create mode 100644 Template/project_header/board_filters.php
delete mode 100644 Template/project_header/show_all_filter.php
diff --git a/Controller/ConfigController.php b/Controller/ConfigController.php
index a35c829..55d8d24 100644
--- a/Controller/ConfigController.php
+++ b/Controller/ConfigController.php
@@ -20,6 +20,7 @@ class ConfigController extends BaseController
'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),
),
'errors' => array(),
)));
@@ -35,6 +36,7 @@ class ConfigController extends BaseController
$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;
if ($this->configModel->save(array(
'organon_tweaks_always_comment_icon' => $alwaysCommentIcon,
@@ -43,20 +45,27 @@ class ConfigController extends BaseController
'organon_tweaks_enhance_recurrence' => $enhanceRecurrence,
'organon_tweaks_emphasize_duedate_default' => $emphasizeDueDateDefault,
'organon_tweaks_show_all_filter' => $showAllFilter,
+ 'organon_tweaks_month_filters' => $monthFilters,
))) {
$this->flash->success(t('Settings saved successfully.'));
} else {
$this->flash->failure(t('Unable to save your settings.'));
}
- // Apply the "Show all tasks" toggle across all boards immediately: on -> (re)create the
- // filter everywhere, off -> remove every one (it is a plugin-managed, reserved-name filter).
+ // 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->addShowAllFilterToAllProjects();
+ $projectHelper->addGroupToAllProjects($projectHelper::GROUP_SHOW_ALL);
} else {
- $projectHelper->removeAllShowAllFilters();
+ $projectHelper->removeGroup($projectHelper::GROUP_SHOW_ALL);
+ }
+
+ if ($monthFilters === 1) {
+ $projectHelper->addGroupToAllProjects($projectHelper::GROUP_MONTH);
+ } else {
+ $projectHelper->removeGroup($projectHelper::GROUP_MONTH);
}
$this->response->redirect($this->helper->url->to('ConfigController', 'show', array('plugin' => 'OrganonTweaks')));
diff --git a/Filter/TaskMonthRangeFilter.php b/Filter/TaskMonthRangeFilter.php
new file mode 100644
index 0000000..bc7c317
--- /dev/null
+++ b/Filter/TaskMonthRangeFilter.php
@@ -0,0 +1,92 @@
+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", ...
+ }
+}
diff --git a/Helper/OrganonProjectHelper.php b/Helper/OrganonProjectHelper.php
index 35ed748..3862af2 100644
--- a/Helper/OrganonProjectHelper.php
+++ b/Helper/OrganonProjectHelper.php
@@ -16,19 +16,29 @@ use Kanboard\Model\CustomFilterModel;
* 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 a shared "Show all tasks" custom filter (an empty query) on every board, so there
- * is always a one-click way to clear the board filter and reveal open + closed tasks.
+ * 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';
- // The plugin RESERVES this custom-filter name: a fixed (non-translated) string so the dedup is
- // stable across locales. A user's own filter with this name is absorbed into the canonical form.
- const SHOW_ALL_FILTER_NAME = 'Show all tasks';
+ // 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),
+ );
/**
* Should the current board emphasize the due date? Board pages only (project_id in the route).
@@ -58,15 +68,17 @@ class OrganonProjectHelper extends Base
}
/**
- * Ensure the board has exactly one canonical "Show all tasks" filter (empty query, shared).
- * Idempotent: create it when absent, force a same-named one back to the canonical form (reserved
- * name), and drop any duplicates. Called lazily on every project-header render, so it covers old
- * and new boards alike. 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).
+ * 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 int $project_id
+ * @param string $name
+ * @param string $query
+ * @param int $append
*/
- public function ensureShowAllFilter($project_id)
+ public function ensureReservedFilter($project_id, $name, $query, $append)
{
$project_id = (int) $project_id;
$user_id = (int) $this->userSession->getId();
@@ -75,30 +87,32 @@ class OrganonProjectHelper extends Base
return;
}
+ $append = $append ? 1 : 0;
+
$existing = $this->db->table(CustomFilterModel::TABLE)
->eq('project_id', $project_id)
- ->eq('name', self::SHOW_ALL_FILTER_NAME)
+ ->eq('name', $name)
->findAll();
if (empty($existing)) {
$this->db->table(CustomFilterModel::TABLE)->insert(array(
- 'filter' => '',
+ 'filter' => $query,
'project_id' => $project_id,
'user_id' => $user_id,
- 'name' => self::SHOW_ALL_FILTER_NAME,
+ 'name' => $name,
'is_shared' => 1,
- 'append' => 0,
+ 'append' => $append,
));
return;
}
- // Reserved name: keep one, force it to the canonical empty + shared form, drop the rest.
+ // Reserved name: keep one, force it to the canonical form, drop the rest.
$keep = array_shift($existing);
- if ($keep['filter'] !== '' || (int) $keep['is_shared'] !== 1) {
+ 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' => '', 'is_shared' => 1));
+ ->update(array('filter' => $query, 'is_shared' => 1, 'append' => $append));
}
foreach ($existing as $duplicate) {
@@ -107,23 +121,55 @@ class OrganonProjectHelper extends Base
}
/**
- * Toggle turned ON: eagerly (re)create the "Show all tasks" filter on every existing board.
- * ensureShowAllFilter() keeps covering boards created afterwards.
+ * 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 addShowAllFilterToAllProjects()
+ public function ensureBoardFilters($project_id)
{
- foreach ($this->projectModel->getAll() as $project) {
- $this->ensureShowAllFilter((int) $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);
+ }
+ }
+
+ private function ensureGroup($project_id, array $rows)
+ {
+ foreach ($rows as $row) {
+ $this->ensureReservedFilter($project_id, $row[0], $row[1], $row[2]);
}
}
/**
- * Toggle turned OFF: remove every "Show all tasks" filter across all boards (reserved name).
+ * 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 removeAllShowAllFilters()
+ public function addGroupToAllProjects(array $rows)
{
- $this->db->table(CustomFilterModel::TABLE)
- ->eq('name', self::SHOW_ALL_FILTER_NAME)
- ->remove();
+ 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();
}
}
diff --git a/Plugin.php b/Plugin.php
index fda3f36..ed0a194 100644
--- a/Plugin.php
+++ b/Plugin.php
@@ -58,11 +58,27 @@ class Plugin extends Base
));
}
- // Tweak: keep a shared "Show all tasks" custom filter (empty query -> open + closed) on every
- // board as a one-click "clear the filter". Ensured lazily when a project header renders, so
- // it covers old and new boards. The config toggle bulk-adds/removes them (ConfigController).
- if ((int) $this->configModel->get('organon_tweaks_show_all_filter', 1) === 1) {
- $this->template->hook->attach('template:project:header:before', 'organonTweaks:project_header/show_all_filter');
+ // Auto-managed shared custom filters (v1.5 "Show all tasks", v1.6 month 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 (ConfigController).
+ $showAllFilters = (int) $this->configModel->get('organon_tweaks_show_all_filter', 1) === 1;
+ $monthFilters = (int) $this->configModel->get('organon_tweaks_month_filters', 1) === 1;
+ if ($showAllFilters || $monthFilters) {
+ $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` = a whole calendar month; `aroundmonth:VALUE` = that month padded by 10
+ // days on each side. 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));
+ });
}
}
@@ -90,7 +106,7 @@ class Plugin extends Base
public function getPluginVersion()
{
- return '1.5.0';
+ return '1.6.0';
}
public function getPluginHomepage()
diff --git a/Template/config/show.php b/Template/config/show.php
index c2eae37..6ab8608 100644
--- a/Template/config/show.php
+++ b/Template/config/show.php
@@ -47,6 +47,13 @@