12 Commits
v0.2.1 ... v1.5

17 changed files with 1073 additions and 54 deletions

View File

@@ -0,0 +1,56 @@
/*
* RecoReco -- keep the "days before" field within one period of the selected frequency.
*
* The Recurring schedule modal is injected by AJAX, so we react to the frequency <select> both when
* it appears (MutationObserver) and when it changes (delegated event). Daily -> field disabled (0);
* weekly -> max 6; monthly -> max 27; yearly -> max 364. The server clamps too, so this is only the
* UI guard.
*/
(function () {
"use strict";
var CAPS = { daily: 0, weekly: 6, monthly_day: 27, monthly_dow: 27, yearly: 364 };
function apply(select) {
// "days before" ceiling / disable by frequency.
var input = document.querySelector('input[name="recoreco_days_before"]');
if (input) {
var max = CAPS.hasOwnProperty(select.value) ? CAPS[select.value] : 27;
if (max === 0) {
input.value = 0;
input.setAttribute("max", "0");
input.disabled = true;
} else {
input.disabled = false;
input.setAttribute("max", String(max));
if (parseInt(input.value, 10) > max) {
input.value = max;
}
}
}
// The last-day/weekday checkbox only means something for monthly and yearly.
var lastday = document.querySelector(".recoreco-lastday");
if (lastday) {
var monthlyOrYearly = select.value === "monthly_day" || select.value === "monthly_dow" || select.value === "yearly";
lastday.style.display = monthlyOrYearly ? "" : "none";
}
}
document.addEventListener("change", function (e) {
if (e.target && e.target.name === "recoreco_frequency") {
apply(e.target);
}
});
if (window.MutationObserver) {
new MutationObserver(function () {
var select = document.querySelector('select[name="recoreco_frequency"]:not([data-recoreco-init])');
if (select) {
select.setAttribute("data-recoreco-init", "1");
apply(select);
}
}).observe(document.body, { childList: true, subtree: true });
}
})();

View File

@@ -0,0 +1,32 @@
<?php
namespace Kanboard\Plugin\RecoReco\Console;
use Kanboard\Console\BaseCommand;
use Kanboard\Plugin\RecoReco\Model\RecoRecoModel;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* CLI entry point for the RecoReco engine. Run by cron (four times a day from v1.0). Can be run by
* hand to test: ./cli recoreco:run
*/
class RecoRecoCommand extends BaseCommand
{
protected function configure()
{
$this
->setName('recoreco:run')
->setDescription('Spawn RecoReco calendar-scheduled cards whose occurrence is due');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$model = new RecoRecoModel($this->container);
$count = $model->run();
$output->writeln('RecoReco: spawned '.$count.' card(s).');
return 0;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Kanboard\Plugin\RecoReco\Controller;
use Kanboard\Controller\BaseController;
use Kanboard\Plugin\RecoReco\Model\RecoRecoModel;
/**
* Global settings page for RecoReco.
*
* Admin-only for free: the application ACL is keyed by the SHORT controller name, and core maps
* 'ConfigController' => APP_ADMIN (AuthenticationProvider::getApplicationAccessMap), so every action
* on a controller named ConfigController requires an application administrator -- plugin included.
* Keep admin-only actions in THIS controller; a differently-named controller would default to
* APP_USER (any logged-in user).
*/
class ConfigController extends BaseController
{
public function show()
{
$this->response->html($this->helper->layout->config('recoReco:config/show', array(
'title' => t('Settings').' &gt; '.t('RecoReco'),
)));
}
/**
* Run the scheduler now -- the same pass cron runs: spawn every template card whose occurrence
* is due, across all boards. Idempotent (an occurrence is never spawned twice), so it is safe to
* trigger at any time.
*/
public function run()
{
$this->checkCSRFForm();
$count = (new RecoRecoModel($this->container))->run();
$this->flash->success(t('RecoReco: spawned %d card(s).', $count));
$this->response->redirect($this->helper->url->to('ConfigController', 'show', array('plugin' => 'RecoReco')));
}
}

View File

@@ -7,29 +7,35 @@ use Kanboard\Model\TaskModel;
/** /**
* The "Recurring schedule" modal: configure a card's calendar recurrence and store it in task * The "Recurring schedule" modal: configure a card's calendar recurrence and store it in task
* metadata. v0.2 is storage only -- no spawning (that is the CLI engine in later versions). * metadata. The CLI engine (RecoRecoModel) does the actual spawning.
* *
* Gate: a card can only be enabled when it has a due date (the recurrence anchor) and is not * Gate: a card can only be enabled when it has a due date (the recurrence anchor), is not already a
* already a native-recurring card (RecoReco and native recurrence are mutually exclusive). * native-recurring card (mutually exclusive), and the board has a column other than the card's own
* (the copy must land elsewhere).
*/ */
class RecurrenceController extends BaseController class RecurrenceController extends BaseController
{ {
public function edit(array $values = array(), array $errors = array()) public function edit(array $values = array(), array $errors = array())
{ {
$task = $this->getTask(); $task = $this->getTask();
$columns = $this->targetColumns($task);
$meta = $this->taskMetadataModel->getAll($task['id']);
if (empty($values)) { if (empty($values)) {
$values = $this->getStoredValues($task); $values = $this->getStoredValues($meta, $task, $columns);
} }
$this->response->html($this->template->render('recoReco:recurrence/edit', array( $this->response->html($this->template->render('recoReco:recurrence/edit', array(
'task' => $task, 'task' => $task,
'values' => $values, 'values' => $values,
'errors' => $errors, 'errors' => $errors,
'columns_list' => $this->columnModel->getList($task['project_id']), 'columns_list' => $columns,
'frequency_list' => $this->getFrequencyList(), 'frequency_list' => $this->getFrequencyList(),
'has_due_date' => ! empty($task['date_due']), 'has_due_date' => ! empty($task['date_due']),
'is_native' => $task['recurrence_status'] != TaskModel::RECURRING_STATUS_NONE, 'is_native' => $task['recurrence_status'] != TaskModel::RECURRING_STATUS_NONE,
'has_target' => ! empty($columns),
'is_clone' => isset($meta['recoreco_clone']) && $meta['recoreco_clone'] == 1,
'source_id' => isset($meta['recoreco_source']) ? (int) $meta['recoreco_source'] : 0,
))); )));
} }
@@ -37,45 +43,84 @@ class RecurrenceController extends BaseController
{ {
$task = $this->getTask(); $task = $this->getTask();
$input = $this->request->getValues(); $input = $this->request->getValues();
$columns = $this->targetColumns($task);
// Only a plain card (has a due date, not native-recurring) may be enabled. // A clone can never be made recurring (that would recurse). Guard even though the modal
$can_recur = ! empty($task['date_due']) && $task['recurrence_status'] == TaskModel::RECURRING_STATUS_NONE; // hides the form for clones.
$is_clone = (int) $this->taskMetadataModel->get($task['id'], 'recoreco_clone', 0) === 1;
// Only a plain card (a due date, not native-recurring, not a clone, a valid target) may enable.
$can_recur = ! empty($task['date_due'])
&& $task['recurrence_status'] == TaskModel::RECURRING_STATUS_NONE
&& ! $is_clone
&& ! empty($columns);
$enabled = ($can_recur && isset($input['recoreco_enabled']) && $input['recoreco_enabled'] == 1) ? 1 : 0; $enabled = ($can_recur && isset($input['recoreco_enabled']) && $input['recoreco_enabled'] == 1) ? 1 : 0;
$frequency = isset($input['recoreco_frequency']) && array_key_exists($input['recoreco_frequency'], $this->getFrequencyList()) $frequency = isset($input['recoreco_frequency']) && array_key_exists($input['recoreco_frequency'], $this->getFrequencyList())
? $input['recoreco_frequency'] ? $input['recoreco_frequency']
: 'monthly_day'; : 'monthly_day';
// Keep the lead time under one period (else a copy's fire time crosses the prior occurrence).
$daysBefore = isset($input['recoreco_days_before']) && ctype_digit((string) $input['recoreco_days_before']) ? (int) $input['recoreco_days_before'] : 0;
$daysBefore = min($daysBefore, $this->maxDaysBefore($frequency));
$values = array( $values = array(
'recoreco_enabled' => $enabled, 'recoreco_enabled' => $enabled,
'recoreco_target_column' => isset($input['recoreco_target_column']) ? (int) $input['recoreco_target_column'] : (int) $task['column_id'], 'recoreco_target_column' => $this->resolveTarget(isset($input['recoreco_target_column']) ? $input['recoreco_target_column'] : 0, $columns),
'recoreco_frequency' => $frequency, 'recoreco_frequency' => $frequency,
'recoreco_last_day' => isset($input['recoreco_last_day']) ? 1 : 0, 'recoreco_last_day' => isset($input['recoreco_last_day']) ? 1 : 0,
'recoreco_days_before' => isset($input['recoreco_days_before']) && ctype_digit((string) $input['recoreco_days_before']) ? (int) $input['recoreco_days_before'] : 0, 'recoreco_days_before' => $daysBefore,
'recoreco_link_copies' => isset($input['recoreco_link_copies']) ? 1 : 0,
); );
// Capture the anchor (the due date at enable time) -- the drift-free pattern. The engine // Capture the anchor (the due date at enable time) -- the drift-free pattern.
// (v0.3) reads it to compute occurrences.
if ($enabled) { if ($enabled) {
$values['recoreco_anchor'] = (int) $task['date_due']; $values['recoreco_anchor'] = (int) $task['date_due'];
} }
$this->taskMetadataModel->save($task['id'], $values); $this->taskMetadataModel->save($task['id'], $values);
// Reconcile the template's duplicate links with the setting right away.
$model = new \Kanboard\Plugin\RecoReco\Model\RecoRecoModel($this->container);
$model->syncCloneLinks($task['id'], $values['recoreco_link_copies'] == 1);
$this->flash->success(t('Recurring schedule saved.')); $this->flash->success(t('Recurring schedule saved.'));
return $this->response->redirect($this->helper->url->to('TaskViewController', 'show', array('task_id' => $task['id'])), true); return $this->response->redirect($this->helper->url->to('TaskViewController', 'show', array('task_id' => $task['id'])), true);
} }
private function getStoredValues(array $task) // Columns the copy can land in: every column except the template's own (the copy must move).
private function targetColumns(array $task)
{ {
$meta = $this->taskMetadataModel->getAll($task['id']); $columns = $this->columnModel->getList($task['project_id']);
unset($columns[$task['column_id']]);
return $columns;
}
// A stored/posted target, if still a valid non-current column; otherwise the first one (or 0).
private function resolveTarget($candidate, array $columns)
{
$candidate = (int) $candidate;
if (array_key_exists($candidate, $columns)) {
return $candidate;
}
$keys = array_keys($columns);
return empty($keys) ? 0 : (int) $keys[0];
}
private function getStoredValues(array $meta, array $task, array $columns)
{
return array( return array(
'recoreco_enabled' => isset($meta['recoreco_enabled']) ? (int) $meta['recoreco_enabled'] : 0, 'recoreco_enabled' => isset($meta['recoreco_enabled']) ? (int) $meta['recoreco_enabled'] : 0,
'recoreco_target_column' => isset($meta['recoreco_target_column']) ? (int) $meta['recoreco_target_column'] : (int) $task['column_id'], 'recoreco_target_column' => $this->resolveTarget(isset($meta['recoreco_target_column']) ? $meta['recoreco_target_column'] : 0, $columns),
'recoreco_frequency' => isset($meta['recoreco_frequency']) ? $meta['recoreco_frequency'] : 'monthly_day', 'recoreco_frequency' => isset($meta['recoreco_frequency']) ? $meta['recoreco_frequency'] : 'monthly_day',
'recoreco_last_day' => isset($meta['recoreco_last_day']) ? (int) $meta['recoreco_last_day'] : 0, 'recoreco_last_day' => isset($meta['recoreco_last_day']) ? (int) $meta['recoreco_last_day'] : 0,
'recoreco_days_before' => isset($meta['recoreco_days_before']) ? (int) $meta['recoreco_days_before'] : 0, 'recoreco_days_before' => isset($meta['recoreco_days_before']) ? (int) $meta['recoreco_days_before'] : 0,
'recoreco_link_copies' => isset($meta['recoreco_link_copies']) ? (int) $meta['recoreco_link_copies'] : 0,
); );
} }
@@ -89,4 +134,22 @@ class RecurrenceController extends BaseController
'daily' => t('Daily'), 'daily' => t('Daily'),
); );
} }
/**
* The largest allowed "days before" for a frequency -- kept strictly under one period so a
* copy's fire time never crosses the previous occurrence. Daily = 0 (disabled).
*/
private function maxDaysBefore($frequency)
{
switch ($frequency) {
case 'daily':
return 0;
case 'weekly':
return 6;
case 'yearly':
return 364;
default: // monthly_day, monthly_dow
return 27;
}
}
} }

38
Helper/RecoRecoHelper.php Normal file
View File

@@ -0,0 +1,38 @@
<?php
namespace Kanboard\Plugin\RecoReco\Helper;
use Kanboard\Core\Base;
use Kanboard\Model\TaskModel;
/**
* Board helper: which RecoReco icon a card should show.
*
* Single-icon rule -- RecoReco only renders when the card is NOT native-recurring
* (recurrence_status == NONE), so native's icon and RecoReco's icon can never appear together.
*/
class RecoRecoHelper extends Base
{
/**
* @param array $task A board task row (has recurrence_status and id).
* @return string 'template', 'clone', or '' (no RecoReco icon).
*/
public function iconType(array $task)
{
if ((int) $task['recurrence_status'] !== TaskModel::RECURRING_STATUS_NONE) {
return '';
}
$meta = $this->taskMetadataModel->getAll($task['id']);
if (isset($meta['recoreco_enabled']) && $meta['recoreco_enabled'] == 1) {
return 'template';
}
if (isset($meta['recoreco_clone']) && $meta['recoreco_clone'] == 1) {
return 'clone';
}
return '';
}
}

View File

@@ -0,0 +1,206 @@
<?php
namespace Kanboard\Plugin\RecoReco\Model;
/**
* Pure calendar math for RecoReco.
*
* Given an anchor due date (the drift-free pattern: it carries the day-of-month, the weekday, and
* the time), a frequency, and the "last day" flag, it computes occurrence datetimes. It has NO
* container dependencies, so it is unit-testable in isolation (Test/OccurrenceCalculatorTest.php).
*
* Occurrences are always computed from the anchor, never by marching a clamped date forward, so a
* "30th" schedule goes 30, 28/29, 30, 30... and never drifts.
*/
class OccurrenceCalculator
{
const MAX_MONTHS = 120;
const MAX_YEARS = 20;
/**
* The earliest occurrence at/after $reference (when $inclusive) or strictly after it.
*
* @param int $anchor Unix timestamp: the pattern.
* @param string $frequency daily | weekly | monthly_day | monthly_dow | yearly
* @param bool $lastDay
* @param int $reference Unix timestamp.
* @param bool $inclusive
* @return int|null Occurrence timestamp, or null for an unknown frequency.
*/
public function occurrenceFrom($anchor, $frequency, $lastDay, $reference, $inclusive = false)
{
$anchor = (int) $anchor;
$reference = (int) $reference;
$lastDay = (bool) $lastDay;
$inclusive = (bool) $inclusive;
switch ($frequency) {
case 'daily':
return $this->daily($anchor, $reference, $inclusive);
case 'weekly':
return $this->weekly($anchor, $reference, $inclusive);
case 'monthly_day':
return $this->monthlyByDay($anchor, $lastDay, $reference, $inclusive);
case 'monthly_dow':
return $this->monthlyByWeekday($anchor, $lastDay, $reference, $inclusive);
case 'yearly':
return $this->yearly($anchor, $lastDay, $reference, $inclusive);
default:
return null;
}
}
private function daily($anchor, $reference, $inclusive)
{
$h = (int) date('G', $anchor);
$i = (int) date('i', $anchor);
$s = (int) date('s', $anchor);
$y = (int) date('Y', $reference);
$m = (int) date('n', $reference);
$d = (int) date('j', $reference);
for ($k = 0; $k < 3; $k++) {
$o = mktime($h, $i, $s, $m, $d + $k, $y);
if ($this->matches($o, $reference, $inclusive)) {
return $o;
}
}
return null;
}
private function weekly($anchor, $reference, $inclusive)
{
$h = (int) date('G', $anchor);
$i = (int) date('i', $anchor);
$s = (int) date('s', $anchor);
$w = (int) date('w', $anchor);
$y = (int) date('Y', $reference);
$m = (int) date('n', $reference);
$d = (int) date('j', $reference);
for ($k = 0; $k < 8; $k++) {
$o = mktime($h, $i, $s, $m, $d + $k, $y);
if ((int) date('w', $o) === $w && $this->matches($o, $reference, $inclusive)) {
return $o;
}
}
return null;
}
private function monthlyByDay($anchor, $lastDay, $reference, $inclusive)
{
$anchorDay = (int) date('j', $anchor);
$h = (int) date('G', $anchor);
$i = (int) date('i', $anchor);
$s = (int) date('s', $anchor);
$y = (int) date('Y', $reference);
$m = (int) date('n', $reference);
for ($k = 0; $k < self::MAX_MONTHS; $k++) {
$lastDom = (int) date('t', mktime(0, 0, 0, $m, 1, $y));
$day = $lastDay ? $lastDom : min($anchorDay, $lastDom);
$o = mktime($h, $i, $s, $m, $day, $y);
if ($this->matches($o, $reference, $inclusive)) {
return $o;
}
$this->nextMonth($m, $y);
}
return null;
}
private function monthlyByWeekday($anchor, $lastDay, $reference, $inclusive)
{
$h = (int) date('G', $anchor);
$i = (int) date('i', $anchor);
$s = (int) date('s', $anchor);
$w = (int) date('w', $anchor);
$k = intdiv((int) date('j', $anchor) - 1, 7) + 1; // 1..5: which occurrence of $w in the month
$y = (int) date('Y', $reference);
$m = (int) date('n', $reference);
for ($j = 0; $j < self::MAX_MONTHS; $j++) {
$day = $lastDay ? $this->lastWeekdayOfMonth($y, $m, $w) : $this->nthWeekdayOfMonth($y, $m, $w, $k);
if ($day !== null) {
$o = mktime($h, $i, $s, $m, $day, $y);
if ($this->matches($o, $reference, $inclusive)) {
return $o;
}
}
$this->nextMonth($m, $y);
}
return null;
}
private function yearly($anchor, $lastDay, $reference, $inclusive)
{
$month = (int) date('n', $anchor);
$anchorDay = (int) date('j', $anchor);
$h = (int) date('G', $anchor);
$i = (int) date('i', $anchor);
$s = (int) date('s', $anchor);
$y = (int) date('Y', $reference);
for ($j = 0; $j < self::MAX_YEARS; $j++) {
$lastDom = (int) date('t', mktime(0, 0, 0, $month, 1, $y));
$day = $lastDay ? $lastDom : min($anchorDay, $lastDom);
$o = mktime($h, $i, $s, $month, $day, $y);
if ($this->matches($o, $reference, $inclusive)) {
return $o;
}
$y++;
}
return null;
}
// The day-of-month of the k-th weekday $w in month $m/$y, or null if that month has no k-th one.
private function nthWeekdayOfMonth($y, $m, $w, $k)
{
$firstDow = (int) date('w', mktime(0, 0, 0, $m, 1, $y));
$firstW = 1 + (($w - $firstDow + 7) % 7);
$day = $firstW + ($k - 1) * 7;
$lastDom = (int) date('t', mktime(0, 0, 0, $m, 1, $y));
return $day <= $lastDom ? $day : null;
}
// The day-of-month of the last weekday $w in month $m/$y.
private function lastWeekdayOfMonth($y, $m, $w)
{
$lastDom = (int) date('t', mktime(0, 0, 0, $m, 1, $y));
$lastDow = (int) date('w', mktime(0, 0, 0, $m, $lastDom, $y));
return $lastDom - (($lastDow - $w + 7) % 7);
}
private function matches($occurrence, $reference, $inclusive)
{
return $inclusive ? $occurrence >= $reference : $occurrence > $reference;
}
private function nextMonth(&$m, &$y)
{
$m++;
if ($m > 12) {
$m = 1;
$y++;
}
}
}

289
Model/RecoRecoModel.php Normal file
View File

@@ -0,0 +1,289 @@
<?php
namespace Kanboard\Plugin\RecoReco\Model;
use Kanboard\Core\Base;
use Kanboard\Model\TaskModel;
/**
* The RecoReco engine: find enabled templates and spawn their due occurrences.
*
* Called by the recoreco:run CLI command (from cron, or from the identical "Run now" button). One
* run is one pass over every enabled template; running it five times a day instead of four is
* harmless -- see the idempotency note below.
*
* Per template there is a SINGLE algorithm (no first-run vs catch-up split):
*
* 1. Yield to native recurrence if the card became native-recurring (decision 13).
* 2. Walk occurrences forward from the cursor (the template due date), collecting every one whose
* fire time (occurrence - days_before) has arrived (before this run's horizon). The first
* occurrence still ahead of the horizon is the "next future" one.
* 3. Cap to the most recent MAX_BACKFILL: if more than that are due (a template set far in the
* past), skip the OLDEST overflow and keep the newest ones -- contiguous, no gap.
* 4. Spawn each kept occurrence into the target column, dated to that occurrence.
* 5. Park the cursor on the next future occurrence and stop.
*
* Idempotency: after a run the cursor sits on a future occurrence, so a second run the same day
* collects nothing. As a belt-and-suspenders guard against a run overlapping cron on the same
* occurrence, spawn() is skipped when a clone for that (template, due) already exists (cloneExists).
*/
class RecoRecoModel extends Base
{
// Most recent occurrences to actually spawn in one run. A template set far in the past spawns
// these newest ones and skips the older overflow (no gap), rather than flooding the board.
const MAX_BACKFILL = 12;
// Safety bound on the occurrence walk so a misbehaving calculator can never loop forever
// (1200 monthly steps is a century).
const MAX_ITERATIONS = 1200;
private $calc;
public function run($now = null)
{
$now = $now ?: time();
$horizon = $this->horizon($now);
$spawned = 0;
$task_ids = $this->db->table('task_has_metadata')
->eq('name', 'recoreco_enabled')
->eq('value', '1')
->findAllByColumn('task_id');
foreach ($task_ids as $task_id) {
$spawned += $this->processTemplate((int) $task_id, $now, $horizon);
}
return $spawned;
}
/**
* The look-ahead horizon for this run: fire everything whose fire time is before it. Computed as
* "ceil now to the next 6h boundary, then +12h", which lands on the four daily runs:
* 05:58 -> 18:00, 11:58 -> 00:00, 17:58 -> 06:00, 23:58 -> 12:00. Gives a 12h look-ahead with a
* 6h overlap between runs, so a missed run is covered by its neighbour. Past-due occurrences are
* naturally included (their fire time is < now < horizon) -- that is the back-fill.
*
* @param int $now
* @return int
*/
private function horizon($now)
{
$startOfDay = mktime(0, 0, 0, (int) date('n', $now), (int) date('j', $now), (int) date('Y', $now));
$boundaryHour = (intdiv((int) date('G', $now), 6) + 1) * 6;
return $startOfDay + $boundaryHour * 3600 + 12 * 3600;
}
public function processTemplate($task_id, $now, $horizon)
{
$task = $this->taskFinderModel->getById($task_id);
if (empty($task)) {
return 0;
}
// Native wins: if the card became native-recurring, RecoReco yields (decision 13). Leave a
// visible trail in the task's activity stream, attributed to the card's owner (creator_id has
// a FK to users, so a system id like 0 would make the insert fail silently -- no user 0).
if ($task['recurrence_status'] != TaskModel::RECURRING_STATUS_NONE) {
$this->taskMetadataModel->save($task_id, array('recoreco_enabled' => 0));
$this->logger->info('RecoReco: disabled on task '.$task_id.' (now native-recurring)');
$this->projectActivityModel->createEvent(
(int) $task['project_id'],
$task_id,
(int) $task['creator_id'],
'recoreco.task.disable',
array('task' => array('id' => $task_id, 'title' => $task['title']))
);
return 0;
}
$meta = $this->taskMetadataModel->getAll($task_id);
$frequency = isset($meta['recoreco_frequency']) ? $meta['recoreco_frequency'] : '';
// All five frequencies are supported since v1.1; skip anything unknown.
if (! in_array($frequency, array('daily', 'weekly', 'monthly_day', 'monthly_dow', 'yearly'), true)) {
return 0;
}
// The anchor defines the recurrence PATTERN (which day-of-month, which weekday ordinal); the
// cursor (the template due date) is our POSITION in that pattern.
$anchor = isset($meta['recoreco_anchor']) ? (int) $meta['recoreco_anchor'] : 0;
if ($anchor <= 0) {
return 0;
}
$lastDay = isset($meta['recoreco_last_day']) && $meta['recoreco_last_day'] == 1;
$daysBefore = isset($meta['recoreco_days_before']) ? (int) $meta['recoreco_days_before'] : 0;
$targetColumn = isset($meta['recoreco_target_column']) ? (int) $meta['recoreco_target_column'] : (int) $task['column_id'];
// Default off: only keep the duplicate link when explicitly turned on.
$linkCopies = isset($meta['recoreco_link_copies']) && $meta['recoreco_link_copies'] == 1;
// Phase 1 -- walk forward from the cursor, collecting every occurrence whose fire time has
// arrived. The loop exits on the first occurrence still ahead of the horizon, which is then
// the "next future" one to park on.
$cursor = (int) $task['date_due'];
$due = array();
$iterations = 0;
while ($cursor - $daysBefore * 86400 < $horizon && $iterations < self::MAX_ITERATIONS) {
$due[] = $cursor;
$next = $this->calculator()->occurrenceFrom($anchor, $frequency, $lastDay, $cursor, false);
if ($next === null || $next <= $cursor) {
break; // calculator cannot advance -- stop rather than loop
}
$cursor = $next;
$iterations++;
}
$nextFuture = $cursor;
// Phase 2 -- cap to the most recent MAX_BACKFILL, skipping the oldest overflow (no gap).
$skip = max(0, count($due) - self::MAX_BACKFILL);
$toSpawn = array_slice($due, $skip);
// Phase 3 -- spawn each kept occurrence, dated to that occurrence. Idempotent: skip any that
// this template has already spawned (survives a run overlapping cron on the same occurrence).
$spawned = 0;
foreach ($toSpawn as $occurrence) {
if ($this->cloneExists($task_id, $occurrence)) {
continue;
}
// duplicate() copies the template's date_due onto the clone, so set it to this occurrence
// first, then spawn.
$this->setDue($task_id, $occurrence);
$this->spawn($task, $targetColumn);
$spawned++;
}
// Phase 4 -- park the cursor on the next future occurrence and stop.
$this->setDue($task_id, $nextFuture);
// Reconcile the duplicate links with the setting (removes both new and old ones when off).
$this->syncCloneLinks($task_id, $linkCopies);
return $spawned;
}
/**
* Has this template already spawned a clone for the given occurrence? A clone records its source
* template (recoreco_source) and carries the occurrence as its due date, so the pair identifies
* it. This makes a repeated or overlapping run idempotent -- an occurrence is never spawned twice.
*
* @param int $template_id
* @param int $due
* @return bool
*/
private function cloneExists($template_id, $due)
{
$clone_ids = $this->db->table('task_has_metadata')
->eq('name', 'recoreco_source')
->eq('value', (string) $template_id)
->findAllByColumn('task_id');
if (empty($clone_ids)) {
return false;
}
return $this->db->table(TaskModel::TABLE)
->in('id', $clone_ids)
->eq('date_due', $due)
->exists();
}
/**
* Reconcile a template's "is a duplicate of" links with the link-copies setting. When off, remove
* the links to THIS template's own RecoReco clones (identified by recoreco_source, so manual
* links are left untouched). When on, do nothing -- new spawns already carry the link.
*
* @param int $template_id
* @param bool $linkCopies
*/
public function syncCloneLinks($template_id, $linkCopies)
{
if ($linkCopies) {
return;
}
foreach ($this->taskLinkModel->getAll($template_id) as $link) {
$opposite = (int) $link['task_id'];
if ((int) $this->taskMetadataModel->get($opposite, 'recoreco_source', 0) === (int) $template_id) {
$this->taskLinkModel->remove($link['id']);
}
}
}
private function setDue($task_id, $due)
{
// Move the template's due date directly (no task events fired). This is the cursor.
$this->db->table(TaskModel::TABLE)->eq('id', $task_id)->update(array('date_due' => $due));
}
private function spawn(array $template, $targetColumn)
{
// The template's date_due is already the occurrence (setDue), so duplicate() copies it onto
// the clone. duplicate() copies fields + tags + links + subtasks, but NO metadata, so the
// clone is not a template.
$newId = $this->taskDuplicationModel->duplicate($template['id']);
if ($newId === false) {
$this->logger->error('RecoReco: duplicate failed for task '.$template['id']);
return false;
}
// duplicate() lands the clone in the source column; move it to the chosen target (top).
if ($targetColumn > 0 && $targetColumn != $template['column_id']) {
$this->taskPositionModel->movePosition($template['project_id'], $newId, $targetColumn, 1, $template['swimlane_id']);
}
// Reset the "[DUPLICATE]" prefix back to the source title (no events).
$this->taskModificationModel->update(array('id' => $newId, 'title' => $template['title']), false);
// Kanboard's duplicate() adds an "is a duplicate of" task link between the template and the
// clone. It is reconciled with the recoreco_link_copies setting by syncCloneLinks() after
// the spawn loop (kept when on, removed when off).
// Mark the clone: white icon + cannot itself be made recurring; record its source template.
$this->taskMetadataModel->save($newId, array(
'recoreco_clone' => 1,
'recoreco_source' => $template['id'],
));
// Signal FinanceBuddy (advance the installment). Harmless no-op if nothing listens.
$payload = array('source_task_id' => (int) $template['id'], 'new_task_id' => (int) $newId);
$this->hook->reference('recoreco:task:spawned', $payload);
// Record the spawn in the clone's activity stream, attributed to the template's owner
// (creator_id has a FK to users, so a system id like 0 would silently fail the insert).
$this->projectActivityModel->createEvent(
(int) $template['project_id'],
$newId,
(int) $template['creator_id'],
'recoreco.task.spawn',
array(
'task' => array('id' => $newId, 'title' => $template['title']),
'template_id' => (int) $template['id'],
'template_title' => $template['title'],
)
);
return $newId;
}
private function calculator()
{
if ($this->calc === null) {
$this->calc = new OccurrenceCalculator();
}
return $this->calc;
}
}

View File

@@ -11,6 +11,37 @@ class Plugin extends Base
// "Recurring schedule" entry in the task Actions sidebar, right after the basic actions // "Recurring schedule" entry in the task Actions sidebar, right after the basic actions
// (next to native "Edit recurrence"). Opens the RecoReco config modal. // (next to native "Edit recurrence"). Opens the RecoReco config modal.
$this->template->hook->attach('template:task:sidebar:after-basic-actions', 'recoReco:task/sidebar_action'); $this->template->hook->attach('template:task:sidebar:after-basic-actions', 'recoReco:task/sidebar_action');
// Modal JS: keep "days before" within one period of the selected frequency.
$this->hook->on('template:layout:js', array(
'template' => 'plugins/RecoReco/Asset/js/recoreco-modal.js',
));
// Board card icon: black on a recurring template, white (inverse) on a generated copy.
$this->template->hook->attach('template:board:task:icons', 'recoReco:board/task_icon');
// Global settings page (Settings -> RecoReco) with a manual "Run now" button that triggers
// the same scheduler pass as cron. Admin-only for free via the shared 'ConfigController' ACL.
$this->template->hook->attach('template:config:sidebar', 'recoReco:config/sidebar');
// Activity-stream entries. The feed renders each event via event/<name-with-underscores>, so
// map our two event names to the plugin templates (a spawn on the new card, and the yield to
// native recurrence on the template). createEvent() writes the rows directly from the model.
$this->template->setTemplateOverride('event/recoreco_task_spawn', 'recoReco:event/recoreco_task_spawn');
$this->template->setTemplateOverride('event/recoreco_task_disable', 'recoReco:event/recoreco_task_disable');
// The scheduling engine runs from the CLI (cron). Registered CLI-only so web requests do
// not build the console app.
if (php_sapi_name() === 'cli') {
$this->container['cli']->add(new \Kanboard\Plugin\RecoReco\Console\RecoRecoCommand($this->container));
}
}
public function getHelpers()
{
return array(
'Plugin\RecoReco\Helper' => array('RecoRecoHelper'),
);
} }
public function getPluginName() public function getPluginName()
@@ -30,7 +61,7 @@ class Plugin extends Base
public function getPluginVersion() public function getPluginVersion()
{ {
return '0.2.1'; return '1.5.0';
} }
public function getPluginHomepage() public function getPluginHomepage()

View File

@@ -21,21 +21,45 @@ leaves native recurrence untouched (the two are mutually exclusive per card).
- **Lead time:** "create the copy N days before the due date", so the card shows up early enough - **Lead time:** "create the copy N days before the due date", so the card shows up early enough
to act on; the copy is still due on the real day. to act on; the copy is still due on the real day.
## Scheduling (cron)
The engine runs from the CLI command `recoreco:run`. Add it to cron **four times a day** -- at
05:58, 11:58, 17:58 and 23:58:
```
58 5,11,17,23 * * * cd /path/to/kanboard && php cli recoreco:run >> /var/log/recoreco.log 2>&1
```
(Point the path at your install and run it as the user that owns Kanboard's data.) Each run looks
ahead 12 hours; the runs overlap by 6 hours, so a single missed run is covered by the next one.
Each run walks a template forward from its current due date and spawns **every** occurrence whose
fire time has arrived -- so occurrences already in the past are backfilled, not skipped. To avoid
flooding the board when a template was set far in the past, a single run spawns at most the **12
most recent** due occurrences; any older overflow is skipped (contiguous, no gap) and the template
advances straight to the next future occurrence. `days before` shifts a copy earlier within the
lead. Runs are idempotent: an occurrence is never spawned twice, so running an extra time is safe.
You can trigger the exact same pass by hand -- either on the CLI (`php cli recoreco:run`) or with
the **Run now** button under **Settings -> RecoReco** (admin only). The button and cron do the
identical thing.
## Status ## Status
Early development. This version adds the **Recurring schedule** modal (a task sidebar action) and All five frequencies work (yearly, monthly by day, monthly by weekday, weekly, daily) with the
stores the settings in task metadata. The scheduling engine and the cron command arrive in the last-day rule, board recurrence icons, backfill with the 12-occurrence cap, the Run-now button, and
following versions. the FinanceBuddy installment hand-off.
## Requirements ## Requirements
- Kanboard >= 1.2.0 - Kanboard >= 1.2.0
- cron (to run `recoreco:run`)
## Installation ## Installation
Copy this folder into your Kanboard installation as `plugins/RecoReco/`. The directory name must be Copy this folder into your Kanboard installation as `plugins/RecoReco/`. The directory name must be
exactly `RecoReco` (Kanboard derives the plugin namespace from the folder name). No build step and exactly `RecoReco` (Kanboard derives the plugin namespace from the folder name). No build step and
no database migration. no database migration. Then add the cron entry above.
## License ## License

View File

@@ -0,0 +1,10 @@
<?php $type = $this->RecoRecoHelper->iconType($task) ?>
<?php if ($type === 'template'): ?>
<span title="<?= t('RecoReco: recurring template') ?>">
<i class="fa fa-refresh fa-rotate-90" role="img" aria-label="<?= t('RecoReco: recurring template') ?>"></i>
</span>
<?php elseif ($type === 'clone'): ?>
<span title="<?= t('RecoReco: generated copy') ?>">
<i class="fa fa-refresh fa-rotate-90 fa-inverse" role="img" aria-label="<?= t('RecoReco: generated copy') ?>"></i>
</span>
<?php endif ?>

14
Template/config/show.php Normal file
View File

@@ -0,0 +1,14 @@
<div class="page-header">
<h2><?= t('RecoReco') ?></h2>
</div>
<p class="form-help">
<?= t('Run the recurring-card scheduler now. This spawns every template card whose occurrence is due, on all boards -- the same pass the cron job runs. It is safe to run at any time: an occurrence is never spawned twice.') ?>
</p>
<form method="post" action="<?= $this->url->href('ConfigController', 'run', array('plugin' => 'RecoReco')) ?>">
<?= $this->form->csrf() ?>
<div class="form-actions">
<button type="submit" class="btn btn-blue"><?= t('Run now') ?></button>
</div>
</form>

View File

@@ -0,0 +1,3 @@
<li <?= $this->app->checkMenuSelection('ConfigController', 'show', 'RecoReco') ?>>
<?= $this->url->link(t('RecoReco'), 'ConfigController', 'show', array('plugin' => 'RecoReco')) ?>
</li>

View File

@@ -0,0 +1,9 @@
<p class="activity-title">
<?= e('RecoReco disabled recurrence on %s -- the card now uses Kanboard built-in recurrence',
$this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id']))
) ?>
<small class="activity-date"><?= $this->dt->datetime($date_creation) ?></small>
</p>
<div class="activity-description">
<p class="activity-task-title"><?= $this->text->e($task['title']) ?></p>
</div>

View File

@@ -0,0 +1,10 @@
<p class="activity-title">
<?= e('RecoReco created %s from the recurring template %s',
$this->url->link(t('#%d', $task['id']), 'TaskViewController', 'show', array('task_id' => $task['id'])),
$this->url->link($this->text->e($template_title), 'TaskViewController', 'show', array('task_id' => $template_id))
) ?>
<small class="activity-date"><?= $this->dt->datetime($date_creation) ?></small>
</p>
<div class="activity-description">
<p class="activity-task-title"><?= $this->text->e($task['title']) ?></p>
</div>

View File

@@ -1,13 +1,36 @@
<div class="page-header"> <div class="page-header">
<h2><?= t('Recurring schedule') ?><?php if ($has_due_date): ?> <small>(<?= date('Y-m-d H:i', $task['date_due']) ?>)</small><?php endif ?></h2> <h2>
<?= t('Recurring schedule') ?>
<?php if ($has_due_date): ?>
<small>(<?= $this->url->link(date('Y-m-d H:i', $task['date_due']), 'TaskModificationController', 'edit', array('task_id' => $task['id']), false, 'js-modal-large') ?>)</small>
<?php else: ?>
<small>-- <?= $this->url->link(t('set a due date'), 'TaskModificationController', 'edit', array('task_id' => $task['id']), false, 'js-modal-large') ?></small>
<?php endif ?>
</h2>
</div> </div>
<?php $can_recur = $has_due_date && ! $is_native ?> <?php if ($is_clone): ?>
<p class="alert alert-info">
<?= t('This card is a copy generated by RecoReco, so it cannot itself be made recurring.') ?>
<?php if ($source_id): ?>
<?= $this->url->link(t('Open the recurring template'), 'TaskViewController', 'show', array('task_id' => $source_id)) ?>
<?php endif ?>
</p>
<?php else: ?>
<?php $can_recur = $has_due_date && ! $is_native && $has_target ?>
<?php if (! $has_due_date): ?> <?php if (! $has_due_date): ?>
<p class="alert alert-info"><?= t('Please set a due date first. RecoReco uses the card due date as the recurrence anchor.') ?></p> <p class="alert alert-info"><?= t('Please set a due date first. RecoReco uses the card due date as the recurrence anchor.') ?></p>
<?php elseif ($is_native): ?> <?php elseif ($is_native): ?>
<p class="alert alert-info"><?= t('This card already uses Kanboard built-in recurrence. RecoReco and native recurrence cannot be used together.') ?></p> <p class="alert alert-info">
<?= t('This card uses Kanboard built-in recurrence.') ?>
<?= $this->url->link(t('Edit built-in recurrence'), 'TaskRecurrenceController', 'edit', array('task_id' => $task['id']), false, 'js-modal-medium') ?>
</p>
<?php elseif (! $has_target): ?>
<p class="alert alert-info"><?= t('Add a target column first.') ?></p>
<?php endif ?> <?php endif ?>
<form method="post" action="<?= $this->url->href('RecurrenceController', 'save', array('plugin' => 'RecoReco', 'task_id' => $task['id'])) ?>" autocomplete="off"> <form method="post" action="<?= $this->url->href('RecurrenceController', 'save', array('plugin' => 'RecoReco', 'task_id' => $task['id'])) ?>" autocomplete="off">
@@ -29,11 +52,17 @@
<?= $this->form->label(t('Frequency'), 'recoreco_frequency') ?> <?= $this->form->label(t('Frequency'), 'recoreco_frequency') ?>
<?= $this->form->select('recoreco_frequency', $frequency_list, $values) ?> <?= $this->form->select('recoreco_frequency', $frequency_list, $values) ?>
<?= $this->form->checkbox('recoreco_last_day', t('Last day of the month (honored only when the due date is the last day / last weekday of its month)'), 1, $values['recoreco_last_day'] == 1) ?> <div class="recoreco-lastday">
<?= $this->form->checkbox('recoreco_last_day', t('Fires on last day/weekday of the month'), 1, $values['recoreco_last_day'] == 1) ?>
</div>
<?= $this->form->label(t('Create the copy this many days before the due date'), 'recoreco_days_before') ?> <?= $this->form->label(t('Create the copy this many days before the due date'), 'recoreco_days_before') ?>
<input type="number" name="recoreco_days_before" min="0" value="<?= $this->text->e($values['recoreco_days_before']) ?>"> <input type="number" name="recoreco_days_before" min="0" value="<?= $this->text->e($values['recoreco_days_before']) ?>">
<?= $this->form->checkbox('recoreco_link_copies', t('Link copies to the template'), 1, $values['recoreco_link_copies'] == 1) ?>
<p class="form-help"><?= t('Links each copy back to the template (a count and quick navigation). Off by default.') ?></p>
<?= $this->modal->submitButtons() ?> <?= $this->modal->submitButtons() ?>
</form> </form>
<?php endif ?>

View File

@@ -0,0 +1,165 @@
<?php
/*
* Standalone test for the RecoReco date math -- no Kanboard / PHPUnit harness needed.
* Run with: php Test/OccurrenceCalculatorTest.php
*
* OccurrenceCalculator is pure, so this exercises the highest-risk correctness surface (month
* lengths, leap years, the last-day rule, the drift-free clamp).
*/
require __DIR__.'/../Model/OccurrenceCalculator.php';
use Kanboard\Plugin\RecoReco\Model\OccurrenceCalculator;
date_default_timezone_set('UTC');
$calc = new OccurrenceCalculator();
$failures = 0;
function ts($str)
{
return strtotime($str.' UTC');
}
function check($label, $got, $expected, &$failures)
{
$ok = $got === $expected;
$g = $got === null ? 'null' : date('Y-m-d H:i', $got);
$e = $expected === null ? 'null' : date('Y-m-d H:i', $expected);
echo ($ok ? 'PASS' : 'FAIL').' '.$label.' got='.$g.' expected='.$e."\n";
if (! $ok) {
$failures++;
}
}
// 1. Plain day-of-month: the 15th, every month.
check(
'monthly 15th, next after Jan 20',
$calc->occurrenceFrom(ts('2023-01-15 10:30'), 'monthly_day', false, ts('2023-01-20 00:00'), false),
ts('2023-02-15 10:30'),
$failures
);
// 2. Drift-free clamp: the 30th -> Feb clamps to 28 -> back to 30 in March (NOT 28).
check(
'30th -> Feb (non-leap) clamps to 28',
$calc->occurrenceFrom(ts('2023-01-30 09:00'), 'monthly_day', false, ts('2023-01-30 09:00'), false),
ts('2023-02-28 09:00'),
$failures
);
check(
'30th -> March returns to 30 (no drift)',
$calc->occurrenceFrom(ts('2023-01-30 09:00'), 'monthly_day', false, ts('2023-02-28 09:00'), false),
ts('2023-03-30 09:00'),
$failures
);
// 3. Last-day rule: last day of each month.
check(
'last-day: Jan 31 -> Feb 28 (non-leap)',
$calc->occurrenceFrom(ts('2023-01-31 08:00'), 'monthly_day', true, ts('2023-01-31 08:00'), false),
ts('2023-02-28 08:00'),
$failures
);
check(
'last-day: Feb -> March 31',
$calc->occurrenceFrom(ts('2023-01-31 08:00'), 'monthly_day', true, ts('2023-02-28 08:00'), false),
ts('2023-03-31 08:00'),
$failures
);
// 4. Leap year: last day of Feb 2024 is the 29th.
check(
'last-day: Feb 2024 -> 29th (leap)',
$calc->occurrenceFrom(ts('2024-01-31 08:00'), 'monthly_day', true, ts('2024-02-01 00:00'), false),
ts('2024-02-29 08:00'),
$failures
);
// 5. Inclusive vs exclusive at an exact occurrence.
check(
'inclusive returns the same instant',
$calc->occurrenceFrom(ts('2023-03-10 12:00'), 'monthly_day', false, ts('2023-03-10 12:00'), true),
ts('2023-03-10 12:00'),
$failures
);
check(
'exclusive skips to next month',
$calc->occurrenceFrom(ts('2023-03-10 12:00'), 'monthly_day', false, ts('2023-03-10 12:00'), false),
ts('2023-04-10 12:00'),
$failures
);
// 6. Daily: same time every day.
check(
'daily -> next day same time',
$calc->occurrenceFrom(ts('2023-03-10 09:30'), 'daily', false, ts('2023-03-10 12:00'), false),
ts('2023-03-11 09:30'),
$failures
);
// 7. Weekly: same weekday every 7 days (2023-03-07 is a Tuesday).
check(
'weekly -> +7 days on the same weekday',
$calc->occurrenceFrom(ts('2023-03-07 08:00'), 'weekly', false, ts('2023-03-07 08:00'), false),
ts('2023-03-14 08:00'),
$failures
);
// 8. Monthly by weekday: 2nd Tuesday (2023-03-14) -> 2nd Tuesday of April (2023-04-11).
check(
'monthly-by-weekday: 2nd Tuesday -> next month 2nd Tuesday',
$calc->occurrenceFrom(ts('2023-03-14 07:00'), 'monthly_dow', false, ts('2023-03-14 07:00'), false),
ts('2023-04-11 07:00'),
$failures
);
// 9. Monthly by weekday, last: last Tuesday of March (2023-03-28) -> last Tuesday of April (04-25).
check(
'monthly-by-weekday last: last Tue -> next month last Tue',
$calc->occurrenceFrom(ts('2023-03-28 07:00'), 'monthly_dow', true, ts('2023-03-28 07:00'), false),
ts('2023-04-25 07:00'),
$failures
);
// 10. Yearly: same month/day next year.
check(
'yearly -> next year same date',
$calc->occurrenceFrom(ts('2023-03-15 10:00'), 'yearly', false, ts('2023-03-15 10:00'), false),
ts('2024-03-15 10:00'),
$failures
);
// 11. Yearly last-day on Feb 29 (leap) -> next year Feb 28.
check(
'yearly last-day: 2024-02-29 -> 2025-02-28',
$calc->occurrenceFrom(ts('2024-02-29 08:00'), 'yearly', true, ts('2024-02-29 08:00'), false),
ts('2025-02-28 08:00'),
$failures
);
// 12. Yearly numeric Feb 29 -> clamps to 28 in a non-leap year, back to 29 in the next leap year.
check(
'yearly numeric 29-Feb -> 2025 clamps to 28',
$calc->occurrenceFrom(ts('2024-02-29 08:00'), 'yearly', false, ts('2024-02-29 08:00'), false),
ts('2025-02-28 08:00'),
$failures
);
check(
'yearly numeric 29-Feb -> 2028 back to 29 (leap)',
$calc->occurrenceFrom(ts('2024-02-29 08:00'), 'yearly', false, ts('2027-03-01 00:00'), false),
ts('2028-02-29 08:00'),
$failures
);
// 13. Unknown frequency returns null.
check(
'unknown frequency -> null',
$calc->occurrenceFrom(ts('2023-03-10 12:00'), 'nope', false, ts('2023-03-10 12:00'), false),
null,
$failures
);
echo "\n".($failures === 0 ? 'ALL PASSED' : $failures.' FAILURE(S)')."\n";
exit($failures === 0 ? 0 : 1);

View File

@@ -1 +1 @@
RecoReco v0.2.1 RecoReco v1.5