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