10 Commits

15 changed files with 762 additions and 112 deletions

View File

@@ -12,11 +12,9 @@
var CAPS = { daily: 0, weekly: 6, monthly_day: 27, monthly_dow: 27, yearly: 364 }; var CAPS = { daily: 0, weekly: 6, monthly_day: 27, monthly_dow: 27, yearly: 364 };
function apply(select) { function apply(select) {
// "days before" ceiling / disable by frequency.
var input = document.querySelector('input[name="recoreco_days_before"]'); var input = document.querySelector('input[name="recoreco_days_before"]');
if (! input) { if (input) {
return;
}
var max = CAPS.hasOwnProperty(select.value) ? CAPS[select.value] : 27; var max = CAPS.hasOwnProperty(select.value) ? CAPS[select.value] : 27;
if (max === 0) { if (max === 0) {
@@ -32,10 +30,96 @@
} }
} }
// 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";
}
}
// "Follow FinanceBuddy installments": when on, the current/limit fields mirror FinanceBuddy's
// live installment current/total and are disabled (so they do not post, keeping FinanceBuddy the
// single source of truth); when off, they restore RecoReco's own stored values.
function applyFollow(checkbox) {
var group = checkbox.closest(".recoreco-limit-group");
if (!group) { return; }
var count = group.querySelector('input[name="recoreco_count"]');
var limit = group.querySelector('input[name="recoreco_limit"]');
if (!count || !limit) { return; }
if (checkbox.checked) {
count.value = group.getAttribute("data-fb-current");
limit.value = group.getAttribute("data-fb-total");
} else {
count.value = group.getAttribute("data-rr-count");
limit.value = group.getAttribute("data-rr-limit");
}
count.disabled = checkbox.checked;
limit.disabled = checkbox.checked;
}
// Trigger column: it can never be the target, so hide+disable that option and reset to Any if it
// was selected; the invert checkbox only means something for a specific column.
function applyTrigger() {
var target = document.querySelector('select[name="recoreco_target_column"]');
var trigger = document.querySelector('select[name="recoreco_trigger_column"]');
var invert = document.querySelector('input[name="recoreco_trigger_invert"]');
if (!target || !trigger) { return; }
for (var i = 0; i < trigger.options.length; i++) {
var isTarget = trigger.options[i].value === target.value;
trigger.options[i].hidden = isTarget;
trigger.options[i].disabled = isTarget;
}
if (trigger.value === target.value) { trigger.value = "any"; }
if (invert) { invert.disabled = (trigger.value === "any"); }
}
// Move-mode makes copies moot, so grey out "Link copies to the template" while it is checked
// (disabled -> it won't post, so the saved value is 0).
function applyMove() {
var move = document.querySelector('input[name="recoreco_move"]');
var link = document.querySelector('input[name="recoreco_link_copies"]');
if (!move || !link) { return; }
link.disabled = move.checked;
var label = link.closest("label");
if (label) { label.style.opacity = move.checked ? "0.5" : ""; }
}
// Italicize the card's current column in the target/trigger dropdowns, so you can see where it
// sits right now. (<option> styling is honored by Chrome/Firefox; Safari may ignore it.)
function applyCurrentItalic() {
var form = document.querySelector('form[data-recoreco-current]');
if (!form) { return; }
var current = form.getAttribute("data-recoreco-current");
var selects = form.querySelectorAll('select[name="recoreco_target_column"], select[name="recoreco_trigger_column"]');
for (var s = 0; s < selects.length; s++) {
for (var i = 0; i < selects[s].options.length; i++) {
if (selects[s].options[i].value === current) {
selects[s].options[i].style.fontStyle = "italic";
}
}
}
}
document.addEventListener("change", function (e) { document.addEventListener("change", function (e) {
if (e.target && e.target.name === "recoreco_frequency") { if (e.target && e.target.name === "recoreco_frequency") {
apply(e.target); apply(e.target);
} }
if (e.target && e.target.name === "recoreco_follow_finance") {
applyFollow(e.target);
}
if (e.target && (e.target.name === "recoreco_target_column" || e.target.name === "recoreco_trigger_column")) {
applyTrigger();
}
if (e.target && e.target.name === "recoreco_move") {
applyMove();
}
}); });
if (window.MutationObserver) { if (window.MutationObserver) {
@@ -44,6 +128,9 @@
if (select) { if (select) {
select.setAttribute("data-recoreco-init", "1"); select.setAttribute("data-recoreco-init", "1");
apply(select); apply(select);
applyTrigger();
applyMove();
applyCurrentItalic();
} }
}).observe(document.body, { childList: true, subtree: true }); }).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,43 @@ 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']);
// The "Follow FinanceBuddy installments" option only appears when FinanceBuddy is enabled on
// this board (installed but not enabled means no installment data to follow).
$fb_enabled = (int) $this->projectMetadataModel->get($task['project_id'], 'financebuddy_enabled', 0) === 1;
if (empty($values)) { if (empty($values)) {
$values = $this->getStoredValues($task); $values = $this->getStoredValues($meta, $task, $columns, $fb_enabled);
} }
$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,
'all_columns' => $this->columnModel->getList($task['project_id']),
'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,
'fb_enabled' => $fb_enabled,
'fb_current' => isset($meta['financebuddy_installment_current']) ? $meta['financebuddy_installment_current'] : '',
'fb_total' => isset($meta['financebuddy_installment_total']) ? $meta['financebuddy_installment_total'] : '',
))); )));
} }
@@ -37,9 +51,17 @@ 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())
@@ -52,23 +74,44 @@ class RecurrenceController extends BaseController
$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' => $daysBefore, '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'];
} }
// Finite-plan limit. When FinanceBuddy is enabled on the board and "Follow" is on, RecoReco
// reads its installment total/current live at run time -- so nothing is stored here and the
// standalone limit/counter are left untouched (the modal greys them). Otherwise store the
// standalone limit (blank/0 = forever) and the 1-based progress counter (seedable).
$fb_enabled = (int) $this->projectMetadataModel->get($task['project_id'], 'financebuddy_enabled', 0) === 1;
$follow = $fb_enabled && isset($input['recoreco_follow_finance']) && $input['recoreco_follow_finance'] == 1;
$values['recoreco_follow_finance'] = $follow ? 1 : 0;
if (! $follow) {
$values['recoreco_limit'] = (isset($input['recoreco_limit']) && ctype_digit((string) $input['recoreco_limit'])) ? (int) $input['recoreco_limit'] : 0;
$values['recoreco_count'] = (isset($input['recoreco_count']) && ctype_digit((string) $input['recoreco_count']) && (int) $input['recoreco_count'] >= 1) ? (int) $input['recoreco_count'] : 1;
}
// Trigger column + move-mode (v1.9).
$allColumns = $this->columnModel->getList($task['project_id']);
$trig = isset($input['recoreco_trigger_column']) ? (string) $input['recoreco_trigger_column'] : 'any';
$values['recoreco_trigger_column'] = ($trig !== 'any' && array_key_exists((int) $trig, $allColumns)) ? (int) $trig : 'any';
$values['recoreco_trigger_invert'] = isset($input['recoreco_trigger_invert']) ? 1 : 0;
$values['recoreco_move'] = isset($input['recoreco_move']) ? 1 : 0;
if ($values['recoreco_move'] === 1) {
$values['recoreco_follow_finance'] = 0; // Follow-FinanceBuddy is N/A in move-mode
}
$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);
@@ -77,17 +120,45 @@ 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) // Target columns: ALL of the project's columns. This used to exclude the card's own column (a
// duplicate copy "must move"), but move-mode legitimately rests the card IN the target, so the
// target must be any column -- otherwise a moved card's own target vanishes from the dropdown and
// a re-save would silently reset it. `target != trigger` is enforced in the modal instead.
private function targetColumns(array $task)
{ {
$meta = $this->taskMetadataModel->getAll($task['id']); return $this->columnModel->getList($task['project_id']);
}
// 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, $fb_enabled)
{
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, 'recoreco_link_copies' => isset($meta['recoreco_link_copies']) ? (int) $meta['recoreco_link_copies'] : 0,
// Follow defaults ON for a fresh card on a FinanceBuddy board; otherwise the stored choice.
'recoreco_follow_finance' => isset($meta['recoreco_follow_finance']) ? (int) $meta['recoreco_follow_finance'] : ($fb_enabled ? 1 : 0),
'recoreco_limit' => isset($meta['recoreco_limit']) ? (int) $meta['recoreco_limit'] : 0,
'recoreco_count' => isset($meta['recoreco_count']) ? (int) $meta['recoreco_count'] : 1,
'recoreco_move' => isset($meta['recoreco_move']) ? (int) $meta['recoreco_move'] : 0,
'recoreco_trigger_column' => isset($meta['recoreco_trigger_column']) ? $meta['recoreco_trigger_column'] : 'any',
'recoreco_trigger_invert' => isset($meta['recoreco_trigger_invert']) ? (int) $meta['recoreco_trigger_invert'] : 0,
); );
} }

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,38 +121,146 @@ 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;
$cursor = (int) $task['date_due']; // Finite-plan stop. RecoReco caps its own recurrence, either by its own limit (standalone) or
$synced = isset($meta['recoreco_synced_due']) ? (int) $meta['recoreco_synced_due'] : null; // by following FinanceBuddy. When follow is on, FinanceBuddy governs: the plan is finite only
// if the card has a numeric installment total, so an ordinary recurring bill (money but no
// installments) recurs forever -- and any stale standalone limit is ignored. In follow mode
// the numbers are read LIVE from FinanceBuddy (never copied), so a mid-plan start, a
// re-purchase, or an extend all just work by editing FinanceBuddy. A blank/0 limit means
// recur forever. The spawn loop below stops when counter > limit (1-based; counter ends on
// limit+1).
$followIntent = isset($meta['recoreco_follow_finance']) && $meta['recoreco_follow_finance'] == 1;
$fbTotal = isset($meta['financebuddy_installment_total']) ? (string) $meta['financebuddy_installment_total'] : '';
$fbHasTotal = $fbTotal !== '' && ctype_digit($fbTotal);
$follow = $followIntent && $fbHasTotal;
if ($synced === null) { if ($followIntent) {
// First run after enabling: place the cursor on the first occurrence. $limit = $fbHasTotal ? (int) $fbTotal : 0; // following FinanceBuddy; no total => forever
$cursor = $this->firstCursor($anchor, $frequency, $lastDay, $now); } else {
$this->setDue($task_id, $cursor); $limitRaw = isset($meta['recoreco_limit']) ? (string) $meta['recoreco_limit'] : '';
} elseif ($cursor !== $synced) { $limit = ($limitRaw !== '' && ctype_digit($limitRaw)) ? (int) $limitRaw : 0;
// 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 // Standalone progress counter (1-based "next occurrence"). Unused in follow mode, where the
// past-due ones -> catch-up), advancing the cursor each time. // counter is FinanceBuddy's live installment_current instead.
$spawned = 0; $count = isset($meta['recoreco_count']) && ctype_digit((string) $meta['recoreco_count']) ? (int) $meta['recoreco_count'] : 1;
$fireTime = $cursor - $daysBefore * 86400;
while ($fireTime < $horizon && $spawned < self::CATCHUP_CAP) { // Trigger gate (cross-cutting, both modes): fire only while the card is in a triggering column,
$this->spawn($task, $targetColumn); // and never while it sits in the target (Gate 2, uniform).
$col = (int) $task['column_id'];
$trig = isset($meta['recoreco_trigger_column']) ? $meta['recoreco_trigger_column'] : 'any';
$invert = isset($meta['recoreco_trigger_invert']) && $meta['recoreco_trigger_invert'] == 1;
$base = ($trig === 'any') ? true : ($invert ? ($col !== (int) $trig) : ($col === (int) $trig));
if (! ($base && $col !== $targetColumn)) {
return 0;
}
// Move-mode: relocate the single card to the target instead of spawning a clone. No back-fill,
// no clone metadata. Honors the standalone limit (save() forced follow=0, so $limit is it).
if (isset($meta['recoreco_move']) && $meta['recoreco_move'] == 1) {
$cursor = (int) $task['date_due'];
if ($cursor - $daysBefore * 86400 >= $horizon) {
return 0; // not due yet
}
if ($limit > 0 && $count > $limit) {
$this->completePlan($task, $task_id); // safety: re-armed after completion
return 0;
}
$this->taskPositionModel->movePosition($task['project_id'], $task_id, $targetColumn, 1, $task['swimlane_id'], false);
$next = $this->calculator()->occurrenceFrom($anchor, $frequency, $lastDay, $cursor, false);
if ($next !== null && $next > $cursor) {
$this->setDue($task_id, $next); // advance one period
}
if ($limit > 0) {
$count++;
$this->setCount($task_id, $count);
if ($count > $limit) {
$this->completePlan($task, $task_id);
}
}
return 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); $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).
// Two guards enforce the finite-plan limit: the leading one catches an already-complete plan
// (a re-enabled done template, or a back-fill overshoot); the trailing one disables the
// instant this run's spawn completes the plan.
$spawned = 0;
$completed = false;
foreach ($toSpawn as $occurrence) {
$counter = $follow ? $this->financeCurrent($task_id) : $count;
if ($limit > 0 && $counter > $limit) {
$completed = true;
break;
}
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. spawn() fires the hook that lets FinanceBuddy advance its installment.
$this->setDue($task_id, $occurrence);
$this->spawn($task, $targetColumn);
$spawned++; $spawned++;
// Standalone advances its own counter (only meaningful with a limit set); follow mode
// relies on FinanceBuddy having advanced its installment during spawn().
if (! $follow && $limit > 0) {
$count++;
$this->setCount($task_id, $count);
}
$counter = $follow ? $this->financeCurrent($task_id) : $count;
if ($limit > 0 && $counter > $limit) {
$completed = true;
break;
}
}
// Phase 4 -- park the cursor on the next future occurrence. When the plan just completed,
// disable the template (its p(total+1) tombstone is already in place) and log it.
$this->setDue($task_id, $nextFuture);
if ($completed) {
$this->completePlan($task, $task_id);
} }
// 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).
@@ -131,6 +269,32 @@ class RecoRecoModel extends Base
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 +318,50 @@ class RecoRecoModel extends Base
} }
} }
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));
}
/** /**
* The first occurrence: respect an explicitly future anchor (the due date the user set) as the * FinanceBuddy's live "next installment to spawn" for a task (1-based), read fresh because
* first one; otherwise jump to the next occurrence on/after now. * FinanceBuddy advances it on every spawn. Defaults to 1 when absent or non-numeric. RecoReco
* only reads this key -- it never writes FinanceBuddy metadata.
*
* @param int $task_id
* @return int
*/ */
private function firstCursor($anchor, $frequency, $lastDay, $now) private function financeCurrent($task_id)
{ {
if ($anchor >= $now) { $current = $this->taskMetadataModel->get($task_id, 'financebuddy_installment_current', 1);
return $anchor;
return ctype_digit((string) $current) ? (int) $current : 1;
} }
return $this->calculator()->occurrenceFrom($anchor, $frequency, $lastDay, $now, true); private function setCount($task_id, $count)
{
$this->taskMetadataModel->save($task_id, array('recoreco_count' => (int) $count));
} }
private function setDue($task_id, $cursor) /**
* A finite plan reached its limit: disable the template and record it in the activity stream,
* attributed to the card owner (creator_id has a FK to users, so a system id would fail silently).
*
* @param array $task
* @param int $task_id
*/
private function completePlan(array $task, $task_id)
{ {
// Advance the template's due date directly (no task events) + mirror it for edit detection. $this->taskMetadataModel->save($task_id, array('recoreco_enabled' => 0));
$this->db->table(TaskModel::TABLE)->eq('id', $task_id)->update(array('date_due' => $cursor)); $this->logger->info('RecoReco: plan complete on task '.$task_id.' (recurrence disabled)');
$this->taskMetadataModel->save($task_id, array('recoreco_synced_due' => $cursor)); $this->projectActivityModel->createEvent(
(int) $task['project_id'],
$task_id,
(int) $task['creator_id'],
'recoreco.task.complete',
array('task' => array('id' => $task_id, 'title' => $task['title']))
);
} }
private function spawn(array $template, $targetColumn) private function spawn(array $template, $targetColumn)
@@ -208,6 +398,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

@@ -17,6 +17,20 @@ class Plugin extends Base
'template' => 'plugins/RecoReco/Asset/js/recoreco-modal.js', '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');
$this->template->setTemplateOverride('event/recoreco_task_complete', 'recoReco:event/recoreco_task_complete');
// 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') {
@@ -24,6 +38,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';
@@ -41,7 +62,7 @@ class Plugin extends Base
public function getPluginVersion() public function getPluginVersion()
{ {
return '1.1.1'; return '1.9.1';
} }
public function getPluginHomepage() public function getPluginHomepage()

View File

@@ -8,6 +8,13 @@ A card marked recurring **stays** as a template; on schedule, RecoReco spawns a
(non-recurring) copy into a column you choose. The template advances to the next date; the copy (non-recurring) copy into a column you choose. The template advances to the next date; the copy
keeps the fired date. keeps the fired date.
> **A note on card order.** Because the template keeps its identity (and its id) for the life of the
> plan while its due date advances, the template ends up as the *oldest* id carrying the *latest*
> date. The spawned copies, by contrast, are ordered naturally (older copy = older id = older date).
> This is intentional: a stable, editable template that stays in place is worth more than making the
> one generator card sort by date. Native Kanboard recurrence avoids the mismatch only by
> reincarnating the card every cycle, which RecoReco deliberately does not do.
RecoReco is opt-in per card and inert until you mark a card, so it needs no per-board setting. It RecoReco is opt-in per card and inert until you mark a card, so it needs no per-board setting. It
leaves native recurrence untouched (the two are mutually exclusive per card). leaves native recurrence untouched (the two are mutually exclusive per card).
@@ -31,15 +38,62 @@ 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.
## Stopping a finite plan
By default a recurring card recurs forever. To make it stop, give it a **limit** in the Recurring
schedule dialog:
- **Stop after N recurrences** -- a plain count; blank or 0 means recur forever. RecoReco keeps a
1-based progress counter and turns the recurrence off once it passes the limit.
- **Follow FinanceBuddy installments** -- shown only on boards where FinanceBuddy is enabled (and on
by default there). RecoReco reads the card's installment total and current *live* from FinanceBuddy
(never copied) and stops after the last installment. Because the numbers are read fresh, editing
them in FinanceBuddy just works: start mid-plan (enter `222/420`), re-purchase (reset the current
installment), or extend (raise the total) -- no RecoReco bookkeeping to keep in sync.
When a followed plan ends, the parked template is left one past the last installment (`p4/3` for a
three-installment plan) as a visible "spent" marker, its recurrence off, with an activity-stream
entry recording the completion.
## Trigger column and move-mode
Two options in the Recurring schedule dialog change *where* and *how* a card recurs:
- **Trigger column.** By default (`Any`) a card fires wherever it sits. Set a **Trigger column** and
it only fires while parked there -- so a template spawns only from its "active" column, and moving
it elsewhere quietly pauses it. The **`any but selected`** checkbox inverts the choice (fire in
every column *except* the selected one, e.g. "any but Archive"). The **target column never
triggers**, in any mode -- this is what lets a moved card come to rest in the target without
re-firing. (One edge to know: a *duplicate* template dragged into its own target column no longer
spawns; previously it fired from any column. Templates normally sit elsewhere, so this rarely
bites.)
- **Move instead of copy.** Tick **"Move the card instead of creating a copy"** and RecoReco
relocates the *one* card to the target column each period instead of spawning a clone -- made for a
recurring chore you don't want piling up copies. Pair it with a trigger column: e.g. trigger =
`Done`, target = `Todo` -- cron moves the card `Done -> Todo` when it's due; you do the task and
drag it back to `Done`; repeat. Move-mode honors the recurrence limit (stop after N moves) but not
the FinanceBuddy follow (there is no copy to advance an installment on). Moves are silent (no
"moved to column" notification each period).
## 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, a
installment hand-off arrive in the following versions. recurrence limit (standalone or following FinanceBuddy installments), the FinanceBuddy installment
hand-off, a per-card trigger column, and move-mode (relocate instead of copy).
## Requirements ## Requirements
@@ -55,3 +109,21 @@ no database migration. Then add the cron entry above.
## License ## License
AGPL-3.0. See LICENSE. AGPL-3.0. See LICENSE.
## More Kanboard plugins by Ruben (drbeco)
All free and AGPL-3.0, at [code.beco.cc](https://code.beco.cc/beco):
- **[FinanceBuddy](https://code.beco.cc/beco/FinanceBuddy)** -- attach a money value (debit/credit
and installments) to cards, shown on the card and totalled per column. Pairs directly with
RecoReco: recurring bills spawn on schedule and their installments count down until the plan is
paid off.
- **[OrganonTweaks](https://code.beco.cc/beco/OrganonTweaks)** -- an umbrella of small
quality-of-life board tweaks: remove an empty column, always show the comment icon, emphasize due
dates, extra search keywords and shared board filters, and more.
- **[BulkMoveTasks](https://code.beco.cc/beco/BulkMoveTasks)** -- move every task from one board
column to another in a single action.
- **[ShrinkVertically](https://code.beco.cc/beco/ShrinkVertically)** -- shrink vertically-collapsed
board columns so the horizontal scrollbar stays within reach.
- **[TweakDrag](https://code.beco.cc/beco/TweakDrag)** -- board drag and touch niceties:
drag-to-scroll, a wider column gap, and smoother card dragging.

View File

@@ -0,0 +1,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 finished the recurrence plan on %s and turned recurrence off',
$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,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,16 +1,39 @@
<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): ?>
<?php if (! $has_due_date): ?> <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): ?>
<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">
<?php endif ?> <?= 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 ?>
<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" data-recoreco-current="<?= (int) $task['column_id'] ?>">
<?= $this->form->csrf() ?> <?= $this->form->csrf() ?>
<?= $this->form->label(t('Make recurring'), 'recoreco_enabled') ?> <?= $this->form->label(t('Make recurring'), 'recoreco_enabled') ?>
@@ -23,22 +46,61 @@
</label> </label>
</div> </div>
<?= $this->form->label(t('Target column (where the copy appears)'), 'recoreco_target_column') ?> <?= $this->form->label(t('Target column'), 'recoreco_target_column') ?>
<?= $this->form->select('recoreco_target_column', $columns_list, $values) ?> <?= $this->form->select('recoreco_target_column', $columns_list, $values) ?>
<?= $this->form->label(t('Trigger column'), 'recoreco_trigger_column') ?>
<div class="recoreco-trigger" style="display:flex; align-items:center; gap:8px;">
<?= $this->form->select('recoreco_trigger_column', array('any' => t('Any')) + $all_columns, $values) ?>
<label style="white-space:nowrap;">
<input type="checkbox" name="recoreco_trigger_invert" value="1" <?= $values['recoreco_trigger_invert'] == 1 ? 'checked="checked"' : '' ?>> <?= t('any but selected') ?>
</label>
</div>
<p class="form-help"><?= t('Recurrence fires only while the card sits in a triggering column; the target column never triggers.') ?></p>
<?= $this->form->checkbox('recoreco_move', t('Move the card instead of creating a copy'), 1, $values['recoreco_move'] == 1) ?>
<p class="form-help"><?= t('Relocate this one card to the target column each period instead of spawning a copy.') ?></p>
<?= $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>
<?php $following = $fb_enabled && $values['recoreco_follow_finance'] == 1 ?>
<?php $limit_display = (int) $values['recoreco_limit'] > 0 ? (int) $values['recoreco_limit'] : '' ?>
<div class="recoreco-limit-group"
data-fb-current="<?= $this->text->e($fb_current) ?>"
data-fb-total="<?= $this->text->e($fb_total) ?>"
data-rr-count="<?= (int) $values['recoreco_count'] ?>"
data-rr-limit="<?= $this->text->e($limit_display) ?>">
<?php if ($fb_enabled): ?>
<?= $this->form->checkbox('recoreco_follow_finance', t('Follow FinanceBuddy installments'), 1, $following) ?>
<p class="form-help"><?= t('Stop recurring when the installment plan ends, following the card total and current installment.') ?></p>
<?php endif ?>
<?= $this->form->label(t('Recurrence'), 'recoreco_count') ?>
<div class="recoreco-limit-inline" style="display:flex; align-items:center; gap:6px;">
<input type="number" name="recoreco_count" min="1" style="width:5em;"
value="<?= $this->text->e($following ? $fb_current : $values['recoreco_count']) ?>"
<?= $following ? 'disabled="disabled"' : '' ?>>
<span><?= t('of') ?></span>
<input type="number" name="recoreco_limit" min="1" style="width:5em;"
value="<?= $this->text->e($following ? $fb_total : $limit_display) ?>"
<?= $following ? 'disabled="disabled"' : '' ?>>
</div>
<p class="form-help"><?= t('Current recurrence and how many to run. Leave the second box empty to recur forever.') ?></p>
</div>
<?= $this->modal->submitButtons() ?> <?= $this->modal->submitButtons() ?>
</form> </form>
<?php endif ?>

View File

@@ -1 +1 @@
RecoReco v1.1.1 RecoReco v1.9.1