7 Commits
v1.1 ... v1.5

14 changed files with 467 additions and 107 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,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,34 +43,44 @@ 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, '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 (removes existing // Reconcile the template's duplicate links with the setting right away.
// links to its RecoReco clones when the option is off).
$model = new \Kanboard\Plugin\RecoReco\Model\RecoRecoModel($this->container); $model = new \Kanboard\Plugin\RecoReco\Model\RecoRecoModel($this->container);
$model->syncCloneLinks($task['id'], $values['recoreco_link_copies'] == 1); $model->syncCloneLinks($task['id'], $values['recoreco_link_copies'] == 1);
@@ -73,13 +89,34 @@ class RecurrenceController extends BaseController
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,
@@ -97,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

@@ -8,15 +8,34 @@ use Kanboard\Model\TaskModel;
/** /**
* The RecoReco engine: find enabled templates and spawn their due occurrences. * The RecoReco engine: find enabled templates and spawn their due occurrences.
* *
* Called by the recoreco:run CLI command. v0.3 handles monthly-by-day and fires everything whose * Called by the recoreco:run CLI command (from cron, or from the identical "Run now" button). One
* fire time has arrived (fire time = occurrence - days_before); v1.0 adds the 4-window cron horizon. * 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: the native-recurrence yield (decision 13), first-run init / manual-edit re-anchor, * Per template there is a SINGLE algorithm (no first-run vs catch-up split):
* then a catch-up loop that duplicates the card into the target column and advances the cursor. *
* 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 class RecoRecoModel extends Base
{ {
const CATCHUP_CAP = 24; // 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; private $calc;
@@ -43,7 +62,7 @@ class RecoRecoModel extends Base
* "ceil now to the next 6h boundary, then +12h", which lands on the four daily runs: * "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 * 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 * 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 catch-up. * naturally included (their fire time is < now < horizon) -- that is the back-fill.
* *
* @param int $now * @param int $now
* @return int * @return int
@@ -64,10 +83,19 @@ class RecoRecoModel extends Base
return 0; return 0;
} }
// Native wins: if the card became native-recurring, RecoReco yields (decision 13). // 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) { if ($task['recurrence_status'] != TaskModel::RECURRING_STATUS_NONE) {
$this->taskMetadataModel->save($task_id, array('recoreco_enabled' => 0)); $this->taskMetadataModel->save($task_id, array('recoreco_enabled' => 0));
$this->logger->info('RecoReco: disabled on task '.$task_id.' (now native-recurring)'); $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; return 0;
} }
@@ -79,6 +107,8 @@ class RecoRecoModel extends Base
return 0; 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; $anchor = isset($meta['recoreco_anchor']) ? (int) $meta['recoreco_anchor'] : 0;
if ($anchor <= 0) { if ($anchor <= 0) {
@@ -91,46 +121,83 @@ class RecoRecoModel extends Base
// Default off: only keep the duplicate link when explicitly turned on. // Default off: only keep the duplicate link when explicitly turned on.
$linkCopies = isset($meta['recoreco_link_copies']) && $meta['recoreco_link_copies'] == 1; $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']; $cursor = (int) $task['date_due'];
$synced = isset($meta['recoreco_synced_due']) ? (int) $meta['recoreco_synced_due'] : null; $due = array();
$iterations = 0;
if ($synced === null) { while ($cursor - $daysBefore * 86400 < $horizon && $iterations < self::MAX_ITERATIONS) {
// First run after enabling: place the cursor on the first occurrence. $due[] = $cursor;
$cursor = $this->firstCursor($anchor, $frequency, $lastDay, $now);
$this->setDue($task_id, $cursor);
} elseif ($cursor !== $synced) {
// The user hand-edited the due date -> re-anchor to it (a reschedule).
$anchor = $cursor;
$this->taskMetadataModel->save($task_id, array('recoreco_anchor' => $anchor));
$this->setDue($task_id, $cursor);
}
// Fire every occurrence whose fire time is before this run's horizon (this includes
// past-due ones -> catch-up), advancing the cursor each time.
$spawned = 0;
$fireTime = $cursor - $daysBefore * 86400;
while ($fireTime < $horizon && $spawned < self::CATCHUP_CAP) {
$this->spawn($task, $targetColumn);
$next = $this->calculator()->occurrenceFrom($anchor, $frequency, $lastDay, $cursor, false); $next = $this->calculator()->occurrenceFrom($anchor, $frequency, $lastDay, $cursor, false);
if ($next === null || $next <= $cursor) { if ($next === null || $next <= $cursor) {
break; break; // calculator cannot advance -- stop rather than loop
} }
$cursor = $next; $cursor = $next;
$this->setDue($task_id, $cursor); $iterations++;
$fireTime = $cursor - $daysBefore * 86400; }
$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++; $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). // Reconcile the duplicate links with the setting (removes both new and old ones when off).
$this->syncCloneLinks($task_id, $linkCopies); $this->syncCloneLinks($task_id, $linkCopies);
return $spawned; 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 * 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 * the links to THIS template's own RecoReco clones (identified by recoreco_source, so manual
@@ -154,24 +221,10 @@ class RecoRecoModel extends Base
} }
} }
/** private function setDue($task_id, $due)
* The first occurrence: respect an explicitly future anchor (the due date the user set) as the
* first one; otherwise jump to the next occurrence on/after now.
*/
private function firstCursor($anchor, $frequency, $lastDay, $now)
{ {
if ($anchor >= $now) { // Move the template's due date directly (no task events fired). This is the cursor.
return $anchor; $this->db->table(TaskModel::TABLE)->eq('id', $task_id)->update(array('date_due' => $due));
}
return $this->calculator()->occurrenceFrom($anchor, $frequency, $lastDay, $now, true);
}
private function setDue($task_id, $cursor)
{
// Advance the template's due date directly (no task events) + mirror it for edit detection.
$this->db->table(TaskModel::TABLE)->eq('id', $task_id)->update(array('date_due' => $cursor));
$this->taskMetadataModel->save($task_id, array('recoreco_synced_due' => $cursor));
} }
private function spawn(array $template, $targetColumn) private function spawn(array $template, $targetColumn)
@@ -208,6 +261,20 @@ class RecoRecoModel extends Base
$payload = array('source_task_id' => (int) $template['id'], 'new_task_id' => (int) $newId); $payload = array('source_task_id' => (int) $template['id'], 'new_task_id' => (int) $newId);
$this->hook->reference('recoreco:task:spawned', $payload); $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; return $newId;
} }

View File

@@ -12,6 +12,24 @@ class Plugin extends Base
// (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 // The scheduling engine runs from the CLI (cron). Registered CLI-only so web requests do
// not build the console app. // not build the console app.
if (php_sapi_name() === 'cli') { if (php_sapi_name() === 'cli') {
@@ -19,6 +37,13 @@ class Plugin extends Base
} }
} }
public function getHelpers()
{
return array(
'Plugin\RecoReco\Helper' => array('RecoRecoHelper'),
);
}
public function getPluginName() public function getPluginName()
{ {
return 'RecoReco'; return 'RecoReco';
@@ -36,7 +61,7 @@ class Plugin extends Base
public function getPluginVersion() public function getPluginVersion()
{ {
return '1.1.0'; return '1.5.0';
} }
public function getPluginHomepage() public function getPluginHomepage()

View File

@@ -31,15 +31,24 @@ The engine runs from the CLI command `recoreco:run`. Add it to cron **four times
``` ```
(Point the path at your install and run it as the user that owns Kanboard's data.) Each run looks (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, and ahead 12 hours; the runs overlap by 6 hours, so a single missed run is covered by the next one.
any occurrence whose time is already past is still caught up. `days before` shifts a copy earlier
within that lead. You can also run it by hand any time: `php cli recoreco:run`. 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
Working for **monthly by day** (with the last-day rule), driven by cron. The remaining frequencies All five frequencies work (yearly, monthly by day, monthly by weekday, weekly, daily) with the
(yearly, weekly, daily, monthly by weekday), the recurrence icons, and the FinanceBuddy last-day rule, board recurrence icons, backfill with the 12-occurrence cap, the Run-now button, and
installment hand-off arrive in the following versions. the FinanceBuddy installment hand-off.
## Requirements ## Requirements

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,16 +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) ?> <?= $this->form->checkbox('recoreco_link_copies', t('Link copies to the template'), 1, $values['recoreco_link_copies'] == 1) ?>
<p class="form-help"> <p class="form-help"><?= t('Links each copy back to the template (a count and quick navigation). Off by default.') ?></p>
<?= t('When on, the template keeps a link to each copy -- a running count and one-click navigation to every one. Off by default; best left off for frequent (daily/weekly) schedules to avoid piling up links.') ?>
</p>
<?= $this->modal->submitButtons() ?> <?= $this->modal->submitButtons() ?>
</form> </form>
<?php endif ?>

View File

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