10 Commits
v0.1 ... v1.2.1

22 changed files with 532 additions and 70 deletions

View File

@@ -1 +0,0 @@
/* Skeleton plugin styles -- add CSS here. Loaded via template:layout:css. */

35
Asset/js/click-guard.js Normal file
View File

@@ -0,0 +1,35 @@
/*
* OrganonTweaks -- only open a card on a quick click.
*
* A real click is a quick press-release; a drag is a press, some movement, and a release
* much later. When you drop a card, jQuery UI can leak a trailing "click" on it, and
* Kanboard opens the card -- an unwanted side effect of the drag.
*
* We simply time the press on a card: record the moment of mousedown / touchstart, and on
* the click that follows, if more than THRESHOLD ms elapsed the press was a drag (held),
* not a click, so we swallow the click in the capture phase before Kanboard's open handler
* runs. A normal click is well under the threshold and opens the card as usual.
*/
(function () {
"use strict";
var THRESHOLD = 100; // ms; a press on a card held longer than this is a drag, not a click
var pressStart = 0;
function onDown(e) {
pressStart = (e.target && e.target.closest && e.target.closest(".task-board")) ? Date.now() : 0;
}
document.addEventListener("mousedown", onDown, true);
document.addEventListener("touchstart", onDown, true);
document.addEventListener("click", function (e) {
if (pressStart &&
e.target && e.target.closest && e.target.closest(".task-board") &&
(Date.now() - pressStart) > THRESHOLD) {
e.stopImmediatePropagation();
e.preventDefault();
}
pressStart = 0;
}, true);
})();

55
Asset/js/keep-scroll.js Normal file
View File

@@ -0,0 +1,55 @@
/*
* OrganonTweaks -- keep the board's horizontal scroll position across refreshes.
*
* When you drop a card (and on Kanboard's periodic AJAX polling) the board is rebuilt with
* `$("#board-container").replaceWith(data)` (BoardDragAndDrop.refresh). The brand-new
* element starts at scrollLeft 0, so the board snaps back to the first column -- a jarring
* jump. This remembers the last scroll position and restores it the moment a replacement
* container appears, so the view stays put.
*
* We observe the STABLE parent (the container itself is replaced) and read the scroll from
* whichever #board-container is current, so it keeps working after every rebuild.
*/
(function () {
"use strict";
function init() {
var container = document.getElementById("board-container");
if (!container) {
return;
}
var parent = container.parentNode;
var lastScrollLeft = container.scrollLeft;
var lastContainer = container;
// Remember the position whenever the board is scrolled (capture: scroll does not
// bubble, and this survives the container being replaced).
document.addEventListener("scroll", function (e) {
var c = document.getElementById("board-container");
if (c && e.target === c) {
lastScrollLeft = c.scrollLeft;
}
}, true);
// When the board is rebuilt, #board-container becomes a new element at scrollLeft 0;
// restore the remembered position before the browser paints it.
if (window.MutationObserver) {
new MutationObserver(function () {
var c = document.getElementById("board-container");
if (c && c !== lastContainer) {
lastContainer = c;
if (c.scrollLeft !== lastScrollLeft) {
c.scrollLeft = lastScrollLeft;
}
}
}).observe(parent, { childList: true });
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

102
Asset/js/relocate.js Normal file
View File

@@ -0,0 +1,102 @@
/*
* OrganonTweaks -- move plugin-injected DOM bits into their native positions on the board.
*
* Both fixes are needed because Kanboard's relevant hooks render at the END of their
* container, not where the item belongs:
*
* 1. "Remove this Column": the template:board:column:dropdown hook renders AFTER the column
* menu <ul> (and the dropdown JS clones only that <ul>), so we move the hidden <li> into
* the column's real <ul>.
* 2. "Always show comment icon": the template:board:task:icons hook renders at the end of
* the card icons row, so the "+" bubble lands after priority instead of in the native
* comment slot. We move it just before the description icon (where the native comment
* icon sits), so the order matches whether or not the card has comments.
*
* Both re-run when the board is redrawn (AJAX polling / drag-and-drop refresh).
*/
(function () {
"use strict";
function relocateColumnMenuItems() {
var items = document.querySelectorAll(".organontweaks-remove-column-item");
for (var i = 0; i < items.length; i++) {
var li = items[i];
// Already moved into a menu list: just make sure it is visible.
if (li.closest("ul")) {
li.style.display = "";
continue;
}
// The hook renders the item as a child of the column's dropdown wrapper;
// its first <ul> is the native menu.
var menu = li.parentNode ? li.parentNode.querySelector("ul") : null;
if (menu) {
menu.appendChild(li);
li.style.display = "";
}
}
}
function repositionCommentIcons() {
var items = document.querySelectorAll(".organontweaks-comment-add");
for (var i = 0; i < items.length; i++) {
var item = items[i];
var row = item.parentNode;
if (!row) {
continue;
}
// Native comment icon sits just before the description icon; if there is no
// description, before the age / closed block. That is the slot we move into.
// NB: the description icon is wrapped in <span class="tooltip"> (Kanboard's
// tooltipLink), not an <a>, so we walk up to whatever is the row's direct child.
var anchor = row.querySelector("i.fa-file-text-o") ||
row.querySelector(".task-icon-age") ||
row.querySelector(".task-board-closed");
while (anchor && anchor.parentNode !== row) {
anchor = anchor.parentNode;
}
// Move only if needed (keeps the MutationObserver from looping).
if (anchor && anchor !== item && item.nextElementSibling !== anchor) {
row.insertBefore(item, anchor);
}
}
}
function apply() {
relocateColumnMenuItems();
repositionCommentIcons();
}
function init() {
var container = document.getElementById("board-container");
if (!container) {
return;
}
apply();
// Observe the STABLE parent, not #board-container: Kanboard replaces the container
// on every card drop / AJAX poll (BoardDragAndDrop.refresh), which would kill an
// observer bound to the container itself and leave the items un-relocated after a
// move. The parent survives, so apply() keeps running on every rebuild.
var parent = container.parentNode;
if (parent && window.MutationObserver) {
new MutationObserver(apply).observe(parent, { childList: true, subtree: true });
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View File

@@ -1 +0,0 @@
// Skeleton plugin script -- add JS here. Loaded via template:layout:js.

View File

View File

@@ -0,0 +1,71 @@
<?php
namespace Kanboard\Plugin\OrganonTweaks\Controller;
use Kanboard\Controller\BaseController;
use Kanboard\Core\Controller\AccessForbiddenException;
use Kanboard\Model\TaskModel;
/**
* Remove an empty board column straight from its header menu.
*/
class ColumnController extends BaseController
{
/**
* Confirmation dialog.
*/
public function confirm()
{
$project = $this->getProject();
$column_id = $this->request->getIntegerParam('column_id');
$this->checkAccess($project['id'], $column_id);
$this->response->html($this->template->render('organonTweaks:board/remove_column', array(
'project' => $project,
'column' => $this->columnModel->getColumnTitleById($column_id),
'column_id' => $column_id,
)));
}
/**
* Remove the column and return to the board.
*/
public function remove()
{
$project = $this->getProject();
$values = $this->request->getValues();
$column_id = isset($values['column_id']) ? (int) $values['column_id'] : 0;
$this->checkAccess($project['id'], $column_id);
if ($this->columnModel->remove($column_id)) {
$this->flash->success(t('Column removed successfully.'));
} else {
$this->flash->failure(t('Unable to remove this column.'));
}
$this->response->redirect($this->helper->url->to('BoardViewController', 'show', array('project_id' => $project['id'])));
}
/**
* Allow only users who can manage columns, and only for a column with no tasks at all
* (open or closed) -- ColumnModel::remove() is a bare delete, so a non-empty column
* would orphan its tasks.
*/
private function checkAccess($project_id, $column_id)
{
if (! $this->helper->user->hasProjectAccess('ColumnController', 'remove', $project_id)) {
throw new AccessForbiddenException();
}
$count = $this->taskFinderModel->countByColumnId(
$project_id,
$column_id,
array(TaskModel::STATUS_OPEN, TaskModel::STATUS_CLOSED)
);
if ($count > 0) {
throw new AccessForbiddenException();
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace Kanboard\Plugin\OrganonTweaks\Controller;
use Kanboard\Controller\BaseController;
/**
* Settings page for the OrganonTweaks plugin: one toggle per tweak.
*/
class ConfigController extends BaseController
{
public function show()
{
$this->response->html($this->helper->layout->config('organonTweaks:config/show', array(
'title' => t('Settings').' &gt; '.t('Organon Tweaks'),
'values' => array(
'organon_tweaks_always_comment_icon' => (int) $this->configModel->get('organon_tweaks_always_comment_icon', 1),
'organon_tweaks_keep_scroll' => (int) $this->configModel->get('organon_tweaks_keep_scroll', 1),
'organon_tweaks_quick_click_only' => (int) $this->configModel->get('organon_tweaks_quick_click_only', 1),
),
'errors' => array(),
)));
}
public function save()
{
$values = $this->request->getValues();
$alwaysCommentIcon = isset($values['organon_tweaks_always_comment_icon']) ? 1 : 0;
$keepScroll = isset($values['organon_tweaks_keep_scroll']) ? 1 : 0;
$quickClickOnly = isset($values['organon_tweaks_quick_click_only']) ? 1 : 0;
if ($this->configModel->save(array(
'organon_tweaks_always_comment_icon' => $alwaysCommentIcon,
'organon_tweaks_keep_scroll' => $keepScroll,
'organon_tweaks_quick_click_only' => $quickClickOnly,
))) {
$this->flash->success(t('Settings saved successfully.'));
} else {
$this->flash->failure(t('Unable to save your settings.'));
}
$this->response->redirect($this->helper->url->to('ConfigController', 'show', array('plugin' => 'OrganonTweaks')));
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Kanboard\Plugin\OrganonTweaks\Helper;
use Kanboard\Core\Base;
use Kanboard\Model\TaskModel;
/**
* Template helper for column tweaks. Exposes an emptiness check that counts BOTH open and
* closed tasks (the board's own $column['nb_tasks'] is open-only), so a column that only
* holds closed tasks is still treated as non-empty.
*/
class OrganonColumnHelper extends Base
{
private static $emptyCache = array();
public function hasNoTasks($project_id, $column_id)
{
if (! isset(self::$emptyCache[$column_id])) {
$count = $this->taskFinderModel->countByColumnId(
$project_id,
$column_id,
array(TaskModel::STATUS_OPEN, TaskModel::STATUS_CLOSED)
);
self::$emptyCache[$column_id] = ($count === 0);
}
return self::$emptyCache[$column_id];
}
}

View File

View File

View File

@@ -1,6 +1,6 @@
<?php
namespace Kanboard\Plugin\Skeleton;
namespace Kanboard\Plugin\OrganonTweaks;
use Kanboard\Core\Plugin\Base;
@@ -8,28 +8,57 @@ class Plugin extends Base
{
public function initialize()
{
// 1. Render a visible word at the top of every page (the demo output).
$this->template->hook->attach('template:layout:top', 'skeleton:layout/header');
// "Remove this Column" entry in the board column header dropdown. The only
// column hook renders outside the native menu <ul>, so the item is rendered
// hidden and relocate.js moves it into the menu (like BulkMoveTasks).
$this->template->hook->attach('template:board:column:dropdown', 'organonTweaks:board/column_dropdown');
// 2. Load the plugin stylesheet (currently empty -- proves the CSS hook fires).
$this->hook->on('template:layout:css', array(
'template' => 'plugins/Skeleton/Asset/css/skeleton.css',
));
// 3. Load the plugin script (currently empty -- proves the JS hook fires).
$this->hook->on('template:layout:js', array(
'template' => 'plugins/Skeleton/Asset/js/skeleton.js',
'template' => 'plugins/OrganonTweaks/Asset/js/relocate.js',
));
// Settings page for the plugin's tweaks.
$this->template->hook->attach('template:config:sidebar', 'organonTweaks:config/sidebar');
// Tweak: always show a comment icon on board cards. When a card has no comments
// yet, add a bubble "+" that opens the same comment modal (add the first comment
// straight from the board). Cards with comments already show the native count.
if ((int) $this->configModel->get('organon_tweaks_always_comment_icon', 1) === 1) {
$this->template->hook->attach('template:board:task:icons', 'organonTweaks:board/task_comment_icon');
}
// Tweak: keep the board horizontal scroll position across refreshes (Kanboard
// rebuilds #board-container on card drop / polling, resetting scrollLeft to 0).
if ((int) $this->configModel->get('organon_tweaks_keep_scroll', 1) === 1) {
$this->hook->on('template:layout:js', array(
'template' => 'plugins/OrganonTweaks/Asset/js/keep-scroll.js',
));
}
// Tweak: only open a card on a quick click (a press held > 100ms is a drag, so the
// trailing click is swallowed and the card does not open).
if ((int) $this->configModel->get('organon_tweaks_quick_click_only', 1) === 1) {
$this->hook->on('template:layout:js', array(
'template' => 'plugins/OrganonTweaks/Asset/js/click-guard.js',
));
}
}
public function getHelpers()
{
return array(
'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper'),
);
}
public function getPluginName()
{
return 'Skeleton';
return 'OrganonTweaks';
}
public function getPluginDescription()
{
return t('Reusable skeleton/template for building Kanboard plugins.');
return t('Umbrella plugin for small Kanboard tweaks: remove an empty board column, always show the card comment icon, and more.');
}
public function getPluginAuthor()
@@ -39,12 +68,12 @@ class Plugin extends Base
public function getPluginVersion()
{
return '0.1.0';
return '1.2.1';
}
public function getPluginHomepage()
{
return 'https://code.beco.cc/beco/kanboard-plugin-skeleton';
return 'https://code.beco.cc/beco/OrganonTweaks';
}
public function getCompatibleVersion()

119
README.md
View File

@@ -1,19 +1,70 @@
# Skeleton -- a Kanboard plugin template
# OrganonTweaks -- umbrella plugin for small Kanboard tweaks
A minimal, working Kanboard plugin that you copy and rename as the starting point for a
real plugin. By itself it does only one trivial thing: it renders the word "Skeleton" at
the top of every page. It changes no data and runs no database migration.
A home for small, unrelated Kanboard adjustments that are not big enough to deserve their
own plugin. Each tweak is self-contained; more will be added over time. If a tweak grows
into a real subsystem it graduates into its own plugin (see PLAN-organon-tweaks-plugin.md).
## What it demonstrates
## Tweaks
- A complete `Plugin.php` registration class with all the metadata Kanboard shows in
Settings -> Plugins (name, description, author, version, homepage, compatible version).
- A template hook (`template:layout:top`) that injects a template into the page.
- Asset hooks (`template:layout:css` and `template:layout:js`) that load a stylesheet and
a script. They are empty for now but prove the injection path works -- handy when a real
plugin needs custom CSS or JS.
- The standard plugin directory layout, with stub folders (`Controller/`, `Model/`,
`Schema/`, `Locale/`, `Test/`) ready to grow into.
### Remove an empty column
Adds a **Remove this Column** entry (trash icon) to a board column's header dropdown, next
to the other column actions. It appears only when the column is completely empty -- no
tasks, open or closed -- because Kanboard's column removal is a plain delete and a non-empty
column would orphan its tasks. Clicking it asks for confirmation, then removes the column
and returns to the board.
- The menu entry is rendered by the `template:board:column:dropdown` hook (which sits
outside the native menu list), then moved into the menu by `Asset/js/relocate.js`.
- The empty check counts open and closed tasks via a helper
(`Helper/OrganonColumnHelper.php`, `taskFinderModel->countByColumnId(...)`), since the
board's own count is open-only.
- Only users allowed to manage columns see it, and the controller re-checks both the
permission and the emptiness before deleting.
### Always show the comment icon
Kanboard shows the comment icon on a board card only once it has at least one comment, so
adding the *first* comment means opening the card and finding the sidebar link. With this
tweak on, a card with no comments shows a comment bubble with a **"+"** that opens the same
comment modal, letting you add the first comment straight from the board. Cards that already
have comments keep showing the count as before.
- Rendered by the `template:board:task:icons` hook (`Template/board/task_comment_icon.php`),
only when the card has no comments and the user may create one (so it does not show for
read-only viewers).
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
### Keep the board scroll position across refreshes
When you drop a card (or Kanboard auto-refreshes the board via polling), it rebuilds the
board with `$("#board-container").replaceWith(...)`, and the new element starts scrolled to
the far left -- so the view jumps back to the first column. This tweak remembers the
horizontal scroll position and restores it as soon as the rebuilt board appears, so the
board stays where you were.
- Implemented in `Asset/js/keep-scroll.js`: it observes the stable parent (the container
itself is replaced), reads/writes `scrollLeft` on whichever `#board-container` is current.
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
### Open a card only on a quick click
Dropping a card sometimes leaks a trailing "click" that opens the card -- an unwanted side
effect of the drag. A real click is a quick press-release; a drag is a press held much
longer. So `Asset/js/click-guard.js` times the press on a card (`mousedown`/`touchstart` to
the following `click`) and, if it was held longer than **100 ms**, swallows the click in the
capture phase before Kanboard's open handler -- so a drag never opens the card. Normal
clicks (well under 100 ms) still open cards as usual. The 100 ms threshold is fixed.
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
## Settings
Global (per Kanboard instance) and admin-only, under "Settings -> Organon Tweaks":
- **Always show the comment icon on board cards** -- default on.
- **Keep the board scroll position across refreshes** -- default on.
- **Open a card only on a quick click** -- default on.
## Requirements
@@ -21,45 +72,9 @@ the top of every page. It changes no data and runs no database migration.
## Installation
No build step and no dependencies.
1. Copy this folder into your Kanboard installation as `plugins/Skeleton/`.
2. Reload any page. The word "Skeleton" appears at the top.
3. Confirm it under Settings -> Plugins.
To uninstall, delete `plugins/Skeleton/`. Nothing else is left behind.
## Directory layout
```
Skeleton/
Plugin.php Registration and hook wiring (the only required file).
README.md
LICENSE AGPL-3.0.
Template/
layout/header.php The visible "Skeleton" word.
Asset/
css/skeleton.css Loaded via template:layout:css.
js/skeleton.js Loaded via template:layout:js.
Controller/ Stub for future request handlers.
Model/ Stub for future business logic / DB access.
Schema/ Stub for future database migrations.
Locale/ Stub for future translations (e.g. pt_BR/, fr_FR/).
Test/ Stub for future unit tests.
```
## How to fork this into a new plugin
1. Copy the folder and rename it, e.g. `plugins/MyPlugin/`. The folder name must match the
namespace and start with a capital letter.
2. In `Plugin.php`, change the namespace from `Kanboard\Plugin\Skeleton` to
`Kanboard\Plugin\MyPlugin`.
3. Update the metadata methods (`getPluginName`, `getPluginDescription`, `getPluginAuthor`,
`getPluginVersion`, `getPluginHomepage`, `getCompatibleVersion`).
4. Update the hook target paths: the lowercase prefix in `'skeleton:layout/header'` and the
`plugins/Skeleton/Asset/...` asset paths must match the new plugin name.
5. Replace `Template/layout/header.php` with your real template, or attach to a different
hook. See the Kanboard plugin hooks documentation for the full list of hook points.
Copy this folder into your Kanboard installation as `plugins/OrganonTweaks/`. The directory
name must be exactly `OrganonTweaks` (Kanboard derives the plugin namespace from the folder
name). No build step and no database migration.
## License

View File

View File

@@ -0,0 +1,12 @@
<?php if ($this->user->hasProjectAccess('ColumnController', 'remove', $column['project_id']) && $this->OrganonColumnHelper->hasNoTasks($column['project_id'], $column['id'])): ?>
<?php /* Rendered outside the menu <ul> by the only column hook, then moved into it by
Asset/js/relocate.js. Hidden until relocated so it never flashes in the header.
Shown only for empty columns (no open or closed tasks). */ ?>
<li class="organontweaks-remove-column-item" style="display: none;">
<?= $this->modal->confirm('trash-o', t('Remove this Column'), 'ColumnController', 'confirm', array(
'plugin' => 'OrganonTweaks',
'project_id' => $column['project_id'],
'column_id' => $column['id'],
)) ?>
</li>
<?php endif ?>

View File

@@ -0,0 +1,17 @@
<div class="page-header">
<h2><?= t('Remove this column') ?></h2>
</div>
<form method="post" action="<?= $this->url->href('ColumnController', 'remove', array('plugin' => 'OrganonTweaks', 'project_id' => $project['id'])) ?>">
<?= $this->form->csrf() ?>
<?= $this->form->hidden('column_id', array('column_id' => $column_id)) ?>
<p class="alert alert-info">
<?= t('Do you really want to remove this column: "%s"?', $column) ?>
</p>
<?= $this->modal->submitButtons(array(
'submitLabel' => t('Remove'),
'color' => 'red',
)) ?>
</form>

View File

@@ -0,0 +1,18 @@
<?php /*
* Rendered by the template:board:task:icons hook, inside the card icons row. When a card
* has no comments yet, Kanboard shows no comment icon at all -- so adding the first comment
* means opening the card and finding the sidebar link. This adds a comment bubble with a
* "+" that opens the same comment modal (CommentListController::show) the native count icon
* uses, so the first comment can be added straight from the board. Cards that already have
* comments render nothing here (the native count icon already shows).
*
* Only shown to users who may create a comment (CommentController::save is PROJECT_MEMBER),
* so it does not appear for read-only viewers.
*/ ?>
<?php if (empty($task['nb_comments']) && $this->user->hasProjectAccess('CommentController', 'save', $task['project_id'])): ?>
<?php /* The hook renders at the END of the icons row; relocate.js moves this span up to
the native comment slot (before the description icon) so the order matches. */ ?>
<span class="organontweaks-comment-add">
<?= $this->modal->medium('comments-o', '+', 'CommentListController', 'show', array('task_id' => $task['id']), t('Add a comment')) ?>
</span>
<?php endif ?>

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

@@ -0,0 +1,32 @@
<div class="page-header">
<h2><?= t('Organon Tweaks') ?></h2>
</div>
<form method="post" action="<?= $this->url->href('ConfigController', 'save', array('plugin' => 'OrganonTweaks')) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
<fieldset>
<?= $this->form->checkbox('organon_tweaks_always_comment_icon', t('Always show the comment icon on board cards'), 1, isset($values['organon_tweaks_always_comment_icon']) && $values['organon_tweaks_always_comment_icon'] == 1) ?>
<p class="form-help">
<?= t('Show a comment bubble with a "+" on cards that have no comments yet, so you can add the first comment straight from the board. Cards that already have comments keep showing the count as usual.') ?>
</p>
</fieldset>
<fieldset>
<?= $this->form->checkbox('organon_tweaks_keep_scroll', t('Keep the board scroll position across refreshes'), 1, isset($values['organon_tweaks_keep_scroll']) && $values['organon_tweaks_keep_scroll'] == 1) ?>
<p class="form-help">
<?= t('When a card is dropped (or the board auto-refreshes), Kanboard rebuilds the board and jumps back to the first column. This restores the horizontal scroll position so the board stays where you were.') ?>
</p>
</fieldset>
<fieldset>
<?= $this->form->checkbox('organon_tweaks_quick_click_only', t('Open a card only on a quick click'), 1, isset($values['organon_tweaks_quick_click_only']) && $values['organon_tweaks_quick_click_only'] == 1) ?>
<p class="form-help">
<?= t('A press on a card held longer than 100 ms is treated as a drag, so releasing it does not open the card. Fixes a drag sometimes opening the card on release. Normal clicks are well under 100 ms.') ?>
</p>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn btn-blue"><?= t('Save') ?></button>
</div>
</form>

View File

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

View File

@@ -1 +0,0 @@
<div class="skeleton-plugin-marker">Skeleton</div>

View File

View File

@@ -1 +1 @@
OrganonTweaks v0.1
OrganonTweaks v1.2.1