diff --git a/README.md b/README.md
index c20b80c..6845193 100644
--- a/README.md
+++ b/README.md
@@ -190,6 +190,22 @@ When editing a page, click the code block button (angle brackets icon `<>`) in t
This complements DokuWiki's built-in monospace formatting (`''`) by providing quick access to HTML code blocks.
+### 0.1) Editor toolbar: Date Fix
+
+The plugin adds two toolbar buttons for normalizing timestamps while editing:
+
+- **Date Fix**: Converts the selected timestamp to `YYYY-MM-DD` (or `YYYY-MM-DD HH:MM:SS` if time is included).
+- **Date Fix (All)**: Scans the whole page and normalizes any recognizable timestamps.
+
+Supported input examples include:
+
+- `2026-01-30`
+- `30.01.2026`
+- `30 Jan 2026`
+- `Jan 30, 2026`
+- `2026-01-30 13:45`
+- `2026-01-30T13:45:00`
+
### 1) List files by glob pattern
The `{{directory>...}}` syntax (or `{{files>...}}` for backwards compatibility) can handle both directory listings and glob patterns. When a glob pattern is used, it renders as a table:
diff --git a/action.php b/action.php
index 6a80d2e..804ca51 100644
--- a/action.php
+++ b/action.php
@@ -48,6 +48,7 @@ class action_plugin_luxtools extends ActionPlugin
"gallery-thumbnails.js",
"open-service.js",
"scratchpads.js",
+ "date-fix.js",
"linkfavicon.js",
"main.js",
];
@@ -100,5 +101,22 @@ class action_plugin_luxtools extends ActionPlugin
"close" => "",
"block" => false,
];
+
+ // Date Fix: normalize selected timestamp
+ $event->data[] = [
+ "type" => "LuxtoolsDatefix",
+ "title" => $this->getLang("toolbar_datefix_title"),
+ "icon" => "../../plugins/luxtools/images/date-fix.svg",
+ "key" => "t",
+ "block" => false,
+ ];
+
+ // Date Fix All: normalize all timestamps on page
+ $event->data[] = [
+ "type" => "LuxtoolsDatefixAll",
+ "title" => $this->getLang("toolbar_datefix_all_title"),
+ "icon" => "../../plugins/luxtools/images/date-fix-all.svg",
+ "block" => false,
+ ];
}
}
diff --git a/deploy.sh b/deploy.sh
index e180d3f..bac9d6f 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -9,12 +9,10 @@ set -euo pipefail
# ./deploy.sh --dry-run # show what would change
# ./deploy.sh /path/to/luxtools
# ./deploy.sh --no-delete # don't delete extraneous files at target
-# ./deploy.sh --preserve-times # keep source mtimes at target
TARGET="/thebe/Web/lib/plugins/luxtools"
DRY_RUN=0
DELETE=1
-PRESERVE_TIMES=0
while (($#)); do
case "$1" in
@@ -26,10 +24,6 @@ while (($#)); do
DELETE=0
shift
;;
- --preserve-times)
- PRESERVE_TIMES=1
- shift
- ;;
-h|--help)
sed -n '1,80p' "$0"
exit 0
@@ -85,12 +79,6 @@ RSYNC_ARGS=(
--exclude=.DS_Store
)
-# DokuWiki's combined CSS (lib/exe/css.php) is cached and invalidated based on source file mtimes.
-# When deploying to a mounted/remote filesystem with a different clock, preserving mtimes can make
-# DokuWiki think the cache is always newer than your plugin CSS. Avoid that by default.
-if (( ! PRESERVE_TIMES )); then
- RSYNC_ARGS+=(--no-times --omit-dir-times)
-fi
if ((DRY_RUN)); then
RSYNC_ARGS+=(--dry-run)
@@ -105,4 +93,20 @@ echo "Deploying luxtools to: $TARGET/"
rsync "${RSYNC_ARGS[@]}" "$SRC_DIR/" "$TARGET/"
+# Invalidate DokuWiki cache by touching conf/local.php
+# This forces DokuWiki to rebuild JavaScript/CSS bundles
+CONF_LOCAL="$(dirname "$TARGET")/../../conf/local.php"
+if [[ -f "$CONF_LOCAL" ]]; then
+ if ((DRY_RUN)); then
+ echo "(dry-run) Would touch $CONF_LOCAL to invalidate cache"
+ elif touch "$CONF_LOCAL" 2>/dev/null; then
+ echo "Cache invalidated (touched conf/local.php)"
+ else
+ echo "Note: Cannot touch conf/local.php (permission denied)."
+ echo " Run 'touch conf/local.php' on the server to clear cache."
+ fi
+else
+ echo "Note: conf/local.php not found at expected path, skip cache invalidation."
+fi
+
echo "Done."
diff --git a/images/date-fix-all.svg b/images/date-fix-all.svg
new file mode 100644
index 0000000..f99468b
--- /dev/null
+++ b/images/date-fix-all.svg
@@ -0,0 +1,10 @@
+
diff --git a/images/date-fix.svg b/images/date-fix.svg
new file mode 100644
index 0000000..41148b1
--- /dev/null
+++ b/images/date-fix.svg
@@ -0,0 +1,8 @@
+
diff --git a/js/date-fix.js b/js/date-fix.js
new file mode 100644
index 0000000..5cf30a1
--- /dev/null
+++ b/js/date-fix.js
@@ -0,0 +1,235 @@
+/* global window, document, DWgetSelection, DWsetSelection, pasteText */
+
+(function () {
+ 'use strict';
+
+ var Luxtools = window.Luxtools || (window.Luxtools = {});
+ var DateFix = Luxtools.DateFix || (Luxtools.DateFix = {});
+
+ // Month name patterns for regex (English and German)
+ var MONTH_PATTERN = '(?:jan|feb|m[aä]r|apr|ma[iy]|jun|jul|aug|sep|sept|okt|oct|nov|de[cz])[a-z]*\\.?';
+
+ // Regex to find date candidates in text for "fix all" feature
+ var CANDIDATE_REGEX = new RegExp(
+ '\\b(?:' +
+ '\\d{4}[-\\/.][\\d]{1,2}[-\\/.][\\d]{1,2}|' + // YYYY-MM-DD
+ '\\d{1,2}[-\\/.][\\d]{1,2}[-\\/.][\\d]{4}|' + // DD-MM-YYYY
+ MONTH_PATTERN + '\\s+\\d{1,2}(?:st|nd|rd|th)?[,]?\\s+\\d{4}|' + // Month DD, YYYY
+ '\\d{1,2}(?:st|nd|rd|th)?\\.?\\s+' + MONTH_PATTERN + '\\s+\\d{4}|' + // DD Month YYYY
+ '\\d{4}\\s+' + MONTH_PATTERN + '\\s+\\d{1,2}' + // YYYY Month DD
+ ')(?:[T\\s]+\\d{1,2}:\\d{2}(?::\\d{2})?(?:\\s*(?:am|pm))?)?\\b',
+ 'gi'
+ );
+
+ // Map month names (English and German) to month numbers (1-12)
+ var MONTH_MAP = {
+ 'jan': 1, 'januar': 1, 'january': 1,
+ 'feb': 2, 'februar': 2, 'february': 2,
+ 'mar': 3, 'mär': 3, 'märz': 3, 'march': 3, 'maerz': 3,
+ 'apr': 4, 'april': 4,
+ 'may': 5, 'mai': 5,
+ 'jun': 6, 'juni': 6, 'june': 6,
+ 'jul': 7, 'juli': 7, 'july': 7,
+ 'aug': 8, 'august': 8,
+ 'sep': 9, 'sept': 9, 'september': 9,
+ 'oct': 10, 'okt': 10, 'oktober': 10, 'october': 10,
+ 'nov': 11, 'november': 11,
+ 'dec': 12, 'dez': 12, 'dezember': 12, 'december': 12
+ };
+
+ function pad2(n) {
+ return n < 10 ? '0' + n : String(n);
+ }
+
+ /**
+ * Look up a month name (English or German) and return its number (1-12).
+ * Returns null if not found.
+ */
+ function parseMonthName(name) {
+ if (!name) return null;
+ var key = name.toLowerCase().replace(/\.$/, '');
+ return MONTH_MAP[key] || null;
+ }
+
+ /**
+ * Preprocess input to make it parseable by Date.
+ * Handles formats Date.parse() doesn't understand natively.
+ */
+ function preprocess(input) {
+ var s = input
+ .replace(/^\s*[([{"'`]+|[)\]}",'`]+\s*$/g, '') // strip surrounding brackets/quotes
+ .replace(/\s*(Z|[+-]\d{2}:?\d{2})$/i, '') // strip timezone
+ .replace(/(\d)(st|nd|rd|th)\b/gi, '$1') // strip ordinal suffixes
+ .replace(/,/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+
+ // Handle month names (English and German) - convert to YYYY-MM-DD format
+ // Pattern: DD Month YYYY or DD. Month YYYY
+ var monthMatch = s.match(/^(\d{1,2})\.?\s+([a-zäö]+)\.?\s+(\d{4})(.*)$/i);
+ if (monthMatch) {
+ var day = parseInt(monthMatch[1], 10);
+ var monthNum = parseMonthName(monthMatch[2]);
+ var year = monthMatch[3];
+ var rest = monthMatch[4] || '';
+ if (monthNum) {
+ return year + '-' + pad2(monthNum) + '-' + pad2(day) + rest;
+ }
+ }
+
+ // Pattern: Month DD, YYYY
+ monthMatch = s.match(/^([a-zäö]+)\.?\s+(\d{1,2})\s+(\d{4})(.*)$/i);
+ if (monthMatch) {
+ var monthNum = parseMonthName(monthMatch[1]);
+ var day = parseInt(monthMatch[2], 10);
+ var year = monthMatch[3];
+ var rest = monthMatch[4] || '';
+ if (monthNum) {
+ return year + '-' + pad2(monthNum) + '-' + pad2(day) + rest;
+ }
+ }
+
+ // Pattern: YYYY Month DD
+ monthMatch = s.match(/^(\d{4})\s+([a-zäö]+)\.?\s+(\d{1,2})(.*)$/i);
+ if (monthMatch) {
+ var year = monthMatch[1];
+ var monthNum = parseMonthName(monthMatch[2]);
+ var day = parseInt(monthMatch[3], 10);
+ var rest = monthMatch[4] || '';
+ if (monthNum) {
+ return year + '-' + pad2(monthNum) + '-' + pad2(day) + rest;
+ }
+ }
+
+ // DD.MM.YYYY or DD/MM/YYYY -> rearrange to YYYY-MM-DD for Date
+ var match = s.match(/^(\d{1,2})[-\/.](\d{1,2})[-\/.](\d{4})(.*)$/);
+ if (match) {
+ var a = parseInt(match[1], 10);
+ var b = parseInt(match[2], 10);
+ var year = match[3];
+ var rest = match[4] || '';
+ // Disambiguate: if first > 12, it must be day (DMY); else assume DMY
+ var day = a > 12 ? a : (b > 12 ? b : a);
+ var month = a > 12 ? b : (b > 12 ? a : b);
+ s = year + '-' + pad2(month) + '-' + pad2(day) + rest;
+ }
+
+ return s;
+ }
+
+ /**
+ * Try to parse a date/time string into a Date object.
+ * Returns null if parsing fails or produces an invalid date.
+ */
+ function tryParse(input) {
+ if (!input || typeof input !== 'string') return null;
+
+ var preprocessed = preprocess(input);
+ if (!preprocessed) return null;
+
+ // Try native parsing
+ var ts = Date.parse(preprocessed);
+ if (!isNaN(ts)) return new Date(ts);
+
+ // Fallback: replace dots/slashes with dashes and try again
+ var normalized = preprocessed.replace(/[\/\.]/g, '-');
+ ts = Date.parse(normalized);
+ if (!isNaN(ts)) return new Date(ts);
+
+ return null;
+ }
+
+ /**
+ * Check if the original input contained a time component.
+ */
+ function hasTimeComponent(input) {
+ return /\d{1,2}:\d{2}/.test(input);
+ }
+
+ /**
+ * Format a Date object to YYYY-MM-DD or YYYY-MM-DD HH:MM:SS.
+ */
+ function formatDate(date, includeTime) {
+ var out = date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate());
+ if (includeTime) {
+ out += ' ' + pad2(date.getHours()) + ':' + pad2(date.getMinutes()) + ':' + pad2(date.getSeconds());
+ }
+ return out;
+ }
+
+ /**
+ * Normalize a timestamp string to YYYY-MM-DD (or YYYY-MM-DD HH:MM:SS if time present).
+ * Returns null if the input cannot be parsed as a valid date.
+ */
+ function normalizeTimestamp(input) {
+ var date = tryParse(input);
+ if (!date) return null;
+ return formatDate(date, hasTimeComponent(input));
+ }
+
+ function getEditor(edid) {
+ return document.getElementById(edid);
+ }
+
+ function fixSelection(edid) {
+ if (typeof DWgetSelection !== 'function' || typeof pasteText !== 'function') return false;
+ var textarea = getEditor(edid);
+ if (!textarea) return false;
+
+ var selection = DWgetSelection(textarea);
+ var text = selection.getText();
+ if (!text || !text.trim()) return false;
+
+ var normalized = normalizeTimestamp(text);
+ if (!normalized) return false;
+
+ pasteText(selection, normalized, { nosel: true });
+ return false;
+ }
+
+ function fixAll(edid) {
+ var textarea = getEditor(edid);
+ if (!textarea) return false;
+
+ var selection = typeof DWgetSelection === 'function' ? DWgetSelection(textarea) : null;
+ var original = textarea.value;
+ var replaced = original.replace(CANDIDATE_REGEX, function (match) {
+ var normalized = normalizeTimestamp(match);
+ return normalized || match;
+ });
+
+ if (replaced !== original) {
+ textarea.value = replaced;
+ if (selection && typeof DWsetSelection === 'function') {
+ selection.start = Math.min(selection.start, textarea.value.length);
+ selection.end = Math.min(selection.end, textarea.value.length);
+ DWsetSelection(selection);
+ }
+ }
+
+ return false;
+ }
+
+ DateFix.normalize = normalizeTimestamp;
+ DateFix.fixSelection = fixSelection;
+ DateFix.fixAll = fixAll;
+
+ // Toolbar button action handlers
+ // DokuWiki toolbar looks for addBtnAction functions for custom button types.
+ // The buttons are registered via PHP (TOOLBAR_DEFINE hook in action.php).
+ window.addBtnActionLuxtoolsDatefix = function ($btn, props, edid) {
+ $btn.on('click', function () {
+ fixSelection(edid);
+ return false;
+ });
+ return 'luxtools-datefix';
+ };
+
+ window.addBtnActionLuxtoolsDatefixAll = function ($btn, props, edid) {
+ $btn.on('click', function () {
+ fixAll(edid);
+ return false;
+ });
+ return 'luxtools-datefix-all';
+ };
+})();
diff --git a/lang/en/lang.php b/lang/en/lang.php
index 083dabb..9c42cb7 100644
--- a/lang/en/lang.php
+++ b/lang/en/lang.php
@@ -73,3 +73,5 @@ $lang["scratchpad_err_unreadable"] = "Scratchpad file is not readable";
$lang["toolbar_code_title"] = "Code Block";
$lang["toolbar_code_sample"] = "your code here";
+$lang["toolbar_datefix_title"] = "Date Fix";
+$lang["toolbar_datefix_all_title"] = "Date Fix (All)";