نمایش تاریخ شمسی در Confluence Jalali Dates in Confluence DC

آخرین به روزرسانی در 15 مهر 1405
نوشته شده توسط علی خادم
نمایش تاریخ شمسی در Confluence

اگر از Confluence Data Center با زبان فارسی استفاده کنید، یکی از مشکلاتی که خیلی زود خودش را نشان می‌دهد، نمایش تاریخ‌ها به صورت میلادی است، زیرا اطلاعات کاربران فارسی‌زبان معمولاً با تقویم شمسی خواناتر و قابل‌استفاده‌تر است. در این مطلب یک راهکار برای نمایش تاریخ شمسی در Confluence ارائه داده شده است. تصویر زیر نمایی از پیاده‌سازی تاریخ شمسی در Confluence است که در یک محیط عملیاتی اجرا شد:

تاریخ شمسی در Confluence

ایده اصلی: تبدیل تاریخ در لایه Front-end

ماژول تاریخ شمسی در Confluence، تاریخ‌های Confluence را در Back-end یا دیتابیس تغییر نمی‌دهد. اطلاعات اصلی همچنان با ساختار استاندارد Confluence باقی می‌مانند و فقط نحوه نمایش آن‌ها برای کاربر تغییر می‌کند. به همین دلیل:

  • APIها همچنان با تاریخ‌های میلادی کار می‌کنند.
  • منطق Search و Sort تغییر نمی‌کند.
  • تغییر تقویم در رابط کاربری باعث تغییر داده‌های اصلی نمی‌شود.

وابستگی‌های مورد نیاز

برای نمایش و انتخاب تاریخ جلالی، ماژول تاریخ شمسی در Confluence از سه فایل استفاده می‌کند؛ قبل از قرار دادن کد اصلی، باید این سه فایل را در صفحات فراخوانی کنید:

  • persian-date.min.js
  • persian-datepicker.min.js
  • persian-datepicker.min.css

می‌توان این فایل‌ها را از Attachmentهای یکی از صفحات Confluence فراخوانی کنید (اگر سرور Confluence شما به اینترنت دسترسی دارد، می توانید از طریق CDN هم این سه فایل را فراخوانی کنید).

کد کامل ماژول تاریخ شمسی در Confluence

/*
 * Shared Jalali runtime — one page-wide MutationObserver for all three
 * Jalali layers. Individual modules register their work here.
 */
(function () {
  'use strict';

  if (window.__jalaliRuntime) return;

  const jobs = [];
  let runTimer = null;
  let observer = null;
  let started = false;

  function runAll() {
    runTimer = null;
    for (let i = 0; i < jobs.length; i++) {
      try { jobs[i](); } catch (e) { /* one module must never stop the others */ }
    }
  }

  function schedule(delay) {
    if (runTimer !== null) return;
    runTimer = setTimeout(runAll, delay === undefined ? 50 : delay);
  }

  function startObserver() {
    if (started || !document.body) return;
    started = true;

    observer = new MutationObserver(function () {
      schedule(50);
    });

    observer.observe(document.body, { childList: true, subtree: true });
    schedule(0);
  }

  window.__jalaliRuntime = {
    register: function (job) {
      if (typeof job !== 'function') return;
      if (jobs.indexOf(job) === -1) jobs.push(job);
      startObserver();
    },
    runNow: function () {
      if (runTimer !== null) {
        clearTimeout(runTimer);
        runTimer = null;
      }
      runAll();
    },
    schedule: schedule
  };

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', startObserver, { once: true });
  } else {
    startObserver();
  }
})();


/*
 * Confluence Data Center 10.x — Jalali (Persian/Shamsi) display layer — v6-refactor
 * PART 1/4: Core conversion + view-mode/edit-mode display
 *
 * PATCH NOTES (this file):
 *   [FIX 1] Overlay badge text is only rewritten when it actually changed
 *           (prevents a self-triggering MutationObserver loop).
 *   [FIX 2] Out-of-range years no longer throw out of the formatters, and
 *           each pass in runAllPasses is isolated in its own try/catch.
 *   [FIX 4] Date-only ISO strings (YYYY-MM-DD) are parsed as LOCAL dates
 *           instead of UTC midnight.
 */
(function () {
  'use strict';

  const runtime = window.__jalaliRuntime;

  // ---- SECTION 1: Jalali<->Gregorian conversion core (jalaali-js, MIT) ----

  const breaks = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210,
    1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178];

  function div(a, b) { return ~~(a / b); }

  function mod(a, b) { return a - ~~(a / b) * b; }

  function jalCal(jy, withoutLeap) {
    let bl = breaks.length, gy = jy + 621, leapJ = -14, jp = breaks[0],
      jm, jump, leap, leapG, march, n, i;

    if (jy < jp || jy >= breaks[bl - 1]) throw new Error('Invalid Jalaali year ' + jy);

    for (i = 1; i < bl; i += 1) {
      jm = breaks[i]; jump = jm - jp;
      if (jy < jm) break;
      leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4);
      jp = jm;
    }

    n = jy - jp;
    leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
    if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;

    leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
    march = 20 + leapJ - leapG;

    if (withoutLeap) return { gy: gy, march: march };

    if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
    leap = mod(mod(n + 1, 33) - 1, 4);
    if (leap === -1) leap = 4;

    return { leap: leap, gy: gy, march: march };
  }

  function g2d(gy, gm, gd) {
    let d = div((gy + div(gm - 8, 6) + 100100) * 1461, 4)
      + div(153 * mod(gm + 9, 12) + 2, 5) + gd - 34840408;
    d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
    return d;
  }

  function d2g(jdn) {
    let j, i, gd, gm, gy;
    j = 4 * jdn + 139361631;
    j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908;
    i = div(mod(j, 1461), 4) * 5 + 308;
    gd = div(mod(i, 153), 5) + 1;
    gm = mod(div(i, 153), 12) + 1;
    gy = div(j, 1461) - 100100 + div(8 - gm, 6);
    return { gy: gy, gm: gm, gd: gd };
  }

  function d2j(jdn) {
    let gy = d2g(jdn).gy, jy = gy - 621, r = jalCal(jy, false),
      jdn1f = g2d(gy, 3, r.march), jd, jm, k;

    k = jdn - jdn1f;

    if (k >= 0) {
      if (k <= 185) { jm = 1 + div(k, 31); jd = mod(k, 31) + 1; return { jy: jy, jm: jm, jd: jd }; }
      else { k -= 186; }
    } else { jy -= 1; k += 179; if (r.leap === 1) k += 1; }

    jm = 7 + div(k, 30); jd = mod(k, 30) + 1;
    return { jy: jy, jm: jm, jd: jd };
  }

  function j2d(jy, jm, jd) {
    const r = jalCal(jy, true);
    return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
  }

  function toJalaali(gy, gm, gd) { return d2j(g2d(gy, gm, gd)); }

  function toGregorian(jy, jm, jd) { return d2g(j2d(jy, jm, jd)); }

  window.__jalaliConv = { toJalaali: toJalaali, toGregorian: toGregorian };

  // ---- SECTION 2: Formatting ----

  const PERSIAN_MONTHS = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور',
    'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'];

  const PERSIAN_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'];

  const USE_PERSIAN_DIGITS = true;

  function toPersianDigits(str) {
    return String(str).replace(/[0-9]/g, function (d) { return PERSIAN_DIGITS[d]; });
  }

  // [FIX 4] Parse the `datetime` attribute. Date-only values ("2026-10-04")
  // are interpreted as LOCAL calendar dates; `new Date("2026-10-04")` would be
  // UTC midnight, which reads back as the previous day west of UTC.
  // Anything else (timestamps etc.) goes through new Date() exactly as before.
  function parseDatetimeAttr(iso) {
    const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
    if (m) return new Date(+m[1], +m[2] - 1, +m[3]);
    return new Date(iso);
  }

  function formatJalali(date, includeTime) {
    if (!date || isNaN(date.getTime())) return null;

    // [FIX 2] jalCal throws for years outside its table; never let that escape.
    let j;
    try {
      j = toJalaali(date.getFullYear(), date.getMonth() + 1, date.getDate());
    } catch (e) {
      return null;
    }

    let out = j.jd + ' ' + PERSIAN_MONTHS[j.jm - 1] + ' ' + j.jy;

    if (includeTime) {
      const hh = ('0' + date.getHours()).slice(-2);
      const mm = ('0' + date.getMinutes()).slice(-2);
      out += '، ' + hh + ':' + mm;
    }

    return USE_PERSIAN_DIGITS ? toPersianDigits(out) : out;
  }

  window.__jalaliFormat = formatJalali;

  // ---- SECTION 3: Shared direct text rewrite ----

  function looksAlreadyJalali(el) {
    const t = el.textContent || '';
    return /[\u0600-\u06FF\u06F0-\u06F9]/.test(t);
  }

  window.__jalaliLooksConverted = looksAlreadyJalali;

  function convertDirectly(root) {
    if (!root) return;

    const els = root.querySelectorAll('time[datetime]:not([data-jalali-applied])');

    for (let i = 0; i < els.length; i++) {
      const el = els[i];

      if (looksAlreadyJalali(el)) {
        el.setAttribute('data-jalali-applied', '1');
        continue;
      }

      const iso = el.getAttribute('datetime');
      if (!iso) continue;

      const d = parseDatetimeAttr(iso);
      const hasTime = /\d{2}:\d{2}/.test(el.textContent || '');
      const formatted = formatJalali(d, hasTime);

      if (formatted) {
        el.setAttribute('data-jalali-applied', '1');
        el.setAttribute('title', el.textContent);
        el.textContent = formatted;
      }
    }
  }

  window.__jalaliConvertDirectly = convertDirectly;

  // ---- SECTION 4: Main-document view-mode pass ----

  const PROSEMIRROR_SELECTOR =
    '.ProseMirror, .ak-editor-content-area, .fabric-editor, .editor-content-area';

  function isInsideProseMirror(el) { return !!el.closest(PROSEMIRROR_SELECTOR); }

  function convertMainDocument() {
    if (!document.body) return;

    const els = document.body.querySelectorAll('time[datetime]:not([data-jalali-applied])');

    for (let i = 0; i < els.length; i++) {
      const el = els[i];

      if (isInsideProseMirror(el)) continue;

      if (looksAlreadyJalali(el)) {
        el.setAttribute('data-jalali-applied', '1');
        continue;
      }

      const iso = el.getAttribute('datetime');
      if (!iso) continue;

      const d = parseDatetimeAttr(iso);
      const hasTime = /\d{2}:\d{2}/.test(el.textContent || '');
      const formatted = formatJalali(d, hasTime);

      if (formatted) {
        el.setAttribute('data-jalali-applied', '1');
        el.setAttribute('title', el.textContent);
        el.textContent = formatted;
      }
    }
  }

  // ---- SECTION 5: ProseMirror edit-mode overlay ----

  const overlayRegistry = new Map();

  function ensureOverlayStyle() {
    if (document.getElementById('jalali-overlay-style')) return;

    const style = document.createElement('style');
    style.id = 'jalali-overlay-style';
    style.textContent =
      '.jalali-overlay-badge{position:fixed;display:flex;align-items:center;' +
      'pointer-events:none;white-space:nowrap;overflow:hidden;' +
      'box-sizing:border-box;z-index:2147483000;}';
    document.head.appendChild(style);
  }

  function styleBadgeLike(badge, sourceEl) {
    const cs = window.getComputedStyle(sourceEl);
    badge.style.font = cs.font; badge.style.color = cs.color; badge.style.letterSpacing = cs.letterSpacing;

    let bgEl = sourceEl, bg = null;
    while (bgEl && !bg) {
      const c = window.getComputedStyle(bgEl).backgroundColor;
      if (c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent') bg = c;
      bgEl = bgEl.parentElement;
    }
    badge.style.backgroundColor = bg || '#fff';
  }

  function positionBadge(badge, sourceEl) {
    const r = sourceEl.getBoundingClientRect();
    if (r.width === 0 && r.height === 0) { badge.style.display = 'none'; return; }

    badge.style.display = 'flex';
    badge.style.left = r.left + 'px'; badge.style.top = r.top + 'px';
    badge.style.width = r.width + 'px'; badge.style.height = r.height + 'px';
  }

  function positionRegisteredOverlays() {
    overlayRegistry.forEach(function (badge, sourceEl) {
      if (!sourceEl.isConnected) return;
      positionBadge(badge, sourceEl);
    });
  }

  function syncProseMirrorOverlays() {
    if (!document.body) return;

    const els = document.body.querySelectorAll('time[datetime]');
    const seen = new Set();

    for (let i = 0; i < els.length; i++) {
      const el = els[i];
      if (!isInsideProseMirror(el)) continue;

      const iso = el.getAttribute('datetime');
      if (!iso) continue;

      const d = parseDatetimeAttr(iso);
      const formatted = formatJalali(d, /\d{2}:\d{2}/.test(el.textContent || ''));
      if (!formatted) continue;

      let badge = overlayRegistry.get(el);
      if (!badge || !badge.isConnected) {
        badge = document.createElement('span');
        badge.className = 'jalali-overlay-badge';
        document.body.appendChild(badge);
        overlayRegistry.set(el, badge);
      }

      // [FIX 1] Assigning textContent always replaces the child text node, which
      // is a childList mutation even when the value is identical. That re-fired
      // the page-wide observer forever. Only write when the text changed.
      if (badge.textContent !== formatted) badge.textContent = formatted;
      styleBadgeLike(badge, el);
      positionBadge(badge, el);
      seen.add(el);
    }

    overlayRegistry.forEach(function (badge, sourceEl) {
      if (!sourceEl.isConnected || !seen.has(sourceEl)) {
        badge.remove();
        overlayRegistry.delete(sourceEl);
      }
    });
  }

  // ---- SECTION 6: TinyMCE iframe hook ----

  const hookedIframes = new WeakSet();
  const iframeWatchers = [];

  function hookOneIframe(iframe) {
    if (hookedIframes.has(iframe)) return;

    let iframeDoc;

    try { iframeDoc = iframe.contentDocument || (iframe.contentWindow && iframe.contentWindow.document); }
    catch (e) { return; }

    if (!iframeDoc || !iframeDoc.body) {
      iframe.addEventListener('load', function onLoad() {
        iframe.removeEventListener('load', onLoad);
        hookOneIframe(iframe);
      });
      return;
    }

    hookedIframes.add(iframe);
    iframeDoc.__jalaliIframeEl = iframe;
    window.__jalaliLastIframeDoc = iframeDoc;

    convertDirectly(iframeDoc.body);

    if (window.__jalaliRebindPickers) window.__jalaliRebindPickers(iframeDoc);

    const watcher = { iframe: iframe, observer: null, timer: null };
    const obs = new MutationObserver(function () {
      clearTimeout(watcher.timer);
      watcher.timer = setTimeout(function () {
        try {
          convertDirectly(iframeDoc.body);
          if (window.__jalaliRebindPickers) window.__jalaliRebindPickers(iframeDoc);
        } catch (e) { /* iframe may have been unloaded */ }
      }, 60);
    });
    watcher.observer = obs;
    iframeWatchers.push(watcher);
    obs.observe(iframeDoc.documentElement, { childList: true, subtree: true });
  }

  function cleanupDetachedIframeWatchers() {
    for (let i = iframeWatchers.length - 1; i >= 0; i--) {
      const watcher = iframeWatchers[i];
      if (watcher.iframe && watcher.iframe.isConnected) continue;
      clearTimeout(watcher.timer);
      try { watcher.observer.disconnect(); } catch (e) { /* already detached */ }
      try { hookedIframes.delete(watcher.iframe); } catch (e) { /* WeakSet cleanup is best-effort */ }
      iframeWatchers.splice(i, 1);
    }
  }

  function detectAndHookIframes() {
    cleanupDetachedIframeWatchers();
    const iframes = document.querySelectorAll(
      '#wysiwygTextarea_ifr, iframe[id$="_ifr"][id*="wysiwyg"], iframe.mce-edit-area__iframe'
    );
    for (let i = 0; i < iframes.length; i++) hookOneIframe(iframes[i]);
  }

  // Declared before the first runtime.runNow() so it never sits in its temporal dead zone.
  let overlayFrame = null;

  // ---- SECTION 7: Drive all passes ----

  // [FIX 2] Each pass is isolated: a failure in one can no longer skip the others.
  function runPass(fn) {
    try { fn(); } catch (e) { /* never block page render */ }
  }

  function runAllPasses() {
    runPass(ensureOverlayStyle);
    runPass(convertMainDocument);
    runPass(syncProseMirrorOverlays);
    runPass(detectAndHookIframes);
  }

  if (runtime) {
    runtime.register(runAllPasses);
    runtime.runNow();
  } else {
    runAllPasses();
  }

  function scheduleOverlayPositionSync() {
    if (overlayFrame !== null) return;
    if (typeof requestAnimationFrame !== 'function') {
      positionRegisteredOverlays();
      return;
    }
    overlayFrame = requestAnimationFrame(function () {
      overlayFrame = null;
      positionRegisteredOverlays();
    });
  }

  window.addEventListener('scroll', scheduleOverlayPositionSync, true);
  window.addEventListener('resize', scheduleOverlayPositionSync);

  if (window.visualViewport) {
    window.visualViewport.addEventListener('resize', scheduleOverlayPositionSync);
    window.visualViewport.addEventListener('scroll', scheduleOverlayPositionSync);
  }
})();




/*
 * Confluence Data Center 10.x — Jalali display layer
 * DATE-LABELS ADDENDUM
 *
 * Converts these to Jalali, format "yyyy / mm / dd" with Latin digits:
 *
 *   1. Page metadata   — li.page-metadata-modification-info a.last-modified
 *   2. Comment dates   — span.comment-date
 *   3. Page history    — #page-history-container tbody tr td[align="middle"]
 *   4. Activity feed   — .update-item-meta
 *        - absolute dates ("Sep 30, 2026") are converted to Jalali
 *        - relative dates ("9 minutes ago", "just now", "yesterday")
 *          are NOT converted, but ARE bidi-isolated, so the leading
 *          digit stays on the left of the phrase in an RTL paragraph.
 *   5. Attachments macro (modified date + version history)
 *   6. Page Information page (viewinfo.action): Author / Last Changed
 *      dates, "set by … at <date time>" in Page restrictions, and the
 *      Recent Changes table. A trailing time of day ("13:48") is kept.
 *
 * Every rendered date is wrapped in LRI…PDI (U+2066 / U+2069) so the
 * visual structure is always year-on-left, day-on-right, no matter the
 * paragraph direction or the element's text-align. The bidi marks are
 * added only to on-screen text — title / aria-label attributes get the
 * plain string.
 *
 * Depends on window.__jalaliConv being installed by Part 1.
 *
 * PATCH NOTES (this file):
 *   [FIX 2] formatJalaliLatin no longer throws on out-of-range years, and
 *           each handler in convertAll is isolated in its own try/catch.
 */
(function () {
  'use strict';

  if (window.__jalaliLabelsInstalled) return;
  window.__jalaliLabelsInstalled = true;

  // -------------------------------------------------------------------
  // Formatting — "yyyy / mm / dd", Latin digits, zero-padded
  // -------------------------------------------------------------------

  function pad2(n) { return n < 10 ? '0' + n : '' + n; }

  function pad4(n) {
    let s = '' + n;
    while (s.length < 4) s = '0' + s;
    return s;
  }

  function formatJalaliLatin(date) {
    if (!date || isNaN(date.getTime())) return null;

    const conv = window.__jalaliConv;
    if (!conv) return null;

    // [FIX 2] Out-of-range years make the converter throw; treat as "not convertible".
    let j;
    try {
      j = conv.toJalaali(date.getFullYear(), date.getMonth() + 1, date.getDate());
    } catch (e) {
      return null;
    }

    return pad4(j.jy) + ' / ' + pad2(j.jm) + ' / ' + pad2(j.jd);
  }

  // U+2066 LEFT-TO-RIGHT ISOLATE, U+2069 POP DIRECTIONAL ISOLATE.
  const LRI = '\u2066';
  const PDI = '\u2069';

  function isolateLTR(s) { return s ? (LRI + s + PDI) : s; }

  // -------------------------------------------------------------------
  // Date parsing — English month name form
  // -------------------------------------------------------------------

  const MONTHS = {
    jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
    jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11
  };

  const DATE_RX = /\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)[a-z]*\.?\s+(\d{1,2}),\s+(\d{4})\b/i;

  function parseEnglishDate(text) {
    if (!text) return null;

    const m = DATE_RX.exec(text);
    if (!m) return null;

    const monIdx = MONTHS[m[1].toLowerCase().slice(0, 3)];
    if (monIdx === undefined) return null;

    const day = parseInt(m[2], 10);
    const year = parseInt(m[3], 10);
    if (!isFinite(day) || !isFinite(year)) return null;

    return new Date(year, monIdx, day);
  }

  function replaceDateInString(str, replacement) {
    if (!str) return str;
    return str.replace(DATE_RX, replacement);
  }

  // -------------------------------------------------------------------
  // Relative-date detection.
  //
  // Recognises the exact forms Confluence uses in the activity feed:
  //   "just now", "yesterday", "tomorrow"
  //   "N minute(s) ago" / "N hour(s) ago" / "N day(s) ago"
  //   "N week(s) ago" / "N month(s) ago" / "N year(s) ago"
  //   "in N minute(s)" (future, unlikely here but harmless)
  //
  // Returns true only if the WHOLE string is a relative date. The check
  // is anchored (^…$) so it never matches a relative phrase embedded in
  // a larger sentence.
  // -------------------------------------------------------------------

  const REL_ABS_RX   = /^(just now|yesterday|tomorrow)$/i;
  const REL_AGO_RX   = /^(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago$/i;
  const REL_IN_RX    = /^in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?$/i;

  function isRelativeDate(trimmed) {
    if (!trimmed) return false;
    return REL_ABS_RX.test(trimmed) ||
           REL_AGO_RX.test(trimmed) ||
           REL_IN_RX.test(trimmed);
  }

  // -------------------------------------------------------------------
  // Handler 1 — page metadata "last updated on …"
  // -------------------------------------------------------------------

  function convertPageMetadata() {
    if (!window.__jalaliConv) return;

    const anchors = document.querySelectorAll(
      'li.page-metadata-modification-info a.last-modified[href*="diffpagesbyversion"]:not([data-jalali-md-applied])'
    );

    for (let i = 0; i < anchors.length; i++) {
      const a = anchors[i];

      const d = parseEnglishDate(a.getAttribute('aria-label') || '') ||
              parseEnglishDate(a.textContent || '');
      if (!d) continue;

      const formatted = formatJalaliLatin(d);
      if (!formatted) continue;

      a.setAttribute('data-jalali-md-applied', '1');

      if (!a.hasAttribute('data-jalali-md-original')) {
        a.setAttribute('data-jalali-md-original', a.textContent || '');
      }

      a.textContent = isolateLTR(formatted);

      const label = a.getAttribute('aria-label');
      if (label && DATE_RX.test(label)) {
        a.setAttribute('aria-label', replaceDateInString(label, formatted));
      }
    }
  }

  // -------------------------------------------------------------------
  // Handler 2 — comment dates
  // -------------------------------------------------------------------

  function convertCommentDates() {
    if (!window.__jalaliConv) return;

    const spans = document.querySelectorAll(
      'span.comment-date:not([data-jalali-comment-applied])'
    );

    for (let i = 0; i < spans.length; i++) {
      const span = spans[i];

      const titleAttr = span.getAttribute('title') || '';
      const d = parseEnglishDate(titleAttr) ||
              parseEnglishDate(span.textContent || '');
      if (!d) continue;

      const formatted = formatJalaliLatin(d);
      if (!formatted) continue;

      span.setAttribute('data-jalali-comment-applied', '1');

      if (!span.hasAttribute('data-jalali-comment-original')) {
        span.setAttribute('data-jalali-comment-original', span.textContent || '');
      }

      span.textContent = isolateLTR(formatted);

      if (titleAttr && DATE_RX.test(titleAttr)) {
        span.setAttribute('title', replaceDateInString(titleAttr, formatted));
      }
    }
  }

  // -------------------------------------------------------------------
  // Handler 3 — page history table "Published" column
  // -------------------------------------------------------------------

  function convertHistoryDates() {
    if (!window.__jalaliConv) return;

    const cells = document.querySelectorAll(
      '#page-history-container tbody tr td[align="middle"]:not([data-jalali-history-applied])'
    );

    for (let i = 0; i < cells.length; i++) {
      const cell = cells[i];

      const text = cell.textContent || '';
      const d = parseEnglishDate(text);
      if (!d) continue;

      const formatted = formatJalaliLatin(d);
      if (!formatted) continue;

      cell.setAttribute('data-jalali-history-applied', '1');

      if (!cell.hasAttribute('data-jalali-history-original')) {
        cell.setAttribute('data-jalali-history-original', text);
      }

      cell.textContent = replaceDateInString(text, isolateLTR(formatted));
    }
  }

  // -------------------------------------------------------------------
  // Handler 4 — activity feed items (.update-item-meta)
  //
  // The date sits as a raw text node inside the div, followed by
  // sibling elements (<span class="separator">, <a>, etc). We cannot
  // touch textContent — that would wipe the links. Instead we walk the
  // div's direct children, find the first text node that contains
  // either an absolute date or a relative date, and rewrite just that
  // text node's value in place.
  //
  //   - Absolute date  →  convert to Jalali, isolate LTR
  //   - Relative date  →  keep text as-is, isolate LTR only
  //
  // Isolating the relative phrase is what fixes the visual bug where
  // "9 minutes ago" renders as "minutes ago … 9" in an RTL paragraph.
  // -------------------------------------------------------------------

  function convertUpdateItemDates() {
    if (!window.__jalaliConv) return;

    const divs = document.querySelectorAll(
      '.update-item-meta:not([data-jalali-update-applied])'
    );

    for (let i = 0; i < divs.length; i++) {
      const div = divs[i];
      let applied = false;

      for (let j = 0; j < div.childNodes.length; j++) {
        const node = div.childNodes[j];

        // Only direct text nodes — never touch element children.
        if (node.nodeType !== 3) continue;

        const text = node.nodeValue || '';
        if (!text.trim()) continue;

        // --- Case 1: absolute date ---
        if (DATE_RX.test(text)) {
          const d = parseEnglishDate(text);
          if (!d) continue;

          const formatted = formatJalaliLatin(d);
          if (!formatted) continue;

          if (!div.hasAttribute('data-jalali-update-original')) {
            div.setAttribute('data-jalali-update-original', text);
          }

          // Replace the date substring in place, preserving any
          // leading/trailing whitespace around it.
          node.nodeValue = text.replace(DATE_RX, isolateLTR(formatted));
          applied = true;
          break;
        }

        // --- Case 2: relative date ---
        const trimmed = text.trim();
        if (isRelativeDate(trimmed)) {
          if (!div.hasAttribute('data-jalali-update-original')) {
            div.setAttribute('data-jalali-update-original', text);
          }

          // Find the offset of the trimmed phrase inside the original
          // text node so leading/trailing whitespace is preserved
          // exactly. Simple indexOf is safe here because `trimmed` was
          // derived from `text` — it cannot appear earlier than its
          // own position.
          const start = text.indexOf(trimmed);
          if (start < 0) continue;

          const before = text.slice(0, start);
          const after  = text.slice(start + trimmed.length);

          // Do NOT change the phrase — just wrap it in LRI…PDI.
          node.nodeValue = before + isolateLTR(trimmed) + after;
          applied = true;
          break;
        }
      }

      if (applied) {
        div.setAttribute('data-jalali-update-applied', '1');
      }
      // If nothing applied, leave the div unmarked so a later mutation
      // tick can retry once the date text has actually loaded.
    }
  }

  // -------------------------------------------------------------------
  // Handler 5 — Attachments macro
  //
  // Confluence's Attachments macro renders attachment dates as plain
  // English text rather than <time datetime> elements. There are two
  // relevant locations: the attachment's main modified date and the
  // version-history table inside the expanded attachment row.
  //
  // We deliberately scope the selectors to the Attachments macro markup
  // so other tables/cells are not affected.
  // -------------------------------------------------------------------

  function convertAttachmentDates() {
    if (!window.__jalaliConv) return;

    // 1. Main attachment modified date.
    const modifiedSpans = document.querySelectorAll(
      '.attachment-row .attachment-created.modified-column > span:first-child:not([data-jalali-attachment-applied])'
    );

    for (let i = 0; i < modifiedSpans.length; i++) {
      const modified = modifiedSpans[i];

      const modifiedText = modified.textContent || '';
      const modifiedDate = parseEnglishDate(modifiedText);
      if (!modifiedDate) continue;

      const modifiedFormatted = formatJalaliLatin(modifiedDate);
      if (!modifiedFormatted) continue;

      modified.setAttribute('data-jalali-attachment-applied', '1');

      if (!modified.hasAttribute('data-jalali-attachment-original')) {
        modified.setAttribute('data-jalali-attachment-original', modifiedText);
      }

      modified.textContent = isolateLTR(modifiedFormatted);
    }

    // 2. Attachment version-history modified date.
    const versionDates = document.querySelectorAll(
      '.attachment-history-wrapper .version-modified-time:not([data-jalali-attachment-version-applied])'
    );

    for (let j = 0; j < versionDates.length; j++) {
      const versionDateEl = versionDates[j];

      const versionText = versionDateEl.textContent || '';
      const versionDate = parseEnglishDate(versionText);
      if (!versionDate) continue;

      const versionFormatted = formatJalaliLatin(versionDate);
      if (!versionFormatted) continue;

      versionDateEl.setAttribute('data-jalali-attachment-version-applied', '1');

      if (!versionDateEl.hasAttribute('data-jalali-attachment-version-original')) {
        versionDateEl.setAttribute('data-jalali-attachment-version-original', versionText);
      }

      versionDateEl.textContent = isolateLTR(versionFormatted);
    }
  }

  // -------------------------------------------------------------------
  // Handler 6 — Page Information page (viewinfo.action)
  //
  // Every selector is scoped to .pageInfoLayoutTable, so nothing outside
  // the Page Information page is touched. Dates live in three places:
  //
  //   a. Author / Last Changed rows
  //        <td align="right"> May 20, 2026</td>
  //   b. Page restrictions
  //        <span class="smalltext">(set by <a>…</a> at Jul 15, 2026 11:53)</span>
  //   c. Recent Changes table (first column link)
  //        <a href="…">Oct 04, 2026 13:48</a>
  //
  // Output is "yyyy / mm / dd" with Latin digits, like the other label
  // handlers. If a time of day follows the date it is kept, and the whole
  // "date time" is wrapped in ONE LRI…PDI isolate so it reads
  // left-to-right in both LTR and RTL paragraphs.
  //
  // Only DIRECT text nodes are rewritten (never textContent), so the
  // user links, icons and other child elements are left intact.
  // -------------------------------------------------------------------

  // Same date pattern as DATE_RX, plus an optional time of day (group 4).
  const DATE_TIME_RX = new RegExp(DATE_RX.source + '(?:\\s+(\\d{1,2}:\\d{2}))?', 'i');

  function convertDirectTextDates(el, appliedAttr, originalAttr) {
    const original = el.textContent || '';
    let applied = false;

    for (let j = 0; j < el.childNodes.length; j++) {
      const node = el.childNodes[j];

      // Only direct text nodes — never touch element children.
      if (node.nodeType !== 3) continue;

      const text = node.nodeValue || '';
      const m = DATE_TIME_RX.exec(text);
      if (!m) continue;

      const d = parseEnglishDate(m[0]);
      const formatted = d ? formatJalaliLatin(d) : null;
      if (!formatted) continue;

      const core = formatted + (m[4] ? ' ' + m[4] : '');
      node.nodeValue = text.replace(DATE_TIME_RX, function () { return isolateLTR(core); });
      applied = true;
    }

    if (applied) {
      el.setAttribute(appliedAttr, '1');
      if (!el.hasAttribute(originalAttr)) el.setAttribute(originalAttr, original);
    }
    // If nothing applied, leave the element unmarked so a later
    // mutation tick can retry once the text has actually loaded.
  }

  function convertPageInfoDates() {
    if (!window.__jalaliConv) return;
    if (!document.querySelector('.pageInfoLayoutTable')) return;

    const els = document.querySelectorAll(
      '.pageInfoLayoutTable table.pageInfoTable td[align="right"]:not([data-jalali-pageinfo-applied]),' +
      '.pageInfoLayoutTable .basicPanelBody span.smalltext:not([data-jalali-pageinfo-applied]),' +
      '.pageInfoLayoutTable table.tableview tr > td:first-child > a:not([data-jalali-pageinfo-applied])'
    );

    for (let i = 0; i < els.length; i++) {
      convertDirectTextDates(els[i], 'data-jalali-pageinfo-applied', 'data-jalali-pageinfo-original');
    }
  }

  // -------------------------------------------------------------------
  // Wiring
  // -------------------------------------------------------------------

  // [FIX 2] Each handler is isolated so one failure can't skip the rest.
  function runHandler(fn) {
    try { fn(); } catch (e) { /* keep the other handlers running */ }
  }

  function convertAll() {
    runHandler(convertPageMetadata);
    runHandler(convertCommentDates);
    runHandler(convertHistoryDates);
    runHandler(convertUpdateItemDates);
    runHandler(convertAttachmentDates);
    runHandler(convertPageInfoDates);
  }

  function start() {
    if (!window.__jalaliConv || !window.__jalaliRuntime) {
      setTimeout(start, 100);
      return;
    }

    window.__jalaliRuntime.register(convertAll);
    window.__jalaliRuntime.runNow();
  }

  start();
})();




/*
 * Confluence Data Center 10.x — Jalali (Persian/Shamsi) display layer
 * PICKER REPLACEMENT — v23 (behavior preserved)
 *
 * FIX A — anchor to a visible element.
 *
 *   v22 anchored to input.aui-date-picker, whose getBoundingClientRect()
 *   is {l:0, t:42, r:0, b:42} — zero-width. The picker was therefore
 *   placed at the top-left corner of the viewport. The visible trigger
 *   for the // flow is the AUI autocomplete container (class
 *   .date-autocomplete / id #autocomplete-trigger). v23 resolves the
 *   anchor by, in order:
 *
 *     1. input.closest('.date-autocomplete, #autocomplete-container')
 *        if that has real geometry;
 *     2. the nearest visible .date-autocomplete / #autocomplete-container
 *        / #autocomplete-trigger in the input's own document;
 *     3. the same in the top document;
 *     4. the first visible ancestor of the input;
 *     5. the input itself as last resort.
 *
 *   The chosen anchor and its rect are logged.
 *
 * FIX B — synchronous commit.
 *
 *   v22 drove the hidden native calendar via chained setTimeouts, then
 *   clicked the day cell in a separate step. Between those timers the
 *   native calendar is frequently replaced (see the repeated
 *   "intercepting" lines in v22 logs), so the click landed on a
 *   detached tree. v23:
 *
 *     - first tries jQuery UI's public API:
 *         $(calendarEl).datepicker('setDate', new Date(y, m, d))
 *       to navigate the native calendar;
 *     - if that throws or the element is not a datepicker, falls back
 *       to a fully synchronous navigate-then-click, re-querying the
 *       next/prev button on every iteration (jQuery UI replaces it on
 *       each month change).
 *
 * PATCH NOTES (this file):
 *   [FIX 3] setDate re-renders the calendar but does NOT fire the widget's
 *           onSelect (that only happens from a real day-cell click). After
 *           setDate we now also click the target day cell, synchronously,
 *           only if the calendar is still attached. If the cell can't be
 *           found we return true exactly as before (no regression).
 *   [FIX 2] watchForNativeDialogs / rebind wiring: the two scans (and the
 *           iframe rebind) are isolated so one failing can't skip the other.
 */
(function () {
  'use strict';

  function waitForLibs(callback, attemptsLeft) {
    attemptsLeft = attemptsLeft === undefined ? 150 : attemptsLeft;

    const ready = window.jQuery && window.persianDate && window.jQuery.fn && window.jQuery.fn.persianDatepicker;

    if (ready) { callback(); return; }

    if (attemptsLeft <= 0) {
      console.warn('[jalali] persian-datepicker dependencies never became available — picker replacement disabled.');
      return;
    }

    setTimeout(function () { waitForLibs(callback, attemptsLeft - 1); }, 100);
  }

  function pad(n) { return ('0' + n).slice(-2); }

  function getProxyInput() {
    const existing = document.getElementById('jalali-picker-proxy-input');
    if (existing) return existing;

    const el = document.createElement('input');
    el.type = 'text';
    el.id = 'jalali-picker-proxy-input';
    el.setAttribute('readonly', 'readonly');
    el.style.position = 'absolute';
    el.style.opacity = '0';
    el.style.pointerEvents = 'none';
    el.style.zIndex = '-1';
    el.style.width = '1px';
    el.style.height = '1px';
    document.body.appendChild(el);
    return el;
  }

  function computeAnchorPageOffset(anchorEl, doc) {
    if (!anchorEl || !anchorEl.isConnected) return null;

    const anchorRect = anchorEl.getBoundingClientRect();
    const iframeEl = doc && doc.__jalaliIframeEl;

    if (!iframeEl) {
      return { left: anchorRect.left + window.scrollX, top: anchorRect.top + window.scrollY, height: anchorRect.height };
    }

    const iframeRect = iframeEl.getBoundingClientRect();
    return {
      left: iframeRect.left + anchorRect.left + window.scrollX,
      top: iframeRect.top + anchorRect.top + window.scrollY,
      height: anchorRect.height
    };
  }

  // -------------------------------------------------------------------
  // Anchor resolution — FIX A
  // -------------------------------------------------------------------

  function hasRealSize(el) {
    if (!el || !el.getBoundingClientRect) return false;
    const r = el.getBoundingClientRect();
    return r.width > 2 && r.height > 2;
  }

  function findAnchorElement(input, doc) {
    // 1. Input lives inside the autocomplete container
    if (input && input.closest) {
      const inside = input.closest('.date-autocomplete, #autocomplete-container');
      if (inside && hasRealSize(inside)) {
        console.info('[jalali] anchor → autocomplete container that contains the input');
        return inside;
      }
    }

    const selector = '.date-autocomplete, #autocomplete-container, #autocomplete-trigger';
    const candidates = [];

    // 2. Same document as the input
    if (doc && doc.querySelectorAll) {
      try {
        const list = doc.querySelectorAll(selector);
        for (let i = 0; i < list.length; i++) {
          if (hasRealSize(list[i])) candidates.push({ el: list[i], doc: doc });
        }
      } catch (e) { /* detached doc */ }
    }

    // 3. Top document as a fallback
    if (doc !== document) {
      const topList = document.querySelectorAll(selector);
      for (let j = 0; j < topList.length; j++) {
        if (hasRealSize(topList[j])) candidates.push({ el: topList[j], doc: document });
      }
    }

    if (candidates.length > 0) {
      // Same-doc candidates preferred; among those, nearest to the input
      const sameDoc = candidates.filter(function (c) { return c.doc === doc; });
      const pool = sameDoc.length ? sameDoc : candidates;
      const inputRect = input.getBoundingClientRect();
      let best = pool[0].el;
      let bestD2 = Infinity;

      for (let k = 0; k < pool.length; k++) {
        const r = pool[k].el.getBoundingClientRect();
        const dx = r.left - inputRect.left;
        const dy = r.top - inputRect.top;
        const d2 = dx * dx + dy * dy;
        if (d2 < bestD2) { bestD2 = d2; best = pool[k].el; }
      }

      console.info('[jalali] anchor → visible autocomplete element nearest to input');
      return best;
    }

    // 4. Walk up from the input
    let cur = input;
    while (cur && cur.nodeType === 1) {
      if (hasRealSize(cur)) {
        console.info('[jalali] anchor → first visible ancestor of input');
        return cur;
      }
      cur = cur.parentElement;
    }

    // 5. Give up
    console.info('[jalali] anchor → input itself (no visible alternative found)');
    return input;
  }

  // -------------------------------------------------------------------
  // Positioning — unchanged from v22 (hardened clamps, fixed positioning,
  // true content-box viewport dimensions, plot-area fallback measurement).
  // -------------------------------------------------------------------

  let __jalaliPosLogCount = 0;
  const __jalaliPosLogMax = 8;

  function measurePopup(containerEl) {
    const r = containerEl.getBoundingClientRect();
    const w = Math.ceil(r.width);
    const h = Math.ceil(r.height);
    if (w > 0 && h > 0) return { w: w, h: h, el: containerEl };

    const plot = containerEl.querySelector('.datepicker-plot-area');
    if (plot) {
      const pr = plot.getBoundingClientRect();
      const pw = Math.ceil(pr.width);
      const ph = Math.ceil(pr.height);
      if (pw > 0 && ph > 0) return { w: pw, h: ph, el: plot };
    }
    return null;
  }

  function positionAndClampFixed(containerEl, resolveAnchorEl, doc) {
    if (!containerEl || !containerEl.isConnected) return;

    const anchorEl = resolveAnchorEl();
    if (!anchorEl || !anchorEl.isConnected) return;

    containerEl.style.setProperty('position', 'fixed', 'important');
    containerEl.style.setProperty('z-index', '2147483000', 'important');

    if (!containerEl.hasAttribute('data-jalali-position-normalized')) {
      containerEl.setAttribute('data-jalali-position-normalized', '1');
      containerEl.style.setProperty('transition', 'none', 'important');
      containerEl.style.setProperty('transform', 'none', 'important');
      containerEl.style.setProperty('transform-origin', '0 0', 'important');
      containerEl.style.setProperty('will-change', 'auto', 'important');
    }

    const anchorRect = anchorEl.getBoundingClientRect();
    const iframeEl = doc && doc.__jalaliIframeEl;
    const iframeRect = iframeEl ? iframeEl.getBoundingClientRect() : null;

    let anchorVP;
    if (iframeEl && iframeRect) {
      anchorVP = {
        left: iframeRect.left + anchorRect.left,
        top: iframeRect.top + anchorRect.top,
        right: iframeRect.left + anchorRect.right,
        bottom: iframeRect.top + anchorRect.bottom
      };
    } else {
      anchorVP = {
        left: anchorRect.left,
        top: anchorRect.top,
        right: anchorRect.right,
        bottom: anchorRect.bottom
      };
    }

    let vw, vh;
    if (window.visualViewport) {
      vw = Math.round(window.visualViewport.width);
      vh = Math.round(window.visualViewport.height);
    } else {
      vw = document.documentElement.clientWidth || window.innerWidth;
      vh = document.documentElement.clientHeight || window.innerHeight;
    }

    const measured = measurePopup(containerEl);
    if (!measured) return;

    const popupW = measured.w;
    const popupH = measured.h;

    const margin = 8;
    const gap = 6;

    let desiredLeft = anchorVP.left;
    let desiredTop = anchorVP.bottom + gap;
    let branch = 'below';

    if (desiredTop + popupH > vh - margin) {
      const aboveTop = anchorVP.top - popupH - gap;
      if (aboveTop >= margin) {
        desiredTop = aboveTop;
        branch = 'above';
      } else {
        const spaceBelow = vh - anchorVP.bottom - gap - margin;
        const spaceAbove = anchorVP.top - gap - margin;
        if (spaceBelow >= spaceAbove) {
          desiredTop = vh - margin - popupH;
          branch = 'bottom-fitted';
        } else {
          desiredTop = margin;
          branch = 'top-fitted';
        }
      }
    }

    if (desiredLeft + popupW > vw - margin) desiredLeft = vw - margin - popupW;
    if (desiredLeft < margin) desiredLeft = margin;
    if (desiredTop < margin) desiredTop = margin;
    if (desiredTop + popupH > vh - margin) desiredTop = Math.max(margin, vh - margin - popupH);

    if (__jalaliPosLogCount < __jalaliPosLogMax) {
      __jalaliPosLogCount++;
      console.info('[jalali] pos#' + __jalaliPosLogCount,
        'anchor:', anchorEl.tagName, '.' + ((anchorEl.className || '').toString().split(' ')[0] || ''),
        'anchorVP:', JSON.stringify({
          l: Math.round(anchorVP.left), t: Math.round(anchorVP.top),
          r: Math.round(anchorVP.right), b: Math.round(anchorVP.bottom)
        }),
        'popupSize:', popupW + 'x' + popupH,
        'measuredFrom:', measured.el === containerEl ? 'container' : 'plot-area',
        'viewport:', vw + 'x' + vh,
        'branch:', branch,
        'placedAt:', Math.round(desiredLeft) + ',' + Math.round(desiredTop));
    }

    containerEl.style.setProperty('left', desiredLeft + 'px', 'important');
    containerEl.style.setProperty('top', desiredTop + 'px', 'important');
    containerEl.style.setProperty('right', 'auto', 'important');
    containerEl.style.setProperty('bottom', 'auto', 'important');
    containerEl.style.setProperty('margin', '0', 'important');
  }

  // -------------------------------------------------------------------
  // Session management — unchanged from v22
  // -------------------------------------------------------------------

  let activeSession = null;

  function closeActiveSession() {
    if (!activeSession) return;

    const s = activeSession;
    activeSession = null;

    clearInterval(s.clampIntervalId);

    if (s.rafHandle && typeof cancelAnimationFrame === 'function') {
      try { cancelAnimationFrame(s.rafHandle); } catch (e) { /* no-op */ }
    }

    s.topDoc.removeEventListener('mousedown', s.onOutsideMouseDown, true);
    s.topDoc.removeEventListener('keydown', s.onKeyDown, true);

    if (s.iframeDoc && s.iframeDoc !== s.topDoc) {
      s.iframeDoc.removeEventListener('mousedown', s.onOutsideMouseDown, true);
      s.iframeDoc.removeEventListener('keydown', s.onKeyDown, true);
    }

    try { s.dp.hide(); } catch (e) { /* already gone */ }
  }

  function forceHidden(el) {
    if (!el || !el.isConnected) return;
    el.style.setProperty('visibility', 'hidden', 'important');
    el.style.setProperty('pointer-events', 'none', 'important');
  }

  function openJalaliPickerCore(resolveAnchorEl, doc, ignoreClickEl, onCommit, getElementsToHide) {
    __jalaliPosLogCount = 0;
    closeActiveSession();

    const initialAnchor = resolveAnchorEl();
    const initialPos = initialAnchor ? computeAnchorPageOffset(initialAnchor, doc) : null;

    const $ = window.jQuery;
    const proxy = getProxyInput();

    if (initialPos) {
      proxy.style.left = initialPos.left + 'px';
      proxy.style.top = (initialPos.top + initialPos.height) + 'px';
    }

    const $proxy = $(proxy);
    if ($proxy.data('datepicker')) {
      try { $proxy.persianDatepicker('destroy'); } catch (e) { /* no-op */ }
    }

    $proxy.persianDatepicker({
      observer: false,
      initialValue: false,
      autoClose: true,
      position: 'auto',
      onSelect: function (unixDate) {
        onCommit(unixDate);
        closeActiveSession();
      }
    });

    const dp = $proxy.data('datepicker');
    if (!dp || !dp.show) return;

    dp.show();

    const containerEl = (dp.model && dp.model.view && dp.model.view.$container && dp.model.view.$container[0]) || null;
    if (!containerEl) return;

    if (getElementsToHide) {
      const initialHide = getElementsToHide();
      for (let hi = 0; hi < initialHide.length; hi++) forceHidden(initialHide[hi]);
    }

    positionAndClampFixed(containerEl, resolveAnchorEl, doc);

    let rafHandle = null;
    if (typeof requestAnimationFrame === 'function') {
      rafHandle = requestAnimationFrame(function () {
        positionAndClampFixed(containerEl, resolveAnchorEl, doc);
      });
    }

    const clampIntervalId = setInterval(function () {
      positionAndClampFixed(containerEl, resolveAnchorEl, doc);

      if (getElementsToHide) {
        const toHide = getElementsToHide();
        for (let hj = 0; hj < toHide.length; hj++) forceHidden(toHide[hj]);
      }
    }, 100);

    function onOutsideMouseDown(ev) {
      const target = ev.target;
      if (containerEl.contains(target)) return;
      if (ignoreClickEl && target === ignoreClickEl) return;
      closeActiveSession();
    }

    function onKeyDown(ev) {
      if (ev.key === 'Escape' || ev.keyCode === 27) closeActiveSession();
    }

    const topDoc = document;
    topDoc.addEventListener('mousedown', onOutsideMouseDown, true);
    topDoc.addEventListener('keydown', onKeyDown, true);

    if (doc && doc !== topDoc) {
      doc.addEventListener('mousedown', onOutsideMouseDown, true);
      doc.addEventListener('keydown', onKeyDown, true);
    }

    activeSession = {
      dp: dp,
      clampIntervalId: clampIntervalId,
      rafHandle: rafHandle,
      topDoc: topDoc,
      iframeDoc: doc,
      onOutsideMouseDown: onOutsideMouseDown,
      onKeyDown: onKeyDown
    };
  }

  // -------------------------------------------------------------------
  // FLOW 1: existing <time> element — unchanged
  // -------------------------------------------------------------------

  function writeGregorianIntoTimeEl(timeEl, unixMs) {
    if (!timeEl || !timeEl.isConnected) {
      console.warn('[jalali] the date element is no longer attached to the page. Please click the date again.');
      return;
    }

    const d = new Date(unixMs);
    const iso = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());

    timeEl.setAttribute('datetime', iso);

    const formatted = window.__jalaliFormat(d, false);
    if (formatted) {
      timeEl.textContent = formatted;
      timeEl.setAttribute('data-jalali-applied', '1');
      timeEl.setAttribute('title', formatted);
    }
  }

  function destroyNearbyNativePicker(timeEl) {
    const $ = window.jQuery;

    const input = (timeEl.previousElementSibling && timeEl.previousElementSibling.tagName === 'INPUT')
      ? timeEl.previousElementSibling
      : (timeEl.nextElementSibling && timeEl.nextElementSibling.tagName === 'INPUT')
        ? timeEl.nextElementSibling : null;

    if (!input) return;

    try { if ($.fn.datepicker && $(input).data('datepicker')) $(input).datepicker('destroy'); }
    catch (e) { /* no-op */ }
  }

  function bindTrigger(timeEl, doc) {
    if (timeEl.hasAttribute('data-jalali-trigger-bound')) return;
    timeEl.setAttribute('data-jalali-trigger-bound', '1');

    destroyNearbyNativePicker(timeEl);

    timeEl.addEventListener('click', function (ev) {
      ev.preventDefault(); ev.stopPropagation();
      if (typeof ev.stopImmediatePropagation === 'function') ev.stopImmediatePropagation();

      openJalaliPickerCore(function () { return timeEl; }, doc, timeEl, function (unixDate) {
        writeGregorianIntoTimeEl(timeEl, unixDate);
      });
    }, true);
  }

  function rebindPickers(doc) {
    if (!window.jQuery || !window.jQuery.fn.persianDatepicker) return;

    const timeEls = doc.querySelectorAll('time[datetime]:not([data-jalali-trigger-bound])');
    for (let i = 0; i < timeEls.length; i++) bindTrigger(timeEls[i], doc);
  }

  // -------------------------------------------------------------------
  // FLOW 2: the "//" slash-command native inline-dialog — v23
  // -------------------------------------------------------------------

  function findActiveDatePickerInput() {
    const topInput = document.querySelector('input.aui-date-picker.active');
    if (topInput && topInput.isConnected) return { input: topInput, doc: document };

    const cached = window.__jalaliLastIframeDoc;
    if (cached && cached.body) {
      try {
        const cachedInput = cached.querySelector('input.aui-date-picker.active');
        if (cachedInput && cachedInput.isConnected) return { input: cachedInput, doc: cached };
      } catch (e) { /* detached */ }
    }

    const iframes = document.querySelectorAll('iframe');
    for (let i = 0; i < iframes.length; i++) {
      try {
        const idoc = iframes[i].contentDocument;
        if (!idoc || !idoc.body) continue;
        const inp = idoc.querySelector('input.aui-date-picker.active');
        if (inp && inp.isConnected) return { input: inp, doc: idoc };
      } catch (e) { /* cross-origin */ }
    }
    return null;
  }

  function getNativeMonthYear(dialogEl) {
    const cell = dialogEl.querySelector('td[data-month][data-year]');
    if (!cell) return null;
    return { month: parseInt(cell.getAttribute('data-month'), 10), year: parseInt(cell.getAttribute('data-year'), 10) };
  }

  function clickWithoutNavigating(a) {
    a.addEventListener('click', function (ev) { ev.preventDefault(); }, { capture: true, once: true });
    a.click();
  }

  // -------------------------------------------------------------------
  // FIX B — synchronous commit. No setTimeouts.
  //
  //   Attempt 1: jQuery UI public API — setDate navigates the calendar to
  //              the target month synchronously. [FIX 3] setDate does not
  //              fire onSelect, so the target day cell is then clicked
  //              (synchronously, only if the calendar is still attached).
  //   Attempt 2: synchronous navigation loop + day-cell click, re-querying
  //              the nav button on every iteration (jQuery UI replaces
  //              the prev/next anchors on each month change).
  // -------------------------------------------------------------------

  function commitToNativeCalendar(calendarEl, targetYear, targetMonth0, targetDay) {
    if (!calendarEl || !calendarEl.isConnected) return false;

    const $ = window.jQuery;
    const daySel = 'td[data-month="' + targetMonth0 + '"][data-year="' + targetYear + '"] a[data-date="' + targetDay + '"]';

    // Attempt 1 — public API
    try {
      const $cal = $(calendarEl);
      if ($cal.hasClass('hasDatepicker') || $cal.data('datepicker')) {
        $cal.datepicker('setDate', new Date(targetYear, targetMonth0, targetDay));

        // [FIX 3] Fire the widget's real selection path via the day cell.
        // Guarded: only when still attached (avoids a double commit if
        // something already closed/replaced the dialog), and failures here
        // never change the return value from the previous behaviour.
        let clicked = false;
        try {
          const dayCell = calendarEl.isConnected ? calendarEl.querySelector(daySel) : null;
          if (dayCell) {
            clickWithoutNavigating(dayCell);
            clicked = true;
          }
        } catch (clickErr) {
          console.info('[jalali] day-cell click after setDate threw:', clickErr && clickErr.message);
        }

        console.info(clicked
          ? '[jalali] commit via datepicker("setDate") + day-cell click'
          : '[jalali] commit via datepicker("setDate")');
        return true;
      }
    } catch (e) {
      console.info('[jalali] setDate path threw, falling back to click-through:', e && e.message);
    }

    // Attempt 2 — synchronous click-through
    const current = getNativeMonthYear(calendarEl);
    if (!current) return false;

    const delta = (targetYear - current.year) * 12 + (targetMonth0 - current.month);

    if (delta !== 0) {
      const dir = delta > 0 ? 'next' : 'prev';
      const steps = Math.abs(delta);
      for (let i = 0; i < steps; i++) {
        const btn = calendarEl.querySelector('.ui-datepicker-' + dir);
        if (!btn) { console.warn('[jalali] could not find .ui-datepicker-' + dir + ' while navigating'); return false; }
        clickWithoutNavigating(btn);
      }
    }

    const cell = calendarEl.querySelector(daySel);
    if (!cell) { console.warn('[jalali] day cell not found after navigation:', daySel); return false; }

    clickWithoutNavigating(cell);
    console.info('[jalali] commit via synchronous day-cell click');
    return true;
  }

  function findAnyVisibleInlineCalendar(doc) {
    if (!doc || !doc.body) return null;

    const candidates =
      doc.querySelectorAll('.ui-datepicker-inline');

    for (let i = candidates.length - 1; i >= 0; i--) {
      const el = candidates[i];
      if (!el.isConnected) continue;

      const cs = window.getComputedStyle(el);

      /*
       * Do NOT use jQuery :visible here.
       *
       * Our own code hides the native calendar with:
       *     visibility: hidden
       *
       * jQuery :visible can still return true for such an element
       * because it has layout dimensions.
       */
      if (cs.display === 'none') continue;
      if (cs.visibility === 'hidden') continue;
      if (cs.opacity === '0') continue;

      const rect = el.getBoundingClientRect();
      if (rect.width <= 0 || rect.height <= 0) continue;

      /*
       * Also check the dialog wrapper, because we may have hidden
       * the wrapper rather than the calendar element itself.
       */
      const wrapper = resolveDialogWrapper(el);
      if (wrapper && wrapper !== el) {
        const wrapperStyle =
          window.getComputedStyle(wrapper);

        if (wrapperStyle.display === 'none') continue;
        if (wrapperStyle.visibility === 'hidden') continue;
        if (wrapperStyle.opacity === '0') continue;
      }

      return el;
    }

    return null;
  }

  function findNewInlineCalendar(doc) {
    if (!doc || !doc.body) return null;

    const $ = window.jQuery;
    const candidates = doc.querySelectorAll('.ui-datepicker-inline');

    for (let i = 0; i < candidates.length; i++) {
      const wrapper = resolveDialogWrapper(candidates[i]);
      if (wrapper && wrapper.hasAttribute('data-jalali-intercepted')) continue;
      if ($(candidates[i]).is(':visible')) return candidates[i];
    }
    return null;
  }

  function resolveDialogWrapper(calendarEl) {
    if (!calendarEl) return null;
    const $ = window.jQuery;
    const $wrapper = $(calendarEl).closest('.aui-inline-dialog');
    return ($wrapper && $wrapper.length) ? $wrapper[0] : calendarEl;
  }

  function interceptLiveCalendar(calendarEl, doc, source, triggerInputInfo) {
    const wrapperEl = resolveDialogWrapper(calendarEl);
    if (!wrapperEl) return;

    const activeInfo = triggerInputInfo || findActiveDatePickerInput();

    const alreadyIntercepted = wrapperEl.hasAttribute('data-jalali-intercepted');
    const previousAnchor = wrapperEl.getAttribute('data-jalali-anchor');

    if (alreadyIntercepted && previousAnchor === 'visible') return;
    if (alreadyIntercepted && !activeInfo) return;

    wrapperEl.setAttribute('data-jalali-intercepted', '1');
    wrapperEl.setAttribute('data-jalali-anchor', activeInfo ? 'visible' : 'wrapper');
    wrapperEl.removeAttribute('data-jalali-pending-intercept');

    const anchorInput = activeInfo ? activeInfo.input : null;
    const anchorDoc = activeInfo ? activeInfo.doc : doc;

    // Resolve a real, visible anchor element now, and remember it for
    // the session. If no input is available, fall back to the wrapper.
    const resolvedAnchor = anchorInput
      ? findAnchorElement(anchorInput, anchorDoc)
      : resolveDialogWrapper(findAnyVisibleInlineCalendar(doc) || calendarEl);

    if (resolvedAnchor) {
      try {
        const rr = resolvedAnchor.getBoundingClientRect();
        console.info('[jalali] intercepting via', source,
          '| anchor resolved to', resolvedAnchor.tagName + '.' + ((resolvedAnchor.className || '').toString().split(' ')[0] || ''),
          '| rect:', Math.round(rr.left) + ',' + Math.round(rr.top) + ' ' + Math.round(rr.width) + 'x' + Math.round(rr.height),
          alreadyIntercepted ? '(re-anchor upgrade)' : '');
      } catch (e) { /* ignore */ }
    }

    openJalaliPickerCore(
      function resolveAnchor() {
        if (resolvedAnchor && resolvedAnchor.isConnected) return resolvedAnchor;

        // If our stored anchor is gone, re-resolve from scratch
        const fresh = findActiveDatePickerInput();
        if (fresh) return findAnchorElement(fresh.input, fresh.doc);

        const live = findAnyVisibleInlineCalendar(doc);
        return live ? resolveDialogWrapper(live) : null;
      },
      (anchorDoc === document ? null : anchorDoc),
      null,
      function onCommit(unixDate) {
        const d = new Date(unixDate);
        const currentCalendar = findAnyVisibleInlineCalendar(doc) || calendarEl;

        if (!currentCalendar || !currentCalendar.isConnected) {
          console.warn('[jalali] native calendar not available at commit time.');
          return;
        }

        const ok = commitToNativeCalendar(currentCalendar, d.getFullYear(), d.getMonth(), d.getDate());
        if (!ok) console.warn('[jalali] commit to native calendar failed.');
      },
      function getElementsToHide() {
        const live = findAnyVisibleInlineCalendar(doc);
        const w = live ? resolveDialogWrapper(live) : null;
        return w ? [w] : [];
      }
    );
  }

  function scanForLiveCalendars(doc) {
    const live = findNewInlineCalendar(doc);
    if (!live) return;

    const wrapperEl = resolveDialogWrapper(live);
    if (!wrapperEl || wrapperEl.hasAttribute('data-jalali-pending-intercept')) return;

    const immediate = findActiveDatePickerInput();
    if (immediate) {
      interceptLiveCalendar(live, doc, 'live calendar selector scan', immediate);
      return;
    }

    // The active input may still be a few ticks away from being inserted
    // by Confluence's own deferred rendering. Polling for it at 200ms
    // intervals for up to ~1 second before falling back is cheap and
    // eliminates the race where the picker was anchored to the wrapper
    // (bottom of the page) because the input hadn't appeared yet.
    wrapperEl.setAttribute('data-jalali-pending-intercept', '1');

    let delayAttempts = 0;
    const maxDelayAttempts = 5; // 5 × 200ms = 1000ms grace period

    function delayedCheck() {
      delayAttempts++;

      if (!wrapperEl.isConnected) return;
      if (wrapperEl.hasAttribute('data-jalali-intercepted')) return;

      const late = findActiveDatePickerInput();
      if (late) {
        wrapperEl.removeAttribute('data-jalali-pending-intercept');
        interceptLiveCalendar(live, doc, 'live calendar selector scan (delayed)', late);
        return;
      }

      if (delayAttempts < maxDelayAttempts) {
        setTimeout(delayedCheck, 200);
        return;
      }

      // Grace period elapsed without an input ever appearing. This is a
      // legacy flow that doesn't produce an input.aui-date-picker element
      // at all, so the wrapper really is the only anchor available.
      wrapperEl.removeAttribute('data-jalali-pending-intercept');
      interceptLiveCalendar(live, doc, 'live calendar selector scan (legacy fallback)', null);
    }

    setTimeout(delayedCheck, 200);
  }

  function tryHookInputWidget(input, doc) {
    if (input.hasAttribute('data-jalali-burst-active')) return;
    input.setAttribute('data-jalali-burst-active', '1');

    console.info('[jalali] found input.aui-date-picker.active in',
      (doc === document ? 'TOP document' : 'an iframe document'),
      '— starting a short detection burst');

    let attempts = 0;
    const maxAttempts = 75;
    const inputInfo = { input: input, doc: doc };

    function burst() {
      attempts++;

      const liveTop = findNewInlineCalendar(document);
      const liveIframe = window.__jalaliLastIframeDoc ? findNewInlineCalendar(window.__jalaliLastIframeDoc) : null;

      if (liveTop) { interceptLiveCalendar(liveTop, document, 'burst poll', inputInfo); return; }
      if (liveIframe) { interceptLiveCalendar(liveIframe, window.__jalaliLastIframeDoc, 'burst poll', inputInfo); return; }

      const anyTop = findAnyVisibleInlineCalendar(document);
      const anyIframe = window.__jalaliLastIframeDoc ? findAnyVisibleInlineCalendar(window.__jalaliLastIframeDoc) : null;

      if (anyTop) { interceptLiveCalendar(anyTop, document, 'burst poll (re-anchor)', inputInfo); return; }
      if (anyIframe) { interceptLiveCalendar(anyIframe, window.__jalaliLastIframeDoc, 'burst poll (re-anchor)', inputInfo); return; }

      if (attempts < maxAttempts) {
        setTimeout(burst, 80);
      } else {
        console.warn('[jalali] burst poll gave up after', (maxAttempts * 80 / 1000).toFixed(1),
          's without finding a visible .ui-datepicker-inline element in either document.');
      }
    }

    burst();
  }

  function scanForActiveInputs(doc) {
    if (!doc || !doc.body) return;

    const inputs = doc.querySelectorAll('input.aui-date-picker.active:not([data-jalali-burst-active])');
    for (let i = 0; i < inputs.length; i++) tryHookInputWidget(inputs[i], doc);

    const stale = doc.querySelectorAll('input.aui-date-picker[data-jalali-burst-active]:not(.active)');
    for (let j = 0; j < stale.length; j++) stale[j].removeAttribute('data-jalali-burst-active');
  }

  // [FIX 2] Isolated so a failure in one scan can't skip the other.
  function watchForNativeDialogs(doc) {
    try { scanForLiveCalendars(doc); } catch (e) { /* keep going */ }
    try { scanForActiveInputs(doc); } catch (e) { /* keep going */ }
  }

  // -------------------------------------------------------------------
  // Wiring
  // -------------------------------------------------------------------

  waitForLibs(function () {
    window.__jalaliRebindPickers = function (iframeDoc) {
      // [FIX 2] rebind and scan are independent of each other.
      try { rebindPickers(iframeDoc); } catch (e) { /* keep going */ }
      watchForNativeDialogs(iframeDoc);
    };

    if (window.__jalaliRuntime) {
      window.__jalaliRuntime.register(function () { watchForNativeDialogs(document); });
      window.__jalaliRuntime.runNow();
    } else {
      watchForNativeDialogs(document);
    }
  });

})();

تبدیل تاریخ‌های Confluence به شمسی

در حالت نمایش صفحه (view)، تاریخ‌های موجود در صفحه اصلی شناسایی و به تاریخ شمسی تبدیل می‌شوند. هنگام ویرایش صفحه (edit)، تاریخ‌های موجود در ویرایشگر مستقیماً تغییر داده نمی‌شوند؛ این تفاوت اهمیت زیادی دارد. اگر مقدار اصلی تاریخ در محیط ویرایش صفحه، مستقیماً تغییر کند، ممکن است بخش‌هایی از منطق داخلی Confluence که به مقدار آن تاریخ یا ساختار HTML آن وابسته هستند، دچار مشکل شوند. به همین دلیل در محیط ویرایش از یک Overlay استفاده شده است.

Overlay چیست؟

برای تاریخ‌هایی که در محیط ویرایشگر قرار دارند، یک المنت با کلاس jalali-overlay-badge ایجاد می‌شود. این Overlay:

  • مقدار اصلی تاریخ را تغییر نمی‌دهد.
  • تاریخ شمسی را به کاربر نشان می‌دهد.
  • هنگام اسکرول مجدداً جابجا می‌شود.
  • هنگام تغییر اندازه پنجره، موقعیت آن دوباره محاسبه می‌شود.
  • برای به‌روزرسانی موقعیت از requestAnimationFrame استفاده می‌کند.

کاربر تاریخ شمسی را می‌بیند، اما ساختار اصلی مورد استفاده ویرایشگر همچنان دست‌نخورده باقی می‌ماند. این رویکرد برای محیط‌های ویرایش اهمیت زیادی دارد.

چرا تاریخ‌ها در Back-end تغییر نمی‌کنند؟

این شاید مهم‌ترین ویژگی این راهکار باشد: ماژول فقط در Front-end فعالیت می‌کند؛ به همین دلیل:

  • دیتابیس تغییر نمی‌کند
  • مقادیر ذخیره‌شده تاریخ همچنان میلادی هستند
  • API تغییر نمی‌کند
  • Search و Sort تغییر منطقی نمی‌کنند
  • منطق داخلی Confluence درباره ترتیب و مقایسه تاریخ‌ها دست‌نخورده باقی می‌ماند

محدوده عملکرد ماژول

در نسخه فعلی، ماژول بخش‌های زیر را پوشش می‌دهد:

بخشوضعیت
Page Metadataپشتیبانی می‌شود
Commentsپشتیبانی می‌شود
Page Historyپشتیبانی می‌شود
Attachments Macroپشتیبانی می‌شود
Attachment Version Historyپشتیبانی می‌شود
Page Informationپشتیبانی می‌شود
Slash-command Date Pickerپشتیبانی می‌شود

چک‌لیست بعد از ارتقای نسخه Confluence

ماژول تاریخ شمسی در Confluence، مستقیماً با بعضی ساختارهای DOM کار می‌کند؛ مهم‌ترین ریسک این پیاده‌سازی، تغییر رابط کاربری و ساختار داخلی Confluence در نسخه‌های آینده است. هنگام ارتقای نسخه باید علاوه بر تست عمومی Confluence، عملکرد این ماژول نیز بررسی شود. بعد از ارتقای Confluence به نسخه جدید، پیشنهاد می‌شود حداقل موارد زیر بررسی شوند:

  1. نمایش تاریخ در صفحات
  2. تاریخ کامنت ها
  3. Page History
  4. Attachments Macro
  5. Attachment Version History
  6. Page Information
  7. ویرایش صفحه
  8. تاریخ‌های داخل محیط ویرایشگر کانفلوئنس

ارسال نظر

ایمیل شما منتشر نخواهد شد. بخش‌های ضروری با * مشخص شده‌اند.

Are you human? Please solve:Captcha