xref: /plugin/annotations/script.js (revision 43d2073c014d8cf78420fa47c6568a01e7249305)
1/**
2 * Annotations plugin — front-end script.
3 *
4 * Responsibilities:
5 *
6 *   1. BOOT: read JSINFO.annotations (injected by action.php); if the user
7 *      has disabled annotations, exit early.
8 *
9 *   2. LOAD: fetch the page's annotation list via the AJAX endpoint, then:
10 *        a. Anchor each annotation in the DOM (re-anchoring).
11 *        b. Wrap matched text in highlight <span>s.
12 *        c. Render per-line gutter markers.
13 *        d. Update the page counter bubble.
14 *
15 *   3. SELECTION: detect when the user finishes a text selection inside the
16 *      wiki content area, show an "Annotate" tooltip, capture the anchor on
17 *      click, and open a new-annotation form.
18 *
19 *   4. PANELS: clicking a highlight opens the annotation thread inline, just
20 *      below the paragraph that contains the highlight. One open panel at a
21 *      time. The panel renders the full thread: author, timestamp, body,
22 *      replies; and permission-gated action buttons.
23 *
24 *   5. AJAX: all state-changing operations POST JSON to
25 *      /lib/exe/ajax.php?call=annotations (with the DokuWiki security token).
26 *      Responses update the in-memory state and re-render affected highlights
27 *      / gutter markers / counter without a page reload.
28 *
29 *   6. ORPHANS: annotations that cannot be re-anchored are counted and
30 *      reachable via the orphaned counter link; their panels open in a
31 *      dedicated orphan drawer at the bottom of the content area.
32 *
33 * FF78 ESR compatibility:
34 *   - No #private fields, ??=, ||=, &&=, Array.at, structuredClone,
35 *     Object.hasOwn, native <dialog>.
36 *   - async/await, fetch, classes, ?., ??, Map/Set, IntersectionObserver OK.
37 */
38
39(function () {
40    'use strict';
41
42    // -----------------------------------------------------------------------
43    // Constants
44    // -----------------------------------------------------------------------
45
46    var AJAX_URL   = DOKU_BASE + 'lib/exe/ajax.php?call=annotations';
47    var CONTENT_ID = 'dokuwiki__content';
48
49    // Colour tokens (also defined in style.css; kept here so JS can read them)
50    var CLS_HIGHLIGHT_OPEN     = 'ann-highlight-open';
51    var CLS_HIGHLIGHT_RESOLVED = 'ann-highlight-resolved';
52    var CLS_HIGHLIGHT_ORPHANED = 'ann-highlight-orphaned';
53    var CLS_GUTTER_MARKER      = 'ann-gutter-marker';
54    var CLS_PANEL              = 'ann-panel';
55    var CLS_COUNTER            = 'ann-counter';
56    var CLS_TOOLTIP            = 'ann-tooltip';
57    var CLS_ORPHAN_DRAWER      = 'ann-orphan-drawer';
58
59    // -----------------------------------------------------------------------
60    // State
61    // -----------------------------------------------------------------------
62
63    /** All annotations fetched from the server, keyed by id. @type {Map<string,object>} */
64    var _annotations = new Map();
65
66    /** Currently open panel element, or null. @type {HTMLElement|null} */
67    var _openPanel = null;
68
69    /** ID of the annotation whose panel is open, or null. @type {string|null} */
70    var _openAnnId = null;
71
72    /** Current user info from JSINFO. @type {{pageId:string, enabled:bool}} */
73    var _info = {};
74
75    /** Lang strings (passed by PHP into JSINFO.annotations.lang). @type {object} */
76    var _lang = {};
77
78    /** The DokuWiki security token. @type {string} */
79    var _token = '';
80
81    /** Whether the current user is logged in. @type {bool} */
82    var _loggedIn = false;
83
84    /** Whether the current user is an admin. @type {bool} */
85    var _isAdmin = false;
86
87    // -----------------------------------------------------------------------
88    // Boot
89    // -----------------------------------------------------------------------
90
91    /**
92     * Entry point: wired to DOMContentLoaded.
93     */
94    function boot() {
95        var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {};
96        var annInfo = jsinfo.annotations || {};
97
98        if (!annInfo.enabled) {
99            return; // user disabled annotations
100        }
101
102        _info      = annInfo;
103        _lang      = annInfo.lang || {};
104        _token     = (typeof DOKU_XMLRPC !== 'undefined') ? '' : (jsinfo.token || '');
105
106        // DokuWiki puts the security token in a hidden field on every page.
107        var tokenField = document.getElementById('dw__token');
108        if (tokenField) {
109            _token = tokenField.value;
110        }
111
112        _loggedIn = !!(jsinfo.userinfo && jsinfo.userinfo.user);
113        _isAdmin  = !!(jsinfo.userinfo && jsinfo.userinfo.isadmin);
114
115        var content = document.getElementById(CONTENT_ID);
116        if (!content) {
117            return; // not a page view
118        }
119
120        renderCounter(annInfo.stats || {total: 0, open: 0, resolved: 0}, 0);
121        loadAnnotations();
122        initSelectionCapture(content);
123    }
124
125    // -----------------------------------------------------------------------
126    // AJAX helpers
127    // -----------------------------------------------------------------------
128
129    /**
130     * POST a JSON payload to the AJAX endpoint.
131     *
132     * @param {object} payload
133     * @returns {Promise<object>} response data
134     */
135    function ajax(payload) {
136        payload.sectok = _token; // DokuWiki security token field name for AJAX
137        return fetch(AJAX_URL, {
138            method:  'POST',
139            headers: {'Content-Type': 'application/json'},
140            body:    JSON.stringify(payload),
141        }).then(function (res) {
142            return res.json();
143        });
144    }
145
146    // -----------------------------------------------------------------------
147    // Load and anchor annotations
148    // -----------------------------------------------------------------------
149
150    /**
151     * Fetch all annotations for the current page and render them.
152     */
153    function loadAnnotations() {
154        // We use a lightweight GET-style call: the action.php AJAX handler
155        // is POST-only, so we pass action=load in the payload.
156        fetch(AJAX_URL + '&action=load&id=' + encodeURIComponent(_info.pageId), {
157            method: 'GET',
158        }).then(function (res) {
159            return res.json();
160        }).then(function (data) {
161            if (!data || !Array.isArray(data.annotations)) {
162                return;
163            }
164            data.annotations.forEach(function (ann) {
165                _annotations.set(ann.id, ann);
166            });
167            renderAll();
168        }).catch(function () {
169            // Graceful degradation: page still works without annotations.
170        });
171    }
172
173    /**
174     * Re-render everything: highlights, gutter markers, counter.
175     */
176    function renderAll() {
177        clearHighlights();
178        clearGutterMarkers();
179
180        var content = document.getElementById(CONTENT_ID);
181        if (!content) return;
182
183        var orphanCount = 0;
184
185        _annotations.forEach(function (ann) {
186            var range = findRange(content, ann.anchor);
187            ann._range   = range; // cache for panel positioning
188            ann._orphaned = !range;
189
190            if (range) {
191                wrapHighlight(range, ann);
192            } else {
193                orphanCount++;
194            }
195        });
196
197        renderGutterMarkers();
198        updateCounter(orphanCount);
199    }
200
201    // -----------------------------------------------------------------------
202    // Text anchoring (re-anchoring)
203    // -----------------------------------------------------------------------
204
205    /**
206     * Find the DOM Range for an anchor's quoted text.
207     *
208     * Algorithm:
209     *   1. Collect the page text via TreeWalker.
210     *   2. Search for the exact quote (normalised).
211     *   3. If found multiple times, use prefix/suffix to disambiguate.
212     *   4. If still ambiguous, use the start offset hint.
213     *   5. Map the character offset back to a DOM Range.
214     *
215     * @param {HTMLElement} root
216     * @param {object}      anchor  {exact, prefix, suffix, start}
217     * @returns {Range|null}
218     */
219    function findRange(root, anchor) {
220        if (!anchor || !anchor.exact) return null;
221
222        var exact  = normalizeWS(anchor.exact);
223        var prefix = normalizeWS(anchor.prefix || '');
224        var suffix = normalizeWS(anchor.suffix || '');
225        var hint   = anchor.start || 0;
226
227        if (exact === '') return null;
228
229        // Collect all text nodes in document order with their cumulative offsets.
230        var chunks = collectTextChunks(root);
231        var fullText = chunks.map(function (c) { return c.text; }).join('');
232        fullText = normalizeWS(fullText);
233
234        // Find all occurrences of exact.
235        var positions = [];
236        var search = fullText;
237        var base   = 0;
238        var idx;
239        while ((idx = search.indexOf(exact)) !== -1) {
240            positions.push(base + idx);
241            base   += idx + exact.length;
242            search  = search.slice(idx + exact.length);
243        }
244
245        if (positions.length === 0) return null;
246
247        var chosenPos = positions[0];
248
249        if (positions.length > 1) {
250            // Disambiguate using prefix + suffix context.
251            var best = null;
252            var bestScore = -1;
253            positions.forEach(function (pos) {
254                var pre = fullText.slice(Math.max(0, pos - prefix.length), pos);
255                var suf = fullText.slice(pos + exact.length, pos + exact.length + suffix.length);
256                var score = 0;
257                if (prefix && pre.indexOf(prefix) !== -1) score++;
258                if (suffix && suf.indexOf(suffix) !== -1) score++;
259                // Use start offset as tiebreaker.
260                var distToHint = Math.abs(pos - hint);
261                if (score > bestScore || (score === bestScore && distToHint < Math.abs(chosenPos - hint))) {
262                    bestScore = score;
263                    best      = pos;
264                    chosenPos = pos;
265                }
266            });
267            if (best !== null) chosenPos = best;
268        }
269
270        return buildRange(chunks, chosenPos, exact.length);
271    }
272
273    /**
274     * Walk the text nodes under root and return an array of
275     * {node, start, text} objects where start is the cumulative character
276     * offset of this node's text in the joined string.
277     *
278     * The joined string is NOT normalised here — we normalise the full string
279     * once above instead.
280     *
281     * @param {HTMLElement} root
282     * @returns {Array<{node:Text, start:number, text:string}>}
283     */
284    function collectTextChunks(root) {
285        var walker = document.createTreeWalker(
286            root,
287            NodeFilter.SHOW_TEXT,
288            null,
289            false
290        );
291        var chunks = [];
292        var offset = 0;
293        var node;
294        while ((node = walker.nextNode())) {
295            // Skip nodes inside our own UI elements.
296            if (isAnnotationUI(node.parentNode)) continue;
297            var text = node.nodeValue || '';
298            chunks.push({node: node, start: offset, text: text});
299            offset += text.length;
300        }
301        return chunks;
302    }
303
304    /**
305     * True if the element (or its ancestor) is part of our annotation UI.
306     *
307     * @param {Node} el
308     * @returns {bool}
309     */
310    function isAnnotationUI(el) {
311        while (el && el !== document.body) {
312            if (el.nodeType === 1) {
313                var cls = el.className || '';
314                if (
315                    cls.indexOf('ann-') !== -1 ||
316                    cls.indexOf(CLS_PANEL) !== -1
317                ) {
318                    return true;
319                }
320            }
321            el = el.parentNode;
322        }
323        return false;
324    }
325
326    /**
327     * Turn character offsets (in the normalised full string) back into a
328     * DOM Range.
329     *
330     * @param {Array<{node:Text, start:number, text:string}>} chunks
331     * @param {number} startOff  start char offset in joined (raw) text
332     * @param {number} length    length of selection in normalised text
333     * @returns {Range|null}
334     */
335    function buildRange(chunks, startOff, length) {
336        // The fullText we searched is normalised (multiple spaces → one), but
337        // chunk offsets are raw. We need to find the raw offset that corresponds
338        // to startOff in the normalised string.
339        //
340        // Simple approach: walk chunks until we've "consumed" startOff
341        // normalised characters (counting consecutive spaces as 1).
342        // This works well enough for typical wiki prose.
343
344        var rawFull  = chunks.map(function (c) { return c.text; }).join('');
345        var normFull = normalizeWS(rawFull);
346
347        // Build a map: normFull[i] → rawFull[j]
348        var normToRaw = buildNormToRaw(rawFull);
349
350        var rawStart = normToRaw[startOff];
351        var rawEnd   = normToRaw[startOff + length - 1];
352        if (rawStart === undefined || rawEnd === undefined) return null;
353        rawEnd++; // exclusive
354
355        // Find which chunks contain rawStart and rawEnd.
356        var startChunk = null, startOffset = 0;
357        var endChunk   = null, endOffset   = 0;
358
359        for (var i = 0; i < chunks.length; i++) {
360            var c = chunks[i];
361            var cEnd = c.start + c.text.length;
362
363            if (startChunk === null && c.start <= rawStart && rawStart < cEnd) {
364                startChunk  = c.node;
365                startOffset = rawStart - c.start;
366            }
367            if (endChunk === null && c.start < rawEnd && rawEnd <= cEnd) {
368                endChunk  = c.node;
369                endOffset = rawEnd - c.start;
370            }
371            if (startChunk && endChunk) break;
372        }
373
374        if (!startChunk || !endChunk) return null;
375
376        try {
377            var range = document.createRange();
378            range.setStart(startChunk, startOffset);
379            range.setEnd(endChunk, endOffset);
380            return range;
381        } catch (e) {
382            return null;
383        }
384    }
385
386    /**
387     * Build an array mapping normalised-string index → raw-string index.
388     * Consecutive whitespace is collapsed to a single space; the mapping
389     * records the index of the first character in each run.
390     *
391     * @param {string} raw
392     * @returns {Array<number>}
393     */
394    function buildNormToRaw(raw) {
395        var map     = [];
396        var inSpace = false;
397        for (var i = 0; i < raw.length; i++) {
398            var ch = raw[i];
399            if (/\s/.test(ch)) {
400                if (!inSpace) {
401                    map.push(i); // one representative space
402                    inSpace = true;
403                }
404                // else: extra whitespace chars are skipped
405            } else {
406                map.push(i);
407                inSpace = false;
408            }
409        }
410        return map;
411    }
412
413    // -----------------------------------------------------------------------
414    // Highlights
415    // -----------------------------------------------------------------------
416
417    /**
418     * Wrap a Range in a highlight <span> for the given annotation.
419     *
420     * @param {Range}  range
421     * @param {object} ann
422     */
423    function wrapHighlight(range, ann) {
424        try {
425            var span = document.createElement('span');
426            span.className = ann.status === 'resolved'
427                ? CLS_HIGHLIGHT_RESOLVED
428                : CLS_HIGHLIGHT_OPEN;
429            span.dataset.annId = ann.id;
430            span.title = ann.body.slice(0, 80) + (ann.body.length > 80 ? '…' : '');
431            span.addEventListener('click', function (e) {
432                e.stopPropagation();
433                openPanel(ann.id);
434            });
435            range.surroundContents(span);
436            ann._highlightEl = span;
437        } catch (e) {
438            // surroundContents throws if the range crosses element boundaries.
439            // Fall back to insertNode with a cloned range fragment.
440            try {
441                var frag = range.extractContents();
442                var span2 = document.createElement('span');
443                span2.className = ann.status === 'resolved'
444                    ? CLS_HIGHLIGHT_RESOLVED
445                    : CLS_HIGHLIGHT_OPEN;
446                span2.dataset.annId = ann.id;
447                span2.appendChild(frag);
448                span2.addEventListener('click', function (e) {
449                    e.stopPropagation();
450                    openPanel(ann.id);
451                });
452                range.insertNode(span2);
453                ann._highlightEl = span2;
454            } catch (e2) {
455                ann._highlightEl = null;
456            }
457        }
458    }
459
460    /**
461     * Remove all highlight spans, restoring the original text nodes.
462     */
463    function clearHighlights() {
464        var spans = document.querySelectorAll(
465            '.' + CLS_HIGHLIGHT_OPEN + ', .' + CLS_HIGHLIGHT_RESOLVED + ', .' + CLS_HIGHLIGHT_ORPHANED
466        );
467        Array.prototype.forEach.call(spans, function (span) {
468            var parent = span.parentNode;
469            if (!parent) return;
470            while (span.firstChild) {
471                parent.insertBefore(span.firstChild, span);
472            }
473            parent.removeChild(span);
474            parent.normalize();
475        });
476    }
477
478    // -----------------------------------------------------------------------
479    // Gutter markers
480    // -----------------------------------------------------------------------
481
482    /**
483     * Render a small marker in the gutter for every anchored annotation.
484     * Markers are absolutely positioned relative to the content wrapper.
485     */
486    function renderGutterMarkers() {
487        var content = document.getElementById(CONTENT_ID);
488        if (!content) return;
489
490        _annotations.forEach(function (ann) {
491            if (!ann._highlightEl) return; // orphan
492
493            var el   = ann._highlightEl;
494            var rect = el.getBoundingClientRect();
495            var contentRect = content.getBoundingClientRect();
496
497            var marker = document.createElement('button');
498            marker.className  = CLS_GUTTER_MARKER;
499            marker.dataset.annId = ann.id;
500            marker.setAttribute('aria-label', 'Annotation');
501            marker.type = 'button';
502            marker.style.top = (rect.top - contentRect.top + content.scrollTop) + 'px';
503            marker.addEventListener('click', function (e) {
504                e.stopPropagation();
505                openPanel(ann.id);
506            });
507            content.appendChild(marker);
508            ann._markerEl = marker;
509        });
510    }
511
512    /**
513     * Remove all gutter markers.
514     */
515    function clearGutterMarkers() {
516        var markers = document.querySelectorAll('.' + CLS_GUTTER_MARKER);
517        Array.prototype.forEach.call(markers, function (m) {
518            if (m.parentNode) m.parentNode.removeChild(m);
519        });
520    }
521
522    // -----------------------------------------------------------------------
523    // Page counter
524    // -----------------------------------------------------------------------
525
526    /**
527     * Render (or update) the counter bubble above the content area.
528     *
529     * @param {object} stats        {total, open, resolved}
530     * @param {number} orphanCount
531     */
532    function renderCounter(stats, orphanCount) {
533        var existing = document.getElementById('ann-counter-bar');
534        if (existing) existing.parentNode.removeChild(existing);
535
536        if (stats.total === 0 && orphanCount === 0) return;
537
538        var bar = document.createElement('div');
539        bar.id = 'ann-counter-bar';
540        bar.className = CLS_COUNTER;
541
542        var total = stats.total || 0;
543        var label = total === 1
544            ? '1 annotation'
545            : total + ' annotations';
546        bar.appendChild(document.createTextNode(label));
547
548        if (orphanCount > 0) {
549            bar.appendChild(document.createTextNode(' · '));
550            var orphanLink = document.createElement('a');
551            orphanLink.href = '#ann-orphan-drawer';
552            orphanLink.className = 'ann-orphan-link';
553            orphanLink.textContent = orphanCount + ' orphaned';
554            orphanLink.addEventListener('click', function (e) {
555                e.preventDefault();
556                toggleOrphanDrawer();
557            });
558            bar.appendChild(orphanLink);
559        }
560
561        if (_isAdmin && (stats.resolved > 0 || orphanCount > 0)) {
562            if (stats.resolved > 0) {
563                var btnCR = document.createElement('button');
564                btnCR.type = 'button';
565                btnCR.className = 'ann-btn ann-btn-admin';
566                btnCR.textContent = 'Clear resolved';
567                btnCR.addEventListener('click', doClearResolved);
568                bar.appendChild(btnCR);
569            }
570            if (orphanCount > 0) {
571                var btnCO = document.createElement('button');
572                btnCO.type = 'button';
573                btnCO.className = 'ann-btn ann-btn-admin';
574                btnCO.textContent = 'Clear orphaned';
575                btnCO.addEventListener('click', doClearOrphaned);
576                bar.appendChild(btnCO);
577            }
578        }
579
580        var content = document.getElementById(CONTENT_ID);
581        if (content && content.parentNode) {
582            content.parentNode.insertBefore(bar, content);
583        }
584    }
585
586    /**
587     * Recount and re-render the counter from in-memory state.
588     */
589    function updateCounter(orphanCount) {
590        var open = 0, resolved = 0;
591        if (orphanCount === undefined) {
592            orphanCount = 0;
593        }
594        _annotations.forEach(function (ann) {
595            if (ann._orphaned) {
596                orphanCount++;
597            } else if (ann.status === 'resolved') {
598                resolved++;
599            } else {
600                open++;
601            }
602        });
603        renderCounter({total: open + resolved, open: open, resolved: resolved}, orphanCount);
604    }
605
606    // -----------------------------------------------------------------------
607    // Annotation panel
608    // -----------------------------------------------------------------------
609
610    /**
611     * Open the thread panel for the given annotation id.
612     * If that panel is already open, close it.
613     *
614     * @param {string} annId
615     */
616    function openPanel(annId) {
617        if (_openAnnId === annId) {
618            closePanel();
619            return;
620        }
621        closePanel();
622
623        var ann = _annotations.get(annId);
624        if (!ann) return;
625
626        var panel = buildPanel(ann);
627        _openPanel  = panel;
628        _openAnnId  = annId;
629
630        // Insert below the paragraph that contains the highlight.
631        var anchor = ann._highlightEl || null;
632        var insertAfter = findParagraph(anchor);
633        if (insertAfter && insertAfter.parentNode) {
634            insertAfter.parentNode.insertBefore(panel, insertAfter.nextSibling);
635        } else {
636            // Orphan or no paragraph found: show at the bottom of content.
637            var content = document.getElementById(CONTENT_ID);
638            if (content) content.appendChild(panel);
639        }
640
641        panel.querySelector('.ann-body-input') && panel.querySelector('.ann-body-input').focus();
642    }
643
644    /**
645     * Close and remove the currently open panel.
646     */
647    function closePanel() {
648        if (_openPanel && _openPanel.parentNode) {
649            _openPanel.parentNode.removeChild(_openPanel);
650        }
651        _openPanel = null;
652        _openAnnId = null;
653    }
654
655    /**
656     * Find the nearest block-level ancestor of el (p, li, h1-h6, etc.)
657     * that can receive a sibling element.
658     *
659     * @param {HTMLElement|null} el
660     * @returns {HTMLElement|null}
661     */
662    function findParagraph(el) {
663        var block = /^(P|LI|DT|DD|H[1-6]|BLOCKQUOTE|PRE|TABLE|DIV)$/;
664        var node = el;
665        while (node && node.id !== CONTENT_ID) {
666            if (node.nodeType === 1 && block.test(node.tagName)) {
667                return node;
668            }
669            node = node.parentNode;
670        }
671        return el; // fallback: use the element itself
672    }
673
674    /**
675     * Build and return the panel DOM element for one annotation.
676     *
677     * @param {object} ann
678     * @returns {HTMLElement}
679     */
680    function buildPanel(ann) {
681        var panel = document.createElement('div');
682        panel.className = CLS_PANEL;
683        panel.dataset.annId = ann.id;
684
685        // Header
686        var header = document.createElement('div');
687        header.className = 'ann-panel-header';
688
689        var closeBtn = document.createElement('button');
690        closeBtn.type = 'button';
691        closeBtn.className = 'ann-btn ann-close';
692        closeBtn.setAttribute('aria-label', 'Close');
693        closeBtn.textContent = '×';
694        closeBtn.addEventListener('click', closePanel);
695        header.appendChild(closeBtn);
696
697        panel.appendChild(header);
698
699        // Main annotation thread entry
700        panel.appendChild(buildThreadEntry(ann, true));
701
702        // Replies
703        (ann.replies || []).forEach(function (reply) {
704            panel.appendChild(buildReplyEntry(ann, reply));
705        });
706
707        // Reply form (if logged in and has read access — gate is server-side anyway)
708        if (_loggedIn) {
709            panel.appendChild(buildReplyForm(ann));
710        }
711
712        return panel;
713    }
714
715    /**
716     * Build the DOM for the top-level annotation entry.
717     *
718     * @param {object}  ann
719     * @param {boolean} isRoot  true for the annotation itself, false for replies
720     * @returns {HTMLElement}
721     */
722    function buildThreadEntry(ann, isRoot) {
723        var entry = document.createElement('div');
724        entry.className = 'ann-thread-entry ann-annotation';
725        entry.dataset.annId = ann.id;
726
727        // Meta row: avatar, author, time, status pill
728        entry.appendChild(buildMeta(ann.author, ann.created, ann.status));
729
730        // Body
731        var bodyEl = document.createElement('div');
732        bodyEl.className = 'ann-body';
733        bodyEl.textContent = ann.body;
734        entry.appendChild(bodyEl);
735
736        // Quoted text snippet
737        if (ann.anchor && ann.anchor.exact) {
738            var quote = document.createElement('blockquote');
739            quote.className = 'ann-quote';
740            quote.textContent = ann.anchor.exact;
741            entry.appendChild(quote);
742        }
743
744        // Action buttons
745        var actions = document.createElement('div');
746        actions.className = 'ann-actions';
747
748        // Resolve/Reopen (any reader)
749        if (_loggedIn) {
750            var resolveBtn = document.createElement('button');
751            resolveBtn.type = 'button';
752            resolveBtn.className = 'ann-btn ann-btn-resolve';
753            resolveBtn.textContent = ann.status === 'resolved' ? 'Reopen' : 'Resolve';
754            resolveBtn.addEventListener('click', function () {
755                doResolve(ann.id, ann.status === 'resolved' ? 'open' : 'resolved');
756            });
757            actions.appendChild(resolveBtn);
758        }
759
760        // Edit + Delete (own or admin)
761        var canEdit = _isAdmin || ann.author === currentUser();
762        if (canEdit && _loggedIn) {
763            var editBtn = document.createElement('button');
764            editBtn.type = 'button';
765            editBtn.className = 'ann-btn';
766            editBtn.textContent = 'Edit';
767            editBtn.addEventListener('click', function () {
768                showEditForm(entry, ann, 'annotation');
769            });
770            actions.appendChild(editBtn);
771
772            var delBtn = document.createElement('button');
773            delBtn.type = 'button';
774            delBtn.className = 'ann-btn ann-btn-danger';
775            delBtn.textContent = 'Delete';
776            delBtn.addEventListener('click', function () {
777                if (confirm('Delete this annotation?')) {
778                    doDeleteAnnotation(ann.id);
779                }
780            });
781            actions.appendChild(delBtn);
782        }
783
784        entry.appendChild(actions);
785        return entry;
786    }
787
788    /**
789     * Build the DOM for one reply entry.
790     *
791     * @param {object} ann   parent annotation
792     * @param {object} reply
793     * @returns {HTMLElement}
794     */
795    function buildReplyEntry(ann, reply) {
796        var entry = document.createElement('div');
797        entry.className = 'ann-thread-entry ann-reply';
798        entry.dataset.replyId = reply.id;
799
800        entry.appendChild(buildMeta(reply.author, reply.created, null));
801
802        var bodyEl = document.createElement('div');
803        bodyEl.className = 'ann-body';
804        bodyEl.textContent = reply.body;
805        entry.appendChild(bodyEl);
806
807        var actions = document.createElement('div');
808        actions.className = 'ann-actions';
809
810        var canEdit = _isAdmin || reply.author === currentUser();
811        if (canEdit && _loggedIn) {
812            var editBtn = document.createElement('button');
813            editBtn.type = 'button';
814            editBtn.className = 'ann-btn';
815            editBtn.textContent = 'Edit';
816            editBtn.addEventListener('click', function () {
817                showEditForm(entry, {annId: ann.id, replyId: reply.id, body: reply.body}, 'reply');
818            });
819            actions.appendChild(editBtn);
820
821            var delBtn = document.createElement('button');
822            delBtn.type = 'button';
823            delBtn.className = 'ann-btn ann-btn-danger';
824            delBtn.textContent = 'Delete';
825            delBtn.addEventListener('click', function () {
826                if (confirm('Delete this reply?')) {
827                    doDeleteReply(ann.id, reply.id);
828                }
829            });
830            actions.appendChild(delBtn);
831        }
832
833        entry.appendChild(actions);
834        return entry;
835    }
836
837    /**
838     * Build the meta row (avatar initials, author name, timestamp, status pill).
839     *
840     * @param {string}      author
841     * @param {number}      timestamp  Unix seconds
842     * @param {string|null} status     'open'|'resolved'|null
843     * @returns {HTMLElement}
844     */
845    function buildMeta(author, timestamp, status) {
846        var meta = document.createElement('div');
847        meta.className = 'ann-meta';
848
849        var avatar = document.createElement('span');
850        avatar.className = 'ann-avatar';
851        avatar.textContent = (author || '?').slice(0, 2).toUpperCase();
852        meta.appendChild(avatar);
853
854        var authorEl = document.createElement('span');
855        authorEl.className = 'ann-author';
856        authorEl.textContent = author || 'Unknown';
857        meta.appendChild(authorEl);
858
859        var timeEl = document.createElement('time');
860        timeEl.className = 'ann-time';
861        var d = new Date(timestamp * 1000);
862        timeEl.dateTime = d.toISOString();
863        timeEl.textContent = formatDate(d);
864        meta.appendChild(timeEl);
865
866        if (status) {
867            var pill = document.createElement('span');
868            pill.className = 'ann-status ann-status-' + status;
869            pill.textContent = status === 'resolved' ? 'Resolved' : 'Open';
870            meta.appendChild(pill);
871        }
872
873        return meta;
874    }
875
876    /**
877     * Build a reply form at the bottom of the panel.
878     *
879     * @param {object} ann
880     * @returns {HTMLElement}
881     */
882    function buildReplyForm(ann) {
883        var form = document.createElement('div');
884        form.className = 'ann-reply-form';
885
886        var ta = document.createElement('textarea');
887        ta.className = 'ann-body-input';
888        ta.placeholder = 'Write a reply…';
889        ta.rows = 3;
890        form.appendChild(ta);
891
892        var row = document.createElement('div');
893        row.className = 'ann-form-row';
894
895        var submitBtn = document.createElement('button');
896        submitBtn.type = 'button';
897        submitBtn.className = 'ann-btn ann-btn-primary';
898        submitBtn.textContent = 'Reply';
899        submitBtn.addEventListener('click', function () {
900            var body = ta.value.trim();
901            if (!body) return;
902            doAddReply(ann.id, body, function () {
903                ta.value = '';
904            });
905        });
906        row.appendChild(submitBtn);
907        form.appendChild(row);
908
909        return form;
910    }
911
912    /**
913     * Replace the body of an entry with an inline edit form.
914     *
915     * @param {HTMLElement} entry
916     * @param {object}      data    {body, annId?, replyId?}  (annId = undefined → annotation)
917     * @param {string}      type    'annotation' | 'reply'
918     */
919    function showEditForm(entry, data, type) {
920        var bodyEl = entry.querySelector('.ann-body');
921        if (!bodyEl) return;
922
923        var ta = document.createElement('textarea');
924        ta.className = 'ann-body-input';
925        ta.value = data.body || '';
926        ta.rows  = 4;
927
928        var row = document.createElement('div');
929        row.className = 'ann-form-row';
930
931        var saveBtn = document.createElement('button');
932        saveBtn.type = 'button';
933        saveBtn.className = 'ann-btn ann-btn-primary';
934        saveBtn.textContent = 'Save';
935        saveBtn.addEventListener('click', function () {
936            var newBody = ta.value.trim();
937            if (!newBody) return;
938            if (type === 'annotation') {
939                doEditAnnotation(data.id || _openAnnId, newBody);
940            } else {
941                doEditReply(data.annId, data.replyId, newBody);
942            }
943        });
944
945        var cancelBtn = document.createElement('button');
946        cancelBtn.type = 'button';
947        cancelBtn.className = 'ann-btn';
948        cancelBtn.textContent = 'Cancel';
949        cancelBtn.addEventListener('click', function () {
950            entry.removeChild(ta);
951            entry.removeChild(row);
952            bodyEl.style.display = '';
953        });
954
955        row.appendChild(saveBtn);
956        row.appendChild(cancelBtn);
957
958        bodyEl.style.display = 'none';
959        entry.insertBefore(ta, bodyEl.nextSibling);
960        entry.insertBefore(row, ta.nextSibling);
961        ta.focus();
962    }
963
964    // -----------------------------------------------------------------------
965    // Orphan drawer
966    // -----------------------------------------------------------------------
967
968    /**
969     * Toggle the orphan drawer visibility.
970     */
971    function toggleOrphanDrawer() {
972        var drawer = document.getElementById('ann-orphan-drawer');
973        if (drawer) {
974            drawer.parentNode.removeChild(drawer);
975            return;
976        }
977        renderOrphanDrawer();
978    }
979
980    /**
981     * Build and insert the orphan drawer at the bottom of the content area.
982     */
983    function renderOrphanDrawer() {
984        var content = document.getElementById(CONTENT_ID);
985        if (!content) return;
986
987        var drawer = document.createElement('div');
988        drawer.id = 'ann-orphan-drawer';
989        drawer.className = CLS_ORPHAN_DRAWER;
990
991        var heading = document.createElement('h4');
992        heading.textContent = 'Orphaned annotations';
993        drawer.appendChild(heading);
994
995        var note = document.createElement('p');
996        note.className = 'ann-orphan-note';
997        note.textContent = 'These annotations reference text that no longer appears on the page.';
998        drawer.appendChild(note);
999
1000        var found = false;
1001        _annotations.forEach(function (ann) {
1002            if (!ann._orphaned) return;
1003            found = true;
1004            var entry = buildThreadEntry(ann, true);
1005            drawer.appendChild(entry);
1006        });
1007
1008        if (!found) {
1009            var empty = document.createElement('p');
1010            empty.textContent = 'None.';
1011            drawer.appendChild(empty);
1012        }
1013
1014        content.appendChild(drawer);
1015    }
1016
1017    // -----------------------------------------------------------------------
1018    // Selection capture
1019    // -----------------------------------------------------------------------
1020
1021    /**
1022     * Wire up mouseup/touchend listeners to detect text selection.
1023     *
1024     * @param {HTMLElement} content
1025     */
1026    function initSelectionCapture(content) {
1027        if (!_loggedIn) return; // anonymous users cannot annotate
1028
1029        document.addEventListener('mouseup', function (e) {
1030            handleSelectionEnd(e, content);
1031        });
1032        document.addEventListener('touchend', function (e) {
1033            // Small delay so the browser has committed the selection.
1034            setTimeout(function () { handleSelectionEnd(e, content); }, 50);
1035        });
1036
1037        // Close tooltip/panel on click outside.
1038        document.addEventListener('mousedown', function (e) {
1039            var tooltip = document.getElementById('ann-tooltip');
1040            if (tooltip && !tooltip.contains(e.target)) {
1041                hideTooltip();
1042            }
1043        });
1044    }
1045
1046    /**
1047     * Handle end of selection: show the "Annotate" tooltip if there is a
1048     * non-empty selection inside the content area.
1049     *
1050     * @param {Event}       e
1051     * @param {HTMLElement} content
1052     */
1053    function handleSelectionEnd(e, content) {
1054        var sel = window.getSelection();
1055        if (!sel || sel.isCollapsed) {
1056            hideTooltip();
1057            return;
1058        }
1059        var range = sel.getRangeAt(0);
1060        if (!content.contains(range.commonAncestorContainer)) {
1061            hideTooltip();
1062            return;
1063        }
1064        var text = sel.toString().trim();
1065        if (text.length < 1) {
1066            hideTooltip();
1067            return;
1068        }
1069
1070        // Show the tooltip near the end of the selection.
1071        var rect = range.getBoundingClientRect();
1072        showTooltip(rect, range, sel, content);
1073    }
1074
1075    /**
1076     * Show the "Annotate" tooltip bubble.
1077     *
1078     * @param {DOMRect}     rect     bounding rect of the selection
1079     * @param {Range}       range
1080     * @param {Selection}   sel
1081     * @param {HTMLElement} content
1082     */
1083    function showTooltip(rect, range, sel, content) {
1084        hideTooltip();
1085
1086        var tip = document.createElement('div');
1087        tip.id = 'ann-tooltip';
1088        tip.className = CLS_TOOLTIP;
1089
1090        var btn = document.createElement('button');
1091        btn.type = 'button';
1092        btn.textContent = 'Annotate';
1093        btn.className = 'ann-btn ann-btn-primary';
1094        btn.addEventListener('mousedown', function (e) {
1095            e.preventDefault(); // don't lose the selection
1096        });
1097        btn.addEventListener('click', function () {
1098            var anchor = captureAnchor(sel, range, content);
1099            hideTooltip();
1100            sel.removeAllRanges();
1101            if (anchor) {
1102                openNewAnnotationForm(anchor, range);
1103            }
1104        });
1105        tip.appendChild(btn);
1106
1107        document.body.appendChild(tip);
1108
1109        // Position below the selection's end.
1110        var scrollTop  = window.pageYOffset || document.documentElement.scrollTop;
1111        var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
1112        tip.style.top  = (rect.bottom + scrollTop  + 6) + 'px';
1113        tip.style.left = (rect.left   + scrollLeft)     + 'px';
1114    }
1115
1116    /**
1117     * Remove the tooltip if it exists.
1118     */
1119    function hideTooltip() {
1120        var tip = document.getElementById('ann-tooltip');
1121        if (tip && tip.parentNode) {
1122            tip.parentNode.removeChild(tip);
1123        }
1124        // Also remove any floating new-annotation form.
1125        var naf = document.getElementById('ann-new-form');
1126        if (naf && naf.parentNode) {
1127            naf.parentNode.removeChild(naf);
1128        }
1129    }
1130
1131    /**
1132     * Capture an anchor object from the current Selection.
1133     *
1134     * @param {Selection}   sel
1135     * @param {Range}       range
1136     * @param {HTMLElement} content
1137     * @returns {object|null} {exact, prefix, suffix, start}
1138     */
1139    function captureAnchor(sel, range, content) {
1140        var exact = normalizeWS(sel.toString());
1141        if (!exact) return null;
1142
1143        // Get full page text for prefix/suffix and start computation.
1144        var chunks   = collectTextChunks(content);
1145        var fullRaw  = chunks.map(function (c) { return c.text; }).join('');
1146        var fullNorm = normalizeWS(fullRaw);
1147
1148        // Find where this text node + offset lands in the raw full text.
1149        var rawStart = 0;
1150        for (var i = 0; i < chunks.length; i++) {
1151            var c = chunks[i];
1152            if (c.node === range.startContainer) {
1153                rawStart = c.start + range.startOffset;
1154                break;
1155            }
1156        }
1157
1158        // Map raw offset to normalised offset.
1159        var normToRaw = buildNormToRaw(fullRaw);
1160        var normStart = 0;
1161        for (var j = 0; j < normToRaw.length; j++) {
1162            if (normToRaw[j] >= rawStart) {
1163                normStart = j;
1164                break;
1165            }
1166        }
1167
1168        var CTX = 30;
1169        var prefix = fullNorm.slice(Math.max(0, normStart - CTX), normStart);
1170        var suffix = fullNorm.slice(normStart + exact.length, normStart + exact.length + CTX);
1171
1172        return {
1173            exact:  exact,
1174            prefix: prefix,
1175            suffix: suffix,
1176            start:  normStart,
1177        };
1178    }
1179
1180    /**
1181     * Open the new-annotation form below the paragraph containing the selection.
1182     *
1183     * @param {object} anchor  {exact, prefix, suffix, start}
1184     * @param {Range}  range
1185     */
1186    function openNewAnnotationForm(anchor, range) {
1187        closePanel();
1188
1189        var insertAfter = findParagraph(range.commonAncestorContainer);
1190        var form = document.createElement('div');
1191        form.id = 'ann-new-form';
1192        form.className = 'ann-new-form';
1193
1194        var quote = document.createElement('blockquote');
1195        quote.className = 'ann-quote';
1196        quote.textContent = anchor.exact;
1197        form.appendChild(quote);
1198
1199        var ta = document.createElement('textarea');
1200        ta.className = 'ann-body-input';
1201        ta.placeholder = 'Add a comment…';
1202        ta.rows = 4;
1203        form.appendChild(ta);
1204
1205        var row = document.createElement('div');
1206        row.className = 'ann-form-row';
1207
1208        var submitBtn = document.createElement('button');
1209        submitBtn.type = 'button';
1210        submitBtn.className = 'ann-btn ann-btn-primary';
1211        submitBtn.textContent = 'Annotate';
1212        submitBtn.addEventListener('click', function () {
1213            var body = ta.value.trim();
1214            if (!body) return;
1215            doCreate(anchor, body, function () {
1216                if (form.parentNode) form.parentNode.removeChild(form);
1217            });
1218        });
1219
1220        var cancelBtn = document.createElement('button');
1221        cancelBtn.type = 'button';
1222        cancelBtn.className = 'ann-btn';
1223        cancelBtn.textContent = 'Cancel';
1224        cancelBtn.addEventListener('click', function () {
1225            if (form.parentNode) form.parentNode.removeChild(form);
1226        });
1227
1228        row.appendChild(submitBtn);
1229        row.appendChild(cancelBtn);
1230        form.appendChild(row);
1231
1232        if (insertAfter && insertAfter.parentNode) {
1233            insertAfter.parentNode.insertBefore(form, insertAfter.nextSibling);
1234        } else {
1235            var content = document.getElementById(CONTENT_ID);
1236            if (content) content.appendChild(form);
1237        }
1238
1239        ta.focus();
1240    }
1241
1242    // -----------------------------------------------------------------------
1243    // AJAX actions
1244    // -----------------------------------------------------------------------
1245
1246    /**
1247     * POST create action and update state on success.
1248     *
1249     * @param {object}   anchor
1250     * @param {string}   body
1251     * @param {Function} onSuccess
1252     */
1253    function doCreate(anchor, body, onSuccess) {
1254        ajax({
1255            action: 'create',
1256            id:     _info.pageId,
1257            anchor: anchor,
1258            body:   body,
1259        }).then(function (data) {
1260            if (!data.success) {
1261                alert('Could not save annotation: ' + (data.error || 'Unknown error'));
1262                return;
1263            }
1264            var ann = data.annotation;
1265            _annotations.set(ann.id, ann);
1266            if (typeof onSuccess === 'function') onSuccess(ann);
1267            renderAll();
1268        }).catch(function () {
1269            alert('Could not save annotation.');
1270        });
1271    }
1272
1273    /**
1274     * POST reply action and refresh the open panel.
1275     *
1276     * @param {string}   annId
1277     * @param {string}   body
1278     * @param {Function} onSuccess
1279     */
1280    function doAddReply(annId, body, onSuccess) {
1281        ajax({
1282            action: 'reply',
1283            id:     _info.pageId,
1284            annId:  annId,
1285            body:   body,
1286        }).then(function (data) {
1287            if (!data.success) {
1288                alert('Could not save reply: ' + (data.error || ''));
1289                return;
1290            }
1291            // Re-fetch the updated annotation from server.
1292            refreshAnnotation(annId, function () {
1293                if (typeof onSuccess === 'function') onSuccess();
1294                reopenPanel(annId);
1295            });
1296        }).catch(function () {
1297            alert('Could not save reply.');
1298        });
1299    }
1300
1301    /**
1302     * POST edit_annotation and re-render.
1303     *
1304     * @param {string} annId
1305     * @param {string} body
1306     */
1307    function doEditAnnotation(annId, body) {
1308        ajax({
1309            action: 'edit_annotation',
1310            id:     _info.pageId,
1311            annId:  annId,
1312            body:   body,
1313        }).then(function (data) {
1314            if (!data.success) {
1315                alert('Could not save: ' + (data.error || ''));
1316                return;
1317            }
1318            var updated = data.annotation;
1319            _annotations.set(updated.id, updated);
1320            reopenPanel(annId);
1321        });
1322    }
1323
1324    /**
1325     * POST edit_reply and re-render.
1326     *
1327     * @param {string} annId
1328     * @param {string} replyId
1329     * @param {string} body
1330     */
1331    function doEditReply(annId, replyId, body) {
1332        ajax({
1333            action:   'edit_reply',
1334            id:       _info.pageId,
1335            annId:    annId,
1336            replyId:  replyId,
1337            body:     body,
1338        }).then(function (data) {
1339            if (!data.success) {
1340                alert('Could not save: ' + (data.error || ''));
1341                return;
1342            }
1343            var updated = data.annotation;
1344            _annotations.set(updated.id, updated);
1345            reopenPanel(annId);
1346        });
1347    }
1348
1349    /**
1350     * POST delete_annotation.
1351     *
1352     * @param {string} annId
1353     */
1354    function doDeleteAnnotation(annId) {
1355        ajax({
1356            action: 'delete_annotation',
1357            id:     _info.pageId,
1358            annId:  annId,
1359        }).then(function (data) {
1360            if (!data.success) {
1361                alert('Could not delete: ' + (data.error || ''));
1362                return;
1363            }
1364            _annotations.delete(annId);
1365            closePanel();
1366            renderAll();
1367        });
1368    }
1369
1370    /**
1371     * POST delete_reply and re-render.
1372     *
1373     * @param {string} annId
1374     * @param {string} replyId
1375     */
1376    function doDeleteReply(annId, replyId) {
1377        ajax({
1378            action:  'delete_reply',
1379            id:      _info.pageId,
1380            annId:   annId,
1381            replyId: replyId,
1382        }).then(function (data) {
1383            if (!data.success) {
1384                alert('Could not delete: ' + (data.error || ''));
1385                return;
1386            }
1387            var updated = data.annotation;
1388            _annotations.set(updated.id, updated);
1389            reopenPanel(annId);
1390        });
1391    }
1392
1393    /**
1394     * POST resolve/reopen action.
1395     *
1396     * @param {string} annId
1397     * @param {string} status  'open' | 'resolved'
1398     */
1399    function doResolve(annId, status) {
1400        ajax({
1401            action: 'resolve',
1402            id:     _info.pageId,
1403            annId:  annId,
1404            status: status,
1405        }).then(function (data) {
1406            if (!data.success) {
1407                alert('Could not update status: ' + (data.error || ''));
1408                return;
1409            }
1410            var updated = data.annotation;
1411            _annotations.set(updated.id, updated);
1412            renderAll();
1413            reopenPanel(annId);
1414        });
1415    }
1416
1417    /**
1418     * POST clear_resolved (admin).
1419     */
1420    function doClearResolved() {
1421        if (!confirm('Delete all resolved annotations on this page?')) return;
1422        ajax({
1423            action: 'clear_resolved',
1424            id:     _info.pageId,
1425        }).then(function (data) {
1426            if (!data.success) {
1427                alert('Could not clear: ' + (data.error || ''));
1428                return;
1429            }
1430            // Remove resolved from local state.
1431            _annotations.forEach(function (ann, id) {
1432                if (ann.status === 'resolved') _annotations.delete(id);
1433            });
1434            closePanel();
1435            renderAll();
1436        });
1437    }
1438
1439    /**
1440     * POST clear_orphaned (admin).
1441     */
1442    function doClearOrphaned() {
1443        if (!confirm('Delete all orphaned annotations on this page?')) return;
1444        ajax({
1445            action: 'clear_orphaned',
1446            id:     _info.pageId,
1447        }).then(function (data) {
1448            if (!data.success) {
1449                alert('Could not clear: ' + (data.error || ''));
1450                return;
1451            }
1452            _annotations.forEach(function (ann, id) {
1453                if (ann._orphaned) _annotations.delete(id);
1454            });
1455            closePanel();
1456            renderAll();
1457        });
1458    }
1459
1460    // -----------------------------------------------------------------------
1461    // Panel management helpers
1462    // -----------------------------------------------------------------------
1463
1464    /**
1465     * Re-fetch one annotation from the server and update local state.
1466     *
1467     * Note: the AJAX endpoint doesn't have a standalone "get one" action,
1468     * so we ask the load endpoint (GET) and pull the matching entry out.
1469     *
1470     * @param {string}   annId
1471     * @param {Function} cb
1472     */
1473    function refreshAnnotation(annId, cb) {
1474        fetch(AJAX_URL + '&action=load&id=' + encodeURIComponent(_info.pageId), {
1475            method: 'GET',
1476        }).then(function (res) {
1477            return res.json();
1478        }).then(function (data) {
1479            if (data && Array.isArray(data.annotations)) {
1480                data.annotations.forEach(function (ann) {
1481                    _annotations.set(ann.id, ann);
1482                });
1483            }
1484            if (typeof cb === 'function') cb();
1485        }).catch(function () {
1486            if (typeof cb === 'function') cb();
1487        });
1488    }
1489
1490    /**
1491     * Close the current panel and re-open it (preserves scroll position and
1492     * re-renders the thread with fresh data).
1493     *
1494     * @param {string} annId
1495     */
1496    function reopenPanel(annId) {
1497        closePanel();
1498        openPanel(annId);
1499    }
1500
1501    // -----------------------------------------------------------------------
1502    // Utilities
1503    // -----------------------------------------------------------------------
1504
1505    /**
1506     * Collapse consecutive whitespace to a single space and trim.
1507     *
1508     * @param {string} s
1509     * @returns {string}
1510     */
1511    function normalizeWS(s) {
1512        return String(s || '').replace(/\s+/g, ' ').trim();
1513    }
1514
1515    /**
1516     * Return the current DokuWiki username from JSINFO.
1517     *
1518     * @returns {string}
1519     */
1520    function currentUser() {
1521        var jsinfo = (typeof JSINFO !== 'undefined' && JSINFO) ? JSINFO : {};
1522        return (jsinfo.userinfo && jsinfo.userinfo.user) ? jsinfo.userinfo.user : '';
1523    }
1524
1525    /**
1526     * Format a Date for display.
1527     *
1528     * @param {Date} d
1529     * @returns {string}
1530     */
1531    function formatDate(d) {
1532        var now  = new Date();
1533        var diff = (now - d) / 1000; // seconds
1534        if (diff < 60)              return 'just now';
1535        if (diff < 3600)            return Math.floor(diff / 60)   + 'm ago';
1536        if (diff < 86400)           return Math.floor(diff / 3600) + 'h ago';
1537        if (diff < 86400 * 7)       return Math.floor(diff / 86400) + 'd ago';
1538        return d.toLocaleDateString();
1539    }
1540
1541    // -----------------------------------------------------------------------
1542    // Init
1543    // -----------------------------------------------------------------------
1544
1545    if (document.readyState === 'loading') {
1546        document.addEventListener('DOMContentLoaded', boot);
1547    } else {
1548        boot();
1549    }
1550
1551}());
1552