3 Commits
v0.3 ... v1.0

6 changed files with 90 additions and 10 deletions

View File

@@ -52,6 +52,7 @@ class RecurrenceController extends BaseController
'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' => isset($input['recoreco_days_before']) && ctype_digit((string) $input['recoreco_days_before']) ? (int) $input['recoreco_days_before'] : 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. The engine
@@ -61,6 +62,12 @@ class RecurrenceController extends BaseController
} }
$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
// links to its RecoReco clones when the option is off).
$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);
@@ -76,6 +83,7 @@ class RecurrenceController extends BaseController
'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,
); );
} }

View File

@@ -23,6 +23,7 @@ class RecoRecoModel extends Base
public function run($now = null) public function run($now = null)
{ {
$now = $now ?: time(); $now = $now ?: time();
$horizon = $this->horizon($now);
$spawned = 0; $spawned = 0;
$task_ids = $this->db->table('task_has_metadata') $task_ids = $this->db->table('task_has_metadata')
@@ -31,13 +32,31 @@ class RecoRecoModel extends Base
->findAllByColumn('task_id'); ->findAllByColumn('task_id');
foreach ($task_ids as $task_id) { foreach ($task_ids as $task_id) {
$spawned += $this->processTemplate((int) $task_id, $now); $spawned += $this->processTemplate((int) $task_id, $now, $horizon);
} }
return $spawned; return $spawned;
} }
public function processTemplate($task_id, $now) /**
* 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 catch-up.
*
* @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); $task = $this->taskFinderModel->getById($task_id);
@@ -69,6 +88,8 @@ class RecoRecoModel extends Base
$lastDay = isset($meta['recoreco_last_day']) && $meta['recoreco_last_day'] == 1; $lastDay = isset($meta['recoreco_last_day']) && $meta['recoreco_last_day'] == 1;
$daysBefore = isset($meta['recoreco_days_before']) ? (int) $meta['recoreco_days_before'] : 0; $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']; $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;
$cursor = (int) $task['date_due']; $cursor = (int) $task['date_due'];
$synced = isset($meta['recoreco_synced_due']) ? (int) $meta['recoreco_synced_due'] : null; $synced = isset($meta['recoreco_synced_due']) ? (int) $meta['recoreco_synced_due'] : null;
@@ -84,11 +105,12 @@ class RecoRecoModel extends Base
$this->setDue($task_id, $cursor); $this->setDue($task_id, $cursor);
} }
// Fire every occurrence whose fire time has arrived, advancing the cursor each time. // 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; $spawned = 0;
$fireTime = $cursor - $daysBefore * 86400; $fireTime = $cursor - $daysBefore * 86400;
while ($fireTime <= $now && $spawned < self::CATCHUP_CAP) { while ($fireTime < $horizon && $spawned < self::CATCHUP_CAP) {
$this->spawn($task, $targetColumn); $this->spawn($task, $targetColumn);
$next = $this->calculator()->occurrenceFrom($anchor, $frequency, $lastDay, $cursor, false); $next = $this->calculator()->occurrenceFrom($anchor, $frequency, $lastDay, $cursor, false);
@@ -103,9 +125,35 @@ class RecoRecoModel extends Base
$spawned++; $spawned++;
} }
// Reconcile the duplicate links with the setting (removes both new and old ones when off).
$this->syncCloneLinks($task_id, $linkCopies);
return $spawned; return $spawned;
} }
/**
* 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']);
}
}
}
/** /**
* The first occurrence: respect an explicitly future anchor (the due date the user set) as the * 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. * first one; otherwise jump to the next occurrence on/after now.
@@ -146,6 +194,10 @@ class RecoRecoModel extends Base
// Reset the "[DUPLICATE]" prefix back to the source title (no events). // Reset the "[DUPLICATE]" prefix back to the source title (no events).
$this->taskModificationModel->update(array('id' => $newId, 'title' => $template['title']), false); $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. // Mark the clone: white icon + cannot itself be made recurring; record its source template.
$this->taskMetadataModel->save($newId, array( $this->taskMetadataModel->save($newId, array(
'recoreco_clone' => 1, 'recoreco_clone' => 1,

View File

@@ -36,7 +36,7 @@ class Plugin extends Base
public function getPluginVersion() public function getPluginVersion()
{ {
return '0.3.0'; return '1.0.0';
} }
public function getPluginHomepage() public function getPluginHomepage()

View File

@@ -21,21 +21,36 @@ 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, and
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`.
## Status ## Status
Early development. This version adds the **Recurring schedule** modal (a task sidebar action) and Working for **monthly by day** (with the last-day rule), driven by cron. The remaining frequencies
stores the settings in task metadata. The scheduling engine and the cron command arrive in the (yearly, weekly, daily, monthly by weekday), the recurrence icons, and the FinanceBuddy
following versions. installment hand-off arrive in the following versions.
## 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

@@ -34,6 +34,11 @@
<?= $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('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>

View File

@@ -1 +1 @@
RecoReco v0.3 RecoReco v1.0