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